From c63b409d8688a5f62f503e775ad154c180b4aa2f Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 15:47:15 +0000 Subject: [PATCH 01/34] docs: plan who may reference what, before accessFrom ships Replace accessFrom and allowCrossNamespace with one grant list per referring kind, kept on the object being referenced: GitProvider.gitBranchesFrom, GitBranch.gitTargetsFrom, and KubeProvider.gitTargetsFrom and applierClaimsFrom. None of these names is released yet, so the rename is free until the next release. The plan carries the grant contract and its semantics table, the allowedBranches comparison (K: keep it as the limit on every claim is recommended), the phasing, revocation, and the limit no grant on one provider can close. Also record what the one-writer-per-branch plan delivered and what it still misses. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/design/one-writer-per-branch-plan.md | 170 ++++++++ docs/future/provider-access-rules.md | 473 ++++++++++++++++++++++ 2 files changed, 643 insertions(+) create mode 100644 docs/design/one-writer-per-branch-plan.md create mode 100644 docs/future/provider-access-rules.md diff --git a/docs/design/one-writer-per-branch-plan.md b/docs/design/one-writer-per-branch-plan.md new file mode 100644 index 00000000..4ee46e7f --- /dev/null +++ b/docs/design/one-writer-per-branch-plan.md @@ -0,0 +1,170 @@ +# One writer per branch: where it stands + +> **status**, 2026-10-23. This page was the plan of 2026-10-21. Most of it has shipped, unreleased, +> in #428, #429, #430 and #432. It now records what exists, how that differs from the plan, and what +> the plan asked for that is still missing. The contract itself is in +> [configuration.md](../configuration.md#sharing-connections-across-namespaces) and +> [architecture.md](../architecture.md); the phased API break that carried most of it is +> [gitbranch-and-kubeprovider-plan.md](gitbranch-and-kubeprovider-plan.md). + +## The direction it served + +GitOps Reverser is moving toward an event-sourced state machine per branch, with durable execution +([branch worker event model](branch-worker-event-model.md)). The plan judged every option against +five properties: + +| | Property | +|---|---| +| **E1** | One owner per branch: one loop orders every decision about a branch | +| **E2** | Every input is an event, in order, on that branch's stream | +| **E3** | Nothing waits by polling another owner | +| **E4** | Decisions are recorded as actions (pending writes) | +| **E5** | The journal key is the branch itself: canonical repository plus branch | + +In October 2026 the worker was keyed by `GitProvider` and branch, so two `GitProvider`s for one +repository gave a branch two workers ("siblings"). Siblings broke E1, E2, E3 and E5, and every +defect the #425 self-review found in the sibling flush came from that shape. + +The plan recommended option F (one shared `GitProvider` per repository, referenced across +namespaces, the Flux model) together with five rules. All five properties now hold. + +## What we have now + +### One worker per repository branch (rule 1): done, #428 + +- The worker key is [`BranchKey{Repository, Branch}`](../../internal/git/types.go), with the + repository as `repourl.Normalize` returns it. Same-host SSH and HTTPS spellings are one key. +- `apply_siblings.go` and `apply_sibling_flush.go` are deleted, and with them the shared flush + generation, the copied observations and the cross-worker source-group claims. Source groups are + local to the one worker. +- The worker keeps the owning provider for credential, committer, signing and metric labels. +- The HA plan's `BranchWriteShard` is this key + ([shard identity](../future/ha-gittarget-distribution-plan.md#shard-identity)). + +### One credential per repository branch (rule 2): done, in a stronger form, #429 + +The plan said: elect implicitly from the `GitTarget`s and refuse a branch written through two +`GitProvider`s. #428 built exactly that, with the oldest `GitTarget` winning. That left a squat gap: +any namespace could hold a branch without a working credential. #429 replaced the rule with an +explicit object: + +- **`GitBranch`** is one branch of the repository its `GitProvider` names. It holds the branch for + every `GitTarget` that references it: one worker, one clone, one credential, one signing key. +- **The branch claim**, in [`branch_owner.go`](../../internal/git/branch_owner.go): a `GitBranch` + becomes a candidate only once its credential opens a receive-pack session. The oldest candidate + holds the branch. The holder keeps it through later proof failures. The claim is in memory and is + recomputed after a restart. +- A younger `GitBranch` for a held branch reports `Stalled=True`, reason `BranchConflict`, naming + the holder. Status carries this; the plan's admission refusal was dropped + so that a folder of manifests can be applied in any order. +- `GitBranch.spec.gitProviderRef` is a `LocalObjectReference`, so creating a claim takes the right + to create objects where the credential lives. + +So option B's per-write credential selection never had to exist, which was the purpose of rule 2. + +### The shared credential (rule 3, option F): done, with a different reference, #428 + #429 + +- `GitProvider.spec.accessFrom` admits other namespaces by name or label selector. A provider + always admits its own namespace. +- The cross-namespace reference is **`GitTarget.spec.gitBranchRef`** (namespaced), not + `gitProviderRef` as the plan drew it. One policy governs both hops: a `GitTarget` in another + namespace may use a `GitBranch` while that branch's `GitProvider` admits the target's namespace + ([`gitBranchAdmitted`](../../internal/authz/kubeprovider_admission.go)). This is checked on every + reconcile, so a policy that is tightened later revokes access. +- One Secret in the platform namespace serves every team. Self-service stays: a team can still own + a `GitProvider`, a `GitBranch` and a Secret in its own namespace. +- Committer and signing come with the credential, so a branch is signed by one key. + +### The decoupled receiver (rule 5, gitops-api #30): done, #432 + +The plan's text was one endpoint, routed by repository and branch, with a `GitProvider`'s own +receiver kept for self-service. What shipped is simpler: + +- `GitReceiver` is a namespaced kind. `GitProvider.spec.receiver` is gone. +- A verified delivery wakes the worker of **every** `GitProvider`, in any namespace, whose + `spec.url` names the pushed repository, for that branch only. It has no reference and no + `accessFrom`. +- Waking across namespaces grants nothing: the worker reads the remote with its own credential and + holds at most one pending push notice. The `202` has no body. +- For self-service, a team creates a `GitReceiver` in its own namespace. +- `GitBranch.status.lastPushNoticeAt` shows whether deliveries arrive. + +### Decided differently from the plan + +| Plan | What we did | Why | +|---|---|---| +| Report `status.branches` to every routed `GitProvider` | Removed; `GitBranch` status replaces it (`status.remote`, `gitTargetCount`) | Listing `GitBranch` objects answers the question | +| Move metric labels to the repository-branch key (breaking) | Kept `{provider_namespace, provider_name, branch}`, taken from the holding `GitBranch`'s provider | gitops-api dashboards; the label set still names one branch worker | +| Refuse a second `GitProvider` for one repository in one namespace, before anything else | Not built; no longer needed | The claim is per `GitBranch`, so extra providers on one repository are harmless, and useful (see "Still missing", item 1) | +| Ship additively, then the key change a release later | One release, hard cuts, no migration | No users yet | +| C2 (`ClusterGitProvider` or `GitCredential`) only if one object per repository is too many | Not built | Nobody has hit the limit | + +## Still missing against the original + +Ordered by how much they matter now. + +1. **Branch-scoped access ("who may write what").** The plan's rule 3 and decision 3 wanted + `accessFrom` to say which namespaces may write *which branches*, optionally *which paths*. What + shipped is namespace-only and provider-wide: every namespace a `GitProvider` admits may use + every `GitBranch` of that provider, and write any folder that the overlap check does not already + hold. Branch isolation is possible today without code: create one `GitProvider` per team in the + platform namespace. Every one of them references the same Secret, with + `allowedBranches: [teams/team-a/*]` and `accessFrom: {names: [team-a]}`. Path isolation is not + possible at all. [gitbranch-and-kubeprovider-plan.md](gitbranch-and-kubeprovider-plan.md) + parks this as "Folder and branch grants per namespace", and + [who may use a provider](../future/provider-access-rules.md) is the proposal toward + it, and also covers item 2 below. + +2. **The claim is weaker than "one credential per branch, enforced".** Three gaps: + - **The push-session proof proves less on some hosts.** On Gitea and Forgejo, a credential that + can only *read* opens a receive-pack session (the AGit flow). On a host that offers push + sessions anonymously, it proves only that the repository is reachable. + - **Who claims first holds the branch.** Anyone who can create a `GitProvider` and a `GitBranch` + with a credential that passes the proof can claim a branch that no older `GitBranch` holds. + That includes a tenant in its own namespace with a copy of the token. The plan's rule 2 tied + the branch to the shared provider. The claim instead ties it to the oldest proven object on + that repository, wherever it lives. + - **An older `GitBranch` takes the branch from a live holder** once it proves itself, and after + a restart a holder whose credential broke loses its claim. A persisted claim belongs to the + HA work. + + Only a per-repository authority closes these: option C's object per repository URL, or a + cluster-level rule naming the one provider through which a repository's branches may be + claimed. Nothing of that exists. + +3. **Alias detection (rule 4, option E).** Not built. `repourl.Normalize` still cannot unify Gitea's + SSH on `:2222` beside HTTPS, a separate SSH hostname, a vanity domain, or a renamed repository. + Two such URLs get two keys, two claims and two workers on one branch: the sibling problem, + undetected. The HA plan's HA-0 question about aliases is still open. The design is unchanged: + a warning when a commit we pushed under one key shows up under the other, in both directions. + +4. **The #425 self-review items that are not about siblings.** R2 and R3 went with the siblings, + and R5 (the `UPGRADING.md` entries) is done. Reading the code, R1 and R4 still look open. + - **R1:** an applier that joins a flush already in progress starts `flushEdits` at the moment it + joins, not at the flush mark + ([`flushed`](../../internal/git/applier_claim.go)). + - **R4:** `forgetSettledSnapshots` still runs only on `GitTarget` teardown, so a replaced worker + can read the old worker's settled-snapshot record. + + Both affect a user with a single `GitProvider`. The local review is + `docs/reviews/20261021-review-425.md`. + +5. **A rate bound on deliveries.** Folding bounds the queue but not the rate, so a token holder can + keep a worker reading continuously. This is accepted for now; a per-worker cooldown would bound + it. + +6. **Per-tenant identity on the Git host.** This is by design. A shared provider + pushes as the platform's account, so the host's audit log names the platform instead of the + team, and one team's credential cannot be revoked separately. A team that needs either keeps its + own provider. The plan named this as Flux's argument for an identity per object and accepted the + trade. + +## Stale text elsewhere + +- [HA plan, shard identity](../future/ha-gittarget-distribution-plan.md#shard-identity) says "one + `GitProvider` owns a repository branch: a `GitTarget` writing it through another one is + refused". Since #429 it is the holding `GitBranch`, and a younger `GitBranch` reports + `BranchConflict`. +- [gitbranch-and-kubeprovider-plan.md](gitbranch-and-kubeprovider-plan.md) still draws + `FluxTrigger`/`ArgoCDTrigger` and `applyTriggerRef` (now `FluxClaim`/`ArgoCDClaim` and + `applierClaimRef`, #438). Its status line says phase 3 is still to come, but it shipped in #432. diff --git a/docs/future/provider-access-rules.md b/docs/future/provider-access-rules.md new file mode 100644 index 00000000..4b436b56 --- /dev/null +++ b/docs/future/provider-access-rules.md @@ -0,0 +1,473 @@ +# Who may reference what: `gitBranchesFrom`, `gitTargetsFrom`, `applierClaimsFrom` + +> **future work**, 2026-10-23. Not built. **The user-facing part should be settled before the next +> release**, because until then renaming costs nothing (see [why now](#why-now)). This page +> replaces three drafts: `docs/reviews/20261023-gitbranch-claims-from-other-namespaces.md` (the +> proposal), `docs/reviews/20261023-gitbranch-claims-review.md` (its review), and +> `docs/future/gitprovider-branch-rules.md`. It follows up items 1 and 2 of "Still missing" in the +> [one-writer-per-branch status](../design/one-writer-per-branch-plan.md). A review of 2026-10-23 +> corrected the defaults, the own-namespace rule, phase 1's watches and the regrant rule; this +> version includes those corrections. + +## The goal + +A platform team keeps one repository credential and one cluster connection in its own namespace. +It decides, per activity, which other namespaces may use them, and how far: + +- **On the repository:** `main` is written by a few named namespaces. Each team claims and writes + its own `teams//*` branches. Any tenant may claim a `feature/*` branch and share it with + other tenants. +- **On the cluster:** tenants mirror their own namespace, and control the appliers that live in + it. One audit namespace mirrors every namespace. The platform namespace may hold the + `Kustomization`s in `flux-system`. + +A team creates and deletes its own `GitBranch` in its own namespace when a grant allows it. No +credential leaves the platform namespace. + +**Where an applier lives is not where it deploys.** `applierClaimsFrom` decides which namespace's +`Kustomization` or `Application` a claim may hold. What that applier then deploys, and where, is +the applier's own configuration and credentials: a Flux `Kustomization`'s `serviceAccountName` +and `targetNamespace`, or an Argo CD `AppProject`'s destinations. That is the workload boundary, +and these grants do not replace it. + +## Why now + +| Field | In 0.53.0 | In the next release as it stands | +|---|---|---| +| `GitProvider.spec.allowedBranches` | Yes | Yes | +| `GitProvider.spec.accessFrom` | No | New (#428) | +| `KubeProvider.spec.accessFrom`, `.allowCrossNamespace` | On `ClusterProvider`, which the next release removes | New, on a new kind (#430) | + +`allowedBranches` is the only released name among them, and `GitProvider` breaks in that release +anyway (`spec.receiver` is gone, and `GitBranch` takes over the branch). If `accessFrom` ships, a +later rename breaks released manifests. If the rename lands first, those names are never released. + +## The rule: consent lives on the object referenced + +**Every object lists who may reference it. The field is named after the referring kind, or after a +family of kinds the API already defines.** That is where Flux keeps `accessFrom`, where a Gateway +listener keeps `allowedRoutes`, and where `KubeProvider` keeps it today. Each referrer does one +thing with what it references, so the field name also says which act is granted: + +| Object | Referenced by | Field | Act it allows | +|---|---|---|---| +| `GitProvider` | `GitBranch` (`gitProviderRef`) | `gitBranchesFrom` | claim a branch with this credential | +| `GitBranch` | `GitTarget` (`gitBranchRef`) | `gitTargetsFrom` | write a folder into this branch | +| `KubeProvider` | `GitTarget` (`kubeProviderRef`) | `gitTargetsFrom` | mirror from this cluster | +| `KubeProvider` | applier claims: `FluxClaim`, `ArgoCDClaim` (`kubeProviderRef`) | `applierClaimsFrom` | hold an applier in this cluster | + +There is no `ApplierClaim` kind. "Applier claim" is the family the API already names: a +`GitTarget` points at one with `applierClaimRef`, and `definitions.md` defines the term. + +A `GitTarget` never names a `GitProvider`, so a `GitProvider` has no `gitTargetsFrom`: deciding there +would mean reaching through the branch. `GitReceiver` is referenced by nothing and has no list. + +The names follow [definitions.md](../definitions.md) rule 2: a reference names the kind it points +at. They also avoid a bare "claim", which `definitions.md` keeps apart as "branch claim" and +"applier claim". + +One decision covers each act, so two policies never cover the same act. This replaces the phase 1 +rule that a `GitBranch` has no access list and its provider's `accessFrom` "governs both hops". Only +the shape changed: the provider decides who may claim, and the branch decides who may write. + +## The grant contract + +Each field is a list of grants. A grant names some namespaces, and may say how far they reach. + +1. **The object's own namespace is admitted without a grant, with the default reach.** Today's + rules for the own namespace stay as they are: a local `GitTarget` or applier claim reaches only + its own namespace, and a local `GitBranch` stays within `allowedBranches`. +2. **A grant only ever adds.** A namespace's reach is its default reach plus the reach of every + grant that matches it, and this also holds for the own namespace. The order of the list never + matters. +3. **`names` OR `selector`.** A grant matches a namespace whose name is in `names`, or whose labels + match `selector`. The two are alternatives, and a namespace needs only one of them. This is + `NamespaceMatcher` as it is today. +4. **One owner writes each list whole** (`+listType=atomic`), so it needs no merge keys. + +### Semantics, field by field + +| Field | Omitted | `[]` | A value | `"*"` | +|---|---|---|---|---| +| A grant list (`gitTargetsFrom`, ...) | Own namespace only | Same as omitted | Grants as below | Not a value of a list | +| `names` | Not matched by name | Refused (CEL) | Exact namespace names | Refused: use `selector: {}` | +| `selector` | Not matched by label | (not a list) | Label selector on control-cluster Namespaces | `selector: {}` matches every namespace | +| `names` and `selector` both omitted | Refused (CEL): a grant must match something | | | | +| `gitBranchesFrom[].branches` | Every branch within `allowedBranches` | Refused (CEL) | `filepath.Match` patterns; `{namespace}` is the `GitBranch`'s namespace | One path segment: `*` matches `main`, never `teams/a` | +| `KubeProvider` `gitTargetsFrom[].sourceNamespaces` | Own namespace only | Refused (CEL) | Exact source-cluster names, added to the own namespace | Every namespace; also allows `rules[].sourceNamespace: "*"` | +| `KubeProvider` `applierClaimsFrom[].applierNamespaces` | Own namespace only | Refused (CEL) | Exact names, added to the own namespace | Every namespace | +| `GitBranch.gitTargetsFrom[]` | no reach field | | | | + +Two defaults differ on purpose. A grant that omits `branches` gets every branch within +`allowedBranches`, because `allowedBranches` is already the limit. A grant that omits +`sourceNamespaces` or `applierNamespaces` gets the own namespace only, because nothing else limits +those. The configuration reference must show both side by side. + +`sourceNamespaces` and `applierNamespaces` take exact names or `"*"`, never globs: a source +namespace in `WatchRule` is already a name or `"*"`. "Own namespace" in a remote cluster means the +namespace of the same name. `{namespace}` exists only in `branches`. A namespace name cannot contain +glob characters, and CEL refuses any other `{...}`. + +## `GitProvider`: who may claim a branch + +```yaml +kind: GitProvider +metadata: {name: platform-repo, namespace: platform} +spec: + url: https://git.example.com/platform/config.git + secretRef: {name: platform-repo-auth} + allowedBranches: ["main", "release/*", "teams/*/*", "feature/*"] # every claim stays inside + gitBranchesFrom: # phase 2: who else may claim, and which branches + - selector: {matchLabels: {tenant: "true"}} + branches: ["teams/{namespace}/*", "feature/*"] +``` + +- **A `GitBranch` in the provider's namespace** may claim any branch within `allowedBranches`. +- **A `GitBranch` in a namespace a grant matches** may claim a branch within both `allowedBranches` + and the grant's `branches`. +- **`accessFrom` is removed.** Who may write moves to the `GitBranch`. +- **`GitBranch.spec.gitProviderRef` becomes a `NamespacedObjectReference`** (phase 2). An omitted + namespace means the `GitBranch`'s own. CEL's `self == oldSelf` treats an omitted namespace and an + explicit own namespace as different values. Default the field at admission, or compare the + resolved namespace in the immutability rule. + +### Can `allowedBranches` go? What people need to configure + +Removing `allowedBranches` was proposed on the assumption that whoever may create a `GitBranch` +beside a provider may also edit the provider. RBAC can separate the two: an admin can put a provider +and its Secret in a team's namespace and let the team create `GitBranch`es there, without letting +the team edit the provider. There, `allowedBranches` is an enforced limit. It is more than a guard +against typos. + +Three ways to handle it, checked against what people configure: + +| Need | Today | **K**: keep `allowedBranches` as the limit for every claim | **R**: remove it; own namespace needs a grant | **U**: remove it; own namespace unlimited | +|---|---|---|---|---| +| A team's own provider writes only `main` | `allowedBranches: [main]` | Same | `gitBranchesFrom: [{names: [], branches: [main]}]` | Not expressible | +| An admin provides the credential in a team's namespace; the team creates `GitBranch`es but must stay on `teams/a/*` | `allowedBranches` | Same | A grant naming the own namespace | **Lost**: the team may claim any branch, `main` included | +| A credential that must never claim `main`, whoever creates the `GitBranch` | `allowedBranches` without `main` | Same | Grant no branch that matches `main` | Lost for the own namespace | +| Delegate `teams/{namespace}/*` to tenants (phase 2) | Not possible | Grant with `branches`, inside the limit | Grant with `branches` | Grant with `branches` | +| Give one named namespace one fixed branch (phase 2) | Not possible | Grant with `names` and `branches` | Same | Same | +| The platform's own namespace claims `main` and `release/*` | `allowedBranches` | Same | A grant naming the own namespace | Implicit | +| Who may write into one branch | Provider-wide `accessFrom` | `GitBranch.gitTargetsFrom` in every option | | | +| Fields to read for one delegated branch | n/a | Two: the limit, then the grant | One | One | +| Same rule as the other lists (own namespace admitted, grants add) | n/a | Yes; `allowedBranches` is the own namespace's default reach and the limit on every grant | **No**: the one list where the own namespace is not admitted | Yes, with unlimited reach, unlike `KubeProvider` | +| Release note | n/a | None | `allowedBranches` removed | `allowedBranches` removed | + +**U loses two needs and is out.** **R** keeps everything configurable, but in exchange: + +- every provider, including the single-team case, has to name its own namespace in a manifest, + which a kustomize overlay that changes the namespace does not rewrite; +- `GitProvider` becomes the one list without an implicit own namespace; +- `gitBranchesFrom` has to ship in phase 1 for the own namespace alone. + +**K** keeps everything configurable with no change for anyone using it today. Its cost is reading +two fields for a delegated branch, and the two have separate jobs: what this credential may touch, +and who else may touch which part of it. + +**Recommendation: K.** `allowedBranches` stays as it is, required, as the limit on every claim +through the provider. This answers the open question of whether to keep it as an "optional +ceiling", but as a limit on everyone rather than the own namespace alone. Choose R only if a single +access field per provider matters more than naming the own namespace in manifests. + +## `GitBranch`: who may write into it + +```yaml +kind: GitBranch +metadata: {name: main, namespace: platform} +spec: + gitProviderRef: {name: platform-repo} + branch: main + gitTargetsFrom: + - names: [release-team] +--- +kind: GitBranch +metadata: {name: checkout-v2, namespace: team-a} +spec: + gitProviderRef: {name: platform-repo, namespace: platform} # phase 2 + branch: feature/checkout-v2 + gitTargetsFrom: + - names: [team-b] # team-a shares its feature branch with team-b +``` + +- **A `GitTarget` in a matched namespace may write through this `GitBranch`.** A `GitTarget` in + the `GitBranch`'s own namespace always may. + [`gitBranchAdmitted`](../../internal/authz/kubeprovider_admission.go) already admits that case, + and reads the list on the `GitBranch` it has already fetched instead of reading the provider. +- **`gitTargetsFrom` is mutable.** `gitProviderRef`, `branch` and `parentBranch` stay immutable, + because together they are the branch's identity. The type comment "every field is immutable" + changes with it. +- **Whoever owns the `GitBranch` decides who shares it.** In phase 1 a `GitBranch` lives beside its + provider, so the provider's namespace decides; with an RBAC split, that is whoever may edit + `GitBranch`es there. In phase 2 a team that claimed a branch decides who else may write into it + with the platform's credential. That reaches nothing new: the writes stay inside the branch the + platform delegated, and the team could write anything there itself. The platform can no longer + keep team branches private, though. If it needs to, a `gitBranchesFrom` grant can carry a ceiling + on how far the claimant may share. Leave that out until someone asks. + +With this, the per-team-provider workaround (see [what works today](#what-works-today-before-any-of-this)) +is no longer needed. One provider, with a `GitBranch` per branch, isolates teams. + +## `KubeProvider`: who may mirror, and who may hold an applier + +```yaml +kind: KubeProvider +metadata: {name: workspace-a, namespace: platform} +spec: + secretRef: {name: workspace-a-kubeconfig} + gitTargetsFrom: # who may mirror from this cluster + - selector: {matchLabels: {tenant: "true"}} + - names: [platform-audit] + sourceNamespaces: ["*"] + applierClaimsFrom: # who may hold an applier in this cluster + - selector: {matchLabels: {tenant: "true"}} + - names: [platform] # the own namespace, extended to flux-system + applierNamespaces: [flux-system] +``` + +- **Tenants** mirror their own namespace and hold appliers that live in it. That is the default + reach of every grant. +- **`platform-audit`** mirrors every namespace. +- **`platform`** is the provider's own namespace. It is admitted without a grant, but only to its own + namespace. The grant naming it adds `flux-system`. +- **`allowCrossNamespace` is removed.** Its two booleans granted cross-namespace reach to every + admitted namespace at once, own namespace included. The API docs had to warn that "the trust is + in every admitted namespace". Reach per grant ends that warning. To keep today's + `sources: true` for everyone, add `sourceNamespaces: ["*"]` to every grant, plus a grant naming + the own namespace. +- **Splitting `accessFrom` changes one thing.** A namespace admitted to mirror is no longer admitted + to hold an applier. Today one matcher lets it do both. + +The `inCluster` arm's consent (`--in-cluster-kube-providers`) is unchanged. Reach granted on an +`inCluster` provider still bypasses live namespace RBAC through the operator's identity, so the +configuration docs keep that warning for `sourceNamespaces` and `applierNamespaces`. + +## The rename, surface by surface + +Measured with `git grep` on 2026-10-23, outside `docs/reviews`. `accessFrom` is in about 75 files, +`allowCrossNamespace` in about 50. With K, `allowedBranches` keeps its name and its meaning for the +own namespace. Most hits are docs, samples, chart and e2e fixtures. + +| Surface | Change | +|---|---| +| `api/v1alpha3` | `GitProvider`: drop `AccessFrom`; add `GitBranchesFrom []GitBranchGrant` in phase 2. `GitBranch`: add `GitTargetsFrom []NamespaceGrant`. `KubeProvider`: drop `AccessFrom` and `AllowCrossNamespace`; add `GitTargetsFrom []SourceGrant` and `ApplierClaimsFrom []ApplierGrant`. Each grant embeds `NamespaceMatcher` inline, with the CEL rules from the semantics table. Doc comments on `GitTarget`, `GitBranch`, `FluxClaim` and `ArgoCDClaim` that say `accessFrom` | +| Accessors | `GitBranch.AdmitsGitTarget(ns, labels)`; `KubeProvider.AdmitsGitTarget` and `AdmitsApplierClaim`, each returning the reach that applies, replacing `AllowsCrossNamespaceSources` and `AllowsCrossNamespaceAppliers`; `GitProvider.AdmitsGitBranch(ns, labels, branch)` in phase 2. `GitProvider.AdmitsNamespace` goes | +| `internal/authz` | `gitBranchAdmitted` reads the `GitBranch` instead of its provider. **`ApplierAdmitted` loses its shortcut**: it returns early when the claim and the target share a provider and a namespace, because one matcher used to grant both acts. It must check `applierClaimsFrom` and `applierNamespaces` on its own. Also `namespaceAdmitted`, `ApplierClaimAdmitted` and `source_namespace.go`. Messages that print `(spec.accessFrom)` or `allowCrossNamespace` name the new field. The reasons `NamespaceNotAuthorized`, `SourceNamespaceNotAllowed` and `ApplierNamespaceNotAllowed` stay | +| Controllers and watches | **`WatchRule` gets a Namespace watch** (phase 1, see implementation notes). `gitDestinationToGitTargets`' comment about the provider's `accessFrom` | +| CRDs and chart | Regenerate; the chart's `local` provider and its values; `config/kubeprovider-local.yaml`; samples | +| `hack/crdfields` | The field inventory | +| Docs | `configuration.md` (sharing section, `GitProvider`, `GitBranch`, `KubeProvider`), `security-model.md`, `rbac.md`, `quickstart.md`, `components.md`, `architecture.md`, the bi-directional and attribution guides, `docs/spec` | +| `definitions.md` | Remove `spec.accessFrom` from the fields mirrored from Flux word for word, and rewrite the line saying a provider's "`spec.accessFrom` admits the others". The `NamespaceMatcher` comment ("One field uses it") is already stale | +| `gitbranch-and-kubeprovider-plan.md` | Its "no `accessFrom` of its own" and "one policy governs both hops" for `GitBranch` are superseded; point there from here | +| `UPGRADING.md` | Nothing with K: `accessFrom` and `allowCrossNamespace` were never released on these kinds, and `allowedBranches` is unchanged | +| e2e and fixtures | Every `accessFrom` and `allowCrossNamespace` in `test/e2e` and `test/fixtures`; `accessFrom` on a shared `GitProvider` moves to `gitTargetsFrom` on its `GitBranch`es | + +## Phasing + +1. **Before the next release: the user-facing shape.** All of it changes admission and its + wiring only. + - `GitBranch.gitTargetsFrom`, replacing `GitProvider.accessFrom`. + - `KubeProvider` `gitTargetsFrom` and `applierClaimsFrom`, with per-grant reach. This replaces one + matcher and two booleans in authz, and removes the `ApplierAdmitted` shortcut. + - The `WatchRule` Namespace watch, so a reach that shrinks takes effect at once. + - `GitBranch` stays beside its provider, so `gitBranchesFrom` is not added yet. A field the + controller ignores would be a promise the API cannot keep. +2. **Then: teams claim their own branches.** Add `gitBranchesFrom`, make `gitProviderRef` + namespaced, and handle claim revocation. All of it is additive. +3. **Separately: one authority per repository** (see [the limit](#the-limit-another-provider-on-the-same-repository)). + +Phase 1 leaves the worker, the claim and the Git write path as they are, so lint and unit tests +plus CI e2e are enough. Phase 2 changes the worker, which is the high-risk case in `AGENTS.md`: run +local e2e before pushing. + +## What works today, before any of this + +Nothing refuses two `GitProvider`s for one URL in one namespace, so a platform can already isolate +teams on one repository with one Secret. It creates one provider per team, and creates the +`GitBranch`es itself: + +```yaml +kind: GitProvider +metadata: {name: repo-team-a, namespace: platform} +spec: + url: https://git.example.com/platform/config.git + secretRef: {name: platform-repo-auth} # the one Secret every team's provider shares + allowedBranches: ["teams/team-a/*"] + accessFrom: {names: [team-a]} +``` + +The example in [configuration.md](../configuration.md#sharing-connections-across-namespaces) has +one provider with `accessFrom: {names: [team-a, team-b]}`, which lets each team write the other's +branch. Until phase 1 lands, the page should say so, or show the per-team form. + +## Revocation + +A grant can shrink in three ways: an edited list, a removed Namespace label, or a deleted object. +Shrinking can end admission altogether, or only narrow a reach while the object stays admitted: +`platform-audit` keeps mirroring its own namespace after losing `"*"`. Both must take effect at +once. The product already has two revocation paths, and both drain: + +- **Deleting a `GitBranch`.** + [`finalize`](../../internal/controller/gitbranch_controller.go) releases the claim first. It then + retires the worker, which closes intake, pushes or refuses each pending write, and settles every + `CommitRequest`. Last, it wakes the next holder. +- **A `GitTarget`, `WatchRule` or applier claim that loses admission or reach.** It is refused on + its next reconcile. A write already decided is still committed and pushed + ([`write_gate.go`](../../internal/git/write_gate.go) explains why: a write held back would come + out of order later). + +Phase 1 changes only what is admitted, so the second path covers it, given the watch it needs. In +phase 2, a `GitBranch` that loses `gitBranchesFrom` takes the `finalize` path but keeps its +finalizer, and reports `Stalled=True`, reason `NamespaceNotAuthorized`. Writes can therefore land +after the API server accepts a policy edit. Git cannot take back a push that is already sent, and +the docs should promise no more than that. + +Two alternatives were rejected: + +- **Refusing unpublished work at revocation.** It needs a worker shutdown with no flush, which + nothing else uses. +- **Keeping a revoked claim reserved until released.** It needs a persisted claim, which belongs + to the HA work. + +### Authorization and priority are separate + +- **Authorization: a `GitBranch` is a candidate only while it is admitted.** An unadmitted one never + opens a push session with the shared credential. Once a grant admits it, it proves itself and + becomes a candidate like any other. +- **Priority: the oldest candidate by `creationTimestamp` holds the branch**, as it does today for a + `GitBranch` whose proof succeeds late. Priority by admission time would need the time of + admission to survive a restart, and the claim is not persisted. Priority by creation time is the + only order a restart recomputes the same way. +- **The consequence:** granting a namespace that holds an old `GitBranch` for a branch someone else + holds hands the branch to the old one once it proves itself. The live holder then reports + `BranchConflict`. The configuration docs say so: remove stale `GitBranch`es before granting. + +For phase 2, the implementation must get these right: + +- **Revocation calls `Claims().Release(uid)`, not `Observe(..., false)`.** `Observe` keeps a + holder's candidacy through a failed proof. A revoked candidate that does not hold the branch must + stop being a candidate too. +- **A transient read error is not a revocation.** Keep the old answer, retry, and hand nothing off + on an error alone. +- **A successor starts only after the old worker retires.** `EnsureWorker` already refuses while + another `GitBranch`'s worker is live. Check the successor's admission again: one edit can revoke + several candidates. +- **Pending work never moves to a new holder or credential.** + +## The limit: another provider on the same repository + +The claim key is the repository and the branch; the provider is not part of it. Grants on +`platform-repo` decide who may claim **through that provider**. They do not stop a `GitBranch` that +claims `main` through a different `GitProvider` for the same URL. That provider can be in a tenant's +namespace with a copied token. On Gitea or Forgejo, a token that can only read is enough, because it +still opens a receive-pack session. If that `GitBranch` is older, or first, it holds the branch. The +platform's `GitBranch`, and the `gitTargetsFrom` on it, then report `BranchConflict` and govern +nothing. + +Per-branch grants make that gap more visible, because "`main` only for the release team" reads as +absolute. State the limit wherever the grants are documented: + +> Grants decide who may claim a branch through this provider, and who may write through this +> `GitBranch`. They cannot stop a claim on the same repository branch through another provider +> whose credential opens a push session. + +Today a platform protects a branch by creating its `GitBranch` first. The fix is a single authority +per repository. Either of these would do: + +- **A cluster-scoped object per repository URL.** This is option C of the original one-writer + plan: `ClusterGitProvider`, or `GitCredential`. +- **A cluster-level rule that names one governing provider.** For a repository it names, branches + may only be claimed through that `GitProvider`. + +Neither exists. + +## Alternatives considered + +| Alternative | Why not chosen | +|---|---| +| Keep `accessFrom` (Flux's spelling) | It does not say which act it grants, and it cannot grant mirroring without applying, or one branch without all | +| Remove `allowedBranches` (options R and U above) | U loses two needs; R keeps them at the cost of naming the own namespace in every provider and an exception to the own-namespace rule | +| Own namespace admitted with unlimited reach (an earlier version of this page) | Widens today's `KubeProvider` behavior, where the own namespace also needs `allowCrossNamespace` to leave itself, and made a grant naming the own namespace meaningless | +| `gitTargetsFrom` on `GitProvider`, with `branches` per grant | A `GitTarget` does not reference the provider, so the decision reached through the branch; it needed branch patterns and `{namespace}` on the writer side too, and per-team providers stayed the only way to isolate before phase 2 | +| Rules keyed by branch on `GitProvider`, first match wins | It needs list order to keep `main` narrow beside a `*` rule. That order is invisible in a merged manifest, and the shape does not carry over to `KubeProvider` | +| Flat `branchClaimsFrom` beside `allowedBranches` and `accessFrom` | Three lists that interact. The effective rule for one branch is their intersection, which nobody can read at a glance | +| Keep `allowCrossNamespace` beside the split lists | Reach stays global across every admitted namespace, which is the warning its own docs carry | +| A separate grant kind in the provider's namespace (`GitBranchGrant`, after Gateway API's `ReferenceGrant`) | Worth it only if someone needs separate RBAC for grants and connections; it adds a kind, a watch and grant-deletion handling | +| Tenant-owned providers with their own credentials | Right when repositories separate tenants. On one shared repository, a copied repository-wide token can claim any branch (see the limit) | + +## Implementation notes + +- **Watches, phase 1.** + - **Already wired:** a `GitBranch` event reaches the `GitTarget`s in every namespace that + reference it ([`gitTargetsOn`](../../internal/controller/gittarget_controller.go)). A Namespace + label change reaches that namespace's `GitTarget`s and applier claims (`namespaceToClaims`). A + `KubeProvider` spec change reaches the `WatchRule`s (`kubeProviderToWatchRules`). + - **Missing:** the `WatchRule` controller does not watch Namespaces. Its `GitTarget` watch fires + only on a spec change or a readiness move. A label change that narrows `sourceNamespaces` + while the `GitTarget` stays admitted therefore re-evaluates no `WatchRule`. Add a Namespace + watch that maps to the `WatchRule`s in that namespace. Today this gap is harmless, because + reach is provider-wide and a label change can only change admission. +- **Watches, phase 2.** `gitTargetsOn` and `gitProviderToGitBranches` find a provider's + `GitBranch`es in the provider's namespace only. Index `GitBranch`es by resolved provider key, and + use the index in both. The `GitBranch` controller does not watch Namespaces either: add a mapper + to the `GitBranch`es in a namespace whose labels change. From there, revocation reaches their + `GitTarget`s through `gitTargetsOn`. +- **Credential resolution (phase 2).** + [`WorkerRequest.provider()`](../../internal/git/worker_manager.go) builds the provider key from + `GitBranch.Namespace`. The resolved provider namespace has to be carried through + `gitDestination`, `WorkerRequest` and worker creation. Test two cases: no provider in the tenant + namespace, and a same-name provider there holding a different credential. Both must use the + platform provider and its Secret. +- **One admission function per act, in `internal/authz`.** Each returns the decision and the reach + that applies. Call the `GitBranch` one (phase 2) before proof and candidacy. Keep distinct reasons + for each case: reference missing, namespace not admitted, branch not allowed, proof failed, branch + conflict. +- **Status.** `kubectl get gitbranch` should show the provider's namespace once it can differ. + +## Evidence before it is ready + +- **Admission tables, per act, row by row from the semantics table.** + - Own, matched and third namespaces, for each of the four lists. + - Omitted and `[]` for every field, with CEL refusing the `[]` it should refuse. + - `names` only, `selector` only, both (OR), neither (refused). + - `{namespace}` expansion, and pattern depth (`teams/*` against `teams/a/b`). + - `sourceNamespaces: ["*"]` against `sourceNamespace: "*"`; exact names against `"*"`. + - The own namespace alone (own reach only) and with a grant naming it (extended). + - A `GitBranch` claim inside `allowedBranches` but outside a grant's `branches`, and the reverse. +- **Mirroring allowed, applying denied, on one provider.** A `GitTarget` and its applier claim in + one namespace, through one `KubeProvider`, where `gitTargetsFrom` matches and + `applierClaimsFrom` does not. The apply must be refused; today's `ApplierAdmitted` shortcut would + allow it. +- **Reach reduced while admission stays.** A namespace matched by two grants loses the label of + the one granting `"*"`. Its `WatchRule`s stop watching other namespaces through the watch, with + the `GitTarget` still admitted. The same for `applierNamespaces`. +- **Sharing edits.** Adding and removing a namespace from `GitBranch.gitTargetsFrom` wakes and + refuses that namespace's `GitTarget`s through the watch. +- **Authorization before candidacy (phase 2).** An unadmitted `GitBranch` never becomes a candidate + and never opens a push session while it is unadmitted. Once admitted, it becomes a candidate. + An older admitted one takes the branch by `creationTimestamp`, also after a restart. +- **Credential resolution (phase 2).** The two cases in the implementation notes. Receiver routing + still reaches the right worker. +- **Revocation, driven by watch events.** A direct `Reconcile` call proves no event wiring. Cover: + - grant edit, grant removal and label removal, with several candidates revoked by one edit; + - queued events, open windows, decided writes, push retry, a push in flight, and waiting or + attached `CommitRequest`s; + - the successor waiting for retirement, and receiving none of the old work. +- **End to end.** A team claims and writes `teams//live`, and shares a feature branch with a + second team. Claims on `main` and on another team's branch fail. A tenant mirrors its own + namespace and is refused another. The audit namespace mirrors `*`. + +## Open questions + +1. **`allowedBranches`: K or R.** The table above recommends K. +2. **A sharing ceiling (phase 2).** May a team that claimed a branch share it with anyone, or does + its `gitBranchesFrom` grant bound whom it may admit? +3. **Recreated namespaces.** A `names` grant also admits a namespace that is deleted and recreated + with the same name. Acceptable, or bind to the UID? +4. **Branch-count limits.** A `teams/{namespace}/*` grant lets a team create any number of + `GitBranch`es, each a worker with a clone. Exact names cap the count. Is a per-grant limit needed? +5. **`parentBranch` reads outside the grants.** A team's branch can be created from `main` although + the team may not write `main`. That looks intended, because it is how a feature branch starts. + Confirm it, and document it. +6. **Path isolation.** Grants are per branch. Per-folder grants inside one branch stay out of scope; + the overlap check only stops two targets claiming the same paths. From ab31df761db9ac22fa1aa63b527a1babefec9b25 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 15:53:09 +0000 Subject: [PATCH 02/34] docs: make every grant explicit, and map access between all objects Fold in the second review of the access plan: - Nobody gets access automatically, the referenced object's own namespace included; an omitted or empty grant list grants nothing. - Scopes are required and exact: branches on gitBranchesFrom, sourceNamespaces and applierNamespaces on KubeProvider. {namespace} is the referring object's namespace everywhere. - allowedBranches goes; the claim policy lives in gitBranchesFrom. - Claiming a branch delegates it, sharing included: the GitBranch's gitTargetsFrom decides who writes, with no provider-level writer policy beside it. - Cluster-scoped mirroring needs clusterScoped on the grant. - One PR, no phases. Refusal messages name the grant that applies. Add the whole picture: every reference in the API, what it lends, and which grant covers it. References that stay in their namespace and lend nothing need none. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/future/provider-access-rules.md | 591 +++++++++++++-------------- 1 file changed, 295 insertions(+), 296 deletions(-) diff --git a/docs/future/provider-access-rules.md b/docs/future/provider-access-rules.md index 4b436b56..b36e75ff 100644 --- a/docs/future/provider-access-rules.md +++ b/docs/future/provider-access-rules.md @@ -1,35 +1,29 @@ -# Who may reference what: `gitBranchesFrom`, `gitTargetsFrom`, `applierClaimsFrom` +# Access between objects: who may reference what > **future work**, 2026-10-23. Not built. **The user-facing part should be settled before the next > release**, because until then renaming costs nothing (see [why now](#why-now)). This page > replaces three drafts: `docs/reviews/20261023-gitbranch-claims-from-other-namespaces.md` (the > proposal), `docs/reviews/20261023-gitbranch-claims-review.md` (its review), and > `docs/future/gitprovider-branch-rules.md`. It follows up items 1 and 2 of "Still missing" in the -> [one-writer-per-branch status](../design/one-writer-per-branch-plan.md). A review of 2026-10-23 -> corrected the defaults, the own-namespace rule, phase 1's watches and the regrant rule; this -> version includes those corrections. +> [one-writer-per-branch status](../design/one-writer-per-branch-plan.md). It includes two reviews +> of 2026-10-23. The second made every grant explicit, made claiming a branch include sharing it, +> and dropped the phases. ## The goal A platform team keeps one repository credential and one cluster connection in its own namespace. -It decides, per activity, which other namespaces may use them, and how far: +It decides, per activity, which other namespaces may use them, and for what: -- **On the repository:** `main` is written by a few named namespaces. Each team claims and writes - its own `teams//*` branches. Any tenant may claim a `feature/*` branch and share it with - other tenants. -- **On the cluster:** tenants mirror their own namespace, and control the appliers that live in - it. One audit namespace mirrors every namespace. The platform namespace may hold the - `Kustomization`s in `flux-system`. +- **On the repository:** the platform holds `main` and decides who writes it. Each team claims + `teams//*` branches and decides who collaborates on them. Any tenant may claim a + `feature/*` branch. +- **On the cluster:** tenants mirror their own namespace and control the appliers that live in it. + One audit namespace mirrors every namespace, and cluster-scoped resources. The platform namespace + controls the `Kustomization`s in `flux-system`. A team creates and deletes its own `GitBranch` in its own namespace when a grant allows it. No credential leaves the platform namespace. -**Where an applier lives is not where it deploys.** `applierClaimsFrom` decides which namespace's -`Kustomization` or `Application` a claim may hold. What that applier then deploys, and where, is -the applier's own configuration and credentials: a Flux `Kustomization`'s `serviceAccountName` -and `targetNamespace`, or an Argo CD `AppProject`'s destinations. That is the workload boundary, -and these grants do not replace it. - ## Why now | Field | In 0.53.0 | In the next release as it stands | @@ -42,285 +36,247 @@ and these grants do not replace it. anyway (`spec.receiver` is gone, and `GitBranch` takes over the branch). If `accessFrom` ships, a later rename breaks released manifests. If the rename lands first, those names are never released. -## The rule: consent lives on the object referenced - -**Every object lists who may reference it. The field is named after the referring kind, or after a -family of kinds the API already defines.** That is where Flux keeps `accessFrom`, where a Gateway -listener keeps `allowedRoutes`, and where `KubeProvider` keeps it today. Each referrer does one -thing with what it references, so the field name also says which act is granted: - -| Object | Referenced by | Field | Act it allows | -|---|---|---|---| -| `GitProvider` | `GitBranch` (`gitProviderRef`) | `gitBranchesFrom` | claim a branch with this credential | -| `GitBranch` | `GitTarget` (`gitBranchRef`) | `gitTargetsFrom` | write a folder into this branch | -| `KubeProvider` | `GitTarget` (`kubeProviderRef`) | `gitTargetsFrom` | mirror from this cluster | -| `KubeProvider` | applier claims: `FluxClaim`, `ArgoCDClaim` (`kubeProviderRef`) | `applierClaimsFrom` | hold an applier in this cluster | - -There is no `ApplierClaim` kind. "Applier claim" is the family the API already names: a -`GitTarget` points at one with `applierClaimRef`, and `definitions.md` defines the term. - -A `GitTarget` never names a `GitProvider`, so a `GitProvider` has no `gitTargetsFrom`: deciding there -would mean reaching through the branch. `GitReceiver` is referenced by nothing and has no list. +## Access between objects: the whole picture -The names follow [definitions.md](../definitions.md) rule 2: a reference names the kind it points -at. They also avoid a bare "claim", which `definitions.md` keeps apart as "branch claim" and -"applier claim". +Every reference in `api/v1alpha3`, and whether it needs a grant. **An object that lends something +lists every grant explicitly: a credential, a branch, or reach into other namespaces.** A +reference that stays in its own namespace and lends nothing needs no grant. Kubernetes draws the +same line. A Pod mounts a Secret in its own namespace without consent, and Gateway API asks for a +`ReferenceGrant` only when a reference crosses namespaces. -One decision covers each act, so two policies never cover the same act. This replaces the phase 1 -rule that a `GitBranch` has no access list and its provider's `accessFrom` "governs both hops". Only -the shape changed: the provider decides who may claim, and the branch decides who may write. - -## The grant contract - -Each field is a list of grants. A grant names some namespaces, and may say how far they reach. - -1. **The object's own namespace is admitted without a grant, with the default reach.** Today's - rules for the own namespace stay as they are: a local `GitTarget` or applier claim reaches only - its own namespace, and a local `GitBranch` stays within `allowedBranches`. -2. **A grant only ever adds.** A namespace's reach is its default reach plus the reach of every - grant that matches it, and this also holds for the own namespace. The order of the list never - matters. -3. **`names` OR `selector`.** A grant matches a namespace whose name is in `names`, or whose labels - match `selector`. The two are alternatives, and a namespace needs only one of them. This is - `NamespaceMatcher` as it is today. -4. **One owner writes each list whole** (`+listType=atomic`), so it needs no merge keys. +| Reference | What it lends | Grant | +|---|---|---| +| `GitBranch.gitProviderRef` → `GitProvider` | A credential, and a branch of its repository | **`GitProvider.gitBranchesFrom`**, scoped by `branches` | +| `GitTarget.gitBranchRef` → `GitBranch` | Writing a folder into the branch | **`GitBranch.gitTargetsFrom`** | +| `GitTarget.kubeProviderRef` → `KubeProvider` | Reading a cluster with its credential | **`KubeProvider.gitTargetsFrom`**, scoped by `sourceNamespaces` and `clusterScoped` | +| `FluxClaim` / `ArgoCDClaim` `.kubeProviderRef` → `KubeProvider` | Control over an applier, with its credential | **`KubeProvider.applierClaimsFrom`**, scoped by `applierNamespaces` | +| `FluxClaim.kustomizationRef`, `ArgoCDClaim.applicationRef` → the applier in the source cluster | (points into a cluster whose objects we do not own) | Bounded by `applierNamespaces` | +| `WatchRule.rules[].sourceNamespace` → a source-cluster namespace | (same) | Bounded by `sourceNamespaces` | +| `ClusterWatchRule.gitTargetRef` → a `GitTarget` in any namespace | Cluster-scoped reads through that target's `KubeProvider` | Bounded by `clusterScoped` on the grant that admits the target | +| `WatchRule.gitTargetRef`, `CommitRequest.gitTargetRef` (own namespace only) | Nothing: what flows in is bounded by the `GitTarget`'s own grants | None | +| `GitTarget.applierClaimRef` (own namespace only) | Nothing new: the applier claim is already bound to one applier by its own grant | None | +| `secretRef`, `knownHostsRef` (own namespace only) | The Kubernetes convention for Secrets | None | +| `GitReceiver` | Nothing references it; a delivery only wakes a worker, which reads the remote with its own credential | None | +| `KubeProvider.inCluster` | The operator's own identity | Consent at install time (`--in-cluster-kube-providers`), unchanged | + +The list is closed. A new kind that references a provider, or lends reach, gets a grant list on the +object it references, following the rules below. + +## The rules + +Every grant answers one question: **who may use this object, and for what?** + +| Question | Answer | +|---|---| +| Where does permission live? | On the referenced object | +| Who gets access automatically? | Nobody, including the object's own namespace | +| What does an omitted or empty grant list mean? | No access | +| How do several grants combine? | Union; order never matters | +| What does an omitted scope mean? | The grant is refused where a scope is required | +| Who controls the grant? | Whoever may edit the referenced object | + +The field is named after the referring kind, or after a family of kinds the API already defines. +"Applier claim" is such a family: there is no `ApplierClaim` kind, but a `GitTarget` points at one +with `applierClaimRef`, and `definitions.md` defines the term. The same `gitTargetsFrom` on two +objects always means "which `GitTarget`s may reference me"; the referenced object decides the +activity. The names follow [definitions.md](../definitions.md) rule 2, and avoid a bare "claim", +which `definitions.md` keeps apart as "branch claim" and "applier claim". + +This means a little more YAML and fewer hidden rules. It replaces the phase 1 decision that a +`GitBranch` has no access list and its provider's `accessFrom` "governs both hops". The provider +decides who may claim, and the branch decides who may write. ### Semantics, field by field | Field | Omitted | `[]` | A value | `"*"` | |---|---|---|---|---| -| A grant list (`gitTargetsFrom`, ...) | Own namespace only | Same as omitted | Grants as below | Not a value of a list | -| `names` | Not matched by name | Refused (CEL) | Exact namespace names | Refused: use `selector: {}` | +| A grant list | No access | No access | Grants, combined by union | Not a value of a list | +| `names` | Not matched by name | Refused (CEL) | Exact control-cluster namespace names | Refused: use `selector: {}` | | `selector` | Not matched by label | (not a list) | Label selector on control-cluster Namespaces | `selector: {}` matches every namespace | -| `names` and `selector` both omitted | Refused (CEL): a grant must match something | | | | -| `gitBranchesFrom[].branches` | Every branch within `allowedBranches` | Refused (CEL) | `filepath.Match` patterns; `{namespace}` is the `GitBranch`'s namespace | One path segment: `*` matches `main`, never `teams/a` | -| `KubeProvider` `gitTargetsFrom[].sourceNamespaces` | Own namespace only | Refused (CEL) | Exact source-cluster names, added to the own namespace | Every namespace; also allows `rules[].sourceNamespace: "*"` | -| `KubeProvider` `applierClaimsFrom[].applierNamespaces` | Own namespace only | Refused (CEL) | Exact names, added to the own namespace | Every namespace | -| `GitBranch.gitTargetsFrom[]` | no reach field | | | | - -Two defaults differ on purpose. A grant that omits `branches` gets every branch within -`allowedBranches`, because `allowedBranches` is already the limit. A grant that omits -`sourceNamespaces` or `applierNamespaces` gets the own namespace only, because nothing else limits -those. The configuration reference must show both side by side. - -`sourceNamespaces` and `applierNamespaces` take exact names or `"*"`, never globs: a source -namespace in `WatchRule` is already a name or `"*"`. "Own namespace" in a remote cluster means the -namespace of the same name. `{namespace}` exists only in `branches`. A namespace name cannot contain -glob characters, and CEL refuses any other `{...}`. - -## `GitProvider`: who may claim a branch +| `names` and `selector` | A grant matches a namespace in `names` **or** matching `selector`; both omitted is refused (CEL) | | | | +| `GitProvider.gitBranchesFrom[].branches` | **Refused**: required | Refused | `filepath.Match` patterns | One path segment: `*` matches `main`; `teams/a` needs `teams/*` | +| `KubeProvider.gitTargetsFrom[].sourceNamespaces` | No namespaces | Refused | Exactly these source-cluster namespaces | Every namespace; also allows `rules[].sourceNamespace: "*"` | +| `KubeProvider.gitTargetsFrom[].clusterScoped` | `false` | (not a list) | `true`: may receive cluster-scoped resources from a `ClusterWatchRule` | (not a list) | +| `KubeProvider.applierClaimsFrom[].applierNamespaces` | **Refused**: required | Refused | Exactly these namespaces | Every namespace | +| `GitBranch.gitTargetsFrom[]` | no scope field | | | | + +A `KubeProvider` `gitTargetsFrom` grant must set `sourceNamespaces`, `clusterScoped: true`, or +both. CEL refuses one that grants nothing. **An explicit list is the whole scope, never "these +plus your own".** `applierNamespaces: [flux-system]` means `flux-system` only. `["*"]` is a +deliberate, broad grant. + +**`{namespace}` always means the referring object's namespace in the control cluster.** It is the +`GitBranch`'s in `branches`, the `GitTarget`'s in `sourceNamespaces`, and the applier claim's in +`applierNamespaces`. In a source-namespace or applier scope it expands to a literal namespace name +in the source cluster. In a remote cluster that is the namespace of the same name, as "own +namespace" means today. A namespace name cannot contain glob characters. CEL refuses any other +`{...}`, and any pattern `filepath.Match` rejects. `sourceNamespaces` and `applierNamespaces` take +exact names, `{namespace}`, or `"*"`, and no globs: a `WatchRule` source namespace is already a name +or `"*"`. + +### Refusal messages name the grant + +A refusal says what the grants allow and what was asked. A tenant should never need a broad grant +to find out why something failed: + +- `GitBranch`: "namespace `team-a` may claim `teams/team-a/*`, `feature/*` through GitProvider + `platform/shared-repo`; requested branch `main` is not allowed". +- `GitTarget` on a branch: "GitBranch `team-a/live` admits `team-a`, `team-b`; namespace `team-c` is + not admitted (`spec.gitTargetsFrom`)". +- `WatchRule`: "KubeProvider `platform/production` lets namespace `team-a` watch `team-a`; + `sourceNamespace: kube-system` is not allowed". + +The reasons `NamespaceNotAuthorized`, `BranchNotAllowed`, `SourceNamespaceNotAllowed` and +`ApplierNamespaceNotAllowed` stay. Only their messages change. + +## `GitProvider`: who may claim which branches ```yaml kind: GitProvider -metadata: {name: platform-repo, namespace: platform} +metadata: {name: shared-repo, namespace: platform} spec: url: https://git.example.com/platform/config.git - secretRef: {name: platform-repo-auth} - allowedBranches: ["main", "release/*", "teams/*/*", "feature/*"] # every claim stays inside - gitBranchesFrom: # phase 2: who else may claim, and which branches - - selector: {matchLabels: {tenant: "true"}} + secretRef: {name: repository-auth} + gitBranchesFrom: + - names: [platform] + branches: [main, "release/*"] + - selector: {matchLabels: {platform.example.com/tenant: "true"}} branches: ["teams/{namespace}/*", "feature/*"] ``` -- **A `GitBranch` in the provider's namespace** may claim any branch within `allowedBranches`. -- **A `GitBranch` in a namespace a grant matches** may claim a branch within both `allowedBranches` - and the grant's `branches`. -- **`accessFrom` is removed.** Who may write moves to the `GitBranch`. -- **`GitBranch.spec.gitProviderRef` becomes a `NamespacedObjectReference`** (phase 2). An omitted - namespace means the `GitBranch`'s own. CEL's `self == oldSelf` treats an omitted namespace and an - explicit own namespace as different values. Default the field at admission, or compare the - resolved namespace in the immutability rule. - -### Can `allowedBranches` go? What people need to configure - -Removing `allowedBranches` was proposed on the assumption that whoever may create a `GitBranch` -beside a provider may also edit the provider. RBAC can separate the two: an admin can put a provider -and its Secret in a team's namespace and let the team create `GitBranch`es there, without letting -the team edit the provider. There, `allowedBranches` is an enforced limit. It is more than a guard -against typos. +- **The complete claim policy is in one place.** A `GitBranch` claims through this provider only if + a grant matches its namespace and its branch. That includes the provider's own namespace, so a + team that owns its provider writes one grant for itself. +- **`allowedBranches` and `accessFrom` are removed.** There is no separate limit and no implicit + bypass for the platform. Who may write moves to the `GitBranch`. +- **`GitBranch.spec.gitProviderRef` becomes a `NamespacedObjectReference`.** An omitted namespace + means the `GitBranch`'s own. CEL's `self == oldSelf` treats an omitted namespace and an explicit + own namespace as different values. Default the field at admission, or compare the resolved + namespace in the immutability rule. +- **Naming the own namespace.** `names: [platform]` is a literal name, which a kustomize overlay + that changes the namespace does not rewrite. A label `selector` avoids that. -Three ways to handle it, checked against what people configure: - -| Need | Today | **K**: keep `allowedBranches` as the limit for every claim | **R**: remove it; own namespace needs a grant | **U**: remove it; own namespace unlimited | -|---|---|---|---|---| -| A team's own provider writes only `main` | `allowedBranches: [main]` | Same | `gitBranchesFrom: [{names: [], branches: [main]}]` | Not expressible | -| An admin provides the credential in a team's namespace; the team creates `GitBranch`es but must stay on `teams/a/*` | `allowedBranches` | Same | A grant naming the own namespace | **Lost**: the team may claim any branch, `main` included | -| A credential that must never claim `main`, whoever creates the `GitBranch` | `allowedBranches` without `main` | Same | Grant no branch that matches `main` | Lost for the own namespace | -| Delegate `teams/{namespace}/*` to tenants (phase 2) | Not possible | Grant with `branches`, inside the limit | Grant with `branches` | Grant with `branches` | -| Give one named namespace one fixed branch (phase 2) | Not possible | Grant with `names` and `branches` | Same | Same | -| The platform's own namespace claims `main` and `release/*` | `allowedBranches` | Same | A grant naming the own namespace | Implicit | -| Who may write into one branch | Provider-wide `accessFrom` | `GitBranch.gitTargetsFrom` in every option | | | -| Fields to read for one delegated branch | n/a | Two: the limit, then the grant | One | One | -| Same rule as the other lists (own namespace admitted, grants add) | n/a | Yes; `allowedBranches` is the own namespace's default reach and the limit on every grant | **No**: the one list where the own namespace is not admitted | Yes, with unlimited reach, unlike `KubeProvider` | -| Release note | n/a | None | `allowedBranches` removed | `allowedBranches` removed | - -**U loses two needs and is out.** **R** keeps everything configurable, but in exchange: - -- every provider, including the single-team case, has to name its own namespace in a manifest, - which a kustomize overlay that changes the namespace does not rewrite; -- `GitProvider` becomes the one list without an implicit own namespace; -- `gitBranchesFrom` has to ship in phase 1 for the own namespace alone. - -**K** keeps everything configurable with no change for anyone using it today. Its cost is reading -two fields for a delegated branch, and the two have separate jobs: what this credential may touch, -and who else may touch which part of it. - -**Recommendation: K.** `allowedBranches` stays as it is, required, as the limit on every claim -through the provider. This answers the open question of whether to keep it as an "optional -ceiling", but as a limit on everyone rather than the own namespace alone. Choose R only if a single -access field per provider matters more than naming the own namespace in manifests. - -## `GitBranch`: who may write into it +## `GitBranch`: who may write through it ```yaml kind: GitBranch -metadata: {name: main, namespace: platform} +metadata: {name: live, namespace: team-a} spec: - gitProviderRef: {name: platform-repo} - branch: main + gitProviderRef: {name: shared-repo, namespace: platform} + branch: teams/team-a/live gitTargetsFrom: - - names: [release-team] + - names: [team-a] --- kind: GitBranch -metadata: {name: checkout-v2, namespace: team-a} +metadata: {name: main, namespace: platform} spec: - gitProviderRef: {name: platform-repo, namespace: platform} # phase 2 - branch: feature/checkout-v2 + gitProviderRef: {name: shared-repo} + branch: main gitTargetsFrom: - - names: [team-b] # team-a shares its feature branch with team-b + - names: [release-team] ``` -- **A `GitTarget` in a matched namespace may write through this `GitBranch`.** A `GitTarget` in - the `GitBranch`'s own namespace always may. - [`gitBranchAdmitted`](../../internal/authz/kubeprovider_admission.go) already admits that case, - and reads the list on the `GitBranch` it has already fetched instead of reading the provider. +- **Claiming a branch delegates it, including the right to share it.** The platform decides which + branches it delegates, and the team decides who collaborates there. Sharing `teams/team-a/live` + with `team-b` means adding `team-b` to this list; the provider and its credential do not change. +- **To keep control of the writers, the platform owns the `GitBranch`,** as for `main` above. There + is no provider-level writer policy beside this one. That would turn delegation into two + policies that intersect. +- **The `GitBranch`'s own namespace needs a grant too.** `names: [team-a]` above is not implied. - **`gitTargetsFrom` is mutable.** `gitProviderRef`, `branch` and `parentBranch` stay immutable, because together they are the branch's identity. The type comment "every field is immutable" changes with it. -- **Whoever owns the `GitBranch` decides who shares it.** In phase 1 a `GitBranch` lives beside its - provider, so the provider's namespace decides; with an RBAC split, that is whoever may edit - `GitBranch`es there. In phase 2 a team that claimed a branch decides who else may write into it - with the platform's credential. That reaches nothing new: the writes stay inside the branch the - platform delegated, and the team could write anything there itself. The platform can no longer - keep team branches private, though. If it needs to, a `gitBranchesFrom` grant can carry a ceiling - on how far the claimant may share. Leave that out until someone asks. - -With this, the per-team-provider workaround (see [what works today](#what-works-today-before-any-of-this)) -is no longer needed. One provider, with a `GitBranch` per branch, isolates teams. +- [`gitBranchAdmitted`](../../internal/authz/kubeprovider_admission.go) reads this list on the + `GitBranch` it already fetches. It no longer reads the provider, and it no longer admits a target + in the `GitBranch`'s namespace automatically. -## `KubeProvider`: who may mirror, and who may hold an applier +## `KubeProvider`: who may mirror, and who may control appliers ```yaml kind: KubeProvider -metadata: {name: workspace-a, namespace: platform} +metadata: {name: production, namespace: platform} spec: - secretRef: {name: workspace-a-kubeconfig} - gitTargetsFrom: # who may mirror from this cluster - - selector: {matchLabels: {tenant: "true"}} + secretRef: {name: production-auth} + gitTargetsFrom: + - selector: {matchLabels: {platform.example.com/tenant: "true"}} + sourceNamespaces: ["{namespace}"] - names: [platform-audit] sourceNamespaces: ["*"] - applierClaimsFrom: # who may hold an applier in this cluster - - selector: {matchLabels: {tenant: "true"}} - - names: [platform] # the own namespace, extended to flux-system + clusterScoped: true + applierClaimsFrom: + - selector: {matchLabels: {platform.example.com/tenant: "true"}} + applierNamespaces: ["{namespace}"] + - names: [platform] applierNamespaces: [flux-system] ``` -- **Tenants** mirror their own namespace and hold appliers that live in it. That is the default - reach of every grant. -- **`platform-audit`** mirrors every namespace. -- **`platform`** is the provider's own namespace. It is admitted without a grant, but only to its own - namespace. The grant naming it adds `flux-system`. -- **`allowCrossNamespace` is removed.** Its two booleans granted cross-namespace reach to every - admitted namespace at once, own namespace included. The API docs had to warn that "the trust is - in every admitted namespace". Reach per grant ends that warning. To keep today's - `sources: true` for everyone, add `sourceNamespaces: ["*"]` to every grant, plus a grant naming - the own namespace. -- **Splitting `accessFrom` changes one thing.** A namespace admitted to mirror is no longer admitted - to hold an applier. Today one matcher lets it do both. - -The `inCluster` arm's consent (`--in-cluster-kube-providers`) is unchanged. Reach granted on an -`inCluster` provider still bypasses live namespace RBAC through the operator's identity, so the -configuration docs keep that warning for `sourceNamespaces` and `applierNamespaces`. - -## The rename, surface by surface - -Measured with `git grep` on 2026-10-23, outside `docs/reviews`. `accessFrom` is in about 75 files, -`allowCrossNamespace` in about 50. With K, `allowedBranches` keeps its name and its meaning for the -own namespace. Most hits are docs, samples, chart and e2e fixtures. +- **`gitTargetsFrom`** admits a `GitTarget` to mirror through this provider. Its + `sourceNamespaces` bound what the `WatchRule`s feeding that target may watch. A `WatchRule` feeds + a `GitTarget` in its own namespace. +- **`clusterScoped: true`** lets a `ClusterWatchRule` mirror cluster-scoped resources through that + `GitTarget`. Today a `ClusterWatchRule` needs only the target's admission. Only a cluster admin can + create one, but without this field `sourceNamespaces: ["{namespace}"]` would look like the whole + reach and would not be. +- **`applierClaimsFrom`** admits an applier claim, and its `applierNamespaces` bound where the + `Kustomization` or `Application` it holds may live. +- **`allowCrossNamespace` and `accessFrom` are removed.** Mirroring and controlling appliers are + granted separately: a namespace admitted to mirror is no longer admitted to apply. -| Surface | Change | -|---|---| -| `api/v1alpha3` | `GitProvider`: drop `AccessFrom`; add `GitBranchesFrom []GitBranchGrant` in phase 2. `GitBranch`: add `GitTargetsFrom []NamespaceGrant`. `KubeProvider`: drop `AccessFrom` and `AllowCrossNamespace`; add `GitTargetsFrom []SourceGrant` and `ApplierClaimsFrom []ApplierGrant`. Each grant embeds `NamespaceMatcher` inline, with the CEL rules from the semantics table. Doc comments on `GitTarget`, `GitBranch`, `FluxClaim` and `ArgoCDClaim` that say `accessFrom` | -| Accessors | `GitBranch.AdmitsGitTarget(ns, labels)`; `KubeProvider.AdmitsGitTarget` and `AdmitsApplierClaim`, each returning the reach that applies, replacing `AllowsCrossNamespaceSources` and `AllowsCrossNamespaceAppliers`; `GitProvider.AdmitsGitBranch(ns, labels, branch)` in phase 2. `GitProvider.AdmitsNamespace` goes | -| `internal/authz` | `gitBranchAdmitted` reads the `GitBranch` instead of its provider. **`ApplierAdmitted` loses its shortcut**: it returns early when the claim and the target share a provider and a namespace, because one matcher used to grant both acts. It must check `applierClaimsFrom` and `applierNamespaces` on its own. Also `namespaceAdmitted`, `ApplierClaimAdmitted` and `source_namespace.go`. Messages that print `(spec.accessFrom)` or `allowCrossNamespace` name the new field. The reasons `NamespaceNotAuthorized`, `SourceNamespaceNotAllowed` and `ApplierNamespaceNotAllowed` stay | -| Controllers and watches | **`WatchRule` gets a Namespace watch** (phase 1, see implementation notes). `gitDestinationToGitTargets`' comment about the provider's `accessFrom` | -| CRDs and chart | Regenerate; the chart's `local` provider and its values; `config/kubeprovider-local.yaml`; samples | -| `hack/crdfields` | The field inventory | -| Docs | `configuration.md` (sharing section, `GitProvider`, `GitBranch`, `KubeProvider`), `security-model.md`, `rbac.md`, `quickstart.md`, `components.md`, `architecture.md`, the bi-directional and attribution guides, `docs/spec` | -| `definitions.md` | Remove `spec.accessFrom` from the fields mirrored from Flux word for word, and rewrite the line saying a provider's "`spec.accessFrom` admits the others". The `NamespaceMatcher` comment ("One field uses it") is already stale | -| `gitbranch-and-kubeprovider-plan.md` | Its "no `accessFrom` of its own" and "one policy governs both hops" for `GitBranch` are superseded; point there from here | -| `UPGRADING.md` | Nothing with K: `accessFrom` and `allowCrossNamespace` were never released on these kinds, and `allowedBranches` is unchanged | -| e2e and fixtures | Every `accessFrom` and `allowCrossNamespace` in `test/e2e` and `test/fixtures`; `accessFrom` on a shared `GitProvider` moves to `gitTargetsFrom` on its `GitBranch`es | - -## Phasing - -1. **Before the next release: the user-facing shape.** All of it changes admission and its - wiring only. - - `GitBranch.gitTargetsFrom`, replacing `GitProvider.accessFrom`. - - `KubeProvider` `gitTargetsFrom` and `applierClaimsFrom`, with per-grant reach. This replaces one - matcher and two booleans in authz, and removes the `ApplierAdmitted` shortcut. - - The `WatchRule` Namespace watch, so a reach that shrinks takes effect at once. - - `GitBranch` stays beside its provider, so `gitBranchesFrom` is not added yet. A field the - controller ignores would be a promise the API cannot keep. -2. **Then: teams claim their own branches.** Add `gitBranchesFrom`, make `gitProviderRef` - namespaced, and handle claim revocation. All of it is additive. -3. **Separately: one authority per repository** (see [the limit](#the-limit-another-provider-on-the-same-repository)). - -Phase 1 leaves the worker, the claim and the Git write path as they are, so lint and unit tests -plus CI e2e are enough. Phase 2 changes the worker, which is the high-risk case in `AGENTS.md`: run -local e2e before pushing. - -## What works today, before any of this - -Nothing refuses two `GitProvider`s for one URL in one namespace, so a platform can already isolate -teams on one repository with one Secret. It creates one provider per team, and creates the -`GitBranch`es itself: +The chart's `local` provider keeps today's default behavior with explicit grants: ```yaml -kind: GitProvider -metadata: {name: repo-team-a, namespace: platform} -spec: - url: https://git.example.com/platform/config.git - secretRef: {name: platform-repo-auth} # the one Secret every team's provider shares - allowedBranches: ["teams/team-a/*"] - accessFrom: {names: [team-a]} +gitTargetsFrom: + - selector: {} + sourceNamespaces: ["{namespace}"] +applierClaimsFrom: + - selector: {} + applierNamespaces: ["{namespace}"] ``` -The example in [configuration.md](../configuration.md#sharing-connections-across-namespaces) has -one provider with `accessFrom: {names: [team-a, team-b]}`, which lets each team write the other's -branch. Until phase 1 lands, the page should say so, or show the per-team form. +The `inCluster` arm's consent (`--in-cluster-kube-providers`) is unchanged. A scope granted on an +`inCluster` provider still bypasses live namespace RBAC through the operator's identity, so the +configuration docs keep that warning for `sourceNamespaces`, `clusterScoped` and +`applierNamespaces`. + +## Boundaries the grants do not replace + +State these in the configuration docs, beside the grants: + +- **A namespace grant trusts that namespace's administrators.** Anyone allowed to create the + referring kind there can use the grant. Labels used in a `selector` must be controlled by trusted + administrators. Kubernetes treats namespace isolation the same way: it comes from + [RBAC and other controls](https://kubernetes.io/docs/concepts/security/multi-tenancy/), not from + namespace names alone. A `names` grant also admits a namespace that is deleted and recreated with + the same name. +- **Controlling an applier is not limiting its workloads.** `applierNamespaces` bounds where the + `Kustomization` or `Application` lives. What it deploys, and where, is its own configuration and + credentials: a Flux `Kustomization`'s `serviceAccountName` and `targetNamespace`, or an Argo CD + `AppProject`'s destinations. +- **The repository's authority must be trusted.** The model assumes platform administrators manage + `GitProvider`s, while tenants manage `GitBranch`es and `GitTarget`s. If tenants may also create + providers, see [the limit](#the-limit-another-provider-on-the-same-repository): grants on one + provider cannot stop a competing claim through another. ## Revocation A grant can shrink in three ways: an edited list, a removed Namespace label, or a deleted object. -Shrinking can end admission altogether, or only narrow a reach while the object stays admitted: -`platform-audit` keeps mirroring its own namespace after losing `"*"`. Both must take effect at -once. The product already has two revocation paths, and both drain: +Shrinking can end admission altogether, or narrow a scope while the object stays admitted. For +example, `platform-audit` keeps a narrower grant after losing `"*"`. Both take effect at once. The +product already has two revocation paths, and both drain: - **Deleting a `GitBranch`.** [`finalize`](../../internal/controller/gitbranch_controller.go) releases the claim first. It then retires the worker, which closes intake, pushes or refuses each pending write, and settles every `CommitRequest`. Last, it wakes the next holder. -- **A `GitTarget`, `WatchRule` or applier claim that loses admission or reach.** It is refused on - its next reconcile. A write already decided is still committed and pushed +- **A `GitTarget`, `WatchRule`, `ClusterWatchRule` or applier claim that loses admission or scope.** + It is refused on its next reconcile. A write already decided is still committed and pushed ([`write_gate.go`](../../internal/git/write_gate.go) explains why: a write held back would come out of order later). -Phase 1 changes only what is admitted, so the second path covers it, given the watch it needs. In -phase 2, a `GitBranch` that loses `gitBranchesFrom` takes the `finalize` path but keeps its -finalizer, and reports `Stalled=True`, reason `NamespaceNotAuthorized`. Writes can therefore land -after the API server accepts a policy edit. Git cannot take back a push that is already sent, and -the docs should promise no more than that. +A `GitBranch` that loses `gitBranchesFrom` takes the `finalize` path but keeps its finalizer, and +reports `Stalled=True`, reason `NamespaceNotAuthorized`. Writes can therefore land after the API +server accepts a policy edit. Git cannot take back a push that is already sent, and the docs should +promise no more than that. Two alternatives were rejected: @@ -331,9 +287,9 @@ Two alternatives were rejected: ### Authorization and priority are separate -- **Authorization: a `GitBranch` is a candidate only while it is admitted.** An unadmitted one never - opens a push session with the shared credential. Once a grant admits it, it proves itself and - becomes a candidate like any other. +- **Authorization: a `GitBranch` is a candidate only while a grant admits it.** An unadmitted one + never opens a push session with the shared credential. Once a grant admits it, it proves itself + and becomes a candidate like any other. - **Priority: the oldest candidate by `creationTimestamp` holds the branch**, as it does today for a `GitBranch` whose proof succeeds late. Priority by admission time would need the time of admission to survive a restart, and the claim is not persisted. Priority by creation time is the @@ -342,7 +298,7 @@ Two alternatives were rejected: holds hands the branch to the old one once it proves itself. The live holder then reports `BranchConflict`. The configuration docs say so: remove stale `GitBranch`es before granting. -For phase 2, the implementation must get these right: +The implementation must get these right: - **Revocation calls `Claims().Release(uid)`, not `Observe(..., false)`.** `Observe` keeps a holder's candidacy through a failed proof. A revoked candidate that does not hold the branch must @@ -357,22 +313,22 @@ For phase 2, the implementation must get these right: ## The limit: another provider on the same repository The claim key is the repository and the branch; the provider is not part of it. Grants on -`platform-repo` decide who may claim **through that provider**. They do not stop a `GitBranch` that +`shared-repo` decide who may claim **through that provider**. They do not stop a `GitBranch` that claims `main` through a different `GitProvider` for the same URL. That provider can be in a tenant's namespace with a copied token. On Gitea or Forgejo, a token that can only read is enough, because it still opens a receive-pack session. If that `GitBranch` is older, or first, it holds the branch. The platform's `GitBranch`, and the `gitTargetsFrom` on it, then report `BranchConflict` and govern nothing. -Per-branch grants make that gap more visible, because "`main` only for the release team" reads as -absolute. State the limit wherever the grants are documented: +State the limit wherever the grants are documented: > Grants decide who may claim a branch through this provider, and who may write through this > `GitBranch`. They cannot stop a claim on the same repository branch through another provider > whose credential opens a push session. -Today a platform protects a branch by creating its `GitBranch` first. The fix is a single authority -per repository. Either of these would do: +Under the trust boundary above, platform administrators create providers, so this needs a copied +credential. If tenants must also create arbitrary providers, one governing authority per repository +becomes part of the design. Either of these would do: - **A cluster-scoped object per repository URL.** This is option C of the original one-writer plan: `ClusterGitProvider`, or `GitCredential`. @@ -381,93 +337,136 @@ per repository. Either of these would do: Neither exists. +## The change, surface by surface + +One PR. Measured with `git grep` on 2026-10-23, outside `docs/reviews`: `accessFrom` is in about 75 +files, `allowCrossNamespace` in about 50, and `allowedBranches` in about 40. Most of these are +docs, samples, the chart and e2e fixtures. + +| Surface | Change | +|---|---| +| `api/v1alpha3` | `GitProvider`: drop `AccessFrom` and `AllowedBranches`; add `GitBranchesFrom []GitBranchGrant`. `GitBranch`: `gitProviderRef` becomes namespaced; add `GitTargetsFrom []NamespaceGrant`. `KubeProvider`: drop `AccessFrom` and `AllowCrossNamespace`; add `GitTargetsFrom []SourceGrant` and `ApplierClaimsFrom []ApplierGrant`. Each grant embeds `NamespaceMatcher` inline, with the CEL rules from the semantics table. Doc comments on `GitTarget`, `GitBranch`, `FluxClaim` and `ArgoCDClaim` that say `accessFrom` | +| Accessors | `GitProvider.AdmitsGitBranch(ns, labels, branch)`, `GitBranch.AdmitsGitTarget(ns, labels)`, `KubeProvider.AdmitsGitTarget` and `AdmitsApplierClaim`, each returning the scope that applies. They replace `AdmitsNamespace`, `AllowsCrossNamespaceSources` and `AllowsCrossNamespaceAppliers`. `{namespace}` expands in one helper | +| `internal/authz` | Every own-namespace shortcut goes: `gitBranchAdmitted`'s and `namespaceAdmitted`'s, and **`ApplierAdmitted`'s**, which returns early when the claim and the target share a provider and a namespace. It must now check `applierClaimsFrom` on its own. `source_namespace.go` checks `sourceNamespaces`, and `watchrule_compile.go` checks `clusterScoped`. Messages follow [refusal messages](#refusal-messages-name-the-grant) | +| Claim and worker | `GitBranch` admission is checked before proof and candidacy; revocation goes through `finalize` without removing the finalizer; `WorkerRequest` carries the resolved provider namespace (see implementation notes) | +| Controllers and watches | `WatchRule` and `GitBranch` get Namespace watches; `GitBranch`es are indexed by resolved provider key (see implementation notes) | +| CRDs and chart | Regenerate; the chart's `local` provider renders the explicit default above, and its values change; `config/kubeprovider-local.yaml`; samples | +| `hack/crdfields` | The field inventory | +| Docs | `configuration.md` (sharing section, the semantics table, `GitProvider`, `GitBranch`, `KubeProvider`, the boundaries), `security-model.md`, `rbac.md`, `quickstart.md`, `components.md`, `architecture.md`, the bi-directional and attribution guides, `docs/spec` | +| `definitions.md` | Remove `spec.accessFrom` from the fields mirrored from Flux word for word, and rewrite the line saying a provider's "`spec.accessFrom` admits the others". Add "grant". The `NamespaceMatcher` comment ("One field uses it") is already stale | +| `gitbranch-and-kubeprovider-plan.md` | Its "no `accessFrom` of its own" and "one policy governs both hops" for `GitBranch` are superseded; point there from here | +| `UPGRADING.md` | One entry: `allowedBranches` is replaced by `gitBranchesFrom`, and every `GitBranch`, `GitTarget` and applier claim now needs a grant, also in the provider's own namespace | +| e2e and fixtures | Every `accessFrom`, `allowCrossNamespace` and `allowedBranches` in `test/e2e` and `test/fixtures`; same-namespace fixtures gain explicit grants | + +The worker and the Git write path change, so this is the high-risk case in `AGENTS.md`: run local +e2e before pushing. + +## What works today, before any of this + +Nothing refuses two `GitProvider`s for one URL in one namespace, so a platform can already isolate +teams on one repository with one Secret. It creates one provider per team, and creates the +`GitBranch`es itself: + +```yaml +kind: GitProvider +metadata: {name: repo-team-a, namespace: platform} +spec: + url: https://git.example.com/platform/config.git + secretRef: {name: repository-auth} # the one Secret every team's provider shares + allowedBranches: ["teams/team-a/*"] + accessFrom: {names: [team-a]} +``` + +The example in [configuration.md](../configuration.md#sharing-connections-across-namespaces) has +one provider with `accessFrom: {names: [team-a, team-b]}`, which lets each team write the other's +branch. Until this lands, the page should say so, or show the per-team form. + ## Alternatives considered | Alternative | Why not chosen | |---|---| | Keep `accessFrom` (Flux's spelling) | It does not say which act it grants, and it cannot grant mirroring without applying, or one branch without all | -| Remove `allowedBranches` (options R and U above) | U loses two needs; R keeps them at the cost of naming the own namespace in every provider and an exception to the own-namespace rule | -| Own namespace admitted with unlimited reach (an earlier version of this page) | Widens today's `KubeProvider` behavior, where the own namespace also needs `allowCrossNamespace` to leave itself, and made a grant naming the own namespace meaningless | -| `gitTargetsFrom` on `GitProvider`, with `branches` per grant | A `GitTarget` does not reference the provider, so the decision reached through the branch; it needed branch patterns and `{namespace}` on the writer side too, and per-team providers stayed the only way to isolate before phase 2 | +| Admit the own namespace automatically, with its own namespace as the scope, and keep `allowedBranches` as a limit on every claim (the previous version of this page) | Hidden rules: the default differed per field, `[flux-system]` meant "flux-system plus your own", and a delegated branch needed two fields read together. Explicit grants show the whole policy in one place | +| Admit the own namespace with unlimited scope | Widens today's `KubeProvider` behavior, and loses the RBAC split where a team may create `GitBranch`es beside a provider it may not edit | +| A provider-level writer policy beside `GitBranch.gitTargetsFrom` | Two policies that intersect. The platform keeps control of a branch by owning its `GitBranch` | +| `gitTargetsFrom` on `GitProvider`, with `branches` per grant | A `GitTarget` does not reference the provider, so the decision reached through the branch | | Rules keyed by branch on `GitProvider`, first match wins | It needs list order to keep `main` narrow beside a `*` rule. That order is invisible in a merged manifest, and the shape does not carry over to `KubeProvider` | -| Flat `branchClaimsFrom` beside `allowedBranches` and `accessFrom` | Three lists that interact. The effective rule for one branch is their intersection, which nobody can read at a glance | -| Keep `allowCrossNamespace` beside the split lists | Reach stays global across every admitted namespace, which is the warning its own docs carry | +| Flat `branchClaimsFrom` beside `allowedBranches` and `accessFrom` | Three lists that interact. The effective rule for one branch is their intersection | | A separate grant kind in the provider's namespace (`GitBranchGrant`, after Gateway API's `ReferenceGrant`) | Worth it only if someone needs separate RBAC for grants and connections; it adds a kind, a watch and grant-deletion handling | +| Two phases: the renames first, cross-namespace claims later | Ships an API whose own-namespace rules change again in the second phase. One PR settles the model once | | Tenant-owned providers with their own credentials | Right when repositories separate tenants. On one shared repository, a copied repository-wide token can claim any branch (see the limit) | ## Implementation notes -- **Watches, phase 1.** +- **Watches.** - **Already wired:** a `GitBranch` event reaches the `GitTarget`s in every namespace that reference it ([`gitTargetsOn`](../../internal/controller/gittarget_controller.go)). A Namespace label change reaches that namespace's `GitTarget`s and applier claims (`namespaceToClaims`). A `KubeProvider` spec change reaches the `WatchRule`s (`kubeProviderToWatchRules`). - - **Missing:** the `WatchRule` controller does not watch Namespaces. Its `GitTarget` watch fires - only on a spec change or a readiness move. A label change that narrows `sourceNamespaces` - while the `GitTarget` stays admitted therefore re-evaluates no `WatchRule`. Add a Namespace - watch that maps to the `WatchRule`s in that namespace. Today this gap is harmless, because - reach is provider-wide and a label change can only change admission. -- **Watches, phase 2.** `gitTargetsOn` and `gitProviderToGitBranches` find a provider's - `GitBranch`es in the provider's namespace only. Index `GitBranch`es by resolved provider key, and - use the index in both. The `GitBranch` controller does not watch Namespaces either: add a mapper - to the `GitBranch`es in a namespace whose labels change. From there, revocation reaches their - `GitTarget`s through `gitTargetsOn`. -- **Credential resolution (phase 2).** + - **`WatchRule` needs a Namespace watch.** Its `GitTarget` watch fires only on a spec change or a + readiness move, so a label change that narrows `sourceNamespaces` while the `GitTarget` stays + admitted would re-evaluate no `WatchRule`. Map a Namespace to the `WatchRule`s in it. The same + holds for `ClusterWatchRule` and `clusterScoped`, mapped through the targets in that namespace. + - **`GitBranch` needs a Namespace watch,** mapped to the `GitBranch`es in that namespace. From + there, revocation reaches their `GitTarget`s through `gitTargetsOn`. + - **Index `GitBranch`es by resolved provider key.** `gitTargetsOn` and `gitProviderToGitBranches` + find a provider's `GitBranch`es in the provider's namespace only. +- **Credential resolution.** [`WorkerRequest.provider()`](../../internal/git/worker_manager.go) builds the provider key from `GitBranch.Namespace`. The resolved provider namespace has to be carried through `gitDestination`, `WorkerRequest` and worker creation. Test two cases: no provider in the tenant namespace, and a same-name provider there holding a different credential. Both must use the platform provider and its Secret. -- **One admission function per act, in `internal/authz`.** Each returns the decision and the reach - that applies. Call the `GitBranch` one (phase 2) before proof and candidacy. Keep distinct reasons - for each case: reference missing, namespace not admitted, branch not allowed, proof failed, branch - conflict. -- **Status.** `kubectl get gitbranch` should show the provider's namespace once it can differ. +- **One admission function per act, in `internal/authz`.** Each returns the decision, the scope + that applies, and the grants for the refusal message. Call the `GitBranch` one before proof and + candidacy. Keep distinct reasons for each case: reference missing, namespace not admitted, branch + not allowed, proof failed, branch conflict. +- **Status.** `kubectl get gitbranch` should show the provider's namespace. ## Evidence before it is ready - **Admission tables, per act, row by row from the semantics table.** - - Own, matched and third namespaces, for each of the four lists. - - Omitted and `[]` for every field, with CEL refusing the `[]` it should refuse. + - Own, matched and third namespaces for each of the four lists, with the own namespace refused + when no grant matches it. + - Omitted and `[]` for every field, with CEL refusing what it should. - `names` only, `selector` only, both (OR), neither (refused). - - `{namespace}` expansion, and pattern depth (`teams/*` against `teams/a/b`). + - `{namespace}` in each of the three scopes, and pattern depth (`teams/*` against `teams/a/b`). - `sourceNamespaces: ["*"]` against `sourceNamespace: "*"`; exact names against `"*"`. - - The own namespace alone (own reach only) and with a grant naming it (extended). - - A `GitBranch` claim inside `allowedBranches` but outside a grant's `branches`, and the reverse. + - `applierNamespaces: [flux-system]` refusing the claim's own namespace. + - `clusterScoped` false and true for a `ClusterWatchRule`. - **Mirroring allowed, applying denied, on one provider.** A `GitTarget` and its applier claim in one namespace, through one `KubeProvider`, where `gitTargetsFrom` matches and `applierClaimsFrom` does not. The apply must be refused; today's `ApplierAdmitted` shortcut would allow it. -- **Reach reduced while admission stays.** A namespace matched by two grants loses the label of - the one granting `"*"`. Its `WatchRule`s stop watching other namespaces through the watch, with - the `GitTarget` still admitted. The same for `applierNamespaces`. -- **Sharing edits.** Adding and removing a namespace from `GitBranch.gitTargetsFrom` wakes and - refuses that namespace's `GitTarget`s through the watch. -- **Authorization before candidacy (phase 2).** An unadmitted `GitBranch` never becomes a candidate - and never opens a push session while it is unadmitted. Once admitted, it becomes a candidate. - An older admitted one takes the branch by `creationTimestamp`, also after a restart. -- **Credential resolution (phase 2).** The two cases in the implementation notes. Receiver routing - still reaches the right worker. +- **Scope narrowed while admission stays.** A namespace matched by two grants loses the label of + the one granting `"*"`. Its `WatchRule`s stop watching the other namespaces through the watch, + with the `GitTarget` still admitted. The same for `applierNamespaces` and `clusterScoped`. +- **Sharing.** Adding and removing a namespace from `GitBranch.gitTargetsFrom` wakes and refuses + that namespace's `GitTarget`s through the watch. +- **Authorization before candidacy.** An unadmitted `GitBranch` never becomes a candidate and never + opens a push session while it is unadmitted. Once admitted, it becomes a candidate. An older + admitted one takes the branch by `creationTimestamp`, also after a restart. +- **Credential resolution.** The two cases in the implementation notes. Receiver routing still + reaches the right worker. +- **Refusal messages.** Each act's message names the grants that apply and the requested value. - **Revocation, driven by watch events.** A direct `Reconcile` call proves no event wiring. Cover: - grant edit, grant removal and label removal, with several candidates revoked by one edit; - queued events, open windows, decided writes, push retry, a push in flight, and waiting or attached `CommitRequest`s; - the successor waiting for retirement, and receiving none of the old work. -- **End to end.** A team claims and writes `teams//live`, and shares a feature branch with a - second team. Claims on `main` and on another team's branch fail. A tenant mirrors its own - namespace and is refused another. The audit namespace mirrors `*`. +- **End to end.** A team claims and writes `teams//live`, and shares it with a second team. + Claims on `main` and on another team's branch fail. A tenant mirrors its own namespace and is + refused another. The audit namespace mirrors `*` and cluster-scoped resources. The chart's default + `local` provider behaves as today. ## Open questions -1. **`allowedBranches`: K or R.** The table above recommends K. -2. **A sharing ceiling (phase 2).** May a team that claimed a branch share it with anyone, or does - its `gitBranchesFrom` grant bound whom it may admit? -3. **Recreated namespaces.** A `names` grant also admits a namespace that is deleted and recreated - with the same name. Acceptable, or bind to the UID? -4. **Branch-count limits.** A `teams/{namespace}/*` grant lets a team create any number of +1. **Branch-count limits.** A `teams/{namespace}/*` grant lets a team create any number of `GitBranch`es, each a worker with a clone. Exact names cap the count. Is a per-grant limit needed? -5. **`parentBranch` reads outside the grants.** A team's branch can be created from `main` although - the team may not write `main`. That looks intended, because it is how a feature branch starts. +2. **`parentBranch` reads outside the grants.** A team's branch can be created from `main` although + the team may not claim `main`. That looks intended, because it is how a feature branch starts. Confirm it, and document it. -6. **Path isolation.** Grants are per branch. Per-folder grants inside one branch stay out of scope; +3. **Path isolation.** Grants are per branch. Per-folder grants inside one branch stay out of scope; the overlap check only stops two targets claiming the same paths. +4. **Recreated namespaces.** A `names` grant admits a recreated namespace of the same name. That is + stated as a boundary above. Bind to the UID only if someone needs it. From eb5e7df2d07bac15bb54bf3224b2b796296f3d4b Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 16:08:51 +0000 Subject: [PATCH 03/34] docs: settle the chart default, branch patterns and revocation promise Fold in the third review of the access plan: - The chart's local provider grants nothing by default. quickstart.enabled grants exactly the quickstart namespace, and a shipped values-playground.yaml grants every namespace its own, as today. No default grants clusterScoped. - Branch patterns get a small language of our own instead of filepath.Match: whole-name matching, * crosses /, {namespace} must be a whole path segment, and no escaping. The syntax and the error style follow placement templates. - Revocation promises to stop new work once the change is observed, defines "accepted" for the drain, and hands a held applier back on an explicit denial. - The four open questions are decided, grant lists are atomic, and priority is the oldest authorized candidate, then UID. Add a checklist of the tests the PR must add. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/future/provider-access-rules.md | 267 +++++++++++++++++++-------- 1 file changed, 195 insertions(+), 72 deletions(-) diff --git a/docs/future/provider-access-rules.md b/docs/future/provider-access-rules.md index b36e75ff..09d99924 100644 --- a/docs/future/provider-access-rules.md +++ b/docs/future/provider-access-rules.md @@ -5,9 +5,11 @@ > replaces three drafts: `docs/reviews/20261023-gitbranch-claims-from-other-namespaces.md` (the > proposal), `docs/reviews/20261023-gitbranch-claims-review.md` (its review), and > `docs/future/gitprovider-branch-rules.md`. It follows up items 1 and 2 of "Still missing" in the -> [one-writer-per-branch status](../design/one-writer-per-branch-plan.md). It includes two reviews +> [one-writer-per-branch status](../design/one-writer-per-branch-plan.md). It includes three reviews > of 2026-10-23. The second made every grant explicit, made claiming a branch include sharing it, -> and dropped the phases. +> and dropped the phases. The third settled the chart default, the branch pattern language, the +> revocation promise and the open questions. [Tests to add](#tests-to-add) is the checklist for the +> PR. ## The goal @@ -74,6 +76,7 @@ Every grant answers one question: **who may use this object, and for what?** | How do several grants combine? | Union; order never matters | | What does an omitted scope mean? | The grant is refused where a scope is required | | Who controls the grant? | Whoever may edit the referenced object | +| Who owns a grant list? | One policy owner per object: every grant list is `+listType=atomic`, and `names`, `branches`, `sourceNamespaces` and `applierNamespaces` are `+listType=set` | The field is named after the referring kind, or after a family of kinds the API already defines. "Applier claim" is such a family: there is no `ApplierClaim` kind, but a `GitTarget` points at one @@ -94,7 +97,7 @@ decides who may claim, and the branch decides who may write. | `names` | Not matched by name | Refused (CEL) | Exact control-cluster namespace names | Refused: use `selector: {}` | | `selector` | Not matched by label | (not a list) | Label selector on control-cluster Namespaces | `selector: {}` matches every namespace | | `names` and `selector` | A grant matches a namespace in `names` **or** matching `selector`; both omitted is refused (CEL) | | | | -| `GitProvider.gitBranchesFrom[].branches` | **Refused**: required | Refused | `filepath.Match` patterns | One path segment: `*` matches `main`; `teams/a` needs `teams/*` | +| `GitProvider.gitBranchesFrom[].branches` | **Refused**: required | Refused | Branch patterns (see [patterns](#patterns)) | Every branch | | `KubeProvider.gitTargetsFrom[].sourceNamespaces` | No namespaces | Refused | Exactly these source-cluster namespaces | Every namespace; also allows `rules[].sourceNamespace: "*"` | | `KubeProvider.gitTargetsFrom[].clusterScoped` | `false` | (not a list) | `true`: may receive cluster-scoped resources from a `ClusterWatchRule` | (not a list) | | `KubeProvider.applierClaimsFrom[].applierNamespaces` | **Refused**: required | Refused | Exactly these namespaces | Every namespace | @@ -105,14 +108,44 @@ both. CEL refuses one that grants nothing. **An explicit list is the whole scope plus your own".** `applierNamespaces: [flux-system]` means `flux-system` only. `["*"]` is a deliberate, broad grant. +### Patterns + +A small language of our own, specified here and independent of the platform. Go's +`filepath.Match` was dropped: its `*` stops at `/`, and it brings character classes, `?` and +escaping, none of which a branch grant needs. + +- **A branch pattern is matched against the whole branch name.** It is made of literal text, `*` + and `{namespace}`. +- **`*` matches any sequence of characters, `/` included, and the empty one.** `feature/*` + matches `feature/checkout/v2`, `teams/{namespace}/*` covers a team's whole prefix, and `*` alone + is every branch. +- **`{namespace}` must be a whole path segment:** at the start or after a `/`, and at the end or + before a `/`. `teams/{namespace}/*` is valid. `teams/{namespace}*` is refused, because for + `team-a` it would also match `teams/team-ab/live`. This is placement's rule, "every value is a + single path segment", applied to a grant. +- **No escaping, `?` or character classes.** Git refuses `*`, `?` and `[` in a branch name + (`git check-ref-format`), so a literal never needs escaping. +- **`sourceNamespaces` and `applierNamespaces`** take exact names, `{namespace}` alone, or `"*"` + alone. A `WatchRule` source namespace is already a name or `"*"`, so these scopes need no + patterns. +- **CEL checks the grammar at admission:** allowed characters, `*`, and `{namespace}` as a whole + segment. Any other `{...}` is refused with a message in placement's style, naming the unknown + variable and the one that exists: "branch pattern `teams/{ns}/*` references unknown variable + `{ns}`; the only variable is `{namespace}`". + **`{namespace}` always means the referring object's namespace in the control cluster.** It is the `GitBranch`'s in `branches`, the `GitTarget`'s in `sourceNamespaces`, and the applier claim's in `applierNamespaces`. In a source-namespace or applier scope it expands to a literal namespace name in the source cluster. In a remote cluster that is the namespace of the same name, as "own -namespace" means today. A namespace name cannot contain glob characters. CEL refuses any other -`{...}`, and any pattern `filepath.Match` rejects. `sourceNamespaces` and `applierNamespaces` take -exact names, `{namespace}`, or `"*"`, and no globs: a `WatchRule` source namespace is already a name -or `"*"`. +namespace" means today. A namespace name is a DNS label, so it is always valid inside a branch name. + +The syntax is placement's: single braces, as in `{namespace}/{resource}/{name}.yaml`, so the API +has one variable syntax. The subject differs, and the configuration docs say so side by side. +In a placement template `{namespace}` is the mirrored resource's namespace, and can render +`_cluster`. In a grant it is the referring object's, which always exists. Placement's `|fallback`, +its sanitizing and its empty-segment dropping do not carry over: they exist to render a path, and a +grant renders nothing. The matcher is a few lines in `api/v1alpha3` beside `NamespaceMatcher`. It +does not reuse the placement parser, which carries fourteen variables, fallbacks and label keys. ### Refusal messages name the grant @@ -222,16 +255,43 @@ spec: - **`allowCrossNamespace` and `accessFrom` are removed.** Mirroring and controlling appliers are granted separately: a namespace admitted to mirror is no longer admitted to apply. -The chart's `local` provider keeps today's default behavior with explicit grants: +### The chart's `local` provider -```yaml -gitTargetsFrom: - - selector: {} - sourceNamespaces: ["{namespace}"] -applierClaimsFrom: - - selector: {} - applierNamespaces: ["{namespace}"] -``` +The installer chooses which namespaces may use the operator's identity; the chart never chooses for +them. + +- **Default install: empty grant lists.** Nothing mirrors or holds an applier through `local` until + values grant it. A `GitTarget` refused by the empty default says how to fix it: "KubeProvider + `gitops-reverser/local` grants no namespace; set `kubeProvider.gitTargetsFrom` in the chart + values". +- **`quickstart.enabled=true`** also renders a grant for exactly the quickstart namespace, so the + [quickstart](../quickstart.md) stays one `helm install`: + + ```yaml + gitTargetsFrom: + - names: [gitops-reverser-quickstart-demo] + sourceNamespaces: ["{namespace}"] + applierClaimsFrom: + - names: [gitops-reverser-quickstart-demo] + applierNamespaces: ["{namespace}"] + ``` + +- **`values-playground.yaml`,** shipped with the chart, grants every namespace its own namespace. + That is today's default, chosen visibly with `-f values-playground.yaml`: + + ```yaml + kubeProvider: + gitTargetsFrom: + - selector: {} + sourceNamespaces: ["{namespace}"] + applierClaimsFrom: + - selector: {} + applierNamespaces: ["{namespace}"] + ``` + +- **No default grants `clusterScoped`,** the playground included. A `ClusterWatchRule` through + `local` stops mirroring until a grant sets it. That is an intentional restriction, and + `UPGRADING.md` says so. The `inCluster` arm's consent (`--in-cluster-kube-providers`) is unchanged. A scope granted on an `inCluster` provider still bypasses live namespace RBAC through the operator's identity, so the @@ -256,13 +316,28 @@ State these in the configuration docs, beside the grants: `GitProvider`s, while tenants manage `GitBranch`es and `GitTarget`s. If tenants may also create providers, see [the limit](#the-limit-another-provider-on-the-same-repository): grants on one provider cannot stop a competing claim through another. +- **Branch grants restrict claiming and writing. They do not make branches confidential from each + other.** A `GitBranch`'s `parentBranch` may name any branch the provider's credential can read, + granted or not, because creating `feature/x` from `main` should not need the right to claim + `main`. A permitted parent's contents can therefore be copied into the delegated branch. +- **The unit a grant delegates is the branch.** Inside one branch, the folder overlap check + coordinates writers; it is not a boundary between tenants. ## Revocation A grant can shrink in three ways: an edited list, a removed Namespace label, or a deleted object. Shrinking can end admission altogether, or narrow a scope while the object stays admitted. For -example, `platform-audit` keeps a narrower grant after losing `"*"`. Both take effect at once. The -product already has two revocation paths, and both drain: +example, `platform-audit` keeps a narrower grant after losing `"*"`. The promise, for both: + +> Policy changes trigger reconciliation. Once revocation is observed, the controller stops +> admitting new work under the revoked permission. Work accepted before that may complete, +> following the drain below. + +**Accepted** means what the branch worker already holds when the revocation is observed: queued +events, open commit windows, and decided writes. That matches what deleting a `GitBranch` drains +today. An event that arrives after the observation is refused. The tests rely on this line to tell +a permitted drain from a revocation bug. The product already has two revocation paths, and both +drain: - **Deleting a `GitBranch`.** [`finalize`](../../internal/controller/gitbranch_controller.go) releases the claim first. It then @@ -278,6 +353,16 @@ reports `Stalled=True`, reason `NamespaceNotAuthorized`. Writes can therefore la server accepts a policy edit. Git cannot take back a push that is already sent, and the docs should promise no more than that. +**An applier claim that loses `applierClaimsFrom` hands its applier back.** Today it does not: a +claim that stops being `Configured` leaves its `Kustomization` suspended under GitOps Reverser's +field manager. It hands back only once no eligible `GitTarget` references the claim, or the claim +is deleted (`applierclaim_controller.go`). After this change, an explicit denial stops new apply +requests and follows the existing hand-back. That costs one final write through the revoked +credential, so an applier is never left suspended forever. A transient read error still keeps the +hold; the controller already tells the two apart (`kept` against a denial). Once the applier is +handed back, Flux or Argo CD resumes, and may apply Git over live edits not mirrored yet. That is +what losing control of an applier means. + Two alternatives were rejected: - **Refusing unpublished work at revocation.** It needs a worker shutdown with no flush, which @@ -290,10 +375,9 @@ Two alternatives were rejected: - **Authorization: a `GitBranch` is a candidate only while a grant admits it.** An unadmitted one never opens a push session with the shared credential. Once a grant admits it, it proves itself and becomes a candidate like any other. -- **Priority: the oldest candidate by `creationTimestamp` holds the branch**, as it does today for a - `GitBranch` whose proof succeeds late. Priority by admission time would need the time of - admission to survive a restart, and the claim is not persisted. Priority by creation time is the - only order a restart recomputes the same way. +- **Priority: the oldest authorized candidate holds the branch,** by `creationTimestamp` and then + UID, as it does today for a `GitBranch` whose proof succeeds late. That is a choice: it is + deterministic, and a restart recomputes it from the objects alone. - **The consequence:** granting a namespace that holds an old `GitBranch` for a branch someone else holds hands the branch to the old one once it proves itself. The live holder then reports `BranchConflict`. The configuration docs say so: remove stale `GitBranch`es before granting. @@ -346,16 +430,17 @@ docs, samples, the chart and e2e fixtures. | Surface | Change | |---|---| | `api/v1alpha3` | `GitProvider`: drop `AccessFrom` and `AllowedBranches`; add `GitBranchesFrom []GitBranchGrant`. `GitBranch`: `gitProviderRef` becomes namespaced; add `GitTargetsFrom []NamespaceGrant`. `KubeProvider`: drop `AccessFrom` and `AllowCrossNamespace`; add `GitTargetsFrom []SourceGrant` and `ApplierClaimsFrom []ApplierGrant`. Each grant embeds `NamespaceMatcher` inline, with the CEL rules from the semantics table. Doc comments on `GitTarget`, `GitBranch`, `FluxClaim` and `ArgoCDClaim` that say `accessFrom` | -| Accessors | `GitProvider.AdmitsGitBranch(ns, labels, branch)`, `GitBranch.AdmitsGitTarget(ns, labels)`, `KubeProvider.AdmitsGitTarget` and `AdmitsApplierClaim`, each returning the scope that applies. They replace `AdmitsNamespace`, `AllowsCrossNamespaceSources` and `AllowsCrossNamespaceAppliers`. `{namespace}` expands in one helper | +| Accessors | `GitProvider.AdmitsGitBranch(ns, labels, branch)`, `GitBranch.AdmitsGitTarget(ns, labels)`, `KubeProvider.AdmitsGitTarget` and `AdmitsApplierClaim`, each returning the scope that applies. They replace `AdmitsNamespace`, `AllowsCrossNamespaceSources` and `AllowsCrossNamespaceAppliers`. One pattern matcher, with `{namespace}` expansion, beside `NamespaceMatcher` | | `internal/authz` | Every own-namespace shortcut goes: `gitBranchAdmitted`'s and `namespaceAdmitted`'s, and **`ApplierAdmitted`'s**, which returns early when the claim and the target share a provider and a namespace. It must now check `applierClaimsFrom` on its own. `source_namespace.go` checks `sourceNamespaces`, and `watchrule_compile.go` checks `clusterScoped`. Messages follow [refusal messages](#refusal-messages-name-the-grant) | | Claim and worker | `GitBranch` admission is checked before proof and candidacy; revocation goes through `finalize` without removing the finalizer; `WorkerRequest` carries the resolved provider namespace (see implementation notes) | +| Applier claims | An explicit admission denial hands the applier back; a transient error keeps the hold | | Controllers and watches | `WatchRule` and `GitBranch` get Namespace watches; `GitBranch`es are indexed by resolved provider key (see implementation notes) | -| CRDs and chart | Regenerate; the chart's `local` provider renders the explicit default above, and its values change; `config/kubeprovider-local.yaml`; samples | +| CRDs and chart | Regenerate. The chart's `local` provider gets empty grant lists by default, a grant for the quickstart namespace under `quickstart.enabled`, and `values-playground.yaml` (see [the chart's `local` provider](#the-charts-local-provider)). `config/kubeprovider-local.yaml` and the e2e install grant what the suite needs; samples | | `hack/crdfields` | The field inventory | | Docs | `configuration.md` (sharing section, the semantics table, `GitProvider`, `GitBranch`, `KubeProvider`, the boundaries), `security-model.md`, `rbac.md`, `quickstart.md`, `components.md`, `architecture.md`, the bi-directional and attribution guides, `docs/spec` | | `definitions.md` | Remove `spec.accessFrom` from the fields mirrored from Flux word for word, and rewrite the line saying a provider's "`spec.accessFrom` admits the others". Add "grant". The `NamespaceMatcher` comment ("One field uses it") is already stale | | `gitbranch-and-kubeprovider-plan.md` | Its "no `accessFrom` of its own" and "one policy governs both hops" for `GitBranch` are superseded; point there from here | -| `UPGRADING.md` | One entry: `allowedBranches` is replaced by `gitBranchesFrom`, and every `GitBranch`, `GitTarget` and applier claim now needs a grant, also in the provider's own namespace | +| `UPGRADING.md` | One entry: `allowedBranches` is replaced by `gitBranchesFrom`; every `GitBranch`, `GitTarget` and applier claim now needs a grant, also in the provider's own namespace; the chart's `local` provider grants nothing unless values or the quickstart do; and a `ClusterWatchRule` needs `clusterScoped` | | e2e and fixtures | Every `accessFrom`, `allowCrossNamespace` and `allowedBranches` in `test/e2e` and `test/fixtures`; same-namespace fixtures gain explicit grants | The worker and the Git write path change, so this is the high-risk case in `AGENTS.md`: run local @@ -394,6 +479,10 @@ branch. Until this lands, the page should say so, or show the per-team form. | Flat `branchClaimsFrom` beside `allowedBranches` and `accessFrom` | Three lists that interact. The effective rule for one branch is their intersection | | A separate grant kind in the provider's namespace (`GitBranchGrant`, after Gateway API's `ReferenceGrant`) | Worth it only if someone needs separate RBAC for grants and connections; it adds a kind, a watch and grant-deletion handling | | Two phases: the renames first, cross-namespace claims later | Ships an API whose own-namespace rules change again in the second phase. One PR settles the model once | +| `filepath.Match` for branch patterns | Its `*` stops at `/`, so `feature/*` missed `feature/a/b` and nothing meant "every branch"; and it brings character classes, `?` and escaping that a grant never needs | +| A chart default that grants every namespace, or every namespace carrying a label | Every namespace silently gains use of the operator's identity. A label default quietly becomes a grant to everyone on platforms where tenants may label their own namespaces | +| A per-grant limit on the number of `GitBranch`es | Quota and authorization have different owners. Overlapping grants make per-grant counting complicated, and a namespace `ResourceQuota` already counts custom resources | +| Binding `names` grants to namespace UIDs | Breaks declarative provisioning, because the UID exists only after the namespace is created. Recreating a namespace is a trusted administrative act | | Tenant-owned providers with their own credentials | Right when repositories separate tenants. On one shared repository, a copied repository-wide token can claim any branch (see the limit) | ## Implementation notes @@ -417,56 +506,90 @@ branch. Until this lands, the page should say so, or show the per-team form. `gitDestination`, `WorkerRequest` and worker creation. Test two cases: no provider in the tenant namespace, and a same-name provider there holding a different credential. Both must use the platform provider and its Secret. +- **Applier hand-back on denial.** `ApplierClaimReconciler` reports a denial through `falseCheck` and + a read error through `kept`. Only the denial goes to the hand-back that "no eligible `GitTarget`" + takes today. - **One admission function per act, in `internal/authz`.** Each returns the decision, the scope that applies, and the grants for the refusal message. Call the `GitBranch` one before proof and candidacy. Keep distinct reasons for each case: reference missing, namespace not admitted, branch not allowed, proof failed, branch conflict. - **Status.** `kubectl get gitbranch` should show the provider's namespace. -## Evidence before it is ready - -- **Admission tables, per act, row by row from the semantics table.** - - Own, matched and third namespaces for each of the four lists, with the own namespace refused - when no grant matches it. - - Omitted and `[]` for every field, with CEL refusing what it should. - - `names` only, `selector` only, both (OR), neither (refused). - - `{namespace}` in each of the three scopes, and pattern depth (`teams/*` against `teams/a/b`). - - `sourceNamespaces: ["*"]` against `sourceNamespace: "*"`; exact names against `"*"`. - - `applierNamespaces: [flux-system]` refusing the claim's own namespace. - - `clusterScoped` false and true for a `ClusterWatchRule`. -- **Mirroring allowed, applying denied, on one provider.** A `GitTarget` and its applier claim in - one namespace, through one `KubeProvider`, where `gitTargetsFrom` matches and - `applierClaimsFrom` does not. The apply must be refused; today's `ApplierAdmitted` shortcut would - allow it. -- **Scope narrowed while admission stays.** A namespace matched by two grants loses the label of - the one granting `"*"`. Its `WatchRule`s stop watching the other namespaces through the watch, - with the `GitTarget` still admitted. The same for `applierNamespaces` and `clusterScoped`. -- **Sharing.** Adding and removing a namespace from `GitBranch.gitTargetsFrom` wakes and refuses - that namespace's `GitTarget`s through the watch. -- **Authorization before candidacy.** An unadmitted `GitBranch` never becomes a candidate and never - opens a push session while it is unadmitted. Once admitted, it becomes a candidate. An older - admitted one takes the branch by `creationTimestamp`, also after a restart. -- **Credential resolution.** The two cases in the implementation notes. Receiver routing still - reaches the right worker. -- **Refusal messages.** Each act's message names the grants that apply and the requested value. -- **Revocation, driven by watch events.** A direct `Reconcile` call proves no event wiring. Cover: - - grant edit, grant removal and label removal, with several candidates revoked by one edit; - - queued events, open windows, decided writes, push retry, a push in flight, and waiting or - attached `CommitRequest`s; - - the successor waiting for retirement, and receiving none of the old work. -- **End to end.** A team claims and writes `teams//live`, and shares it with a second team. - Claims on `main` and on another team's branch fail. A tenant mirrors its own namespace and is - refused another. The audit namespace mirrors `*` and cluster-scoped resources. The chart's default - `local` provider behaves as today. - -## Open questions - -1. **Branch-count limits.** A `teams/{namespace}/*` grant lets a team create any number of - `GitBranch`es, each a worker with a clone. Exact names cap the count. Is a per-grant limit needed? -2. **`parentBranch` reads outside the grants.** A team's branch can be created from `main` although - the team may not claim `main`. That looks intended, because it is how a feature branch starts. - Confirm it, and document it. -3. **Path isolation.** Grants are per branch. Per-folder grants inside one branch stay out of scope; - the overlap check only stops two targets claiming the same paths. -4. **Recreated namespaces.** A `names` grant admits a recreated namespace of the same name. That is - stated as a boundary above. Bind to the UID only if someone needs it. +## Decided + +The questions earlier versions left open, closed without a new grant field: + +| Question | Decision | Why | +|---|---|---| +| Branch-count limits | No per-grant limit; a namespace `ResourceQuota` (`count/gitbranches.configbutler.ai: "20"`) | Quota and authorization have different owners. It bounds `GitBranch` objects in a namespace, across providers; repository size and churn stay unbounded. Exact branch names in a grant cap the distinct branches, while competing `GitBranch` objects for one branch stay uncounted | +| `parentBranch` outside the claim grant | Allowed, as far as the provider's credential can read | Creating `feature/x` from `main` should not need the right to claim `main`. The confidentiality boundary above says what follows | +| Path isolation | Out of scope | The delegated unit is the branch; the overlap check coordinates writers | +| Recreated namespaces | `names` grants stay name-based, with no UID binding | It fits declarative provisioning; reusing a namespace is a trusted administrative act | + +## Tests to add + +The checklist for the PR. Each line is a behavior a later change could break without noticing. +Unit tests cover the matcher and the admission functions. Watch-driven tests run in envtest, driven +by watch events: a direct `Reconcile` call proves no event wiring. e2e covers what needs a Git +server and an applier. + +**Patterns and CEL** (unit, plus CEL tests on the CRD): + +- [ ] `teams/{namespace}/*` for `team-a` matches `teams/team-a/live` and **does not** match + `teams/team-ab/live`. +- [ ] `teams/{namespace}*` is refused at admission (`{namespace}` is not a whole segment). +- [ ] `feature/*` matches `feature/checkout/v2`; `*` matches every branch, `main` included. +- [ ] `teams/*` matches `teams/a/b` (`*` crosses `/`); `main` does not match `main-old` (whole name). +- [ ] `teams/{ns}/*` is refused, and the message names `{ns}` and `{namespace}`. +- [ ] `[]` is refused for `names`, `branches`, `sourceNamespaces` and `applierNamespaces`; a grant + with neither `names` nor `selector` is refused; a `KubeProvider` `gitTargetsFrom` grant with + neither `sourceNamespaces` nor `clusterScoped: true` is refused. +- [ ] `names` and `selector` combine by OR; `selector: {}` matches every namespace. + +**Admission** (unit, `internal/authz`): + +- [ ] The own namespace is refused when no grant names it, on all four lists. For a `GitBranch`, + that includes a `GitTarget` in the `GitBranch`'s own namespace. +- [ ] `applierNamespaces: [flux-system]` refuses a claim on an applier in the claim's own + namespace. An explicit list is the whole scope. +- [ ] `sourceNamespaces: ["{namespace}"]` refuses `sourceNamespace: "*"`, and `["*"]` allows it. +- [ ] Mirroring allowed, applying denied, on one `KubeProvider`, with the `GitTarget` and its applier + claim in one namespace. Today's `ApplierAdmitted` shortcut would allow the apply. +- [ ] A `ClusterWatchRule` is refused through a target whose grant lacks `clusterScoped`, and + compiles once it is set. +- [ ] Refusal messages name the grants that apply and the requested value, for each act. + +**Claim and credential** (unit and envtest): + +- [ ] An unadmitted `GitBranch` never opens a push session with the shared credential. +- [ ] A `GitBranch` admitted later becomes a candidate. If it is older than the holder, it takes the + branch (`creationTimestamp`, then UID), also after a restart. +- [ ] A cross-namespace `GitBranch` pushes with the platform provider's Secret, both when its own + namespace has no provider and when it has a same-name provider with a different credential. +- [ ] Receiver routing still wakes the worker of a cross-namespace `GitBranch`. + +**Revocation** (envtest, driven by watch events): + +- [ ] Removing a namespace from `GitBranch.gitTargetsFrom` refuses that namespace's `GitTarget`s; + adding it back admits them. +- [ ] Scope narrowed while admission stays: a namespace matched by two grants loses the label of the + one granting `"*"`. Its `WatchRule`s stop watching the other namespaces, and the `GitTarget` + stays admitted. This is the new `WatchRule` Namespace watch. +- [ ] A label removed from a claimant's namespace revokes its `GitBranch` through the new + `GitBranch` Namespace watch, and its `GitTarget`s in other namespaces follow. +- [ ] A revoked `GitBranch`'s worker drains what it held when the revocation was observed. An event + arriving afterwards is refused. The successor starts only after the old worker retires, and + receives none of its work. +- [ ] One grant edit revokes two candidates; neither holds the branch afterwards. +- [ ] An applier claim denied by `applierClaimsFrom` hands its applier back. A read error on the + `KubeProvider` keeps the hold. + +**Chart and end to end:** + +- [ ] A default install grants nothing: a `GitTarget` through `local` is refused with the message + that names `kubeProvider.gitTargetsFrom`. +- [ ] `quickstart.enabled=true` mirrors the quickstart namespace and nothing else. +- [ ] `values-playground.yaml` lets any namespace mirror its own namespace, and no other. +- [ ] e2e: a team claims `teams//live` through a platform provider and writes it, then shares it + with a second team. Claims on `main` and on another team's branch are refused with their + messages. From 8688825a86a4bd0aabcee2661edc6c9ec0d38d28 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 16:17:36 +0000 Subject: [PATCH 04/34] docs: add a platform example, and state identity and hand-back precisely Fold in the fourth review of the access plan, read against Flux's own sources: - Say plainly that explicit same-namespace consent is stricter than Flux's ACL, and why. - Every operation runs as the provider's identity: what a source or applier grant permits, and what bounds it instead (the provider's read access, the applier's service account; targetNamespace only places). - Add a platform and tenant example: the platform's reconciler owns providers, grants, labels and RBAC; a tenant's Flux reconciler may only create the objects that use them, which is what keeps the grants and the one-provider limit holding. - The applier hand-back promises retries and visible outcomes, keeps a prior suspension, and takes the existing retire path with its deadline. - Controller refusals name API fields, not Helm values. - Test recovery as well as refusal, including after a restart. Shorten the interim workaround section. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/future/provider-access-rules.md | 210 +++++++++++++++++---------- 1 file changed, 134 insertions(+), 76 deletions(-) diff --git a/docs/future/provider-access-rules.md b/docs/future/provider-access-rules.md index 09d99924..9e049a49 100644 --- a/docs/future/provider-access-rules.md +++ b/docs/future/provider-access-rules.md @@ -5,11 +5,9 @@ > replaces three drafts: `docs/reviews/20261023-gitbranch-claims-from-other-namespaces.md` (the > proposal), `docs/reviews/20261023-gitbranch-claims-review.md` (its review), and > `docs/future/gitprovider-branch-rules.md`. It follows up items 1 and 2 of "Still missing" in the -> [one-writer-per-branch status](../design/one-writer-per-branch-plan.md). It includes three reviews -> of 2026-10-23. The second made every grant explicit, made claiming a branch include sharing it, -> and dropped the phases. The third settled the chart default, the branch pattern language, the -> revocation promise and the open questions. [Tests to add](#tests-to-add) is the checklist for the -> PR. +> [one-writer-per-branch status](../design/one-writer-per-branch-plan.md). Four reviews of +> 2026-10-23 shaped it, the last read against Flux's own sources; the permission model is settled. +> [Tests to add](#tests-to-add) is the checklist for the PR. ## The goal @@ -42,9 +40,12 @@ later rename breaks released manifests. If the rename lands first, those names a Every reference in `api/v1alpha3`, and whether it needs a grant. **An object that lends something lists every grant explicitly: a credential, a branch, or reach into other namespaces.** A -reference that stays in its own namespace and lends nothing needs no grant. Kubernetes draws the -same line. A Pod mounts a Secret in its own namespace without consent, and Gateway API asks for a -`ReferenceGrant` only when a reference crosses namespaces. +reference that stays in its own namespace and lends nothing needs no grant. + +This is stricter than Flux on purpose. Flux keeps consent on the referenced object too (`accessFrom`), +but its ACL helper admits every same-namespace reference. GitOps Reverser requires explicit consent +for provider and branch use, also within one namespace. Administering a provider and creating its +consumers can then carry different RBAC permissions. | Reference | What it lends | Grant | |---|---|---| @@ -261,9 +262,10 @@ The installer chooses which namespaces may use the operator's identity; the char them. - **Default install: empty grant lists.** Nothing mirrors or holds an applier through `local` until - values grant it. A `GitTarget` refused by the empty default says how to fix it: "KubeProvider - `gitops-reverser/local` grants no namespace; set `kubeProvider.gitTargetsFrom` in the chart - values". + values grant it. The controller's refusal names the API field, because the + same provider may be managed with Kustomize: "KubeProvider `gitops-reverser/local` admits no + namespace in `spec.gitTargetsFrom`". The chart docs map that field to + `kubeProvider.gitTargetsFrom`. - **`quickstart.enabled=true`** also renders a grant for exactly the quickstart namespace, so the [quickstart](../quickstart.md) stays one `helm install`: @@ -302,26 +304,80 @@ configuration docs keep that warning for `sourceNamespaces`, `clusterScoped` and State these in the configuration docs, beside the grants: -- **A namespace grant trusts that namespace's administrators.** Anyone allowed to create the - referring kind there can use the grant. Labels used in a `selector` must be controlled by trusted - administrators. Kubernetes treats namespace isolation the same way: it comes from - [RBAC and other controls](https://kubernetes.io/docs/concepts/security/multi-tenancy/), not from - namespace names alone. A `names` grant also admits a namespace that is deleted and recreated with - the same name. -- **Controlling an applier is not limiting its workloads.** `applierNamespaces` bounds where the - `Kustomization` or `Application` lives. What it deploys, and where, is its own configuration and - credentials: a Flux `Kustomization`'s `serviceAccountName` and `targetNamespace`, or an Argo CD - `AppProject`'s destinations. -- **The repository's authority must be trusted.** The model assumes platform administrators manage - `GitProvider`s, while tenants manage `GitBranch`es and `GitTarget`s. If tenants may also create - providers, see [the limit](#the-limit-another-provider-on-the-same-repository): grants on one - provider cannot stop a competing claim through another. +- **Every operation runs as the provider's identity, never as the referrer's.** Grants decide who + may use that identity, and for what. They are not Flux-style impersonation, and this PR does not + add impersonation. + - A source-namespace grant permits mirroring the resource kinds the provider identity can read in + that namespace. It does not reproduce the referring user's Kubernetes permissions. + - An applier-namespace grant permits controlling matching appliers in that namespace. The + applier's own execution identity and RBAC bound what it deploys: a Flux `Kustomization`'s + `serviceAccountName`, or an Argo CD `AppProject`'s destinations. `targetNamespace` only places + the workloads; the service account's permissions authorize them. +- **A namespace grant trusts that namespace's administrators.** Anyone, or any reconciler, allowed + to create the referring kind there can use the grant. Labels used in a `selector` must be + controlled by trusted administrators. A `names` grant also admits a namespace that is deleted and + recreated with the same name. - **Branch grants restrict claiming and writing. They do not make branches confidential from each - other.** A `GitBranch`'s `parentBranch` may name any branch the provider's credential can read, - granted or not, because creating `feature/x` from `main` should not need the right to claim - `main`. A permitted parent's contents can therefore be copied into the delegated branch. -- **The unit a grant delegates is the branch.** Inside one branch, the folder overlap check - coordinates writers; it is not a boundary between tenants. + other.** A `parentBranch` may name any branch the provider's credential can read, so a permitted + parent's contents can be copied into the delegated branch. Inside one branch, the folder overlap + check coordinates writers; it is not a boundary between tenants. + +## A platform and its tenants + +The model assumes one split, and it is enforced by RBAC: platform administrators manage providers, +grants and namespace labels, and tenants manage what uses them. With Flux, objects are applied by a +reconciler, so what counts is what each reconciler's identity may create. The platform's +reconciler applies: + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: team-a + labels: {platform.example.com/tenant: "true"} # the label the grants select on +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: {name: gitops-reverser-tenant} +rules: + - apiGroups: [configbutler.ai] + resources: [gitbranches, gittargets, watchrules, fluxclaims, argocdclaims, commitrequests] + verbs: [get, list, watch, create, update, patch, delete] +--- +apiVersion: v1 +kind: ServiceAccount +metadata: {name: tenant-reconciler, namespace: team-a} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: {name: tenant-reconciler, namespace: team-a} +roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: gitops-reverser-tenant} +subjects: [{kind: ServiceAccount, name: tenant-reconciler, namespace: team-a}] +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: {name: team-a, namespace: team-a} +spec: + serviceAccountName: tenant-reconciler # team-a's repository is applied as this identity + sourceRef: {kind: GitRepository, name: team-a} + path: ./ + interval: 10m + prune: true +``` + +The platform's reconciler also applies the `GitProvider` and `KubeProvider` above, with their +grants, and the `GitBranch` for `main`. The Role also needs whatever team-a's own workloads require. + +| Identity | Manages | Cannot | +|---|---|---| +| The platform's reconciler | `GitProvider`, `KubeProvider` and their grants; Namespaces and their labels; tenant RBAC; platform `GitBranch`es such as `main` | | +| `team-a/tenant-reconciler` | `GitBranch`, `GitTarget`, `WatchRule`, applier claims and `CommitRequest`s in `team-a` | Create or edit a provider; label a Namespace; change RBAC | + +The last column is what makes the grants hold. A tenant cannot grant itself a label, and cannot +create the second provider that [the limit](#the-limit-another-provider-on-the-same-repository) +describes. Flux's multi-tenancy lockdown (`--no-cross-namespace-refs` and +`--default-service-account` on kustomize-controller) makes the tenant identity mandatory, so no +tenant `Kustomization` falls back to the controller's own. ## Revocation @@ -353,15 +409,27 @@ reports `Stalled=True`, reason `NamespaceNotAuthorized`. Writes can therefore la server accepts a policy edit. Git cannot take back a push that is already sent, and the docs should promise no more than that. -**An applier claim that loses `applierClaimsFrom` hands its applier back.** Today it does not: a -claim that stops being `Configured` leaves its `Kustomization` suspended under GitOps Reverser's -field manager. It hands back only once no eligible `GitTarget` references the claim, or the claim -is deleted (`applierclaim_controller.go`). After this change, an explicit denial stops new apply -requests and follows the existing hand-back. That costs one final write through the revoked -credential, so an applier is never left suspended forever. A transient read error still keeps the -hold; the controller already tells the two apart (`kept` against a denial). Once the applier is -handed back, Flux or Argo CD resumes, and may apply Git over live edits not mirrored yet. That is -what losing control of an applier means. +**An applier claim that loses `applierClaimsFrom`:** + +> Revocation stops new apply requests and starts handing back the existing hold. The hand-back +> restores the recorded prior state and leaves other owners' declarations alone. It may need retries +> and may fail; its outcome is visible in status and Events. + +Today a claim that stops being `Configured` keeps its `Kustomization` suspended, and hands back only +once no eligible `GitTarget` references it or it is deleted. A denial now takes the same +[`retire`](../../internal/controller/fluxclaim_hold.go) path as "no eligible `GitTarget`". That path: + +- keeps the claim the holder (`Held=False`, `Releasing`) while it hands back; +- keeps a `Kustomization` that was suspended before adoption suspended + (`status.previousSuspendState`); +- gives up after `releaseDeadline` (10 minutes) with a Warning Event, as it already does for a live + claim. It may be unable to finish: the Secret is gone, the identity lost its RBAC, or the cluster + is unreachable. + +A regrant during the hand-back lets it finish, then adopts the applier anew. A read error never +starts a hand-back; the controller already tells it apart from a denial (`kept` against +`falseCheck`). Once the applier is resumed, Flux or Argo CD may apply Git over live edits not +mirrored yet. That is what losing control of an applier means. Two alternatives were rejected: @@ -410,16 +478,11 @@ State the limit wherever the grants are documented: > `GitBranch`. They cannot stop a claim on the same repository branch through another provider > whose credential opens a push session. -Under the trust boundary above, platform administrators create providers, so this needs a copied -credential. If tenants must also create arbitrary providers, one governing authority per repository -becomes part of the design. Either of these would do: - -- **A cluster-scoped object per repository URL.** This is option C of the original one-writer - plan: `ClusterGitProvider`, or `GitCredential`. -- **A cluster-level rule that names one governing provider.** For a repository it names, branches - may only be claimed through that `GitProvider`. - -Neither exists. +In the [platform example](#a-platform-and-its-tenants), tenants cannot create providers, so a +competing claim needs someone with provider rights. If tenants must create their own providers, one +governing authority per repository becomes part of the design: a cluster-scoped object per +repository URL (option C of the original one-writer plan), or a cluster-level rule naming the one +provider through which a repository's branches may be claimed. Neither exists. ## The change, surface by surface @@ -433,11 +496,11 @@ docs, samples, the chart and e2e fixtures. | Accessors | `GitProvider.AdmitsGitBranch(ns, labels, branch)`, `GitBranch.AdmitsGitTarget(ns, labels)`, `KubeProvider.AdmitsGitTarget` and `AdmitsApplierClaim`, each returning the scope that applies. They replace `AdmitsNamespace`, `AllowsCrossNamespaceSources` and `AllowsCrossNamespaceAppliers`. One pattern matcher, with `{namespace}` expansion, beside `NamespaceMatcher` | | `internal/authz` | Every own-namespace shortcut goes: `gitBranchAdmitted`'s and `namespaceAdmitted`'s, and **`ApplierAdmitted`'s**, which returns early when the claim and the target share a provider and a namespace. It must now check `applierClaimsFrom` on its own. `source_namespace.go` checks `sourceNamespaces`, and `watchrule_compile.go` checks `clusterScoped`. Messages follow [refusal messages](#refusal-messages-name-the-grant) | | Claim and worker | `GitBranch` admission is checked before proof and candidacy; revocation goes through `finalize` without removing the finalizer; `WorkerRequest` carries the resolved provider namespace (see implementation notes) | -| Applier claims | An explicit admission denial hands the applier back; a transient error keeps the hold | +| Applier claims | An explicit admission denial takes the `retire` path; a read error keeps the hold | | Controllers and watches | `WatchRule` and `GitBranch` get Namespace watches; `GitBranch`es are indexed by resolved provider key (see implementation notes) | | CRDs and chart | Regenerate. The chart's `local` provider gets empty grant lists by default, a grant for the quickstart namespace under `quickstart.enabled`, and `values-playground.yaml` (see [the chart's `local` provider](#the-charts-local-provider)). `config/kubeprovider-local.yaml` and the e2e install grant what the suite needs; samples | | `hack/crdfields` | The field inventory | -| Docs | `configuration.md` (sharing section, the semantics table, `GitProvider`, `GitBranch`, `KubeProvider`, the boundaries), `security-model.md`, `rbac.md`, `quickstart.md`, `components.md`, `architecture.md`, the bi-directional and attribution guides, `docs/spec` | +| Docs | `configuration.md` (sharing section, the semantics table, `GitProvider`, `GitBranch`, `KubeProvider`, the boundaries, the platform example), `security-model.md`, `rbac.md`, `quickstart.md`, `components.md`, `architecture.md`, the bi-directional and attribution guides, `docs/spec` | | `definitions.md` | Remove `spec.accessFrom` from the fields mirrored from Flux word for word, and rewrite the line saying a provider's "`spec.accessFrom` admits the others". Add "grant". The `NamespaceMatcher` comment ("One field uses it") is already stale | | `gitbranch-and-kubeprovider-plan.md` | Its "no `accessFrom` of its own" and "one policy governs both hops" for `GitBranch` are superseded; point there from here | | `UPGRADING.md` | One entry: `allowedBranches` is replaced by `gitBranchesFrom`; every `GitBranch`, `GitTarget` and applier claim now needs a grant, also in the provider's own namespace; the chart's `local` provider grants nothing unless values or the quickstart do; and a `ClusterWatchRule` needs `clusterScoped` | @@ -446,25 +509,13 @@ docs, samples, the chart and e2e fixtures. The worker and the Git write path change, so this is the high-risk case in `AGENTS.md`: run local e2e before pushing. -## What works today, before any of this +## Until this lands -Nothing refuses two `GitProvider`s for one URL in one namespace, so a platform can already isolate -teams on one repository with one Secret. It creates one provider per team, and creates the -`GitBranch`es itself: - -```yaml -kind: GitProvider -metadata: {name: repo-team-a, namespace: platform} -spec: - url: https://git.example.com/platform/config.git - secretRef: {name: repository-auth} # the one Secret every team's provider shares - allowedBranches: ["teams/team-a/*"] - accessFrom: {names: [team-a]} -``` - -The example in [configuration.md](../configuration.md#sharing-connections-across-namespaces) has -one provider with `accessFrom: {names: [team-a, team-b]}`, which lets each team write the other's -branch. Until this lands, the page should say so, or show the per-team form. +Today a platform isolates teams on one repository by creating one `GitProvider` per team, all +sharing one Secret, each with its own `allowedBranches` and `accessFrom`, and creating the +`GitBranch`es itself. The example in +[configuration.md](../configuration.md#sharing-connections-across-namespaces) uses one provider for +two teams, which lets each write the other's branch; the page should say so. ## Alternatives considered @@ -503,12 +554,7 @@ branch. Until this lands, the page should say so, or show the per-team form. - **Credential resolution.** [`WorkerRequest.provider()`](../../internal/git/worker_manager.go) builds the provider key from `GitBranch.Namespace`. The resolved provider namespace has to be carried through - `gitDestination`, `WorkerRequest` and worker creation. Test two cases: no provider in the tenant - namespace, and a same-name provider there holding a different credential. Both must use the - platform provider and its Secret. -- **Applier hand-back on denial.** `ApplierClaimReconciler` reports a denial through `falseCheck` and - a read error through `kept`. Only the denial goes to the hand-back that "no eligible `GitTarget`" - takes today. + `gitDestination`, `WorkerRequest` and worker creation. - **One admission function per act, in `internal/authz`.** Each returns the decision, the scope that applies, and the grants for the refusal message. Call the `GitBranch` one before proof and candidacy. Keep distinct reasons for each case: reference missing, namespace not admitted, branch @@ -583,11 +629,23 @@ server and an applier. - [ ] One grant edit revokes two candidates; neither holds the branch afterwards. - [ ] An applier claim denied by `applierClaimsFrom` hands its applier back. A read error on the `KubeProvider` keeps the hold. +- [ ] A `Kustomization` suspended before adoption stays suspended after a denial's hand-back. +- [ ] A hand-back that cannot finish (the identity lost its RBAC) shows in status and a Warning + Event, and gives up at `releaseDeadline`. +- [ ] A regrant during the hand-back lets it finish, then adopts the applier anew. + +**Recovery** (envtest; fixing the grant must be enough, with no consumer edited): + +- [ ] Restoring a grant re-admits, each through its watch: a `GitBranch` (it proves itself and + claims), a namespace's source scope, `clusterScoped` for a `ClusterWatchRule`, and an applier + claim (it adopts its applier). +- [ ] The same after a controller restart: restored grants are read fresh, and nothing revoked + earlier stays refused. **Chart and end to end:** - [ ] A default install grants nothing: a `GitTarget` through `local` is refused with the message - that names `kubeProvider.gitTargetsFrom`. + that names `spec.gitTargetsFrom`. - [ ] `quickstart.enabled=true` mirrors the quickstart namespace and nothing else. - [ ] `values-playground.yaml` lets any namespace mirror its own namespace, and no other. - [ ] e2e: a team claims `teams//live` through a platform provider and writes it, then shares it From 51e7654daf8202932147baac06fc01f4cd47b2b3 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 16:23:49 +0000 Subject: [PATCH 05/34] docs: say where a grant admits from with an enum, and drop booleans Every grant gets a required from: Same, Names, Selector or All, after Gateway API's allowedRoutes.namespaces.from. Same admits the object's own namespace without naming it, so a kustomize overlay that moves the object keeps working; All makes admitting everyone visible. One grant has one source, and the old names-OR-selector rule goes with NamespaceMatcher. clusterScoped: true becomes clusterResources, a list like sourceNamespaces, so it can narrow to resource names. names may not hold {namespace}, which would admit every referrer. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/future/provider-access-rules.md | 132 +++++++++++++++++---------- 1 file changed, 85 insertions(+), 47 deletions(-) diff --git a/docs/future/provider-access-rules.md b/docs/future/provider-access-rules.md index 9e049a49..12dce7d7 100644 --- a/docs/future/provider-access-rules.md +++ b/docs/future/provider-access-rules.md @@ -51,11 +51,11 @@ consumers can then carry different RBAC permissions. |---|---|---| | `GitBranch.gitProviderRef` → `GitProvider` | A credential, and a branch of its repository | **`GitProvider.gitBranchesFrom`**, scoped by `branches` | | `GitTarget.gitBranchRef` → `GitBranch` | Writing a folder into the branch | **`GitBranch.gitTargetsFrom`** | -| `GitTarget.kubeProviderRef` → `KubeProvider` | Reading a cluster with its credential | **`KubeProvider.gitTargetsFrom`**, scoped by `sourceNamespaces` and `clusterScoped` | +| `GitTarget.kubeProviderRef` → `KubeProvider` | Reading a cluster with its credential | **`KubeProvider.gitTargetsFrom`**, scoped by `sourceNamespaces` and `clusterResources` | | `FluxClaim` / `ArgoCDClaim` `.kubeProviderRef` → `KubeProvider` | Control over an applier, with its credential | **`KubeProvider.applierClaimsFrom`**, scoped by `applierNamespaces` | | `FluxClaim.kustomizationRef`, `ArgoCDClaim.applicationRef` → the applier in the source cluster | (points into a cluster whose objects we do not own) | Bounded by `applierNamespaces` | | `WatchRule.rules[].sourceNamespace` → a source-cluster namespace | (same) | Bounded by `sourceNamespaces` | -| `ClusterWatchRule.gitTargetRef` → a `GitTarget` in any namespace | Cluster-scoped reads through that target's `KubeProvider` | Bounded by `clusterScoped` on the grant that admits the target | +| `ClusterWatchRule.gitTargetRef` → a `GitTarget` in any namespace | Cluster-scoped reads through that target's `KubeProvider` | Bounded by `clusterResources` on the grant that admits the target | | `WatchRule.gitTargetRef`, `CommitRequest.gitTargetRef` (own namespace only) | Nothing: what flows in is bounded by the `GitTarget`'s own grants | None | | `GitTarget.applierClaimRef` (own namespace only) | Nothing new: the applier claim is already bound to one applier by its own grant | None | | `secretRef`, `knownHostsRef` (own namespace only) | The Kubernetes convention for Secrets | None | @@ -77,7 +77,8 @@ Every grant answers one question: **who may use this object, and for what?** | How do several grants combine? | Union; order never matters | | What does an omitted scope mean? | The grant is refused where a scope is required | | Who controls the grant? | Whoever may edit the referenced object | -| Who owns a grant list? | One policy owner per object: every grant list is `+listType=atomic`, and `names`, `branches`, `sourceNamespaces` and `applierNamespaces` are `+listType=set` | +| Who owns a grant list? | One policy owner per object: every grant list is `+listType=atomic`, and `names`, `branches`, `sourceNamespaces`, `clusterResources` and `applierNamespaces` are `+listType=set` | +| Are there booleans? | No. A grant's source is an enum (`from`), and every scope is a list, so either can grow a value later without a new field ([API conventions](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md): think twice about bool fields) | The field is named after the referring kind, or after a family of kinds the API already defines. "Applier claim" is such a family: there is no `ApplierClaim` kind, but a `GitTarget` points at one @@ -90,21 +91,48 @@ This means a little more YAML and fewer hidden rules. It replaces the phase 1 de `GitBranch` has no access list and its provider's `accessFrom` "governs both hops". The provider decides who may claim, and the branch decides who may write. +### Where a grant admits from + +Every grant has a required `from`, as a Gateway API listener's `allowedRoutes.namespaces.from`: + +```yaml +gitTargetsFrom: + - from: Same # this object's own namespace, whatever it is named + - from: Names + names: [team-b] + - from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} +``` + +| `from` | Admits | Requires | +|---|---|---| +| `Same` | The referenced object's own namespace | nothing else | +| `Names` | Exactly these control-cluster namespaces | `names` | +| `Selector` | Control-cluster namespaces whose labels match | `selector` | +| `All` | Every namespace | nothing else | + +CEL refuses `names` without `Names` and `selector` without `Selector`. "Team-b or every tenant" is +two grants, combined by union; one grant never combines two sources. `Same` is how an object admits +its own namespace without writing its name. A kustomize overlay that sets `namespace:` does not +rewrite a name inside `names`, so a literal own-namespace name breaks silently; `Same` does not. +`Names` has no Gateway API counterpart, which matches by the `kubernetes.io/metadata.name` label; +a plain list reads better. `All` replaces the old `selector: {}`, so admitting everyone is a word a +reviewer sees. + ### Semantics, field by field | Field | Omitted | `[]` | A value | `"*"` | |---|---|---|---|---| | A grant list | No access | No access | Grants, combined by union | Not a value of a list | -| `names` | Not matched by name | Refused (CEL) | Exact control-cluster namespace names | Refused: use `selector: {}` | -| `selector` | Not matched by label | (not a list) | Label selector on control-cluster Namespaces | `selector: {}` matches every namespace | -| `names` and `selector` | A grant matches a namespace in `names` **or** matching `selector`; both omitted is refused (CEL) | | | | +| `from` | **Refused**: required | (not a list) | `Same`, `Names`, `Selector` or `All` | Refused: use `All` | +| `names` (with `Names`) | Refused | Refused | Exact control-cluster namespace names; `{namespace}` is refused, because it would admit every referrer (use `Same` for the own namespace) | Refused: use `All` | | `GitProvider.gitBranchesFrom[].branches` | **Refused**: required | Refused | Branch patterns (see [patterns](#patterns)) | Every branch | | `KubeProvider.gitTargetsFrom[].sourceNamespaces` | No namespaces | Refused | Exactly these source-cluster namespaces | Every namespace; also allows `rules[].sourceNamespace: "*"` | -| `KubeProvider.gitTargetsFrom[].clusterScoped` | `false` | (not a list) | `true`: may receive cluster-scoped resources from a `ClusterWatchRule` | (not a list) | +| `KubeProvider.gitTargetsFrom[].clusterResources` | No cluster-scoped resources | Refused | Exactly these cluster-scoped resources (`clusterroles`, `customresourcedefinitions.apiextensions.k8s.io`), which a `ClusterWatchRule` may mirror through the target | Every cluster-scoped resource | | `KubeProvider.applierClaimsFrom[].applierNamespaces` | **Refused**: required | Refused | Exactly these namespaces | Every namespace | | `GitBranch.gitTargetsFrom[]` | no scope field | | | | -A `KubeProvider` `gitTargetsFrom` grant must set `sourceNamespaces`, `clusterScoped: true`, or +A `KubeProvider` `gitTargetsFrom` grant must set `sourceNamespaces`, `clusterResources`, or both. CEL refuses one that grants nothing. **An explicit list is the whole scope, never "these plus your own".** `applierNamespaces: [flux-system]` means `flux-system` only. `["*"]` is a deliberate, broad grant. @@ -145,7 +173,7 @@ has one variable syntax. The subject differs, and the configuration docs say so In a placement template `{namespace}` is the mirrored resource's namespace, and can render `_cluster`. In a grant it is the referring object's, which always exists. Placement's `|fallback`, its sanitizing and its empty-segment dropping do not carry over: they exist to render a path, and a -grant renders nothing. The matcher is a few lines in `api/v1alpha3` beside `NamespaceMatcher`. It +grant renders nothing. The matcher is a few lines in `api/v1alpha3`. It does not reuse the placement parser, which carries fourteen variables, fallbacks and label keys. ### Refusal messages name the grant @@ -172,9 +200,10 @@ spec: url: https://git.example.com/platform/config.git secretRef: {name: repository-auth} gitBranchesFrom: - - names: [platform] + - from: Same branches: [main, "release/*"] - - selector: {matchLabels: {platform.example.com/tenant: "true"}} + - from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} branches: ["teams/{namespace}/*", "feature/*"] ``` @@ -187,8 +216,6 @@ spec: means the `GitBranch`'s own. CEL's `self == oldSelf` treats an omitted namespace and an explicit own namespace as different values. Default the field at admission, or compare the resolved namespace in the immutability rule. -- **Naming the own namespace.** `names: [platform]` is a literal name, which a kustomize overlay - that changes the namespace does not rewrite. A label `selector` avoids that. ## `GitBranch`: who may write through it @@ -199,7 +226,7 @@ spec: gitProviderRef: {name: shared-repo, namespace: platform} branch: teams/team-a/live gitTargetsFrom: - - names: [team-a] + - from: Same --- kind: GitBranch metadata: {name: main, namespace: platform} @@ -207,16 +234,17 @@ spec: gitProviderRef: {name: shared-repo} branch: main gitTargetsFrom: - - names: [release-team] + - from: Names + names: [release-team] ``` - **Claiming a branch delegates it, including the right to share it.** The platform decides which branches it delegates, and the team decides who collaborates there. Sharing `teams/team-a/live` - with `team-b` means adding `team-b` to this list; the provider and its credential do not change. + with `team-b` means adding a `from: Names` grant for it; the provider and its credential do not change. - **To keep control of the writers, the platform owns the `GitBranch`,** as for `main` above. There is no provider-level writer policy beside this one. That would turn delegation into two policies that intersect. -- **The `GitBranch`'s own namespace needs a grant too.** `names: [team-a]` above is not implied. +- **The `GitBranch`'s own namespace needs a grant too.** `from: Same` above is not implied. - **`gitTargetsFrom` is mutable.** `gitProviderRef`, `branch` and `parentBranch` stay immutable, because together they are the branch's identity. The type comment "every field is immutable" changes with it. @@ -232,23 +260,26 @@ metadata: {name: production, namespace: platform} spec: secretRef: {name: production-auth} gitTargetsFrom: - - selector: {matchLabels: {platform.example.com/tenant: "true"}} + - from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} sourceNamespaces: ["{namespace}"] - - names: [platform-audit] + - from: Names + names: [platform-audit] sourceNamespaces: ["*"] - clusterScoped: true + clusterResources: ["*"] applierClaimsFrom: - - selector: {matchLabels: {platform.example.com/tenant: "true"}} + - from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} applierNamespaces: ["{namespace}"] - - names: [platform] + - from: Same applierNamespaces: [flux-system] ``` - **`gitTargetsFrom`** admits a `GitTarget` to mirror through this provider. Its `sourceNamespaces` bound what the `WatchRule`s feeding that target may watch. A `WatchRule` feeds a `GitTarget` in its own namespace. -- **`clusterScoped: true`** lets a `ClusterWatchRule` mirror cluster-scoped resources through that - `GitTarget`. Today a `ClusterWatchRule` needs only the target's admission. Only a cluster admin can +- **`clusterResources`** lets a `ClusterWatchRule` mirror those cluster-scoped resources through + that `GitTarget`. Today a `ClusterWatchRule` needs only the target's admission. Only a cluster admin can create one, but without this field `sourceNamespaces: ["{namespace}"]` would look like the whole reach and would not be. - **`applierClaimsFrom`** admits an applier claim, and its `applierNamespaces` bound where the @@ -271,10 +302,12 @@ them. ```yaml gitTargetsFrom: - - names: [gitops-reverser-quickstart-demo] + - from: Names + names: [gitops-reverser-quickstart-demo] sourceNamespaces: ["{namespace}"] applierClaimsFrom: - - names: [gitops-reverser-quickstart-demo] + - from: Names + names: [gitops-reverser-quickstart-demo] applierNamespaces: ["{namespace}"] ``` @@ -284,20 +317,20 @@ them. ```yaml kubeProvider: gitTargetsFrom: - - selector: {} + - from: All sourceNamespaces: ["{namespace}"] applierClaimsFrom: - - selector: {} + - from: All applierNamespaces: ["{namespace}"] ``` -- **No default grants `clusterScoped`,** the playground included. A `ClusterWatchRule` through +- **No default grants `clusterResources`,** the playground included. A `ClusterWatchRule` through `local` stops mirroring until a grant sets it. That is an intentional restriction, and `UPGRADING.md` says so. The `inCluster` arm's consent (`--in-cluster-kube-providers`) is unchanged. A scope granted on an `inCluster` provider still bypasses live namespace RBAC through the operator's identity, so the -configuration docs keep that warning for `sourceNamespaces`, `clusterScoped` and +configuration docs keep that warning for `sourceNamespaces`, `clusterResources` and `applierNamespaces`. ## Boundaries the grants do not replace @@ -314,8 +347,8 @@ State these in the configuration docs, beside the grants: `serviceAccountName`, or an Argo CD `AppProject`'s destinations. `targetNamespace` only places the workloads; the service account's permissions authorize them. - **A namespace grant trusts that namespace's administrators.** Anyone, or any reconciler, allowed - to create the referring kind there can use the grant. Labels used in a `selector` must be - controlled by trusted administrators. A `names` grant also admits a namespace that is deleted and + to create the referring kind there can use the grant. Labels used by a `Selector` grant must + be controlled by trusted administrators. A `Names` grant also admits a namespace that is deleted and recreated with the same name. - **Branch grants restrict claiming and writing. They do not make branches confidential from each other.** A `parentBranch` may name any branch the provider's credential can read, so a permitted @@ -492,18 +525,18 @@ docs, samples, the chart and e2e fixtures. | Surface | Change | |---|---| -| `api/v1alpha3` | `GitProvider`: drop `AccessFrom` and `AllowedBranches`; add `GitBranchesFrom []GitBranchGrant`. `GitBranch`: `gitProviderRef` becomes namespaced; add `GitTargetsFrom []NamespaceGrant`. `KubeProvider`: drop `AccessFrom` and `AllowCrossNamespace`; add `GitTargetsFrom []SourceGrant` and `ApplierClaimsFrom []ApplierGrant`. Each grant embeds `NamespaceMatcher` inline, with the CEL rules from the semantics table. Doc comments on `GitTarget`, `GitBranch`, `FluxClaim` and `ArgoCDClaim` that say `accessFrom` | -| Accessors | `GitProvider.AdmitsGitBranch(ns, labels, branch)`, `GitBranch.AdmitsGitTarget(ns, labels)`, `KubeProvider.AdmitsGitTarget` and `AdmitsApplierClaim`, each returning the scope that applies. They replace `AdmitsNamespace`, `AllowsCrossNamespaceSources` and `AllowsCrossNamespaceAppliers`. One pattern matcher, with `{namespace}` expansion, beside `NamespaceMatcher` | -| `internal/authz` | Every own-namespace shortcut goes: `gitBranchAdmitted`'s and `namespaceAdmitted`'s, and **`ApplierAdmitted`'s**, which returns early when the claim and the target share a provider and a namespace. It must now check `applierClaimsFrom` on its own. `source_namespace.go` checks `sourceNamespaces`, and `watchrule_compile.go` checks `clusterScoped`. Messages follow [refusal messages](#refusal-messages-name-the-grant) | +| `api/v1alpha3` | `GitProvider`: drop `AccessFrom` and `AllowedBranches`; add `GitBranchesFrom []GitBranchGrant`. `GitBranch`: `gitProviderRef` becomes namespaced; add `GitTargetsFrom []NamespaceGrant`. `KubeProvider`: drop `AccessFrom` and `AllowCrossNamespace`; add `GitTargetsFrom []SourceGrant` and `ApplierClaimsFrom []ApplierGrant`. Each grant has `from` with `names` or `selector`, and the CEL rules from the semantics table. `NamespaceMatcher` goes: nothing else uses it. Doc comments on `GitTarget`, `GitBranch`, `FluxClaim` and `ArgoCDClaim` that say `accessFrom` | +| Accessors | `GitProvider.AdmitsGitBranch(ns, labels, branch)`, `GitBranch.AdmitsGitTarget(ns, labels)`, `KubeProvider.AdmitsGitTarget` and `AdmitsApplierClaim`, each returning the scope that applies. They replace `AdmitsNamespace`, `AllowsCrossNamespaceSources` and `AllowsCrossNamespaceAppliers`. One `from` matcher, and one pattern matcher with `{namespace}` expansion | +| `internal/authz` | Every own-namespace shortcut goes: `gitBranchAdmitted`'s and `namespaceAdmitted`'s, and **`ApplierAdmitted`'s**, which returns early when the claim and the target share a provider and a namespace. It must now check `applierClaimsFrom` on its own. `source_namespace.go` checks `sourceNamespaces`, and `watchrule_compile.go` checks `clusterResources`. Messages follow [refusal messages](#refusal-messages-name-the-grant) | | Claim and worker | `GitBranch` admission is checked before proof and candidacy; revocation goes through `finalize` without removing the finalizer; `WorkerRequest` carries the resolved provider namespace (see implementation notes) | | Applier claims | An explicit admission denial takes the `retire` path; a read error keeps the hold | | Controllers and watches | `WatchRule` and `GitBranch` get Namespace watches; `GitBranch`es are indexed by resolved provider key (see implementation notes) | | CRDs and chart | Regenerate. The chart's `local` provider gets empty grant lists by default, a grant for the quickstart namespace under `quickstart.enabled`, and `values-playground.yaml` (see [the chart's `local` provider](#the-charts-local-provider)). `config/kubeprovider-local.yaml` and the e2e install grant what the suite needs; samples | | `hack/crdfields` | The field inventory | | Docs | `configuration.md` (sharing section, the semantics table, `GitProvider`, `GitBranch`, `KubeProvider`, the boundaries, the platform example), `security-model.md`, `rbac.md`, `quickstart.md`, `components.md`, `architecture.md`, the bi-directional and attribution guides, `docs/spec` | -| `definitions.md` | Remove `spec.accessFrom` from the fields mirrored from Flux word for word, and rewrite the line saying a provider's "`spec.accessFrom` admits the others". Add "grant". The `NamespaceMatcher` comment ("One field uses it") is already stale | +| `definitions.md` | Remove `spec.accessFrom` from the fields mirrored from Flux word for word, and rewrite the line saying a provider's "`spec.accessFrom` admits the others". Add "grant", and `from` among the names borrowed from Gateway API | | `gitbranch-and-kubeprovider-plan.md` | Its "no `accessFrom` of its own" and "one policy governs both hops" for `GitBranch` are superseded; point there from here | -| `UPGRADING.md` | One entry: `allowedBranches` is replaced by `gitBranchesFrom`; every `GitBranch`, `GitTarget` and applier claim now needs a grant, also in the provider's own namespace; the chart's `local` provider grants nothing unless values or the quickstart do; and a `ClusterWatchRule` needs `clusterScoped` | +| `UPGRADING.md` | One entry: `allowedBranches` is replaced by `gitBranchesFrom`; every `GitBranch`, `GitTarget` and applier claim now needs a grant, also in the provider's own namespace; the chart's `local` provider grants nothing unless values or the quickstart do; and a `ClusterWatchRule` needs `clusterResources` | | e2e and fixtures | Every `accessFrom`, `allowCrossNamespace` and `allowedBranches` in `test/e2e` and `test/fixtures`; same-namespace fixtures gain explicit grants | The worker and the Git write path change, so this is the high-risk case in `AGENTS.md`: run local @@ -532,8 +565,9 @@ two teams, which lets each write the other's branch; the page should say so. | Two phases: the renames first, cross-namespace claims later | Ships an API whose own-namespace rules change again in the second phase. One PR settles the model once | | `filepath.Match` for branch patterns | Its `*` stops at `/`, so `feature/*` missed `feature/a/b` and nothing meant "every branch"; and it brings character classes, `?` and escaping that a grant never needs | | A chart default that grants every namespace, or every namespace carrying a label | Every namespace silently gains use of the operator's identity. A label default quietly becomes a grant to everyone on platforms where tenants may label their own namespaces | +| `names` and `selector` side by side in one grant, and `clusterScoped: true` (the previous version of this page) | Two booleans in disguise: an unset field meant something, `selector: {}` meant everyone, and a bool cannot narrow later. `from` and list scopes say it outright | | A per-grant limit on the number of `GitBranch`es | Quota and authorization have different owners. Overlapping grants make per-grant counting complicated, and a namespace `ResourceQuota` already counts custom resources | -| Binding `names` grants to namespace UIDs | Breaks declarative provisioning, because the UID exists only after the namespace is created. Recreating a namespace is a trusted administrative act | +| Binding `Names` grants to namespace UIDs | Breaks declarative provisioning, because the UID exists only after the namespace is created. Recreating a namespace is a trusted administrative act | | Tenant-owned providers with their own credentials | Right when repositories separate tenants. On one shared repository, a copied repository-wide token can claim any branch (see the limit) | ## Implementation notes @@ -546,7 +580,7 @@ two teams, which lets each write the other's branch; the page should say so. - **`WatchRule` needs a Namespace watch.** Its `GitTarget` watch fires only on a spec change or a readiness move, so a label change that narrows `sourceNamespaces` while the `GitTarget` stays admitted would re-evaluate no `WatchRule`. Map a Namespace to the `WatchRule`s in it. The same - holds for `ClusterWatchRule` and `clusterScoped`, mapped through the targets in that namespace. + holds for `ClusterWatchRule` and `clusterResources`, mapped through the targets in that namespace. - **`GitBranch` needs a Namespace watch,** mapped to the `GitBranch`es in that namespace. From there, revocation reaches their `GitTarget`s through `gitTargetsOn`. - **Index `GitBranch`es by resolved provider key.** `gitTargetsOn` and `gitProviderToGitBranches` @@ -570,7 +604,7 @@ The questions earlier versions left open, closed without a new grant field: | Branch-count limits | No per-grant limit; a namespace `ResourceQuota` (`count/gitbranches.configbutler.ai: "20"`) | Quota and authorization have different owners. It bounds `GitBranch` objects in a namespace, across providers; repository size and churn stay unbounded. Exact branch names in a grant cap the distinct branches, while competing `GitBranch` objects for one branch stay uncounted | | `parentBranch` outside the claim grant | Allowed, as far as the provider's credential can read | Creating `feature/x` from `main` should not need the right to claim `main`. The confidentiality boundary above says what follows | | Path isolation | Out of scope | The delegated unit is the branch; the overlap check coordinates writers | -| Recreated namespaces | `names` grants stay name-based, with no UID binding | It fits declarative provisioning; reusing a namespace is a trusted administrative act | +| Recreated namespaces | `Names` grants stay name-based, with no UID binding | It fits declarative provisioning; reusing a namespace is a trusted administrative act | ## Tests to add @@ -587,10 +621,13 @@ server and an applier. - [ ] `feature/*` matches `feature/checkout/v2`; `*` matches every branch, `main` included. - [ ] `teams/*` matches `teams/a/b` (`*` crosses `/`); `main` does not match `main-old` (whole name). - [ ] `teams/{ns}/*` is refused, and the message names `{ns}` and `{namespace}`. -- [ ] `[]` is refused for `names`, `branches`, `sourceNamespaces` and `applierNamespaces`; a grant - with neither `names` nor `selector` is refused; a `KubeProvider` `gitTargetsFrom` grant with - neither `sourceNamespaces` nor `clusterScoped: true` is refused. -- [ ] `names` and `selector` combine by OR; `selector: {}` matches every namespace. +- [ ] `[]` is refused for `names`, `branches`, `sourceNamespaces`, `clusterResources` and + `applierNamespaces`; a grant without `from` is refused, and so are `names` without `Names` and + `selector` without `Selector`; a `KubeProvider` `gitTargetsFrom` grant with neither + `sourceNamespaces` nor `clusterResources` is refused. +- [ ] `names: ["{namespace}"]` is refused, with a message pointing to `from: Same`. +- [ ] `from: Same` admits the object's own namespace after a kustomize overlay moves the object to + another namespace, and admits nothing else; `from: All` admits every namespace. **Admission** (unit, `internal/authz`): @@ -601,8 +638,9 @@ server and an applier. - [ ] `sourceNamespaces: ["{namespace}"]` refuses `sourceNamespace: "*"`, and `["*"]` allows it. - [ ] Mirroring allowed, applying denied, on one `KubeProvider`, with the `GitTarget` and its applier claim in one namespace. Today's `ApplierAdmitted` shortcut would allow the apply. -- [ ] A `ClusterWatchRule` is refused through a target whose grant lacks `clusterScoped`, and - compiles once it is set. +- [ ] A `ClusterWatchRule` for `clusterroles` is refused through a target whose grant has no + `clusterResources`, or only `[customresourcedefinitions.apiextensions.k8s.io]`, and compiles + with `[clusterroles]` or `["*"]`. - [ ] Refusal messages name the grants that apply and the requested value, for each act. **Claim and credential** (unit and envtest): @@ -637,7 +675,7 @@ server and an applier. **Recovery** (envtest; fixing the grant must be enough, with no consumer edited): - [ ] Restoring a grant re-admits, each through its watch: a `GitBranch` (it proves itself and - claims), a namespace's source scope, `clusterScoped` for a `ClusterWatchRule`, and an applier + claims), a namespace's source scope, `clusterResources` for a `ClusterWatchRule`, and an applier claim (it adopts its applier). - [ ] The same after a controller restart: restored grants are read fresh, and nothing revoked earlier stays refused. From 91ed0a8edc369a1569aafeeb192b99dd21de632e Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 16:26:05 +0000 Subject: [PATCH 06/34] docs: name the grant lists allowedGitBranches, allowedGitTargets, allowedApplierClaims With a from in every grant, gitBranchesFrom read "from" twice. "allowed" plus the referring kind, then from, is Gateway API's own allowedRoutes.namespaces.from, where from was borrowed. It also carries on from the removed allowedBranches, which now says who may claim too. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/future/provider-access-rules.md | 84 ++++++++++++++-------------- 1 file changed, 43 insertions(+), 41 deletions(-) diff --git a/docs/future/provider-access-rules.md b/docs/future/provider-access-rules.md index 12dce7d7..5234e485 100644 --- a/docs/future/provider-access-rules.md +++ b/docs/future/provider-access-rules.md @@ -49,10 +49,10 @@ consumers can then carry different RBAC permissions. | Reference | What it lends | Grant | |---|---|---| -| `GitBranch.gitProviderRef` → `GitProvider` | A credential, and a branch of its repository | **`GitProvider.gitBranchesFrom`**, scoped by `branches` | -| `GitTarget.gitBranchRef` → `GitBranch` | Writing a folder into the branch | **`GitBranch.gitTargetsFrom`** | -| `GitTarget.kubeProviderRef` → `KubeProvider` | Reading a cluster with its credential | **`KubeProvider.gitTargetsFrom`**, scoped by `sourceNamespaces` and `clusterResources` | -| `FluxClaim` / `ArgoCDClaim` `.kubeProviderRef` → `KubeProvider` | Control over an applier, with its credential | **`KubeProvider.applierClaimsFrom`**, scoped by `applierNamespaces` | +| `GitBranch.gitProviderRef` → `GitProvider` | A credential, and a branch of its repository | **`GitProvider.allowedGitBranches`**, scoped by `branches` | +| `GitTarget.gitBranchRef` → `GitBranch` | Writing a folder into the branch | **`GitBranch.allowedGitTargets`** | +| `GitTarget.kubeProviderRef` → `KubeProvider` | Reading a cluster with its credential | **`KubeProvider.allowedGitTargets`**, scoped by `sourceNamespaces` and `clusterResources` | +| `FluxClaim` / `ArgoCDClaim` `.kubeProviderRef` → `KubeProvider` | Control over an applier, with its credential | **`KubeProvider.allowedApplierClaims`**, scoped by `applierNamespaces` | | `FluxClaim.kustomizationRef`, `ArgoCDClaim.applicationRef` → the applier in the source cluster | (points into a cluster whose objects we do not own) | Bounded by `applierNamespaces` | | `WatchRule.rules[].sourceNamespace` → a source-cluster namespace | (same) | Bounded by `sourceNamespaces` | | `ClusterWatchRule.gitTargetRef` → a `GitTarget` in any namespace | Cluster-scoped reads through that target's `KubeProvider` | Bounded by `clusterResources` on the grant that admits the target | @@ -80,9 +80,10 @@ Every grant answers one question: **who may use this object, and for what?** | Who owns a grant list? | One policy owner per object: every grant list is `+listType=atomic`, and `names`, `branches`, `sourceNamespaces`, `clusterResources` and `applierNamespaces` are `+listType=set` | | Are there booleans? | No. A grant's source is an enum (`from`), and every scope is a list, so either can grow a value later without a new field ([API conventions](https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md): think twice about bool fields) | -The field is named after the referring kind, or after a family of kinds the API already defines. -"Applier claim" is such a family: there is no `ApplierClaim` kind, but a `GitTarget` points at one -with `applierClaimRef`, and `definitions.md` defines the term. The same `gitTargetsFrom` on two +The field is `allowed` plus the referring kind, or a family of kinds the API already defines, +followed by a `from` in each grant. That is Gateway API's `allowedRoutes.namespaces.from`, the +shape `from` is borrowed from. "Applier claim" is such a family: there is no `ApplierClaim` kind, but a `GitTarget` points at one +with `applierClaimRef`, and `definitions.md` defines the term. The same `allowedGitTargets` on two objects always means "which `GitTarget`s may reference me"; the referenced object decides the activity. The names follow [definitions.md](../definitions.md) rule 2, and avoid a bare "claim", which `definitions.md` keeps apart as "branch claim" and "applier claim". @@ -96,7 +97,7 @@ decides who may claim, and the branch decides who may write. Every grant has a required `from`, as a Gateway API listener's `allowedRoutes.namespaces.from`: ```yaml -gitTargetsFrom: +allowedGitTargets: - from: Same # this object's own namespace, whatever it is named - from: Names names: [team-b] @@ -126,13 +127,13 @@ reviewer sees. | A grant list | No access | No access | Grants, combined by union | Not a value of a list | | `from` | **Refused**: required | (not a list) | `Same`, `Names`, `Selector` or `All` | Refused: use `All` | | `names` (with `Names`) | Refused | Refused | Exact control-cluster namespace names; `{namespace}` is refused, because it would admit every referrer (use `Same` for the own namespace) | Refused: use `All` | -| `GitProvider.gitBranchesFrom[].branches` | **Refused**: required | Refused | Branch patterns (see [patterns](#patterns)) | Every branch | -| `KubeProvider.gitTargetsFrom[].sourceNamespaces` | No namespaces | Refused | Exactly these source-cluster namespaces | Every namespace; also allows `rules[].sourceNamespace: "*"` | -| `KubeProvider.gitTargetsFrom[].clusterResources` | No cluster-scoped resources | Refused | Exactly these cluster-scoped resources (`clusterroles`, `customresourcedefinitions.apiextensions.k8s.io`), which a `ClusterWatchRule` may mirror through the target | Every cluster-scoped resource | -| `KubeProvider.applierClaimsFrom[].applierNamespaces` | **Refused**: required | Refused | Exactly these namespaces | Every namespace | -| `GitBranch.gitTargetsFrom[]` | no scope field | | | | +| `GitProvider.allowedGitBranches[].branches` | **Refused**: required | Refused | Branch patterns (see [patterns](#patterns)) | Every branch | +| `KubeProvider.allowedGitTargets[].sourceNamespaces` | No namespaces | Refused | Exactly these source-cluster namespaces | Every namespace; also allows `rules[].sourceNamespace: "*"` | +| `KubeProvider.allowedGitTargets[].clusterResources` | No cluster-scoped resources | Refused | Exactly these cluster-scoped resources (`clusterroles`, `customresourcedefinitions.apiextensions.k8s.io`), which a `ClusterWatchRule` may mirror through the target | Every cluster-scoped resource | +| `KubeProvider.allowedApplierClaims[].applierNamespaces` | **Refused**: required | Refused | Exactly these namespaces | Every namespace | +| `GitBranch.allowedGitTargets[]` | no scope field | | | | -A `KubeProvider` `gitTargetsFrom` grant must set `sourceNamespaces`, `clusterResources`, or +A `KubeProvider` `allowedGitTargets` grant must set `sourceNamespaces`, `clusterResources`, or both. CEL refuses one that grants nothing. **An explicit list is the whole scope, never "these plus your own".** `applierNamespaces: [flux-system]` means `flux-system` only. `["*"]` is a deliberate, broad grant. @@ -184,7 +185,7 @@ to find out why something failed: - `GitBranch`: "namespace `team-a` may claim `teams/team-a/*`, `feature/*` through GitProvider `platform/shared-repo`; requested branch `main` is not allowed". - `GitTarget` on a branch: "GitBranch `team-a/live` admits `team-a`, `team-b`; namespace `team-c` is - not admitted (`spec.gitTargetsFrom`)". + not admitted (`spec.allowedGitTargets`)". - `WatchRule`: "KubeProvider `platform/production` lets namespace `team-a` watch `team-a`; `sourceNamespace: kube-system` is not allowed". @@ -199,7 +200,7 @@ metadata: {name: shared-repo, namespace: platform} spec: url: https://git.example.com/platform/config.git secretRef: {name: repository-auth} - gitBranchesFrom: + allowedGitBranches: - from: Same branches: [main, "release/*"] - from: Selector @@ -225,7 +226,7 @@ metadata: {name: live, namespace: team-a} spec: gitProviderRef: {name: shared-repo, namespace: platform} branch: teams/team-a/live - gitTargetsFrom: + allowedGitTargets: - from: Same --- kind: GitBranch @@ -233,7 +234,7 @@ metadata: {name: main, namespace: platform} spec: gitProviderRef: {name: shared-repo} branch: main - gitTargetsFrom: + allowedGitTargets: - from: Names names: [release-team] ``` @@ -245,7 +246,7 @@ spec: is no provider-level writer policy beside this one. That would turn delegation into two policies that intersect. - **The `GitBranch`'s own namespace needs a grant too.** `from: Same` above is not implied. -- **`gitTargetsFrom` is mutable.** `gitProviderRef`, `branch` and `parentBranch` stay immutable, +- **`allowedGitTargets` is mutable.** `gitProviderRef`, `branch` and `parentBranch` stay immutable, because together they are the branch's identity. The type comment "every field is immutable" changes with it. - [`gitBranchAdmitted`](../../internal/authz/kubeprovider_admission.go) reads this list on the @@ -259,7 +260,7 @@ kind: KubeProvider metadata: {name: production, namespace: platform} spec: secretRef: {name: production-auth} - gitTargetsFrom: + allowedGitTargets: - from: Selector selector: {matchLabels: {platform.example.com/tenant: "true"}} sourceNamespaces: ["{namespace}"] @@ -267,7 +268,7 @@ spec: names: [platform-audit] sourceNamespaces: ["*"] clusterResources: ["*"] - applierClaimsFrom: + allowedApplierClaims: - from: Selector selector: {matchLabels: {platform.example.com/tenant: "true"}} applierNamespaces: ["{namespace}"] @@ -275,14 +276,14 @@ spec: applierNamespaces: [flux-system] ``` -- **`gitTargetsFrom`** admits a `GitTarget` to mirror through this provider. Its +- **`allowedGitTargets`** admits a `GitTarget` to mirror through this provider. Its `sourceNamespaces` bound what the `WatchRule`s feeding that target may watch. A `WatchRule` feeds a `GitTarget` in its own namespace. - **`clusterResources`** lets a `ClusterWatchRule` mirror those cluster-scoped resources through that `GitTarget`. Today a `ClusterWatchRule` needs only the target's admission. Only a cluster admin can create one, but without this field `sourceNamespaces: ["{namespace}"]` would look like the whole reach and would not be. -- **`applierClaimsFrom`** admits an applier claim, and its `applierNamespaces` bound where the +- **`allowedApplierClaims`** admits an applier claim, and its `applierNamespaces` bound where the `Kustomization` or `Application` it holds may live. - **`allowCrossNamespace` and `accessFrom` are removed.** Mirroring and controlling appliers are granted separately: a namespace admitted to mirror is no longer admitted to apply. @@ -295,17 +296,17 @@ them. - **Default install: empty grant lists.** Nothing mirrors or holds an applier through `local` until values grant it. The controller's refusal names the API field, because the same provider may be managed with Kustomize: "KubeProvider `gitops-reverser/local` admits no - namespace in `spec.gitTargetsFrom`". The chart docs map that field to - `kubeProvider.gitTargetsFrom`. + namespace in `spec.allowedGitTargets`". The chart docs map that field to + `kubeProvider.allowedGitTargets`. - **`quickstart.enabled=true`** also renders a grant for exactly the quickstart namespace, so the [quickstart](../quickstart.md) stays one `helm install`: ```yaml - gitTargetsFrom: + allowedGitTargets: - from: Names names: [gitops-reverser-quickstart-demo] sourceNamespaces: ["{namespace}"] - applierClaimsFrom: + allowedApplierClaims: - from: Names names: [gitops-reverser-quickstart-demo] applierNamespaces: ["{namespace}"] @@ -316,10 +317,10 @@ them. ```yaml kubeProvider: - gitTargetsFrom: + allowedGitTargets: - from: All sourceNamespaces: ["{namespace}"] - applierClaimsFrom: + allowedApplierClaims: - from: All applierNamespaces: ["{namespace}"] ``` @@ -437,12 +438,12 @@ drain: ([`write_gate.go`](../../internal/git/write_gate.go) explains why: a write held back would come out of order later). -A `GitBranch` that loses `gitBranchesFrom` takes the `finalize` path but keeps its finalizer, and +A `GitBranch` that loses `allowedGitBranches` takes the `finalize` path but keeps its finalizer, and reports `Stalled=True`, reason `NamespaceNotAuthorized`. Writes can therefore land after the API server accepts a policy edit. Git cannot take back a push that is already sent, and the docs should promise no more than that. -**An applier claim that loses `applierClaimsFrom`:** +**An applier claim that loses `allowedApplierClaims`:** > Revocation stops new apply requests and starts handing back the existing hold. The hand-back > restores the recorded prior state and leaves other owners' declarations alone. It may need retries @@ -502,7 +503,7 @@ The claim key is the repository and the branch; the provider is not part of it. claims `main` through a different `GitProvider` for the same URL. That provider can be in a tenant's namespace with a copied token. On Gitea or Forgejo, a token that can only read is enough, because it still opens a receive-pack session. If that `GitBranch` is older, or first, it holds the branch. The -platform's `GitBranch`, and the `gitTargetsFrom` on it, then report `BranchConflict` and govern +platform's `GitBranch`, and the `allowedGitTargets` on it, then report `BranchConflict` and govern nothing. State the limit wherever the grants are documented: @@ -525,9 +526,9 @@ docs, samples, the chart and e2e fixtures. | Surface | Change | |---|---| -| `api/v1alpha3` | `GitProvider`: drop `AccessFrom` and `AllowedBranches`; add `GitBranchesFrom []GitBranchGrant`. `GitBranch`: `gitProviderRef` becomes namespaced; add `GitTargetsFrom []NamespaceGrant`. `KubeProvider`: drop `AccessFrom` and `AllowCrossNamespace`; add `GitTargetsFrom []SourceGrant` and `ApplierClaimsFrom []ApplierGrant`. Each grant has `from` with `names` or `selector`, and the CEL rules from the semantics table. `NamespaceMatcher` goes: nothing else uses it. Doc comments on `GitTarget`, `GitBranch`, `FluxClaim` and `ArgoCDClaim` that say `accessFrom` | +| `api/v1alpha3` | `GitProvider`: drop `AccessFrom` and `AllowedBranches`; add `AllowedGitBranches []GitBranchGrant`. `GitBranch`: `gitProviderRef` becomes namespaced; add `AllowedGitTargets []NamespaceGrant`. `KubeProvider`: drop `AccessFrom` and `AllowCrossNamespace`; add `AllowedGitTargets []SourceGrant` and `AllowedApplierClaims []ApplierGrant`. Each grant has `from` with `names` or `selector`, and the CEL rules from the semantics table. `NamespaceMatcher` goes: nothing else uses it. Doc comments on `GitTarget`, `GitBranch`, `FluxClaim` and `ArgoCDClaim` that say `accessFrom` | | Accessors | `GitProvider.AdmitsGitBranch(ns, labels, branch)`, `GitBranch.AdmitsGitTarget(ns, labels)`, `KubeProvider.AdmitsGitTarget` and `AdmitsApplierClaim`, each returning the scope that applies. They replace `AdmitsNamespace`, `AllowsCrossNamespaceSources` and `AllowsCrossNamespaceAppliers`. One `from` matcher, and one pattern matcher with `{namespace}` expansion | -| `internal/authz` | Every own-namespace shortcut goes: `gitBranchAdmitted`'s and `namespaceAdmitted`'s, and **`ApplierAdmitted`'s**, which returns early when the claim and the target share a provider and a namespace. It must now check `applierClaimsFrom` on its own. `source_namespace.go` checks `sourceNamespaces`, and `watchrule_compile.go` checks `clusterResources`. Messages follow [refusal messages](#refusal-messages-name-the-grant) | +| `internal/authz` | Every own-namespace shortcut goes: `gitBranchAdmitted`'s and `namespaceAdmitted`'s, and **`ApplierAdmitted`'s**, which returns early when the claim and the target share a provider and a namespace. It must now check `allowedApplierClaims` on its own. `source_namespace.go` checks `sourceNamespaces`, and `watchrule_compile.go` checks `clusterResources`. Messages follow [refusal messages](#refusal-messages-name-the-grant) | | Claim and worker | `GitBranch` admission is checked before proof and candidacy; revocation goes through `finalize` without removing the finalizer; `WorkerRequest` carries the resolved provider namespace (see implementation notes) | | Applier claims | An explicit admission denial takes the `retire` path; a read error keeps the hold | | Controllers and watches | `WatchRule` and `GitBranch` get Namespace watches; `GitBranch`es are indexed by resolved provider key (see implementation notes) | @@ -536,7 +537,7 @@ docs, samples, the chart and e2e fixtures. | Docs | `configuration.md` (sharing section, the semantics table, `GitProvider`, `GitBranch`, `KubeProvider`, the boundaries, the platform example), `security-model.md`, `rbac.md`, `quickstart.md`, `components.md`, `architecture.md`, the bi-directional and attribution guides, `docs/spec` | | `definitions.md` | Remove `spec.accessFrom` from the fields mirrored from Flux word for word, and rewrite the line saying a provider's "`spec.accessFrom` admits the others". Add "grant", and `from` among the names borrowed from Gateway API | | `gitbranch-and-kubeprovider-plan.md` | Its "no `accessFrom` of its own" and "one policy governs both hops" for `GitBranch` are superseded; point there from here | -| `UPGRADING.md` | One entry: `allowedBranches` is replaced by `gitBranchesFrom`; every `GitBranch`, `GitTarget` and applier claim now needs a grant, also in the provider's own namespace; the chart's `local` provider grants nothing unless values or the quickstart do; and a `ClusterWatchRule` needs `clusterResources` | +| `UPGRADING.md` | One entry: `allowedBranches` became `allowedGitBranches`, which also says who may claim; every `GitBranch`, `GitTarget` and applier claim now needs a grant, also in the provider's own namespace; the chart's `local` provider grants nothing unless values or the quickstart do; and a `ClusterWatchRule` needs `clusterResources` | | e2e and fixtures | Every `accessFrom`, `allowCrossNamespace` and `allowedBranches` in `test/e2e` and `test/fixtures`; same-namespace fixtures gain explicit grants | The worker and the Git write path change, so this is the high-risk case in `AGENTS.md`: run local @@ -557,9 +558,10 @@ two teams, which lets each write the other's branch; the page should say so. | Keep `accessFrom` (Flux's spelling) | It does not say which act it grants, and it cannot grant mirroring without applying, or one branch without all | | Admit the own namespace automatically, with its own namespace as the scope, and keep `allowedBranches` as a limit on every claim (the previous version of this page) | Hidden rules: the default differed per field, `[flux-system]` meant "flux-system plus your own", and a delegated branch needed two fields read together. Explicit grants show the whole policy in one place | | Admit the own namespace with unlimited scope | Widens today's `KubeProvider` behavior, and loses the RBAC split where a team may create `GitBranch`es beside a provider it may not edit | -| A provider-level writer policy beside `GitBranch.gitTargetsFrom` | Two policies that intersect. The platform keeps control of a branch by owning its `GitBranch` | -| `gitTargetsFrom` on `GitProvider`, with `branches` per grant | A `GitTarget` does not reference the provider, so the decision reached through the branch | +| A provider-level writer policy beside `GitBranch.allowedGitTargets` | Two policies that intersect. The platform keeps control of a branch by owning its `GitBranch` | +| `allowedGitTargets` on `GitProvider`, with `branches` per grant | A `GitTarget` does not reference the provider, so the decision reached through the branch | | Rules keyed by branch on `GitProvider`, first match wins | It needs list order to keep `main` narrow beside a `*` rule. That order is invisible in a merged manifest, and the shape does not carry over to `KubeProvider` | +| Field names ending in `From` (`gitBranchesFrom`, an earlier version of this page) | With `from` in every grant, `gitBranchesFrom: [{from: Same}]` says "from" twice. `allowed` plus the kind reads as one sentence, and is Gateway API's own pattern | | Flat `branchClaimsFrom` beside `allowedBranches` and `accessFrom` | Three lists that interact. The effective rule for one branch is their intersection | | A separate grant kind in the provider's namespace (`GitBranchGrant`, after Gateway API's `ReferenceGrant`) | Worth it only if someone needs separate RBAC for grants and connections; it adds a kind, a watch and grant-deletion handling | | Two phases: the renames first, cross-namespace claims later | Ships an API whose own-namespace rules change again in the second phase. One PR settles the model once | @@ -623,7 +625,7 @@ server and an applier. - [ ] `teams/{ns}/*` is refused, and the message names `{ns}` and `{namespace}`. - [ ] `[]` is refused for `names`, `branches`, `sourceNamespaces`, `clusterResources` and `applierNamespaces`; a grant without `from` is refused, and so are `names` without `Names` and - `selector` without `Selector`; a `KubeProvider` `gitTargetsFrom` grant with neither + `selector` without `Selector`; a `KubeProvider` `allowedGitTargets` grant with neither `sourceNamespaces` nor `clusterResources` is refused. - [ ] `names: ["{namespace}"]` is refused, with a message pointing to `from: Same`. - [ ] `from: Same` admits the object's own namespace after a kustomize overlay moves the object to @@ -654,7 +656,7 @@ server and an applier. **Revocation** (envtest, driven by watch events): -- [ ] Removing a namespace from `GitBranch.gitTargetsFrom` refuses that namespace's `GitTarget`s; +- [ ] Removing a namespace from `GitBranch.allowedGitTargets` refuses that namespace's `GitTarget`s; adding it back admits them. - [ ] Scope narrowed while admission stays: a namespace matched by two grants loses the label of the one granting `"*"`. Its `WatchRule`s stop watching the other namespaces, and the `GitTarget` @@ -665,7 +667,7 @@ server and an applier. arriving afterwards is refused. The successor starts only after the old worker retires, and receives none of its work. - [ ] One grant edit revokes two candidates; neither holds the branch afterwards. -- [ ] An applier claim denied by `applierClaimsFrom` hands its applier back. A read error on the +- [ ] An applier claim denied by `allowedApplierClaims` hands its applier back. A read error on the `KubeProvider` keeps the hold. - [ ] A `Kustomization` suspended before adoption stays suspended after a denial's hand-back. - [ ] A hand-back that cannot finish (the identity lost its RBAC) shows in status and a Warning @@ -683,7 +685,7 @@ server and an applier. **Chart and end to end:** - [ ] A default install grants nothing: a `GitTarget` through `local` is refused with the message - that names `spec.gitTargetsFrom`. + that names `spec.allowedGitTargets`. - [ ] `quickstart.enabled=true` mirrors the quickstart namespace and nothing else. - [ ] `values-playground.yaml` lets any namespace mirror its own namespace, and no other. - [ ] e2e: a team claims `teams//live` through a platform provider and writes it, then shares it From 18e4db63aff87eca9f4cc71ccde9c20dc33f7347 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 16:26:44 +0000 Subject: [PATCH 07/34] docs: rewrap the naming paragraph to the line limit Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/future/provider-access-rules.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/future/provider-access-rules.md b/docs/future/provider-access-rules.md index 5234e485..7f091930 100644 --- a/docs/future/provider-access-rules.md +++ b/docs/future/provider-access-rules.md @@ -82,11 +82,12 @@ Every grant answers one question: **who may use this object, and for what?** The field is `allowed` plus the referring kind, or a family of kinds the API already defines, followed by a `from` in each grant. That is Gateway API's `allowedRoutes.namespaces.from`, the -shape `from` is borrowed from. "Applier claim" is such a family: there is no `ApplierClaim` kind, but a `GitTarget` points at one -with `applierClaimRef`, and `definitions.md` defines the term. The same `allowedGitTargets` on two -objects always means "which `GitTarget`s may reference me"; the referenced object decides the -activity. The names follow [definitions.md](../definitions.md) rule 2, and avoid a bare "claim", -which `definitions.md` keeps apart as "branch claim" and "applier claim". +shape `from` is borrowed from. "Applier claim" is such a family: there is no `ApplierClaim` kind, +but a `GitTarget` points at one with `applierClaimRef`, and `definitions.md` defines the term. +The same `allowedGitTargets` on two objects always means "which `GitTarget`s may reference me"; +the referenced object decides the activity. The names follow [definitions.md](../definitions.md) +rule 2, and avoid a bare "claim", which `definitions.md` keeps apart as "branch claim" and "applier +claim". This means a little more YAML and fewer hidden rules. It replaces the phase 1 decision that a `GitBranch` has no access list and its provider's `accessFrom` "governs both hops". The provider From 0f591da9ebd21bc1b92e072d2ad2b79debdf1b7c Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 16:33:10 +0000 Subject: [PATCH 08/34] docs: lead with one tenant end to end, and define cluster resources Fold in the fifth review of the access plan: - Open with a walkthrough: four objects (GitProvider, KubeProvider, GitBranch, GitTarget), an allowed/refused table, and the difference between from: Same (the object holding the grant) and {namespace} (the object asking). The reference inventory and "why now" move later. - Define clusterResources identity: resource.group, bare names for the core group only, every served version, exact names or "*". It is decided on the ClusterWatchRule's text, as sourceNamespace "*" is, so discovery never widens a narrow grant; one forbidden type refuses the whole rule. - Refuse from: Selector with an empty selector; everyone takes from: All. Describe the Gateway API borrowing accurately (there from is optional, defaulting to Same). - Fix the ownership summary: branch owners manage writer grants. - Drop the "settled" claim from the introduction. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/future/provider-access-rules.md | 222 +++++++++++++++++++-------- 1 file changed, 157 insertions(+), 65 deletions(-) diff --git a/docs/future/provider-access-rules.md b/docs/future/provider-access-rules.md index 7f091930..0191c6db 100644 --- a/docs/future/provider-access-rules.md +++ b/docs/future/provider-access-rules.md @@ -1,74 +1,98 @@ # Access between objects: who may reference what -> **future work**, 2026-10-23. Not built. **The user-facing part should be settled before the next -> release**, because until then renaming costs nothing (see [why now](#why-now)). This page -> replaces three drafts: `docs/reviews/20261023-gitbranch-claims-from-other-namespaces.md` (the -> proposal), `docs/reviews/20261023-gitbranch-claims-review.md` (its review), and -> `docs/future/gitprovider-branch-rules.md`. It follows up items 1 and 2 of "Still missing" in the -> [one-writer-per-branch status](../design/one-writer-per-branch-plan.md). Four reviews of -> 2026-10-23 shaped it, the last read against Flux's own sources; the permission model is settled. +> **future work**, 2026-10-23. Not built. The proposed contract for who may use a provider or a +> branch, to land before the next release while the names are still free (see [why now](#why-now)). +> It replaces three drafts: `docs/reviews/20261023-gitbranch-claims-from-other-namespaces.md`, +> `docs/reviews/20261023-gitbranch-claims-review.md` and `docs/future/gitprovider-branch-rules.md`, +> and follows up items 1 and 2 of "Still missing" in the +> [one-writer-per-branch status](../design/one-writer-per-branch-plan.md). > [Tests to add](#tests-to-add) is the checklist for the PR. -## The goal +## How it works: one tenant, end to end -A platform team keeps one repository credential and one cluster connection in its own namespace. -It decides, per activity, which other namespaces may use them, and for what: +A platform team keeps one repository credential and one cluster connection in its namespace, +`platform`. Namespace `team-a` carries the label `platform.example.com/tenant: "true"`. -- **On the repository:** the platform holds `main` and decides who writes it. Each team claims - `teams//*` branches and decides who collaborates on them. Any tenant may claim a - `feature/*` branch. -- **On the cluster:** tenants mirror their own namespace and control the appliers that live in it. - One audit namespace mirrors every namespace, and cluster-scoped resources. The platform namespace - controls the `Kustomization`s in `flux-system`. +1. The platform allows tenants to claim `teams//*` and to mirror their own + namespace. +2. Team A creates a `GitBranch` for `teams/team-a/live`, and allows `GitTarget`s from its own + namespace to write it. +3. Team A creates a `GitTarget` that references that branch and the platform's `KubeProvider`. +4. The `GitTarget` runs only while **both** grants allow it: the branch's writer grant and the + provider's source grant. -A team creates and deletes its own `GitBranch` in its own namespace when a grant allows it. No -credential leaves the platform namespace. - -## Why now +```yaml +# platform: the credential, and who may claim which branches +kind: GitProvider +metadata: {name: shared-repo, namespace: platform} +spec: + url: https://git.example.com/platform/config.git + secretRef: {name: repository-auth} + allowedGitBranches: + - from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} + branches: ["teams/{namespace}/*"] +--- +# platform: the cluster connection, and who may mirror which namespaces +kind: KubeProvider +metadata: {name: production, namespace: platform} +spec: + secretRef: {name: production-auth} + allowedGitTargets: + - from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} + sourceNamespaces: ["{namespace}"] +--- +# team-a: the branch it claims, and who may write it +kind: GitBranch +metadata: {name: live, namespace: team-a} +spec: + gitProviderRef: {name: shared-repo, namespace: platform} + branch: teams/team-a/live + allowedGitTargets: + - from: Same +--- +# team-a: what to mirror, and where; a WatchRule in team-a selects what +kind: GitTarget +metadata: {name: apps, namespace: team-a} +spec: + gitBranchRef: {name: live} + kubeProviderRef: {name: production, namespace: platform} + path: apps +``` -| Field | In 0.53.0 | In the next release as it stands | +| Request | Result | Because | |---|---|---| -| `GitProvider.spec.allowedBranches` | Yes | Yes | -| `GitProvider.spec.accessFrom` | No | New (#428) | -| `KubeProvider.spec.accessFrom`, `.allowCrossNamespace` | On `ClusterProvider`, which the next release removes | New, on a new kind (#430) | +| `team-a`'s `GitBranch` claims `teams/team-a/live` | Allowed | The provider's grant selects `team-a`, and `teams/{namespace}/*` expands to `teams/team-a/*` | +| `team-a`'s `GitBranch` claims `main` or `teams/team-b/live` | Refused, `BranchNotAllowed` | No grant gives `team-a` either branch | +| `team-a`'s `GitTarget` writes through `team-a/live` | Allowed | `from: Same` on the `GitBranch` | +| `team-b`'s `GitTarget` writes through `team-a/live` | Refused, until team A adds `from: Names, names: [team-b]` | The branch's owner decides who shares it | +| A `WatchRule` in `team-a` watches `team-a` | Allowed | `sourceNamespaces: ["{namespace}"]` | +| A `WatchRule` in `team-a` watches `kube-system` | Refused, `SourceNamespaceNotAllowed` | The grant's scope is `team-a` only | +| A `GitTarget` in `platform` mirrors through `production` | Refused, `NamespaceNotAuthorized` | No grant admits `platform`; its own namespace is not automatic | -`allowedBranches` is the only released name among them, and `GitProvider` breaks in that release -anyway (`spec.receiver` is gone, and `GitBranch` takes over the branch). If `accessFrom` ships, a -later rename breaks released manifests. If the rename lands first, those names are never released. +Two expressions name a namespace, and they name different ones: -## Access between objects: the whole picture +| Expression | Whose namespace | +|---|---| +| `from: Same` | The object **containing** the grant: for this `GitBranch`, `team-a`; for the providers, `platform` | +| `{namespace}` in a scope | The object **asking** for access: for `team-a`'s `GitBranch`, `team-a` | -Every reference in `api/v1alpha3`, and whether it needs a grant. **An object that lends something -lists every grant explicitly: a credential, a branch, or reach into other namespaces.** A -reference that stays in its own namespace and lends nothing needs no grant. +**Platform administrators manage provider grants and authorization labels. Branch owners manage +writer grants on their branches.** That split is why team A can share its branch without asking +the platform. + +## The rules + +Every grant answers one question: **who may use this object, and for what?** Grants exist only +where an object lends something: a credential, a branch, or reach into other namespaces (the +[whole picture](#access-between-objects-the-whole-picture) lists every reference). This is stricter than Flux on purpose. Flux keeps consent on the referenced object too (`accessFrom`), but its ACL helper admits every same-namespace reference. GitOps Reverser requires explicit consent for provider and branch use, also within one namespace. Administering a provider and creating its consumers can then carry different RBAC permissions. -| Reference | What it lends | Grant | -|---|---|---| -| `GitBranch.gitProviderRef` → `GitProvider` | A credential, and a branch of its repository | **`GitProvider.allowedGitBranches`**, scoped by `branches` | -| `GitTarget.gitBranchRef` → `GitBranch` | Writing a folder into the branch | **`GitBranch.allowedGitTargets`** | -| `GitTarget.kubeProviderRef` → `KubeProvider` | Reading a cluster with its credential | **`KubeProvider.allowedGitTargets`**, scoped by `sourceNamespaces` and `clusterResources` | -| `FluxClaim` / `ArgoCDClaim` `.kubeProviderRef` → `KubeProvider` | Control over an applier, with its credential | **`KubeProvider.allowedApplierClaims`**, scoped by `applierNamespaces` | -| `FluxClaim.kustomizationRef`, `ArgoCDClaim.applicationRef` → the applier in the source cluster | (points into a cluster whose objects we do not own) | Bounded by `applierNamespaces` | -| `WatchRule.rules[].sourceNamespace` → a source-cluster namespace | (same) | Bounded by `sourceNamespaces` | -| `ClusterWatchRule.gitTargetRef` → a `GitTarget` in any namespace | Cluster-scoped reads through that target's `KubeProvider` | Bounded by `clusterResources` on the grant that admits the target | -| `WatchRule.gitTargetRef`, `CommitRequest.gitTargetRef` (own namespace only) | Nothing: what flows in is bounded by the `GitTarget`'s own grants | None | -| `GitTarget.applierClaimRef` (own namespace only) | Nothing new: the applier claim is already bound to one applier by its own grant | None | -| `secretRef`, `knownHostsRef` (own namespace only) | The Kubernetes convention for Secrets | None | -| `GitReceiver` | Nothing references it; a delivery only wakes a worker, which reads the remote with its own credential | None | -| `KubeProvider.inCluster` | The operator's own identity | Consent at install time (`--in-cluster-kube-providers`), unchanged | - -The list is closed. A new kind that references a provider, or lends reach, gets a grant list on the -object it references, following the rules below. - -## The rules - -Every grant answers one question: **who may use this object, and for what?** - | Question | Answer | |---|---| | Where does permission live? | On the referenced object | @@ -95,7 +119,9 @@ decides who may claim, and the branch decides who may write. ### Where a grant admits from -Every grant has a required `from`, as a Gateway API listener's `allowedRoutes.namespaces.from`: +Every grant has a required `from`. The values `Same`, `Selector` and `All` are Gateway API's +(`allowedRoutes.namespaces.from`); Gateway API makes `from` optional with `Same` as the default, and +here it is required, so no grant admits anyone by omission: ```yaml allowedGitTargets: @@ -118,8 +144,8 @@ two grants, combined by union; one grant never combines two sources. `Same` is h its own namespace without writing its name. A kustomize overlay that sets `namespace:` does not rewrite a name inside `names`, so a literal own-namespace name breaks silently; `Same` does not. `Names` has no Gateway API counterpart, which matches by the `kubernetes.io/metadata.name` label; -a plain list reads better. `All` replaces the old `selector: {}`, so admitting everyone is a word a -reviewer sees. +a plain list reads better. Admitting everyone takes `from: All`, a word a reviewer sees: +`from: Selector` with an empty `selector: {}` is refused. ### Semantics, field by field @@ -127,10 +153,11 @@ reviewer sees. |---|---|---|---|---| | A grant list | No access | No access | Grants, combined by union | Not a value of a list | | `from` | **Refused**: required | (not a list) | `Same`, `Names`, `Selector` or `All` | Refused: use `All` | +| `selector` (with `Selector`) | Refused | (not a list) | A label selector on control-cluster Namespaces | `selector: {}` is refused: use `from: All` | | `names` (with `Names`) | Refused | Refused | Exact control-cluster namespace names; `{namespace}` is refused, because it would admit every referrer (use `Same` for the own namespace) | Refused: use `All` | | `GitProvider.allowedGitBranches[].branches` | **Refused**: required | Refused | Branch patterns (see [patterns](#patterns)) | Every branch | | `KubeProvider.allowedGitTargets[].sourceNamespaces` | No namespaces | Refused | Exactly these source-cluster namespaces | Every namespace; also allows `rules[].sourceNamespace: "*"` | -| `KubeProvider.allowedGitTargets[].clusterResources` | No cluster-scoped resources | Refused | Exactly these cluster-scoped resources (`clusterroles`, `customresourcedefinitions.apiextensions.k8s.io`), which a `ClusterWatchRule` may mirror through the target | Every cluster-scoped resource | +| `KubeProvider.allowedGitTargets[].clusterResources` | No cluster-scoped resources | Refused | Exactly these cluster-scoped resources, by identity (see [cluster-scoped resources](#cluster-scoped-resources)) | Every cluster-scoped resource, including CRDs installed later | | `KubeProvider.allowedApplierClaims[].applierNamespaces` | **Refused**: required | Refused | Exactly these namespaces | Every namespace | | `GitBranch.allowedGitTargets[]` | no scope field | | | | @@ -178,6 +205,25 @@ its sanitizing and its empty-segment dropping do not carry over: they exist to r grant renders nothing. The matcher is a few lines in `api/v1alpha3`. It does not reuse the placement parser, which carries fourteen variables, fallbacks and label keys. +### Cluster-scoped resources + +`clusterResources` is the one scope that names resource types, so it defines their identity: + +- **A non-core resource is `resource.group`:** `clusterroles.rbac.authorization.k8s.io`, + `customresourcedefinitions.apiextensions.k8s.io`. **A bare name is the core group only:** + `nodes`, `namespaces`. A bare `clusterroles` therefore matches nothing. +- **Exact identities or `"*"`,** with no aliases and no partial wildcards. An identity covers every + served version of its group and resource. +- **It is decided on the rule's text, as `sourceNamespace: "*"` is.** A `ClusterWatchRule` item + whose `apiGroups` or `resources` holds `"*"` needs `clusterResources: ["*"]`. An item that names + its groups and resources is checked pair by pair. An item that omits `apiGroups` resolves to one + group by discovery, as today, and that pair is checked. +- **One forbidden type refuses the whole `ClusterWatchRule`,** with a message that names the + forbidden types, as an invalid `objectSelector` refuses it today. Nothing is filtered silently. +- **Discovery never widens a grant.** Under a narrow grant no wildcard item compiles, so a CRD + installed later cannot slip in. Under `["*"]`, a CRD installed later is included: that is what + `"*"` grants. + ### Refusal messages name the grant A refusal says what the grants allow and what was asked. A tenant should never need a broad grant @@ -281,9 +327,10 @@ spec: `sourceNamespaces` bound what the `WatchRule`s feeding that target may watch. A `WatchRule` feeds a `GitTarget` in its own namespace. - **`clusterResources`** lets a `ClusterWatchRule` mirror those cluster-scoped resources through - that `GitTarget`. Today a `ClusterWatchRule` needs only the target's admission. Only a cluster admin can - create one, but without this field `sourceNamespaces: ["{namespace}"]` would look like the whole - reach and would not be. + that `GitTarget` (see [cluster-scoped resources](#cluster-scoped-resources)). Today a + `ClusterWatchRule` needs only the target's admission. Only a cluster admin can create one, but + without this field `sourceNamespaces: ["{namespace}"]` would look like the whole reach and would + not be. - **`allowedApplierClaims`** admits an applier claim, and its `applierNamespaces` bound where the `Kustomization` or `Application` it holds may live. - **`allowCrossNamespace` and `accessFrom` are removed.** Mirroring and controlling appliers are @@ -359,8 +406,9 @@ State these in the configuration docs, beside the grants: ## A platform and its tenants -The model assumes one split, and it is enforced by RBAC: platform administrators manage providers, -grants and namespace labels, and tenants manage what uses them. With Flux, objects are applied by a +The model assumes one split, and RBAC enforces it. Platform administrators manage providers, their +grants and the authorization labels. Branch owners manage writer grants on their branches, and +tenants manage the objects that use both. With Flux, objects are applied by a reconciler, so what counts is what each reconciler's identity may create. The platform's reconciler applies: @@ -519,6 +567,42 @@ governing authority per repository becomes part of the design: a cluster-scoped repository URL (option C of the original one-writer plan), or a cluster-level rule naming the one provider through which a repository's branches may be claimed. Neither exists. +## Access between objects: the whole picture + +Every reference in `api/v1alpha3`, and whether it needs a grant. **An object that lends something +lists every grant explicitly: a credential, a branch, or reach into other namespaces.** A +reference that stays in its own namespace and lends nothing needs no grant. + +| Reference | What it lends | Grant | +|---|---|---| +| `GitBranch.gitProviderRef` → `GitProvider` | A credential, and a branch of its repository | **`GitProvider.allowedGitBranches`**, scoped by `branches` | +| `GitTarget.gitBranchRef` → `GitBranch` | Writing a folder into the branch | **`GitBranch.allowedGitTargets`** | +| `GitTarget.kubeProviderRef` → `KubeProvider` | Reading a cluster with its credential | **`KubeProvider.allowedGitTargets`**, scoped by `sourceNamespaces` and `clusterResources` | +| `FluxClaim` / `ArgoCDClaim` `.kubeProviderRef` → `KubeProvider` | Control over an applier, with its credential | **`KubeProvider.allowedApplierClaims`**, scoped by `applierNamespaces` | +| `FluxClaim.kustomizationRef`, `ArgoCDClaim.applicationRef` → the applier in the source cluster | (points into a cluster whose objects we do not own) | Bounded by `applierNamespaces` | +| `WatchRule.rules[].sourceNamespace` → a source-cluster namespace | (same) | Bounded by `sourceNamespaces` | +| `ClusterWatchRule.gitTargetRef` → a `GitTarget` in any namespace | Cluster-scoped reads through that target's `KubeProvider` | Bounded by `clusterResources` on the grant that admits the target | +| `WatchRule.gitTargetRef`, `CommitRequest.gitTargetRef` (own namespace only) | Nothing: what flows in is bounded by the `GitTarget`'s own grants | None | +| `GitTarget.applierClaimRef` (own namespace only) | Nothing new: the applier claim is already bound to one applier by its own grant | None | +| `secretRef`, `knownHostsRef` (own namespace only) | The Kubernetes convention for Secrets | None | +| `GitReceiver` | Nothing references it; a delivery only wakes a worker, which reads the remote with its own credential | None | +| `KubeProvider.inCluster` | The operator's own identity | Consent at install time (`--in-cluster-kube-providers`), unchanged | + +The list is closed. A new kind that references a provider, or lends reach, gets a grant list on the +object it references, following [the rules](#the-rules). + +## Why now + +| Field | In 0.53.0 | In the next release as it stands | +|---|---|---| +| `GitProvider.spec.allowedBranches` | Yes | Yes | +| `GitProvider.spec.accessFrom` | No | New (#428) | +| `KubeProvider.spec.accessFrom`, `.allowCrossNamespace` | On `ClusterProvider`, which the next release removes | New, on a new kind (#430) | + +`allowedBranches` is the only released name among them, and `GitProvider` breaks in that release +anyway (`spec.receiver` is gone, and `GitBranch` takes over the branch). If `accessFrom` ships, a +later rename breaks released manifests. If the rename lands first, those names are never released. + ## The change, surface by surface One PR. Measured with `git grep` on 2026-10-23, outside `docs/reviews`: `accessFrom` is in about 75 @@ -629,6 +713,8 @@ server and an applier. `selector` without `Selector`; a `KubeProvider` `allowedGitTargets` grant with neither `sourceNamespaces` nor `clusterResources` is refused. - [ ] `names: ["{namespace}"]` is refused, with a message pointing to `from: Same`. +- [ ] `from: Selector` with no `selector`, or with `selector: {}`, is refused (use `from: All`); + a non-empty selector is accepted. - [ ] `from: Same` admits the object's own namespace after a kustomize overlay moves the object to another namespace, and admits nothing else; `from: All` admits every namespace. @@ -641,9 +727,15 @@ server and an applier. - [ ] `sourceNamespaces: ["{namespace}"]` refuses `sourceNamespace: "*"`, and `["*"]` allows it. - [ ] Mirroring allowed, applying denied, on one `KubeProvider`, with the `GitTarget` and its applier claim in one namespace. Today's `ApplierAdmitted` shortcut would allow the apply. -- [ ] A `ClusterWatchRule` for `clusterroles` is refused through a target whose grant has no - `clusterResources`, or only `[customresourcedefinitions.apiextensions.k8s.io]`, and compiles - with `[clusterroles]` or `["*"]`. +- [ ] A `ClusterWatchRule` for RBAC `clusterroles` is refused through a target whose grant has no + `clusterResources`, or only `[customresourcedefinitions.apiextensions.k8s.io]`, or a bare + `[clusterroles]`. It compiles with `[clusterroles.rbac.authorization.k8s.io]` or `["*"]`. +- [ ] A `ClusterWatchRule` item with `resources: ["*"]` or `apiGroups: ["*"]` is refused under a + narrow grant, and a CRD installed later is not mirrored. Under `["*"]` it is. +- [ ] A `ClusterWatchRule` naming one permitted and one forbidden type is refused whole, and the + message names the forbidden type. +- [ ] Removing one identity from `clusterResources` refuses the rules naming it, while the + `GitTarget` stays admitted for its other rules. - [ ] Refusal messages name the grants that apply and the requested value, for each act. **Claim and credential** (unit and envtest): From 3e2f23f367db7e48b3b1c5e63c02c52e9b82a1fd Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 17:07:27 +0000 Subject: [PATCH 09/34] feat(api): add access grants with a required from, and branch patterns GitProvider.spec.allowedGitBranches, GitBranch.spec.allowedGitTargets, KubeProvider.spec.allowedGitTargets and KubeProvider.spec.allowedApplierClaims are lists of grants. Each grant has a required enum from (Same, Names, Selector, All); names goes only with Names and selector only with Selector, and an empty selector is refused. Scopes are lists: branches, sourceNamespaces and clusterResources, applierNamespaces. Branch patterns are matched against the whole branch name; * crosses /, and {namespace} must be a whole path segment. CEL checks the grammar and names an unknown variable. GitBranch.spec.gitProviderRef becomes a NamespacedObjectReference. The old fields stay until the admission switch in the next commit. Co-Authored-By: Claude Opus 5.5 (1M context) --- api/v1alpha3/access_grants.go | 572 ++++++++++++++++++ api/v1alpha3/access_grants_test.go | 261 ++++++++ api/v1alpha3/gitbranch_types.go | 40 +- api/v1alpha3/gitprovider_types.go | 11 + api/v1alpha3/kubeprovider_types.go | 19 + api/v1alpha3/zz_generated.deepcopy.go | 150 ++++- .../bases/configbutler.ai_gitbranches.yaml | 126 +++- .../bases/configbutler.ai_gitproviders.yaml | 153 +++++ .../bases/configbutler.ai_kubeproviders.yaml | 288 +++++++++ docs/configuration.md | 6 +- hack/crdfields/fields.yaml | 22 +- internal/applier/dispatcher_test.go | 2 +- internal/authz/gitbranch_admission_test.go | 2 +- .../access_grants_admission_test.go | 150 +++++ .../controller/dependency_watches_test.go | 2 +- .../controller/git_branch_helpers_test.go | 2 +- .../controller/gitbranch_controller_test.go | 2 +- .../controller/gittarget_controller_test.go | 2 +- .../controller/gittarget_immutability_test.go | 2 +- .../gittarget_shared_branch_test.go | 2 +- .../superseded_fields_admission_test.go | 2 +- internal/git/git_branch_ref_test.go | 7 +- internal/watch/event_router_test.go | 2 +- 23 files changed, 1794 insertions(+), 31 deletions(-) create mode 100644 api/v1alpha3/access_grants.go create mode 100644 api/v1alpha3/access_grants_test.go create mode 100644 internal/controller/access_grants_admission_test.go diff --git a/api/v1alpha3/access_grants.go b/api/v1alpha3/access_grants.go new file mode 100644 index 00000000..59addd55 --- /dev/null +++ b/api/v1alpha3/access_grants.go @@ -0,0 +1,572 @@ +// SPDX-License-Identifier: Apache-2.0 + +package v1alpha3 + +import ( + "errors" + "fmt" + "sort" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" +) + +// Access between objects (docs/configuration.md): consent lives on the object +// that lends something, in a list named "allowed" plus the referring kind. Every grant says where +// it admits from with an enum borrowed from Gateway API's allowedRoutes.namespaces.from, required +// here so that no grant admits anyone by omission. Nothing is automatic, the object's own namespace +// included: an omitted or empty list admits nobody, and grants combine by union. + +// GrantFrom is where a grant admits referring objects from. +// +kubebuilder:validation:Enum=Same;Names;Selector;All +type GrantFrom string + +const ( + // GrantFromSame admits the namespace of the object that holds the grant. + GrantFromSame GrantFrom = "Same" + // GrantFromNames admits exactly the namespaces the grant lists in names. + GrantFromNames GrantFrom = "Names" + // GrantFromSelector admits the namespaces whose labels match the grant's selector. + GrantFromSelector GrantFrom = "Selector" + // GrantFromAll admits every namespace. + GrantFromAll GrantFrom = "All" +) + +// NamespaceVariable is the one variable a grant's scope may hold. It stands for the namespace of the +// object asking for access, in the control cluster. +const NamespaceVariable = "{namespace}" + +// GrantNamespace is one control-cluster namespace a grant admits by name. +// +kubebuilder:validation:MaxLength=63 +// +kubebuilder:validation:XValidation:rule="self != '{namespace}'",message="names: '{namespace}' is refused, because it would admit every referring namespace; use from: Same for the namespace of the object holding the grant" +// +kubebuilder:validation:XValidation:rule="self != '*'",message="names: '*' is refused; use from: All to admit every namespace" +// +kubebuilder:validation:XValidation:rule="self == '{namespace}' || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')",message="names holds namespace names (DNS labels)" +type GrantNamespace string + +// ScopeNamespace is one namespace in a source or applier scope: a namespace name, "{namespace}" for +// the namespace of the object asking for access, or "*" for every namespace. +// +kubebuilder:validation:MaxLength=63 +// +kubebuilder:validation:XValidation:rule="self == '*' || self == '{namespace}' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')",message="a namespace scope holds a namespace name, '{namespace}' alone, or '*' alone" +type ScopeNamespace string + +// ClusterResourceIdentity names a cluster-scoped resource type: resource.group, a bare resource for +// the core group, or "*" for every one. +// +kubebuilder:validation:MaxLength=317 +// +kubebuilder:validation:Pattern=`^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*)$` +type ClusterResourceIdentity string + +// The pattern grammar is checked in CEL so a refusal names the mistake. The character set is an +// OpenAPI pattern: git check-ref-format refuses all of these in a branch name, so a pattern holding +// one could never match, and with no '?' or '[' nothing in a pattern ever needs escaping. + +// BranchPattern is a pattern matched against a whole branch name: literal text, '*' for any +// sequence of characters ('/' included), and '{namespace}' as a whole path segment. +// +kubebuilder:validation:MinLength=1 +// +kubebuilder:validation:MaxLength=255 +// +kubebuilder:validation:Pattern=`^[^\s~^:?\[\\]*$` +// +kubebuilder:validation:XValidation:rule="!self.replace('{namespace}', 'x').matches('[{][^{}]*[}]')",messageExpression="'branch pattern ' + self + ' references unknown variable ' + self.replace('{namespace}', 'x').find('[{][^{}]*[}]') + '; the only variable is {namespace}'" +// +kubebuilder:validation:XValidation:rule="self.replace('{namespace}', 'x').matches('[{][^{}]*[}]') || !self.replace('{namespace}', 'x').matches('[{}]')",messageExpression="'branch pattern ' + self + ' has an unmatched brace; the only variable is {namespace}'" +// +kubebuilder:validation:XValidation:rule="!self.matches('[^/][{]namespace[}]') && !self.matches('[{]namespace[}][^/]')",messageExpression="'branch pattern ' + self + ': {namespace} must be a whole path segment, at the start or after a /, and at the end or before a /'" +// +kubebuilder:validation:XValidation:rule="!self.contains('..') && !self.contains('@{') && !self.startsWith('/') && !self.endsWith('/') && !self.contains('//')",messageExpression="'branch pattern ' + self + ' can match no branch name: git refuses .., @{, a leading or trailing / and //'" +type BranchPattern string + +// The source half (from, names, selector) is repeated in each grant type rather than embedded: the +// CEL rules sit on the type, and an inline-embedded struct's type-level rules do not carry over to +// the struct embedding it. + +// NamespaceGrant admits the objects in some control-cluster namespaces, for the act the list holding +// it names. +// +kubebuilder:validation:XValidation:rule="self.from != 'Names' || has(self.names)",message="from: Names needs names" +// +kubebuilder:validation:XValidation:rule="!has(self.names) || self.from == 'Names'",message="names goes only with from: Names" +// +kubebuilder:validation:XValidation:rule="self.from != 'Selector' || has(self.selector)",message="from: Selector needs a selector; to admit every namespace, use from: All" +// +kubebuilder:validation:XValidation:rule="!has(self.selector) || self.from == 'Selector'",message="selector goes only with from: Selector" +// +kubebuilder:validation:XValidation:rule="!has(self.selector) || (has(self.selector.matchLabels) && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) && size(self.selector.matchExpressions) > 0)",message="selector: {} is refused; to admit every namespace, use from: All" +type NamespaceGrant struct { + // From is where this grant admits from: Same (the namespace of the object holding the grant), + // Names (the namespaces in names), Selector (the namespaces whose labels match selector) or All + // (every namespace). + // +required + From GrantFrom `json:"from"` + + // Names are the control-cluster namespaces admitted with from: Names, by exact name. + // +optional + // +listType=set + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=64 + Names []GrantNamespace `json:"names,omitempty"` + + // Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + // something: to admit every namespace, use from: All. + // +optional + Selector *metav1.LabelSelector `json:"selector,omitempty"` +} + +// GitBranchGrant admits GitBranches in some control-cluster namespaces to claim the branches its +// patterns match through a GitProvider. +// +kubebuilder:validation:XValidation:rule="self.from != 'Names' || has(self.names)",message="from: Names needs names" +// +kubebuilder:validation:XValidation:rule="!has(self.names) || self.from == 'Names'",message="names goes only with from: Names" +// +kubebuilder:validation:XValidation:rule="self.from != 'Selector' || has(self.selector)",message="from: Selector needs a selector; to admit every namespace, use from: All" +// +kubebuilder:validation:XValidation:rule="!has(self.selector) || self.from == 'Selector'",message="selector goes only with from: Selector" +// +kubebuilder:validation:XValidation:rule="!has(self.selector) || (has(self.selector.matchLabels) && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) && size(self.selector.matchExpressions) > 0)",message="selector: {} is refused; to admit every namespace, use from: All" +type GitBranchGrant struct { + // From is where this grant admits from: Same (this GitProvider's namespace), Names (the + // namespaces in names), Selector (the namespaces whose labels match selector) or All (every + // namespace). + // +required + From GrantFrom `json:"from"` + + // Names are the control-cluster namespaces admitted with from: Names, by exact name. + // +optional + // +listType=set + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=64 + Names []GrantNamespace `json:"names,omitempty"` + + // Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + // something: to admit every namespace, use from: All. + // +optional + Selector *metav1.LabelSelector `json:"selector,omitempty"` + + // Branches are the branches an admitted GitBranch may claim, as patterns matched against the + // whole branch name. '*' matches any sequence of characters, '/' included, so "*" alone is every + // branch. '{namespace}' is the claiming GitBranch's namespace, and must be a whole path segment: + // "teams/{namespace}/*". There is no escaping, '?' or character class. + // +required + // +listType=set + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=64 + Branches []BranchPattern `json:"branches"` +} + +// SourceGrant admits GitTargets in some control-cluster namespaces to mirror through a KubeProvider, +// and bounds what they may mirror. +// +kubebuilder:validation:XValidation:rule="self.from != 'Names' || has(self.names)",message="from: Names needs names" +// +kubebuilder:validation:XValidation:rule="!has(self.names) || self.from == 'Names'",message="names goes only with from: Names" +// +kubebuilder:validation:XValidation:rule="self.from != 'Selector' || has(self.selector)",message="from: Selector needs a selector; to admit every namespace, use from: All" +// +kubebuilder:validation:XValidation:rule="!has(self.selector) || self.from == 'Selector'",message="selector goes only with from: Selector" +// +kubebuilder:validation:XValidation:rule="!has(self.selector) || (has(self.selector.matchLabels) && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) && size(self.selector.matchExpressions) > 0)",message="selector: {} is refused; to admit every namespace, use from: All" +// +kubebuilder:validation:XValidation:rule="has(self.sourceNamespaces) || has(self.clusterResources)",message="a grant in allowedGitTargets sets sourceNamespaces, clusterResources, or both; one that grants neither grants nothing" +type SourceGrant struct { + // From is where this grant admits from: Same (this KubeProvider's namespace), Names (the + // namespaces in names), Selector (the namespaces whose labels match selector) or All (every + // namespace). + // +required + From GrantFrom `json:"from"` + + // Names are the control-cluster namespaces admitted with from: Names, by exact name. + // +optional + // +listType=set + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=64 + Names []GrantNamespace `json:"names,omitempty"` + + // Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + // something: to admit every namespace, use from: All. + // +optional + Selector *metav1.LabelSelector `json:"selector,omitempty"` + + // SourceNamespaces are the source-cluster namespaces the WatchRules feeding an admitted GitTarget + // may watch: exact names, '{namespace}' for the GitTarget's own namespace, or '*' for every + // namespace, which also allows rules[].sourceNamespace: "*". The list is the whole scope; the + // GitTarget's own namespace is in it only when listed. Omitted, no namespace. + // +optional + // +listType=set + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=64 + SourceNamespaces []ScopeNamespace `json:"sourceNamespaces,omitempty"` + + // ClusterResources are the cluster-scoped resource types a ClusterWatchRule may mirror through an + // admitted GitTarget: resource.group (clusterroles.rbac.authorization.k8s.io), a bare resource for + // the core group only (nodes), or '*' for every one, including CRDs installed later. An identity + // covers every served version. A ClusterWatchRule item with '*' in apiGroups or resources needs + // '*' here. Omitted, none. + // +optional + // +listType=set + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=64 + ClusterResources []ClusterResourceIdentity `json:"clusterResources,omitempty"` +} + +// ApplierGrant admits applier claims in some control-cluster namespaces to hold an applier through a +// KubeProvider, and bounds where that applier may live. +// +kubebuilder:validation:XValidation:rule="self.from != 'Names' || has(self.names)",message="from: Names needs names" +// +kubebuilder:validation:XValidation:rule="!has(self.names) || self.from == 'Names'",message="names goes only with from: Names" +// +kubebuilder:validation:XValidation:rule="self.from != 'Selector' || has(self.selector)",message="from: Selector needs a selector; to admit every namespace, use from: All" +// +kubebuilder:validation:XValidation:rule="!has(self.selector) || self.from == 'Selector'",message="selector goes only with from: Selector" +// +kubebuilder:validation:XValidation:rule="!has(self.selector) || (has(self.selector.matchLabels) && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) && size(self.selector.matchExpressions) > 0)",message="selector: {} is refused; to admit every namespace, use from: All" +type ApplierGrant struct { + // From is where this grant admits from: Same (this KubeProvider's namespace), Names (the + // namespaces in names), Selector (the namespaces whose labels match selector) or All (every + // namespace). + // +required + From GrantFrom `json:"from"` + + // Names are the control-cluster namespaces admitted with from: Names, by exact name. + // +optional + // +listType=set + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=64 + Names []GrantNamespace `json:"names,omitempty"` + + // Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + // something: to admit every namespace, use from: All. + // +optional + Selector *metav1.LabelSelector `json:"selector,omitempty"` + + // ApplierNamespaces are the source-cluster namespaces in which an admitted applier claim's + // Kustomization or Application may live: exact names, '{namespace}' for the applier claim's own + // namespace, or '*' for every namespace. The list is the whole scope. + // +required + // +listType=set + // +kubebuilder:validation:MinItems=1 + // +kubebuilder:validation:MaxItems=64 + ApplierNamespaces []ScopeNamespace `json:"applierNamespaces"` +} + +// source is the admitting half of a GitBranchGrant. +func (g *GitBranchGrant) source() NamespaceGrant { + return NamespaceGrant{From: g.From, Names: g.Names, Selector: g.Selector} +} + +// source is the admitting half of a SourceGrant. +func (g *SourceGrant) source() NamespaceGrant { + return NamespaceGrant{From: g.From, Names: g.Names, Selector: g.Selector} +} + +// source is the admitting half of an ApplierGrant. +func (g *ApplierGrant) source() NamespaceGrant { + return NamespaceGrant{From: g.From, Names: g.Names, Selector: g.Selector} +} + +// Admits reports whether this grant, held by an object in holderNamespace, admits a referring +// object in namespace, by the labels that namespace carries in the control cluster. A selector that +// does not convert is an error, never a silent allow; an unknown from admits nothing. +func (g *NamespaceGrant) Admits(holderNamespace, namespace string, nsLabels map[string]string) (bool, error) { + switch g.From { + case GrantFromSame: + return namespace == holderNamespace, nil + case GrantFromNames: + for _, name := range g.Names { + if string(name) == namespace { + return true, nil + } + } + return false, nil + case GrantFromSelector: + if g.Selector == nil { + return false, nil + } + selector, err := metav1.LabelSelectorAsSelector(g.Selector) + if err != nil { + return false, fmt.Errorf("selector %s: %w", metav1.FormatLabelSelector(g.Selector), err) + } + if selector.Empty() { + // selector: {} is refused at admission; a stored one admits nobody rather than everyone. + return false, nil + } + return selector.Matches(labels.Set(nsLabels)), nil + case GrantFromAll: + return true, nil + } + return false, nil +} + +// Describe names the namespaces this grant admits, for a refusal message. +func (g *NamespaceGrant) Describe(holderNamespace string) string { + switch g.From { + case GrantFromSame: + return holderNamespace + case GrantFromNames: + names := make([]string, 0, len(g.Names)) + for _, name := range g.Names { + names = append(names, string(name)) + } + return strings.Join(names, ", ") + case GrantFromSelector: + if g.Selector == nil { + return "no namespace" + } + return "namespaces labelled " + metav1.FormatLabelSelector(g.Selector) + case GrantFromAll: + return "every namespace" + } + return fmt.Sprintf("no namespace (from %q is unknown)", g.From) +} + +// admitAll evaluates every grant's source for namespace and returns which admit it. A grant whose +// selector does not convert admits nothing; its error is returned only when no grant admits, so +// one malformed grant cannot widen anything, and an admitted namespace is not refused over a grant +// that did not apply to it. +func admitAll( + sources []NamespaceGrant, holderNamespace, namespace string, nsLabels map[string]string, +) ([]int, error) { + var admitting []int + var errs []error + for i := range sources { + admitted, err := sources[i].Admits(holderNamespace, namespace, nsLabels) + if err != nil { + errs = append(errs, fmt.Errorf("grant %d: %w", i, err)) + continue + } + if admitted { + admitting = append(admitting, i) + } + } + if len(admitting) == 0 && len(errs) > 0 { + return nil, errors.Join(errs...) + } + return admitting, nil +} + +// describeAdmitted names the namespaces a list of grants admits, for a refusal message: "admits +// team-a, team-b" or "admits no namespace". +func describeAdmitted(sources []NamespaceGrant, holderNamespace string) string { + if len(sources) == 0 { + return "admits no namespace" + } + parts := make([]string, 0, len(sources)) + for i := range sources { + parts = append(parts, sources[i].Describe(holderNamespace)) + } + return "admits " + strings.Join(parts, "; ") +} + +// expandScope replaces '{namespace}' in each scope entry with namespace, and returns the entries +// sorted and without duplicates. +func expandScope[S ~string](entries []S, namespace string) []string { + out := make([]string, 0, len(entries)) + for _, entry := range entries { + out = append(out, strings.ReplaceAll(string(entry), NamespaceVariable, namespace)) + } + return sortedUniqueStrings(out) +} + +func sortedUniqueStrings(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 +} + +// MatchBranchPattern reports whether branch matches pattern for a GitBranch in namespace: the +// pattern is matched against the whole branch name, '*' matches any sequence of characters, '/' +// included, and '{namespace}' is namespace. Nothing else is special. +func MatchBranchPattern(pattern BranchPattern, namespace, branch string) bool { + return globMatch(strings.ReplaceAll(string(pattern), NamespaceVariable, namespace), branch) +} + +// globMatch matches name against a pattern in which '*' is the only special character, and matches +// any sequence, the empty one included. +func globMatch(pattern, name string) bool { + parts := strings.Split(pattern, "*") + if len(parts) == 1 { + return pattern == name + } + if !strings.HasPrefix(name, parts[0]) { + return false + } + name = name[len(parts[0]):] + last := parts[len(parts)-1] + for _, part := range parts[1 : len(parts)-1] { + index := strings.Index(name, part) + if index < 0 { + return false + } + name = name[index+len(part):] + } + return strings.HasSuffix(name, last) && len(name) >= len(last) +} + +// ClusterResourceIdentityFor is the identity of a resource in group: resource.group, or the bare +// resource for the core group. +func ClusterResourceIdentityFor(group, resource string) string { + if group == "" { + return resource + } + return resource + "." + group +} + +// GitBranchAdmission is what a GitProvider's allowedGitBranches give a GitBranch's namespace. +// +kubebuilder:object:generate=false +type GitBranchAdmission struct { + // Admitted reports whether some grant admits the namespace. + Admitted bool + // Branches are the patterns the admitting grants give the namespace, '{namespace}' expanded. + Branches []string + // Allowed reports whether the namespace is admitted and the branch matches one of Branches. + Allowed bool +} + +// AdmitsGitBranch reports what spec.allowedGitBranches gives a GitBranch in namespace, by the labels +// that namespace carries in the control cluster, and whether it may claim branch. +func (p *GitProvider) AdmitsGitBranch( + namespace string, nsLabels map[string]string, branch string, +) (GitBranchAdmission, error) { + grants := p.Spec.AllowedGitBranches + sources := make([]NamespaceGrant, len(grants)) + for i := range grants { + sources[i] = grants[i].source() + } + admitting, err := admitAll(sources, p.Namespace, namespace, nsLabels) + if err != nil || len(admitting) == 0 { + return GitBranchAdmission{}, err + } + out := GitBranchAdmission{Admitted: true} + var patterns []BranchPattern + for _, i := range admitting { + for _, pattern := range grants[i].Branches { + patterns = append(patterns, pattern) + if MatchBranchPattern(pattern, namespace, branch) { + out.Allowed = true + } + } + } + out.Branches = expandScope(patterns, namespace) + return out, nil +} + +// DescribeAllowedGitBranches names the namespaces spec.allowedGitBranches admits. +func (p *GitProvider) DescribeAllowedGitBranches() string { + sources := make([]NamespaceGrant, len(p.Spec.AllowedGitBranches)) + for i := range p.Spec.AllowedGitBranches { + sources[i] = p.Spec.AllowedGitBranches[i].source() + } + return describeAdmitted(sources, p.Namespace) +} + +// AdmitsGitTarget reports whether spec.allowedGitTargets admits a GitTarget in namespace, by the +// labels that namespace carries in the control cluster. +func (b *GitBranch) AdmitsGitTarget(namespace string, nsLabels map[string]string) (bool, error) { + admitting, err := admitAll(b.Spec.AllowedGitTargets, b.Namespace, namespace, nsLabels) + return len(admitting) > 0, err +} + +// DescribeAllowedGitTargets names the namespaces spec.allowedGitTargets admits. +func (b *GitBranch) DescribeAllowedGitTargets() string { + return describeAdmitted(b.Spec.AllowedGitTargets, b.Namespace) +} + +// SourceScope is what a KubeProvider's allowedGitTargets give a GitTarget's namespace: whether it may +// mirror at all, and what. +// +kubebuilder:object:generate=false +type SourceScope struct { + // Admitted reports whether some grant admits the namespace. + Admitted bool + // SourceNamespaces is the union of the admitting grants' sourceNamespaces, '{namespace}' expanded. + // "*" is every namespace. + SourceNamespaces []string + // ClusterResources is the union of the admitting grants' clusterResources. "*" is every + // cluster-scoped resource. + ClusterResources []string +} + +// AllowsSourceNamespace reports whether a WatchRule may watch namespace; "*" asks for every +// namespace, which only a "*" in the scope allows. +func (s SourceScope) AllowsSourceNamespace(namespace string) bool { + return scopeAllows(s.SourceNamespaces, namespace) +} + +// AllowsAllClusterResources reports whether the scope holds "*" in clusterResources. +func (s SourceScope) AllowsAllClusterResources() bool { + return scopeAllows(s.ClusterResources, "*") +} + +// AllowsClusterResource reports whether a ClusterWatchRule may mirror resource in group. +func (s SourceScope) AllowsClusterResource(group, resource string) bool { + return scopeAllows(s.ClusterResources, ClusterResourceIdentityFor(group, resource)) +} + +// scopeAllows reports whether scope holds value or "*". +func scopeAllows(scope []string, value string) bool { + for _, entry := range scope { + if entry == "*" || entry == value { + return true + } + } + return false +} + +// AdmitsGitTarget reports what spec.allowedGitTargets gives a GitTarget in namespace, by the labels +// that namespace carries in the control cluster. +func (p *KubeProvider) AdmitsGitTarget(namespace string, nsLabels map[string]string) (SourceScope, error) { + grants := p.Spec.AllowedGitTargets + sources := make([]NamespaceGrant, len(grants)) + for i := range grants { + sources[i] = grants[i].source() + } + admitting, err := admitAll(sources, p.Namespace, namespace, nsLabels) + if err != nil || len(admitting) == 0 { + return SourceScope{}, err + } + var namespaces []ScopeNamespace + var resources []ClusterResourceIdentity + for _, i := range admitting { + namespaces = append(namespaces, grants[i].SourceNamespaces...) + resources = append(resources, grants[i].ClusterResources...) + } + return SourceScope{ + Admitted: true, + SourceNamespaces: expandScope(namespaces, namespace), + ClusterResources: expandScope(resources, namespace), + }, nil +} + +// DescribeAllowedGitTargets names the namespaces spec.allowedGitTargets admits. +func (p *KubeProvider) DescribeAllowedGitTargets() string { + sources := make([]NamespaceGrant, len(p.Spec.AllowedGitTargets)) + for i := range p.Spec.AllowedGitTargets { + sources[i] = p.Spec.AllowedGitTargets[i].source() + } + return describeAdmitted(sources, p.Namespace) +} + +// ApplierScope is what a KubeProvider's allowedApplierClaims give an applier claim's namespace. +// +kubebuilder:object:generate=false +type ApplierScope struct { + // Admitted reports whether some grant admits the namespace. + Admitted bool + // ApplierNamespaces is the union of the admitting grants' applierNamespaces, '{namespace}' + // expanded. "*" is every namespace. + ApplierNamespaces []string +} + +// AllowsApplierNamespace reports whether an applier in namespace may be held. +func (s ApplierScope) AllowsApplierNamespace(namespace string) bool { + return scopeAllows(s.ApplierNamespaces, namespace) +} + +// AdmitsApplierClaim reports what spec.allowedApplierClaims gives an applier claim in namespace, by +// the labels that namespace carries in the control cluster. +func (p *KubeProvider) AdmitsApplierClaim(namespace string, nsLabels map[string]string) (ApplierScope, error) { + grants := p.Spec.AllowedApplierClaims + sources := make([]NamespaceGrant, len(grants)) + for i := range grants { + sources[i] = grants[i].source() + } + admitting, err := admitAll(sources, p.Namespace, namespace, nsLabels) + if err != nil || len(admitting) == 0 { + return ApplierScope{}, err + } + var namespaces []ScopeNamespace + for _, i := range admitting { + namespaces = append(namespaces, grants[i].ApplierNamespaces...) + } + return ApplierScope{Admitted: true, ApplierNamespaces: expandScope(namespaces, namespace)}, nil +} + +// DescribeAllowedApplierClaims names the namespaces spec.allowedApplierClaims admits. +func (p *KubeProvider) DescribeAllowedApplierClaims() string { + sources := make([]NamespaceGrant, len(p.Spec.AllowedApplierClaims)) + for i := range p.Spec.AllowedApplierClaims { + sources[i] = p.Spec.AllowedApplierClaims[i].source() + } + return describeAdmitted(sources, p.Namespace) +} diff --git a/api/v1alpha3/access_grants_test.go b/api/v1alpha3/access_grants_test.go new file mode 100644 index 00000000..38e1fa9f --- /dev/null +++ b/api/v1alpha3/access_grants_test.go @@ -0,0 +1,261 @@ +// 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" +) + +func TestMatchBranchPattern(t *testing.T) { + tests := []struct { + pattern BranchPattern + namespace string + branch string + matches bool + }{ + {"teams/{namespace}/*", "team-a", "teams/team-a/live", true}, + {"teams/{namespace}/*", "team-a", "teams/team-ab/live", false}, + {"teams/{namespace}/*", "team-a", "teams/team-a", false}, + {"teams/{namespace}/*", "team-a", "teams/team-a/", true}, + {"feature/*", "x", "feature/checkout/v2", true}, + {"feature/*", "x", "feature", false}, + {"*", "x", "main", true}, + {"*", "x", "teams/a/b", true}, + {"teams/*", "x", "teams/a/b", true}, + {"main", "x", "main", true}, + {"main", "x", "main-old", false}, + {"main", "x", "old-main", false}, + {"release/*/hotfix", "x", "release/1.2/hotfix", true}, + {"release/*/hotfix", "x", "release/1.2/hotfix/2", false}, + {"*-old", "x", "main-old", true}, + {"a*a", "x", "a", false}, + {"a*a", "x", "aa", true}, + {"{namespace}", "team-a", "team-a", true}, + {"{namespace}/*", "team-a", "team-b/x", false}, + } + for _, tt := range tests { + t.Run(string(tt.pattern)+"~"+tt.branch, func(t *testing.T) { + assert.Equal(t, tt.matches, MatchBranchPattern(tt.pattern, tt.namespace, tt.branch)) + }) + } +} + +func TestNamespaceGrant_Admits(t *testing.T) { + tenant := map[string]string{"tenant": "true"} + tests := []struct { + name string + grant NamespaceGrant + namespace string + labels map[string]string + admits bool + }{ + {"Same admits the holder's namespace", NamespaceGrant{From: GrantFromSame}, "platform", nil, true}, + {"Same admits nothing else", NamespaceGrant{From: GrantFromSame}, "team-a", tenant, false}, + {"Names admits a listed namespace", + NamespaceGrant{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "team-a", nil, true}, + {"Names admits nothing else", + NamespaceGrant{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "team-b", nil, false}, + {"Selector admits a matching namespace", NamespaceGrant{From: GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: tenant}}, "team-a", tenant, true}, + {"Selector admits nothing else", NamespaceGrant{From: GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: tenant}}, "team-a", nil, false}, + {"an empty selector admits nobody", NamespaceGrant{From: GrantFromSelector, + Selector: &metav1.LabelSelector{}}, "team-a", tenant, false}, + {"All admits every namespace", NamespaceGrant{From: GrantFromAll}, "anything", nil, true}, + {"an unknown from admits nothing", NamespaceGrant{From: "Everyone"}, "platform", nil, false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + admits, err := tt.grant.Admits("platform", tt.namespace, tt.labels) + require.NoError(t, err) + assert.Equal(t, tt.admits, admits) + }) + } +} + +// TestNamespaceGrant_SameFollowsTheHolder: Same names no namespace, so a kustomize overlay that moves +// the object holding the grant moves what it admits with it. +func TestNamespaceGrant_SameFollowsTheHolder(t *testing.T) { + branch := &GitBranch{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a"}, + Spec: GitBranchSpec{AllowedGitTargets: []NamespaceGrant{{From: GrantFromSame}}}, + } + admits, err := branch.AdmitsGitTarget("team-a", nil) + require.NoError(t, err) + assert.True(t, admits) + + branch.Namespace = "team-a-staging" // the overlay's namespace: + admits, err = branch.AdmitsGitTarget("team-a-staging", nil) + require.NoError(t, err) + assert.True(t, admits, "Same admits the namespace the object is in now") + admits, err = branch.AdmitsGitTarget("team-a", nil) + require.NoError(t, err) + assert.False(t, admits, "and nothing else") +} + +func TestNamespaceGrant_InvalidSelectorIsAnError(t *testing.T) { + grant := NamespaceGrant{From: GrantFromSelector, Selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "tenant", Operator: "Bogus"}}, + }} + _, err := grant.Admits("platform", "team-a", nil) + require.Error(t, err) + + // One malformed grant refuses nobody another grant admits, and admits nobody itself. + branch := &GitBranch{ObjectMeta: metav1.ObjectMeta{Namespace: "platform"}, + Spec: GitBranchSpec{AllowedGitTargets: []NamespaceGrant{grant, {From: GrantFromSame}}}} + admits, err := branch.AdmitsGitTarget("platform", nil) + require.NoError(t, err) + assert.True(t, admits) + admits, err = branch.AdmitsGitTarget("team-a", nil) + require.Error(t, err) + assert.False(t, admits) +} + +func TestGitBranch_AdmitsGitTarget_NothingAutomatic(t *testing.T) { + branch := &GitBranch{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a"}} + admits, err := branch.AdmitsGitTarget("team-a", nil) + require.NoError(t, err) + assert.False(t, admits, "an omitted list admits nobody, the GitBranch's own namespace included") + assert.Equal(t, "admits no namespace", branch.DescribeAllowedGitTargets()) + + branch.Spec.AllowedGitTargets = []NamespaceGrant{{From: GrantFromSame}, + {From: GrantFromNames, Names: []GrantNamespace{"team-b"}}} + assert.Equal(t, "admits team-a; team-b", branch.DescribeAllowedGitTargets()) +} + +func TestGitProvider_AdmitsGitBranch(t *testing.T) { + provider := &GitProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "shared-repo"}, + Spec: GitProviderSpec{AllowedGitBranches: []GitBranchGrant{ + {From: GrantFromSame, Branches: []BranchPattern{"main", "release/*"}}, + {From: GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tenant": "true"}}, + Branches: []BranchPattern{"teams/{namespace}/*", "feature/*"}}, + }}, + } + tenant := map[string]string{"tenant": "true"} + + got, err := provider.AdmitsGitBranch("team-a", tenant, "teams/team-a/live") + require.NoError(t, err) + assert.Equal(t, GitBranchAdmission{Admitted: true, Allowed: true, + Branches: []string{"feature/*", "teams/team-a/*"}}, got) + + got, err = provider.AdmitsGitBranch("team-a", tenant, "main") + require.NoError(t, err) + assert.True(t, got.Admitted) + assert.False(t, got.Allowed, "main is the platform's") + + got, err = provider.AdmitsGitBranch("team-a", tenant, "teams/team-b/live") + require.NoError(t, err) + assert.False(t, got.Allowed, "another team's prefix") + + got, err = provider.AdmitsGitBranch("team-c", nil, "teams/team-c/live") + require.NoError(t, err) + assert.False(t, got.Admitted, "no grant admits an unlabelled namespace") + + got, err = provider.AdmitsGitBranch("platform", nil, "release/1.0") + require.NoError(t, err) + assert.True(t, got.Allowed) + + provider.Spec.AllowedGitBranches = nil + got, err = provider.AdmitsGitBranch("platform", nil, "main") + require.NoError(t, err) + assert.False(t, got.Admitted, "nothing is automatic, the provider's own namespace included") +} + +func TestKubeProvider_AdmitsGitTarget(t *testing.T) { + tenant := map[string]string{"tenant": "true"} + provider := &KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "production"}, + Spec: KubeProviderSpec{AllowedGitTargets: []SourceGrant{ + {From: GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}, + SourceNamespaces: []ScopeNamespace{"{namespace}"}}, + {From: GrantFromNames, Names: []GrantNamespace{"platform-audit"}, + SourceNamespaces: []ScopeNamespace{"*"}, ClusterResources: []ClusterResourceIdentity{"*"}}, + {From: GrantFromNames, Names: []GrantNamespace{"rbac-mirror"}, + ClusterResources: []ClusterResourceIdentity{"clusterroles.rbac.authorization.k8s.io", "nodes"}}, + }}, + } + + scope, err := provider.AdmitsGitTarget("team-a", tenant) + require.NoError(t, err) + assert.True(t, scope.Admitted) + assert.True(t, scope.AllowsSourceNamespace("team-a")) + assert.False(t, scope.AllowsSourceNamespace("kube-system")) + assert.False(t, scope.AllowsSourceNamespace("*"), "{namespace} does not allow a wildcard") + assert.False(t, scope.AllowsClusterResource("", "nodes")) + + scope, err = provider.AdmitsGitTarget("platform-audit", nil) + require.NoError(t, err) + assert.True(t, scope.AllowsSourceNamespace("*")) + assert.True(t, scope.AllowsSourceNamespace("kube-system")) + assert.True(t, scope.AllowsAllClusterResources()) + + scope, err = provider.AdmitsGitTarget("rbac-mirror", nil) + require.NoError(t, err) + assert.True(t, scope.Admitted) + assert.Empty(t, scope.SourceNamespaces, "clusterResources alone grants no namespace") + assert.True(t, scope.AllowsClusterResource("rbac.authorization.k8s.io", "clusterroles")) + assert.True(t, scope.AllowsClusterResource("", "nodes")) + assert.False(t, scope.AllowsClusterResource("", "clusterroles"), "a bare name is the core group only") + assert.False(t, scope.AllowsAllClusterResources()) + + scope, err = provider.AdmitsGitTarget("platform", nil) + require.NoError(t, err) + assert.False(t, scope.Admitted, "the provider's own namespace needs a grant") +} + +// TestKubeProvider_AdmitsGitTarget_UnionOfScopes: a namespace two grants admit gets both scopes. +func TestKubeProvider_AdmitsGitTarget_UnionOfScopes(t *testing.T) { + provider := &KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform"}, + Spec: KubeProviderSpec{AllowedGitTargets: []SourceGrant{ + {From: GrantFromAll, SourceNamespaces: []ScopeNamespace{"{namespace}"}}, + {From: GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"audit": "true"}}, + SourceNamespaces: []ScopeNamespace{"*"}}, + }}, + } + scope, err := provider.AdmitsGitTarget("team-a", map[string]string{"audit": "true"}) + require.NoError(t, err) + assert.Equal(t, []string{"*", "team-a"}, scope.SourceNamespaces) + + scope, err = provider.AdmitsGitTarget("team-a", nil) + require.NoError(t, err) + assert.Equal(t, []string{"team-a"}, scope.SourceNamespaces, "losing the label narrows the scope") +} + +func TestKubeProvider_AdmitsApplierClaim(t *testing.T) { + provider := &KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform"}, + Spec: KubeProviderSpec{ + AllowedGitTargets: []SourceGrant{{From: GrantFromAll, SourceNamespaces: []ScopeNamespace{"{namespace}"}}}, + AllowedApplierClaims: []ApplierGrant{ + { + From: GrantFromNames, + Names: []GrantNamespace{"team-a"}, + ApplierNamespaces: []ScopeNamespace{"flux-system"}, + }, + }, + }, + } + scope, err := provider.AdmitsApplierClaim("team-a", nil) + require.NoError(t, err) + assert.True(t, scope.AllowsApplierNamespace("flux-system")) + assert.False(t, scope.AllowsApplierNamespace("team-a"), "an explicit list is the whole scope") + + scope, err = provider.AdmitsApplierClaim("team-b", nil) + require.NoError(t, err) + assert.False(t, scope.Admitted, "admission to mirror does not admit to apply") + assert.Equal(t, "admits team-a", provider.DescribeAllowedApplierClaims()) +} + +func TestGitBranch_GitProviderKey(t *testing.T) { + branch := &GitBranch{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a"}} + branch.Spec.GitProviderRef.Name = "shared-repo" + assert.Equal(t, "team-a/shared-repo", branch.GitProviderKey().String(), "omitted is the own namespace") + branch.Spec.GitProviderRef.Namespace = "platform" + assert.Equal(t, "platform/shared-repo", branch.GitProviderKey().String()) +} diff --git a/api/v1alpha3/gitbranch_types.go b/api/v1alpha3/gitbranch_types.go index 9c2e998c..d8a1ff87 100644 --- a/api/v1alpha3/gitbranch_types.go +++ b/api/v1alpha3/gitbranch_types.go @@ -10,24 +10,24 @@ import ( // GitBranchSpec defines the desired state of GitBranch. // -// Every field is immutable: together they are the repository branch this object claims, and a -// claim that could be repointed would move a branch worker's checkout, credential and pending -// writes to another branch under the GitTargets that reference it. Delete and recreate instead. +// gitProviderRef, branch and parentBranch are immutable: together they are the repository branch +// this object claims, and a claim that could be repointed would move a branch worker's checkout, +// credential and pending writes to another branch under the GitTargets that reference it. Delete +// and recreate instead. allowedGitTargets is the branch owner's policy and stays mutable. +// +// CEL cannot read metadata.namespace, so the reference is compared as written: spelling out the +// GitBranch's own namespace where it was omitted is refused too. // +kubebuilder:validation:XValidation:rule="self.gitProviderRef == oldSelf.gitProviderRef",message="spec.gitProviderRef is immutable; delete and recreate the GitBranch to change it" // +kubebuilder:validation:XValidation:rule="self.branch == oldSelf.branch",message="spec.branch is immutable; delete and recreate the GitBranch to change it" // +kubebuilder:validation:XValidation:rule="has(self.parentBranch) == has(oldSelf.parentBranch) && (!has(self.parentBranch) || self.parentBranch == oldSelf.parentBranch)",message="spec.parentBranch is immutable; delete and recreate the GitBranch to change it" type GitBranchSpec struct { - // A LocalObjectReference on purpose: creating a GitBranch then needs the right to create objects - // in the namespace that holds the credential, which is what makes a branch claim an act of the - // credential's owner. Other namespaces reach the branch through the GitProvider's accessFrom. - - // GitProviderRef names the GitProvider, in this GitBranch's namespace, whose repository and - // credential this branch is written with. GitTargets in other namespaces may reference this - // GitBranch while that GitProvider's spec.accessFrom admits their namespace. + // GitProviderRef names the GitProvider whose repository and credential this branch is written + // with. Namespace omitted means this GitBranch's own namespace. The GitBranch claims its branch + // only while that GitProvider's spec.allowedGitBranches admits this namespace and the branch. // Immutable: delete and recreate the GitBranch to change it. // +required // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.gitProviderRef.name must not be empty" - GitProviderRef meta.LocalObjectReference `json:"gitProviderRef"` + GitProviderRef meta.NamespacedObjectReference `json:"gitProviderRef"` // Branch is the branch on the remote that this GitBranch writes. It must match one of the // GitProvider's allowedBranches. Only one GitBranch writes a repository branch: the oldest one @@ -54,6 +54,15 @@ type GitBranchSpec struct { // +optional // +kubebuilder:validation:MinLength=1 ParentBranch string `json:"parentBranch,omitempty"` + + // AllowedGitTargets says which GitTargets may write through this GitBranch. Nothing is admitted + // automatically, this GitBranch's own namespace included: a GitTarget there needs a grant too, + // such as from: Same. Omitted or empty, no GitTarget writes through it. Grants combine by union. + // Mutable: claiming a branch includes the right to share it. + // +optional + // +listType=atomic + // +kubebuilder:validation:MaxItems=64 + AllowedGitTargets []NamespaceGrant `json:"allowedGitTargets,omitempty"` } // GitBranchStatus defines the observed state of GitBranch. @@ -171,6 +180,7 @@ type GitBranchParentStatus struct { // +kubebuilder:object:root=true // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Provider",type=string,JSONPath=`.spec.gitProviderRef.name` +// +kubebuilder:printcolumn:name="ProviderNamespace",type=string,JSONPath=`.spec.gitProviderRef.namespace` // +kubebuilder:printcolumn:name="Branch",type=string,JSONPath=`.spec.branch` // +kubebuilder:printcolumn:name="Commit",type=string,JSONPath=`.status.remote.commit` // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` @@ -201,9 +211,13 @@ type GitBranch struct { } // GitProviderKey is the GitProvider this GitBranch writes through: spec.gitProviderRef, in this -// GitBranch's own namespace. +// GitBranch's own namespace unless the reference names another one. func (b *GitBranch) GitProviderKey() types.NamespacedName { - return types.NamespacedName{Namespace: b.Namespace, Name: b.Spec.GitProviderRef.Name} + namespace := b.Spec.GitProviderRef.Namespace + if namespace == "" { + namespace = b.Namespace + } + return types.NamespacedName{Namespace: namespace, Name: b.Spec.GitProviderRef.Name} } // +kubebuilder:object:root=true diff --git a/api/v1alpha3/gitprovider_types.go b/api/v1alpha3/gitprovider_types.go index 5c4f7f8a..1969c27b 100644 --- a/api/v1alpha3/gitprovider_types.go +++ b/api/v1alpha3/gitprovider_types.go @@ -58,6 +58,17 @@ type GitProviderSpec struct { // +optional AccessFrom *NamespaceMatcher `json:"accessFrom,omitempty"` + // AllowedGitBranches says which GitBranches may claim a branch of this repository through this + // GitProvider, and which branches: a GitBranch claims only while a grant admits its namespace and + // one of that grant's branches matches its spec.branch. Nothing is admitted automatically, this + // GitProvider's own namespace included; omitted or empty, no GitBranch claims through it. Grants + // combine by union. Who may write through a claimed branch is that GitBranch's + // spec.allowedGitTargets. + // +optional + // +listType=atomic + // +kubebuilder:validation:MaxItems=64 + AllowedGitBranches []GitBranchGrant `json:"allowedGitBranches,omitempty"` + // AllowedBranches restricts which branches can be written to. // +required // +kubebuilder:validation:MinItems=1 diff --git a/api/v1alpha3/kubeprovider_types.go b/api/v1alpha3/kubeprovider_types.go index 0924006b..a4bd35c4 100644 --- a/api/v1alpha3/kubeprovider_types.go +++ b/api/v1alpha3/kubeprovider_types.go @@ -58,6 +58,25 @@ type KubeProviderSpec struct { // +optional AllowCrossNamespace *KubeProviderCrossNamespace `json:"allowCrossNamespace,omitempty"` + // AllowedGitTargets says which GitTargets may mirror through this provider, and what: each grant + // admits some control-cluster namespaces and bounds what the WatchRules feeding a GitTarget there + // may watch (sourceNamespaces), and what a ClusterWatchRule may mirror through it + // (clusterResources). Nothing is admitted automatically, this KubeProvider's own namespace + // included; omitted or empty, no GitTarget mirrors through it. Grants combine by union. + // +optional + // +listType=atomic + // +kubebuilder:validation:MaxItems=64 + AllowedGitTargets []SourceGrant `json:"allowedGitTargets,omitempty"` + + // AllowedApplierClaims says which applier claims (FluxClaims and ArgoCDClaims) may hold an applier + // through this provider, and in which namespaces that applier may live (applierNamespaces). + // Admission to mirror does not admit to apply. Nothing is admitted automatically; omitted or + // empty, no applier claim holds an applier through it. Grants combine by union. + // +optional + // +listType=atomic + // +kubebuilder:validation:MaxItems=64 + AllowedApplierClaims []ApplierGrant `json:"allowedApplierClaims,omitempty"` + // Client overrides the operator's outgoing kube-client throttles for this cluster. Omitted, // the built-in defaults apply (20 QPS, burst 30). Refused beside inCluster. // +optional diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index f4af1e50..69ed7e76 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -63,6 +63,36 @@ func (in *ApplierClaimReference) DeepCopy() *ApplierClaimReference { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ApplierGrant) DeepCopyInto(out *ApplierGrant) { + *out = *in + if in.Names != nil { + in, out := &in.Names, &out.Names + *out = make([]GrantNamespace, len(*in)) + copy(*out, *in) + } + if in.Selector != nil { + in, out := &in.Selector, &out.Selector + *out = new(v1.LabelSelector) + (*in).DeepCopyInto(*out) + } + if in.ApplierNamespaces != nil { + in, out := &in.ApplierNamespaces, &out.ApplierNamespaces + *out = make([]ScopeNamespace, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplierGrant. +func (in *ApplierGrant) DeepCopy() *ApplierGrant { + if in == nil { + return nil + } + out := new(ApplierGrant) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ApplyOperation) DeepCopyInto(out *ApplyOperation) { *out = *in @@ -743,7 +773,7 @@ func (in *GitBranch) DeepCopyInto(out *GitBranch) { *out = *in out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) - out.Spec = in.Spec + in.Spec.DeepCopyInto(&out.Spec) in.Status.DeepCopyInto(&out.Status) } @@ -765,6 +795,36 @@ func (in *GitBranch) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitBranchGrant) DeepCopyInto(out *GitBranchGrant) { + *out = *in + if in.Names != nil { + in, out := &in.Names, &out.Names + *out = make([]GrantNamespace, len(*in)) + copy(*out, *in) + } + if in.Selector != nil { + in, out := &in.Selector, &out.Selector + *out = new(v1.LabelSelector) + (*in).DeepCopyInto(*out) + } + if in.Branches != nil { + in, out := &in.Branches, &out.Branches + *out = make([]BranchPattern, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitBranchGrant. +func (in *GitBranchGrant) DeepCopy() *GitBranchGrant { + if in == nil { + return nil + } + out := new(GitBranchGrant) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitBranchList) DeepCopyInto(out *GitBranchList) { *out = *in @@ -840,6 +900,13 @@ func (in *GitBranchRemoteStatus) DeepCopy() *GitBranchRemoteStatus { func (in *GitBranchSpec) DeepCopyInto(out *GitBranchSpec) { *out = *in out.GitProviderRef = in.GitProviderRef + if in.AllowedGitTargets != nil { + in, out := &in.AllowedGitTargets, &out.AllowedGitTargets + *out = make([]NamespaceGrant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitBranchSpec. @@ -960,6 +1027,13 @@ func (in *GitProviderSpec) DeepCopyInto(out *GitProviderSpec) { *out = new(NamespaceMatcher) (*in).DeepCopyInto(*out) } + if in.AllowedGitBranches != nil { + in, out := &in.AllowedGitBranches, &out.AllowedGitBranches + *out = make([]GitBranchGrant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.AllowedBranches != nil { in, out := &in.AllowedBranches, &out.AllowedBranches *out = make([]string, len(*in)) @@ -1520,6 +1594,20 @@ func (in *KubeProviderSpec) DeepCopyInto(out *KubeProviderSpec) { *out = new(KubeProviderCrossNamespace) **out = **in } + if in.AllowedGitTargets != nil { + in, out := &in.AllowedGitTargets, &out.AllowedGitTargets + *out = make([]SourceGrant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.AllowedApplierClaims != nil { + in, out := &in.AllowedApplierClaims, &out.AllowedApplierClaims + *out = make([]ApplierGrant, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.Client != nil { in, out := &in.Client, &out.Client *out = new(KubeProviderClient) @@ -1564,6 +1652,31 @@ func (in *KubeProviderStatus) DeepCopy() *KubeProviderStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NamespaceGrant) DeepCopyInto(out *NamespaceGrant) { + *out = *in + if in.Names != nil { + in, out := &in.Names, &out.Names + *out = make([]GrantNamespace, len(*in)) + copy(*out, *in) + } + if in.Selector != nil { + in, out := &in.Selector, &out.Selector + *out = new(v1.LabelSelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceGrant. +func (in *NamespaceGrant) DeepCopy() *NamespaceGrant { + if in == nil { + return nil + } + out := new(NamespaceGrant) + in.DeepCopyInto(out) + 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 @@ -1654,6 +1767,41 @@ func (in *ResourceRule) DeepCopy() *ResourceRule { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SourceGrant) DeepCopyInto(out *SourceGrant) { + *out = *in + if in.Names != nil { + in, out := &in.Names, &out.Names + *out = make([]GrantNamespace, len(*in)) + copy(*out, *in) + } + if in.Selector != nil { + in, out := &in.Selector, &out.Selector + *out = new(v1.LabelSelector) + (*in).DeepCopyInto(*out) + } + if in.SourceNamespaces != nil { + in, out := &in.SourceNamespaces, &out.SourceNamespaces + *out = make([]ScopeNamespace, len(*in)) + copy(*out, *in) + } + if in.ClusterResources != nil { + in, out := &in.ClusterResources, &out.ClusterResources + *out = make([]ClusterResourceIdentity, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SourceGrant. +func (in *SourceGrant) DeepCopy() *SourceGrant { + if in == nil { + return nil + } + out := new(SourceGrant) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *StreamsStatus) DeepCopyInto(out *StreamsStatus) { *out = *in diff --git a/config/crd/bases/configbutler.ai_gitbranches.yaml b/config/crd/bases/configbutler.ai_gitbranches.yaml index 936a27bc..fce4f649 100644 --- a/config/crd/bases/configbutler.ai_gitbranches.yaml +++ b/config/crd/bases/configbutler.ai_gitbranches.yaml @@ -18,6 +18,9 @@ spec: - jsonPath: .spec.gitProviderRef.name name: Provider type: string + - jsonPath: .spec.gitProviderRef.namespace + name: ProviderNamespace + type: string - jsonPath: .spec.branch name: Branch type: string @@ -78,6 +81,119 @@ spec: spec: description: spec defines the desired state of GitBranch properties: + allowedGitTargets: + description: |- + AllowedGitTargets says which GitTargets may write through this GitBranch. Nothing is admitted + automatically, this GitBranch's own namespace included: a GitTarget there needs a grant too, + such as from: Same. Omitted or empty, no GitTarget writes through it. Grants combine by union. + Mutable: claiming a branch includes the right to share it. + items: + description: |- + NamespaceGrant admits the objects in some control-cluster namespaces, for the act the list holding + it names. + properties: + from: + description: |- + From is where this grant admits from: Same (the namespace of the object holding the grant), + Names (the namespaces in names), Selector (the namespaces whose labels match selector) or All + (every namespace). + enum: + - Same + - Names + - Selector + - All + type: string + names: + description: 'Names are the control-cluster namespaces admitted + with from: Names, by exact name.' + items: + description: GrantNamespace is one control-cluster namespace + a grant admits by name. + maxLength: 63 + type: string + x-kubernetes-validations: + - message: 'names: ''{namespace}'' is refused, because it + would admit every referring namespace; use from: Same + for the namespace of the object holding the grant' + rule: self != '{namespace}' + - message: 'names: ''*'' is refused; use from: All to admit + every namespace' + rule: self != '*' + - message: names holds namespace names (DNS labels) + rule: self == '{namespace}' || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + selector: + description: |- + Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + something: to admit every namespace, use from: All. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + required: + - from + type: object + x-kubernetes-validations: + - message: 'from: Names needs names' + rule: self.from != 'Names' || has(self.names) + - message: 'names goes only with from: Names' + rule: '!has(self.names) || self.from == ''Names''' + - message: 'from: Selector needs a selector; to admit every namespace, + use from: All' + rule: self.from != 'Selector' || has(self.selector) + - message: 'selector goes only with from: Selector' + rule: '!has(self.selector) || self.from == ''Selector''' + - message: 'selector: {} is refused; to admit every namespace, use + from: All' + rule: '!has(self.selector) || (has(self.selector.matchLabels) + && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) + && size(self.selector.matchExpressions) > 0)' + maxItems: 64 + type: array + x-kubernetes-list-type: atomic branch: description: |- Branch is the branch on the remote that this GitBranch writes. It must match one of the @@ -89,14 +205,18 @@ spec: type: string gitProviderRef: description: |- - GitProviderRef names the GitProvider, in this GitBranch's namespace, whose repository and - credential this branch is written with. GitTargets in other namespaces may reference this - GitBranch while that GitProvider's spec.accessFrom admits their namespace. + GitProviderRef names the GitProvider whose repository and credential this branch is written + with. Namespace omitted means this GitBranch's own namespace. The GitBranch claims its branch + only while that GitProvider's spec.allowedGitBranches admits this namespace and the branch. Immutable: delete and recreate the GitBranch to change it. properties: name: description: Name of the referent. type: string + namespace: + description: Namespace of the referent, when not specified it + acts as LocalObjectReference. + type: string required: - name type: object diff --git a/config/crd/bases/configbutler.ai_gitproviders.yaml b/config/crd/bases/configbutler.ai_gitproviders.yaml index ea751c8f..314b82b2 100644 --- a/config/crd/bases/configbutler.ai_gitproviders.yaml +++ b/config/crd/bases/configbutler.ai_gitproviders.yaml @@ -135,6 +135,159 @@ spec: type: string minItems: 1 type: array + allowedGitBranches: + description: |- + AllowedGitBranches says which GitBranches may claim a branch of this repository through this + GitProvider, and which branches: a GitBranch claims only while a grant admits its namespace and + one of that grant's branches matches its spec.branch. Nothing is admitted automatically, this + GitProvider's own namespace included; omitted or empty, no GitBranch claims through it. Grants + combine by union. Who may write through a claimed branch is that GitBranch's + spec.allowedGitTargets. + items: + description: |- + GitBranchGrant admits GitBranches in some control-cluster namespaces to claim the branches its + patterns match through a GitProvider. + properties: + branches: + description: |- + Branches are the branches an admitted GitBranch may claim, as patterns matched against the + whole branch name. '*' matches any sequence of characters, '/' included, so "*" alone is every + branch. '{namespace}' is the claiming GitBranch's namespace, and must be a whole path segment: + "teams/{namespace}/*". There is no escaping, '?' or character class. + items: + description: |- + BranchPattern is a pattern matched against a whole branch name: literal text, '*' for any + sequence of characters ('/' included), and '{namespace}' as a whole path segment. + maxLength: 255 + minLength: 1 + pattern: ^[^\s~^:?\[\\]*$ + type: string + x-kubernetes-validations: + - messageExpression: '''branch pattern '' + self + '' references + unknown variable '' + self.replace(''{namespace}'', ''x'').find(''[{][^{}]*[}]'') + + ''; the only variable is {namespace}''' + rule: '!self.replace(''{namespace}'', ''x'').matches(''[{][^{}]*[}]'')' + - messageExpression: '''branch pattern '' + self + '' has + an unmatched brace; the only variable is {namespace}''' + rule: self.replace('{namespace}', 'x').matches('[{][^{}]*[}]') + || !self.replace('{namespace}', 'x').matches('[{}]') + - messageExpression: '''branch pattern '' + self + '': {namespace} + must be a whole path segment, at the start or after a + /, and at the end or before a /''' + rule: '!self.matches(''[^/][{]namespace[}]'') && !self.matches(''[{]namespace[}][^/]'')' + - messageExpression: '''branch pattern '' + self + '' can + match no branch name: git refuses .., @{, a leading or + trailing / and //''' + rule: '!self.contains(''..'') && !self.contains(''@{'') + && !self.startsWith(''/'') && !self.endsWith(''/'') && + !self.contains(''//'')' + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + from: + description: |- + From is where this grant admits from: Same (this GitProvider's namespace), Names (the + namespaces in names), Selector (the namespaces whose labels match selector) or All (every + namespace). + enum: + - Same + - Names + - Selector + - All + type: string + names: + description: 'Names are the control-cluster namespaces admitted + with from: Names, by exact name.' + items: + description: GrantNamespace is one control-cluster namespace + a grant admits by name. + maxLength: 63 + type: string + x-kubernetes-validations: + - message: 'names: ''{namespace}'' is refused, because it + would admit every referring namespace; use from: Same + for the namespace of the object holding the grant' + rule: self != '{namespace}' + - message: 'names: ''*'' is refused; use from: All to admit + every namespace' + rule: self != '*' + - message: names holds namespace names (DNS labels) + rule: self == '{namespace}' || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + selector: + description: |- + Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + something: to admit every namespace, use from: All. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + required: + - branches + - from + type: object + x-kubernetes-validations: + - message: 'from: Names needs names' + rule: self.from != 'Names' || has(self.names) + - message: 'names goes only with from: Names' + rule: '!has(self.names) || self.from == ''Names''' + - message: 'from: Selector needs a selector; to admit every namespace, + use from: All' + rule: self.from != 'Selector' || has(self.selector) + - message: 'selector goes only with from: Selector' + rule: '!has(self.selector) || self.from == ''Selector''' + - message: 'selector: {} is refused; to admit every namespace, use + from: All' + rule: '!has(self.selector) || (has(self.selector.matchLabels) + && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) + && size(self.selector.matchExpressions) > 0)' + maxItems: 64 + type: array + x-kubernetes-list-type: atomic commit: description: |- Commit configures the commit identity and signing behavior this connection uses. Message diff --git a/config/crd/bases/configbutler.ai_kubeproviders.yaml b/config/crd/bases/configbutler.ai_kubeproviders.yaml index 9570a54d..3923c686 100644 --- a/config/crd/bases/configbutler.ai_kubeproviders.yaml +++ b/config/crd/bases/configbutler.ai_kubeproviders.yaml @@ -156,6 +156,294 @@ spec: ClusterWatchRule, which selects no namespaces at all. type: boolean type: object + allowedApplierClaims: + description: |- + AllowedApplierClaims says which applier claims (FluxClaims and ArgoCDClaims) may hold an applier + through this provider, and in which namespaces that applier may live (applierNamespaces). + Admission to mirror does not admit to apply. Nothing is admitted automatically; omitted or + empty, no applier claim holds an applier through it. Grants combine by union. + items: + description: |- + ApplierGrant admits applier claims in some control-cluster namespaces to hold an applier through a + KubeProvider, and bounds where that applier may live. + properties: + applierNamespaces: + description: |- + ApplierNamespaces are the source-cluster namespaces in which an admitted applier claim's + Kustomization or Application may live: exact names, '{namespace}' for the applier claim's own + namespace, or '*' for every namespace. The list is the whole scope. + items: + description: |- + ScopeNamespace is one namespace in a source or applier scope: a namespace name, "{namespace}" for + the namespace of the object asking for access, or "*" for every namespace. + maxLength: 63 + type: string + x-kubernetes-validations: + - message: a namespace scope holds a namespace name, '{namespace}' + alone, or '*' alone + rule: self == '*' || self == '{namespace}' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + from: + description: |- + From is where this grant admits from: Same (this KubeProvider's namespace), Names (the + namespaces in names), Selector (the namespaces whose labels match selector) or All (every + namespace). + enum: + - Same + - Names + - Selector + - All + type: string + names: + description: 'Names are the control-cluster namespaces admitted + with from: Names, by exact name.' + items: + description: GrantNamespace is one control-cluster namespace + a grant admits by name. + maxLength: 63 + type: string + x-kubernetes-validations: + - message: 'names: ''{namespace}'' is refused, because it + would admit every referring namespace; use from: Same + for the namespace of the object holding the grant' + rule: self != '{namespace}' + - message: 'names: ''*'' is refused; use from: All to admit + every namespace' + rule: self != '*' + - message: names holds namespace names (DNS labels) + rule: self == '{namespace}' || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + selector: + description: |- + Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + something: to admit every namespace, use from: All. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + required: + - applierNamespaces + - from + type: object + x-kubernetes-validations: + - message: 'from: Names needs names' + rule: self.from != 'Names' || has(self.names) + - message: 'names goes only with from: Names' + rule: '!has(self.names) || self.from == ''Names''' + - message: 'from: Selector needs a selector; to admit every namespace, + use from: All' + rule: self.from != 'Selector' || has(self.selector) + - message: 'selector goes only with from: Selector' + rule: '!has(self.selector) || self.from == ''Selector''' + - message: 'selector: {} is refused; to admit every namespace, use + from: All' + rule: '!has(self.selector) || (has(self.selector.matchLabels) + && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) + && size(self.selector.matchExpressions) > 0)' + maxItems: 64 + type: array + x-kubernetes-list-type: atomic + allowedGitTargets: + description: |- + AllowedGitTargets says which GitTargets may mirror through this provider, and what: each grant + admits some control-cluster namespaces and bounds what the WatchRules feeding a GitTarget there + may watch (sourceNamespaces), and what a ClusterWatchRule may mirror through it + (clusterResources). Nothing is admitted automatically, this KubeProvider's own namespace + included; omitted or empty, no GitTarget mirrors through it. Grants combine by union. + items: + description: |- + SourceGrant admits GitTargets in some control-cluster namespaces to mirror through a KubeProvider, + and bounds what they may mirror. + properties: + clusterResources: + description: |- + ClusterResources are the cluster-scoped resource types a ClusterWatchRule may mirror through an + admitted GitTarget: resource.group (clusterroles.rbac.authorization.k8s.io), a bare resource for + the core group only (nodes), or '*' for every one, including CRDs installed later. An identity + covers every served version. A ClusterWatchRule item with '*' in apiGroups or resources needs + '*' here. Omitted, none. + items: + description: |- + ClusterResourceIdentity names a cluster-scoped resource type: resource.group, a bare resource for + the core group, or "*" for every one. + maxLength: 317 + pattern: ^(\*|[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*)$ + type: string + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + from: + description: |- + From is where this grant admits from: Same (this KubeProvider's namespace), Names (the + namespaces in names), Selector (the namespaces whose labels match selector) or All (every + namespace). + enum: + - Same + - Names + - Selector + - All + type: string + names: + description: 'Names are the control-cluster namespaces admitted + with from: Names, by exact name.' + items: + description: GrantNamespace is one control-cluster namespace + a grant admits by name. + maxLength: 63 + type: string + x-kubernetes-validations: + - message: 'names: ''{namespace}'' is refused, because it + would admit every referring namespace; use from: Same + for the namespace of the object holding the grant' + rule: self != '{namespace}' + - message: 'names: ''*'' is refused; use from: All to admit + every namespace' + rule: self != '*' + - message: names holds namespace names (DNS labels) + rule: self == '{namespace}' || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + selector: + description: |- + Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + something: to admit every namespace, use from: All. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + sourceNamespaces: + description: |- + SourceNamespaces are the source-cluster namespaces the WatchRules feeding an admitted GitTarget + may watch: exact names, '{namespace}' for the GitTarget's own namespace, or '*' for every + namespace, which also allows rules[].sourceNamespace: "*". The list is the whole scope; the + GitTarget's own namespace is in it only when listed. Omitted, no namespace. + items: + description: |- + ScopeNamespace is one namespace in a source or applier scope: a namespace name, "{namespace}" for + the namespace of the object asking for access, or "*" for every namespace. + maxLength: 63 + type: string + x-kubernetes-validations: + - message: a namespace scope holds a namespace name, '{namespace}' + alone, or '*' alone + rule: self == '*' || self == '{namespace}' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + required: + - from + type: object + x-kubernetes-validations: + - message: 'from: Names needs names' + rule: self.from != 'Names' || has(self.names) + - message: 'names goes only with from: Names' + rule: '!has(self.names) || self.from == ''Names''' + - message: 'from: Selector needs a selector; to admit every namespace, + use from: All' + rule: self.from != 'Selector' || has(self.selector) + - message: 'selector goes only with from: Selector' + rule: '!has(self.selector) || self.from == ''Selector''' + - message: 'selector: {} is refused; to admit every namespace, use + from: All' + rule: '!has(self.selector) || (has(self.selector.matchLabels) + && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) + && size(self.selector.matchExpressions) > 0)' + - message: a grant in allowedGitTargets sets sourceNamespaces, clusterResources, + or both; one that grants neither grants nothing + rule: has(self.sourceNamespaces) || has(self.clusterResources) + maxItems: 64 + type: array + x-kubernetes-list-type: atomic attribution: description: |- Attribution groups this cluster's author-attribution settings. The block is spelled diff --git a/docs/configuration.md b/docs/configuration.md index f0d18f16..e0d187aa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -70,6 +70,7 @@ fields that need more than a line. | `url` | **required** | Repository URL, SSH or HTTPS | | `allowedBranches` | **required** | Branches a `GitTarget` may write. A target naming any other is rejected | | `accessFrom` | **no other namespace** | Other namespaces whose `GitTarget`s may write through this provider, by name or label selector. Its own namespace is always admitted | +| `allowedGitBranches` | **no `GitBranch`**, its own namespace included | Which namespaces' `GitBranch`es may claim which branches through this provider. Each grant has a `from` (`Same`, `Names`, `Selector`, `All`) and the `branches` patterns it allows | | `secretRef` | anonymous access | Secret holding the write credential. See [the credentials Secret](#gitproviderspecsecretref-the-credentials-secret) | | `knownHostsRef` | the Secret's own `known_hosts` | Optional ConfigMap or Secret sharing SSH host keys across providers. Host-key verification fails closed either way | | `commit.committer` | the operator's built-in identity | Name and email written as the commit committer. See [committer identity](#committer-identity) | @@ -77,12 +78,13 @@ fields that need more than a line. ### `GitBranch` (namespaced): one branch of the repository -Every field is immutable: to change one, delete the `GitBranch` and create a new one. +Every field but `allowedGitTargets` is immutable: to change one, delete the `GitBranch` and create a new one. | Field | Default | What it does | |---|---|---| | `gitProviderRef` | **required** | The `GitProvider`, in this namespace, whose repository and credential write this branch | | `branch` | **required** | Branch to write. Must be in the provider's `allowedBranches`. Only one `GitBranch` holds a repository branch; see [who writes a repository branch](#who-writes-a-repository-branch) | +| `allowedGitTargets` | **no `GitTarget`**, its own namespace included | Which namespaces' `GitTarget`s may write through this branch, each grant with a `from`. The only mutable field | | `parentBranch` | the remote's default branch | Branch a `branch` the remote does not have yet is created from. Only read, so it need not be allowed. See [starting the write branch from another branch](#starting-the-write-branch-from-another-branch-specparentbranch) | ### `GitReceiver` (namespaced): the endpoint a Git host notifies on a push @@ -103,6 +105,8 @@ Set exactly one of `secretRef` and `inCluster`. Both are immutable: delete and r | `accessFrom` | **no other namespace** | Other control-cluster namespaces whose objects may use this provider, by name or label selector. Its own namespace is always admitted; an empty `selector: {}` admits every namespace | | `allowCrossNamespace.sources` | `false` | Whether rules may watch namespaces other than their own, `*` included. See [cross-namespace grants](#what-a-namespace-may-reach-outside-itself-specallowcrossnamespace) | | `allowCrossNamespace.appliers` | `false` | Whether applier claims may name a `Kustomization` or `Application` in another namespace than their own | +| `allowedGitTargets` | **no `GitTarget`**, its own namespace included | Which namespaces' `GitTarget`s may mirror through this provider, and what: each grant has a `from`, and `sourceNamespaces`, `clusterResources` or both | +| `allowedApplierClaims` | **no applier claim**, its own namespace included | Which namespaces' applier claims may hold an applier through this provider, and the `applierNamespaces` that applier may live in | | `client.qps` | `20` | Client rate limit for this source cluster. Refused beside `inCluster` | | `client.burst` | `30` | Client burst for this source cluster. Refused beside `inCluster` | | `attribution.auditRoute` | `.` | Audit route this provider joins. A route is held by one namespace. See [audit route](#audit-route-and-auditfactsreceived) | diff --git a/hack/crdfields/fields.yaml b/hack/crdfields/fields.yaml index 98800158..35109e08 100644 --- a/hack/crdfields/fields.yaml +++ b/hack/crdfields/fields.yaml @@ -37,6 +37,11 @@ kinds: doc: >- Other namespaces whose `GitTarget`s may write through this provider, by name or label selector. Its own namespace is always admitted + - path: allowedGitBranches + default: "**no `GitBranch`**, its own namespace included" + doc: >- + Which namespaces' `GitBranch`es may claim which branches through this provider. Each grant + has a `from` (`Same`, `Names`, `Selector`, `All`) and the `branches` patterns it allows - path: secretRef default: anonymous access doc: >- @@ -60,7 +65,7 @@ kinds: - kind: GitBranch title: "`GitBranch` (namespaced): one branch of the repository" - intro: "Every field is immutable: to change one, delete the `GitBranch` and create a new one." + intro: "Every field but `allowedGitTargets` is immutable: to change one, delete the `GitBranch` and create a new one." fields: - path: gitProviderRef doc: >- @@ -69,6 +74,11 @@ kinds: doc: >- Branch to write. Must be in the provider's `allowedBranches`. Only one `GitBranch` holds a repository branch; see [who writes a repository branch](#who-writes-a-repository-branch) + - path: allowedGitTargets + default: "**no `GitTarget`**, its own namespace included" + doc: >- + Which namespaces' `GitTarget`s may write through this branch, each grant with a `from`. The + only mutable field - path: parentBranch default: the remote's default branch doc: >- @@ -119,6 +129,16 @@ kinds: doc: >- Whether applier claims may name a `Kustomization` or `Application` in another namespace than their own + - path: allowedGitTargets + default: "**no `GitTarget`**, its own namespace included" + doc: >- + Which namespaces' `GitTarget`s may mirror through this provider, and what: each grant has a + `from`, and `sourceNamespaces`, `clusterResources` or both + - path: allowedApplierClaims + default: "**no applier claim**, its own namespace included" + doc: >- + Which namespaces' applier claims may hold an applier through this provider, and the + `applierNamespaces` that applier may live in - path: client.qps default: "`20`" doc: Client rate limit for this source cluster. Refused beside `inCluster` diff --git a/internal/applier/dispatcher_test.go b/internal/applier/dispatcher_test.go index 29d724fb..78d47e8d 100644 --- a/internal/applier/dispatcher_test.go +++ b/internal/applier/dispatcher_test.go @@ -179,7 +179,7 @@ func controlPlane() []client.Object { &v1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Namespace: testGitBranch.Namespace, Name: testGitBranch.Name}, Spec: v1alpha3.GitBranchSpec{ - GitProviderRef: fluxmeta.LocalObjectReference{Name: testProvider.Name}, Branch: testBranch, + GitProviderRef: fluxmeta.NamespacedObjectReference{Name: testProvider.Name}, Branch: testBranch, }, }, kubeProvider(localCluster, targetNS), diff --git a/internal/authz/gitbranch_admission_test.go b/internal/authz/gitbranch_admission_test.go index 56ab10a1..0148cf86 100644 --- a/internal/authz/gitbranch_admission_test.go +++ b/internal/authz/gitbranch_admission_test.go @@ -29,7 +29,7 @@ func sharedGitBranch(provider string) *configv1alpha3.GitBranch { return &configv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "main"}, Spec: configv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: provider}, Branch: "main", + GitProviderRef: meta.NamespacedObjectReference{Name: provider}, Branch: "main", }, } } diff --git a/internal/controller/access_grants_admission_test.go b/internal/controller/access_grants_admission_test.go new file mode 100644 index 00000000..8e567e70 --- /dev/null +++ b/internal/controller/access_grants_admission_test.go @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "fmt" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// The grant grammar is checked by the API server, so a refusal names the mistake at apply time. Each +// case is sent as unstructured, because a typed object drops the empty lists and the empty +// selector these rules exist to refuse. +var _ = Describe("Access grants at admission", func() { + var counter int + object := func(kind string, spec map[string]any) *unstructured.Unstructured { + counter++ + u := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "configbutler.ai/v1alpha3", + "kind": kind, + "metadata": map[string]any{"name": fmt.Sprintf("grant-cel-%d", counter), "namespace": "default"}, + "spec": spec, + }} + return u + } + gitProvider := func(grants ...any) *unstructured.Unstructured { + return object("GitProvider", map[string]any{ + "url": "https://example.com/repo.git", "allowedBranches": []any{"*"}, "allowedGitBranches": grants, + }) + } + gitBranch := func(grants ...any) *unstructured.Unstructured { + return object("GitBranch", map[string]any{ + "gitProviderRef": map[string]any{"name": "p"}, "branch": "main", "allowedGitTargets": grants, + }) + } + kubeProvider := func(field string, grants ...any) *unstructured.Unstructured { + return object("KubeProvider", map[string]any{ + "secretRef": map[string]any{"name": "kubeconfig"}, field: grants, + }) + } + create := func(obj client.Object) error { + err := k8sClient.Create(context.Background(), obj) + if err == nil { + DeferCleanup(func() { _ = k8sClient.Delete(context.Background(), obj) }) + } + return err + } + refused := func(obj client.Object, message string) { + GinkgoHelper() + err := create(obj) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(message)) + } + accepted := func(obj client.Object) { + GinkgoHelper() + Expect(create(obj)).To(Succeed()) + } + branches := func(patterns ...any) map[string]any { + return map[string]any{"from": "Same", "branches": patterns} + } + + It("accepts the branch patterns the grammar allows", func() { + accepted(gitProvider(branches("main", "release/*", "teams/{namespace}/*", "{namespace}", "*", + "feature/*/v2"))) + }) + + It("refuses {namespace} that is not a whole path segment", func() { + refused(gitProvider(branches("teams/{namespace}*")), "must be a whole path segment") + refused(gitProvider(branches("teams/x{namespace}/live")), "must be a whole path segment") + }) + + It("refuses an unknown variable, naming it and the one that exists", func() { + refused(gitProvider(branches("teams/{ns}/*")), + "branch pattern teams/{ns}/* references unknown variable {ns}; the only variable is {namespace}") + refused(gitProvider(branches("teams/{namespace")), "unmatched brace") + }) + + It("refuses what no branch name can hold", func() { + refused(gitProvider(branches("feature/?")), "spec.allowedGitBranches[0].branches[0]") + refused(gitProvider(branches("feature/[ab]")), "spec.allowedGitBranches[0].branches[0]") + refused(gitProvider(branches("a..b")), "can match no branch name") + }) + + It("refuses an empty scope or names list", func() { + refused(gitProvider(branches()), "spec.allowedGitBranches[0].branches") + refused(gitProvider(map[string]any{"from": "Same"}), "branches: Required value") + refused(gitBranch(map[string]any{"from": "Names", "names": []any{}}), "names") + refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", "sourceNamespaces": []any{}}), + "sourceNamespaces") + refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", "clusterResources": []any{}}), + "clusterResources") + refused(kubeProvider("allowedApplierClaims", map[string]any{"from": "Same", "applierNamespaces": []any{}}), + "applierNamespaces") + refused(kubeProvider("allowedApplierClaims", map[string]any{"from": "Same"}), + "applierNamespaces: Required value") + }) + + It("refuses a grant without from, or with names or a selector beside the wrong from", func() { + refused(gitBranch(map[string]any{}), "from: Required value") + refused(gitBranch(map[string]any{"from": "Everyone"}), "Unsupported value") + refused(gitBranch(map[string]any{"from": "Names"}), "from: Names needs names") + refused(gitBranch(map[string]any{"from": "Same", "names": []any{"team-b"}}), + "names goes only with from: Names") + refused(gitBranch(map[string]any{"from": "All", + "selector": map[string]any{"matchLabels": map[string]any{"a": "b"}}}), + "selector goes only with from: Selector") + }) + + It("refuses a KubeProvider source grant that grants nothing", func() { + refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same"}), + "sets sourceNamespaces, clusterResources, or both") + accepted( + kubeProvider("allowedGitTargets", map[string]any{"from": "Same", "sourceNamespaces": []any{"{namespace}"}}), + ) + accepted(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", + "clusterResources": []any{"clusterroles.rbac.authorization.k8s.io", "nodes", "*"}})) + }) + + It("refuses {namespace} in names, pointing to from: Same, and * pointing to from: All", func() { + refused(gitBranch(map[string]any{"from": "Names", "names": []any{"{namespace}"}}), "use from: Same") + refused(gitBranch(map[string]any{"from": "Names", "names": []any{"*"}}), "use from: All") + refused(gitBranch(map[string]any{"from": "Names", "names": []any{"Team_A"}}), "namespace names") + }) + + It("refuses a Selector grant without a selector or with an empty one, and accepts a real one", func() { + refused(gitBranch(map[string]any{"from": "Selector"}), "from: Selector needs a selector") + refused(gitBranch(map[string]any{"from": "Selector", "selector": map[string]any{}}), + "selector: {} is refused; to admit every namespace, use from: All") + refused(gitBranch(map[string]any{"from": "Selector", + "selector": map[string]any{"matchLabels": map[string]any{}}}), "selector: {} is refused") + accepted(gitBranch(map[string]any{"from": "Selector", + "selector": map[string]any{"matchLabels": map[string]any{"tenant": "true"}}})) + accepted(gitBranch(map[string]any{"from": "Selector", "selector": map[string]any{ + "matchExpressions": []any{map[string]any{"key": "tenant", "operator": "Exists"}}}})) + accepted(gitBranch(map[string]any{"from": "All"}, map[string]any{"from": "Same"})) + }) + + It("refuses a scope entry that is neither a name, {namespace} nor *", func() { + refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", + "sourceNamespaces": []any{"team-{namespace}"}}), "'{namespace}' alone, or '*' alone") + refused(kubeProvider("allowedApplierClaims", map[string]any{"from": "Same", + "applierNamespaces": []any{"flux-*"}}), "'{namespace}' alone, or '*' alone") + refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", + "clusterResources": []any{"cluster*"}}), "clusterResources[0]") + }) +}) diff --git a/internal/controller/dependency_watches_test.go b/internal/controller/dependency_watches_test.go index 257fb523..3b3fe6d9 100644 --- a/internal/controller/dependency_watches_test.go +++ b/internal/controller/dependency_watches_test.go @@ -98,7 +98,7 @@ func gitBranch(providerName, namespace string) *configbutleraiv1alpha3.GitBranch return &configbutleraiv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Name: gitBranchNameForTest(providerName, "main"), Namespace: namespace}, Spec: configbutleraiv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: providerName}, Branch: "main", + GitProviderRef: meta.NamespacedObjectReference{Name: providerName}, Branch: "main", }, } } diff --git a/internal/controller/git_branch_helpers_test.go b/internal/controller/git_branch_helpers_test.go index 4121454c..68a5d20b 100644 --- a/internal/controller/git_branch_helpers_test.go +++ b/internal/controller/git_branch_helpers_test.go @@ -35,7 +35,7 @@ func gitBranchOn(provider, namespace, branch string) *configbutleraiv1alpha3.Git return &configbutleraiv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Name: gitBranchNameForTest(provider, branch), Namespace: namespace}, Spec: configbutleraiv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: provider}, Branch: branch, + GitProviderRef: meta.NamespacedObjectReference{Name: provider}, Branch: branch, }, } } diff --git a/internal/controller/gitbranch_controller_test.go b/internal/controller/gitbranch_controller_test.go index 5f0aa3d8..7914a655 100644 --- a/internal/controller/gitbranch_controller_test.go +++ b/internal/controller/gitbranch_controller_test.go @@ -54,7 +54,7 @@ func branchObject(namespace, name, provider, branch string, age time.Duration) * CreationTimestamp: metav1.NewTime(gitBranchEpoch.Add(-age)), }, Spec: configbutleraiv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: provider}, Branch: branch, + GitProviderRef: meta.NamespacedObjectReference{Name: provider}, Branch: branch, }, } } diff --git a/internal/controller/gittarget_controller_test.go b/internal/controller/gittarget_controller_test.go index 2d7356bd..04c7d586 100644 --- a/internal/controller/gittarget_controller_test.go +++ b/internal/controller/gittarget_controller_test.go @@ -914,7 +914,7 @@ func createGitBranchFor(ctx context.Context, provider *configbutleraiv1alpha3.Gi Name: gitBranchNameForTest(provider.Name, branch), Namespace: provider.Namespace, }, Spec: configbutleraiv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: provider.Name}, Branch: branch, + GitProviderRef: meta.NamespacedObjectReference{Name: provider.Name}, Branch: branch, }, } Expect(k8sClient.Create(ctx, gitBranch)).Should(Succeed()) diff --git a/internal/controller/gittarget_immutability_test.go b/internal/controller/gittarget_immutability_test.go index fd0abaf0..2b4709cb 100644 --- a/internal/controller/gittarget_immutability_test.go +++ b/internal/controller/gittarget_immutability_test.go @@ -85,7 +85,7 @@ var _ = Describe("GitTarget Destination Immutability", func() { gitBranch := &configbutleraiv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Name: tc.key.Name, Namespace: tc.key.Namespace}, Spec: configbutleraiv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: "prov-a"}, + GitProviderRef: meta.NamespacedObjectReference{Name: "prov-a"}, Branch: "edits", ParentBranch: tc.parent, }, diff --git a/internal/controller/gittarget_shared_branch_test.go b/internal/controller/gittarget_shared_branch_test.go index df50af59..52c6fd5f 100644 --- a/internal/controller/gittarget_shared_branch_test.go +++ b/internal/controller/gittarget_shared_branch_test.go @@ -88,7 +88,7 @@ func TestCheckForConflicts_AnUnadmittedTargetReservesNoFolder(t *testing.T) { branch := &configbutleraiv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: gitBranchNameForTest("monorepo", "main")}, Spec: configbutleraiv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: "monorepo"}, Branch: "main", + GitProviderRef: meta.NamespacedObjectReference{Name: "monorepo"}, Branch: "main", }, } squatter := sharedProviderTarget("intruder", "everything", "platform", ".", 2*time.Hour) diff --git a/internal/controller/superseded_fields_admission_test.go b/internal/controller/superseded_fields_admission_test.go index 94ff19e3..e2b73b57 100644 --- a/internal/controller/superseded_fields_admission_test.go +++ b/internal/controller/superseded_fields_admission_test.go @@ -215,7 +215,7 @@ var _ = Describe("Reference names must not be empty", func() { branch := &configbutleraiv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Name: "empty-provider-name", Namespace: "default"}, Spec: configbutleraiv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: ""}, Branch: "main", + GitProviderRef: meta.NamespacedObjectReference{Name: ""}, Branch: "main", }, } Expect(k8sClient.Create(ctx, branch)).NotTo(Succeed(), diff --git a/internal/git/git_branch_ref_test.go b/internal/git/git_branch_ref_test.go index 8888d4cb..d299634f 100644 --- a/internal/git/git_branch_ref_test.go +++ b/internal/git/git_branch_ref_test.go @@ -33,12 +33,15 @@ func TestResolveGitBranch_FollowsTheTargetToItsBranchAndProvider(t *testing.T) { branch := &configv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "main"}, Spec: configv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: "acme"}, Branch: "main", + GitProviderRef: meta.NamespacedObjectReference{Name: "acme"}, Branch: "main", }, } orphan := &configv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "orphan"}, - Spec: configv1alpha3.GitBranchSpec{GitProviderRef: meta.LocalObjectReference{Name: "gone"}, Branch: "x"}, + Spec: configv1alpha3.GitBranchSpec{ + GitProviderRef: meta.NamespacedObjectReference{Name: "gone"}, + Branch: "x", + }, } provider := &configv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "acme"}, diff --git a/internal/watch/event_router_test.go b/internal/watch/event_router_test.go index 66c228b1..ff3fdbe7 100644 --- a/internal/watch/event_router_test.go +++ b/internal/watch/event_router_test.go @@ -586,7 +586,7 @@ func testGitBranchObject() *configv1alpha3.GitBranch { return &configv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Name: gitBranchNameForTest("team-a-provider", "main"), Namespace: "team-a"}, Spec: configv1alpha3.GitBranchSpec{ - GitProviderRef: meta.LocalObjectReference{Name: "team-a-provider"}, + GitProviderRef: meta.NamespacedObjectReference{Name: "team-a-provider"}, Branch: "main", }, } From 5a74f5f2f50b0f321fff67c0514ba8e9de400df0 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 17:28:47 +0000 Subject: [PATCH 10/34] feat(authz): admit by explicit grants and remove accessFrom Every admission now reads the grant list on the referenced object, and nothing is automatic, the own namespace included: - a GitTarget mirrors through a KubeProvider while its allowedGitTargets admit the target's namespace, and writes through a GitBranch while that branch's allowedGitTargets admit it; - a GitBranch claims through a GitProvider while its allowedGitBranches admit the namespace and a pattern matches the branch (BranchNotAllowed otherwise), checked before the push-session proof; - an applier claim holds an applier while allowedApplierClaims admit it and the applier's namespace is in applierNamespaces. ApplierAdmitted no longer skips that check when the claim and the target share a provider; - a WatchRule item watches only namespaces in the grant's sourceNamespaces, the rule's own included. LegacySourceNamespace is gone. Refusals name the grant list and what it admits. GitProvider.accessFrom and allowedBranches, KubeProvider.accessFrom and allowCrossNamespace, and NamespaceMatcher are removed. Samples, guides and fixtures carry grants. Co-Authored-By: Claude Opus 5.5 (1M context) --- api/v1alpha3/access_grants.go | 2 +- api/v1alpha3/access_grants_test.go | 7 +- api/v1alpha3/argocdclaim_types.go | 5 +- api/v1alpha3/audit_route_test.go | 8 +- api/v1alpha3/fluxclaim_types.go | 5 +- api/v1alpha3/gitbranch_types.go | 7 +- api/v1alpha3/gitprovider_types.go | 27 +- api/v1alpha3/gittarget_types.go | 12 +- api/v1alpha3/helpers_test.go | 191 ---------- api/v1alpha3/kubeprovider_types.go | 73 +--- api/v1alpha3/kubeprovider_types_test.go | 25 +- api/v1alpha3/namespace_matcher.go | 81 ----- api/v1alpha3/namespace_matcher_test.go | 138 -------- api/v1alpha3/watchrule_types.go | 23 +- api/v1alpha3/zz_generated.deepcopy.go | 60 ---- .../bases/configbutler.ai_argocdclaims.yaml | 5 +- .../crd/bases/configbutler.ai_fluxclaims.yaml | 5 +- .../bases/configbutler.ai_gitbranches.yaml | 7 +- .../bases/configbutler.ai_gitproviders.yaml | 76 ---- .../crd/bases/configbutler.ai_gittargets.yaml | 12 +- .../bases/configbutler.ai_kubeproviders.yaml | 95 +---- .../crd/bases/configbutler.ai_watchrules.yaml | 7 +- config/samples/README.md | 2 +- config/samples/argocdclaim.yaml | 4 +- config/samples/fluxclaim.yaml | 4 +- config/samples/kubeprovider.yaml | 34 +- config/samples/quickstart-gitbranch.yaml | 11 +- config/samples/quickstart-gitprovider.yaml | 7 +- config/samples/quickstart-gittarget.yaml | 8 +- docs/bi-directional-argocd.md | 28 +- docs/bi-directional-flux.md | 28 +- docs/configuration.md | 17 +- hack/crdfields/fields.yaml | 42 +-- internal/applier/dispatcher_test.go | 17 +- internal/applier/holder_test.go | 4 +- internal/authz/gitbranch_admission_test.go | 191 ++++++---- internal/authz/kubeprovider_admission.go | 232 +++++++----- internal/authz/kubeprovider_admission_test.go | 329 ++++++++++-------- internal/authz/source_namespace.go | 239 ++++--------- internal/authz/source_namespace_test.go | 140 +++++--- .../access_grants_admission_test.go | 2 +- .../controller/applierclaim_controller.go | 2 +- .../applierclaim_controller_test.go | 12 +- internal/controller/applierclaim_rule_test.go | 5 +- .../clusterwatchrule_admission_test.go | 24 +- .../controller/clusterwatchrule_controller.go | 8 +- .../controller/dependency_watches_test.go | 1 + .../controller/git_branch_helpers_test.go | 1 + internal/controller/gitbranch_controller.go | 35 +- .../controller/gitbranch_controller_test.go | 25 +- internal/controller/gitprovider_controller.go | 1 - .../controller/gitprovider_controller_test.go | 27 +- .../gitprovider_immutability_test.go | 15 +- internal/controller/gittarget_controller.go | 18 +- .../controller/gittarget_controller_test.go | 91 +++-- .../controller/gittarget_gitbranch_test.go | 31 +- .../gittarget_placement_validation_test.go | 4 +- .../gittarget_shared_branch_test.go | 26 +- .../controller/gittarget_source_cluster.go | 3 +- .../gittarget_source_cluster_test.go | 73 +++- internal/controller/grant_helpers_test.go | 40 +++ internal/controller/suite_test.go | 15 +- .../superseded_fields_admission_test.go | 25 +- internal/controller/watchrule_controller.go | 5 +- .../controller/watchrule_controller_test.go | 20 +- .../controller/watchrule_source_namespace.go | 20 +- .../watchrule_source_namespace_test.go | 25 +- .../wiring/readiness_wiring_test.go | 17 +- internal/watch/bootstrap_admission_test.go | 32 +- internal/watch/manager_startup_test.go | 38 +- internal/watch/source_namespace_test.go | 24 +- internal/watch/watchrule_compile.go | 3 +- internal/webhook/receiver_test.go | 6 +- .../podinfos-intent/2-reverse-gitops.yaml | 8 +- .../test/coffeeconfig-reverse-gitops.yaml | 8 +- .../1-flat-serialized/config/gittarget.yaml | 6 +- .../config/kubeprovider.yaml | 15 +- .../config/gittarget-second-namespace.yaml | 2 + .../config/gittarget.yaml | 2 + .../3-tree-serialized/config/gittarget.yaml | 6 +- .../config/kubeprovider.yaml | 15 +- .../config/gittarget.yaml | 2 + .../config/gittarget-empty-folder.yaml | 2 + .../config/gittarget.yaml | 2 + .../config/gittarget-app-root.yaml | 2 + .../config/gittarget-prod.yaml | 2 + .../config/gittarget-test.yaml | 2 + .../config/gittarget-prod.yaml | 2 + .../config/gittarget-prod.yaml | 2 + .../homelab-argocd/config/gittarget.yaml | 2 + .../homelab-flux/config/gittarget-media.yaml | 2 + .../homelab-flux/config/gittarget.yaml | 2 + .../specific-examples/prerequisites/README.md | 22 +- .../prerequisites/config/gitprovider.yaml | 5 +- test/playground/config/gitprovider.yaml | 5 +- test/playground/config/gittarget.yaml | 2 + 96 files changed, 1316 insertions(+), 1684 deletions(-) delete mode 100644 api/v1alpha3/namespace_matcher.go delete mode 100644 api/v1alpha3/namespace_matcher_test.go create mode 100644 internal/controller/grant_helpers_test.go diff --git a/api/v1alpha3/access_grants.go b/api/v1alpha3/access_grants.go index 59addd55..0d3297f3 100644 --- a/api/v1alpha3/access_grants.go +++ b/api/v1alpha3/access_grants.go @@ -329,7 +329,7 @@ func describeAdmitted(sources []NamespaceGrant, holderNamespace string) string { for i := range sources { parts = append(parts, sources[i].Describe(holderNamespace)) } - return "admits " + strings.Join(parts, "; ") + return "admits " + strings.Join(parts, ", ") } // expandScope replaces '{namespace}' in each scope entry with namespace, and returns the entries diff --git a/api/v1alpha3/access_grants_test.go b/api/v1alpha3/access_grants_test.go index 38e1fa9f..7c806173 100644 --- a/api/v1alpha3/access_grants_test.go +++ b/api/v1alpha3/access_grants_test.go @@ -59,6 +59,11 @@ func TestNamespaceGrant_Admits(t *testing.T) { NamespaceGrant{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "team-a", nil, true}, {"Names admits nothing else", NamespaceGrant{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "team-b", nil, false}, + // An exact allow-list, never a prefix: otherwise "team-a-evil" would inherit team-a's grant. + {"Names is no prefix match", + NamespaceGrant{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "team-a-evil", nil, false}, + {"Names is case-sensitive", + NamespaceGrant{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "Team-A", nil, false}, {"Selector admits a matching namespace", NamespaceGrant{From: GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}}, "team-a", tenant, true}, {"Selector admits nothing else", NamespaceGrant{From: GrantFromSelector, @@ -124,7 +129,7 @@ func TestGitBranch_AdmitsGitTarget_NothingAutomatic(t *testing.T) { branch.Spec.AllowedGitTargets = []NamespaceGrant{{From: GrantFromSame}, {From: GrantFromNames, Names: []GrantNamespace{"team-b"}}} - assert.Equal(t, "admits team-a; team-b", branch.DescribeAllowedGitTargets()) + assert.Equal(t, "admits team-a, team-b", branch.DescribeAllowedGitTargets()) } func TestGitProvider_AdmitsGitBranch(t *testing.T) { diff --git a/api/v1alpha3/argocdclaim_types.go b/api/v1alpha3/argocdclaim_types.go index 7fad9311..8cb6b557 100644 --- a/api/v1alpha3/argocdclaim_types.go +++ b/api/v1alpha3/argocdclaim_types.go @@ -29,8 +29,9 @@ type ArgoCDClaimSpec struct { // KubeProviderRef names the KubeProvider whose cluster holds the Application, such as a // management cluster running Argo CD for the source cluster. Namespace omitted means this - // applier claim's own namespace. The provider must admit this applier claim's namespace: its own, or one its - // spec.accessFrom admits. Its identity needs get, update and patch on the Application. Immutable. + // applier claim's own namespace. The provider's spec.allowedApplierClaims must admit this applier + // claim's namespace, with the Application's namespace in applierNamespaces. Its identity needs + // get, update and patch on the Application. Immutable. // +required // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.kubeProviderRef.name must not be empty" // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.kubeProviderRef is immutable; delete and recreate the ArgoCDClaim to hold an Application elsewhere" diff --git a/api/v1alpha3/audit_route_test.go b/api/v1alpha3/audit_route_test.go index 868f1cf3..21743494 100644 --- a/api/v1alpha3/audit_route_test.go +++ b/api/v1alpha3/audit_route_test.go @@ -26,9 +26,11 @@ func TestKubeProvider_AuditRoute_SharedBySeveralProviders(t *testing.T) { delegating := KubeProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "srcns-delegating"}, Spec: KubeProviderSpec{ - InCluster: &KubeProviderInCluster{}, - AllowCrossNamespace: &KubeProviderCrossNamespace{Sources: true}, - Attribution: &KubeProviderAttribution{AuditRoute: "platform.local"}, + InCluster: &KubeProviderInCluster{}, + AllowedGitTargets: []SourceGrant{ + {From: GrantFromSame, SourceNamespaces: []ScopeNamespace{"*"}}, + }, + Attribution: &KubeProviderAttribution{AuditRoute: "platform.local"}, }, } diff --git a/api/v1alpha3/fluxclaim_types.go b/api/v1alpha3/fluxclaim_types.go index 783e1d95..80c6f249 100644 --- a/api/v1alpha3/fluxclaim_types.go +++ b/api/v1alpha3/fluxclaim_types.go @@ -30,8 +30,9 @@ type FluxClaimSpec struct { // KubeProviderRef names the KubeProvider whose cluster holds the Kustomization, such as a // management cluster running Flux for the source cluster. Namespace omitted means this - // applier claim's own namespace. The provider must admit this applier claim's namespace: its own, or one its - // spec.accessFrom admits. Its identity needs get and patch on the Kustomization and its GitRepository. Immutable. + // applier claim's own namespace. The provider's spec.allowedApplierClaims must admit this applier + // claim's namespace, with the Kustomization's namespace in applierNamespaces. Its identity needs + // get and patch on the Kustomization and its GitRepository. Immutable. // +required // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.kubeProviderRef.name must not be empty" // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="spec.kubeProviderRef is immutable; delete and recreate the FluxClaim to hold a Kustomization elsewhere" diff --git a/api/v1alpha3/gitbranch_types.go b/api/v1alpha3/gitbranch_types.go index d8a1ff87..bf2b3430 100644 --- a/api/v1alpha3/gitbranch_types.go +++ b/api/v1alpha3/gitbranch_types.go @@ -30,7 +30,8 @@ type GitBranchSpec struct { GitProviderRef meta.NamespacedObjectReference `json:"gitProviderRef"` // Branch is the branch on the remote that this GitBranch writes. It must match one of the - // GitProvider's allowedBranches. Only one GitBranch writes a repository branch: the oldest one + // patterns the GitProvider's spec.allowedGitBranches give this GitBranch's namespace. Only one + // GitBranch writes a repository branch: the oldest admitted one // whose credential proves it can open a push session holds it, and any other GitBranch for the // same repository and branch reports Stalled with reason BranchConflict. // Immutable: delete and recreate the GitBranch to change it. @@ -48,8 +49,8 @@ type GitBranchSpec struct { // default branch), or a branch with no history when the repository is empty. When set, it must // exist on the remote for spec.branch to be created; otherwise this GitBranch reports // ParentBranchNotFound and nothing is written. Once spec.branch exists, it is used as it is and - // the parent is not followed. It is only read, so it does not need to be in the GitProvider's - // allowedBranches. + // the parent is not followed. It is only read, so the GitProvider's spec.allowedGitBranches need + // not allow it. // Immutable, including adding or removing it: delete and recreate the GitBranch to change it. // +optional // +kubebuilder:validation:MinLength=1 diff --git a/api/v1alpha3/gitprovider_types.go b/api/v1alpha3/gitprovider_types.go index 1969c27b..0dad5786 100644 --- a/api/v1alpha3/gitprovider_types.go +++ b/api/v1alpha3/gitprovider_types.go @@ -14,8 +14,8 @@ import ( // targets at a different repository and orphan their existing materialization (the same // reason a GitTarget's destination is immutable). To repoint, delete and recreate the // GitProvider. Everything else here is operational and deliberately stays mutable — -// notably allowedBranches (widening or narrowing the writable set is a normal change -// that must not require tearing down every GitTarget), plus auth, push tuning, and +// notably allowedGitBranches (widening or narrowing who may claim which branches is a +// normal change that must not require tearing down every GitBranch), plus auth and // commit identity/signing. // // +kubebuilder:validation:XValidation:rule="self.url == oldSelf.url",message="spec.url is immutable; delete and recreate the GitProvider to point at a different repository" @@ -51,13 +51,6 @@ type GitProviderSpec struct { // +optional KnownHostsRef *KnownHostsReference `json:"knownHostsRef,omitempty"` - // AccessFrom is the policy for which OTHER namespaces may reference this GitProvider from a - // GitTarget. This GitProvider's own namespace is always admitted. Empty (or omitted) admits no - // other namespace. A GitTarget admitted from another namespace writes with this provider's - // credential, commit identity and allowedBranches, as one in its own namespace does. - // +optional - AccessFrom *NamespaceMatcher `json:"accessFrom,omitempty"` - // AllowedGitBranches says which GitBranches may claim a branch of this repository through this // GitProvider, and which branches: a GitBranch claims only while a grant admits its namespace and // one of that grant's branches matches its spec.branch. Nothing is admitted automatically, this @@ -69,12 +62,6 @@ type GitProviderSpec struct { // +kubebuilder:validation:MaxItems=64 AllowedGitBranches []GitBranchGrant `json:"allowedGitBranches,omitempty"` - // AllowedBranches restricts which branches can be written to. - // +required - // +kubebuilder:validation:MinItems=1 - // +kubebuilder:validation:items:MinLength=1 - AllowedBranches []string `json:"allowedBranches"` - // Commit configures the commit identity and signing behavior this connection uses. Message // formatting moved to GitTarget.spec.commit.message. // +optional @@ -322,16 +309,6 @@ type GitProviderList struct { Items []GitProvider `json:"items"` } -// AdmitsNamespace reports whether a GitTarget in namespace (by name and labels) may write through -// this GitProvider: its own namespace always, another one only as spec.accessFrom admits it. A -// malformed selector is an error, never a silent allow. -func (p *GitProvider) AdmitsNamespace(nsName string, nsLabels map[string]string) (bool, error) { - if nsName == p.Namespace { - return true, nil - } - return p.Spec.AccessFrom.Matches(nsName, nsLabels) -} - func init() { SchemeBuilder.Register(&GitProvider{}, &GitProviderList{}) } diff --git a/api/v1alpha3/gittarget_types.go b/api/v1alpha3/gittarget_types.go index 9e05e92a..ae47d834 100644 --- a/api/v1alpha3/gittarget_types.go +++ b/api/v1alpha3/gittarget_types.go @@ -36,9 +36,9 @@ import ( // +kubebuilder:validation:XValidation:rule="!has(self.onRefusal) || self.onRefusal != 'Reapply' || has(self.applierClaimRef)",message="spec.onRefusal: Reapply needs spec.applierClaimRef, the applier claim asked to reapply" type GitTargetSpec struct { // GitBranchRef names the GitBranch this target writes: the repository branch, and the credential - // it is written with. Namespace omitted means this GitTarget's own namespace. A GitBranch in - // another namespace is used only while its GitProvider's spec.accessFrom admits this GitTarget's - // namespace. Many GitTargets may reference one GitBranch; they share its branch worker. + // it is written with. Namespace omitted means this GitTarget's own namespace. The GitBranch is + // used only while its spec.allowedGitTargets admit this GitTarget's namespace, its own included. + // Many GitTargets may reference one GitBranch; they share its branch worker. // Immutable: delete and recreate the GitTarget to change its destination. // +required // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.gitBranchRef.name must not be empty" @@ -203,9 +203,9 @@ type GitTargetSpec struct { SerializeNamespace *bool `json:"serializeNamespace,omitempty"` // KubeProviderRef names the KubeProvider this GitTarget mirrors FROM: the source cluster, and the - // credential it is read with. Namespace omitted means this GitTarget's own namespace. A - // KubeProvider in another namespace is used only while its spec.accessFrom admits this - // GitTarget's namespace. Immutable: a folder's source cluster is part of what the folder means; + // credential it is read with. Namespace omitted means this GitTarget's own namespace. The + // KubeProvider is used only while its spec.allowedGitTargets admit this GitTarget's namespace, + // its own included. Immutable: a folder's source cluster is part of what the folder means; // delete and recreate the GitTarget to change it. // +required // +kubebuilder:validation:XValidation:rule="self.name != ''",message="spec.kubeProviderRef.name must not be empty" diff --git a/api/v1alpha3/helpers_test.go b/api/v1alpha3/helpers_test.go index f16a5c4f..43c1739d 100644 --- a/api/v1alpha3/helpers_test.go +++ b/api/v1alpha3/helpers_test.go @@ -7,7 +7,6 @@ import ( meta "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ) @@ -69,196 +68,6 @@ func TestIsInCluster_FollowsInClusterNotName(t *testing.T) { } } -// AdmitsNamespace is the authorization predicate behind the reconcile-time refusal, and there is -// no admission webhook backstopping it — that reconcile call site is the ENTIRE boundary. So every -// contract below is a security boundary rather than a routing convenience: a provider holds a -// credential that can read a whole remote cluster, and referencing it from a GitTarget mirrors that -// cluster into the target's Git destination. Every namespace below is another than the provider's -// own ("platform"), which is always admitted and is covered separately. -func TestAdmitsNamespace_Authorization(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - policy *NamespaceMatcher - nsName string - labels map[string]string - want bool - wantErr bool - }{ - { - // Deny-by-default: a provider whose author never wrote a policy grants nothing. - // The dangerous inversion would be treating "no policy" as "no restriction", which - // would silently open a freshly created provider to every namespace in the cluster. - name: "nil policy denies", - policy: nil, - nsName: "team-a", - want: false, - }, - { - // A policy object that exists but says nothing is the same statement as no policy: - // it enumerates zero namespaces, so it admits zero namespaces. - name: "empty policy denies", - policy: &NamespaceMatcher{}, - nsName: "team-a", - want: false, - }, - { - name: "listed name is allowed", - policy: &NamespaceMatcher{Names: []string{"team-a", "team-b"}}, - nsName: "team-b", - want: true, - }, - { - // Names is an exact allow-list, never a prefix or substring match; otherwise an - // attacker could create "team-a-evil" and inherit "team-a"'s grant. - name: "unlisted name is denied", - policy: &NamespaceMatcher{Names: []string{"team-a"}}, - nsName: "team-a-evil", - want: false, - }, - { - name: "name match is case-sensitive and exact", - policy: &NamespaceMatcher{Names: []string{"team-a"}}, - nsName: "Team-A", - want: false, - }, - { - name: "selector match is allowed", - policy: &NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, - }, - nsName: "anything", - labels: map[string]string{"tier": "prod"}, - want: true, - }, - { - name: "selector miss is denied", - policy: &NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, - }, - nsName: "anything", - labels: map[string]string{"tier": "dev"}, - want: false, - }, - { - // Names and Selector are ORed, so a listed namespace stays allowed even when its - // labels do not match. Requiring both would be a silent tightening that breaks - // existing grants. - name: "name allows even when the selector misses", - policy: &NamespaceMatcher{ - Names: []string{"team-a"}, - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, - }, - nsName: "team-a", - labels: map[string]string{"tier": "dev"}, - want: true, - }, - { - name: "selector allows even when the name is unlisted", - policy: &NamespaceMatcher{ - Names: []string{"team-a"}, - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, - }, - nsName: "team-z", - labels: map[string]string{"tier": "prod"}, - want: true, - }, - { - // GOTCHA worth pinning loudly: an EMPTY selector is the Kubernetes "match everything" - // selector, not "match nothing". `selector: {}` therefore grants every namespace in - // the cluster. It is deliberate (it is how a platform admin says "any namespace"), - // but it looks identical to an accidentally blank field, so the behavior must be - // asserted rather than rediscovered in production. - name: "empty selector matches every namespace", - policy: &NamespaceMatcher{Selector: &metav1.LabelSelector{}}, - nsName: "any-namespace-at-all", - want: true, - }, - { - // ...including a namespace that carries no labels at all. - name: "empty selector matches a namespace with no labels", - policy: &NamespaceMatcher{Selector: &metav1.LabelSelector{}}, - nsName: "bare", - labels: nil, - want: true, - }, - { - // A malformed selector must FAIL CLOSED: the error is surfaced to the caller AND the - // allow answer is false. Returning true on a parse failure would turn a typo in a - // provider's policy into a cluster-wide grant. - name: "invalid selector fails closed", - policy: &NamespaceMatcher{ - Selector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{ - {Key: "tier", Operator: "NotAnOperator", Values: []string{"prod"}}, - }, - }, - }, - nsName: "team-a", - labels: map[string]string{"tier": "prod"}, - want: false, - wantErr: true, - }, - { - // The name allow-list short-circuits before the selector is parsed, so a listed - // namespace is admitted even when the selector alongside it is malformed. Pinned - // because it is the one path where a broken policy does not surface an error. - name: "listed name short-circuits an invalid selector", - policy: &NamespaceMatcher{ - Names: []string{"team-a"}, - Selector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{ - {Key: "tier", Operator: "NotAnOperator"}, - }, - }, - }, - nsName: "team-a", - labels: map[string]string{"tier": "prod"}, - want: true, - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - provider := &KubeProvider{ - ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "remote"}, - Spec: KubeProviderSpec{AccessFrom: tc.policy}, - } - - allowed, err := provider.AdmitsNamespace(tc.nsName, tc.labels) - if tc.wantErr { - require.Error(t, err) - } else { - require.NoError(t, err) - } - assert.Equal(t, tc.want, allowed) - }) - } -} - -// AdmitsNamespace admits the provider's own namespace before accessFrom is read at all: a tenant's -// provider serves its own namespace with no policy written, and even a malformed policy (which fails -// closed for every other namespace) cannot lock the owner out. -func TestAdmitsNamespace_OwnNamespaceAlwaysAdmitted(t *testing.T) { - t.Parallel() - - invalid := &NamespaceMatcher{Selector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "tier", Operator: "NotAnOperator"}}, - }} - for _, policy := range []*NamespaceMatcher{nil, {}, invalid} { - provider := &KubeProvider{ - ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "remote"}, - Spec: KubeProviderSpec{AccessFrom: policy}, - } - admitted, err := provider.AdmitsNamespace("platform", nil) - require.NoError(t, err) - assert.True(t, admitted, "policy %+v", policy) - } -} - // SourceCluster is the id the watch data plane keys a source cluster on: the referenced // KubeProvider's namespace/name, its namespace defaulted to the GitTarget's own. It keys the GVK→GVR // registry and the watch context, so two providers sharing a name in two namespaces must yield two diff --git a/api/v1alpha3/kubeprovider_types.go b/api/v1alpha3/kubeprovider_types.go index a4bd35c4..6e6c94e3 100644 --- a/api/v1alpha3/kubeprovider_types.go +++ b/api/v1alpha3/kubeprovider_types.go @@ -43,21 +43,6 @@ type KubeProviderSpec struct { // +optional InCluster *KubeProviderInCluster `json:"inCluster,omitempty"` - // The policy that decides who may WIELD the credential: source-cluster RBAC bounds what it may - // read, and cannot say which control-cluster tenant may use it. The selector reads control-cluster - // labels locally, so it has no degradation path. - - // AccessFrom admits other control-cluster namespaces to reference this provider. Its own - // namespace is always admitted; omitted, no other namespace is. Its selector matches labels on - // Namespaces in the control cluster, never on the source cluster this provider names. - // +optional - AccessFrom *NamespaceMatcher `json:"accessFrom,omitempty"` - - // AllowCrossNamespace grants what a namespace this provider admits may reach outside itself, - // through this provider's credential. Omitted, it may reach nothing outside itself. - // +optional - AllowCrossNamespace *KubeProviderCrossNamespace `json:"allowCrossNamespace,omitempty"` - // AllowedGitTargets says which GitTargets may mirror through this provider, and what: each grant // admits some control-cluster namespaces and bounds what the WatchRules feeding a GitTarget there // may watch (sourceNamespaces), and what a ClusterWatchRule may mirror through it @@ -94,33 +79,6 @@ type KubeProviderSpec struct { // is the whole statement. type KubeProviderInCluster struct{} -// Each field is a power of its own, granted on purpose: sources lets a namespace READ other -// namespaces' objects into its own Git, appliers lets it suspend and apply other namespaces' -// deployments. On an inCluster provider same-name coupling WAS the boundary, so either one -// bypasses live namespace RBAC: the owner of an admitted object acts on another namespace through -// the operator's own credential. It is a delegation, not owner consent: it does not prove that the -// namespace owns the objects it reaches, or the repository it writes. - -// KubeProviderCrossNamespace is what a namespace a KubeProvider admits may reach outside itself. -// The trust is in every admitted namespace, for everything in reach of the provider's identity; it -// does not prove that the namespace owns what it reaches. -type KubeProviderCrossNamespace struct { - // Sources lets a WatchRule mirroring through this provider watch namespaces other than its own, - // including a rules[].sourceNamespace of "*", which reaches every namespace the credential can - // read. False (the default): a WatchRule watches only its own namespace. It grants no access by - // itself: the source credential's RBAC remains the hard maximum. It does not apply to - // ClusterWatchRule, which selects no namespaces at all. - // +optional - Sources bool `json:"sources,omitempty"` - - // Appliers lets a FluxClaim or ArgoCDClaim reaching its applier through this provider name a - // Kustomization or Application in another namespace than its own. False (the default): only - // the applier claim's own namespace, which in a remote cluster is the namespace of the same name; an - // applier claim naming another is Configured=False (ApplierNamespaceNotAllowed). - // +optional - Appliers bool `json:"appliers,omitempty"` -} - // KubeProviderClient holds the per-cluster kube-client throttles. type KubeProviderClient struct { // QPS overrides the operator's outgoing kube-client query-per-second throttle for this @@ -213,10 +171,10 @@ type KubeProviderStatus struct { // +kubebuilder:printcolumn:name="Age",type=date,JSONPath=`.metadata.creationTimestamp` // KubeProvider is a connection to a Kubernetes cluster and the identity used there. A GitTarget -// mirrors from it; an applier claim reaches its applier through it. It holds the credential that reaches it (a kubeconfig Secret in its own -// namespace, or the operator's own identity where the installation allows it), which control-cluster -// namespaces may use it (its own, and those spec.accessFrom admits), and the audit route its -// attribution facts are partitioned under. +// mirrors from it; an applier claim reaches its applier through it. It holds the credential that +// reaches it (a kubeconfig Secret in its own namespace, or the operator's own identity where the +// installation allows it), who may use it and for what (spec.allowedGitTargets and +// spec.allowedApplierClaims), and the audit route its attribution facts are partitioned under. type KubeProvider struct { metav1.TypeMeta `json:",inline"` @@ -272,29 +230,6 @@ func DefaultAuditRoute(provider types.NamespacedName) string { return provider.Namespace + "." + provider.Name } -// AdmitsNamespace reports whether a consumer in namespace (by name and labels) may use this -// provider: its own namespace always, another one only as spec.accessFrom admits it. A malformed -// selector is an error, never a silent allow. The labels are always CONTROL-cluster Namespace -// labels. -func (p *KubeProvider) AdmitsNamespace(nsName string, nsLabels map[string]string) (bool, error) { - if nsName == p.Namespace { - return true, nil - } - return p.Spec.AccessFrom.Matches(nsName, nsLabels) -} - -// AllowsCrossNamespaceSources reports whether a WatchRule mirroring through this provider may -// watch namespaces other than its own (spec.allowCrossNamespace.sources). -func (p *KubeProvider) AllowsCrossNamespaceSources() bool { - return p.Spec.AllowCrossNamespace != nil && p.Spec.AllowCrossNamespace.Sources -} - -// AllowsCrossNamespaceAppliers reports whether an applier claim reaching its applier through this provider -// may name one in another namespace than its own (spec.allowCrossNamespace.appliers). -func (p *KubeProvider) AllowsCrossNamespaceAppliers() bool { - return p.Spec.AllowCrossNamespace != nil && p.Spec.AllowCrossNamespace.Appliers -} - // AuditRouteHolder returns the namespace that holds route among providers, and false when none of // them carries it. A route is held by one namespace, so a provider in another namespace cannot read // facts that belong to a tenant it was never admitted to: diff --git a/api/v1alpha3/kubeprovider_types_test.go b/api/v1alpha3/kubeprovider_types_test.go index 8c6672a7..a093db19 100644 --- a/api/v1alpha3/kubeprovider_types_test.go +++ b/api/v1alpha3/kubeprovider_types_test.go @@ -8,7 +8,6 @@ import ( meta "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" ) @@ -37,34 +36,12 @@ func TestKubeProvider_AuditRoute(t *testing.T) { assert.Equal(t, "prod-eu-1", new(kubeProvider("tenant-a", "prod", "prod-eu-1", t0)).AuditRoute()) } -func TestKubeProvider_AdmitsNamespace(t *testing.T) { - p := kubeProvider("platform", "remote", "", time.Now()) - - admitted, err := p.AdmitsNamespace("platform", nil) - require.NoError(t, err) - assert.True(t, admitted, "a provider admits its own namespace without accessFrom") - - admitted, err = p.AdmitsNamespace("tenant-a", nil) - require.NoError(t, err) - assert.False(t, admitted, "omitted accessFrom admits no other namespace") - - p.Spec.AccessFrom = &NamespaceMatcher{Names: []string{"tenant-a"}} - admitted, err = p.AdmitsNamespace("tenant-a", nil) - require.NoError(t, err) - assert.True(t, admitted) -} - -func TestKubeProvider_CredentialAndGrants(t *testing.T) { +func TestKubeProvider_CredentialAndKey(t *testing.T) { p := kubeProvider("platform", "local", "", time.Now()) assert.False(t, p.IsInCluster()) - assert.False(t, p.AllowsCrossNamespaceSources()) - assert.False(t, p.AllowsCrossNamespaceAppliers()) p.Spec.InCluster = &KubeProviderInCluster{} - p.Spec.AllowCrossNamespace = &KubeProviderCrossNamespace{Sources: true, Appliers: true} assert.True(t, p.IsInCluster()) - assert.True(t, p.AllowsCrossNamespaceSources()) - assert.True(t, p.AllowsCrossNamespaceAppliers()) assert.Equal(t, types.NamespacedName{Namespace: "platform", Name: "local"}, p.Key()) } diff --git a/api/v1alpha3/namespace_matcher.go b/api/v1alpha3/namespace_matcher.go deleted file mode 100644 index 6b06f9a8..00000000 --- a/api/v1alpha3/namespace_matcher.go +++ /dev/null @@ -1,81 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package v1alpha3 - -import ( - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/labels" -) - -// NamespaceMatcher is the deny-by-default namespace-policy SHAPE this API uses where a field bounds -// "which namespaces". It carries an explicit name allow-list and a label selector, ORed: a -// namespace is admitted if it is listed OR its labels match. -// -// It is deny-by-default and the empty matcher is NOT "unrestricted": a matcher with neither names -// nor selector admits NOTHING. Its one use is authorization, and the fail-open reading is the -// catastrophic one, so an absent field means "no policy declared" (which admits nothing here) while -// a declared-but-empty one also admits nothing. -// -// One field uses it: KubeProvider.spec.accessFrom, whose selector matches labels on Namespaces -// in the CONTROL cluster. It had a source-side twin, GitTarget.spec.allowedSourceNamespaces, -// evaluated against Namespace labels in another cluster; that twin and everything it forced (a -// three-valued verdict, a degradation path, source-cluster Namespace get/list/watch) were deleted. -// Matches therefore still takes the labels rather than fetching them, which is now merely -// convenient rather than load-bearing. -type NamespaceMatcher struct { - // `*` is rejected because Kubernetes treats it as a LITERAL name: `names: ["*"]` would admit - // nothing while reading as if it admitted everything. `selector: {}` is the "every namespace" - // form, because it resolves live. - - // Names is an explicit allow-list of namespace names. Entries are namespace names (DNS-1123 - // labels), never patterns — `*` is rejected. To admit every namespace, declare `selector: {}`. - // +optional - // +listType=set - // +kubebuilder:validation:items:MinLength=1 - // +kubebuilder:validation:items:MaxLength=63 - // +kubebuilder:validation:items:Pattern=`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$` - Names []string `json:"names,omitempty"` - - // Selector is a label selector matched against Namespace labels; a namespace whose labels - // match is admitted. ORed with Names. - // +optional - Selector *metav1.LabelSelector `json:"selector,omitempty"` -} - -// MatchesName reports whether nsName is in the matcher's explicit Names allow-list. -// -// It is separate from Matches so the answer never depends on the labels when a name already -// admits. A nil matcher matches nothing. -func (m *NamespaceMatcher) MatchesName(nsName string) bool { - if m == nil { - return false - } - for _, n := range m.Names { - if n == nsName { - return true - } - } - return false -} - -// Matches reports whether a namespace (by name and by the labels it carries IN THE CLUSTER THIS -// FIELD DESCRIBES) is admitted. Names are checked before the selector, so the answer never depends -// on the labels when a name already admits. A malformed selector is returned as an error rather -// than a silent allow or a silent deny — it is a configuration mistake the operator must see. -// A nil matcher admits nothing. -func (m *NamespaceMatcher) Matches(nsName string, nsLabels map[string]string) (bool, error) { - if m == nil { - return false, nil - } - if m.MatchesName(nsName) { - return true, nil - } - if m.Selector == nil { - return false, nil - } - sel, err := metav1.LabelSelectorAsSelector(m.Selector) - if err != nil { - return false, err - } - return sel.Matches(labels.Set(nsLabels)), nil -} diff --git a/api/v1alpha3/namespace_matcher_test.go b/api/v1alpha3/namespace_matcher_test.go deleted file mode 100644 index 4e2c848d..00000000 --- a/api/v1alpha3/namespace_matcher_test.go +++ /dev/null @@ -1,138 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 - -package v1alpha3 - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" -) - -// TestNamespaceMatcher_DenyByDefault pins the semantics accessFrom depends on. The fail-open -// reading is the catastrophic one, so the nil and empty cases get their own assertions rather than -// riding along with a general case. -func TestNamespaceMatcher_DenyByDefault(t *testing.T) { - var nilMatcher *NamespaceMatcher - - allowed, err := nilMatcher.Matches("anything", nil) - require.NoError(t, err) - assert.False(t, allowed, "a nil matcher admits nothing") - - empty := &NamespaceMatcher{} - allowed, err = empty.Matches("anything", map[string]string{"a": "b"}) - require.NoError(t, err) - assert.False(t, allowed, "an EMPTY declared policy admits nothing: empty is not unrestricted") -} - -// TestNamespaceMatcher_NamesAndSelectorAreOred covers the OR contract and that the NAME half never -// consults labels, so a listed namespace is admitted whatever it is labelled. -func TestNamespaceMatcher_NamesAndSelectorAreOred(t *testing.T) { - matcher := &NamespaceMatcher{ - Names: []string{"repo-config"}, - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirrorable": "true"}}, - } - - tests := []struct { - name string - nsName string - labels map[string]string - allowed bool - }{ - {"listed by name, no labels at all", "repo-config", nil, true}, - {"matched by selector", "other", map[string]string{"mirrorable": "true"}, true}, - {"neither", "other", map[string]string{"mirrorable": "false"}, false}, - {"listed by name despite non-matching labels", "repo-config", - map[string]string{"mirrorable": "false"}, true}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - allowed, err := matcher.Matches(tt.nsName, tt.labels) - require.NoError(t, err) - assert.Equal(t, tt.allowed, allowed) - }) - } - - assert.True(t, matcher.MatchesName("repo-config")) - assert.False(t, matcher.MatchesName("other")) -} - -// TestNamespaceMatcher_InvalidSelectorIsAnError: a malformed selector must surface, not silently -// allow or silently deny. Both silent outcomes are configuration bugs an operator never sees. -func TestNamespaceMatcher_InvalidSelectorIsAnError(t *testing.T) { - matcher := &NamespaceMatcher{ - Selector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{{ - Key: "x", Operator: "NotARealOperator", Values: []string{"y"}, - }}, - }, - } - - _, err := matcher.Matches("any", map[string]string{"x": "y"}) - - require.Error(t, err) -} - -// A present-but-EMPTY selector admits EVERY namespace, while a nil one admits none. -// LabelSelectorAsSelector returns labels.Everything() for the first and labels.Nothing() for the -// second, and a provider shared with every namespace says so with `accessFrom: {selector: {}}`, so -// if this ever flipped every such install would stop admitting anything. -func TestNamespaceMatcher_EmptySelectorMatchesEverything(t *testing.T) { - declared := &NamespaceMatcher{Selector: &metav1.LabelSelector{}} - admits, err := declared.Matches("any", nil) - require.NoError(t, err) - assert.True(t, admits, "a present-but-empty selector admits EVERY namespace, labels or not") - - admits, err = declared.Matches("any", map[string]string{"anything": "at-all"}) - require.NoError(t, err) - assert.True(t, admits) - - absent := &NamespaceMatcher{Names: []string{"repo-config"}} - admits, err = absent.Matches("other", map[string]string{"anything": "at-all"}) - require.NoError(t, err) - assert.False(t, admits, "a nil selector admits nothing beyond the listed names") -} - -// TestResourceRule_EffectiveSourceNamespace pins the per-item defaulting every consumer keys on. -// Getting this wrong produces a stale watch, not a visible failure. -func TestResourceRule_EffectiveSourceNamespace(t *testing.T) { - const own = "tenant-acme" - item := &ResourceRule{Resources: []string{"configmaps"}} - - assert.Equal(t, own, item.EffectiveSourceNamespace(own), "omitted means the rule's own") - assert.False(t, item.OverridesSourceNamespace(own)) - assert.False(t, item.IsSourceNamespaceWildcard()) - - item.SourceNamespace = "repo-config" - assert.Equal(t, "repo-config", item.EffectiveSourceNamespace(own)) - assert.True(t, item.OverridesSourceNamespace(own)) - assert.False(t, item.IsSourceNamespaceWildcard()) - - // Restating the rule's own namespace is NOT an override: it needs no delegation flag. - item.SourceNamespace = own - assert.Equal(t, own, item.EffectiveSourceNamespace(own)) - assert.False(t, item.OverridesSourceNamespace(own), - "naming your own namespace explicitly must behave exactly like omitting it") - - // "*" is ALWAYS an override: it reaches every namespace the source credential can read, which - // is the widest request in the API and the last one that should slip past the delegation flag. - item.SourceNamespace = SourceNamespaceWildcard - assert.True(t, item.IsSourceNamespaceWildcard()) - assert.True(t, item.OverridesSourceNamespace(own)) -} - -// The security default in one line: the delegation flag must be false on a provider that never -// mentions it, so a WatchRule may watch only its own namespace until a platform admin says -// otherwise. -func TestKubeProvider_DelegationFlagDefaultsClosed(t *testing.T) { - provider := &KubeProvider{} - assert.False(t, provider.AllowsCrossNamespaceSources(), - "source-namespace delegation must never be on by default") - assert.False(t, provider.AllowsCrossNamespaceAppliers(), - "nor may an applier claim reach another namespace's applier by default") - provider.Spec.AllowCrossNamespace = &KubeProviderCrossNamespace{Appliers: true} - assert.False(t, provider.AllowsCrossNamespaceSources(), "each power is granted on its own") - assert.True(t, provider.AllowsCrossNamespaceAppliers()) -} diff --git a/api/v1alpha3/watchrule_types.go b/api/v1alpha3/watchrule_types.go index 440b85aa..7fb6c996 100644 --- a/api/v1alpha3/watchrule_types.go +++ b/api/v1alpha3/watchrule_types.go @@ -12,8 +12,8 @@ import ( // WatchRuleSpec defines the desired state of WatchRule. // WatchRule selects NAMESPACED resources on its GitTarget's source cluster. Each rules[] item // carries its own source namespace: omitted for this WatchRule's own namespace, an explicit name, -// or "*" for every namespace the source credential can read (refused unless the KubeProvider -// sets allowCrossNamespace.sources). +// or "*" for every namespace the source credential can read. Each is watched only while the +// KubeProvider's spec.allowedGitTargets give the GitTarget's namespace that source namespace. type WatchRuleSpec struct { // GitTargetRef names the GitTarget this rule feeds, in this WatchRule's own namespace. // +required @@ -109,9 +109,10 @@ type ResourceRule struct { // "*" for every namespace the source credential can read. // // "*" is one cluster-wide list and watch rather than a set of per-namespace ones, so its bound - // is the credential's own RBAC and nothing else. Naming any namespace other than this rule's - // own, "*" included, requires the GitTarget's KubeProvider to admit the target's namespace - // AND to set spec.allowCrossNamespace.sources; while that is false, "*" is refused outright. + // is the credential's own RBAC and nothing else. Every value, this rule's own namespace + // included, needs a grant: the GitTarget's KubeProvider must admit the target's namespace in + // spec.allowedGitTargets with that namespace in sourceNamespaces ("{namespace}" is the target's + // own). "*" needs "*" there. // // This changes only which namespace is WATCHED, never where objects are written: Git placement // follows each mirrored object's own namespace. @@ -126,8 +127,8 @@ type ResourceRule struct { // // It kept its spelling and changed its meaning: it used to resolve live through // GitTarget.spec.allowedSourceNamespaces into a concrete set. That field is gone, so the token is -// bounded by source-cluster RBAC alone and is refused while the KubeProvider does not set -// spec.allowCrossNamespace.sources. See docs/design/source-scope-simplification.md, which is the +// bounded by source-cluster RBAC alone and is refused unless the KubeProvider's grant gives +// sourceNamespaces "*". See docs/design/source-scope-simplification.md, which is the // definition of record. const SourceNamespaceWildcard = "*" @@ -151,14 +152,6 @@ func (r *ResourceRule) IsSourceNamespaceWildcard() bool { return r.SourceNamespace == SourceNamespaceWildcard } -// OverridesSourceNamespace reports whether this item asks for a source namespace OTHER than the -// WatchRule's own — the case that needs the KubeProvider's delegation flag. A sourceNamespace -// that merely restates the rule's own namespace is not an override and stays the legacy case; "*" -// always is one, since it reaches every namespace the credential can read. -func (r *ResourceRule) OverridesSourceNamespace(ruleNamespace string) bool { - return r.IsSourceNamespaceWildcard() || r.EffectiveSourceNamespace(ruleNamespace) != ruleNamespace -} - // DescribeSourceNamespace renders this item's requested source namespace for an operator-facing // message. An omitted value is spelled out rather than shown as an empty string. func (r *ResourceRule) DescribeSourceNamespace(ruleNamespace string) string { diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index 69ed7e76..0b3f4320 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -1022,11 +1022,6 @@ func (in *GitProviderSpec) DeepCopyInto(out *GitProviderSpec) { *out = new(KnownHostsReference) **out = **in } - if in.AccessFrom != nil { - in, out := &in.AccessFrom, &out.AccessFrom - *out = new(NamespaceMatcher) - (*in).DeepCopyInto(*out) - } if in.AllowedGitBranches != nil { in, out := &in.AllowedGitBranches, &out.AllowedGitBranches *out = make([]GitBranchGrant, len(*in)) @@ -1034,11 +1029,6 @@ func (in *GitProviderSpec) DeepCopyInto(out *GitProviderSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.AllowedBranches != nil { - in, out := &in.AllowedBranches, &out.AllowedBranches - *out = make([]string, len(*in)) - copy(*out, *in) - } if in.Commit != nil { in, out := &in.Commit, &out.Commit *out = new(CommitSpec) @@ -1509,21 +1499,6 @@ func (in *KubeProviderClient) DeepCopy() *KubeProviderClient { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *KubeProviderCrossNamespace) DeepCopyInto(out *KubeProviderCrossNamespace) { - *out = *in -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeProviderCrossNamespace. -func (in *KubeProviderCrossNamespace) DeepCopy() *KubeProviderCrossNamespace { - if in == nil { - return nil - } - out := new(KubeProviderCrossNamespace) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KubeProviderInCluster) DeepCopyInto(out *KubeProviderInCluster) { *out = *in @@ -1584,16 +1559,6 @@ func (in *KubeProviderSpec) DeepCopyInto(out *KubeProviderSpec) { *out = new(KubeProviderInCluster) **out = **in } - if in.AccessFrom != nil { - in, out := &in.AccessFrom, &out.AccessFrom - *out = new(NamespaceMatcher) - (*in).DeepCopyInto(*out) - } - if in.AllowCrossNamespace != nil { - in, out := &in.AllowCrossNamespace, &out.AllowCrossNamespace - *out = new(KubeProviderCrossNamespace) - **out = **in - } if in.AllowedGitTargets != nil { in, out := &in.AllowedGitTargets, &out.AllowedGitTargets *out = make([]SourceGrant, len(*in)) @@ -1677,31 +1642,6 @@ func (in *NamespaceGrant) DeepCopy() *NamespaceGrant { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NamespaceMatcher) DeepCopyInto(out *NamespaceMatcher) { - *out = *in - if in.Names != nil { - in, out := &in.Names, &out.Names - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.Selector != nil { - in, out := &in.Selector, &out.Selector - *out = new(v1.LabelSelector) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceMatcher. -func (in *NamespaceMatcher) DeepCopy() *NamespaceMatcher { - if in == nil { - return nil - } - out := new(NamespaceMatcher) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PrunePolicy) DeepCopyInto(out *PrunePolicy) { *out = *in diff --git a/config/crd/bases/configbutler.ai_argocdclaims.yaml b/config/crd/bases/configbutler.ai_argocdclaims.yaml index 2e5dc4ce..73a00dbb 100644 --- a/config/crd/bases/configbutler.ai_argocdclaims.yaml +++ b/config/crd/bases/configbutler.ai_argocdclaims.yaml @@ -126,8 +126,9 @@ spec: description: |- KubeProviderRef names the KubeProvider whose cluster holds the Application, such as a management cluster running Argo CD for the source cluster. Namespace omitted means this - applier claim's own namespace. The provider must admit this applier claim's namespace: its own, or one its - spec.accessFrom admits. Its identity needs get, update and patch on the Application. Immutable. + applier claim's own namespace. The provider's spec.allowedApplierClaims must admit this applier + claim's namespace, with the Application's namespace in applierNamespaces. Its identity needs + get, update and patch on the Application. Immutable. properties: name: description: Name of the referent. diff --git a/config/crd/bases/configbutler.ai_fluxclaims.yaml b/config/crd/bases/configbutler.ai_fluxclaims.yaml index 3c704f13..945630b4 100644 --- a/config/crd/bases/configbutler.ai_fluxclaims.yaml +++ b/config/crd/bases/configbutler.ai_fluxclaims.yaml @@ -97,8 +97,9 @@ spec: description: |- KubeProviderRef names the KubeProvider whose cluster holds the Kustomization, such as a management cluster running Flux for the source cluster. Namespace omitted means this - applier claim's own namespace. The provider must admit this applier claim's namespace: its own, or one its - spec.accessFrom admits. Its identity needs get and patch on the Kustomization and its GitRepository. Immutable. + applier claim's own namespace. The provider's spec.allowedApplierClaims must admit this applier + claim's namespace, with the Kustomization's namespace in applierNamespaces. Its identity needs + get and patch on the Kustomization and its GitRepository. Immutable. properties: name: description: Name of the referent. diff --git a/config/crd/bases/configbutler.ai_gitbranches.yaml b/config/crd/bases/configbutler.ai_gitbranches.yaml index fce4f649..1cf52b18 100644 --- a/config/crd/bases/configbutler.ai_gitbranches.yaml +++ b/config/crd/bases/configbutler.ai_gitbranches.yaml @@ -197,7 +197,8 @@ spec: branch: description: |- Branch is the branch on the remote that this GitBranch writes. It must match one of the - GitProvider's allowedBranches. Only one GitBranch writes a repository branch: the oldest one + patterns the GitProvider's spec.allowedGitBranches give this GitBranch's namespace. Only one + GitBranch writes a repository branch: the oldest admitted one whose credential proves it can open a push session holds it, and any other GitBranch for the same repository and branch reports Stalled with reason BranchConflict. Immutable: delete and recreate the GitBranch to change it. @@ -230,8 +231,8 @@ spec: default branch), or a branch with no history when the repository is empty. When set, it must exist on the remote for spec.branch to be created; otherwise this GitBranch reports ParentBranchNotFound and nothing is written. Once spec.branch exists, it is used as it is and - the parent is not followed. It is only read, so it does not need to be in the GitProvider's - allowedBranches. + the parent is not followed. It is only read, so the GitProvider's spec.allowedGitBranches need + not allow it. Immutable, including adding or removing it: delete and recreate the GitBranch to change it. minLength: 1 type: string diff --git a/config/crd/bases/configbutler.ai_gitproviders.yaml b/config/crd/bases/configbutler.ai_gitproviders.yaml index 314b82b2..252b2949 100644 --- a/config/crd/bases/configbutler.ai_gitproviders.yaml +++ b/config/crd/bases/configbutler.ai_gitproviders.yaml @@ -60,81 +60,6 @@ spec: spec: description: spec defines the desired state of GitProvider properties: - accessFrom: - description: |- - AccessFrom is the policy for which OTHER namespaces may reference this GitProvider from a - GitTarget. This GitProvider's own namespace is always admitted. Empty (or omitted) admits no - other namespace. A GitTarget admitted from another namespace writes with this provider's - credential, commit identity and allowedBranches, as one in its own namespace does. - properties: - names: - description: |- - Names is an explicit allow-list of namespace names. Entries are namespace names (DNS-1123 - labels), never patterns — `*` is rejected. To admit every namespace, declare `selector: {}`. - items: - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - type: array - x-kubernetes-list-type: set - selector: - description: |- - Selector is a label selector matched against Namespace labels; a namespace whose labels - match is admitted. ORed with Names. - properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. - items: - 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 - allowedBranches: - description: AllowedBranches restricts which branches can be written - to. - items: - minLength: 1 - type: string - minItems: 1 - type: array allowedGitBranches: description: |- AllowedGitBranches says which GitBranches may claim a branch of this repository through this @@ -390,7 +315,6 @@ spec: minLength: 1 type: string required: - - allowedBranches - url type: object x-kubernetes-validations: diff --git a/config/crd/bases/configbutler.ai_gittargets.yaml b/config/crd/bases/configbutler.ai_gittargets.yaml index d4c6bd3d..ff2aa6a4 100644 --- a/config/crd/bases/configbutler.ai_gittargets.yaml +++ b/config/crd/bases/configbutler.ai_gittargets.yaml @@ -307,9 +307,9 @@ spec: gitBranchRef: description: |- GitBranchRef names the GitBranch this target writes: the repository branch, and the credential - it is written with. Namespace omitted means this GitTarget's own namespace. A GitBranch in - another namespace is used only while its GitProvider's spec.accessFrom admits this GitTarget's - namespace. Many GitTargets may reference one GitBranch; they share its branch worker. + it is written with. Namespace omitted means this GitTarget's own namespace. The GitBranch is + used only while its spec.allowedGitTargets admit this GitTarget's namespace, its own included. + Many GitTargets may reference one GitBranch; they share its branch worker. Immutable: delete and recreate the GitTarget to change its destination. properties: name: @@ -343,9 +343,9 @@ spec: kubeProviderRef: description: |- KubeProviderRef names the KubeProvider this GitTarget mirrors FROM: the source cluster, and the - credential it is read with. Namespace omitted means this GitTarget's own namespace. A - KubeProvider in another namespace is used only while its spec.accessFrom admits this - GitTarget's namespace. Immutable: a folder's source cluster is part of what the folder means; + credential it is read with. Namespace omitted means this GitTarget's own namespace. The + KubeProvider is used only while its spec.allowedGitTargets admit this GitTarget's namespace, + its own included. Immutable: a folder's source cluster is part of what the folder means; delete and recreate the GitTarget to change it. properties: name: diff --git a/config/crd/bases/configbutler.ai_kubeproviders.yaml b/config/crd/bases/configbutler.ai_kubeproviders.yaml index 3923c686..1845b7aa 100644 --- a/config/crd/bases/configbutler.ai_kubeproviders.yaml +++ b/config/crd/bases/configbutler.ai_kubeproviders.yaml @@ -44,10 +44,10 @@ spec: openAPIV3Schema: description: |- KubeProvider is a connection to a Kubernetes cluster and the identity used there. A GitTarget - mirrors from it; an applier claim reaches its applier through it. It holds the credential that reaches it (a kubeconfig Secret in its own - namespace, or the operator's own identity where the installation allows it), which control-cluster - namespaces may use it (its own, and those spec.accessFrom admits), and the audit route its - attribution facts are partitioned under. + mirrors from it; an applier claim reaches its applier through it. It holds the credential that + reaches it (a kubeconfig Secret in its own namespace, or the operator's own identity where the + installation allows it), who may use it and for what (spec.allowedGitTargets and + spec.allowedApplierClaims), and the audit route its attribution facts are partitioned under. properties: apiVersion: description: |- @@ -69,93 +69,6 @@ spec: spec: description: spec defines the desired state of KubeProvider. properties: - accessFrom: - description: |- - AccessFrom admits other control-cluster namespaces to reference this provider. Its own - namespace is always admitted; omitted, no other namespace is. Its selector matches labels on - Namespaces in the control cluster, never on the source cluster this provider names. - properties: - names: - description: |- - Names is an explicit allow-list of namespace names. Entries are namespace names (DNS-1123 - labels), never patterns — `*` is rejected. To admit every namespace, declare `selector: {}`. - items: - maxLength: 63 - minLength: 1 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - type: string - type: array - x-kubernetes-list-type: set - selector: - description: |- - Selector is a label selector matched against Namespace labels; a namespace whose labels - match is admitted. ORed with Names. - properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. - items: - 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 - allowCrossNamespace: - description: |- - AllowCrossNamespace grants what a namespace this provider admits may reach outside itself, - through this provider's credential. Omitted, it may reach nothing outside itself. - properties: - appliers: - description: |- - Appliers lets a FluxClaim or ArgoCDClaim reaching its applier through this provider name a - Kustomization or Application in another namespace than its own. False (the default): only - the applier claim's own namespace, which in a remote cluster is the namespace of the same name; an - applier claim naming another is Configured=False (ApplierNamespaceNotAllowed). - type: boolean - sources: - description: |- - Sources lets a WatchRule mirroring through this provider watch namespaces other than its own, - including a rules[].sourceNamespace of "*", which reaches every namespace the credential can - read. False (the default): a WatchRule watches only its own namespace. It grants no access by - itself: the source credential's RBAC remains the hard maximum. It does not apply to - ClusterWatchRule, which selects no namespaces at all. - type: boolean - type: object allowedApplierClaims: description: |- AllowedApplierClaims says which applier claims (FluxClaims and ArgoCDClaims) may hold an applier diff --git a/config/crd/bases/configbutler.ai_watchrules.yaml b/config/crd/bases/configbutler.ai_watchrules.yaml index 4efc2dd3..c845a22e 100644 --- a/config/crd/bases/configbutler.ai_watchrules.yaml +++ b/config/crd/bases/configbutler.ai_watchrules.yaml @@ -223,9 +223,10 @@ spec: "*" for every namespace the source credential can read. "*" is one cluster-wide list and watch rather than a set of per-namespace ones, so its bound - is the credential's own RBAC and nothing else. Naming any namespace other than this rule's - own, "*" included, requires the GitTarget's KubeProvider to admit the target's namespace - AND to set spec.allowCrossNamespace.sources; while that is false, "*" is refused outright. + is the credential's own RBAC and nothing else. Every value, this rule's own namespace + included, needs a grant: the GitTarget's KubeProvider must admit the target's namespace in + spec.allowedGitTargets with that namespace in sourceNamespaces ("{namespace}" is the target's + own). "*" needs "*" there. This changes only which namespace is WATCHED, never where objects are written: Git placement follows each mirrored object's own namespace. diff --git a/config/samples/README.md b/config/samples/README.md index 3c903b18..92a759d2 100644 --- a/config/samples/README.md +++ b/config/samples/README.md @@ -10,7 +10,7 @@ These samples are quick starting points for common GitOps Reverser setups. - `gitreceiver.yaml`: A `GitReceiver` a Git host's push webhook points at, with its token Secret in its own namespace. It wakes every `GitProvider` whose `spec.url` a delivery names. - `kubeprovider.yaml`: A `KubeProvider` for a remote cluster, with its kubeconfig Secret in its own - namespace, showing `accessFrom` and `allowCrossNamespace`. + namespace, showing `allowedGitTargets` and `allowedApplierClaims`. - `clusterwatchrule.yaml`: Minimal `ClusterWatchRule` for cluster-scoped resources. - `fluxclaim.yaml`, `argocdclaim.yaml`: An applier claim holding the Flux `Kustomization` or the Argo CD `Application` that applies `example-target`'s folder, for its `spec.applierClaimRef`. diff --git a/config/samples/argocdclaim.yaml b/config/samples/argocdclaim.yaml index ff879d34..69a5c06e 100644 --- a/config/samples/argocdclaim.yaml +++ b/config/samples/argocdclaim.yaml @@ -11,8 +11,8 @@ spec: kubeProviderRef: name: local namespace: gitops-reverser - # In another namespace than the applier claim's, so the KubeProvider must set - # allowCrossNamespace.appliers: true, or the applier claim reports ApplierNamespaceNotAllowed. + # The KubeProvider's spec.allowedApplierClaims must admit this namespace with this applier's + # namespace in applierNamespaces, or the applier claim reports ApplierNamespaceNotAllowed. applicationRef: namespace: argocd name: example-apps diff --git a/config/samples/fluxclaim.yaml b/config/samples/fluxclaim.yaml index 2921d769..3dbfb454 100644 --- a/config/samples/fluxclaim.yaml +++ b/config/samples/fluxclaim.yaml @@ -11,8 +11,8 @@ spec: kubeProviderRef: name: local namespace: gitops-reverser - # In another namespace than the applier claim's, so the KubeProvider must set - # allowCrossNamespace.appliers: true, or the applier claim reports ApplierNamespaceNotAllowed. + # The KubeProvider's spec.allowedApplierClaims must admit this namespace with this applier's + # namespace in applierNamespaces, or the applier claim reports ApplierNamespaceNotAllowed. kustomizationRef: namespace: flux-system name: example-apps diff --git a/config/samples/kubeprovider.yaml b/config/samples/kubeprovider.yaml index 7bb8c52f..3e113805 100644 --- a/config/samples/kubeprovider.yaml +++ b/config/samples/kubeprovider.yaml @@ -17,17 +17,23 @@ metadata: spec: secretRef: name: prod-eu-1-kubeconfig - # Consumers in this namespace are always admitted. accessFrom admits the others: here, every - # namespace labeled tier=trusted in the control cluster. - accessFrom: - selector: - matchLabels: - tier: trusted - # What those namespaces may reach outside themselves, each granted on purpose. sources: a - # WatchRule may choose which SOURCE namespaces to mirror; while false (the default) it watches - # only its own namespace, and `sourceNamespace: "*"` is refused outright. appliers: an applier claim may - # suspend and apply a Kustomization or Application in another namespace. Both delegate to every - # admitted namespace; neither proves that a namespace owns what it reaches. - allowCrossNamespace: - sources: true - appliers: false + # Nothing uses this provider without a grant, its own namespace included. Each grant says where it + # admits from (Same, Names, Selector or All) and what: the source namespaces a GitTarget's + # WatchRules may watch ("{namespace}" is the GitTarget's own; "*" is every namespace, which + # `sourceNamespace: "*"` needs), and the cluster-scoped resources a ClusterWatchRule may mirror. + # Here: every namespace labelled tier=trusted in the control cluster mirrors its own namespace, + # and this namespace mirrors every namespace and the CRDs. + allowedGitTargets: + - from: Selector + selector: + matchLabels: + tier: trusted + sourceNamespaces: ["{namespace}"] + - from: Same + sourceNamespaces: ["*"] + clusterResources: [customresourcedefinitions.apiextensions.k8s.io] + # Which applier claims may hold a Flux Kustomization or an Argo CD Application through this + # provider, and where it may live. Admission to mirror is no admission to apply. + allowedApplierClaims: + - from: Same + applierNamespaces: ["{namespace}"] diff --git a/config/samples/quickstart-gitbranch.yaml b/config/samples/quickstart-gitbranch.yaml index cc6e524e..58938092 100644 --- a/config/samples/quickstart-gitbranch.yaml +++ b/config/samples/quickstart-gitbranch.yaml @@ -4,11 +4,16 @@ metadata: name: example-branch namespace: default spec: - # A GitProvider in this GitBranch's namespace. Its credential has to be able to open a push - # session on the repository, or this GitBranch never holds the branch. + # The GitProvider whose credential writes this branch; add `namespace:` for one in another + # namespace. Its spec.allowedGitBranches must admit this namespace and the branch, and its + # credential has to be able to open a push session on the repository, or this GitBranch never + # holds the branch. gitProviderRef: name: example-provider - # Must match one of the GitProvider's allowedBranches. Only one GitBranch writes a repository branch. + # Only one GitBranch writes a repository branch. branch: main # Optional: the branch `main` is created from while the remote does not have it. # parentBranch: develop + # Which GitTargets may write through this branch. Its own namespace needs a grant too. + allowedGitTargets: + - from: Same diff --git a/config/samples/quickstart-gitprovider.yaml b/config/samples/quickstart-gitprovider.yaml index 986464ae..3b4a172b 100644 --- a/config/samples/quickstart-gitprovider.yaml +++ b/config/samples/quickstart-gitprovider.yaml @@ -6,7 +6,12 @@ metadata: spec: # Replace with your own repository URL url: "git@github.com:YOUR_USERNAME/my-k8s-audit.git" - allowedBranches: ["*"] + # Which GitBranches may claim which branches through this provider. Nothing is automatic, this + # namespace included. '*' matches any branch; "teams/{namespace}/*" would give each namespace its + # own prefix. + allowedGitBranches: + - from: Same + branches: ["*"] secretRef: name: git-creds diff --git a/config/samples/quickstart-gittarget.yaml b/config/samples/quickstart-gittarget.yaml index 3c4bd977..6d715ab1 100644 --- a/config/samples/quickstart-gittarget.yaml +++ b/config/samples/quickstart-gittarget.yaml @@ -4,13 +4,13 @@ metadata: name: example-target namespace: default spec: - # The GitBranch this folder is written to. Add `namespace:` to share one from another namespace, - # which its GitProvider's accessFrom has to admit. + # The GitBranch this folder is written to. Add `namespace:` to use one from another namespace, + # whose spec.allowedGitTargets has to admit this one. gitBranchRef: name: example-branch # The KubeProvider this folder mirrors from: the chart's `local` provider in its release - # namespace, which admits every namespace by default. Omit `namespace:` for one in this - # GitTarget's own namespace. Immutable. + # namespace, whose spec.allowedGitTargets must admit this namespace (the chart's quickstart values + # do). Omit `namespace:` for one in this GitTarget's own namespace. Immutable. kubeProviderRef: name: local namespace: gitops-reverser diff --git a/docs/bi-directional-argocd.md b/docs/bi-directional-argocd.md index bcd33087..9ba6e659 100644 --- a/docs/bi-directional-argocd.md +++ b/docs/bi-directional-argocd.md @@ -121,10 +121,12 @@ spec: The GitOps Reverser side. The `GitProvider` points at the same repository (any URL form of it), which is how the `GitReceiver` the Git host's push webhook calls finds it; an `ArgoCDClaim` holds the `Application`, in the cluster the chart's `local` `KubeProvider` names (the operator's own, -through `spec.inCluster`), and the `GitTarget` names the applier claim. The `Application` is in `argocd` -and the applier claim in `team-a`, so the provider lets `team-a`'s applier claims reach another namespace -(`allowCrossNamespace.appliers`); with the Helm chart, set -`kubeProvider.allowCrossNamespace.appliers: true` instead of declaring it. A `KubeProvider` with +through `spec.inCluster`), and the `GitTarget` names the applier claim. Nothing may use a provider +without a grant: the `KubeProvider` lets `team-a`'s `GitTarget`s mirror `team-a` +(`allowedGitTargets`), and its applier claims hold an `Application` in `argocd` +(`allowedApplierClaims`); with the Helm chart, set `kubeProvider.allowedGitTargets` and +`kubeProvider.allowedApplierClaims` instead of declaring it. The `GitProvider` lets `team-a` claim +`main`, and the `GitBranch` lets `team-a`'s `GitTarget`s write it. A `KubeProvider` with `inCluster` that you declare yourself is honored only once it is listed in `--in-cluster-kube-providers`, which the chart does for its own: @@ -136,10 +138,14 @@ metadata: namespace: gitops-reverser spec: inCluster: {} - accessFrom: - names: [team-a] - allowCrossNamespace: - appliers: true + allowedGitTargets: + - from: Names + names: [team-a] + sourceNamespaces: ["{namespace}"] + allowedApplierClaims: + - from: Names + names: [team-a] + applierNamespaces: [argocd] --- apiVersion: v1 kind: Secret @@ -166,7 +172,9 @@ metadata: namespace: team-a spec: url: git@github.com:acme/platform.git - allowedBranches: ["main"] + allowedGitBranches: + - from: Same + branches: [main] secretRef: name: platform-git --- @@ -179,6 +187,8 @@ spec: gitProviderRef: name: platform branch: main + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/docs/bi-directional-flux.md b/docs/bi-directional-flux.md index 1fd8d213..ac1e8888 100644 --- a/docs/bi-directional-flux.md +++ b/docs/bi-directional-flux.md @@ -71,10 +71,12 @@ spec: The GitOps Reverser side. The `GitProvider` points at the same repository (any URL form of it), which is how the `GitReceiver` the Git host's push webhook calls finds it; a `FluxClaim` holds the `Kustomization`, in the cluster the chart's `local` `KubeProvider` names (the operator's own, -through `spec.inCluster`), and the `GitTarget` names the applier claim. The `Kustomization` is in -`flux-system` and the applier claim in `team-a`, so the provider lets `team-a`'s applier claims reach another -namespace (`allowCrossNamespace.appliers`); with the Helm chart, set -`kubeProvider.allowCrossNamespace.appliers: true` instead of declaring it. A `KubeProvider` with +through `spec.inCluster`), and the `GitTarget` names the applier claim. Nothing may use a provider +without a grant: the `KubeProvider` lets `team-a`'s `GitTarget`s mirror `team-a` +(`allowedGitTargets`), and its applier claims hold a `Kustomization` in `flux-system` +(`allowedApplierClaims`); with the Helm chart, set `kubeProvider.allowedGitTargets` and +`kubeProvider.allowedApplierClaims` instead of declaring it. The `GitProvider` lets `team-a` claim +`main`, and the `GitBranch` lets `team-a`'s `GitTarget`s write it. A `KubeProvider` with `inCluster` that you declare yourself is honored only once it is listed in `--in-cluster-kube-providers`, which the chart does for its own: @@ -86,10 +88,14 @@ metadata: namespace: gitops-reverser spec: inCluster: {} - accessFrom: - names: [team-a] - allowCrossNamespace: - appliers: true + allowedGitTargets: + - from: Names + names: [team-a] + sourceNamespaces: ["{namespace}"] + allowedApplierClaims: + - from: Names + names: [team-a] + applierNamespaces: [flux-system] --- apiVersion: v1 kind: Secret @@ -116,7 +122,9 @@ metadata: namespace: team-a spec: url: git@github.com:acme/platform.git - allowedBranches: ["main"] + allowedGitBranches: + - from: Same + branches: [main] secretRef: name: platform-git --- @@ -129,6 +137,8 @@ spec: gitProviderRef: name: platform branch: main + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/docs/configuration.md b/docs/configuration.md index e0d187aa..9cdbcbd2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,8 +68,6 @@ fields that need more than a line. | Field | Default | What it does | |---|---|---| | `url` | **required** | Repository URL, SSH or HTTPS | -| `allowedBranches` | **required** | Branches a `GitTarget` may write. A target naming any other is rejected | -| `accessFrom` | **no other namespace** | Other namespaces whose `GitTarget`s may write through this provider, by name or label selector. Its own namespace is always admitted | | `allowedGitBranches` | **no `GitBranch`**, its own namespace included | Which namespaces' `GitBranch`es may claim which branches through this provider. Each grant has a `from` (`Same`, `Names`, `Selector`, `All`) and the `branches` patterns it allows | | `secretRef` | anonymous access | Secret holding the write credential. See [the credentials Secret](#gitproviderspecsecretref-the-credentials-secret) | | `knownHostsRef` | the Secret's own `known_hosts` | Optional ConfigMap or Secret sharing SSH host keys across providers. Host-key verification fails closed either way | @@ -82,8 +80,8 @@ Every field but `allowedGitTargets` is immutable: to change one, delete the `Git | Field | Default | What it does | |---|---|---| -| `gitProviderRef` | **required** | The `GitProvider`, in this namespace, whose repository and credential write this branch | -| `branch` | **required** | Branch to write. Must be in the provider's `allowedBranches`. Only one `GitBranch` holds a repository branch; see [who writes a repository branch](#who-writes-a-repository-branch) | +| `gitProviderRef` | **required** | The `GitProvider` whose repository and credential write this branch: in this namespace, or `namespace` names another. Its `allowedGitBranches` must admit this namespace and `branch` | +| `branch` | **required** | Branch to write. Only one `GitBranch` holds a repository branch; see [who writes a repository branch](#who-writes-a-repository-branch) | | `allowedGitTargets` | **no `GitTarget`**, its own namespace included | Which namespaces' `GitTarget`s may write through this branch, each grant with a `from`. The only mutable field | | `parentBranch` | the remote's default branch | Branch a `branch` the remote does not have yet is created from. Only read, so it need not be allowed. See [starting the write branch from another branch](#starting-the-write-branch-from-another-branch-specparentbranch) | @@ -102,9 +100,6 @@ Set exactly one of `secretRef` and `inCluster`. Both are immutable: delete and r |---|---|---| | `secretRef` | none; set `inCluster` instead | Secret in this provider's namespace holding a kubeconfig for the source cluster. `key` defaults to `value`, then `value.yaml`. See [`KubeProvider`](#kubeprovider) | | `inCluster` | none; set `secretRef` instead | `{}` uses the operator's own identity. Honored only for a provider listed in `--in-cluster-kube-providers`. See [the operator's own identity](#the-operators-own-identity-specincluster) | -| `accessFrom` | **no other namespace** | Other control-cluster namespaces whose objects may use this provider, by name or label selector. Its own namespace is always admitted; an empty `selector: {}` admits every namespace | -| `allowCrossNamespace.sources` | `false` | Whether rules may watch namespaces other than their own, `*` included. See [cross-namespace grants](#what-a-namespace-may-reach-outside-itself-specallowcrossnamespace) | -| `allowCrossNamespace.appliers` | `false` | Whether applier claims may name a `Kustomization` or `Application` in another namespace than their own | | `allowedGitTargets` | **no `GitTarget`**, its own namespace included | Which namespaces' `GitTarget`s may mirror through this provider, and what: each grant has a `from`, and `sourceNamespaces`, `clusterResources` or both | | `allowedApplierClaims` | **no applier claim**, its own namespace included | Which namespaces' applier claims may hold an applier through this provider, and the `applierNamespaces` that applier may live in | | `client.qps` | `20` | Client rate limit for this source cluster. Refused beside `inCluster` | @@ -117,9 +112,9 @@ The destination fields are immutable: to move a target, delete it and create a n | Field | Default | What it does | |---|---|---| -| `gitBranchRef` | **required** | The `GitBranch` this target writes: its own namespace, or `namespace` names one whose `GitProvider`'s `accessFrom` admits it | +| `gitBranchRef` | **required** | The `GitBranch` this target writes: its own namespace, or `namespace` names another. Its `allowedGitTargets` must admit this namespace | | `path` | **required** | Folder within the repository. `.` targets the root; empty is rejected | -| `kubeProviderRef` | **required** | The `KubeProvider` to mirror from: its own namespace, or `namespace` names one whose `accessFrom` admits it | +| `kubeProviderRef` | **required** | The `KubeProvider` to mirror from: its own namespace, or `namespace` names another. Its `allowedGitTargets` must admit this namespace | | `commit.window.idleTimeout` | `5s` | Close a commit window after this much silence. `0s` commits every write on its own. See [the commit window](#the-commit-window-speccommitwindow) | | `commit.window.maxDuration` | `1m` | Close a commit window this long after it opened, however much keeps arriving | | `commit.message` | the built-in templates | How commits are phrased. See [commit messages](commit-messages.md) | @@ -162,7 +157,7 @@ The references are immutable: to hold another `Kustomization`, delete the applie | Field | Default | What it does | |---|---|---| -| `kubeProviderRef` | **required** | The `KubeProvider` whose cluster holds the `Kustomization`: the applier claim's own namespace, or `namespace` names one whose `accessFrom` admits it | +| `kubeProviderRef` | **required** | The `KubeProvider` whose cluster holds the `Kustomization`: the applier claim's own namespace, or `namespace` names another. Its `allowedApplierClaims` must admit this namespace | | `kustomizationRef` | **required** | The `Kustomization` that applies the folders of the `GitTarget`s naming this applier claim. See [who applies this folder](#who-applies-this-folder-specapplierclaimref) | ### `ArgoCDClaim` (namespaced): the Argo CD `Application` GitOps Reverser asks to sync @@ -171,7 +166,7 @@ The references are immutable: to hold another `Application`, delete the applier | Field | Default | What it does | |---|---|---| -| `kubeProviderRef` | **required** | The `KubeProvider` whose cluster holds the `Application`: the applier claim's own namespace, or `namespace` names one whose `accessFrom` admits it | +| `kubeProviderRef` | **required** | The `KubeProvider` whose cluster holds the `Application`: the applier claim's own namespace, or `namespace` names another. Its `allowedApplierClaims` must admit this namespace | | `applicationRef` | **required** | The `Application`, with automated sync off, that syncs the folders of the `GitTarget`s naming this applier claim. See [who applies this folder](#who-applies-this-folder-specapplierclaimref) | ### `CommitRequest` (namespaced): save now diff --git a/hack/crdfields/fields.yaml b/hack/crdfields/fields.yaml index 35109e08..36b0044f 100644 --- a/hack/crdfields/fields.yaml +++ b/hack/crdfields/fields.yaml @@ -30,13 +30,6 @@ kinds: fields: - path: url doc: Repository URL, SSH or HTTPS - - path: allowedBranches - doc: Branches a `GitTarget` may write. A target naming any other is rejected - - path: accessFrom - default: "**no other namespace**" - doc: >- - Other namespaces whose `GitTarget`s may write through this provider, by name or label - selector. Its own namespace is always admitted - path: allowedGitBranches default: "**no `GitBranch`**, its own namespace included" doc: >- @@ -69,11 +62,12 @@ kinds: fields: - path: gitProviderRef doc: >- - The `GitProvider`, in this namespace, whose repository and credential write this branch + The `GitProvider` whose repository and credential write this branch: in this namespace, or + `namespace` names another. Its `allowedGitBranches` must admit this namespace and `branch` - path: branch doc: >- - Branch to write. Must be in the provider's `allowedBranches`. Only one `GitBranch` holds a - repository branch; see [who writes a repository branch](#who-writes-a-repository-branch) + Branch to write. Only one `GitBranch` holds a repository branch; see + [who writes a repository branch](#who-writes-a-repository-branch) - path: allowedGitTargets default: "**no `GitTarget`**, its own namespace included" doc: >- @@ -113,22 +107,6 @@ kinds: `{}` uses the operator's own identity. Honored only for a provider listed in `--in-cluster-kube-providers`. See [the operator's own identity](#the-operators-own-identity-specincluster) - - path: accessFrom - default: "**no other namespace**" - doc: >- - Other control-cluster namespaces whose objects may use this provider, by name or label - selector. Its own namespace is always admitted; an empty `selector: {}` admits every - namespace - - path: allowCrossNamespace.sources - default: "`false`" - doc: >- - Whether rules may watch namespaces other than their own, `*` included. See - [cross-namespace grants](#what-a-namespace-may-reach-outside-itself-specallowcrossnamespace) - - path: allowCrossNamespace.appliers - default: "`false`" - doc: >- - Whether applier claims may name a `Kustomization` or `Application` in another namespace than - their own - path: allowedGitTargets default: "**no `GitTarget`**, its own namespace included" doc: >- @@ -157,8 +135,8 @@ kinds: fields: - path: gitBranchRef doc: >- - The `GitBranch` this target writes: its own namespace, or `namespace` names one whose - `GitProvider`'s `accessFrom` admits it + The `GitBranch` this target writes: its own namespace, or `namespace` names another. Its + `allowedGitTargets` must admit this namespace - path: gitProviderRef skip: moved to `GitBranch`, kept in the schema only so admission refuses it, naming `gitBranchRef` - path: branch @@ -169,8 +147,8 @@ kinds: doc: Folder within the repository. `.` targets the root; empty is rejected - path: kubeProviderRef doc: >- - The `KubeProvider` to mirror from: its own namespace, or `namespace` names one whose - `accessFrom` admits it + The `KubeProvider` to mirror from: its own namespace, or `namespace` names another. Its + `allowedGitTargets` must admit this namespace - path: clusterProviderRef skip: replaced by `kubeProviderRef`, kept in the schema only so admission refuses it, naming `kubeProviderRef` - path: commit.window @@ -283,7 +261,7 @@ kinds: - path: kubeProviderRef doc: >- The `KubeProvider` whose cluster holds the `Kustomization`: the applier claim's own namespace, or - `namespace` names one whose `accessFrom` admits it + `namespace` names another. Its `allowedApplierClaims` must admit this namespace - path: clusterProviderRef skip: replaced by `kubeProviderRef`, kept in the schema only so admission refuses it, naming `kubeProviderRef` - path: kustomizationRef @@ -298,7 +276,7 @@ kinds: - path: kubeProviderRef doc: >- The `KubeProvider` whose cluster holds the `Application`: the applier claim's own namespace, or - `namespace` names one whose `accessFrom` admits it + `namespace` names another. Its `allowedApplierClaims` must admit this namespace - path: clusterProviderRef skip: replaced by `kubeProviderRef`, kept in the schema only so admission refuses it, naming `kubeProviderRef` - path: applicationRef diff --git a/internal/applier/dispatcher_test.go b/internal/applier/dispatcher_test.go index 78d47e8d..cec28d01 100644 --- a/internal/applier/dispatcher_test.go +++ b/internal/applier/dispatcher_test.go @@ -154,16 +154,22 @@ func reapplying(target *v1alpha3.GitTarget) *v1alpha3.GitTarget { return target } -// kubeProvider is the KubeProvider name in kubeProviderNS, admitting the GitTargets in the named -// namespaces. +// kubeProvider is the KubeProvider name in kubeProviderNS, admitting the GitTargets and applier +// claims in the named namespaces. func kubeProvider(name string, admits ...string) *v1alpha3.KubeProvider { + names := make([]v1alpha3.GrantNamespace, 0, len(admits)) + for _, ns := range admits { + names = append(names, v1alpha3.GrantNamespace(ns)) + } return &v1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: kubeProviderNS, Name: name}, Spec: v1alpha3.KubeProviderSpec{ - InCluster: &v1alpha3.KubeProviderInCluster{}, - AccessFrom: &v1alpha3.NamespaceMatcher{Names: admits}, + InCluster: &v1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: []v1alpha3.SourceGrant{{From: v1alpha3.GrantFromNames, Names: names, + SourceNamespaces: []v1alpha3.ScopeNamespace{v1alpha3.NamespaceVariable}}}, // The test appliers live in flux-system and argocd, not in the applier claims' namespace. - AllowCrossNamespace: &v1alpha3.KubeProviderCrossNamespace{Appliers: true}, + AllowedApplierClaims: []v1alpha3.ApplierGrant{{From: v1alpha3.GrantFromNames, Names: names, + ApplierNamespaces: []v1alpha3.ScopeNamespace{"*"}}}, }, } } @@ -180,6 +186,7 @@ func controlPlane() []client.Object { ObjectMeta: metav1.ObjectMeta{Namespace: testGitBranch.Namespace, Name: testGitBranch.Name}, Spec: v1alpha3.GitBranchSpec{ GitProviderRef: fluxmeta.NamespacedObjectReference{Name: testProvider.Name}, Branch: testBranch, + AllowedGitTargets: []v1alpha3.NamespaceGrant{{From: v1alpha3.GrantFromAll}}, }, }, kubeProvider(localCluster, targetNS), diff --git a/internal/applier/holder_test.go b/internal/applier/holder_test.go index 8afd2145..671e3c6e 100644 --- a/internal/applier/holder_test.go +++ b/internal/applier/holder_test.go @@ -61,7 +61,9 @@ func referenceFrom(t *testing.T, c client.Client, claim v1alpha3.ApplierClaim) { require.NoError(t, c.Create(ctx, kubeProvider(name, targetNS, claim.GetNamespace()))) continue } - provider.Spec.AccessFrom.Names = append(provider.Spec.AccessFrom.Names, claim.GetNamespace()) + ns := v1alpha3.GrantNamespace(claim.GetNamespace()) + provider.Spec.AllowedGitTargets[0].Names = append(provider.Spec.AllowedGitTargets[0].Names, ns) + provider.Spec.AllowedApplierClaims[0].Names = append(provider.Spec.AllowedApplierClaims[0].Names, ns) require.NoError(t, c.Update(ctx, provider)) } } diff --git a/internal/authz/gitbranch_admission_test.go b/internal/authz/gitbranch_admission_test.go index 0148cf86..8d005fb9 100644 --- a/internal/authz/gitbranch_admission_test.go +++ b/internal/authz/gitbranch_admission_test.go @@ -4,32 +4,28 @@ package authz import ( "context" + "errors" "testing" "github.com/fluxcd/pkg/apis/meta" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" ) -// sharedGitProvider is the GitProvider platform/monorepo, with policy as its spec.accessFrom. -func sharedGitProvider(policy *configv1alpha3.NamespaceMatcher) *configv1alpha3.GitProvider { - return &configv1alpha3.GitProvider{ - ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "monorepo"}, - Spec: configv1alpha3.GitProviderSpec{URL: "https://github.com/acme/platform", AccessFrom: policy}, - } -} - -// sharedGitBranch is the GitBranch platform/main, written with the GitProvider platform/provider. -func sharedGitBranch(provider string) *configv1alpha3.GitBranch { +// branchIn is the GitBranch namespace/main, with writers as its spec.allowedGitTargets. +func branchIn(namespace string, writers ...configv1alpha3.NamespaceGrant) *configv1alpha3.GitBranch { return &configv1alpha3.GitBranch{ - ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "main"}, + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "main"}, Spec: configv1alpha3.GitBranchSpec{ - GitProviderRef: meta.NamespacedObjectReference{Name: provider}, Branch: "main", + GitProviderRef: meta.NamespacedObjectReference{Name: "monorepo"}, Branch: "main", + AllowedGitTargets: writers, }, } } @@ -42,79 +38,69 @@ func targetWriting(namespace, name string) *configv1alpha3.GitTarget { return target } -// TestGitTargetAdmitted_GitBranchsProviderAccessFrom: a GitTarget writes with its GitBranch's -// GitProvider credential, so a GitBranch in another namespace is used only while that GitProvider's -// spec.accessFrom admits the GitTarget's namespace. A GitBranch in the GitTarget's own namespace -// needs no policy. -func TestGitTargetAdmitted_GitBranchsProviderAccessFrom(t *testing.T) { - // The KubeProvider gate passes in every case, so only the GitProvider's decides. - source := providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{Names: []string{testNS}}) +// TestGitTargetAdmitted_GitBranchAllowedGitTargets: a GitTarget writes through a GitBranch only while +// one of that GitBranch's spec.allowedGitTargets admits its namespace, the GitBranch's own namespace +// included. +func TestGitTargetAdmitted_GitBranchAllowedGitTargets(t *testing.T) { + // The KubeProvider gate passes in every case, so only the GitBranch's decides. + source := providerNamed("prod-eu-1", mirrorFrom(testNS)) + same := configv1alpha3.NamespaceGrant{From: configv1alpha3.GrantFromSame} tests := []struct { - name string - objects []client.Object - target *configv1alpha3.GitTarget - allowed bool + name string + objects []client.Object + target *configv1alpha3.GitTarget + allowed bool + wantMessage string }{ { - name: "its own namespace, with no policy at all", - objects: []client.Object{source}, + name: "its own namespace, with no grant at all", + objects: []client.Object{source, branchIn(testNS)}, target: targetWriting("", "main"), - allowed: true, + wantMessage: `GitBranch team-a/main admits no namespace in spec.allowedGitTargets; namespace "team-a" ` + + "is not admitted", }, { - name: "its own namespace, named explicitly", - objects: []client.Object{source}, + name: "its own namespace, admitted by from: Same", + objects: []client.Object{source, branchIn(testNS, same)}, target: targetWriting(testNS, "main"), allowed: true, }, { - name: "another namespace, with no policy", - objects: []client.Object{ - source, - sharedGitProvider(nil), - sharedGitBranch("monorepo"), - namespaceLabeled(nil), - }, - target: targetWriting("platform", "main"), + name: "another namespace, not admitted by from: Same", + objects: []client.Object{source, namespaceLabeled(nil), branchIn("platform", same)}, + target: targetWriting("platform", "main"), + wantMessage: `GitBranch platform/main admits platform in spec.allowedGitTargets; namespace "team-a"`, }, { name: "another namespace, admitted by name", - objects: []client.Object{source, namespaceLabeled(nil), sharedGitBranch("monorepo"), - sharedGitProvider(&configv1alpha3.NamespaceMatcher{Names: []string{testNS}})}, + objects: []client.Object{source, namespaceLabeled(nil), branchIn("platform", same, + configv1alpha3.NamespaceGrant{From: configv1alpha3.GrantFromNames, + Names: []configv1alpha3.GrantNamespace{testNS}})}, target: targetWriting("platform", "main"), allowed: true, }, { name: "another namespace, admitted by its labels", - objects: []client.Object{ - source, - namespaceLabeled(map[string]string{"tenant": "yes"}), - sharedGitBranch("monorepo"), - sharedGitProvider(&configv1alpha3.NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tenant": "yes"}}, - }), - }, + objects: []client.Object{source, namespaceLabeled(map[string]string{"tenant": "yes"}), + branchIn("platform", configv1alpha3.NamespaceGrant{From: configv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tenant": "yes"}}})}, target: targetWriting("platform", "main"), allowed: true, }, { - name: "another namespace, not admitted", - objects: []client.Object{source, namespaceLabeled(nil), sharedGitBranch("monorepo"), - sharedGitProvider(&configv1alpha3.NamespaceMatcher{Names: []string{"team-b"}})}, - target: targetWriting("platform", "main"), + name: "a grant naming other namespaces, listed in the message", + objects: []client.Object{source, namespaceLabeled(nil), branchIn("platform", same, + configv1alpha3.NamespaceGrant{From: configv1alpha3.GrantFromNames, + Names: []configv1alpha3.GrantNamespace{"team-b", "team-c"}})}, + target: targetWriting("platform", "main"), + wantMessage: "GitBranch platform/main admits platform, team-b, team-c in spec.allowedGitTargets", }, { - name: "another namespace's GitBranch that does not exist wields nothing", + name: "a GitBranch that does not exist lends nothing, and is reported elsewhere", objects: []client.Object{source}, target: targetWriting("platform", "missing"), allowed: true, }, - { - name: "another namespace's GitBranch whose GitProvider does not exist wields nothing", - objects: []client.Object{source, sharedGitBranch("missing")}, - target: targetWriting("platform", "main"), - allowed: true, - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -126,9 +112,96 @@ func TestGitTargetAdmitted_GitBranchsProviderAccessFrom(t *testing.T) { assert.Equal(t, tt.allowed, decision.Allowed, decision.Message) if !tt.allowed { assert.Equal(t, ReasonNamespaceNotAuthorized, decision.Reason) - assert.Contains(t, decision.Message, "GitBranch platform/main") - assert.Contains(t, decision.Message, "GitProvider platform/monorepo") + assert.Contains(t, decision.Message, tt.wantMessage) } }) } } + +// sharedRepo is the GitProvider platform/shared-repo with grants as its spec.allowedGitBranches. +func sharedRepo(grants ...configv1alpha3.GitBranchGrant) *configv1alpha3.GitProvider { + return &configv1alpha3.GitProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: platformNS, Name: "shared-repo"}, + Spec: configv1alpha3.GitProviderSpec{ + URL: "https://git.example.com/platform/config.git", + AllowedGitBranches: grants, + }, + } +} + +// claiming is a GitBranch in namespace claiming branch through platform/shared-repo. +func claiming(namespace, branch string) *configv1alpha3.GitBranch { + return &configv1alpha3.GitBranch{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "live"}, + Spec: configv1alpha3.GitBranchSpec{ + GitProviderRef: meta.NamespacedObjectReference{Name: "shared-repo", Namespace: platformNS}, + Branch: branch, + }, + } +} + +// TestGitBranchClaimAdmitted is the walkthrough of docs/future/provider-access-rules.md: tenants may +// claim their own prefix, and the platform keeps main. +func TestGitBranchClaimAdmitted(t *testing.T) { + tenant := map[string]string{"platform.example.com/tenant": "true"} + provider := sharedRepo( + configv1alpha3.GitBranchGrant{From: configv1alpha3.GrantFromSame, + Branches: []configv1alpha3.BranchPattern{"main"}}, + configv1alpha3.GitBranchGrant{From: configv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: tenant}, + Branches: []configv1alpha3.BranchPattern{"teams/{namespace}/*", "feature/*"}}, + ) + namespaces := []client.Object{ + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testNS, Labels: tenant}}, + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-c"}}, + } + tests := []struct { + name string + branch *configv1alpha3.GitBranch + provider *configv1alpha3.GitProvider + wantReason string + wantMessage string + }{ + {"a tenant claims its own prefix", claiming(testNS, "teams/team-a/live"), provider, "", ""}, + {"a tenant claims a feature branch", claiming(testNS, "feature/checkout/v2"), provider, "", ""}, + {"a tenant may not claim main", claiming(testNS, "main"), provider, ReasonBranchNotAllowed, + `namespace "team-a" may claim "feature/*", "teams/team-a/*" through GitProvider platform/shared-repo ` + + `(spec.allowedGitBranches); requested branch "main" is not allowed`}, + {"a tenant may not claim another team's branch", claiming(testNS, "teams/team-b/live"), provider, + ReasonBranchNotAllowed, `requested branch "teams/team-b/live" is not allowed`}, + {"{namespace} is a whole segment: team-a never matches team-ab", claiming(testNS, "teams/team-ab/live"), + provider, ReasonBranchNotAllowed, ""}, + {"an unlabelled namespace is not admitted at all", claiming("team-c", "teams/team-c/live"), provider, + ReasonNamespaceNotAuthorized, `GitProvider platform/shared-repo admits platform, namespaces labelled ` + + `platform.example.com/tenant=true in spec.allowedGitBranches; namespace "team-c" is not admitted`}, + {"the platform claims main through from: Same", claiming(platformNS, "main"), provider, "", ""}, + {"the provider's own namespace needs a grant too", claiming(platformNS, "main"), sharedRepo(), + ReasonNamespaceNotAuthorized, "admits no namespace in spec.allowedGitBranches"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := fake.NewClientBuilder().WithScheme(admissionScheme(t)).WithObjects(namespaces...).Build() + decision, err := GitBranchClaimAdmitted(context.Background(), reader, tt.branch, tt.provider) + require.NoError(t, err) + assert.Equal(t, tt.wantReason == "", decision.Allowed, decision.Message) + assert.Equal(t, tt.wantReason, decision.Reason) + assert.Contains(t, decision.Message, tt.wantMessage) + }) + } +} + +// TestGitBranchClaimAdmitted_ReadErrorIsNoRevocation: a transient read keeps the caller's last +// answer; it must never come back as a denial. +func TestGitBranchClaimAdmitted_ReadErrorIsNoRevocation(t *testing.T) { + boom := errors.New("apiserver unavailable") + reader := fake.NewClientBuilder().WithScheme(admissionScheme(t)). + WithInterceptorFuncs(interceptor.Funcs{ + Get: func(context.Context, client.WithWatch, client.ObjectKey, client.Object, ...client.GetOption) error { + return boom + }, + }).Build() + decision, err := GitBranchClaimAdmitted(context.Background(), reader, claiming(testNS, "main"), sharedRepo()) + require.ErrorIs(t, err, boom) + assert.False(t, decision.Allowed) + assert.Empty(t, decision.Reason) +} diff --git a/internal/authz/kubeprovider_admission.go b/internal/authz/kubeprovider_admission.go index bea6959f..583173e7 100644 --- a/internal/authz/kubeprovider_admission.go +++ b/internal/authz/kubeprovider_admission.go @@ -1,10 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 -// Package authz holds the namespace-admission decisions for the providers a GitTarget uses. -// -// A KubeProvider holds a credential that can read a lot of a source cluster; it admits its own -// namespace, and its spec.accessFrom admits the OTHER control-cluster namespaces permitted to use -// it. A GitProvider holds a write credential, and admits namespaces the same way. +// Package authz holds the admission decisions for the objects that lend something: a GitProvider's +// credential (spec.allowedGitBranches), a GitBranch (spec.allowedGitTargets), and a KubeProvider's +// cluster connection (spec.allowedGitTargets, spec.allowedApplierClaims). Each grant list lives on +// the referenced object, and nothing is admitted automatically, the object's own namespace included. // // The decision lives in its own package — outside both internal/controller and internal/watch — // because more than one call site compiles rules or starts a data plane for a GitTarget: the @@ -16,6 +15,8 @@ package authz import ( "context" "fmt" + "strconv" + "strings" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" @@ -32,14 +33,17 @@ const ( // is denied rather than mirroring on an implicit identity. ReasonKubeProviderNotFound = "KubeProviderNotFound" - // ReasonNamespaceNotAuthorized is the denial reason when the consumer's namespace is neither the - // KubeProvider's own nor admitted by its spec.accessFrom — including the case where that policy - // carries a selector the apiserver accepted but that does not convert to a selector. + // ReasonNamespaceNotAuthorized is the denial reason when no grant on the referenced object admits + // the referring object's namespace — including a grant whose selector the apiserver accepted but + // that does not convert to a selector. ReasonNamespaceNotAuthorized = "NamespaceNotAuthorized" + // ReasonBranchNotAllowed is the denial reason when a GitProvider admits a GitBranch's namespace, + // but none of the grants that admit it allows the branch the GitBranch claims. + ReasonBranchNotAllowed = "BranchNotAllowed" + // ReasonApplierNamespaceNotAllowed is the denial reason when an applier claim names an applier in - // another namespace than its own and its KubeProvider does not set - // spec.allowCrossNamespace.appliers. + // a namespace outside the applierNamespaces its KubeProvider grants it. ReasonApplierNamespaceNotAllowed = "ApplierNamespaceNotAllowed" // ReasonAuditRouteConflict is the denial reason when a KubeProvider's audit route is held by a @@ -59,9 +63,10 @@ type Decision struct { Message string } -// GitTargetAdmitted reports whether target's namespace is admitted by the KubeProvider that -// target references, reading both the provider and the target's Namespace through reader, and by -// the GitProvider of the GitBranch it writes through (gitBranchAdmitted). +// GitTargetAdmitted reports whether target may mirror through the KubeProvider it references +// (spec.allowedGitTargets) and write through the GitBranch it references (that GitBranch's +// spec.allowedGitTargets), reading the provider, the GitBranch and the target's Namespace through +// reader. // // It is evaluated on every reconcile rather than only at admission, so a policy TIGHTENED after a // GitTarget was created revokes it too. The two denial paths are deliberate and ordered: a missing @@ -70,36 +75,62 @@ type Decision struct { // // A non-NotFound read error is returned as err so the caller requeues instead of tearing down a // running data plane on a transient apiserver failure. A NotFound Namespace is NOT an error: the -// policy is then evaluated against empty labels, so a `names` entry still admits a namespace that -// has not been created yet while a selector correctly does not match it. +// policy is then evaluated against empty labels, so a Names grant still admits a namespace that has +// not been created yet while a selector correctly does not match it. func GitTargetAdmitted( ctx context.Context, reader client.Reader, target *configv1alpha3.GitTarget, ) (Decision, error) { + decision, _, err := GitTargetSourceScope(ctx, reader, target) + return decision, err +} + +// GitTargetSourceScope is GitTargetAdmitted, plus what the grants that admit target let it mirror: +// the source namespaces its WatchRules may watch and the cluster-scoped resources a ClusterWatchRule +// may mirror through it. The scope is meaningful only when the decision allows. +func GitTargetSourceScope( + ctx context.Context, + reader client.Reader, + target *configv1alpha3.GitTarget, +) (Decision, configv1alpha3.SourceScope, error) { key := target.KubeProviderKey() - decision, _, err := namespaceAdmitted(ctx, reader, target.Namespace, key, fmt.Sprintf( + decision, provider, err := kubeProviderUsable(ctx, reader, key, fmt.Sprintf( "referenced KubeProvider %s was not found; a GitTarget may mirror a source cluster only "+ "through an existing KubeProvider, which the operator never creates", key)) if err != nil || !decision.Allowed { - return decision, err + return decision, configv1alpha3.SourceScope{}, err + } + nsLabels, err := namespaceLabels(ctx, reader, target.Namespace) + if err != nil { + return Decision{}, configv1alpha3.SourceScope{}, err + } + scope, selErr := provider.AdmitsGitTarget(target.Namespace, nsLabels) + if !scope.Admitted { + return notAdmitted("KubeProvider", key, provider.DescribeAllowedGitTargets(), "spec.allowedGitTargets", + target.Namespace, selErr), configv1alpha3.SourceScope{}, nil } - return gitBranchAdmitted(ctx, reader, target) + if decision, err = AuditRouteHeld(ctx, reader, provider); err != nil || !decision.Allowed { + return decision, configv1alpha3.SourceScope{}, err + } + if decision, err = gitBranchAdmitted(ctx, reader, target, nsLabels); err != nil || !decision.Allowed { + return decision, configv1alpha3.SourceScope{}, err + } + return Decision{Allowed: true}, scope, nil } -// gitBranchAdmitted reports whether target's namespace may write through the GitBranch it -// references. One in its own namespace always may. One in another namespace may only while the -// GitBranch's GitProvider admits target's namespace in its spec.accessFrom, because the GitTarget -// then writes with that provider's credential: one policy governs both hops, so it cannot be split -// across two objects that disagree. A GitBranch or GitProvider that does not exist admits nothing -// and denies nothing here: there is no credential to wield, and their status reports them missing. +// gitBranchAdmitted reports whether target may write through the GitBranch it references: one of +// that GitBranch's spec.allowedGitTargets admits target's namespace. The GitBranch's own namespace +// is admitted only by a grant too. A GitBranch that does not exist admits nothing and denies nothing +// here: there is nothing to write through, and the GitTarget's status reports it missing. func gitBranchAdmitted( ctx context.Context, reader client.Reader, target *configv1alpha3.GitTarget, + nsLabels map[string]string, ) (Decision, error) { key := target.GitBranchKey() - if key.Name == "" || key.Namespace == target.Namespace { + if key.Name == "" { return Decision{Allowed: true}, nil } branch := &configv1alpha3.GitBranch{} @@ -109,72 +140,94 @@ func gitBranchAdmitted( } return Decision{}, fmt.Errorf("read GitBranch %s: %w", key, err) } - providerKey := branch.GitProviderKey() - provider := &configv1alpha3.GitProvider{} - if err := reader.Get(ctx, providerKey, provider); err != nil { - if apierrors.IsNotFound(err) { - return Decision{Allowed: true}, nil - } - return Decision{}, fmt.Errorf("read GitProvider %s: %w", providerKey, err) + admitted, selErr := branch.AdmitsGitTarget(target.Namespace, nsLabels) + if !admitted { + return notAdmitted("GitBranch", key, branch.DescribeAllowedGitTargets(), "spec.allowedGitTargets", + target.Namespace, selErr), nil } - nsLabels, err := namespaceLabels(ctx, reader, target.Namespace) + return Decision{Allowed: true}, nil +} + +// GitBranchClaimAdmitted reports whether branch may claim its spec.branch through provider, the +// GitProvider it references: a grant in provider's spec.allowedGitBranches admits the GitBranch's +// namespace, and one of the patterns those grants give it matches the branch. A namespace no grant +// admits is ReasonNamespaceNotAuthorized; an admitted one asking for a branch outside its patterns +// is ReasonBranchNotAllowed. A non-NotFound read of the Namespace is returned as err, so a caller +// keeps its last answer rather than revoking on a transient failure. +func GitBranchClaimAdmitted( + ctx context.Context, + reader client.Reader, + branch *configv1alpha3.GitBranch, + provider *configv1alpha3.GitProvider, +) (Decision, error) { + nsLabels, err := namespaceLabels(ctx, reader, branch.Namespace) if err != nil { return Decision{}, err } - allowed, selErr := provider.AdmitsNamespace(target.Namespace, nsLabels) - if selErr != nil { - return Decision{ - Reason: ReasonNamespaceNotAuthorized, - Message: fmt.Sprintf("GitProvider %s accessFrom selector is invalid: %v", providerKey, selErr), - }, nil - } - if !allowed { + key := client.ObjectKeyFromObject(provider) + admission, selErr := provider.AdmitsGitBranch(branch.Namespace, nsLabels, branch.Spec.Branch) + switch { + case !admission.Admitted: + return notAdmitted("GitProvider", key, provider.DescribeAllowedGitBranches(), "spec.allowedGitBranches", + branch.Namespace, selErr), nil + case !admission.Allowed: return Decision{ - Reason: ReasonNamespaceNotAuthorized, + Reason: ReasonBranchNotAllowed, Message: fmt.Sprintf( - "namespace %q is not permitted to reference GitBranch %s: its GitProvider %s does not admit it "+ - "(spec.accessFrom)", target.Namespace, key, providerKey), + "namespace %q may claim %s through GitProvider %s (spec.allowedGitBranches); requested branch %q "+ + "is not allowed", branch.Namespace, quoteAll(admission.Branches), key, branch.Spec.Branch), }, nil } return Decision{Allowed: true}, nil } -// ApplierClaimAdmitted reports whether an applier claim's namespace is admitted by the KubeProvider it -// names (spec.kubeProviderRef), and whether it may reach its applier there. The applier is -// reached with that provider's credential, so the provider's accessFrom decides who may wield it, -// exactly as for mirroring, and its allowCrossNamespace.appliers whether a namespace may suspend -// and apply another namespace's applier. The applier claim's own namespace is always allowed; in a remote -// cluster that is the namespace of the same name, as it is for mirroring. +// ApplierClaimAdmitted reports whether an applier claim may hold its applier through the KubeProvider +// it names (spec.kubeProviderRef): a grant in that provider's spec.allowedApplierClaims admits the +// applier claim's namespace, and the applier lives in one of the applierNamespaces those grants +// give it. The applier is reached with that provider's credential, so admission to mirror through +// it is no admission to apply. func ApplierClaimAdmitted( ctx context.Context, reader client.Reader, claim configv1alpha3.ApplierClaim, ) (Decision, error) { key := claim.KubeProviderKey() - decision, provider, err := namespaceAdmitted(ctx, reader, claim.GetNamespace(), key, fmt.Sprintf( + decision, provider, err := kubeProviderUsable(ctx, reader, key, fmt.Sprintf( "KubeProvider %s named by %s %s/%s spec.kubeProviderRef was not found", key, claim.ClaimKind(), claim.GetNamespace(), claim.GetName())) if err != nil || !decision.Allowed { return decision, err } + nsLabels, err := namespaceLabels(ctx, reader, claim.GetNamespace()) + if err != nil { + return Decision{}, err + } + scope, selErr := provider.AdmitsApplierClaim(claim.GetNamespace(), nsLabels) + if !scope.Admitted { + return notAdmitted("KubeProvider", key, provider.DescribeAllowedApplierClaims(), "spec.allowedApplierClaims", + claim.GetNamespace(), selErr), nil + } + if decision, err = AuditRouteHeld(ctx, reader, provider); err != nil || !decision.Allowed { + return decision, err + } applier := claim.Applier() - if applier.Namespace != claim.GetNamespace() && !provider.AllowsCrossNamespaceAppliers() { + if !scope.AllowsApplierNamespace(applier.Namespace) { return Decision{ Reason: ReasonApplierNamespaceNotAllowed, Message: fmt.Sprintf( - "%s %s is in another namespace than the applier claim's own %q, and KubeProvider %s does not "+ - "set spec.allowCrossNamespace.appliers", claim.ApplierKind(), applier, claim.GetNamespace(), - key), + "KubeProvider %s lets namespace %q hold appliers in %s (spec.allowedApplierClaims); %s %s is in "+ + "namespace %q, which is not allowed", key, claim.GetNamespace(), + quoteAll(scope.ApplierNamespaces), claim.ApplierKind(), applier, applier.Namespace), }, nil } - return decision, nil + return Decision{Allowed: true}, nil } -// ApplierAdmitted reports whether target may ask the applier its applier claim holds to apply: its -// namespace is admitted by its own KubeProvider (GitTargetAdmitted) and by the applier claim's -// (ApplierClaimAdmitted). A GitTarget names an applier claim in its own namespace only, so the second -// answers for the GitTarget's namespace too. A KubeProvider's client is shared by every object -// that uses the cluster, so a client being available is no permission to use it. +// ApplierAdmitted reports whether target may ask the applier its applier claim holds to apply: +// target may mirror (GitTargetAdmitted), and the applier claim may hold its applier +// (ApplierClaimAdmitted). Both are always checked, also when the two name one KubeProvider from one +// namespace: mirroring and applying are granted separately. A KubeProvider's client is shared by +// every object that uses the cluster, so a client being available is no permission to use it. func ApplierAdmitted( ctx context.Context, reader client.Reader, @@ -185,19 +238,14 @@ func ApplierAdmitted( if err != nil || !decision.Allowed { return decision, err } - if claim.KubeProviderKey() == target.KubeProviderKey() && claim.GetNamespace() == target.Namespace { - return decision, nil - } return ApplierClaimAdmitted(ctx, reader, claim) } -// namespaceAdmitted is the check both admissions share: the provider exists, it admits namespace -// (its own, or through spec.accessFrom), and it holds its audit route. notFound is the denial -// message for a provider that does not exist. It returns the provider it read once it exists. -func namespaceAdmitted( +// kubeProviderUsable reads the KubeProvider at key. notFound is the denial message for a provider +// that does not exist. It returns the provider it read once it exists. +func kubeProviderUsable( ctx context.Context, reader client.Reader, - namespace string, key k8stypes.NamespacedName, notFound string, ) (Decision, *configv1alpha3.KubeProvider, error) { @@ -214,33 +262,35 @@ func namespaceAdmitted( } return Decision{}, nil, fmt.Errorf("read KubeProvider %s: %w", key, err) } + return Decision{Allowed: true}, provider, nil +} - nsLabels, err := namespaceLabels(ctx, reader, namespace) - if err != nil { - return Decision{}, nil, err +// notAdmitted is the NamespaceNotAuthorized refusal of a grant list: what it admits, the field, and +// the namespace that asked. selErr is a grant whose selector does not convert, which admitted nobody. +func notAdmitted( + kind string, key k8stypes.NamespacedName, admits, field, namespace string, selErr error, +) Decision { + message := fmt.Sprintf("%s %s %s in %s; namespace %q is not admitted", kind, key, admits, field, namespace) + if admits == "admits no namespace" { + message = fmt.Sprintf("%s %s admits no namespace in %s; namespace %q is not admitted", kind, key, field, + namespace) } - - allowed, selErr := provider.AdmitsNamespace(namespace, nsLabels) if selErr != nil { - return Decision{ - Reason: ReasonNamespaceNotAuthorized, - Message: fmt.Sprintf("KubeProvider %s accessFrom selector is invalid: %v", key, selErr), - }, provider, nil - } - if !allowed { - return Decision{ - Reason: ReasonNamespaceNotAuthorized, - Message: fmt.Sprintf( - "namespace %q is not permitted to reference KubeProvider %s: it is not the provider's own "+ - "namespace, and spec.accessFrom does not admit it", namespace, key), - }, provider, nil + message += fmt.Sprintf(" (a grant's selector is invalid: %v)", selErr) } + return Decision{Reason: ReasonNamespaceNotAuthorized, Message: message} +} - routeDecision, err := AuditRouteHeld(ctx, reader, provider) - if err != nil || !routeDecision.Allowed { - return routeDecision, provider, err +// quoteAll renders a scope as quoted, comma-separated values, or "nothing" when it is empty. +func quoteAll(values []string) string { + if len(values) == 0 { + return "nothing" } - return Decision{Allowed: true}, provider, nil + quoted := make([]string, 0, len(values)) + for _, value := range values { + quoted = append(quoted, strconv.Quote(value)) + } + return strings.Join(quoted, ", ") } // AuditRouteHeld reports whether provider's namespace holds the audit route provider carries @@ -277,7 +327,7 @@ func AuditRouteHeld( } // namespaceLabels reads a control-cluster Namespace's labels. A Namespace that does not exist has -// none: a names entry still admits it, and a selector does not. +// none: a Names grant still admits it, and a selector does not. func namespaceLabels(ctx context.Context, reader client.Reader, namespace string) (map[string]string, error) { var ns corev1.Namespace if err := reader.Get(ctx, k8stypes.NamespacedName{Name: namespace}, &ns); err != nil { diff --git a/internal/authz/kubeprovider_admission_test.go b/internal/authz/kubeprovider_admission_test.go index c326ce66..0242d4c2 100644 --- a/internal/authz/kubeprovider_admission_test.go +++ b/internal/authz/kubeprovider_admission_test.go @@ -47,20 +47,41 @@ func targetIn(providerName string) *configv1alpha3.GitTarget { return t } -// providerNamed builds a KubeProvider in platformNS, so the GitTarget in testNS is admitted only -// through policy. -func providerNamed(name string, policy *configv1alpha3.NamespaceMatcher) *configv1alpha3.KubeProvider { +// providerNamed builds a KubeProvider in platformNS with grants as its spec.allowedGitTargets, so +// the GitTarget in testNS is admitted only through a grant. +func providerNamed(name string, grants ...configv1alpha3.SourceGrant) *configv1alpha3.KubeProvider { return &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: platformNS}, - Spec: configv1alpha3.KubeProviderSpec{AccessFrom: policy}, + Spec: configv1alpha3.KubeProviderSpec{AllowedGitTargets: grants}, } } +// mirrorFrom is a grant that admits namespaces by name to mirror their own namespace. +func mirrorFrom(namespaces ...string) configv1alpha3.SourceGrant { + names := make([]configv1alpha3.GrantNamespace, 0, len(namespaces)) + for _, ns := range namespaces { + names = append(names, configv1alpha3.GrantNamespace(ns)) + } + return configv1alpha3.SourceGrant{From: configv1alpha3.GrantFromNames, Names: names, + SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.NamespaceVariable}} +} + func namespaceLabeled(labels map[string]string) *corev1.Namespace { return &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testNS, Labels: labels}} } func TestGitTargetAdmitted_Policy(t *testing.T) { + ownNamespace := func(grants ...configv1alpha3.SourceGrant) *configv1alpha3.KubeProvider { + return &configv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: testNS}, + Spec: configv1alpha3.KubeProviderSpec{AllowedGitTargets: grants}, + } + } + ownTarget := &configv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "mirror", Namespace: testNS}, + Spec: configv1alpha3.GitTargetSpec{KubeProviderRef: meta.NamespacedObjectReference{Name: "local"}}, + } + tenant := map[string]string{"tenant": "true"} tests := []struct { name string objects []client.Object @@ -87,157 +108,108 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { wantMessage: "spec.kubeProviderRef names no KubeProvider", }, { - // A provider admits its own namespace without any accessFrom, like a GitProvider. - name: "a provider in the target's own namespace is admitted without accessFrom", - objects: []client.Object{ - &configv1alpha3.KubeProvider{ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: testNS}}, - namespaceLabeled(nil), - }, - target: &configv1alpha3.GitTarget{ - ObjectMeta: metav1.ObjectMeta{Name: "mirror", Namespace: testNS}, - Spec: configv1alpha3.GitTargetSpec{ - KubeProviderRef: meta.NamespacedObjectReference{Name: "local"}, - }, - }, + // Nothing is automatic, the provider's own namespace included. + name: "the provider's own namespace is refused when no grant names it", + objects: []client.Object{ownNamespace(), namespaceLabeled(nil)}, + target: ownTarget, + wantReason: ReasonNamespaceNotAuthorized, + wantMessage: `KubeProvider team-a/local admits no namespace in spec.allowedGitTargets; namespace "team-a"`, + }, + { + name: "from: Same admits the provider's own namespace", + objects: []client.Object{ownNamespace(configv1alpha3.SourceGrant{From: configv1alpha3.GrantFromSame, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{namespace}"}}), namespaceLabeled(nil)}, + target: ownTarget, wantAllowed: true, }, { - name: "nil accessFrom denies another namespace by default", - objects: []client.Object{ - providerNamed("prod-eu-1", nil), - namespaceLabeled(nil), - }, + name: "from: Same admits nothing else", + objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.SourceGrant{ + From: configv1alpha3.GrantFromSame, SourceNamespaces: []configv1alpha3.ScopeNamespace{"*"}}), + namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), - wantAllowed: false, wantReason: ReasonNamespaceNotAuthorized, - wantMessage: `namespace "team-a" is not permitted to reference KubeProvider platform/prod-eu-1`, + wantMessage: `KubeProvider platform/prod-eu-1 admits platform in spec.allowedGitTargets; namespace "team-a"`, }, { - name: "empty policy struct denies by default", - objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{}), - namespaceLabeled(nil), - }, + name: "an omitted list denies every namespace", + objects: []client.Object{providerNamed("prod-eu-1"), namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), - wantAllowed: false, wantReason: ReasonNamespaceNotAuthorized, + wantMessage: "admits no namespace in spec.allowedGitTargets", }, { - name: "names entry admits", + name: "a Names grant admits", objects: []client.Object{ - providerNamed("prod-eu-1", - &configv1alpha3.NamespaceMatcher{Names: []string{"team-b", "team-a"}}), + providerNamed("prod-eu-1", mirrorFrom("team-b", "team-a")), namespaceLabeled(nil), }, target: targetIn("prod-eu-1"), wantAllowed: true, }, { - name: "names list not containing the namespace denies", - objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{Names: []string{"team-b"}}), - namespaceLabeled(nil), - }, + name: "a Names grant not listing the namespace denies, naming what it admits", + objects: []client.Object{providerNamed("prod-eu-1", mirrorFrom("team-b")), namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), - wantAllowed: false, wantReason: ReasonNamespaceNotAuthorized, + wantMessage: `KubeProvider platform/prod-eu-1 admits team-b in spec.allowedGitTargets; namespace "team-a" is not admitted`, }, { - // The chart default. An empty selector matches every label set, so it admits every - // namespace — the single-cluster install's "no policy configured" shape. - name: "empty selector admits every namespace", - objects: []client.Object{ - providerNamed("prod-eu-1", - &configv1alpha3.NamespaceMatcher{Selector: &metav1.LabelSelector{}}), - namespaceLabeled(nil), - }, + name: "a Selector grant admits a namespace by its labels", + objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.SourceGrant{ + From: configv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{namespace}"}}), namespaceLabeled(tenant)}, target: targetIn("prod-eu-1"), wantAllowed: true, }, { - name: "selector matching namespace labels admits", - objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, - }), - namespaceLabeled(map[string]string{"tier": "prod"}), - }, + name: "a Selector grant does not admit a namespace without the labels", + objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.SourceGrant{ + From: configv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{namespace}"}}), namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), - wantAllowed: true, - }, - { - name: "selector not matching namespace labels denies", - objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, - }), - namespaceLabeled(map[string]string{"tier": "dev"}), - }, - target: targetIn("prod-eu-1"), - wantAllowed: false, wantReason: ReasonNamespaceNotAuthorized, + wantMessage: "admits namespaces labelled tenant=true", }, { - // Names and selector are ORed: a listed namespace is admitted even when the selector - // rejects it. Guards against a future refactor turning the OR into an AND. - name: "names admits even when the selector does not match", - objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{ - Names: []string{"team-a"}, - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, - }), - namespaceLabeled(map[string]string{"tier": "dev"}), - }, + // A namespace not created yet has no labels: a Names grant still admits it. + name: "a Names grant admits a namespace that does not exist yet", + objects: []client.Object{providerNamed("prod-eu-1", mirrorFrom("team-a"))}, target: targetIn("prod-eu-1"), wantAllowed: true, }, { - name: "invalid selector denies with a legible message rather than admitting", - objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchExpressions: []metav1.LabelSelectorRequirement{{ - Key: "tier", Operator: "NotARealOperator", - }}}, - }), - namespaceLabeled(nil), - }, + // A malformed selector must FAIL CLOSED, and say so. + name: "an invalid selector denies, and the message says why", + objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.SourceGrant{ + From: configv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "tier", Operator: "NotAnOperator"}}, + }, SourceNamespaces: []configv1alpha3.ScopeNamespace{"*"}}), namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), - wantAllowed: false, wantReason: ReasonNamespaceNotAuthorized, - wantMessage: "accessFrom selector is invalid", + wantMessage: "a grant's selector is invalid", }, { - // A missing Namespace object is not an error: `names` is evaluated against the NAME, - // which exists in the reference even before the namespace object does. - name: "absent namespace object still admitted by a names entry", - objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), - }, + name: "from: All admits every namespace", + objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.SourceGrant{ + From: configv1alpha3.GrantFromAll, SourceNamespaces: []configv1alpha3.ScopeNamespace{"{namespace}"}}), + namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), wantAllowed: true, }, - { - // ...but a selector has no labels to match against, so it correctly does not admit. - name: "absent namespace object is not admitted by a label selector", - objects: []client.Object{ - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "prod"}}, - }), - }, - target: targetIn("prod-eu-1"), - wantAllowed: false, - wantReason: ReasonNamespaceNotAuthorized, - }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - cl := fake.NewClientBuilder().WithScheme(admissionScheme(t)).WithObjects(tc.objects...).Build() - - decision, err := GitTargetAdmitted(context.Background(), cl, tc.target) + reader := fake.NewClientBuilder(). + WithScheme(admissionScheme(t)). + WithObjects(tc.objects...). + Build() + decision, err := GitTargetAdmitted(context.Background(), reader, tc.target) require.NoError(t, err) - assert.Equal(t, tc.wantAllowed, decision.Allowed) + + assert.Equal(t, tc.wantAllowed, decision.Allowed, decision.Message) if tc.wantAllowed { assert.Empty(t, decision.Reason, "an admission carries no reason") assert.Empty(t, decision.Message, "an admission carries no message") @@ -285,7 +257,7 @@ func TestGitTargetAdmitted_ReadErrorsSurfaceAsError(t *testing.T) { t.Run(tc.name, func(t *testing.T) { cl := fake.NewClientBuilder().WithScheme(admissionScheme(t)). WithObjects( - providerNamed("prod-eu-1", &configv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), + providerNamed("prod-eu-1", mirrorFrom("team-a")), namespaceLabeled(nil), ). WithInterceptorFuncs(interceptor.Funcs{ @@ -312,19 +284,90 @@ func TestGitTargetAdmitted_ReadErrorsSurfaceAsError(t *testing.T) { } } -// TestApplierAdmitted: an applier is reached with its applier claim's KubeProvider's credential, so -// that provider must admit the namespace too, and the source's denial comes first. -func TestApplierAdmitted(t *testing.T) { - admits := func(name string, namespaces ...string) *configv1alpha3.KubeProvider { - return providerNamed(name, &configv1alpha3.NamespaceMatcher{Names: namespaces}) +// claimOn is a FluxClaim in testNS on the KubeProvider cluster, holding the Kustomization +// applierNamespace/apps. +func claimOn(cluster, applierNamespace string) configv1alpha3.ApplierClaim { + return &configv1alpha3.FluxClaim{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNS, Name: "apps"}, + Spec: configv1alpha3.FluxClaimSpec{ + KubeProviderRef: meta.NamespacedObjectReference{Name: cluster, Namespace: platformNS}, + KustomizationRef: meta.NamespacedObjectReference{Name: "apps", Namespace: applierNamespace}, + }, } - claimOn := func(cluster string) configv1alpha3.ApplierClaim { - return &configv1alpha3.FluxClaim{ - ObjectMeta: metav1.ObjectMeta{Namespace: testNS, Name: "apps"}, - Spec: configv1alpha3.FluxClaimSpec{ - KubeProviderRef: meta.NamespacedObjectReference{Name: cluster, Namespace: platformNS}, - }, - } +} + +// appliersFrom is a grant that admits namespaces by name to hold appliers in scope. +func appliersFrom(scope []configv1alpha3.ScopeNamespace, namespaces ...string) configv1alpha3.ApplierGrant { + names := make([]configv1alpha3.GrantNamespace, 0, len(namespaces)) + for _, ns := range namespaces { + names = append(names, configv1alpha3.GrantNamespace(ns)) + } + return configv1alpha3.ApplierGrant{From: configv1alpha3.GrantFromNames, Names: names, ApplierNamespaces: scope} +} + +// TestApplierClaimAdmitted: an applier claim needs its own grant, and the applier must live in the +// scope that grant gives. An explicit list is the whole scope. +func TestApplierClaimAdmitted(t *testing.T) { + own := []configv1alpha3.ScopeNamespace{"{namespace}"} + provider := func(grants ...configv1alpha3.ApplierGrant) *configv1alpha3.KubeProvider { + p := providerNamed("hub", mirrorFrom(testNS)) + p.Spec.AllowedApplierClaims = grants + return p + } + tests := []struct { + name string + provider *configv1alpha3.KubeProvider + applierNS string + wantAllowed bool + wantReason string + wantMessage string + }{ + {"admitted to mirror is not admitted to apply", provider(), testNS, false, ReasonNamespaceNotAuthorized, + `KubeProvider platform/hub admits no namespace in spec.allowedApplierClaims; namespace "team-a"`}, + {"{namespace} admits an applier in the claim's own namespace", + provider(appliersFrom(own, testNS)), testNS, true, "", ""}, + {"{namespace} refuses an applier elsewhere", provider(appliersFrom(own, testNS)), "flux-system", false, + ReasonApplierNamespaceNotAllowed, `KubeProvider platform/hub lets namespace "team-a" hold appliers in ` + + `"team-a" (spec.allowedApplierClaims); Kustomization flux-system/apps is in namespace "flux-system"`}, + {"an explicit list refuses the claim's own namespace", + provider(appliersFrom([]configv1alpha3.ScopeNamespace{"flux-system"}, testNS)), testNS, false, + ReasonApplierNamespaceNotAllowed, `hold appliers in "flux-system"`}, + { + "an explicit list allows what it lists", + provider( + appliersFrom([]configv1alpha3.ScopeNamespace{"flux-system"}, testNS), + ), + "flux-system", + true, + "", + "", + }, + {"* allows every namespace", + provider(appliersFrom([]configv1alpha3.ScopeNamespace{"*"}, testNS)), "anywhere", true, "", ""}, + {"a grant for another namespace admits nothing here", + provider(appliersFrom(own, "team-b")), testNS, false, ReasonNamespaceNotAuthorized, + "admits team-b in spec.allowedApplierClaims"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + reader := fake.NewClientBuilder().WithScheme(admissionScheme(t)). + WithObjects(tc.provider, namespaceLabeled(nil)).Build() + decision, err := ApplierClaimAdmitted(context.Background(), reader, claimOn("hub", tc.applierNS)) + require.NoError(t, err) + assert.Equal(t, tc.wantAllowed, decision.Allowed, decision.Message) + assert.Equal(t, tc.wantReason, decision.Reason) + assert.Contains(t, decision.Message, tc.wantMessage) + }) + } +} + +// TestApplierAdmitted: asking an applier to apply needs both admissions, always. Mirroring and +// applying are granted separately, also on one KubeProvider from one namespace. +func TestApplierAdmitted(t *testing.T) { + own := []configv1alpha3.ScopeNamespace{"{namespace}"} + withAppliers := func(p *configv1alpha3.KubeProvider, grants ...configv1alpha3.ApplierGrant) *configv1alpha3.KubeProvider { + p.Spec.AllowedApplierClaims = grants + return p } tests := []struct { name string @@ -335,35 +378,41 @@ func TestApplierAdmitted(t *testing.T) { wantMessage string }{ { - name: "the applier claim's provider is the source's", - objects: []client.Object{admits("prod-eu-1", testNS)}, - claim: claimOn("prod-eu-1"), + name: "one provider granting both", + objects: []client.Object{ + withAppliers(providerNamed("prod-eu-1", mirrorFrom(testNS)), appliersFrom(own, testNS)), + }, + claim: claimOn("prod-eu-1", testNS), wantAllowed: true, }, { - name: "another provider that admits the namespace", - objects: []client.Object{admits("prod-eu-1", testNS), admits("hub", testNS)}, - claim: claimOn("hub"), - wantAllowed: true, + // The GitTarget and its applier claim are in one namespace on one KubeProvider; the + // shortcut that once admitted this without reading allowedApplierClaims is gone. + name: "mirroring allowed, applying denied, on one provider", + objects: []client.Object{providerNamed("prod-eu-1", mirrorFrom(testNS))}, + claim: claimOn("prod-eu-1", testNS), + wantReason: ReasonNamespaceNotAuthorized, + wantMessage: "spec.allowedApplierClaims", }, { - name: "another provider that does not admit the namespace", - objects: []client.Object{admits("prod-eu-1", testNS), admits("hub", "elsewhere")}, - claim: claimOn("hub"), - wantReason: ReasonNamespaceNotAuthorized, - wantMessage: "KubeProvider platform/hub", + name: "another provider that admits the applier claim", + objects: []client.Object{providerNamed("prod-eu-1", mirrorFrom(testNS)), + withAppliers(providerNamed("hub"), appliersFrom(own, testNS))}, + claim: claimOn("hub", testNS), + wantAllowed: true, }, { name: "another provider that does not exist", - objects: []client.Object{admits("prod-eu-1", testNS)}, - claim: claimOn("hub"), + objects: []client.Object{providerNamed("prod-eu-1", mirrorFrom(testNS))}, + claim: claimOn("hub", testNS), wantReason: ReasonKubeProviderNotFound, wantMessage: "FluxClaim " + testNS + "/apps spec.kubeProviderRef", }, { - name: "the source's denial comes first", - objects: []client.Object{admits("prod-eu-1", "elsewhere"), admits("hub", testNS)}, - claim: claimOn("hub"), + name: "the source's denial comes first", + objects: []client.Object{providerNamed("prod-eu-1", mirrorFrom("elsewhere")), + withAppliers(providerNamed("hub"), appliersFrom(own, testNS))}, + claim: claimOn("hub", testNS), wantReason: ReasonNamespaceNotAuthorized, wantMessage: "KubeProvider platform/prod-eu-1", }, @@ -388,7 +437,7 @@ func TestAuditRouteHeld(t *testing.T) { p := &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, CreationTimestamp: created}, Spec: configv1alpha3.KubeProviderSpec{ - AccessFrom: &configv1alpha3.NamespaceMatcher{Names: []string{testNS}}, + AllowedGitTargets: []configv1alpha3.SourceGrant{mirrorFrom(testNS)}, }, } if route != "" { @@ -432,7 +481,7 @@ func TestAuditRouteHeld_ListErrorSurfacesAsError(t *testing.T) { return boom }, }).Build() - decision, err := AuditRouteHeld(context.Background(), reader, providerNamed("prod-eu-1", nil)) + decision, err := AuditRouteHeld(context.Background(), reader, providerNamed("prod-eu-1")) require.ErrorIs(t, err, boom) assert.False(t, decision.Allowed) assert.Empty(t, decision.Reason) diff --git a/internal/authz/source_namespace.go b/internal/authz/source_namespace.go index 661d4fc1..5b54bee1 100644 --- a/internal/authz/source_namespace.go +++ b/internal/authz/source_namespace.go @@ -8,7 +8,6 @@ import ( "sort" "strings" - apierrors "k8s.io/apimachinery/pkg/api/errors" "sigs.k8s.io/controller-runtime/pkg/client" configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" @@ -17,26 +16,16 @@ import ( // Reasons for the WatchRule SourceNamespaceAuthorized condition. They are the rule-side names, so a // reader never has to know which gate input produced the verdict — the Message carries that. // -// There used to be five, and three of them existed only because the deleted -// GitTarget.spec.allowedSourceNamespaces had a SELECTOR half evaluated against Namespace labels in -// ANOTHER cluster: that read could be Forbidden, or still syncing, or permanently unevaluatable, so -// the verdict had to be three-valued and two of the reasons described an inability to decide rather -// than a decision. Nothing here reads another cluster any more. Every input is on the WatchRule, the -// GitTarget and the KubeProvider, all in the control plane, so the gate is a comparison and its -// answer is always allowed or denied. +// Every input is on the WatchRule, the GitTarget, the KubeProvider and the GitTarget's Namespace, all +// in the control plane, so the gate is a comparison and its answer is always allowed or denied. const ( - // ReasonLegacySourceNamespace is the True reason when EVERY item watches the rule's OWN - // namespace. No authorization was needed. - ReasonLegacySourceNamespace = "LegacySourceNamespace" - - // ReasonSourceNamespaceAllowed is the True reason when at least one item names a namespace - // other than the rule's own — an authorized override or the cluster-wide wildcard — and every - // item passed the gate. + // ReasonSourceNamespaceAllowed is the True reason when every item watches a namespace the + // KubeProvider's grants give the GitTarget's namespace. ReasonSourceNamespaceAllowed = "SourceNamespaceAllowed" // ReasonSourceNamespaceNotAllowed is the TERMINAL False reason for a refusal: the referenced - // KubeProvider does not exist, does not admit this GitTarget's namespace, or does not set - // spec.allowCrossNamespace.sources. It is a decision, never an inability to decide. + // KubeProvider does not exist, does not admit this GitTarget's namespace, or its grants do not + // give it the namespace an item asks for. It is a decision, never an inability to decide. ReasonSourceNamespaceNotAllowed = "SourceNamespaceNotAllowed" ) @@ -62,7 +51,8 @@ type SourceNamespaceDecision struct { // ResolvedSourceScope is a WHOLE WatchRule's source-namespace verdict: one decision per spec.rules // item, index-aligned, plus the aggregate the SourceNamespaceAuthorized condition publishes. // -// It is a pure function of (rule spec, provider flags) and is recomputed on every compile. Nothing +// It is a pure function of (rule spec, the grants that admit the GitTarget) and is recomputed on +// every compile. Nothing // per-item is persisted across a spec change, which is what lets rule items have no stable API // identity: no state outlives the spec that produced it. type ResolvedSourceScope struct { @@ -110,78 +100,61 @@ func (s ResolvedSourceScope) Fingerprint() string { // (docs/spec/where-validation-lives.md). Like GitTargetAdmitted it runs on every reconcile, so a // policy TIGHTENED after a rule was accepted revokes it. // -// The gate is two rules: -// -// 1. An item watching the rule's OWN namespace is allowed, with no provider involvement at all. -// This is the legacy case and it must stay free: gating it would break every existing WatchRule -// on upgrade. -// 2. Any OTHER namespace — a name, or "*" for every namespace the credential can read — -// additionally requires the GitTarget's namespace to be admitted by its KubeProvider, and -// that provider to set spec.allowCrossNamespace.sources. -// -// There is no third rule. GitTarget.spec.allowedSourceNamespaces used to supply one, and what it -// bounded is now bounded by the source credential's own RBAC: a request this gate permits still -// fails 403 at the apiserver if the credential cannot read that namespace, which is a better -// answer than a policy field that restates the credential in the one place that cannot revoke it. +// An item may watch a namespace only while the KubeProvider's spec.allowedGitTargets admit the +// GitTarget's namespace and give it that namespace in sourceNamespaces: a name, '{namespace}' for the +// GitTarget's own namespace (which is the WatchRule's), or "*" for every namespace, which is also +// what an item asking for "*" needs. The rule's own namespace is in scope only when a grant lists it. +// The source credential's RBAC stays the hard maximum: a request this gate permits still fails 403 +// at the apiserver if the credential cannot read that namespace. // -// A non-NotFound KubeProvider read error is returned as err so the caller requeues instead of -// tearing down a running stream on a transient apiserver failure. +// A non-NotFound read error is returned as err so the caller requeues instead of tearing down a +// running stream on a transient apiserver failure. func ResolveWatchRuleSourceScope( ctx context.Context, reader client.Reader, rule *configv1alpha3.WatchRule, target *configv1alpha3.GitTarget, ) (ResolvedSourceScope, error) { - gate := &itemGate{reader: reader, rule: rule, target: target} + decision, scope, err := GitTargetSourceScope(ctx, reader, target) + if err != nil { + return ResolvedSourceScope{}, err + } + gate := &itemGate{rule: rule, target: target, decision: decision, scope: scope} items := make([]SourceNamespaceDecision, 0, len(rule.Spec.Rules)) for i := range rule.Spec.Rules { - decision, err := gate.decide(ctx, i, &rule.Spec.Rules[i]) - if err != nil { - return ResolvedSourceScope{}, err - } - items = append(items, decision) + items = append(items, gate.decide(i, &rule.Spec.Rules[i])) } return aggregateSourceScope(items), nil } -// itemGate carries the per-rule inputs so each item's decision reads as one step. The -// KubeProvider verdict is memoised: every overriding item asks the same question of the same -// provider, and re-reading it per item would multiply apiserver reads by the rule's item count for -// an answer that cannot differ within one compile. +// itemGate carries the per-rule inputs so each item's decision reads as one step. The KubeProvider +// is read once per rule: every item asks the same provider about the same GitTarget. type itemGate struct { - reader client.Reader - rule *configv1alpha3.WatchRule - target *configv1alpha3.GitTarget - - delegation *SourceNamespaceDecision - delegationAsked bool + rule *configv1alpha3.WatchRule + target *configv1alpha3.GitTarget + decision Decision + scope configv1alpha3.SourceScope } // decide resolves ONE rule item. -func (g *itemGate) decide( - ctx context.Context, - index int, - item *configv1alpha3.ResourceRule, -) (SourceNamespaceDecision, error) { +func (g *itemGate) decide(index int, item *configv1alpha3.ResourceRule) SourceNamespaceDecision { base := SourceNamespaceDecision{Index: index, Requested: item.SourceNamespace} - - if !item.OverridesSourceNamespace(g.rule.Namespace) { - own := item.EffectiveSourceNamespace(g.rule.Namespace) - base.Namespaces = []string{own} - base.Allowed = true - base.Reason = ReasonLegacySourceNamespace - base.Message = fmt.Sprintf( - "%s watches this WatchRule's own namespace %q, which needs no authorization", - g.describeItem(index, item), own) - return base, nil + provider := g.target.KubeProviderKey() + if !g.decision.Allowed { + base.Reason = ReasonSourceNamespaceNotAllowed + base.Message = fmt.Sprintf("%s: GitTarget %s/%s may not mirror through KubeProvider %s at all: %s", + g.describeItem(index, item), g.target.Namespace, g.target.Name, provider, g.decision.Message) + return base } - refusal, refused, err := g.overrideDelegated(ctx, index, item) - if err != nil { - return SourceNamespaceDecision{}, err - } - if refused { - return refusal, nil + requested := item.EffectiveSourceNamespace(g.rule.Namespace) + if !g.scope.AllowsSourceNamespace(requested) { + base.Reason = ReasonSourceNamespaceNotAllowed + base.Message = fmt.Sprintf( + "KubeProvider %s lets namespace %q watch %s (spec.allowedGitTargets[].sourceNamespaces); %s is not "+ + "allowed", provider, g.target.Namespace, describeScope(g.scope.SourceNamespaces), + g.describeItem(index, item)) + return base } base.Allowed = true @@ -192,97 +165,23 @@ func (g *itemGate) decide( // named-namespace collection on the same type, never a replacement for one. base.Namespaces = []string{""} base.Message = fmt.Sprintf( - "%s: KubeProvider %s sets spec.allowCrossNamespace.sources, so this item watches every "+ - "namespace its source credential can read, as one cluster-wide stream", - g.describeItem(index, item), g.target.SourceCluster()) - return base, nil + "%s: KubeProvider %s grants sourceNamespaces \"*\", so this item watches every namespace its source "+ + "credential can read, as one cluster-wide stream", g.describeItem(index, item), provider) + return base } - - candidate := item.EffectiveSourceNamespace(g.rule.Namespace) - base.Namespaces = []string{candidate} + base.Namespaces = []string{requested} base.Message = fmt.Sprintf( - "%s: KubeProvider %s sets spec.allowCrossNamespace.sources, so this item may watch source "+ - "namespace %q; whether it can is decided by that credential's RBAC", - g.describeItem(index, item), g.target.SourceCluster(), candidate) - return base, nil -} - -// overrideDelegated applies the two provider-side halves of the gate: the provider must admit the -// GitTarget's own namespace, and it must set the delegation flag. Its verdict is identical for -// every item, so it is computed once per rule. -// -// It returns refused=true with the refusal to publish (retargeted at this item), or refused=false -// when the item is authorized. A read error is returned as err so the caller requeues. -func (g *itemGate) overrideDelegated( - ctx context.Context, - index int, - item *configv1alpha3.ResourceRule, -) (SourceNamespaceDecision, bool, error) { - if !g.delegationAsked { - refusal, err := g.evaluateDelegation(ctx) - if err != nil { - return SourceNamespaceDecision{}, false, err - } - g.delegation = refusal - g.delegationAsked = true - } - 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 + "%s: KubeProvider %s lets this item watch source namespace %q; whether it can is decided by that "+ + "credential's RBAC", g.describeItem(index, item), provider, requested) + return base } -// evaluateDelegation returns a refusal template when the provider side of the gate denies, or nil -// when it permits. The message is item-agnostic; decide prefixes the item that asked. -func (g *itemGate) evaluateDelegation(ctx context.Context) (*SourceNamespaceDecision, error) { - providerName := g.target.KubeProviderKey() - - var provider configv1alpha3.KubeProvider - if err := g.reader.Get(ctx, providerName, &provider); err != nil { - if apierrors.IsNotFound(err) { - return &SourceNamespaceDecision{ - Reason: ReasonSourceNamespaceNotAllowed, - Message: fmt.Sprintf( - "referenced KubeProvider %s was not found, so it delegates nothing; a "+ - "WatchRule may watch a namespace other than its own only through an "+ - "existing provider that sets spec.allowCrossNamespace.sources", - providerName), - }, nil - } - // Transient: requeue rather than tear down a running stream. - return nil, fmt.Errorf("read KubeProvider %s: %w", providerName, err) - } - - // The GitTarget itself must be admitted by that provider before it can delegate anything. - admitted, err := GitTargetAdmitted(ctx, g.reader, g.target) - if err != nil { - return nil, err +// describeScope renders a source-namespace scope for a message. +func describeScope(namespaces []string) string { + if len(namespaces) == 0 { + return "no namespace" } - if !admitted.Allowed { - return &SourceNamespaceDecision{ - Reason: ReasonSourceNamespaceNotAllowed, - Message: fmt.Sprintf( - "GitTarget %s/%s may not mirror through KubeProvider %s at all: %s", - g.target.Namespace, g.target.Name, providerName, admitted.Message), - }, nil - } - - if !provider.AllowsCrossNamespaceSources() { - return &SourceNamespaceDecision{ - Reason: ReasonSourceNamespaceNotAllowed, - Message: fmt.Sprintf( - "KubeProvider %s does not set spec.allowCrossNamespace.sources; a WatchRule may "+ - "watch only its own namespace %q until a platform admin delegates that choice", - providerName, g.rule.Namespace), - }, nil - } - - return nil, nil //nolint:nilnil // nil refusal means "the provider side permits"; see the godoc. + return quoteAll(namespaces) } // describeItem names an item by index AND by what it selects. The index alone goes stale the moment @@ -293,13 +192,8 @@ func (g *itemGate) describeItem(index int, item *configv1alpha3.ResourceRule) st } // aggregateSourceScope folds the per-item decisions into the one SourceNamespaceAuthorized verdict -// the object publishes. The precedence is stated rather than derived, because two implementations -// of "worst wins" would otherwise disagree about mixed rules: -// -// 1. any item denied → False / SourceNamespaceNotAllowed / Stalled=True -// 2. every item admitted, at least one naming a namespace other than the rule's own → True / -// SourceNamespaceAllowed -// 3. every item on its own namespace → True / LegacySourceNamespace +// the object publishes: any item denied is False / SourceNamespaceNotAllowed / Stalled=True, naming +// the first denied item; otherwise True / SourceNamespaceAllowed. // // There is no "cannot say yet" row. Every input is a control-plane object this reconcile already // read, so an item that is not denied is decided. @@ -307,7 +201,7 @@ func aggregateSourceScope(items []SourceNamespaceDecision) ResolvedSourceScope { out := ResolvedSourceScope{Items: items} if len(items) == 0 { out.Allowed = true - out.Reason = ReasonLegacySourceNamespace + out.Reason = ReasonSourceNamespaceAllowed out.Message = "no rule items to authorize" return out } @@ -321,24 +215,11 @@ func aggregateSourceScope(items []SourceNamespaceDecision) ResolvedSourceScope { } out.Allowed = true - out.Reason, out.Message = admittedAggregate(items) + out.Reason = ReasonSourceNamespaceAllowed + out.Message = summariseAdmitted(items) return out } -// admittedAggregate picks the True reason once every item was admitted. -func admittedAggregate(items []SourceNamespaceDecision) (string, string) { - legacy := true - for _, item := range items { - if item.Reason != ReasonLegacySourceNamespace { - legacy = false - } - } - if legacy { - return ReasonLegacySourceNamespace, items[0].Message - } - return ReasonSourceNamespaceAllowed, summariseAdmitted(items) -} - func summariseAdmitted(items []SourceNamespaceDecision) string { all := make([]string, 0, len(items)) for _, item := range items { diff --git a/internal/authz/source_namespace_test.go b/internal/authz/source_namespace_test.go index f25c027a..23713a7a 100644 --- a/internal/authz/source_namespace_test.go +++ b/internal/authz/source_namespace_test.go @@ -69,18 +69,27 @@ func snRule(sourceNamespaces ...string) *configv1alpha3.WatchRule { } } -// snKubeProvider admits snTenantNS by name, so the "provider admits the target's namespace" leg -// of the gate passes and each case isolates the leg it is actually about. -func snKubeProvider(delegate bool) *configv1alpha3.KubeProvider { +// snKubeProvider admits snTenantNS by name with scope as its sourceNamespaces, so the "provider +// admits the target's namespace" leg of the gate passes and each case isolates the scope. +func snKubeProvider(scope ...configv1alpha3.ScopeNamespace) *configv1alpha3.KubeProvider { + grant := configv1alpha3.SourceGrant{ + From: configv1alpha3.GrantFromNames, Names: []configv1alpha3.GrantNamespace{snTenantNS}, + SourceNamespaces: scope, + } + if len(scope) == 0 { + grant.ClusterResources = []configv1alpha3.ClusterResourceIdentity{"*"} + } return &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: snProvider, Namespace: snProviderNS}, - Spec: configv1alpha3.KubeProviderSpec{ - AccessFrom: &configv1alpha3.NamespaceMatcher{Names: []string{snTenantNS}}, - AllowCrossNamespace: &configv1alpha3.KubeProviderCrossNamespace{Sources: delegate}, - }, + Spec: configv1alpha3.KubeProviderSpec{AllowedGitTargets: []configv1alpha3.SourceGrant{grant}}, } } +const ( + own = configv1alpha3.ScopeNamespace(configv1alpha3.NamespaceVariable) + all = configv1alpha3.ScopeNamespace("*") +) + func snReader(t *testing.T, objects ...client.Object) client.Reader { t.Helper() objects = append(objects, @@ -88,8 +97,8 @@ func snReader(t *testing.T, objects ...client.Object) client.Reader { return fake.NewClientBuilder().WithScheme(snScheme(t)).WithObjects(objects...).Build() } -// The gate is two rules and this is the table for both of them: an item on the rule's own namespace -// is free, and anything else needs the KubeProvider's delegation. +// The gate is one rule and this is its table: an item may watch a namespace only while the grants +// that admit the GitTarget give it that namespace. The rule's own namespace is no exception. // // There is deliberately no row for a GitTarget policy. There is no longer such a field: what it // bounded is bounded by the source credential's own RBAC, which this gate cannot and does not try @@ -98,74 +107,94 @@ func TestResolveWatchRuleSourceScope(t *testing.T) { tests := []struct { name string items []string - delegate bool + scope []configv1alpha3.ScopeNamespace admitted bool - reason string namespaces [][]string says string }{ { - name: "an omitted item watches the rule's own namespace, ungated", + name: "an omitted item watches the rule's own namespace, granted by {namespace}", items: []string{""}, - delegate: false, + scope: []configv1alpha3.ScopeNamespace{own}, admitted: true, - reason: authz.ReasonLegacySourceNamespace, namespaces: [][]string{{snTenantNS}}, - says: "gating the legacy case would break every existing WatchRule on upgrade", }, { - name: "restating the rule's own namespace is not an override", + name: "an explicit scope is the whole scope, the rule's own namespace excluded", + items: []string{""}, + scope: []configv1alpha3.ScopeNamespace{"flux-system"}, + admitted: false, + says: `sourceNamespaces: [flux-system] means flux-system only`, + }, + { + name: "a grant with clusterResources alone gives no namespace", + items: []string{""}, + admitted: false, + }, + { + name: "restating the rule's own namespace is the same request", items: []string{snTenantNS}, - delegate: false, + scope: []configv1alpha3.ScopeNamespace{own}, admitted: true, - reason: authz.ReasonLegacySourceNamespace, namespaces: [][]string{{snTenantNS}}, - says: "spelling out what omission means must not need a platform-admin flag", }, { - name: "a named override is allowed once the provider delegates", + name: "a named namespace is allowed once a grant lists it", items: []string{snSourceNS}, - delegate: true, + scope: []configv1alpha3.ScopeNamespace{own, configv1alpha3.ScopeNamespace(snSourceNS)}, admitted: true, - reason: authz.ReasonSourceNamespaceAllowed, namespaces: [][]string{{snSourceNS}}, }, { - name: "a named override is refused while the provider does not delegate", + name: "a named namespace is refused under {namespace}", items: []string{snSourceNS}, - delegate: false, + scope: []configv1alpha3.ScopeNamespace{own}, admitted: false, - reason: authz.ReasonSourceNamespaceNotAllowed, }, { - name: "the wildcard resolves to the cluster-wide collection", + name: "the wildcard resolves to the cluster-wide collection under *", items: []string{snWildcard}, - delegate: true, + scope: []configv1alpha3.ScopeNamespace{all}, admitted: true, - reason: authz.ReasonSourceNamespaceAllowed, namespaces: [][]string{{""}}, says: `"*" is one cluster-wide list and watch, which is the empty namespace`, }, { - name: "the wildcard is refused while the provider does not delegate", + name: "the wildcard is refused under {namespace}", items: []string{snWildcard}, - delegate: false, + scope: []configv1alpha3.ScopeNamespace{own}, admitted: false, - reason: authz.ReasonSourceNamespaceNotAllowed, says: "the widest request in the API must not be the one that slips through", }, + { + name: "the wildcard is refused under a list of names", + items: []string{snWildcard}, + scope: []configv1alpha3.ScopeNamespace{own, configv1alpha3.ScopeNamespace(snSourceNS)}, + admitted: false, + }, + { + name: "* allows a named namespace", + items: []string{snSourceNS}, + scope: []configv1alpha3.ScopeNamespace{all}, + admitted: true, + namespaces: [][]string{{snSourceNS}}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - reader := snReader(t, snTarget(), snKubeProvider(tt.delegate)) + reader := snReader(t, snTarget(), snKubeProvider(tt.scope...)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule(tt.items...), snTarget()) require.NoError(t, err) assert.Equal(t, tt.admitted, resolved.Admitted(), tt.says) - assert.Equal(t, tt.reason, resolved.Reason, tt.says) + reason := authz.ReasonSourceNamespaceNotAllowed + if tt.admitted { + reason = authz.ReasonSourceNamespaceAllowed + } + assert.Equal(t, reason, resolved.Reason, tt.says) if tt.namespaces != nil { for i, want := range tt.namespaces { assert.Equal(t, want, resolved.NamespacesFor(i), tt.says) @@ -176,10 +205,10 @@ func TestResolveWatchRuleSourceScope(t *testing.T) { } } -// A rule mixing a legacy item with an authorized override resolves each independently, and the +// A rule mixing its own namespace with other ones resolves each item independently, and the // aggregate is the one condition the object publishes. func TestResolveWatchRuleSourceScope_MixedItemsResolveIndependently(t *testing.T) { - reader := snReader(t, snTarget(), snKubeProvider(true)) + reader := snReader(t, snTarget(), snKubeProvider(own, all)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule("", snSourceNS, snWildcard), snTarget()) @@ -189,15 +218,14 @@ func TestResolveWatchRuleSourceScope_MixedItemsResolveIndependently(t *testing.T assert.Equal(t, []string{snTenantNS}, resolved.NamespacesFor(0)) assert.Equal(t, []string{snSourceNS}, resolved.NamespacesFor(1)) assert.Equal(t, []string{""}, resolved.NamespacesFor(2)) - assert.Equal(t, authz.ReasonSourceNamespaceAllowed, resolved.Reason, - "one overriding item makes the whole rule an override for reporting purposes") + assert.Equal(t, authz.ReasonSourceNamespaceAllowed, resolved.Reason) } // A denied item refuses the WHOLE rule rather than being trimmed away. Mirroring two of the three // namespaces a rule asked for is worse than a loud failure: the operator would have no way to see // that the third was dropped. func TestResolveWatchRuleSourceScope_DeniedItemRefusesTheWholeRule(t *testing.T) { - reader := snReader(t, snTarget(), snKubeProvider(false)) + reader := snReader(t, snTarget(), snKubeProvider(own)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule("", snSourceNS), snTarget()) @@ -208,14 +236,16 @@ func TestResolveWatchRuleSourceScope_DeniedItemRefusesTheWholeRule(t *testing.T) assert.Equal(t, authz.ReasonSourceNamespaceNotAllowed, resolved.Reason) assert.Contains(t, resolved.Message, "spec.rules[1]", "the message must name the item that decided it, since the index is what gets edited") + assert.Contains(t, resolved.Message, `KubeProvider platform/workspaces lets namespace "tenant-acme" watch `+ + `"tenant-acme" (spec.allowedGitTargets[].sourceNamespaces); spec.rules[1] (resources configmaps, `+ + `sourceNamespace "repo-config") is not allowed`, "the refusal names the grant and the request") } -// A GitTarget the provider does not admit at all cannot delegate anything to its rules: the -// provider-side leg is checked before the flag, so a target outside accessFrom is refused even -// through a provider that delegates freely. -func TestResolveWatchRuleSourceScope_UnadmittedGitTargetCannotDelegate(t *testing.T) { - denying := snKubeProvider(true) - denying.Spec.AccessFrom = &configv1alpha3.NamespaceMatcher{Names: []string{"some-other-tenant"}} +// A GitTarget the provider does not admit at all gets no scope, however wide the grant it is not +// admitted by. +func TestResolveWatchRuleSourceScope_UnadmittedGitTargetGetsNoScope(t *testing.T) { + denying := snKubeProvider(own, all) + denying.Spec.AllowedGitTargets[0].Names = []configv1alpha3.GrantNamespace{"some-other-tenant"} reader := snReader(t, snTarget(), denying) resolved, err := authz.ResolveWatchRuleSourceScope( @@ -227,9 +257,9 @@ func TestResolveWatchRuleSourceScope_UnadmittedGitTargetCannotDelegate(t *testin assert.Contains(t, resolved.Message, "may not mirror through KubeProvider") } -// A missing KubeProvider denies an override rather than erroring: a provider that does not exist -// delegates nothing, which is a decision the rule's owner can act on. -func TestResolveWatchRuleSourceScope_MissingKubeProviderDeniesOverride(t *testing.T) { +// A missing KubeProvider denies rather than erroring: a provider that does not exist grants nothing, +// which is a decision the rule's owner can act on. +func TestResolveWatchRuleSourceScope_MissingKubeProviderDenies(t *testing.T) { reader := snReader(t, snTarget()) resolved, err := authz.ResolveWatchRuleSourceScope( @@ -246,7 +276,7 @@ func TestResolveWatchRuleSourceScope_ProviderReadErrorIsRequeued(t *testing.T) { boom := errors.New("apiserver unavailable") reader := fake.NewClientBuilder(). WithScheme(snScheme(t)). - WithObjects(snTarget(), snKubeProvider(true)). + WithObjects(snTarget(), snKubeProvider(own, all)). WithInterceptorFuncs(interceptor.Funcs{ Get: func( _ context.Context, _ client.WithWatch, key client.ObjectKey, @@ -270,7 +300,7 @@ func TestResolveWatchRuleSourceScope_ProviderIsReadOncePerRule(t *testing.T) { reads := 0 reader := fake.NewClientBuilder(). WithScheme(snScheme(t)). - WithObjects(snTarget(), snKubeProvider(true), + WithObjects(snTarget(), snKubeProvider(own, all), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: snTenantNS}}). WithInterceptorFuncs(interceptor.Funcs{ Get: func( @@ -290,7 +320,7 @@ func TestResolveWatchRuleSourceScope_ProviderIsReadOncePerRule(t *testing.T) { require.NoError(t, err) require.True(t, resolved.Admitted()) assert.LessOrEqual(t, reads, 2, - "the delegation verdict cannot differ within one compile, so it is memoised rather than "+ + "the provider's verdict cannot differ within one compile, so it is read once rather than "+ "multiplying apiserver reads by the rule's item count") } @@ -298,7 +328,7 @@ func TestResolveWatchRuleSourceScope_ProviderIsReadOncePerRule(t *testing.T) { // the RESOLVED set moves — including for the wildcard, whose resolved value is the empty string. func TestResolvedSourceScope_Fingerprint(t *testing.T) { ctx := context.Background() - reader := snReader(t, snTarget(), snKubeProvider(true)) + reader := snReader(t, snTarget(), snKubeProvider(own, all)) named, err := authz.ResolveWatchRuleSourceScope(ctx, reader, snRule(snSourceNS), snTarget()) require.NoError(t, err) @@ -316,21 +346,21 @@ func TestResolvedSourceScope_Fingerprint(t *testing.T) { // An empty rule is vacuously authorized. It is reachable through the API only transiently, and a // gate that errored on it would turn an empty list into a stall. func TestResolveWatchRuleSourceScope_NoItems(t *testing.T) { - reader := snReader(t, snTarget(), snKubeProvider(false)) + reader := snReader(t, snTarget(), snKubeProvider(own)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule(), snTarget()) require.NoError(t, err) assert.True(t, resolved.Admitted()) - assert.Equal(t, authz.ReasonLegacySourceNamespace, resolved.Reason) + assert.Equal(t, authz.ReasonSourceNamespaceAllowed, resolved.Reason) } // NamespacesFor is indexed by the item's position in spec.rules, and the store compiles from it // position by position. An out-of-range index returns nil rather than panicking: the resolved scope // and the spec are two objects, and a caller reading them apart must not take the process down. func TestResolvedSourceScope_NamespacesForIsBoundsChecked(t *testing.T) { - reader := snReader(t, snTarget(), snKubeProvider(true)) + reader := snReader(t, snTarget(), snKubeProvider(own, all)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule("", snSourceNS), snTarget()) @@ -345,7 +375,7 @@ func TestResolvedSourceScope_NamespacesForIsBoundsChecked(t *testing.T) { // same namespace twice, and the cluster-wide collection is spelled out rather than shown as the empty // string an operator would read as a missing value. func TestResolveWatchRuleSourceScope_AggregateMessageIsDeduplicatedAndLegible(t *testing.T) { - reader := snReader(t, snTarget(), snKubeProvider(true)) + reader := snReader(t, snTarget(), snKubeProvider(own, all)) resolved, err := authz.ResolveWatchRuleSourceScope( context.Background(), reader, snRule(snSourceNS, snSourceNS, snWildcard), snTarget()) diff --git a/internal/controller/access_grants_admission_test.go b/internal/controller/access_grants_admission_test.go index 8e567e70..eeb08776 100644 --- a/internal/controller/access_grants_admission_test.go +++ b/internal/controller/access_grants_admission_test.go @@ -29,7 +29,7 @@ var _ = Describe("Access grants at admission", func() { } gitProvider := func(grants ...any) *unstructured.Unstructured { return object("GitProvider", map[string]any{ - "url": "https://example.com/repo.git", "allowedBranches": []any{"*"}, "allowedGitBranches": grants, + "url": "https://example.com/repo.git", "allowedGitBranches": grants, }) } gitBranch := func(grants ...any) *unstructured.Unstructured { diff --git a/internal/controller/applierclaim_controller.go b/internal/controller/applierclaim_controller.go index da32c3bd..8f32c8b8 100644 --- a/internal/controller/applierclaim_controller.go +++ b/internal/controller/applierclaim_controller.go @@ -524,7 +524,7 @@ func (r *ApplierClaimReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(r.kubeProviderToClaims), builder.WithPredicates(predicate.GenerationChangedPredicate{}), ). - // An accessFrom selector reads namespace labels. + // A Selector grant reads namespace labels. Watches( &corev1.Namespace{}, handler.EnqueueRequestsFromMapFunc(r.namespaceToClaims), diff --git a/internal/controller/applierclaim_controller_test.go b/internal/controller/applierclaim_controller_test.go index b51150b8..228d5a47 100644 --- a/internal/controller/applierclaim_controller_test.go +++ b/internal/controller/applierclaim_controller_test.go @@ -119,16 +119,16 @@ func claimProviderKey(name string) string { return claimProviderNamespace + "/" + name } -// admitting is a KubeProvider in claimProviderNamespace whose accessFrom admits namespaces, and -// which lets an applier claim name an applier in another namespace (the test appliers live in flux-system -// and argocd). +// admitting is a KubeProvider in claimProviderNamespace that admits namespaces to mirror their own +// namespace, and to hold appliers in any namespace (the test appliers live in flux-system and +// argocd). func admitting(name string, namespaces ...string) *configbutleraiv1alpha3.KubeProvider { return &configbutleraiv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: claimProviderNamespace}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ - InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, - AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{Names: namespaces}, - AllowCrossNamespace: &configbutleraiv1alpha3.KubeProviderCrossNamespace{Appliers: true}, + InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: mirrorGrants(namespaces...), + AllowedApplierClaims: applierGrants([]configbutleraiv1alpha3.ScopeNamespace{"*"}, namespaces...), }, } } diff --git a/internal/controller/applierclaim_rule_test.go b/internal/controller/applierclaim_rule_test.go index d143bb52..c9d0e056 100644 --- a/internal/controller/applierclaim_rule_test.go +++ b/internal/controller/applierclaim_rule_test.go @@ -466,7 +466,7 @@ func TestApplierClaimRule_AnApplierInAnotherNamespaceNeedsTheOptIn(t *testing.T) assert.Equal(t, tc.wantStatus, configured.Status, configured.Message) assert.Equal(t, tc.wantReason, configured.Reason) if tc.wantStatus == metav1.ConditionFalse { - assert.Contains(t, configured.Message, "spec.allowCrossNamespace.appliers") + assert.Contains(t, configured.Message, "spec.allowedApplierClaims") assert.True(t, conditionIsTrue(*got.StatusConditions(), ConditionTypeStalled)) assert.Zero(t, *calls, "the applier's cluster is never asked") assert.Empty(t, got.GetFinalizers(), "and nothing is held") @@ -503,7 +503,8 @@ func applierNamespaceReconciler( } provider := admitting(claimLocalProvider, "team-a") if !optIn { - provider.Spec.AllowCrossNamespace = nil + provider.Spec.AllowedApplierClaims = applierGrants( + []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.NamespaceVariable}, "team-a") } r := claimReconciler(claimTestClient(t, claim, provider), kind, applierFakeSource(t, appliers...), nil, true) calls := new(int) diff --git a/internal/controller/clusterwatchrule_admission_test.go b/internal/controller/clusterwatchrule_admission_test.go index f478a0eb..16b3fe14 100644 --- a/internal/controller/clusterwatchrule_admission_test.go +++ b/internal/controller/clusterwatchrule_admission_test.go @@ -127,12 +127,12 @@ func cwaGitProvider() *configbutleraiv1alpha3.GitProvider { } } -func cwaKubeProvider(policy *configbutleraiv1alpha3.NamespaceMatcher) *configbutleraiv1alpha3.KubeProvider { +func cwaKubeProvider(policy []configbutleraiv1alpha3.SourceGrant) *configbutleraiv1alpha3.KubeProvider { return &configbutleraiv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: cwaProviderName, Namespace: cwaProviderNS}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ - SecretRef: &meta.SecretKeyReference{Name: "kc"}, - AccessFrom: policy, + SecretRef: &meta.SecretKeyReference{Name: "kc"}, + AllowedGitTargets: policy, }, } } @@ -151,12 +151,12 @@ func cwaClusterWatchRule() *configbutleraiv1alpha3.ClusterWatchRule { } // cwaAdmitting is the policy that admits cwaTargetNS; cwaDenying admits some other namespace. -func cwaAdmitting() *configbutleraiv1alpha3.NamespaceMatcher { - return &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{cwaTargetNS}} +func cwaAdmitting() []configbutleraiv1alpha3.SourceGrant { + return mirrorGrants(cwaTargetNS) } -func cwaDenying() *configbutleraiv1alpha3.NamespaceMatcher { - return &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"some-other-namespace"}} +func cwaDenying() []configbutleraiv1alpha3.SourceGrant { + return mirrorGrants("some-other-namespace") } type cwaFixture struct { @@ -362,9 +362,11 @@ func TestReconcile_AdmittedBySelectorOnNamespaceLabels(t *testing.T) { ctx := context.Background() f := newCWAFixture(t, []client.Object{ cwaClusterWatchRule(), cwaGitTarget(), cwaGitProvider(), - cwaKubeProvider(&configbutleraiv1alpha3.NamespaceMatcher{ - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirror": "yes"}}, - }), + cwaKubeProvider([]configbutleraiv1alpha3.SourceGrant{{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirror": "yes"}}, + ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}, + }}), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ Name: cwaTargetNS, Labels: map[string]string{"mirror": "yes"}, }}, @@ -396,7 +398,7 @@ func TestReconcile_RevocationRemovesCompiledRule(t *testing.T) { var provider configbutleraiv1alpha3.KubeProvider require.NoError(t, f.client.Get(ctx, k8stypes.NamespacedName{Namespace: cwaProviderNS, Name: cwaProviderName}, &provider)) - provider.Spec.AccessFrom = cwaDenying() + provider.Spec.AllowedGitTargets = cwaDenying() require.NoError(t, f.client.Update(ctx, &provider)) // Round 2: the same reconcile now refuses. diff --git a/internal/controller/clusterwatchrule_controller.go b/internal/controller/clusterwatchrule_controller.go index b227d950..5791b557 100644 --- a/internal/controller/clusterwatchrule_controller.go +++ b/internal/controller/clusterwatchrule_controller.go @@ -220,7 +220,7 @@ func (r *ClusterWatchRuleReconciler) reconcileClusterWatchRuleViaTarget( // admission of the referenced GitTarget: a ClusterWatchRule is cluster-scoped and its gitTargetRef // carries a REQUIRED namespace, so it may name a GitTarget in ANY namespace and widen that target's // mirror scope cluster-wide. Compiling such a rule without re-applying the target's own provider -// admission would let it mirror through a credential whose accessFrom never admitted that target. +// admission would let it mirror through a credential whose grants never admitted that target. // // It lives in internal/watch rather than here because the startup bootstrap must apply exactly the // same refusal BEFORE the first reconcile — otherwise every restart reopens the window it closes. @@ -379,7 +379,7 @@ func (r *ClusterWatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(r.gitTargetToClusterWatchRules), builder.WithPredicates(gitTargetReadyProjectionChanged()), ). - // React to a KubeProvider's accessFrom changing. Without this, REVOKING a + // React to a KubeProvider's grants changing. Without this, REVOKING a // namespace stops the GitTarget (which does watch KubeProvider) but leaves this rule's // compiled entry resident until the next periodic reconcile, so the admission gate would // converge on a ~10m delay instead of on the event. The GitTarget's own status flip cannot @@ -390,7 +390,7 @@ func (r *ClusterWatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(r.kubeProviderToClusterWatchRules), builder.WithPredicates(kubeProviderReadyOrSpecChanged()), ). - // React to a Namespace's LABELS changing: accessFrom may admit by selector, so a + // React to a Namespace's LABELS changing: a grant may admit by selector, so a // label change on a GitTarget's namespace grants or revokes every ClusterWatchRule pointing // at a target in it. LabelChangedPredicate ignores unrelated namespace churn. Watches( @@ -442,7 +442,7 @@ func (r *ClusterWatchRuleReconciler) kubeProviderToClusterWatchRules( } // namespaceToClusterWatchRules maps a Namespace label change to every ClusterWatchRule whose -// referenced GitTarget lives in that namespace — the selector half of accessFrom. +// referenced GitTarget lives in that namespace — what a Selector grant reads. func (r *ClusterWatchRuleReconciler) namespaceToClusterWatchRules( ctx context.Context, obj client.Object, diff --git a/internal/controller/dependency_watches_test.go b/internal/controller/dependency_watches_test.go index 3b3fe6d9..6bab671d 100644 --- a/internal/controller/dependency_watches_test.go +++ b/internal/controller/dependency_watches_test.go @@ -99,6 +99,7 @@ func gitBranch(providerName, namespace string) *configbutleraiv1alpha3.GitBranch ObjectMeta: metav1.ObjectMeta{Name: gitBranchNameForTest(providerName, "main"), Namespace: namespace}, Spec: configbutleraiv1alpha3.GitBranchSpec{ GitProviderRef: meta.NamespacedObjectReference{Name: providerName}, Branch: "main", + AllowedGitTargets: writersFromSame(), }, } } diff --git a/internal/controller/git_branch_helpers_test.go b/internal/controller/git_branch_helpers_test.go index 68a5d20b..2ccc40c8 100644 --- a/internal/controller/git_branch_helpers_test.go +++ b/internal/controller/git_branch_helpers_test.go @@ -36,6 +36,7 @@ func gitBranchOn(provider, namespace, branch string) *configbutleraiv1alpha3.Git ObjectMeta: metav1.ObjectMeta{Name: gitBranchNameForTest(provider, branch), Namespace: namespace}, Spec: configbutleraiv1alpha3.GitBranchSpec{ GitProviderRef: meta.NamespacedObjectReference{Name: provider}, Branch: branch, + AllowedGitTargets: writersFromSame(), }, } } diff --git a/internal/controller/gitbranch_controller.go b/internal/controller/gitbranch_controller.go index 6383ae36..b090db4a 100644 --- a/internal/controller/gitbranch_controller.go +++ b/internal/controller/gitbranch_controller.go @@ -5,7 +5,6 @@ package controller import ( "context" "fmt" - "path/filepath" "sync" "time" @@ -25,6 +24,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/source" configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" "github.com/ConfigButler/gitops-reverser/internal/git" "github.com/ConfigButler/gitops-reverser/internal/repourl" "github.com/ConfigButler/gitops-reverser/internal/telemetry" @@ -36,13 +36,16 @@ const GitBranchFinalizer = "configbutler.ai/git-branch" const ( // GitBranchReasonGitProviderNotFound is the stall for a GitBranch whose GitProvider does not - // exist in its namespace. + // exist. GitBranchReasonGitProviderNotFound = "GitProviderNotFound" // GitBranchReasonGitProviderNotReady is the progress reason while the GitProvider is not Ready. GitBranchReasonGitProviderNotReady = "GitProviderNotReady" - // GitBranchReasonBranchNotAllowed is the stall for a branch outside the GitProvider's - // allowedBranches. - GitBranchReasonBranchNotAllowed = "BranchNotAllowed" + // GitBranchReasonBranchNotAllowed is the stall for a branch outside the patterns the + // GitProvider's spec.allowedGitBranches give this GitBranch's namespace. + GitBranchReasonBranchNotAllowed = authz.ReasonBranchNotAllowed + // GitBranchReasonNamespaceNotAuthorized is the stall for a GitBranch whose namespace no grant in + // the GitProvider's spec.allowedGitBranches admits. + GitBranchReasonNamespaceNotAuthorized = authz.ReasonNamespaceNotAuthorized // GitBranchReasonPushSessionFailed is the progress reason while the credential cannot open a // push (receive-pack) session on the remote. A GitBranch that holds its branch keeps it. GitBranchReasonPushSessionFailed = "PushSessionFailed" @@ -189,11 +192,15 @@ func (r *GitBranchReconciler) evaluate( Created: branch.CreationTimestamp.Time, } - if !branchAllowed(provider.Spec.AllowedBranches, branch.Spec.Branch) { + // Admission comes before the proof, so a GitBranch no grant admits never opens a push session + // with the provider's credential. + admitted, err := authz.GitBranchClaimAdmitted(ctx, r.Client, branch, &provider) + if err != nil { + return nil, 0, err + } + if !admitted.Allowed { r.observeClaim(key, claimant, false) - rd.stalled(GitBranchReasonBranchNotAllowed, fmt.Sprintf( - "Branch '%s' does not match any pattern in allowedBranches list %v of GitProvider '%s'", - branch.Spec.Branch, provider.Spec.AllowedBranches, branch.GitProviderKey())) + rd.stalled(admitted.Reason, admitted.Message) return rd, 0, nil } @@ -390,16 +397,6 @@ func (r *GitBranchReconciler) finalize(ctx context.Context, branch *configbutler return client.IgnoreNotFound(r.Update(ctx, update)) } -// branchAllowed reports whether branch matches one of the GitProvider's allowedBranches patterns. -func branchAllowed(patterns []string, branch string) bool { - for _, pattern := range patterns { - if match, err := filepath.Match(pattern, branch); err == nil && match { - return true - } - } - return false -} - // parentBranchNotFoundMessage says which parent is missing and what fixes it. func parentBranchNotFoundMessage(branch *configbutleraiv1alpha3.GitBranch, missing string) string { switch { diff --git a/internal/controller/gitbranch_controller_test.go b/internal/controller/gitbranch_controller_test.go index 7914a655..d81fd058 100644 --- a/internal/controller/gitbranch_controller_test.go +++ b/internal/controller/gitbranch_controller_test.go @@ -29,16 +29,23 @@ const gitBranchTestURL = "https://github.com/acme/platform.git" var gitBranchEpoch = time.Date(2026, 10, 21, 12, 0, 0, 0, time.UTC) -// branchProvider is a GitProvider in namespace for the test repository, allowing branches, Ready -// unless told otherwise. It has no secretRef, so its credential resolves to anonymous and the -// fixture's prover decides what that credential may do. -func branchProvider(namespace, name string, allowed ...string) *configbutleraiv1alpha3.GitProvider { +// branchProvider is a GitProvider in namespace for the test repository, letting GitBranches in its +// own namespace claim the allowed branch patterns (every branch when none are given), Ready unless +// told otherwise. It has no secretRef, so its credential resolves to anonymous and the fixture's +// prover decides what that credential may do. +func branchProvider( + namespace, name string, + allowed ...configbutleraiv1alpha3.BranchPattern, +) *configbutleraiv1alpha3.GitProvider { if len(allowed) == 0 { - allowed = []string{"*"} + allowed = []configbutleraiv1alpha3.BranchPattern{"*"} } return &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: k8stypes.UID("uid-" + name)}, - Spec: configbutleraiv1alpha3.GitProviderSpec{URL: gitBranchTestURL, AllowedBranches: allowed}, + Spec: configbutleraiv1alpha3.GitProviderSpec{URL: gitBranchTestURL, + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + {From: configbutleraiv1alpha3.GrantFromSame, Branches: allowed}, + }}, Status: configbutleraiv1alpha3.GitProviderStatus{Conditions: []metav1.Condition{{ Type: ConditionTypeReady, Status: metav1.ConditionTrue, Reason: ReasonSucceeded, LastTransitionTime: metav1.Now(), @@ -55,6 +62,7 @@ func branchObject(namespace, name, provider, branch string, age time.Duration) * }, Spec: configbutleraiv1alpha3.GitBranchSpec{ GitProviderRef: meta.NamespacedObjectReference{Name: provider}, Branch: branch, + AllowedGitTargets: writersFromSame(), }, } } @@ -210,7 +218,8 @@ func TestGitBranch_TheHolderKeepsItsBranchWhenItsCredentialFails(t *testing.T) { assert.Equal(t, GitBranchReasonBranchConflict, readyOf(younger).Reason) } -// TestGitBranch_TheBranchMustBeAllowed: allowedBranches globs moved here from the GitTarget. +// TestGitBranch_TheBranchMustBeAllowed: a branch matching no pattern the grants give the +// GitBranch's namespace is refused, and never claimed. func TestGitBranch_TheBranchMustBeAllowed(t *testing.T) { for branch, allowed := range map[string]bool{ "main": true, "develop": true, "feature/login": true, "release/v1.0": true, @@ -230,7 +239,7 @@ func TestGitBranch_TheBranchMustBeAllowed(t *testing.T) { assert.Equal(t, GitBranchReasonBranchNotAllowed, readyOf(got).Reason) assert.True(t, conditionIsTrue(got.Status.Conditions, ConditionTypeStalled)) _, _, held := f.workers.Claims().HeldBy(ctrlclient.ObjectKeyFromObject(got)) - assert.False(t, held, "a branch outside allowedBranches is never claimed") + assert.False(t, held, "a branch outside the granted patterns is never claimed") }) } } diff --git a/internal/controller/gitprovider_controller.go b/internal/controller/gitprovider_controller.go index 5c61c701..d1c15628 100644 --- a/internal/controller/gitprovider_controller.go +++ b/internal/controller/gitprovider_controller.go @@ -102,7 +102,6 @@ func (r *GitProviderReconciler) reconcileGitProvider( "name", gitProvider.Name, "namespace", gitProvider.Namespace, "url", gitProvider.Spec.URL, - "allowedBranches", gitProvider.Spec.AllowedBranches, "generation", gitProvider.Generation, "resourceVersion", gitProvider.ResourceVersion) diff --git a/internal/controller/gitprovider_controller_test.go b/internal/controller/gitprovider_controller_test.go index 6a89fd58..a3109cf5 100644 --- a/internal/controller/gitprovider_controller_test.go +++ b/internal/controller/gitprovider_controller_test.go @@ -315,8 +315,13 @@ var _ = Describe("GitProvider Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "git@github.com:test/repo.git", - AllowedBranches: []string{"main"}, + URL: "git@github.com:test/repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, + }, + }, SecretRef: &meta.LocalObjectReference{ Name: "nonexistent-secret", }, @@ -376,8 +381,13 @@ var _ = Describe("GitProvider Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "git@github.com:test/repo.git", - AllowedBranches: []string{"main"}, + URL: "git@github.com:test/repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, + }, + }, SecretRef: &meta.LocalObjectReference{ Name: "malformed-secret", }, @@ -436,8 +446,13 @@ var _ = Describe("GitProvider Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "git@github.com:test/repo.git", - AllowedBranches: []string{"main"}, + URL: "git@github.com:test/repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, + }, + }, Commit: &configbutleraiv1alpha3.CommitSpec{ Signing: &configbutleraiv1alpha3.CommitSigningSpec{ SecretRef: meta.LocalObjectReference{ diff --git a/internal/controller/gitprovider_immutability_test.go b/internal/controller/gitprovider_immutability_test.go index 7f570165..d5828b12 100644 --- a/internal/controller/gitprovider_immutability_test.go +++ b/internal/controller/gitprovider_immutability_test.go @@ -24,25 +24,30 @@ var _ = Describe("GitProvider URL Immutability", func() { interval = time.Millisecond * 250 ) - It("rejects a change to the URL but allows changing allowedBranches", func() { + It("rejects a change to the URL but allows changing allowedGitBranches", func() { ctx := context.Background() key := types.NamespacedName{Name: "immutable-provider", Namespace: "default"} provider := &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: key.Name, Namespace: key.Namespace}, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://example.com/repo.git", - AllowedBranches: []string{"main"}, + URL: "https://example.com/repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, + }, + }, }, } Expect(k8sClient.Create(ctx, provider)).Should(Succeed()) DeferCleanup(func() { _ = k8sClient.Delete(ctx, provider) }) - // allowedBranches is operational and mutable: widening the set succeeds. + // allowedGitBranches is operational and mutable: widening the set succeeds. Eventually(func(g Gomega) { current := &configbutleraiv1alpha3.GitProvider{} g.Expect(k8sClient.Get(ctx, key, current)).To(Succeed()) - current.Spec.AllowedBranches = []string{"main", "develop"} + current.Spec.AllowedGitBranches[0].Branches = []configbutleraiv1alpha3.BranchPattern{"main", "develop"} g.Expect(k8sClient.Update(ctx, current)).To(Succeed()) }, timeout, interval).Should(Succeed()) diff --git a/internal/controller/gittarget_controller.go b/internal/controller/gittarget_controller.go index 754831e5..7d689cb4 100644 --- a/internal/controller/gittarget_controller.go +++ b/internal/controller/gittarget_controller.go @@ -473,9 +473,9 @@ func (r *GitTargetReconciler) evaluateValidatedGate( // KubeProvider now, not here — the GitTarget only NAMES its source cluster. The // KubeProvider's readiness is projected onto the GitTarget as a separate condition. - // Namespace authorization: a GitTarget may reference a KubeProvider only from the provider's - // own namespace or one its spec.accessFrom admits, and only while the provider holds its audit - // route. Enforced HERE and only here, on every reconcile — which + // Namespace authorization: a GitTarget may reference a KubeProvider only while a grant in its + // spec.allowedGitTargets admits the GitTarget's namespace, and only while the provider holds its + // audit route. Enforced HERE and only here, on every reconcile — which // also covers a policy tightened after the GitTarget was created. Failing this gate returns // before DeclareForGitTarget below, so an unauthorized target starts no watch and writes no Git. authorized, authReason, authMsg, authErr := r.checkSourceAuthorization(ctx, target) @@ -1439,8 +1439,8 @@ func (r *GitTargetReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(r.gitDestinationToGitTargets), builder.WithPredicates(gitBranchReadyOrSpecChanged()), ). - // A GitProvider's accessFrom decides which namespaces may reference its GitBranches, so a - // changed or freshly applied provider re-runs the GitTargets on those GitBranches. + // A GitProvider's grants decide which GitBranches may claim through it, so a changed or + // freshly applied provider re-runs the GitTargets on those GitBranches. // GenerationChangedPredicate ignores the status-only updates its controller writes itself. Watches( &configbutleraiv1alpha3.GitProvider{}, @@ -1457,7 +1457,7 @@ func (r *GitTargetReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(r.kubeProviderToGitTargets), builder.WithPredicates(kubeProviderReadyOrSpecChanged()), ). - // React to a Namespace's LABELS changing: a KubeProvider's accessFrom selector is + // React to a Namespace's LABELS changing: a Selector grant is // evaluated against namespace labels, so a label change can grant or revoke a GitTarget's // authorization. Re-enqueue the GitTargets in that namespace so the reconcile-time refusal // converges instead of waiting for the periodic reconcile. LabelChangedPredicate ignores the @@ -1597,7 +1597,7 @@ func gitTargetsOn( // gitDestinationToGitTargets maps a GitBranch or GitProvider event to every GitTarget writing // through it (gitTargetsOn): a GitBranch taking or losing its branch changes whether they can be -// wired, and a GitProvider's accessFrom decides which of their namespaces may reference it. +// wired, and a GitBranch's allowedGitTargets decide which of their namespaces may reference it. func (r *GitTargetReconciler) gitDestinationToGitTargets( ctx context.Context, obj client.Object, @@ -1615,8 +1615,8 @@ func (r *GitTargetReconciler) gitDestinationToGitTargets( } // kubeProviderToGitTargets maps a KubeProvider event to every GitTarget that references it as -// its source, across ALL namespaces (accessFrom may admit any of them). It re-enqueues dependents -// when the provider's Ready flips or its accessFrom policy changes, so the projected +// its source, across ALL namespaces (a grant may admit any of them). It re-enqueues dependents +// when the provider's Ready flips or its grants change, so the projected // KubeProviderReady and the namespace-authorization refusal converge without waiting for the // periodic reconcile. func (r *GitTargetReconciler) kubeProviderToGitTargets( diff --git a/internal/controller/gittarget_controller_test.go b/internal/controller/gittarget_controller_test.go index 04c7d586..815902a7 100644 --- a/internal/controller/gittarget_controller_test.go +++ b/internal/controller/gittarget_controller_test.go @@ -70,8 +70,13 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, + }, + }, SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, @@ -130,8 +135,13 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main", "develop"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"main", "develop"}, + }, + }, SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, @@ -250,8 +260,13 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, + }, + }, SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, @@ -339,8 +354,13 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, + }, + }, SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, @@ -458,8 +478,13 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, + }, + }, }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -524,8 +549,13 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, + }, + }, }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -602,8 +632,13 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, + }, + }, }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -684,8 +719,13 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, + }, + }, }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -757,8 +797,13 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, + }, + }, }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -837,8 +882,13 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedBranches: []string{"main"}, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, + }, + }, }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -915,6 +965,7 @@ func createGitBranchFor(ctx context.Context, provider *configbutleraiv1alpha3.Gi }, Spec: configbutleraiv1alpha3.GitBranchSpec{ GitProviderRef: meta.NamespacedObjectReference{Name: provider.Name}, Branch: branch, + AllowedGitTargets: writersFromSame(), }, } Expect(k8sClient.Create(ctx, gitBranch)).Should(Succeed()) diff --git a/internal/controller/gittarget_gitbranch_test.go b/internal/controller/gittarget_gitbranch_test.go index 33d9d474..49280466 100644 --- a/internal/controller/gittarget_gitbranch_test.go +++ b/internal/controller/gittarget_gitbranch_test.go @@ -27,9 +27,8 @@ import ( "github.com/ConfigButler/gitops-reverser/internal/watch" ) -// destinationFixture is a GitTarget team-a/apps on the GitBranch platform/main, which team-a may -// reference through the GitProvider platform/acme's accessFrom, with a started worker manager and a -// router with no watch plane. +// destinationFixture is a GitTarget team-a/apps on the GitBranch platform/main, whose +// allowedGitTargets admit team-a, with a started worker manager and a router with no watch plane. type destinationFixture struct { r *GitTargetReconciler client ctrlclient.Client @@ -43,8 +42,10 @@ func newDestinationFixture(t *testing.T) *destinationFixture { require.NoError(t, clientgoscheme.AddToScheme(scheme)) require.NoError(t, configbutleraiv1alpha3.AddToScheme(scheme)) provider := branchProvider("platform", "acme") - provider.Spec.AccessFrom = &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}} branch := branchObject("platform", "main", "acme", "main", time.Hour) + branch.Spec.AllowedGitTargets = []configbutleraiv1alpha3.NamespaceGrant{ + {From: configbutleraiv1alpha3.GrantFromNames, Names: grantNames("team-a")}, + } target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "apps"}, Spec: configbutleraiv1alpha3.GitTargetSpec{ @@ -97,8 +98,8 @@ func TestReconcile_AGitBranchThatHoldsNothingKeepsTheTargetWaiting(t *testing.T) } // TestReconcile_ATargetOnAHeldGitBranchInAnotherNamespaceIsWired: the GitBranch holds the branch and -// its GitProvider admits the target's namespace, so the target is wired to the holder's worker with -// that provider's identity. +// admits the target's namespace, so the target is wired to the holder's worker with its provider's +// identity. func TestReconcile_ATargetOnAHeldGitBranchInAnotherNamespaceIsWired(t *testing.T) { f := newDestinationFixture(t) f.workers.Claims().Observe(git.BranchKeyFor(gitBranchTestURL, "main"), git.BranchClaimant{ @@ -121,22 +122,22 @@ func TestReconcile_ATargetOnAHeldGitBranchInAnotherNamespaceIsWired(t *testing.T assert.Equal(t, "main", worker.Branch) } -// TestReconcile_AGitBranchWhoseProviderDoesNotAdmitTheNamespace: the cross-namespace reference is -// decided by the branch's GitProvider, against the TARGET's namespace. -func TestReconcile_AGitBranchWhoseProviderDoesNotAdmitTheNamespace(t *testing.T) { +// TestReconcile_AGitBranchThatDoesNotAdmitTheNamespace: who may write through a GitBranch is decided +// by the GitBranch's spec.allowedGitTargets, against the TARGET's namespace. +func TestReconcile_AGitBranchThatDoesNotAdmitTheNamespace(t *testing.T) { f := newDestinationFixture(t) - provider := &configbutleraiv1alpha3.GitProvider{} - require.NoError(t, f.client.Get(context.Background(), k8stypes.NamespacedName{Namespace: "platform", Name: "acme"}, - provider)) - provider.Spec.AccessFrom = nil - require.NoError(t, f.client.Update(context.Background(), provider)) + branch := &configbutleraiv1alpha3.GitBranch{} + require.NoError(t, f.client.Get(context.Background(), k8stypes.NamespacedName{Namespace: "platform", Name: "main"}, + branch)) + branch.Spec.AllowedGitTargets = nil + require.NoError(t, f.client.Update(context.Background(), branch)) _, got := f.reconcile(t) validated := apimeta.FindStatusCondition(got.Status.Conditions, GitTargetConditionValidated) require.NotNil(t, validated) assert.Equal(t, GitTargetReasonNamespaceNotAuthorized, validated.Reason) - assert.Contains(t, validated.Message, "GitBranch platform/main") + assert.Contains(t, validated.Message, "GitBranch platform/main admits no namespace in spec.allowedGitTargets") } // TestGitBranchWatches_MapToWhatTheyChange: a GitProvider event reaches the GitBranches in its diff --git a/internal/controller/gittarget_placement_validation_test.go b/internal/controller/gittarget_placement_validation_test.go index 35aae105..206be1a5 100644 --- a/internal/controller/gittarget_placement_validation_test.go +++ b/internal/controller/gittarget_placement_validation_test.go @@ -262,7 +262,9 @@ func TestEvaluateValidatedGate_InvalidPlacementPolicy(t *testing.T) { provider := &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "provider-a", Namespace: ns}, Spec: configbutleraiv1alpha3.GitProviderSpec{ - AllowedBranches: []string{"main"}, + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + {From: configbutleraiv1alpha3.GrantFromSame, Branches: []configbutleraiv1alpha3.BranchPattern{"main"}}, + }, }, } target := &configbutleraiv1alpha3.GitTarget{ diff --git a/internal/controller/gittarget_shared_branch_test.go b/internal/controller/gittarget_shared_branch_test.go index 52c6fd5f..13480a97 100644 --- a/internal/controller/gittarget_shared_branch_test.go +++ b/internal/controller/gittarget_shared_branch_test.go @@ -48,7 +48,8 @@ func sharedProviderReconciler(t *testing.T, objects ...ctrlclient.Object) *GitTa require.NoError(t, configbutleraiv1alpha3.AddToScheme(scheme)) // The KubeProvider every GitTarget here mirrors through admits every namespace. local := admitting(claimLocalProvider) - local.Spec.AccessFrom = &configbutleraiv1alpha3.NamespaceMatcher{Selector: &metav1.LabelSelector{}} + local.Spec.AllowedGitTargets = []configbutleraiv1alpha3.SourceGrant{{From: configbutleraiv1alpha3.GrantFromAll, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.NamespaceVariable}}} objects = append(objects, local) for _, ns := range []string{"platform", "team-b"} { objects = append(objects, &configbutleraiv1alpha3.GitProvider{ @@ -80,15 +81,16 @@ func TestCheckForConflicts_ASharedGitBranchIsOneBranchAcrossNamespaces(t *testin } // TestCheckForConflicts_AnUnadmittedTargetReservesNoFolder: a GitTarget whose namespace the -// GitBranch's GitProvider does not admit writes nothing, so it cannot hold a folder against the -// targets that may. Without this, any namespace that knows a GitBranch's name could create a target -// at "." first and block every folder on that branch. Once the provider admits it, the older target -// owns its path as any other does. +// GitBranch does not admit writes nothing, so it cannot hold a folder against the targets that may. +// Without this, any namespace that knows a GitBranch's name could create a target at "." first and +// block every folder on that branch. Once the GitBranch admits it, the older target owns its path as +// any other does. func TestCheckForConflicts_AnUnadmittedTargetReservesNoFolder(t *testing.T) { branch := &configbutleraiv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: gitBranchNameForTest("monorepo", "main")}, Spec: configbutleraiv1alpha3.GitBranchSpec{ GitProviderRef: meta.NamespacedObjectReference{Name: "monorepo"}, Branch: "main", + AllowedGitTargets: writersFromSame(), }, } squatter := sharedProviderTarget("intruder", "everything", "platform", ".", 2*time.Hour) @@ -97,15 +99,13 @@ func TestCheckForConflicts_AnUnadmittedTargetReservesNoFolder(t *testing.T) { r := sharedProviderReconciler(t, branch, squatter, owner) conflict, msg, _, err := r.checkForConflicts(context.Background(), owner) require.NoError(t, err) - assert.False(t, conflict, "a target its GitProvider does not admit must not hold a folder: %s", msg) + assert.False(t, conflict, "a target its GitBranch does not admit must not hold a folder: %s", msg) - var provider configbutleraiv1alpha3.GitProvider - require.NoError( - t, - r.Get(context.Background(), ctrlclient.ObjectKey{Namespace: "platform", Name: "monorepo"}, &provider), - ) - provider.Spec.AccessFrom = &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"intruder"}} - require.NoError(t, r.Update(context.Background(), &provider)) + var stored configbutleraiv1alpha3.GitBranch + require.NoError(t, r.Get(context.Background(), ctrlclient.ObjectKeyFromObject(branch), &stored)) + stored.Spec.AllowedGitTargets = append(stored.Spec.AllowedGitTargets, configbutleraiv1alpha3.NamespaceGrant{ + From: configbutleraiv1alpha3.GrantFromNames, Names: grantNames("intruder")}) + require.NoError(t, r.Update(context.Background(), &stored)) conflict, msg, reason, err := r.checkForConflicts(context.Background(), owner) require.NoError(t, err) diff --git a/internal/controller/gittarget_source_cluster.go b/internal/controller/gittarget_source_cluster.go index 7ea714bd..42fd2b03 100644 --- a/internal/controller/gittarget_source_cluster.go +++ b/internal/controller/gittarget_source_cluster.go @@ -13,7 +13,8 @@ import ( ) // GitTargetReasonNamespaceNotAuthorized is the Validated=False reason when a GitTarget's namespace -// is not admitted by its referenced KubeProvider's spec.accessFrom. It runs on every +// is not admitted by its referenced KubeProvider's spec.allowedGitTargets, or by its GitBranch's. +// It runs on every // reconcile, so a policy tightened AFTER a GitTarget was created stops that target's watches too. const GitTargetReasonNamespaceNotAuthorized = authz.ReasonNamespaceNotAuthorized diff --git a/internal/controller/gittarget_source_cluster_test.go b/internal/controller/gittarget_source_cluster_test.go index ccd7028c..b5224ecd 100644 --- a/internal/controller/gittarget_source_cluster_test.go +++ b/internal/controller/gittarget_source_cluster_test.go @@ -118,11 +118,12 @@ func scScheme(t *testing.T) *runtime.Scheme { } func TestCheckSourceAuthorization(t *testing.T) { - // The provider lives in "platform", so team-a reaches it only through spec.accessFrom. - provider := func(policy *configbutleraiv1alpha3.NamespaceMatcher) *configbutleraiv1alpha3.KubeProvider { + // The provider lives in "platform", so team-a reaches it only through spec.allowedGitTargets. + provider := func(grant configbutleraiv1alpha3.SourceGrant) *configbutleraiv1alpha3.KubeProvider { p := remoteKubeProvider("kc", "") p.Namespace = "platform" - p.Spec.AccessFrom = policy + grant.SourceNamespaces = []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.NamespaceVariable} + p.Spec.AllowedGitTargets = []configbutleraiv1alpha3.SourceGrant{grant} return p } ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-a", Labels: map[string]string{"tier": "trusted"}}} @@ -159,21 +160,34 @@ func TestCheckSourceAuthorization(t *testing.T) { { name: "provider allows the namespace by name", objects: []client.Object{ - provider(&configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), ns, + provider( + configbutleraiv1alpha3.SourceGrant{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames("team-a"), + }, + ), + ns, }, providerRef: "prod-eu-1", wantAuthorized: true, }, { name: "provider does not allow the namespace -> refused", objects: []client.Object{ - provider(&configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-b"}}), ns, + provider( + configbutleraiv1alpha3.SourceGrant{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames("team-b"), + }, + ), + ns, }, providerRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, }, { name: "provider allows the namespace by label selector", objects: []client.Object{ - provider(&configbutleraiv1alpha3.NamespaceMatcher{ + provider(configbutleraiv1alpha3.SourceGrant{ + From: configbutleraiv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "trusted"}}, }), ns, }, @@ -182,9 +196,10 @@ func TestCheckSourceAuthorization(t *testing.T) { { // A selector the API accepted but that cannot compile must FAIL CLOSED. Treating an // unevaluatable policy as "allow" would hand a namespace access it was never granted. - name: "invalid accessFrom selector -> refused, not allowed", + name: "invalid selector -> refused, not allowed", objects: []client.Object{ - provider(&configbutleraiv1alpha3.NamespaceMatcher{ + provider(configbutleraiv1alpha3.SourceGrant{ + From: configbutleraiv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{ MatchExpressions: []metav1.LabelSelectorRequirement{ {Key: "tier", Operator: "BogusOperator", Values: []string{"trusted"}}, @@ -199,14 +214,20 @@ func TestCheckSourceAuthorization(t *testing.T) { // no labels. A name-based allow still works; a selector-only policy then denies. name: "namespace object absent -> evaluated with no labels, name allow still holds", objects: []client.Object{ - provider(&configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}), + provider( + configbutleraiv1alpha3.SourceGrant{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames("team-a"), + }, + ), }, providerRef: "prod-eu-1", wantAuthorized: true, }, { name: "namespace object absent -> selector-only policy denies (no labels to match)", objects: []client.Object{ - provider(&configbutleraiv1alpha3.NamespaceMatcher{ + provider(configbutleraiv1alpha3.SourceGrant{ + From: configbutleraiv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "trusted"}}, }), }, @@ -229,11 +250,13 @@ func TestCheckSourceAuthorization(t *testing.T) { } // TestCheckSourceAuthorization_OwnNamespaceAndAuditRoute covers the two admission rules a -// namespaced KubeProvider adds: its OWN namespace is always admitted, with no accessFrom and with -// the reference's namespace omitted; and a provider whose audit route another namespace holds is -// denied to every consumer, its own namespace included. +// namespaced KubeProvider adds: its OWN namespace is admitted only by a grant, such as from: Same, +// with the reference's namespace omitted; and a provider whose audit route another namespace holds +// is denied to every consumer, its own namespace included. func TestCheckSourceAuthorization_OwnNamespaceAndAuditRoute(t *testing.T) { own := inClusterKubeProvider("local") + own.Spec.AllowedGitTargets = []configbutleraiv1alpha3.SourceGrant{{From: configbutleraiv1alpha3.GrantFromSame, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.NamespaceVariable}}} target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: kpNamespace}, Spec: configbutleraiv1alpha3.GitTargetSpec{ @@ -241,13 +264,23 @@ func TestCheckSourceAuthorization_OwnNamespaceAndAuditRoute(t *testing.T) { }, } - t.Run("own namespace is admitted without accessFrom", func(t *testing.T) { + t.Run("own namespace is admitted by from: Same", func(t *testing.T) { cl := fake.NewClientBuilder().WithScheme(scScheme(t)).WithObjects(own).Build() ok, _, _, err := (&GitTargetReconciler{Client: cl}).checkSourceAuthorization(context.Background(), target) require.NoError(t, err) assert.True(t, ok) }) + t.Run("own namespace is refused with no grant", func(t *testing.T) { + cl := fake.NewClientBuilder().WithScheme(scScheme(t)).WithObjects(inClusterKubeProvider("local")).Build() + ok, reason, msg, err := (&GitTargetReconciler{Client: cl}).checkSourceAuthorization( + context.Background(), target) + require.NoError(t, err) + assert.False(t, ok) + assert.Equal(t, GitTargetReasonNamespaceNotAuthorized, reason) + assert.Contains(t, msg, "admits no namespace in spec.allowedGitTargets") + }) + t.Run("a route held by another namespace is refused", func(t *testing.T) { // "team-b.edge" names team-b, which holds it while a provider there carries it, however // much older the team-a provider spelling it out is. @@ -282,7 +315,7 @@ func TestCheckSourceAuthorization_ReadErrorsRequeue(t *testing.T) { } provider := remoteKubeProvider("kc", "") provider.Namespace = "platform" - provider.Spec.AccessFrom = &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}} + provider.Spec.AllowedGitTargets = mirrorGrants("team-a") tests := []struct { name string @@ -373,10 +406,14 @@ func TestReconcile_UnauthorizedNamespaceStartsNoWatch(t *testing.T) { // The provider lives in platform and admits team-b only; the GitTarget lives in team-a. provider := remoteKubeProvider("kc", "") provider.Namespace = providerKey.Namespace - provider.Spec.AccessFrom = &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-b"}} + provider.Spec.AllowedGitTargets = mirrorGrants("team-b") gitProvider := &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "gp", Namespace: ns}, - Spec: configbutleraiv1alpha3.GitProviderSpec{AllowedBranches: []string{"main"}}, + Spec: configbutleraiv1alpha3.GitProviderSpec{ + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + {From: configbutleraiv1alpha3.GrantFromSame, Branches: []configbutleraiv1alpha3.BranchPattern{"main"}}, + }, + }, } target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: ns, UID: "gt-uid"}, @@ -411,7 +448,7 @@ func TestReconcile_UnauthorizedNamespaceStartsNoWatch(t *testing.T) { require.NotNil(t, validated, "Validated condition must be set") assert.Equal(t, metav1.ConditionFalse, validated.Status) assert.Equal(t, GitTargetReasonNamespaceNotAuthorized, validated.Reason) - assert.Contains(t, validated.Message, "not permitted to reference") + assert.Contains(t, validated.Message, "admits team-b in spec.allowedGitTargets") // The data plane is reported blocked, not merely un-ready... streams := apimeta.FindStatusCondition(got.Status.Conditions, GitTargetConditionStreamsRunning) diff --git a/internal/controller/grant_helpers_test.go b/internal/controller/grant_helpers_test.go new file mode 100644 index 00000000..d5525b3a --- /dev/null +++ b/internal/controller/grant_helpers_test.go @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" +) + +// grantNames converts namespace names into a grant's names. +func grantNames(namespaces ...string) []configbutleraiv1alpha3.GrantNamespace { + names := make([]configbutleraiv1alpha3.GrantNamespace, 0, len(namespaces)) + for _, ns := range namespaces { + names = append(names, configbutleraiv1alpha3.GrantNamespace(ns)) + } + return names +} + +// mirrorGrants admits the named namespaces to mirror their own namespace and every cluster-scoped +// resource, the reach a provider admitting them had before grants were scoped. +func mirrorGrants(namespaces ...string) []configbutleraiv1alpha3.SourceGrant { + return []configbutleraiv1alpha3.SourceGrant{{ + From: configbutleraiv1alpha3.GrantFromNames, Names: grantNames(namespaces...), + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.NamespaceVariable}, + ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}, + }} +} + +// applierGrants admits the named namespaces to hold appliers in scope. +func applierGrants( + scope []configbutleraiv1alpha3.ScopeNamespace, namespaces ...string, +) []configbutleraiv1alpha3.ApplierGrant { + return []configbutleraiv1alpha3.ApplierGrant{{ + From: configbutleraiv1alpha3.GrantFromNames, Names: grantNames(namespaces...), ApplierNamespaces: scope, + }} +} + +// writersFromSame is the allowedGitTargets of a GitBranch shared with its own namespace only. +func writersFromSame() []configbutleraiv1alpha3.NamespaceGrant { + return []configbutleraiv1alpha3.NamespaceGrant{{From: configbutleraiv1alpha3.GrantFromSame}} +} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index f125506d..44686f81 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -197,12 +197,21 @@ var _ = BeforeSuite(func() { // Ship the suite's shared in-cluster KubeProvider, so a GitTarget or applier claim naming it // (testKubeProviderRef) passes the reconcile-time hard gate that REQUIRES the referenced - // KubeProvider to exist. Its empty selector admits every namespace the specs create. + // KubeProvider to exist. Its grants admit every namespace the specs create, to mirror its own + // namespace and every cluster-scoped resource, and to hold appliers anywhere. Expect(k8sClient.Create(ctx, &configbutleraiv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: testKubeProviderNamespace, Name: testKubeProviderName}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ - InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, - AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{Selector: &metav1.LabelSelector{}}, + InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: []configbutleraiv1alpha3.SourceGrant{{ + From: configbutleraiv1alpha3.GrantFromAll, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.NamespaceVariable}, + ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}, + }}, + AllowedApplierClaims: []configbutleraiv1alpha3.ApplierGrant{{ + From: configbutleraiv1alpha3.GrantFromAll, + ApplierNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"*"}, + }}, }, })).To(Succeed()) diff --git a/internal/controller/superseded_fields_admission_test.go b/internal/controller/superseded_fields_admission_test.go index e2b73b57..7de723ac 100644 --- a/internal/controller/superseded_fields_admission_test.go +++ b/internal/controller/superseded_fields_admission_test.go @@ -69,11 +69,9 @@ var _ = Describe("Superseded source-scope fields", func() { provider := &configbutleraiv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: "renamed-source-scope", Namespace: "default"}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ - SecretRef: &meta.SecretKeyReference{Name: "kc"}, - AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{Names: []string{"team-a"}}, - AllowCrossNamespace: &configbutleraiv1alpha3.KubeProviderCrossNamespace{ - Sources: true, Appliers: true, - }, + SecretRef: &meta.SecretKeyReference{Name: "kc"}, + AllowedGitTargets: mirrorGrants("team-a"), + AllowedApplierClaims: applierGrants([]configbutleraiv1alpha3.ScopeNamespace{"*"}, "team-a"), }, } Expect(k8sClient.Create(ctx, provider)).To(Succeed()) @@ -81,9 +79,9 @@ var _ = Describe("Superseded source-scope fields", func() { var storedProvider configbutleraiv1alpha3.KubeProvider Expect(k8sClient.Get(ctx, provider.Key(), &storedProvider)).To(Succeed()) - Expect(storedProvider.Spec.AccessFrom).NotTo(BeNil(), "accessFrom must round-trip, not be pruned") - Expect(storedProvider.AllowsCrossNamespaceSources()).To(BeTrue(), "allowCrossNamespace must round-trip") - Expect(storedProvider.AllowsCrossNamespaceAppliers()).To(BeTrue()) + Expect(storedProvider.Spec.AllowedGitTargets).To(Equal(provider.Spec.AllowedGitTargets), + "allowedGitTargets must round-trip, not be pruned") + Expect(storedProvider.Spec.AllowedApplierClaims).To(Equal(provider.Spec.AllowedApplierClaims)) target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "renamed-commit", Namespace: "default"}, @@ -185,9 +183,14 @@ var _ = Describe("Reference names must not be empty", func() { provider := &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "empty-secret-name", Namespace: "default"}, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://example.com/repo.git", - AllowedBranches: []string{"main"}, - SecretRef: &meta.LocalObjectReference{Name: ""}, + URL: "https://example.com/repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, + }, + }, + SecretRef: &meta.LocalObjectReference{Name: ""}, }, } diff --git a/internal/controller/watchrule_controller.go b/internal/controller/watchrule_controller.go index 7f0e50be..87a0cce4 100644 --- a/internal/controller/watchrule_controller.go +++ b/internal/controller/watchrule_controller.go @@ -280,11 +280,10 @@ func (r *WatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(r.gitTargetToWatchRules), builder.WithPredicates(gitTargetReadyProjectionChanged()), ). - // React to a KubeProvider's allowCrossNamespace.sources (or accessFrom) - // changing. The GitTarget->WatchRules edge above still cannot be relied on to carry this: a + // React to a KubeProvider's grants changing. The GitTarget->WatchRules edge above still cannot be relied on to carry this: a // KubeProvider change reaches the GitTarget as a status update that need not move the // GitTarget's readiness at all, and only a readiness move re-enqueues the rules. Without - // this mapper, flipping the delegation flag would leave every affected WatchRule + // this mapper, narrowing a grant would leave every affected WatchRule // un-reconciled until its periodic requeue — so a REVOCATION would take minutes. Watches( &configbutleraiv1alpha3.KubeProvider{}, diff --git a/internal/controller/watchrule_controller_test.go b/internal/controller/watchrule_controller_test.go index 0f0a2169..f9dfd392 100644 --- a/internal/controller/watchrule_controller_test.go +++ b/internal/controller/watchrule_controller_test.go @@ -63,8 +63,13 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test/repo.git", - AllowedBranches: []string{"*"}, + URL: "https://github.com/test/repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"*"}, + }, + }, SecretRef: &meta.LocalObjectReference{ Name: "git-credentials", }, @@ -179,8 +184,13 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/octocat/Hello-World", - AllowedBranches: []string{"main"}, + URL: "https://github.com/octocat/Hello-World", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, + }, + }, }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -280,7 +290,7 @@ var _ = Describe("WatchRule Controller", func() { By("Verifying the legacy own-namespace rule is authorized without any policy or flag") Expect(sourceAuthorized.Status).To(Equal(metav1.ConditionTrue)) - Expect(sourceAuthorized.Reason).To(Equal(WatchRuleReasonLegacySourceNamespace)) + Expect(sourceAuthorized.Reason).To(Equal(WatchRuleReasonSourceNamespaceAllowed)) for _, c := range updatedRule.Status.Conditions { if c.Type == ConditionTypeReady { diff --git a/internal/controller/watchrule_source_namespace.go b/internal/controller/watchrule_source_namespace.go index eac3b2d7..8b3d98de 100644 --- a/internal/controller/watchrule_source_namespace.go +++ b/internal/controller/watchrule_source_namespace.go @@ -16,19 +16,11 @@ import ( ) // SourceNamespaceAuthorized condition reasons, re-exported from internal/authz so the decision and -// the status surface can never drift apart. -// -// Three of the six went with the source-side selector: the condition can no longer be Unknown, -// because the gate reads only control-plane objects this reconcile already has, and it can no -// longer report an empty resolved scope, because "*" is one cluster-wide stream rather than a set -// that could come back empty. +// the status surface can never drift apart. The condition can never be Unknown: the gate reads only +// control-plane objects this reconcile already has. const ( - // WatchRuleReasonLegacySourceNamespace is the True reason when every item watches the rule's - // own namespace. - WatchRuleReasonLegacySourceNamespace = authz.ReasonLegacySourceNamespace - // WatchRuleReasonSourceNamespaceAllowed is the True reason when every item is admitted and at - // least one names a namespace other than the rule's own — an authorized override or the - // cluster-wide wildcard. + // WatchRuleReasonSourceNamespaceAllowed is the True reason when every item watches a namespace the + // KubeProvider's grants give the GitTarget. WatchRuleReasonSourceNamespaceAllowed = authz.ReasonSourceNamespaceAllowed // WatchRuleReasonSourceNamespaceNotAllowed is the TERMINAL False reason for a refusal. WatchRuleReasonSourceNamespaceNotAllowed = authz.ReasonSourceNamespaceNotAllowed @@ -94,8 +86,8 @@ func (r *WatchRuleReconciler) gateSourceNamespace( // asserts the terminal condition must also be able to assert the rule is already gone. // // The refusal is terminal (Stalled=True, Reconciling=False) rather than a retry: nothing this -// controller does will change the verdict. Recovery arrives as an EVENT — a KubeProvider flag or -// accessFrom change — through the mappers registered in SetupWithManager. +// controller does will change the verdict. Recovery arrives as an EVENT — a KubeProvider grant change +// or a Namespace label change — through the mappers registered in SetupWithManager. func (r *WatchRuleReconciler) refuseSourceNamespace( ctx context.Context, st *reconcileStatus, diff --git a/internal/controller/watchrule_source_namespace_test.go b/internal/controller/watchrule_source_namespace_test.go index 9d924a13..57e58755 100644 --- a/internal/controller/watchrule_source_namespace_test.go +++ b/internal/controller/watchrule_source_namespace_test.go @@ -50,15 +50,17 @@ func wrsnGitProvider() *configbutleraiv1alpha3.GitProvider { } } -func wrsnKubeProvider(delegate bool) *configbutleraiv1alpha3.KubeProvider { +// wrsnKubeProvider admits wrsnTenantNS to watch its own namespace, and every namespace when wide. +func wrsnKubeProvider(wide bool) *configbutleraiv1alpha3.KubeProvider { + grants := mirrorGrants(wrsnTenantNS) + if wide { + grants[0].SourceNamespaces = append(grants[0].SourceNamespaces, "*") + } return &configbutleraiv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: wrsnProvider, Namespace: wrsnProviderNS}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ - SecretRef: &meta.SecretKeyReference{Name: "kc"}, - AccessFrom: &configbutleraiv1alpha3.NamespaceMatcher{ - Names: []string{wrsnTenantNS}, - }, - AllowCrossNamespace: &configbutleraiv1alpha3.KubeProviderCrossNamespace{Sources: delegate}, + SecretRef: &meta.SecretKeyReference{Name: "kc"}, + AllowedGitTargets: grants, }, } } @@ -170,7 +172,7 @@ func TestReconcile_LegacyWatchRuleNeedsNoPolicyOrFlag(t *testing.T) { cond := wrsnCondition(t, f.reloadRule(ctx, t), ConditionTypeSourceNamespaceAuthorized) assert.Equal(t, metav1.ConditionTrue, cond.Status) - assert.Equal(t, WatchRuleReasonLegacySourceNamespace, cond.Reason) + assert.Equal(t, WatchRuleReasonSourceNamespaceAllowed, cond.Reason) } // TestReconcile_DeniedSourceNamespaceStartsNoWatch mirrors @@ -191,7 +193,7 @@ func TestReconcile_DeniedSourceNamespaceStartsNoWatch(t *testing.T) { cond := wrsnCondition(t, rule, ConditionTypeSourceNamespaceAuthorized) assert.Equal(t, metav1.ConditionFalse, cond.Status) assert.Equal(t, WatchRuleReasonSourceNamespaceNotAllowed, cond.Reason) - assert.Contains(t, cond.Message, "spec.allowCrossNamespace.sources", + assert.Contains(t, cond.Message, "spec.allowedGitTargets[].sourceNamespaces", "the message must name the fix") } @@ -259,11 +261,12 @@ func TestReconcile_RevokedSourceNamespaceRemovesTheCompiledRuleAndReplans(t *tes require.NoError(t, err) require.Equal(t, []string{wrsnRule}, f.compiledNames(), "precondition: the rule is compiled") - // The platform admin withdraws the delegation. + // The platform admin narrows the grant to the tenant's own namespace. var provider configbutleraiv1alpha3.KubeProvider require.NoError(t, f.client.Get(ctx, k8stypes.NamespacedName{Namespace: wrsnProviderNS, Name: wrsnProvider}, &provider)) - provider.Spec.AllowCrossNamespace = nil + provider.Spec.AllowedGitTargets[0].SourceNamespaces = []configbutleraiv1alpha3.ScopeNamespace{ + configbutleraiv1alpha3.NamespaceVariable} require.NoError(t, f.client.Update(ctx, &provider)) // Observe the world at the exact moment the data plane is replanned. @@ -401,7 +404,7 @@ func TestReconcile_WildcardWithoutDelegationIsRefused(t *testing.T) { cond := wrsnCondition(t, rule, ConditionTypeSourceNamespaceAuthorized) assert.Equal(t, metav1.ConditionFalse, cond.Status) assert.Equal(t, WatchRuleReasonSourceNamespaceNotAllowed, cond.Reason) - assert.Contains(t, cond.Message, "spec.allowCrossNamespace.sources") + assert.Contains(t, cond.Message, "spec.allowedGitTargets[].sourceNamespaces") assert.Equal(t, metav1.ConditionTrue, wrsnCondition(t, rule, ConditionTypeStalled).Status, "a refusal is terminal: nothing this controller does will change the verdict") } diff --git a/internal/controller/wiring/readiness_wiring_test.go b/internal/controller/wiring/readiness_wiring_test.go index 0ad17582..8db177c0 100644 --- a/internal/controller/wiring/readiness_wiring_test.go +++ b/internal/controller/wiring/readiness_wiring_test.go @@ -109,9 +109,9 @@ func startManager(t *testing.T, register func(manager.Manager) error) { } // seedTarget creates a namespace, a GitProvider, a KubeProvider and a GitTarget mirroring through -// it, and returns the namespace. The KubeProvider is in the target's own namespace, which it always -// admits: a ClusterWatchRule is refused (and its GitTargetReady overwritten with the refusal) unless -// the source cluster admits the target's namespace. +// it, and returns the namespace. The KubeProvider is in the target's own namespace and grants it +// every cluster-scoped resource: a ClusterWatchRule is refused (and its GitTargetReady overwritten +// with the refusal) unless the source cluster admits the target's namespace. func seedTarget(ctx context.Context, t *testing.T, name string) string { t.Helper() @@ -122,15 +122,20 @@ func seedTarget(ctx context.Context, t *testing.T, name string) string { require.NoError(t, k8sClient.Create(ctx, &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "provider", Namespace: namespace}, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test/repo.git", - AllowedBranches: []string{"*"}, - SecretRef: &meta.LocalObjectReference{Name: "git-credentials"}, + URL: "https://github.com/test/repo.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ + {From: configbutleraiv1alpha3.GrantFromSame, Branches: []configbutleraiv1alpha3.BranchPattern{"*"}}, + }, + SecretRef: &meta.LocalObjectReference{Name: "git-credentials"}, }, })) require.NoError(t, k8sClient.Create(ctx, &configbutleraiv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: "source", Namespace: namespace}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: []configbutleraiv1alpha3.SourceGrant{{From: configbutleraiv1alpha3.GrantFromSame, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.NamespaceVariable}, + ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}}}, }, })) require.NoError(t, k8sClient.Create(ctx, &configbutleraiv1alpha3.GitTarget{ diff --git a/internal/watch/bootstrap_admission_test.go b/internal/watch/bootstrap_admission_test.go index cb95a567..f310c839 100644 --- a/internal/watch/bootstrap_admission_test.go +++ b/internal/watch/bootstrap_admission_test.go @@ -51,12 +51,26 @@ func bootGitProvider() *configv1alpha3.GitProvider { } } -func bootKubeProvider(policy *configv1alpha3.NamespaceMatcher) *configv1alpha3.KubeProvider { +// mirrorGrants admits the named namespaces to mirror their own namespace and every cluster-scoped +// resource, the reach a provider admitting them had before grants were scoped. +func mirrorGrants(namespaces ...string) []configv1alpha3.SourceGrant { + names := make([]configv1alpha3.GrantNamespace, 0, len(namespaces)) + for _, ns := range namespaces { + names = append(names, configv1alpha3.GrantNamespace(ns)) + } + return []configv1alpha3.SourceGrant{{ + From: configv1alpha3.GrantFromNames, Names: names, + SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.NamespaceVariable}, + ClusterResources: []configv1alpha3.ClusterResourceIdentity{"*"}, + }} +} + +func bootKubeProvider(policy []configv1alpha3.SourceGrant) *configv1alpha3.KubeProvider { return &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: bootProviderNS, Name: bootProviderName}, Spec: configv1alpha3.KubeProviderSpec{ - SecretRef: &meta.SecretKeyReference{Name: "prod-eu-1-kubeconfig"}, - AccessFrom: policy, + SecretRef: &meta.SecretKeyReference{Name: "prod-eu-1-kubeconfig"}, + AllowedGitTargets: policy, }, } } @@ -96,7 +110,7 @@ func bootCompiledNames(m *Manager) []string { func TestBootstrapClusterWatchRule_RefusesUnauthorizedGitTargetNamespace(t *testing.T) { m := bootManager(t, bootGitTarget(), bootGitProvider(), - bootKubeProvider(&configv1alpha3.NamespaceMatcher{Names: []string{"some-other-namespace"}}), + bootKubeProvider(mirrorGrants("some-other-namespace")), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, ) @@ -126,7 +140,7 @@ func TestBootstrapClusterWatchRule_RefusesMissingKubeProvider(t *testing.T) { func TestBootstrapClusterWatchRule_SeedsAdmittedRule(t *testing.T) { m := bootManager(t, bootGitTarget(), bootGitProvider(), - bootKubeProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), + bootKubeProvider(mirrorGrants(bootTargetNS)), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, ) @@ -163,7 +177,7 @@ func TestBootstrapRuleStore_SkipsUnauthorizedRuleButStillReady(t *testing.T) { &configv1alpha3.GitProvider{ObjectMeta: metav1.ObjectMeta{Name: "git", Namespace: "team-ok"}}, admittedRule, // Admits team-ok only, so the team-a rule is refused. - bootKubeProvider(&configv1alpha3.NamespaceMatcher{Names: []string{"team-ok"}}), + bootKubeProvider(mirrorGrants("team-ok")), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-ok"}}, ) @@ -189,7 +203,7 @@ func TestBootstrapClusterWatchRule_WildcardStillResolvesItsClusterScopedTypes(t m := bootManager(t, bootGitTarget(), bootGitProvider(), - bootKubeProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), + bootKubeProvider(mirrorGrants(bootTargetNS)), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, ) @@ -207,7 +221,7 @@ func TestCompileClusterWatchRule_RefusalRemovesAnAlreadyCompiledRule(t *testing. ctx := context.Background() m := bootManager(t, bootGitTarget(), bootGitProvider(), - bootKubeProvider(&configv1alpha3.NamespaceMatcher{Names: []string{bootTargetNS}}), + bootKubeProvider(mirrorGrants(bootTargetNS)), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: bootTargetNS}}, ) @@ -221,7 +235,7 @@ func TestCompileClusterWatchRule_RefusalRemovesAnAlreadyCompiledRule(t *testing. var provider configv1alpha3.KubeProvider providerKey := client.ObjectKey{Namespace: bootProviderNS, Name: bootProviderName} require.NoError(t, m.Client.Get(ctx, providerKey, &provider)) - provider.Spec.AccessFrom = &configv1alpha3.NamespaceMatcher{Names: []string{"someone-else"}} + provider.Spec.AllowedGitTargets = mirrorGrants("someone-else") require.NoError(t, m.Client.Update(ctx, &provider)) decision, err = CompileClusterWatchRule( diff --git a/internal/watch/manager_startup_test.go b/internal/watch/manager_startup_test.go index 83c7784c..b35e6926 100644 --- a/internal/watch/manager_startup_test.go +++ b/internal/watch/manager_startup_test.go @@ -40,15 +40,28 @@ func TestManagerStart_MustSeedRuleStoreFromExistingWatchRules(t *testing.T) { existingGitTarget := &configv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "playground-target", Namespace: "tilt-playground"}, Spec: configv1alpha3.GitTargetSpec{ - GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("playground-provider", "main")}, - Path: "live-cluster", + GitBranchRef: meta.NamespacedObjectReference{Name: gitBranchNameForTest("playground-provider", "main")}, + KubeProviderRef: meta.NamespacedObjectReference{Name: "local"}, + Path: "live-cluster", + }, + } + // A WatchRule's own namespace is watched only through a grant, so the fixture ships the provider + // that gives it. + localKubeProvider := &configv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: "tilt-playground"}, + Spec: configv1alpha3.KubeProviderSpec{ + InCluster: &configv1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: []configv1alpha3.SourceGrant{{From: configv1alpha3.GrantFromSame, + SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.NamespaceVariable}}}, }, } existingGitProvider := &configv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "playground-provider", Namespace: "tilt-playground"}, Spec: configv1alpha3.GitProviderSpec{ - URL: "https://example.invalid/playground.git", - AllowedBranches: []string{"main"}, + URL: "https://example.invalid/playground.git", + AllowedGitBranches: []configv1alpha3.GitBranchGrant{ + {From: configv1alpha3.GrantFromSame, Branches: []configv1alpha3.BranchPattern{"main"}}, + }, }, } @@ -59,6 +72,7 @@ func TestManagerStart_MustSeedRuleStoreFromExistingWatchRules(t *testing.T) { existingWatchRule, existingGitTarget, existingGitProvider, + localKubeProvider, ). Build() @@ -114,19 +128,25 @@ func TestManagerStart_MustSeedRuleStoreFromExistingClusterWatchRules(t *testing. existingGitProvider := &configv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "ops-provider", Namespace: "ops"}, Spec: configv1alpha3.GitProviderSpec{ - URL: "https://example.invalid/ops.git", - AllowedBranches: []string{"main"}, + URL: "https://example.invalid/ops.git", + AllowedGitBranches: []configv1alpha3.GitBranchGrant{ + {From: configv1alpha3.GrantFromSame, Branches: []configv1alpha3.BranchPattern{"main"}}, + }, }, } // existingGitTarget references the KubeProvider ops/local, in its own namespace. Bootstrap - // applies that provider's namespace admission before seeding a ClusterWatchRule, so the fixture - // must ship the provider (its own namespace is always admitted). Without it the seed is + // applies that provider's grants before seeding a ClusterWatchRule, so the fixture must ship the + // provider, granting its own namespace every cluster-scoped resource. Without it the seed is // correctly REFUSED, which is the behavior TestBootstrapClusterWatchRule_RefusesMissingKubeProvider // covers; this test is about the admitted path. localKubeProvider := &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: "ops"}, - Spec: configv1alpha3.KubeProviderSpec{InCluster: &configv1alpha3.KubeProviderInCluster{}}, + Spec: configv1alpha3.KubeProviderSpec{ + InCluster: &configv1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: []configv1alpha3.SourceGrant{{From: configv1alpha3.GrantFromSame, + ClusterResources: []configv1alpha3.ClusterResourceIdentity{"*"}}}, + }, } fakeClient := fake.NewClientBuilder(). diff --git a/internal/watch/source_namespace_test.go b/internal/watch/source_namespace_test.go index 691ee4f7..2010d912 100644 --- a/internal/watch/source_namespace_test.go +++ b/internal/watch/source_namespace_test.go @@ -48,13 +48,20 @@ func snbGitProvider() *configv1alpha3.GitProvider { } } -func snbKubeProvider(delegate bool) *configv1alpha3.KubeProvider { +// snbKubeProvider admits snbTenantNS to watch its own namespace, and every namespace when wide. +func snbKubeProvider(wide bool) *configv1alpha3.KubeProvider { + grant := configv1alpha3.SourceGrant{ + From: configv1alpha3.GrantFromNames, Names: []configv1alpha3.GrantNamespace{snbTenantNS}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.NamespaceVariable}, + } + if wide { + grant.SourceNamespaces = append(grant.SourceNamespaces, "*") + } return &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: snbProviderNS, Name: snbProvider}, Spec: configv1alpha3.KubeProviderSpec{ - InCluster: &configv1alpha3.KubeProviderInCluster{}, - AccessFrom: &configv1alpha3.NamespaceMatcher{Names: []string{snbTenantNS}}, - AllowCrossNamespace: &configv1alpha3.KubeProviderCrossNamespace{Sources: delegate}, + InCluster: &configv1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: []configv1alpha3.SourceGrant{grant}, }, } } @@ -186,8 +193,8 @@ func TestCompileWatchRule_WildcardIsRefusedWithoutDelegation(t *testing.T) { require.NoError(t, err) assert.False(t, resolved.Admitted()) - assert.Contains(t, resolved.Message, "spec.allowCrossNamespace.sources", - "the refusal must name the flag a platform admin has to set") + assert.Contains(t, resolved.Message, "spec.allowedGitTargets[].sourceNamespaces", + "the refusal must name the grant a platform admin has to widen") assert.Empty(t, m.RuleStore.SnapshotWatchRules()) } @@ -209,11 +216,12 @@ func TestCompileWatchRule_TerminalRefusalRemovesAnAlreadyCompiledRule(t *testing require.True(t, resolved.Admitted()) require.Len(t, m.RuleStore.SnapshotWatchRules(), 1, "precondition: the rule is compiled") - // The platform admin withdraws the delegation. + // The platform admin narrows the grant to the tenant's own namespace. var stored configv1alpha3.KubeProvider providerKey := k8stypes.NamespacedName{Namespace: snbProviderNS, Name: snbProvider} require.NoError(t, m.Client.Get(ctx, providerKey, &stored)) - stored.Spec.AllowCrossNamespace = nil + stored.Spec.AllowedGitTargets[0].SourceNamespaces = []configv1alpha3.ScopeNamespace{ + configv1alpha3.NamespaceVariable} require.NoError(t, m.Client.Update(ctx, &stored)) resolved, err = CompileWatchRule(ctx, m.Client, m.RuleStore, rule, target) diff --git a/internal/watch/watchrule_compile.go b/internal/watch/watchrule_compile.go index 4549e5f0..3c6b0e94 100644 --- a/internal/watch/watchrule_compile.go +++ b/internal/watch/watchrule_compile.go @@ -148,7 +148,8 @@ func CompileClusterWatchRule( const ( // ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized is the terminal reason when the // referenced GitTarget's namespace is not admitted by that target's KubeProvider — either - // because spec.accessFrom excludes it or because the provider does not exist at all. + // because no grant in its spec.allowedGitTargets admits it or because the provider does not + // exist at all. // // One rule-side reason covers both provider-side causes on purpose: from the ClusterWatchRule's // point of view the single fact that matters is that this rule may not compile against this diff --git a/internal/webhook/receiver_test.go b/internal/webhook/receiver_test.go index aa335ad9..bc7b2d3c 100644 --- a/internal/webhook/receiver_test.go +++ b/internal/webhook/receiver_test.go @@ -95,8 +95,10 @@ func testProvider(namespace, name, url string) *v1alpha3.GitProvider { return &v1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, Spec: v1alpha3.GitProviderSpec{ - URL: url, - AllowedBranches: []string{"*"}, + URL: url, + AllowedGitBranches: []v1alpha3.GitBranchGrant{ + {From: v1alpha3.GrantFromSame, Branches: []v1alpha3.BranchPattern{"*"}}, + }, }, } } diff --git a/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml b/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml index 402fac77..c6c9f6c3 100644 --- a/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml +++ b/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml @@ -4,9 +4,9 @@ metadata: name: demo spec: url: http://gitea-http.gitea-e2e.svc.cluster.local:13000/testorg/demo.git - allowedBranches: - - main - - nl-stuff + allowedGitBranches: + - from: Same + branches: [main, nl-stuff] secretRef: name: git-creds-demo --- @@ -18,6 +18,8 @@ spec: gitProviderRef: name: demo branch: nl-stuff + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml b/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml index 19b4da22..20f6690a 100644 --- a/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml +++ b/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml @@ -4,9 +4,9 @@ metadata: name: demo-coffeeconfig spec: url: http://gitea-http.gitea-e2e.svc.cluster.local:13000/testorg/demo.git - allowedBranches: - - main - - demo-test + allowedGitBranches: + - from: Same + branches: [main, demo-test] secretRef: name: git-creds-demo --- @@ -18,6 +18,8 @@ spec: gitProviderRef: name: demo-coffeeconfig branch: demo-test + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml index 181f6152..d0d76774 100644 --- a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: mirror-repository branch: main + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget @@ -20,8 +22,8 @@ spec: name: prod path: mirror/prod # The WatchRule beside this file names source namespaces other than its own. The - # KubeProvider's allowCrossNamespace.sources is the only grant that needs, and the - # source credential's RBAC bounds what it can actually read. + # KubeProvider's allowedGitTargets grant gives this namespace sourceNamespaces "*", + # and the source credential's RBAC bounds what it can actually read. # Flat is a DECLARED shape. Without a template the built-in ladder ends at the # built-in identity path, which is a tree. One file per object, directly in the # folder, is what this template says. diff --git a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/kubeprovider.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/kubeprovider.yaml index 93541345..c8dee31c 100644 --- a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/kubeprovider.yaml +++ b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/kubeprovider.yaml @@ -1,8 +1,8 @@ -# Required, because the WatchRule beside this file names source namespaces other -# than its own. This is the ONE grant that permits that: the owner of the -# KubeProvider delegates the choice of source namespace to the GitTargets it -# admits, which here are those in its own namespace. What those targets can -# actually read is bounded by the source credential's own RBAC. +# Required, because nothing mirrors through a KubeProvider without a grant. The +# WatchRule beside this file names source namespaces other than its own, so the +# grant gives the GitTargets in the provider's own namespace every source +# namespace ("*"). What those targets can actually read is bounded by the source +# credential's own RBAC. apiVersion: configbutler.ai/v1alpha3 kind: KubeProvider metadata: @@ -12,5 +12,6 @@ spec: # A kubeconfig Secret in this provider's own namespace. secretRef: name: prod-kubeconfig - allowCrossNamespace: - sources: true + allowedGitTargets: + - from: Same + sourceNamespaces: ["*"] diff --git a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml index 1a854400..3f9c19c2 100644 --- a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml +++ b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: artifacts-repository branch: main + allowedGitTargets: + - from: Same --- # The fence around "one namespace", as a fixture. Same folder and same flag as # gittarget.yaml; what differs is that TWO WatchRules reach it, from two source namespaces. diff --git a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml index c432e5d7..68d69e95 100644 --- a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: artifacts-repository branch: main + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml index 78b58552..d9b0168d 100644 --- a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: mirror-repository branch: main + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget @@ -20,8 +22,8 @@ spec: name: home path: clusters/home # The WatchRule beside this file names source namespaces other than its own. The - # KubeProvider's allowCrossNamespace.sources is the only grant that needs, and the - # source credential's RBAC bounds what it can actually read. + # KubeProvider's allowedGitTargets grant gives this namespace sourceNamespaces "*", + # and the source credential's RBAC bounds what it can actually read. # No placement declared and no kustomization anywhere in the subtree, so the # ladder falls through to the built-in identity path: # {namespace}/{groupPath}/{resource}/{name}.yaml diff --git a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/kubeprovider.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/kubeprovider.yaml index 9316cd8d..ff81adf8 100644 --- a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/kubeprovider.yaml +++ b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/kubeprovider.yaml @@ -1,8 +1,8 @@ -# Required, because the WatchRule beside this file names source namespaces other -# than its own. This is the ONE grant that permits that: the owner of the -# KubeProvider delegates the choice of source namespace to the GitTargets it -# admits, which here are those in its own namespace. What those targets can -# actually read is bounded by the source credential's own RBAC. +# Required, because nothing mirrors through a KubeProvider without a grant. The +# WatchRule beside this file names source namespaces other than its own, so the +# grant gives the GitTargets in the provider's own namespace every source +# namespace ("*"). What those targets can actually read is bounded by the source +# credential's own RBAC. apiVersion: configbutler.ai/v1alpha3 kind: KubeProvider metadata: @@ -12,5 +12,6 @@ spec: # A kubeconfig Secret in this provider's own namespace. secretRef: name: home-kubeconfig - allowCrossNamespace: - sources: true + allowedGitTargets: + - from: Same + sourceNamespaces: ["*"] diff --git a/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml index 59c45526..8f9ac9a6 100644 --- a/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: artifacts-repository branch: main + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml index ee2494af..661e9607 100644 --- a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml +++ b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: app-repository branch: main + allowedGitTargets: + - from: Same --- # The same folder, when it does not exist yet. This is the ONLY difference # between adopting a kustomize folder and creating one. diff --git a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml index f64b4398..4c29a550 100644 --- a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: app-repository branch: main + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml index 516edd6a..3f929ad0 100644 --- a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml +++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: app-repository branch: main + allowedGitTargets: + - from: Same --- # The MISTAKE this shape exists to name, kept as a fixture because a set where every write # succeeds is advertising rather than specification. Same repository as gittarget-prod.yaml, diff --git a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml index a7761f93..6be4b672 100644 --- a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: app-repository branch: main + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml index e4af09cf..7f3e4ec6 100644 --- a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml +++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: app-repository branch: main + allowedGitTargets: + - from: Same --- # The second and third targets are the same object with the environment swapped. # Nothing is shared between them except the byType map, if one were declared. diff --git a/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml index c63de1c6..949472a7 100644 --- a/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: app-repository branch: main + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml index 7dab35e8..4705c52d 100644 --- a/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: app-repository branch: main + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml index eda89caf..f1508243 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: homelab-repository branch: main + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml index 9bfe4b36..8cc9b085 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: homelab-repository branch: main + allowedGitTargets: + - from: Same --- # The second target. HelmReleases are a different layer from the sources they pull # from, so they get their own folder and their own GitTarget rather than sharing one diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml index d5945fef..af3398ca 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: homelab-repository branch: main + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md b/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md index c8f66e61..055a6857 100644 --- a/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md +++ b/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md @@ -15,25 +15,25 @@ namespace demo GitTarget/demo -> GitProvider/app-repository ``` -The provider is namespace-local because it carries credentials. A `GitTarget` may refer to a -provider in another namespace only when that provider's `spec.accessFrom` admits the target's -namespace, and every `GitTarget` writing one repository branch must refer to the same provider. +The provider carries credentials, so nothing uses it without a grant. Its +`spec.allowedGitBranches` says which namespaces' `GitBranch`es may claim which branches, and each +`GitBranch`'s `spec.allowedGitTargets` says which namespaces' `GitTarget`s may write through it; the +provider's own namespace needs a grant too (`from: Same`). ## Source cluster authority Every `GitTarget` names its source cluster with `spec.kubeProviderRef`. The examples here use the `KubeProvider` the chart renders, `local` in `gitops-reverser`, which uses the operator's own -identity. A target that captures objects from a different source namespace needs one grant, and it -belongs to the owner of the `KubeProvider`: the provider delegates the *ability* to choose a source -namespace (`allowCrossNamespace.sources`). What may actually be read is bounded by that provider -credential's own RBAC in the source cluster. Concrete specimens are in +identity. Every target needs a grant from the owner of the `KubeProvider` +(`spec.allowedGitTargets`), and the grant's `sourceNamespaces` bound which source namespaces its +`WatchRule`s may watch, its own included only when listed (`"{namespace}"`). What may actually be read +is bounded by that provider credential's own RBAC in the source cluster. Concrete specimens are in [`../../shapes/1-flat-serialized/config/`](../../shapes/1-flat-serialized/config/kubeprovider.yaml) and [`../../shapes/3-tree-serialized/config/`](../../shapes/3-tree-serialized/config/kubeprovider.yaml). -The source-cluster rule and source-namespace authorization are separate checks. A `KubeProvider` -admits consumers in its own namespace, and `accessFrom` decides which other configuration -namespaces may reference it; `allowCrossNamespace.sources` decides whether those namespaces' -`GitTarget`s may look outside their own. +The source-cluster rule and source-namespace authorization are one grant read twice. A grant in +`allowedGitTargets` decides which configuration namespaces may reference the `KubeProvider` at all, +and its `sourceNamespaces` decide what those namespaces' `GitTarget`s may watch. ## Credentials stay out of this design diff --git a/test/fixtures/layout-corpus/specific-examples/prerequisites/config/gitprovider.yaml b/test/fixtures/layout-corpus/specific-examples/prerequisites/config/gitprovider.yaml index 26188ab4..b5aac283 100644 --- a/test/fixtures/layout-corpus/specific-examples/prerequisites/config/gitprovider.yaml +++ b/test/fixtures/layout-corpus/specific-examples/prerequisites/config/gitprovider.yaml @@ -9,8 +9,9 @@ spec: name: app-repository-credentials knownHostsRef: name: example-com-known-hosts - allowedBranches: - - main + allowedGitBranches: + - from: Same + branches: [main] commit: committer: name: GitOps Reverser diff --git a/test/playground/config/gitprovider.yaml b/test/playground/config/gitprovider.yaml index 2b58b8c6..5f536186 100644 --- a/test/playground/config/gitprovider.yaml +++ b/test/playground/config/gitprovider.yaml @@ -5,7 +5,8 @@ metadata: namespace: tilt-playground spec: url: http://gitea-http.gitea-e2e.svc.cluster.local:13000/testorg/playground.git - allowedBranches: - - "main" + allowedGitBranches: + - from: Same + branches: [main] secretRef: name: git-creds-playground diff --git a/test/playground/config/gittarget.yaml b/test/playground/config/gittarget.yaml index 269f0479..864484b7 100644 --- a/test/playground/config/gittarget.yaml +++ b/test/playground/config/gittarget.yaml @@ -7,6 +7,8 @@ spec: gitProviderRef: name: playground-provider branch: main + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget From 1276c5ca170363d99b15de681fdd32bd74321520 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 17:34:27 +0000 Subject: [PATCH 11/34] feat(git): claim only while admitted, and carry the provider's namespace A GitBranch that loses its grant takes the deletion path without being deleted: it releases its claim (Release, not a failed proof, so it stops being a candidate), retires its worker, which pushes or refuses what it held, keeps its finalizer and reports Stalled with the grant's reason. The successor is checked again before it is woken, so one edit that revokes several candidates leaves none of them holding the branch. WorkerRequest carries the GitProvider with its namespace resolved, so a GitBranch claiming through a provider in another namespace pushes with that provider's credential, and receiver routing reaches its worker. The branch-target metric names the same provider. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../controller/git_branch_helpers_test.go | 7 +- internal/controller/gitbranch_access_test.go | 201 ++++++++++++++++++ internal/controller/gitbranch_controller.go | 94 ++++++-- .../controller/gitbranch_controller_test.go | 15 +- internal/controller/gittarget_controller.go | 18 +- .../controller/gittarget_gitbranch_test.go | 52 +++++ internal/git/claim_helpers_test.go | 2 +- internal/git/worker_branch_owner_test.go | 44 +++- internal/git/worker_manager.go | 9 +- internal/watch/git_branch_helpers_test.go | 6 +- 10 files changed, 410 insertions(+), 38 deletions(-) create mode 100644 internal/controller/gitbranch_access_test.go diff --git a/internal/controller/git_branch_helpers_test.go b/internal/controller/git_branch_helpers_test.go index 2ccc40c8..6db7fb63 100644 --- a/internal/controller/git_branch_helpers_test.go +++ b/internal/controller/git_branch_helpers_test.go @@ -27,7 +27,12 @@ func claimWorkerForTest(m *git.WorkerManager, repo git.RepoIdentity) git.WorkerR UID: k8stypes.UID(shopRepo1Main.String()), Created: time.Unix(0, 0), }, true) - return git.WorkerRequest{GitBranch: shopRepo1Main, Provider: "repo1", Branch: "main", Repo: repo} + return git.WorkerRequest{ + GitBranch: shopRepo1Main, + Provider: k8stypes.NamespacedName{Namespace: "shop", Name: "repo1"}, + Branch: "main", + Repo: repo, + } } // gitBranchOn is the GitBranch "-" in namespace, writing branch through provider. diff --git a/internal/controller/gitbranch_access_test.go b/internal/controller/gitbranch_access_test.go new file mode 100644 index 00000000..9b695bc2 --- /dev/null +++ b/internal/controller/gitbranch_access_test.go @@ -0,0 +1,201 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "testing" + "time" + + meta "github.com/fluxcd/pkg/apis/meta" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + k8stypes "k8s.io/apimachinery/pkg/types" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/git" +) + +// sharedProvider is the GitProvider platform/shared-repo, letting the named namespaces claim every +// branch. +func sharedProvider(namespaces ...string) *configbutleraiv1alpha3.GitProvider { + provider := branchProvider("platform", "shared-repo") + provider.Spec.AllowedGitBranches = []configbutleraiv1alpha3.GitBranchGrant{{ + From: configbutleraiv1alpha3.GrantFromNames, Names: grantNames(namespaces...), + Branches: []configbutleraiv1alpha3.BranchPattern{"*"}, + }} + return provider +} + +// tenantBranch is a GitBranch namespace/main claiming main through platform/shared-repo, created +// age ago. +func tenantBranch(namespace string, age time.Duration) *configbutleraiv1alpha3.GitBranch { + branch := branchObject(namespace, "main", "shared-repo", "main", age) + branch.Spec.GitProviderRef = meta.NamespacedObjectReference{Name: "shared-repo", Namespace: "platform"} + return branch +} + +// grantOnly replaces the shared provider's grant so it admits exactly namespaces. +func (f *gitBranchFixture) grantOnly(t *testing.T, namespaces ...string) { + t.Helper() + stored := &configbutleraiv1alpha3.GitProvider{} + key := k8stypes.NamespacedName{Namespace: "platform", Name: "shared-repo"} + require.NoError(t, f.client.Get(context.Background(), key, stored)) + stored.Spec.AllowedGitBranches = sharedProvider(namespaces...).Spec.AllowedGitBranches + if len(namespaces) == 0 { + stored.Spec.AllowedGitBranches = nil + } + require.NoError(t, f.client.Update(context.Background(), stored)) +} + +func (f *gitBranchFixture) holder() (git.BranchClaimant, bool) { + return f.workers.Claims().Holder(git.BranchKeyFor(gitBranchTestURL, "main")) +} + +// TestGitBranch_AnUnadmittedGitBranchNeverOpensAPushSession: admission comes before the proof, so a +// GitBranch no grant admits never uses the shared credential. +func TestGitBranch_AnUnadmittedGitBranchNeverOpensAPushSession(t *testing.T) { + f := newGitBranchFixture(t, sharedProvider("team-a"), tenantBranch("team-b", time.Hour)) + proofs := 0 + f.r.proveProvider = func(*configbutleraiv1alpha3.GitProvider) error { proofs++; return nil } + + got := f.reconcile(t, "team-b", "main") + + assert.Zero(t, proofs, "no push session is opened with a credential the GitBranch was not granted") + assert.Equal(t, GitBranchReasonNamespaceNotAuthorized, readyOf(got).Reason) + assert.Contains( + t, + readyOf(got).Message, + `GitProvider platform/shared-repo admits team-a in spec.allowedGitBranches; `+ + `namespace "team-b" is not admitted`, + ) + assert.True(t, conditionIsTrue(got.Status.Conditions, ConditionTypeStalled)) + _, held := f.holder() + assert.False(t, held) +} + +// TestGitBranch_AGitBranchAdmittedLaterTakesTheBranchByAge: once a grant admits it, a GitBranch proves +// itself and becomes a candidate like any other, and the oldest candidate holds the branch, also +// when the claims are recomputed after a restart. +func TestGitBranch_AGitBranchAdmittedLaterTakesTheBranchByAge(t *testing.T) { + f := newGitBranchFixture(t, sharedProvider("team-b"), + tenantBranch("team-a", 2*time.Hour), tenantBranch("team-b", time.Hour)) + f.reconcile(t, "team-a", "main") + f.reconcile(t, "team-b", "main") + holder, held := f.holder() + require.True(t, held) + assert.Equal(t, "team-b/main", holder.GitBranch.String(), "only the admitted one holds the branch") + + f.grantOnly(t, "team-a", "team-b") + older := f.reconcile(t, "team-a", "main") + younger := f.reconcile(t, "team-b", "main") + + assert.Equal(t, metav1.ConditionTrue, readyOf(older).Status, readyOf(older).Message) + assert.Equal(t, GitBranchReasonBranchConflict, readyOf(younger).Reason, + "the older GitBranch takes the branch once it is admitted and proves itself") + + // After a restart the claims are recomputed from the objects alone, in whatever order. + restarted := newGitBranchFixture(t) + restarted.client = f.client + restarted.r.Client = f.client + restarted.reconcile(t, "team-b", "main") + restarted.reconcile(t, "team-a", "main") + holder, held = restarted.holder() + require.True(t, held) + assert.Equal(t, "team-a/main", holder.GitBranch.String()) +} + +// TestGitBranch_RevocationDrainsAndKeepsTheFinalizer: a GitBranch whose grant is withdrawn gives up +// its claim and retires its worker, as deletion does, but keeps its finalizer and reports why. The +// old worker takes nothing new, and the successor starts a worker of its own. +func TestGitBranch_RevocationDrainsAndKeepsTheFinalizer(t *testing.T) { + f := newGitBranchFixture(t, sharedProvider("team-a", "team-b"), + tenantBranch("team-a", 2*time.Hour), tenantBranch("team-b", time.Hour)) + f.reconcile(t, "team-a", "main") + f.reconcile(t, "team-b", "main") + revoked := k8stypes.NamespacedName{Namespace: "team-a", Name: "main"} + repo := git.RepoIdentity{ProviderUID: "uid-shared-repo", URL: gitBranchTestURL} + provider := k8stypes.NamespacedName{Namespace: "platform", Name: "shared-repo"} + require.NoError(t, f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ + GitBranch: revoked, Provider: provider, Branch: "main", Repo: repo, + })) + old, live := f.workers.WorkerForGitBranch(revoked) + require.True(t, live) + + f.grantOnly(t, "team-b") + got := f.reconcile(t, "team-a", "main") + + assert.Equal(t, GitBranchReasonNamespaceNotAuthorized, readyOf(got).Reason) + assert.Contains(t, readyOf(got).Message, "gave up the branch") + assert.True(t, conditionIsTrue(got.Status.Conditions, ConditionTypeStalled)) + assert.Contains(t, got.GetFinalizers(), GitBranchFinalizer, "a revoked GitBranch is not being deleted") + _, live = f.workers.WorkerForGitBranch(revoked) + assert.False(t, live, "the worker retired, pushing or refusing what it held") + assert.False(t, old.Enqueue(git.Event{}), "an event arriving after the revocation is refused") + + holder, held := f.holder() + require.True(t, held) + assert.Equal(t, "team-b/main", holder.GitBranch.String()) + require.Len(t, f.workers.GitBranchEvents(), 1, "the successor is woken") + <-f.workers.GitBranchEvents() + successor := k8stypes.NamespacedName{Namespace: "team-b", Name: "main"} + require.NoError(t, f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ + GitBranch: successor, Provider: provider, Branch: "main", Repo: repo, + })) + fresh, live := f.workers.WorkerForGitBranch(successor) + require.True(t, live) + assert.NotSame(t, old, fresh, "the successor receives none of the old worker's work") + + // Restoring the grant re-admits it; it proves itself and, being older, takes the branch back. + f.grantOnly(t, "team-a", "team-b") + back := f.reconcile(t, "team-a", "main") + assert.Equal(t, metav1.ConditionTrue, readyOf(back).Status, readyOf(back).Message) +} + +// TestGitBranch_OneEditRevokesTwoCandidates: the holder's successor is checked again before it is +// handed the branch, so a grant edit that revokes both leaves neither holding it. +func TestGitBranch_OneEditRevokesTwoCandidates(t *testing.T) { + f := newGitBranchFixture(t, sharedProvider("team-a", "team-b", "team-c"), + tenantBranch("team-a", 3*time.Hour), tenantBranch("team-b", 2*time.Hour), + tenantBranch("team-c", time.Hour)) + for _, ns := range []string{"team-a", "team-b", "team-c"} { + f.reconcile(t, ns, "main") + } + + f.grantOnly(t, "team-c") + f.reconcile(t, "team-a", "main") + + holder, held := f.holder() + require.True(t, held) + assert.Equal(t, "team-c/main", holder.GitBranch.String(), "team-b, also revoked, never holds the branch") + _, b, bHolds := f.workers.Claims().HeldBy(k8stypes.NamespacedName{Namespace: "team-b", Name: "main"}) + assert.False(t, bHolds, "%v", b) +} + +// TestGitBranch_ANamespaceLabelLossRevokesASelectorGrant: a label removed from a claimant's namespace +// revokes a Selector grant on its next reconcile. +func TestGitBranch_ANamespaceLabelLossRevokesASelectorGrant(t *testing.T) { + tenant := map[string]string{"tenant": "true"} + provider := branchProvider("platform", "shared-repo") + provider.Spec.AllowedGitBranches = []configbutleraiv1alpha3.GitBranchGrant{{ + From: configbutleraiv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}, + Branches: []configbutleraiv1alpha3.BranchPattern{"teams/{namespace}/*", "main"}, + }} + namespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-a", Labels: tenant}} + f := newGitBranchFixture(t, provider, namespace, tenantBranch("team-a", time.Hour)) + got := f.reconcile(t, "team-a", "main") + require.Equal(t, metav1.ConditionTrue, readyOf(got).Status, readyOf(got).Message) + + stored := &corev1.Namespace{} + require.NoError(t, f.client.Get(context.Background(), ctrlclient.ObjectKeyFromObject(namespace), stored)) + stored.Labels = nil + require.NoError(t, f.client.Update(context.Background(), stored)) + got = f.reconcile(t, "team-a", "main") + + assert.Equal(t, GitBranchReasonNamespaceNotAuthorized, readyOf(got).Reason) + _, held := f.holder() + assert.False(t, held) +} diff --git a/internal/controller/gitbranch_controller.go b/internal/controller/gitbranch_controller.go index b090db4a..c0b0aa4a 100644 --- a/internal/controller/gitbranch_controller.go +++ b/internal/controller/gitbranch_controller.go @@ -199,8 +199,12 @@ func (r *GitBranchReconciler) evaluate( return nil, 0, err } if !admitted.Allowed { - r.observeClaim(key, claimant, false) - rd.stalled(admitted.Reason, admitted.Message) + message := admitted.Message + if r.relinquish(ctx, branch) { + message += "; this GitBranch gave up the branch, and its branch worker pushed or refused the writes " + + "it held" + } + rd.stalled(admitted.Reason, message) return rd, 0, nil } @@ -367,25 +371,13 @@ func (r *GitBranchReconciler) ensureFinalizer( return nil } -// finalize gives up the branch. The claim goes first, so no GitTarget reconcile can start this -// GitBranch's worker again; then the worker is stopped, which closes its intake, pushes or refuses -// every pending write and settles every CommitRequest it holds (BranchWorker.Stop). Only then is -// the finalizer removed. Nothing deletes the branch on the remote. +// finalize gives up the branch (relinquish), and only then removes the finalizer. Nothing deletes +// the branch on the remote. func (r *GitBranchReconciler) finalize(ctx context.Context, branch *configbutleraiv1alpha3.GitBranch) error { if !controllerutil.ContainsFinalizer(branch, GitBranchFinalizer) { return nil } - if r.WorkerManager != nil { - claims := r.WorkerManager.Claims() - key, _, held := claims.HeldBy(client.ObjectKeyFromObject(branch)) - claims.Release(branch.UID) - r.WorkerManager.RetireGitBranch(client.ObjectKeyFromObject(branch)) - // The next candidate holds the branch now. It may be a GitBranch stalled on BranchConflict, - // which nothing else would reconcile before its steady requeue. - if next, taken := claims.Holder(key); held && taken { - r.WorkerManager.WakeGitBranch(next.GitBranch) - } - } + r.relinquish(ctx, branch) r.proofsMu.Lock() delete(r.proofs, branch.UID) r.proofsMu.Unlock() @@ -397,6 +389,74 @@ func (r *GitBranchReconciler) finalize(ctx context.Context, branch *configbutler return client.IgnoreNotFound(r.Update(ctx, update)) } +// relinquish gives up whatever branch claim the GitBranch has, and reports whether it held its branch. +// It is the one path for a GitBranch being deleted and for one that no grant admits any more, which +// keeps its finalizer. The claim goes first, so no GitTarget reconcile can start this GitBranch's +// worker again; then the worker is stopped, which closes its intake, pushes or refuses every pending +// write and settles every CommitRequest it holds (BranchWorker.Stop). Release, not a failed proof: +// Observe keeps a holder's candidacy through a failed proof, and a revoked GitBranch must stop being +// a candidate at all. Last, the next holder is woken, once the old worker has retired, so pending +// work never moves to it. +func (r *GitBranchReconciler) relinquish(ctx context.Context, branch *configbutleraiv1alpha3.GitBranch) bool { + if r.WorkerManager == nil { + return false + } + claims := r.WorkerManager.Claims() + key, _, held := claims.HeldBy(client.ObjectKeyFromObject(branch)) + claims.Release(branch.UID) + r.WorkerManager.RetireGitBranch(client.ObjectKeyFromObject(branch)) + if held { + r.wakeNextHolder(ctx, key) + } + return held +} + +// maxSuccessorChecks bounds wakeNextHolder's walk down the candidates for one branch. +const maxSuccessorChecks = 16 + +// wakeNextHolder wakes the GitBranch that holds key now: it may be one stalled on BranchConflict, +// which nothing else would reconcile before its steady requeue. One grant edit can revoke several +// candidates, so the successor's admission is checked again first, and a candidate no grant admits +// any more is released here rather than handed the branch until its own reconcile notices. A read +// error leaves the successor as it is: its own reconcile decides. +func (r *GitBranchReconciler) wakeNextHolder(ctx context.Context, key git.BranchKey) { + claims := r.WorkerManager.Claims() + for range maxSuccessorChecks { + next, taken := claims.Holder(key) + if !taken { + return + } + admitted, decided := r.stillAdmitted(ctx, next) + if !decided || admitted { + r.WorkerManager.WakeGitBranch(next.GitBranch) + return + } + claims.Release(next.UID) + } +} + +// stillAdmitted reports whether the GitBranch behind a claimant may still claim its branch, and +// whether that could be decided: a GitBranch that is gone, or replaced under its name, is no +// candidate; one whose GitProvider is gone keeps its claim, as the GitBranch reconcile keeps it. +func (r *GitBranchReconciler) stillAdmitted(ctx context.Context, claimant git.BranchClaimant) (bool, bool) { + var branch configbutleraiv1alpha3.GitBranch + if err := r.Get(ctx, claimant.GitBranch, &branch); err != nil { + return false, apierrors.IsNotFound(err) + } + if branch.UID != claimant.UID || !branch.DeletionTimestamp.IsZero() { + return false, true + } + var provider configbutleraiv1alpha3.GitProvider + if err := r.Get(ctx, branch.GitProviderKey(), &provider); err != nil { + return apierrors.IsNotFound(err), apierrors.IsNotFound(err) + } + decision, err := authz.GitBranchClaimAdmitted(ctx, r.Client, &branch, &provider) + if err != nil { + return false, false + } + return decision.Allowed, true +} + // parentBranchNotFoundMessage says which parent is missing and what fixes it. func parentBranchNotFoundMessage(branch *configbutleraiv1alpha3.GitBranch, missing string) string { switch { diff --git a/internal/controller/gitbranch_controller_test.go b/internal/controller/gitbranch_controller_test.go index d81fd058..016318b4 100644 --- a/internal/controller/gitbranch_controller_test.go +++ b/internal/controller/gitbranch_controller_test.go @@ -271,7 +271,7 @@ func TestGitBranch_DeletionStopsTheWorkerAndReleasesTheBranch(t *testing.T) { f.reconcile(t, "team-b", "main") holder := k8stypes.NamespacedName{Namespace: "platform", Name: "main"} require.NoError(t, f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ - GitBranch: holder, Provider: "acme", Branch: "main", + GitBranch: holder, Provider: k8stypes.NamespacedName{Namespace: "platform", Name: "acme"}, Branch: "main", Repo: git.RepoIdentity{ProviderUID: "uid-acme", URL: gitBranchTestURL}, })) @@ -304,8 +304,11 @@ func TestGitBranch_AMissingParentStallsTheBranch(t *testing.T) { key := ctrlclient.ObjectKeyFromObject(branch) require.NoError(t, f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ - GitBranch: key, Provider: "acme", Branch: "edits", ParentBranch: "release", - Repo: git.RepoIdentity{ProviderUID: "uid-acme", URL: gitBranchTestURL}, + GitBranch: key, + Provider: k8stypes.NamespacedName{Namespace: "platform", Name: "acme"}, + Branch: "edits", + ParentBranch: "release", + Repo: git.RepoIdentity{ProviderUID: "uid-acme", URL: gitBranchTestURL}, })) worker, _ := f.workers.WorkerForGitBranch(key) worker.RecordMissingParentForTest("release") @@ -332,7 +335,7 @@ func TestGitBranch_AnUnresolvedDefaultBranchStallsToo(t *testing.T) { f.reconcile(t, "platform", "edits") key := k8stypes.NamespacedName{Namespace: "platform", Name: "edits"} require.NoError(t, f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ - GitBranch: key, Provider: "acme", Branch: "edits", + GitBranch: key, Provider: k8stypes.NamespacedName{Namespace: "platform", Name: "acme"}, Branch: "edits", Repo: git.RepoIdentity{ProviderUID: "uid-acme", URL: gitBranchTestURL}, })) worker, _ := f.workers.WorkerForGitBranch(key) @@ -378,7 +381,7 @@ func TestGitBranch_ANoticeIsPublishedWithoutAnyLook(t *testing.T) { f.reconcile(t, "platform", "edits") key := k8stypes.NamespacedName{Namespace: "platform", Name: "edits"} require.NoError(t, f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ - GitBranch: key, Provider: "acme", Branch: "edits", + GitBranch: key, Provider: k8stypes.NamespacedName{Namespace: "platform", Name: "acme"}, Branch: "edits", Repo: git.RepoIdentity{ProviderUID: "uid-acme", URL: gitBranchTestURL}, })) assert.Nil(t, f.reconcile(t, "platform", "edits").Status.LastPushNoticeAt, "no delivery yet") @@ -403,7 +406,7 @@ func TestGitBranch_ARepointClearsTheNotice(t *testing.T) { "the same repository keeps it") key := k8stypes.NamespacedName{Namespace: "platform", Name: "main"} require.NoError(t, f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ - GitBranch: key, Provider: "acme", Branch: "main", + GitBranch: key, Provider: k8stypes.NamespacedName{Namespace: "platform", Name: "acme"}, Branch: "main", Repo: git.RepoIdentity{ProviderUID: "uid-acme", URL: gitBranchTestURL}, })) require.True(t, f.workers.NotifyPush("platform", "acme", "main", "abc"), "the old repository's worker") diff --git a/internal/controller/gittarget_controller.go b/internal/controller/gittarget_controller.go index 7d689cb4..6595d6b9 100644 --- a/internal/controller/gittarget_controller.go +++ b/internal/controller/gittarget_controller.go @@ -308,8 +308,14 @@ func (r *GitTargetReconciler) publishAheadOfGates( ) { layout, scanned := r.observeLayout(target) publishLayout(st, target, layout, scanned) + // The provider labels join git_pushes_total, which the worker labels with the provider it + // pushes through: the GitBranch's, which may live in another namespace than the GitBranch. + provider := dest.providerKey() + if provider.Name == "" { + provider.Namespace = dest.key.Namespace + } telemetry.RecordBranchTarget( - target.Namespace, target.Name, dest.key.Namespace, dest.providerName(), dest.branchName(), + target.Namespace, target.Name, provider.Namespace, provider.Name, dest.branchName(), target.SourceCluster()) } @@ -1013,11 +1019,13 @@ func (r *GitTargetReconciler) resolveDestination( return gitDestination{key: target.GitBranchKey(), branch: branch, provider: provider}, nil } -func (d gitDestination) providerName() string { +// providerKey is the GitBranch's GitProvider, its namespace resolved: a GitBranch may name a +// GitProvider in another namespace, and the worker pushes with that one's credential. +func (d gitDestination) providerKey() k8stypes.NamespacedName { if d.branch == nil { - return "" + return k8stypes.NamespacedName{} } - return d.branch.Spec.GitProviderRef.Name + return d.branch.GitProviderKey() } func (d gitDestination) branchName() string { @@ -1031,7 +1039,7 @@ func (d gitDestination) branchName() string { func (d gitDestination) workerRequest() git.WorkerRequest { return git.WorkerRequest{ GitBranch: d.key, - Provider: d.providerName(), + Provider: d.providerKey(), Branch: d.branchName(), Repo: repoIdentityOf(d.provider), ParentBranch: d.branch.Spec.ParentBranch, diff --git a/internal/controller/gittarget_gitbranch_test.go b/internal/controller/gittarget_gitbranch_test.go index 49280466..aa3290f9 100644 --- a/internal/controller/gittarget_gitbranch_test.go +++ b/internal/controller/gittarget_gitbranch_test.go @@ -224,3 +224,55 @@ func TestGitTargetsOn_FindsTheTargetsOfABranchOrItsProvider(t *testing.T) { require.NoError(t, err) assert.Empty(t, none) } + +// TestReconcile_ACrossNamespaceGitBranchWritesWithItsProvidersCredential: a GitBranch in team-a that +// claims through platform/acme is written with platform/acme's credential and repository, also when +// team-a has a GitProvider of the same name for another repository. +func TestReconcile_ACrossNamespaceGitBranchWritesWithItsProvidersCredential(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, clientgoscheme.AddToScheme(scheme)) + require.NoError(t, configbutleraiv1alpha3.AddToScheme(scheme)) + platform := branchProvider("platform", "acme") + platform.Spec.AllowedGitBranches[0] = configbutleraiv1alpha3.GitBranchGrant{ + From: configbutleraiv1alpha3.GrantFromNames, Names: grantNames("team-a"), + Branches: []configbutleraiv1alpha3.BranchPattern{"teams/{namespace}/*"}, + } + decoy := branchProvider("team-a", "acme") + decoy.UID = "uid-decoy" + decoy.Spec.URL = "https://github.com/acme/decoy.git" + branch := branchObject("team-a", "live", "acme", "teams/team-a/live", time.Hour) + branch.Spec.GitProviderRef.Namespace = "platform" + target := &configbutleraiv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "apps"}, + Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: claimProviderRef(claimLocalProvider), + GitBranchRef: meta.NamespacedObjectReference{Name: "live"}, + Path: "apps", + }, + } + c := fake.NewClientBuilder().WithScheme(scheme). + WithObjects(platform, decoy, branch, target, admitting(claimLocalProvider, "team-a")). + WithStatusSubresource(&configbutleraiv1alpha3.GitTarget{}).Build() + workers := git.NewWorkerManager(c, logr.Discard(), git.BranchWorkerLimits{}, types.SensitiveResourcePolicy{}) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go func() { _ = workers.Start(ctx) }() + time.Sleep(50 * time.Millisecond) + workers.Claims().Observe(git.BranchKeyFor(gitBranchTestURL, "teams/team-a/live"), git.BranchClaimant{ + GitBranch: ctrlclient.ObjectKeyFromObject(branch), UID: branch.UID, Created: branch.CreationTimestamp.Time, + }, true) + r := &GitTargetReconciler{ + Client: c, WorkerManager: workers, EventRouter: watch.NewEventRouter(workers, nil, c, logr.Discard()), + } + + _, err := r.Reconcile(context.Background(), ctrl.Request{NamespacedName: ctrlclient.ObjectKeyFromObject(target)}) + require.NoError(t, err) + + worker, wired := workers.WorkerForTarget(target) + require.True(t, wired) + assert.Equal(t, "platform", worker.GitProviderNamespace, "the credential is read from the provider's namespace") + assert.Equal(t, "acme", worker.GitProviderRef) + _, onPlatformRepo := workers.WorkerForGitBranchAt(ctrlclient.ObjectKeyFromObject(branch), + git.BranchKeyFor(gitBranchTestURL, "teams/team-a/live")) + assert.True(t, onPlatformRepo, "the repository is the platform provider's, not the decoy's") +} diff --git a/internal/git/claim_helpers_test.go b/internal/git/claim_helpers_test.go index 08f73dbc..b68254eb 100644 --- a/internal/git/claim_helpers_test.go +++ b/internal/git/claim_helpers_test.go @@ -26,7 +26,7 @@ func claimForTest(m *WorkerManager, provider, namespace, branch string, repo Rep }, true) return WorkerRequest{ GitBranch: gitBranch, - Provider: provider, + Provider: k8stypes.NamespacedName{Namespace: namespace, Name: provider}, Branch: branch, Repo: repo, } diff --git a/internal/git/worker_branch_owner_test.go b/internal/git/worker_branch_owner_test.go index 5a2393db..f8c7f413 100644 --- a/internal/git/worker_branch_owner_test.go +++ b/internal/git/worker_branch_owner_test.go @@ -28,7 +28,8 @@ func TestEnsureWorker_OnlyTheHolderGetsAWorker(t *testing.T) { require.NoError(t, m.EnsureWorker(ctx, claimForTest(m, "monorepo", "team-a", "main", teamA))) refused := WorkerRequest{ - GitBranch: testGitBranch("monorepo", "team-b", "main"), Provider: "monorepo", Branch: "main", Repo: teamB, + GitBranch: testGitBranch("monorepo", "team-b", "main"), + Provider: k8stypes.NamespacedName{Namespace: "team-b", Name: "monorepo"}, Branch: "main", Repo: teamB, } require.ErrorIs(t, m.EnsureWorker(ctx, refused), ErrBranchOwnedElsewhere) _, found := m.WorkerForGitBranch(refused.GitBranch) @@ -65,7 +66,15 @@ func TestReconcileWorkers_StopsTheWorkerOfAGitBranchThatLostItsBranch(t *testing Observe(key, BranchClaimant{GitBranch: older, UID: "older", Created: time.Unix(0, 0).Add(-time.Hour)}, true) require.ErrorIs( t, - m.EnsureWorker(ctx, WorkerRequest{GitBranch: older, Provider: "monorepo", Branch: "main", Repo: repo}), + m.EnsureWorker( + ctx, + WorkerRequest{ + GitBranch: older, + Provider: k8stypes.NamespacedName{Namespace: older.Namespace, Name: "monorepo"}, + Branch: "main", + Repo: repo, + }, + ), ErrBranchOwnedElsewhere, "the previous holder's worker is still live", ) @@ -76,7 +85,15 @@ func TestReconcileWorkers_StopsTheWorkerOfAGitBranchThatLostItsBranch(t *testing require.NoError( t, - m.EnsureWorker(ctx, WorkerRequest{GitBranch: older, Provider: "monorepo", Branch: "main", Repo: repo}), + m.EnsureWorker( + ctx, + WorkerRequest{ + GitBranch: older, + Provider: k8stypes.NamespacedName{Namespace: older.Namespace, Name: "monorepo"}, + Branch: "main", + Repo: repo, + }, + ), ) require.NoError(t, m.ReconcileWorkers(context.Background())) _, found = m.WorkerForGitBranch(older) @@ -102,3 +119,24 @@ func TestRetireGitBranch_StopsTheWorkerAndForgetsTheBranch(t *testing.T) { assert.False(t, known, "the next holder starts from the remote") m.RetireGitBranch(request.GitBranch) // a second call finds nothing to do } + +// TestEnsureWorker_ACrossNamespaceGitBranchWritesWithItsProvider: a GitBranch in team-a claiming +// through platform/shared-repo gets a worker that pushes with that provider's credential, never a +// same-name provider in team-a, and a push delivery for the provider wakes it. +func TestEnsureWorker_ACrossNamespaceGitBranchWritesWithItsProvider(t *testing.T) { + m, ctx := startedManager(t) + repo := RepoIdentity{URL: ownerTestURL} + request := claimForTest(m, "shared-repo", "team-a", "teams/team-a/live", repo) + request.Provider = k8stypes.NamespacedName{Namespace: "platform", Name: "shared-repo"} + require.NoError(t, m.EnsureWorker(ctx, request)) + + worker, found := m.WorkerForGitBranch(request.GitBranch) + require.True(t, found) + assert.Equal(t, "platform", worker.GitProviderNamespace, "the credential is read where the provider lives") + assert.Equal(t, "shared-repo", worker.GitProviderRef) + + assert.False(t, m.NotifyPush("team-a", "shared-repo", "teams/team-a/live", ""), + "a same-name provider in the GitBranch's namespace is not the one it writes with") + assert.True(t, m.NotifyPush("platform", "shared-repo", "teams/team-a/live", ""), + "receiver routing reaches the worker of a cross-namespace GitBranch") +} diff --git a/internal/git/worker_manager.go b/internal/git/worker_manager.go index 14b2ff5b..514f7b5c 100644 --- a/internal/git/worker_manager.go +++ b/internal/git/worker_manager.go @@ -364,12 +364,13 @@ func (m *WorkerManager) RemoteForBranch(key BranchKey) (RemoteObservation, bool) } // WorkerRequest names the branch worker a GitTarget needs: the GitBranch holding the branch, the -// GitProvider (in the GitBranch's namespace) it writes with, and the repository that provider names. +// GitProvider it writes with, and the repository that provider names. type WorkerRequest struct { // GitBranch is the claim holder the worker writes for. GitBranch k8stypes.NamespacedName - // Provider is the GitBranch's GitProvider, by name, in the GitBranch's namespace. - Provider string + // Provider is the GitBranch's GitProvider, its namespace resolved: a GitBranch may name one in + // another namespace, whose credential the worker then pushes with. + Provider k8stypes.NamespacedName // Branch is the GitBranch's spec.branch. Branch string // Repo is the repository the GitProvider names now. @@ -379,7 +380,7 @@ type WorkerRequest struct { } func (r WorkerRequest) provider() k8stypes.NamespacedName { - return k8stypes.NamespacedName{Namespace: r.GitBranch.Namespace, Name: r.Provider} + return r.Provider } // EnsureWorker ensures a worker exists for the repository branch req names, keyed by that branch diff --git a/internal/watch/git_branch_helpers_test.go b/internal/watch/git_branch_helpers_test.go index b7fd675f..348d0228 100644 --- a/internal/watch/git_branch_helpers_test.go +++ b/internal/watch/git_branch_helpers_test.go @@ -30,6 +30,10 @@ func claimWorkerForTest( Created: time.Unix(0, 0), }, true) return git.WorkerRequest{ - GitBranch: gitBranch, Provider: provider, Branch: branch, Repo: repo, ParentBranch: parentBranch, + GitBranch: gitBranch, + Provider: k8stypes.NamespacedName{Namespace: namespace, Name: provider}, + Branch: branch, + Repo: repo, + ParentBranch: parentBranch, } } From 925647029a7bd0ba9da1e54755b9424280bd925c Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 17:43:55 +0000 Subject: [PATCH 12/34] feat(controller): watch the Namespaces and providers that grants read GitBranches are indexed by the GitProvider they claim through, its namespace resolved, so a provider's grant edit reaches GitBranches in every namespace, and their GitTargets with them. The GitBranch and WatchRule controllers watch Namespace labels: a Selector grant reads them, and a namespace can lose the grant giving a wider scope while its GitTarget stays admitted. The WatchRule and ClusterWatchRule controllers share one helper for the watches they have in common. Wiring tests drive revocation and recovery by watch events alone. Co-Authored-By: Claude Opus 5.5 (1M context) --- cmd/main.go | 5 + .../applierclaim_controller_test.go | 1 + .../controller/clusterwatchrule_controller.go | 31 +- internal/controller/dependency_watches.go | 64 ++++ .../controller/dependency_watches_test.go | 1 + internal/controller/gitbranch_controller.go | 38 ++- internal/controller/gittarget_controller.go | 10 +- .../controller/gittarget_gitbranch_test.go | 33 +- internal/controller/suite_test.go | 2 + internal/controller/watchrule_controller.go | 35 +- .../wiring/access_grants_wiring_test.go | 304 ++++++++++++++++++ .../wiring/readiness_wiring_test.go | 4 + 12 files changed, 456 insertions(+), 72 deletions(-) create mode 100644 internal/controller/wiring/access_grants_wiring_test.go diff --git a/cmd/main.go b/cmd/main.go index 0a7a37bb..0c964e41 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -329,6 +329,11 @@ func main() { fatalIfErr(watchMgr.SetupWithManager(mgr), "unable to setup watch ingestion manager") fatalIfErr(mgr.Add(watchMgr), "unable to add watch ingestion manager") + // The GitBranch, GitTarget and applier claim controllers find a GitProvider's GitBranches, in any + // namespace, through this index. + fatalIfErr(controller.IndexGitBranches(context.Background(), mgr.GetFieldIndexer()), + "unable to index GitBranches by their GitProvider") + if err := (&controller.GitProviderReconciler{ Client: mgr.GetClient(), Scheme: mgr.GetScheme(), diff --git a/internal/controller/applierclaim_controller_test.go b/internal/controller/applierclaim_controller_test.go index 228d5a47..80b0f3d9 100644 --- a/internal/controller/applierclaim_controller_test.go +++ b/internal/controller/applierclaim_controller_test.go @@ -235,6 +235,7 @@ func claimTestClient(t *testing.T, objects ...ctrlclient.Object) ctrlclient.Clie referencingTarget("checkout-argo", configbutleraiv1alpha3.ApplierClaimKindArgoCD)) } return fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...). + WithIndex(&configbutleraiv1alpha3.GitBranch{}, GitBranchProviderIndex, IndexGitBranchProvider). WithStatusSubresource(&configbutleraiv1alpha3.FluxClaim{}, &configbutleraiv1alpha3.ArgoCDClaim{}). Build() } diff --git a/internal/controller/clusterwatchrule_controller.go b/internal/controller/clusterwatchrule_controller.go index 5791b557..77e4b726 100644 --- a/internal/controller/clusterwatchrule_controller.go +++ b/internal/controller/clusterwatchrule_controller.go @@ -8,7 +8,6 @@ import ( "fmt" "github.com/go-logr/logr" - corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" @@ -370,35 +369,9 @@ func (r *ClusterWatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { // already suppresses no-op writes, so the loop has no fuel; this makes it structural, and // matches what GitProvider and KubeProvider already do. For(&configbutleraiv1alpha3.ClusterWatchRule{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). - // This rule MIRRORS the GitTarget's readiness into its own GitTargetReady, so the watch has - // to see the status-only update that carries a heal — GenerationChangedPredicate would drop - // exactly that. gitTargetReadyProjectionChanged fires on a spec change or on a move in the - // mirrored verdict, and on nothing else. - Watches( - &configbutleraiv1alpha3.GitTarget{}, - handler.EnqueueRequestsFromMapFunc(r.gitTargetToClusterWatchRules), - builder.WithPredicates(gitTargetReadyProjectionChanged()), - ). - // React to a KubeProvider's grants changing. Without this, REVOKING a - // namespace stops the GitTarget (which does watch KubeProvider) but leaves this rule's - // compiled entry resident until the next periodic reconcile, so the admission gate would - // converge on a ~10m delay instead of on the event. The GitTarget's own status flip cannot - // be relied on to carry it: the edge above re-enqueues only on a move in the MIRRORED - // readiness, and a revocation need not move it. - Watches( - &configbutleraiv1alpha3.KubeProvider{}, - handler.EnqueueRequestsFromMapFunc(r.kubeProviderToClusterWatchRules), - builder.WithPredicates(kubeProviderReadyOrSpecChanged()), - ). - // React to a Namespace's LABELS changing: a grant may admit by selector, so a - // label change on a GitTarget's namespace grants or revokes every ClusterWatchRule pointing - // at a target in it. LabelChangedPredicate ignores unrelated namespace churn. - Watches( - &corev1.Namespace{}, - handler.EnqueueRequestsFromMapFunc(r.namespaceToClusterWatchRules), - builder.WithPredicates(predicate.LabelChangedPredicate{}), - ). Named("clusterwatchrule") + b = ruleDependencyWatches(b, r.gitTargetToClusterWatchRules, r.kubeProviderToClusterWatchRules, + r.namespaceToClusterWatchRules) // React to a stream of this rule's GitTarget reaching or leaving Streaming, for the reason the // WatchRule controller wires the same channel: the data plane is the only thing that knows when diff --git a/internal/controller/dependency_watches.go b/internal/controller/dependency_watches.go index 4c8cf589..96391bc9 100644 --- a/internal/controller/dependency_watches.go +++ b/internal/controller/dependency_watches.go @@ -5,8 +5,14 @@ package controller import ( "context" + corev1 "k8s.io/api/core/v1" + "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/predicate" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" ) // logDependencyListError records a List failure from inside a cross-kind watch @@ -19,3 +25,61 @@ func logDependencyListError(ctx context.Context, err error, listKind string, tri "affected resources will recover on the periodic requeue", "trigger", client.ObjectKeyFromObject(trigger)) } + +// GitBranchProviderIndex indexes GitBranches by the GitProvider they claim through, its namespace +// resolved (namespace/name). A GitBranch may name a GitProvider in another namespace, so the +// GitBranches of a provider cannot be found by listing the provider's namespace. +const GitBranchProviderIndex = "configbutler.ai/gitbranch-provider" + +// IndexGitBranchProvider is the GitBranchProviderIndex value of a GitBranch. +func IndexGitBranchProvider(obj client.Object) []string { + branch, ok := obj.(*configbutleraiv1alpha3.GitBranch) + if !ok || branch.Spec.GitProviderRef.Name == "" { + return nil + } + return []string{branch.GitProviderKey().String()} +} + +// IndexGitBranches registers GitBranchProviderIndex. The GitBranch, GitTarget and applier claim +// controllers all find a provider's GitBranches through it, so it is registered once, before any of +// them is set up. +func IndexGitBranches(ctx context.Context, indexer client.FieldIndexer) error { + return indexer.IndexField(ctx, &configbutleraiv1alpha3.GitBranch{}, GitBranchProviderIndex, + IndexGitBranchProvider) +} + +// gitBranchesOf lists the GitBranches, from any namespace, that claim through the GitProvider provider. +func gitBranchesOf( + ctx context.Context, reader client.Reader, provider client.Object, +) ([]configbutleraiv1alpha3.GitBranch, error) { + var list configbutleraiv1alpha3.GitBranchList + if err := reader.List(ctx, &list, + client.MatchingFields{GitBranchProviderIndex: client.ObjectKeyFromObject(provider).String()}); err != nil { + return nil, err + } + return list.Items, nil +} + +// ruleDependencyWatches adds the watches a WatchRule and a ClusterWatchRule share: their GitTarget, +// its KubeProvider and the Namespace whose labels its grants read. +// +// - The rule MIRRORS the GitTarget's readiness into its own GitTargetReady, so the watch has to see +// the status-only update that carries a heal; GenerationChangedPredicate would drop exactly that. +// gitTargetReadyProjectionChanged fires on a spec change or on a move in the mirrored verdict. +// - A KubeProvider's grants changing. The GitTarget edge cannot carry it: a provider change reaches +// the GitTarget as a status update that need not move its readiness, so without this edge a +// revocation would leave the compiled rule resident until the periodic requeue. +// - A Namespace's labels changing. A Selector grant reads them, and a namespace matched by two grants +// can lose the one granting a wider scope while its GitTarget stays admitted, which moves nothing +// the GitTarget edge sees. LabelChangedPredicate ignores unrelated namespace churn. +func ruleDependencyWatches( + b *builder.Builder, gitTargets, kubeProviders, namespaces handler.MapFunc, +) *builder.Builder { + return b. + Watches(&configbutleraiv1alpha3.GitTarget{}, handler.EnqueueRequestsFromMapFunc(gitTargets), + builder.WithPredicates(gitTargetReadyProjectionChanged())). + Watches(&configbutleraiv1alpha3.KubeProvider{}, handler.EnqueueRequestsFromMapFunc(kubeProviders), + builder.WithPredicates(kubeProviderReadyOrSpecChanged())). + Watches(&corev1.Namespace{}, handler.EnqueueRequestsFromMapFunc(namespaces), + builder.WithPredicates(predicate.LabelChangedPredicate{})) +} diff --git a/internal/controller/dependency_watches_test.go b/internal/controller/dependency_watches_test.go index 6bab671d..b4ba6c95 100644 --- a/internal/controller/dependency_watches_test.go +++ b/internal/controller/dependency_watches_test.go @@ -38,6 +38,7 @@ func newDependencyWatchesTestClient(t *testing.T, objects ...ctrlclient.Object) return fake.NewClientBuilder(). WithScheme(scheme). WithObjects(objects...). + WithIndex(&configbutleraiv1alpha3.GitBranch{}, GitBranchProviderIndex, IndexGitBranchProvider). Build() } diff --git a/internal/controller/gitbranch_controller.go b/internal/controller/gitbranch_controller.go index c0b0aa4a..f04f33f0 100644 --- a/internal/controller/gitbranch_controller.go +++ b/internal/controller/gitbranch_controller.go @@ -8,6 +8,7 @@ import ( "sync" "time" + corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -483,8 +484,10 @@ func (r *GitBranchReconciler) SetupWithManager(mgr ctrl.Manager) error { b := ctrl.NewControllerManagedBy(mgr). For(&configbutleraiv1alpha3.GitBranch{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). // Every GitProvider event, status included: its Ready flipping is what un-blocks a GitBranch, - // and providers are few. + // its grants decide which GitBranches may claim through it, and providers are few. Watches(&configbutleraiv1alpha3.GitProvider{}, handler.EnqueueRequestsFromMapFunc(r.gitProviderToGitBranches)). + Watches(&corev1.Namespace{}, handler.EnqueueRequestsFromMapFunc(r.namespaceToGitBranches), + builder.WithPredicates(predicate.LabelChangedPredicate{})). // gitBranchRef is immutable, so only a GitTarget appearing or going changes the count. Watches(&configbutleraiv1alpha3.GitTarget{}, handler.EnqueueRequestsFromMapFunc(gitTargetToGitBranch), builder.WithPredicates(predicate.Funcs{ @@ -499,19 +502,36 @@ func (r *GitBranchReconciler) SetupWithManager(mgr ctrl.Manager) error { return b.Complete(r) } -// gitProviderToGitBranches enqueues the GitBranches in the provider's namespace that name it. +// gitProviderToGitBranches enqueues the GitBranches, from any namespace, that claim through the +// provider: its grants decide whether each may. func (r *GitBranchReconciler) gitProviderToGitBranches(ctx context.Context, obj client.Object) []ctrlreconcile.Request { + branches, err := gitBranchesOf(ctx, r.Client, obj) + if err != nil { + logDependencyListError(ctx, err, "GitBranches", obj) + return nil + } + requests := make([]ctrlreconcile.Request, 0, len(branches)) + for i := range branches { + requests = append(requests, ctrlreconcile.Request{NamespacedName: client.ObjectKeyFromObject(&branches[i])}) + } + return requests +} + +// namespaceToGitBranches enqueues the GitBranches in a Namespace whose labels changed: a Selector +// grant on their GitProvider reads those labels, so the change can admit or revoke them. Their +// GitTargets follow through the GitBranch watch on the GitTarget controller. +func (r *GitBranchReconciler) namespaceToGitBranches(ctx context.Context, obj client.Object) []ctrlreconcile.Request { var branches configbutleraiv1alpha3.GitBranchList - if err := r.List(ctx, &branches, client.InNamespace(obj.GetNamespace())); err != nil { + if err := r.List(ctx, &branches, client.InNamespace(obj.GetName())); err != nil { + logDependencyListError(ctx, err, "GitBranches", obj) return nil } - var requests []ctrlreconcile.Request + requests := make([]ctrlreconcile.Request, 0, len(branches.Items)) for i := range branches.Items { - if branches.Items[i].Spec.GitProviderRef.Name == obj.GetName() { - requests = append(requests, ctrlreconcile.Request{ - NamespacedName: client.ObjectKeyFromObject(&branches.Items[i]), - }) - } + requests = append( + requests, + ctrlreconcile.Request{NamespacedName: client.ObjectKeyFromObject(&branches.Items[i])}, + ) } return requests } diff --git a/internal/controller/gittarget_controller.go b/internal/controller/gittarget_controller.go index 6595d6b9..85ac27ce 100644 --- a/internal/controller/gittarget_controller.go +++ b/internal/controller/gittarget_controller.go @@ -1577,14 +1577,12 @@ func gitTargetsOn( case *configbutleraiv1alpha3.GitBranch: branches[client.ObjectKeyFromObject(obj)] = true case *configbutleraiv1alpha3.GitProvider: - var list configbutleraiv1alpha3.GitBranchList - if err := reader.List(ctx, &list, client.InNamespace(obj.GetNamespace())); err != nil { + list, err := gitBranchesOf(ctx, reader, obj) + if err != nil { return nil, err } - for i := range list.Items { - if list.Items[i].Spec.GitProviderRef.Name == obj.GetName() { - branches[client.ObjectKeyFromObject(&list.Items[i])] = true - } + for i := range list { + branches[client.ObjectKeyFromObject(&list[i])] = true } } if len(branches) == 0 { diff --git a/internal/controller/gittarget_gitbranch_test.go b/internal/controller/gittarget_gitbranch_test.go index aa3290f9..26d3ce3f 100644 --- a/internal/controller/gittarget_gitbranch_test.go +++ b/internal/controller/gittarget_gitbranch_test.go @@ -11,6 +11,7 @@ import ( "github.com/go-logr/logr" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" apimeta "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" @@ -140,21 +141,32 @@ func TestReconcile_AGitBranchThatDoesNotAdmitTheNamespace(t *testing.T) { assert.Contains(t, validated.Message, "GitBranch platform/main admits no namespace in spec.allowedGitTargets") } -// TestGitBranchWatches_MapToWhatTheyChange: a GitProvider event reaches the GitBranches in its -// namespace that name it; a GitTarget event reaches the GitBranch it references, from any namespace. +// TestGitBranchWatches_MapToWhatTheyChange: a GitProvider event reaches the GitBranches that claim +// through it, from any namespace; a Namespace event reaches the GitBranches in it; a GitTarget event +// reaches the GitBranch it references, from any namespace. func TestGitBranchWatches_MapToWhatTheyChange(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, configbutleraiv1alpha3.AddToScheme(scheme)) - c := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + require.NoError(t, clientgoscheme.AddToScheme(scheme)) + tenant := branchObject("team-c", "live", "acme", "teams/team-c/live", time.Hour) + tenant.Spec.GitProviderRef.Namespace = "platform" + c := fake.NewClientBuilder().WithScheme(scheme). + WithIndex(&configbutleraiv1alpha3.GitBranch{}, GitBranchProviderIndex, IndexGitBranchProvider).WithObjects( branchObject("platform", "main", "acme", "main", time.Hour), branchObject("platform", "release", "acme", "release", time.Hour), branchObject("platform", "other", "mirror", "main", time.Hour), branchObject("team-b", "main", "acme", "main", time.Hour), + tenant, ).Build() r := &GitBranchReconciler{Client: c} - assert.Equal(t, []string{"platform/main", "platform/release"}, - requestsToKeys(r.gitProviderToGitBranches(context.Background(), branchProvider("platform", "acme")))) + assert.Equal(t, []string{"platform/main", "platform/release", "team-c/live"}, + requestsToKeys(r.gitProviderToGitBranches(context.Background(), branchProvider("platform", "acme"))), + "a GitBranch in another namespace claiming through the provider is reached too") + assert.Equal(t, []string{"team-b/main"}, + requestsToKeys(r.gitProviderToGitBranches(context.Background(), branchProvider("team-b", "acme")))) + assert.Equal(t, []string{"team-c/live"}, requestsToKeys(r.namespaceToGitBranches(context.Background(), + &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-c"}}))) target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "apps"}, @@ -208,10 +220,13 @@ func TestGitTargetsOn_FindsTheTargetsOfABranchOrItsProvider(t *testing.T) { }, } } - c := fake.NewClientBuilder().WithScheme(scheme).WithObjects( - branchObject("platform", "main", "acme", "main", time.Hour), + tenant := branchObject("team-c", "live", "acme", "teams/team-c/live", time.Hour) + tenant.Spec.GitProviderRef.Namespace = "platform" + c := fake.NewClientBuilder().WithScheme(scheme). + WithIndex(&configbutleraiv1alpha3.GitBranch{}, GitBranchProviderIndex, IndexGitBranchProvider).WithObjects( + branchObject("platform", "main", "acme", "main", time.Hour), tenant, target("platform", "infra", "", "main"), target("team-a", "apps", "platform", "main"), - target("team-a", "own", "", "main"), + target("team-a", "own", "", "main"), target("team-d", "shared", "team-c", "live"), ).Build() byBranch, err := gitTargetsOn(context.Background(), c, branchObject("platform", "main", "acme", "main", 0)) @@ -219,7 +234,7 @@ func TestGitTargetsOn_FindsTheTargetsOfABranchOrItsProvider(t *testing.T) { assert.Len(t, byBranch, 2) byProvider, err := gitTargetsOn(context.Background(), c, branchProvider("platform", "acme")) require.NoError(t, err) - assert.Len(t, byProvider, 2) + assert.Len(t, byProvider, 3, "including the targets of a GitBranch in another namespace that claims through it") none, err := gitTargetsOn(context.Background(), c, branchProvider("platform", "unused")) require.NoError(t, err) assert.Empty(t, none) diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 44686f81..6160a668 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -115,6 +115,8 @@ var _ = BeforeSuite(func() { }) Expect(err).NotTo(HaveOccurred()) + Expect(IndexGitBranches(ctx, mgr.GetFieldIndexer())).To(Succeed()) + testRuleStore = rulestore.NewStore() // Initialize WorkerManager for new architecture diff --git a/internal/controller/watchrule_controller.go b/internal/controller/watchrule_controller.go index 87a0cce4..816ef021 100644 --- a/internal/controller/watchrule_controller.go +++ b/internal/controller/watchrule_controller.go @@ -271,26 +271,8 @@ func (r *WatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { // already suppresses no-op writes, so the loop has no fuel; this makes it structural, and // matches what GitProvider and KubeProvider already do. For(&configbutleraiv1alpha3.WatchRule{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). - // This rule MIRRORS the GitTarget's readiness into its own GitTargetReady, so the watch has - // to see the status-only update that carries a heal — GenerationChangedPredicate would drop - // exactly that. gitTargetReadyProjectionChanged fires on a spec change or on a move in the - // mirrored verdict, and on nothing else. - Watches( - &configbutleraiv1alpha3.GitTarget{}, - handler.EnqueueRequestsFromMapFunc(r.gitTargetToWatchRules), - builder.WithPredicates(gitTargetReadyProjectionChanged()), - ). - // React to a KubeProvider's grants changing. The GitTarget->WatchRules edge above still cannot be relied on to carry this: a - // KubeProvider change reaches the GitTarget as a status update that need not move the - // GitTarget's readiness at all, and only a readiness move re-enqueues the rules. Without - // this mapper, narrowing a grant would leave every affected WatchRule - // un-reconciled until its periodic requeue — so a REVOCATION would take minutes. - Watches( - &configbutleraiv1alpha3.KubeProvider{}, - handler.EnqueueRequestsFromMapFunc(r.kubeProviderToWatchRules), - builder.WithPredicates(kubeProviderReadyOrSpecChanged()), - ). Named("watchrule") + b = ruleDependencyWatches(b, r.gitTargetToWatchRules, r.kubeProviderToWatchRules, r.namespaceToWatchRules) if r.WatchManager != nil { // React to a stream of this rule's GitTarget reaching or leaving Streaming. Without it a @@ -307,6 +289,21 @@ func (r *WatchRuleReconciler) SetupWithManager(mgr ctrl.Manager) error { return b.Complete(r) } +// namespaceToWatchRules maps a Namespace label change to the WatchRules in it. A WatchRule feeds a +// GitTarget in its own namespace, which is the namespace the grants are evaluated for. +func (r *WatchRuleReconciler) namespaceToWatchRules(ctx context.Context, obj client.Object) []ctrlreconcile.Request { + var rules configbutleraiv1alpha3.WatchRuleList + if err := r.List(ctx, &rules, client.InNamespace(obj.GetName())); err != nil { + logDependencyListError(ctx, err, "WatchRules", obj) + return nil + } + requests := make([]ctrlreconcile.Request, 0, len(rules.Items)) + for i := range rules.Items { + requests = append(requests, ctrlreconcile.Request{NamespacedName: client.ObjectKeyFromObject(&rules.Items[i])}) + } + return requests +} + // kubeProviderToWatchRules maps a KubeProvider change to every WatchRule whose GitTarget // mirrors through that provider, so a delegation grant or revocation converges on the event rather // than on the periodic reconcile. diff --git a/internal/controller/wiring/access_grants_wiring_test.go b/internal/controller/wiring/access_grants_wiring_test.go new file mode 100644 index 00000000..5604bb47 --- /dev/null +++ b/internal/controller/wiring/access_grants_wiring_test.go @@ -0,0 +1,304 @@ +// SPDX-License-Identifier: Apache-2.0 + +package wiring + +import ( + "context" + "strings" + "testing" + "time" + + meta "github.com/fluxcd/pkg/apis/meta" + "github.com/go-logr/logr" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apimeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/manager" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" + "github.com/ConfigButler/gitops-reverser/internal/controller" + "github.com/ConfigButler/gitops-reverser/internal/git" + "github.com/ConfigButler/gitops-reverser/internal/rulestore" + itypes "github.com/ConfigButler/gitops-reverser/internal/types" + "github.com/ConfigButler/gitops-reverser/internal/watch" +) + +// Revocation and recovery are driven by watch events: a grant edit or a Namespace label change, and +// nothing on the consumer. Each wait is propagationBudget, shorter than any requeue that could +// rescue a missing watch. + +// conditionsOf reads the conditions of a WatchRule, GitBranch or GitTarget. +func conditionsOf(obj client.Object) []metav1.Condition { + switch typed := obj.(type) { + case *configbutleraiv1alpha3.WatchRule: + return typed.Status.Conditions + case *configbutleraiv1alpha3.ClusterWatchRule: + return typed.Status.Conditions + case *configbutleraiv1alpha3.GitBranch: + return typed.Status.Conditions + case *configbutleraiv1alpha3.GitTarget: + return typed.Status.Conditions + case *configbutleraiv1alpha3.FluxClaim: + return typed.Status.Conditions + } + return nil +} + +// awaitCondition polls key until its condition conditionType has reason want, or fails. +func awaitCondition( + ctx context.Context, t *testing.T, key types.NamespacedName, fresh func() client.Object, + conditionType, want string, budget time.Duration, +) *metav1.Condition { + t.Helper() + deadline := time.Now().Add(budget) + last := "absent" + for time.Now().Before(deadline) { + obj := fresh() + if err := k8sClient.Get(ctx, key, obj); err == nil { + if c := apimeta.FindStatusCondition(conditionsOf(obj), conditionType); c != nil { + if c.Reason == want { + return c + } + last = c.Reason + ": " + c.Message + } + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("%s %s never reached reason %q within %s (last seen %q)", key, conditionType, want, budget, last) + return nil +} + +// createNamespace creates a Namespace with labels. +func createNamespace(ctx context.Context, t *testing.T, name string, labels map[string]string) { + t.Helper() + require.NoError( + t, + k8sClient.Create(ctx, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: name, Labels: labels}}), + ) +} + +// setLabels replaces a Namespace's labels. +func setLabels(ctx context.Context, t *testing.T, name string, labels map[string]string) { + t.Helper() + require.NoError(t, retry.RetryOnConflict(retry.DefaultRetry, func() error { + ns := &corev1.Namespace{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: name}, ns); err != nil { + return err + } + ns.Labels = labels + return k8sClient.Update(ctx, ns) + })) +} + +// TestWatchRule_ANarrowedScopeRefusesOnALabelChange: a namespace matched by two grants loses the label +// of the one granting "*". Its GitTarget stays admitted, so its readiness does not move; only the +// WatchRule Namespace watch re-evaluates the rule, and a restored label admits it again. +func TestWatchRule_ANarrowedScopeRefusesOnALabelChange(t *testing.T) { + ctx := context.Background() + startManager(t, func(m manager.Manager) error { + return (&controller.WatchRuleReconciler{ + Client: m.GetClient(), Scheme: m.GetScheme(), RuleStore: rulestore.NewStore(), + }).SetupWithManager(m) + }) + + const namespace = "wiring-scope" + both := map[string]string{"mirror": "yes", "audit": "yes"} + createNamespace(ctx, t, namespace, both) + require.NoError(t, k8sClient.Create(ctx, &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "source", Namespace: namespace}, + Spec: configbutleraiv1alpha3.KubeProviderSpec{ + InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: []configbutleraiv1alpha3.SourceGrant{ + {From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirror": "yes"}}, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"{namespace}"}}, + {From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"audit": "yes"}}, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"*"}}, + }, + }, + })) + target := &configbutleraiv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "target", Namespace: namespace}, + Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: meta.NamespacedObjectReference{Name: "source"}, + GitBranchRef: meta.NamespacedObjectReference{Name: "provider-main"}, + Path: "mirror", + }, + } + require.NoError(t, k8sClient.Create(ctx, target)) + rule := &configbutleraiv1alpha3.WatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "rule", Namespace: namespace}, + Spec: configbutleraiv1alpha3.WatchRuleSpec{ + GitTargetRef: meta.LocalObjectReference{Name: "target"}, + Rules: []configbutleraiv1alpha3.ResourceRule{ + {Resources: []string{"configmaps"}, SourceNamespace: configbutleraiv1alpha3.SourceNamespaceWildcard}, + }, + }, + } + require.NoError(t, k8sClient.Create(ctx, rule)) + key := client.ObjectKeyFromObject(rule) + fresh := func() client.Object { return &configbutleraiv1alpha3.WatchRule{} } + + awaitCondition(ctx, t, key, fresh, controller.ConditionTypeSourceNamespaceAuthorized, + controller.WatchRuleReasonSourceNamespaceAllowed, settleBudget) + + setLabels(ctx, t, namespace, map[string]string{"mirror": "yes"}) + refused := awaitCondition(ctx, t, key, fresh, controller.ConditionTypeSourceNamespaceAuthorized, + controller.WatchRuleReasonSourceNamespaceNotAllowed, propagationBudget) + require.Contains(t, refused.Message, `watch "wiring-scope"`) + decision, err := authz.GitTargetAdmitted(ctx, k8sClient, target) + require.NoError(t, err) + require.True(t, decision.Allowed, "the GitTarget stays admitted for its narrower scope") + + setLabels(ctx, t, namespace, both) + awaitCondition(ctx, t, key, fresh, controller.ConditionTypeSourceNamespaceAuthorized, + controller.WatchRuleReasonSourceNamespaceAllowed, propagationBudget) +} + +// registerBranchAndTargetControllers installs the GitBranch and GitTarget controllers, the GitBranch +// index they find a provider's GitBranches through, and the worker manager that holds the claims. +func registerBranchAndTargetControllers(m manager.Manager) error { + if err := controller.IndexGitBranches(context.Background(), m.GetFieldIndexer()); err != nil { + return err + } + workers := git.NewWorkerManager(m.GetClient(), logr.Discard(), git.BranchWorkerLimits{}, + itypes.SensitiveResourcePolicy{}) + if err := m.Add(workers); err != nil { + return err + } + if err := (&controller.GitBranchReconciler{ + Client: m.GetClient(), Scheme: m.GetScheme(), WorkerManager: workers, + }).SetupWithManager(m); err != nil { + return err + } + return (&controller.GitTargetReconciler{ + Client: m.GetClient(), Scheme: m.GetScheme(), WorkerManager: workers, + EventRouter: watch.NewEventRouter(workers, nil, m.GetClient(), logr.Discard()), + }).SetupWithManager(m) +} + +// TestGitBranch_ALabelRemovedRevokesTheClaimAndItsGitTargetsFollow: a label removed from a claimant's +// namespace revokes its GitBranch through the GitBranch Namespace watch, and a GitTarget in another +// namespace writing through it follows. Restoring the label re-admits the GitBranch, which then +// tries to prove itself again. Then the branch owner's writer grant is withdrawn and restored, and +// the GitTarget is refused and admitted again, each on the GitBranch event alone. +func TestGitBranch_ALabelRemovedRevokesTheClaimAndItsGitTargetsFollow(t *testing.T) { + ctx := context.Background() + startManager(t, registerBranchAndTargetControllers) + + tenant := map[string]string{"tenant": "true"} + createNamespace(ctx, t, "wiring-platform", nil) + createNamespace(ctx, t, "wiring-tenant", tenant) + createNamespace(ctx, t, "wiring-writer", nil) + require.NoError(t, k8sClient.Create(ctx, &configbutleraiv1alpha3.GitProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "shared-repo", Namespace: "wiring-platform"}, + Spec: configbutleraiv1alpha3.GitProviderSpec{ + // Nothing listens here, so the push-session proof fails at once: the reasons tell an + // admitted GitBranch (PushSessionFailed) from a revoked one (NamespaceNotAuthorized). + URL: "https://127.0.0.1:1/platform/config.git", + AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: tenant}, + Branches: []configbutleraiv1alpha3.BranchPattern{"teams/{namespace}/*"}, + }}, + }, + })) + branch := &configbutleraiv1alpha3.GitBranch{ + ObjectMeta: metav1.ObjectMeta{Name: "live", Namespace: "wiring-tenant"}, + Spec: configbutleraiv1alpha3.GitBranchSpec{ + GitProviderRef: meta.NamespacedObjectReference{Name: "shared-repo", Namespace: "wiring-platform"}, + Branch: "teams/wiring-tenant/live", + AllowedGitTargets: []configbutleraiv1alpha3.NamespaceGrant{ + { + From: configbutleraiv1alpha3.GrantFromNames, + Names: []configbutleraiv1alpha3.GrantNamespace{"wiring-writer"}, + }, + }, + }, + } + require.NoError(t, k8sClient.Create(ctx, branch)) + require.NoError(t, k8sClient.Create(ctx, &configbutleraiv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "source", Namespace: "wiring-writer"}, + Spec: configbutleraiv1alpha3.KubeProviderSpec{ + InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, + AllowedGitTargets: []configbutleraiv1alpha3.SourceGrant{{From: configbutleraiv1alpha3.GrantFromSame, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"{namespace}"}}}, + }, + })) + target := &configbutleraiv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Name: "apps", Namespace: "wiring-writer"}, + Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: meta.NamespacedObjectReference{Name: "source"}, + GitBranchRef: meta.NamespacedObjectReference{Name: "live", Namespace: "wiring-tenant"}, + Path: "apps", + }, + } + require.NoError(t, k8sClient.Create(ctx, target)) + branchKey := client.ObjectKeyFromObject(branch) + freshBranch := func() client.Object { return &configbutleraiv1alpha3.GitBranch{} } + awaitCondition(ctx, t, branchKey, freshBranch, controller.ConditionTypeReady, + controller.GitBranchReasonPushSessionFailed, settleBudget) + + setLabels(ctx, t, "wiring-tenant", nil) + awaitCondition(ctx, t, branchKey, freshBranch, controller.ConditionTypeReady, + controller.GitBranchReasonNamespaceNotAuthorized, propagationBudget) + targetKey := client.ObjectKeyFromObject(target) + awaitTarget(ctx, t, targetKey, controller.GitTargetConditionGitBranchReady, + func(c *metav1.Condition) bool { return strings.Contains(c.Message, "spec.allowedGitBranches") }) + + setLabels(ctx, t, "wiring-tenant", tenant) + awaitCondition(ctx, t, branchKey, freshBranch, controller.ConditionTypeReady, + controller.GitBranchReasonPushSessionFailed, propagationBudget) + + // The branch owner removes the writer's namespace from allowedGitTargets, and adds it back: the + // GitBranch edit reaches the GitTarget in the other namespace each time. + refused := func(c *metav1.Condition) bool { return c.Reason == controller.GitTargetReasonNamespaceNotAuthorized } + admitted := func(c *metav1.Condition) bool { return !refused(c) } + writers := branch.Spec.AllowedGitTargets + setWriters(ctx, t, branchKey, []configbutleraiv1alpha3.NamespaceGrant{{From: configbutleraiv1alpha3.GrantFromSame}}) + awaitTarget(ctx, t, targetKey, controller.GitTargetConditionValidated, refused) + setWriters(ctx, t, branchKey, writers) + awaitTarget(ctx, t, targetKey, controller.GitTargetConditionValidated, admitted) +} + +// awaitTarget polls a GitTarget until its condition conditionType satisfies want, within +// propagationBudget. +func awaitTarget( + ctx context.Context, t *testing.T, key types.NamespacedName, conditionType string, + want func(*metav1.Condition) bool, +) { + t.Helper() + deadline := time.Now().Add(propagationBudget) + var last *metav1.Condition + for time.Now().Before(deadline) { + got := &configbutleraiv1alpha3.GitTarget{} + require.NoError(t, k8sClient.Get(ctx, key, got)) + if last = apimeta.FindStatusCondition(got.Status.Conditions, conditionType); last != nil && want(last) { + return + } + time.Sleep(50 * time.Millisecond) + } + t.Fatalf("GitTarget %s %s never reached what was waited for within %s: %+v", key, conditionType, + propagationBudget, last) +} + +// setWriters replaces a GitBranch's allowedGitTargets. +func setWriters( + ctx context.Context, t *testing.T, key types.NamespacedName, grants []configbutleraiv1alpha3.NamespaceGrant, +) { + t.Helper() + require.NoError(t, retry.RetryOnConflict(retry.DefaultRetry, func() error { + stored := &configbutleraiv1alpha3.GitBranch{} + if err := k8sClient.Get(ctx, key, stored); err != nil { + return err + } + stored.Spec.AllowedGitTargets = grants + return k8sClient.Update(ctx, stored) + })) +} diff --git a/internal/controller/wiring/readiness_wiring_test.go b/internal/controller/wiring/readiness_wiring_test.go index 8db177c0..8d2b14a6 100644 --- a/internal/controller/wiring/readiness_wiring_test.go +++ b/internal/controller/wiring/readiness_wiring_test.go @@ -26,7 +26,9 @@ import ( "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "k8s.io/client-go/util/retry" + "k8s.io/utils/ptr" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/config" "sigs.k8s.io/controller-runtime/pkg/envtest" "sigs.k8s.io/controller-runtime/pkg/manager" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" @@ -90,6 +92,8 @@ func startManager(t *testing.T, register func(manager.Manager) error) { Scheme: scheme.Scheme, Metrics: metricsserver.Options{BindAddress: "0"}, LeaderElection: false, + // Each test starts its own manager in this one process, some with the same controllers. + Controller: config.Controller{SkipNameValidation: ptr.To(true)}, }) require.NoError(t, err) require.NoError(t, register(mgr)) From cee08e43ae915e1150bfc67aa9352aef1a47e9c9 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 17:49:10 +0000 Subject: [PATCH 13/34] feat(watch): bound ClusterWatchRules by the grant's clusterResources A ClusterWatchRule compiles only for the cluster-scoped types the grants that admit its GitTarget give in clusterResources (resource.group, a bare name for the core group, or "*"). The decision is on the rule's text: an item with "*" in apiGroups or resources needs "*", named pairs are checked pair by pair, and an item with apiGroups omitted is checked by the group discovery resolves it to. One forbidden type refuses the whole rule (ResourcesResolved=False, ClusterResourceNotAllowed) and the message names it. The compiled rule keeps the granted identities, and the planner skips any match outside them, so discovery serving a new type later never widens a grant. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/applier/dispatcher_test.go | 4 +- .../applierclaim_controller_test.go | 4 +- .../clusterwatchrule_admission_test.go | 18 +- .../controller/clusterwatchrule_controller.go | 43 +++-- internal/controller/constants.go | 5 + .../gittarget_source_cluster_test.go | 2 +- .../watchrule_source_namespace_test.go | 2 +- internal/rulestore/object_selector_test.go | 2 +- internal/rulestore/store.go | 13 ++ internal/rulestore/store_test.go | 39 +++- internal/watch/bootstrap.go | 3 +- internal/watch/bootstrap_admission_test.go | 6 +- internal/watch/cluster_context.go | 28 +++ internal/watch/cluster_resources_test.go | 179 ++++++++++++++++++ .../config_plane_split_review_fixes_test.go | 2 +- internal/watch/object_selector_test.go | 4 +- internal/watch/watched_type_helpers_test.go | 4 +- internal/watch/watched_type_metrics_test.go | 8 +- internal/watch/watched_type_resolver.go | 5 + internal/watch/watched_type_resolver_test.go | 15 +- internal/watch/watchrule_compile.go | 107 ++++++++++- 21 files changed, 433 insertions(+), 60 deletions(-) create mode 100644 internal/watch/cluster_resources_test.go diff --git a/internal/applier/dispatcher_test.go b/internal/applier/dispatcher_test.go index cec28d01..d3373407 100644 --- a/internal/applier/dispatcher_test.go +++ b/internal/applier/dispatcher_test.go @@ -42,7 +42,7 @@ const ( ) // kubeProviderNS is where the tests' KubeProviders live: a namespace of their own, so whether one -// admits targetNS is its spec.accessFrom's decision rather than the own-namespace rule's. +// admits targetNS is decided by its grants. const kubeProviderNS = "clusters" // localCluster is the KubeProvider a test GitTarget mirrors through when it names none. @@ -472,7 +472,7 @@ func TestDispatcher_AsksAnApplierInItsOwnCluster(t *testing.T) { } // TestDispatcher_AnApplierClusterThatDoesNotAdmitAsksNothing: the applier's KubeProvider gates who -// may wield its credential exactly as a source's does, so a GitTarget its accessFrom does not admit +// may wield its credential exactly as a source's does, so a GitTarget its grants do not admit // asks nothing, though its own source admits it. func TestDispatcher_AnApplierClusterThatDoesNotAdmitAsksNothing(t *testing.T) { hub := kustomizationApplier("hub", fluxSystemNS, "checkout") diff --git a/internal/controller/applierclaim_controller_test.go b/internal/controller/applierclaim_controller_test.go index 80b0f3d9..028c9044 100644 --- a/internal/controller/applierclaim_controller_test.go +++ b/internal/controller/applierclaim_controller_test.go @@ -101,7 +101,7 @@ func applierSim(t *testing.T, objs ...runtime.Object) *fluxsim.Sim { } // The applier claim tests reach their appliers through KubeProviders in claimProviderNamespace, a -// namespace of their own, so a provider admits team-a only through spec.accessFrom: the namespace +// namespace of their own, so a provider admits team-a only through a grant: the namespace // policy is what those tests exercise. claimLocalProvider is the operator's own cluster there. const ( claimProviderNamespace = "clusters" @@ -464,7 +464,7 @@ func TestApplierClaimReconcile_AHangingClusterIsBounded(t *testing.T) { } // TestApplierClaimReconcile_TheClaimsKubeProviderAdmitsIt: the applier is reached with the -// applier claim's KubeProvider's credential, so that provider's accessFrom gates the check before the +// applier claim's KubeProvider's credential, so that provider's allowedApplierClaims gate the check before the // cluster is ever asked. func TestApplierClaimReconcile_TheClaimsKubeProviderAdmitsIt(t *testing.T) { cases := []struct { diff --git a/internal/controller/clusterwatchrule_admission_test.go b/internal/controller/clusterwatchrule_admission_test.go index 16b3fe14..9dac0248 100644 --- a/internal/controller/clusterwatchrule_admission_test.go +++ b/internal/controller/clusterwatchrule_admission_test.go @@ -35,6 +35,8 @@ type cwaWatchManager struct { onReconcile func() // collectionOverlap, when set, is the refusal message both CollectionOverlap methods report. collectionOverlap string + // groupsFor is what ClusterResourceGroupsFor resolves an omitted apiGroups with. + groupsFor func(string) []string } func (m *cwaWatchManager) TriggerRuleChange(internaltypes.ResourceReference) { @@ -93,6 +95,10 @@ func (m *cwaWatchManager) StreamSummaryForClusterWatchRule( func (m *cwaWatchManager) StreamStateEvents() <-chan event.GenericEvent { return nil } +func (m *cwaWatchManager) ClusterResourceGroupsFor(configbutleraiv1alpha3.GitTarget) func(string) []string { + return m.groupsFor +} + func cwaRunningSummary() watch.StreamSummary { return watch.StreamSummary{ Total: 1, Ready: 1, Reason: "Streaming", Message: "1/1 streams running", @@ -106,7 +112,7 @@ const ( cwaTargetName = "prod-mirror" cwaTargetNS = "team-a" cwaProviderName = "prod-eu-1" - // cwaProviderNS holds the KubeProvider, so cwaTargetNS reaches it only through accessFrom. + // cwaProviderNS holds the KubeProvider, so cwaTargetNS reaches it only through a grant. cwaProviderNS = "platform" ) @@ -263,7 +269,7 @@ func TestReconcile_ClusterWatchRuleRefusedWhenTargetNamespaceUnauthorized(t *tes // TestReconcile_ClusterWatchRuleRefusedWhenKubeProviderMissing covers the other half of the // shared gate: an undeclared provider is a hard denial, so an operator cannot sidestep -// accessFrom by simply never creating the provider. +// a grant by simply never creating the provider. func TestReconcile_ClusterWatchRuleRefusedWhenKubeProviderMissing(t *testing.T) { ctx := context.Background() f := newCWAFixture(t, []client.Object{ @@ -315,7 +321,7 @@ func TestReconcile_RefusalStopsDataPlaneBeforePublishingStatus(t *testing.T) { // Pre-seed the store as an earlier, admitted reconcile would have left it, so "no compiled // rule at the end" cannot pass vacuously. f.store.AddOrUpdateClusterWatchRule( - *cwaClusterWatchRule(), cwaTargetName, cwaTargetNS, "git", cwaTargetNS, "clusters/prod") + *cwaClusterWatchRule(), []string{"*"}, cwaTargetName, cwaTargetNS, "git", cwaTargetNS, "clusters/prod") require.Len(t, f.compiledNames(), 1, "precondition: the rule starts out compiled") f.wm.onReconcile = func() { @@ -378,7 +384,7 @@ func TestReconcile_AdmittedBySelectorOnNamespaceLabels(t *testing.T) { } // TestReconcile_RevocationRemovesCompiledRule is the revocation case: a rule that was admitted and -// running must be torn down when the provider's accessFrom stops admitting its target's +// running must be torn down when the provider's grants stop admitting its target's // namespace. Same terminal status as an initial denial. func TestReconcile_RevocationRemovesCompiledRule(t *testing.T) { ctx := context.Background() @@ -394,7 +400,7 @@ func TestReconcile_RevocationRemovesCompiledRule(t *testing.T) { require.Equal(t, []string{cwaRuleName}, f.compiledNames(), "precondition: the rule is running") replansAfterAdmission := f.wm.replans - // Revoke: the namespace leaves accessFrom. + // Revoke: the namespace leaves the grant. var provider configbutleraiv1alpha3.KubeProvider require.NoError(t, f.client.Get(ctx, k8stypes.NamespacedName{Namespace: cwaProviderNS, Name: cwaProviderName}, &provider)) @@ -435,7 +441,7 @@ func TestReconcile_AdmissionReadErrorRequeuesAndKeepsCompiledRule(t *testing.T) }) f.store.AddOrUpdateClusterWatchRule( - *cwaClusterWatchRule(), cwaTargetName, cwaTargetNS, "git", cwaTargetNS, "clusters/prod") + *cwaClusterWatchRule(), []string{"*"}, cwaTargetName, cwaTargetNS, "git", cwaTargetNS, "clusters/prod") _, err := f.reconcile(ctx) diff --git a/internal/controller/clusterwatchrule_controller.go b/internal/controller/clusterwatchrule_controller.go index 77e4b726..fbce9c3c 100644 --- a/internal/controller/clusterwatchrule_controller.go +++ b/internal/controller/clusterwatchrule_controller.go @@ -234,22 +234,16 @@ func (r *ClusterWatchRuleReconciler) gateClusterWatchRule( target configbutleraiv1alpha3.GitTarget, log logr.Logger, ) (bool, ctrl.Result, error) { + var groupsFor func(string) []string + if r.WatchManager != nil { + groupsFor = r.WatchManager.ClusterResourceGroupsFor(target) + } decision, err := watch.CompileClusterWatchRule( - ctx, r.Client, r.RuleStore, *clusterRule, target) + ctx, r.Client, r.RuleStore, *clusterRule, target, groupsFor) var selectorErr *watch.ObjectSelectorError if errors.As(err, &selectorErr) { - // A refusal of the rule's own resources, not of its GitTarget, so it lands on - // ResourcesResolved rather than on the condition refuseClusterWatchRule writes. - log.Info("Refusing ClusterWatchRule", "name", clusterRule.Name, - "reason", watch.ReasonInvalidObjectSelector, "message", selectorErr.Message) - if r.WatchManager != nil { - r.WatchManager.TriggerRuleChange(clusterWatchRuleGitTarget(clusterRule)) - } - st.set(ConditionTypeResourcesResolved, metav1.ConditionFalse, - watch.ReasonInvalidObjectSelector, selectorErr.Message) - st.set(ConditionTypeStreamsRunning, metav1.ConditionFalse, - watch.ReasonInvalidObjectSelector, "No streams: the ClusterWatchRule was refused") - result, stallErr := r.stallRule(ctx, st, watch.ReasonInvalidObjectSelector, selectorErr.Message) + result, stallErr := r.refuseRuleResources(ctx, st, clusterRule, watch.ReasonInvalidObjectSelector, + selectorErr.Message, log) return true, result, stallErr } if err != nil { @@ -262,11 +256,34 @@ func (r *ClusterWatchRuleReconciler) gateClusterWatchRule( if decision.Admitted { return false, ctrl.Result{}, nil } + if decision.ResourcesRefused { + result, stallErr := r.refuseRuleResources(ctx, st, clusterRule, decision.Reason, decision.Message, log) + return true, result, stallErr + } result, refuseErr := r.refuseClusterWatchRule(ctx, st, clusterRule, decision, log) return true, result, refuseErr } +// refuseRuleResources is the refusal of the rule's own resources rather than of its GitTarget, so it +// lands on ResourcesResolved rather than on the condition refuseClusterWatchRule writes. The compiled +// rule is already out of the store; the replan tears down what it kept alive. +func (r *ClusterWatchRuleReconciler) refuseRuleResources( + ctx context.Context, + st *reconcileStatus, + clusterRule *configbutleraiv1alpha3.ClusterWatchRule, + reason, message string, + log logr.Logger, +) (ctrl.Result, error) { + log.Info("Refusing ClusterWatchRule", "name", clusterRule.Name, "reason", reason, "message", message) + if r.WatchManager != nil { + r.WatchManager.TriggerRuleChange(clusterWatchRuleGitTarget(clusterRule)) + } + st.set(ConditionTypeResourcesResolved, metav1.ConditionFalse, reason, message) + st.set(ConditionTypeStreamsRunning, metav1.ConditionFalse, reason, "No streams: the ClusterWatchRule was refused") + return r.stallRule(ctx, st, reason, message) +} + // refuseClusterWatchRule is the denial half of the ClusterWatchRule gate. // // Order is the contract, not an implementation detail: CompileClusterWatchRule has ALREADY removed diff --git a/internal/controller/constants.go b/internal/controller/constants.go index 3a6b2b46..794c1f31 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -43,6 +43,11 @@ type WatchManagerInterface interface { StreamSummaryForWatchRule(rule configv1alpha3.WatchRule) watch.StreamSummary StreamSummaryForClusterWatchRule(rule configv1alpha3.ClusterWatchRule) watch.StreamSummary + // ClusterResourceGroupsFor resolves, in the cluster a GitTarget mirrors from, the groups that + // serve a cluster-scoped resource by name, so a ClusterWatchRule item that omits apiGroups is + // checked against the grant by the pair it resolves to. + ClusterResourceGroupsFor(target configv1alpha3.GitTarget) func(resource string) []string + // StreamStateEvents is the channel a rule controller wires via source.Channel so a stream // reaching (or leaving) Streaming re-reconciles the rules that project it, instead of leaving // them to discover it on RequeueStreamSettleInterval. A stream coming up is the last thing diff --git a/internal/controller/gittarget_source_cluster_test.go b/internal/controller/gittarget_source_cluster_test.go index b5224ecd..50472355 100644 --- a/internal/controller/gittarget_source_cluster_test.go +++ b/internal/controller/gittarget_source_cluster_test.go @@ -666,7 +666,7 @@ func TestObserveSourceReachable_Defaults(t *testing.T) { } // TestKubeProviderToGitTargets: a provider event requeues the GitTargets that mirror through it, -// in any namespace its accessFrom may admit, matched on the provider's namespace AND name. +// in any namespace its grants may admit, matched on the provider's namespace AND name. func TestKubeProviderToGitTargets(t *testing.T) { target := func(namespace, name string, ref meta.NamespacedObjectReference) *configbutleraiv1alpha3.GitTarget { return &configbutleraiv1alpha3.GitTarget{ diff --git a/internal/controller/watchrule_source_namespace_test.go b/internal/controller/watchrule_source_namespace_test.go index 57e58755..08dcc291 100644 --- a/internal/controller/watchrule_source_namespace_test.go +++ b/internal/controller/watchrule_source_namespace_test.go @@ -29,7 +29,7 @@ const ( wrsnTarget = "acme" wrsnRule = "repo-config-rule" wrsnProvider = "workspaces" - // wrsnProviderNS holds the KubeProvider, so wrsnTenantNS reaches it only through accessFrom. + // wrsnProviderNS holds the KubeProvider, so wrsnTenantNS reaches it only through a grant. wrsnProviderNS = "platform" ) diff --git a/internal/rulestore/object_selector_test.go b/internal/rulestore/object_selector_test.go index a5d0804a..5a9935c1 100644 --- a/internal/rulestore/object_selector_test.go +++ b/internal/rulestore/object_selector_test.go @@ -68,7 +68,7 @@ func TestAddOrUpdateClusterWatchRule_CompilesTheCanonicalObjectSelector(t *testi }}, }, } - store.AddOrUpdateClusterWatchRule(rule, "target", "ns", "p", "ns", "path") + store.AddOrUpdateClusterWatchRule(rule, []string{"*"}, "target", "ns", "p", "ns", "path") rules := store.SnapshotClusterWatchRules() if len(rules) != 1 || len(rules[0].Rules) != 1 { diff --git a/internal/rulestore/store.go b/internal/rulestore/store.go index a72ef8d2..e820b399 100644 --- a/internal/rulestore/store.go +++ b/internal/rulestore/store.go @@ -7,6 +7,7 @@ It provides efficient lookup and matching of Kubernetes resources against active package rulestore import ( + "slices" "strings" "sync" @@ -141,10 +142,20 @@ type CompiledClusterRule struct { // CreatedAt is the ClusterWatchRule's creation timestamp; see CompiledRule.CreatedAt. CreatedAt metav1.Time + // ClusterResources are the cluster-scoped resource identities the KubeProvider's grants gave the + // GitTarget when the rule compiled ("*" for every one). The planner watches no other type, so + // discovery serving a new type later never widens what the rule mirrors. + ClusterResources []string + // Rules contains the compiled cluster resource rules with per-rule scope. Rules []CompiledClusterResourceRule } +// AllowsType reports whether the grant the rule compiled under covers resource in group. +func (c CompiledClusterRule) AllowsType(group, resource string) bool { + return configv1alpha3.SourceScope{ClusterResources: c.ClusterResources}.AllowsClusterResource(group, resource) +} + // CompiledClusterResourceRule represents a single cluster resource rule. // // It carries no scope: a ClusterWatchRule is cluster-scope-only, so resolution always matches @@ -265,6 +276,7 @@ func (s *RuleStore) GetWatchRule(key types.NamespacedName) (CompiledRule, bool) // - path: POSIX-like relative path prefix for writes (from GitTarget.Spec.Path, sanitized upstream) func (s *RuleStore) AddOrUpdateClusterWatchRule( rule configv1alpha3.ClusterWatchRule, + clusterResources []string, gitTargetName string, gitTargetNamespace string, gitBranchName string, @@ -287,6 +299,7 @@ func (s *RuleStore) AddOrUpdateClusterWatchRule( GitBranchNamespace: gitBranchNamespace, Path: path, CreatedAt: rule.CreationTimestamp, + ClusterResources: slices.Clone(clusterResources), Rules: make([]CompiledClusterResourceRule, 0, len(rule.Spec.Rules)), } diff --git a/internal/rulestore/store_test.go b/internal/rulestore/store_test.go index 3aea2ee4..b6359545 100644 --- a/internal/rulestore/store_test.go +++ b/internal/rulestore/store_test.go @@ -137,7 +137,7 @@ func TestAddOrUpdateClusterWatchRule(t *testing.T) { // Add rule store.AddOrUpdateClusterWatchRule( - rule, + rule, []string{"*"}, "test-cluster-target", "gitops-system", "cluster-provider", @@ -222,7 +222,15 @@ func TestDeleteClusterWatchRule(t *testing.T) { key := types.NamespacedName{Name: "delete-cluster-test", Namespace: ""} // Add rule - store.AddOrUpdateClusterWatchRule(rule, "test-dest", "gitops-system", "test-repo", "gitops-system", "test") + store.AddOrUpdateClusterWatchRule( + rule, + []string{"*"}, + "test-dest", + "gitops-system", + "test-repo", + "gitops-system", + "test", + ) // Verify it exists if _, exists := store.clusterRules[key]; !exists { @@ -431,7 +439,7 @@ func TestGetMatchingClusterRules(t *testing.T) { podRule.Name = "pod-cluster-rule" store.AddOrUpdateClusterWatchRule( - clusterRule, + clusterRule, []string{"*"}, "dest1", "gitops-system", "repo1", @@ -439,7 +447,7 @@ func TestGetMatchingClusterRules(t *testing.T) { "cluster", ) store.AddOrUpdateClusterWatchRule( - podRule, + podRule, []string{"*"}, "dest2", "gitops-system", "repo2", @@ -664,7 +672,15 @@ func TestSnapshotClusterWatchRules(t *testing.T) { } rule1.Name = "cluster-rule1" - store.AddOrUpdateClusterWatchRule(rule1, "dest1", "gitops-system", "repo1", "gitops-system", "cluster") + store.AddOrUpdateClusterWatchRule( + rule1, + []string{"*"}, + "dest1", + "gitops-system", + "repo1", + "gitops-system", + "cluster", + ) snapshot := store.SnapshotClusterWatchRules() @@ -838,6 +854,7 @@ func TestGetMatchingRules_NamespacedWatchRule_NamespaceContract(t *testing.T) { }}, }, }, + []string{"*"}, "cluster-target", "ops", "cluster-provider", "ops", "cluster", ) @@ -1009,7 +1026,15 @@ func TestSharedSelector_RoutesAlikeThroughBothEntryPoints(t *testing.T) { }, } clusterRule.Name = "cluster-rule" - store.AddOrUpdateClusterWatchRule(clusterRule, "target", "tenant-acme", "provider", "tenant-acme", "clusters") + store.AddOrUpdateClusterWatchRule( + clusterRule, + []string{"*"}, + "target", + "tenant-acme", + "provider", + "tenant-acme", + "clusters", + ) inScope := &unstructured.Unstructured{} inScope.SetNamespace("tenant-acme") @@ -1168,7 +1193,7 @@ func TestStore_OwnsItsSelectorSlices(t *testing.T) { }, } rule.Name = "cluster-rule" - store.AddOrUpdateClusterWatchRule(rule, "target", "tenant-acme", "provider", + store.AddOrUpdateClusterWatchRule(rule, []string{"*"}, "target", "tenant-acme", "provider", "tenant-acme", "clusters") return func() { diff --git a/internal/watch/bootstrap.go b/internal/watch/bootstrap.go index 2c5be40d..37e0d1c9 100644 --- a/internal/watch/bootstrap.go +++ b/internal/watch/bootstrap.go @@ -93,7 +93,8 @@ func (m *Manager) bootstrapClusterWatchRule(ctx context.Context, rule configv1al // target the provider never admitted. A refusal is not fatal to startup: the // rule is simply left out of the store, and the reconciler's own gate re-decides (and can grant // it) as soon as the controller's initial sync reaches this rule. - decision, err := CompileClusterWatchRule(ctx, m.Client, m.RuleStore, rule, target) + decision, err := CompileClusterWatchRule(ctx, m.Client, m.RuleStore, rule, target, + m.ClusterResourceGroupsFor(target)) if err != nil { return fmt.Errorf("evaluating admission for ClusterWatchRule %q: %w", rule.Name, err) } diff --git a/internal/watch/bootstrap_admission_test.go b/internal/watch/bootstrap_admission_test.go index f310c839..f6fa5826 100644 --- a/internal/watch/bootstrap_admission_test.go +++ b/internal/watch/bootstrap_admission_test.go @@ -30,7 +30,7 @@ const ( bootTargetNS = "team-a" bootProviderName = "prod-eu-1" // bootProviderNS is the KubeProvider's namespace, not the GitTarget's, so whether it admits the - // GitTarget is its spec.accessFrom's decision rather than the own-namespace rule's. + // GitTarget is decided by a Names grant in its spec.allowedGitTargets. bootProviderNS = "platform" ) @@ -226,7 +226,7 @@ func TestCompileClusterWatchRule_RefusalRemovesAnAlreadyCompiledRule(t *testing. ) decision, err := CompileClusterWatchRule( - ctx, m.Client, m.RuleStore, *bootClusterWatchRule(), *bootGitTarget()) + ctx, m.Client, m.RuleStore, *bootClusterWatchRule(), *bootGitTarget(), nil) require.NoError(t, err) require.True(t, decision.Admitted) require.Len(t, bootCompiledNames(m), 1, "precondition: the rule is compiled") @@ -239,7 +239,7 @@ func TestCompileClusterWatchRule_RefusalRemovesAnAlreadyCompiledRule(t *testing. require.NoError(t, m.Client.Update(ctx, &provider)) decision, err = CompileClusterWatchRule( - ctx, m.Client, m.RuleStore, *bootClusterWatchRule(), *bootGitTarget()) + ctx, m.Client, m.RuleStore, *bootClusterWatchRule(), *bootGitTarget(), nil) require.NoError(t, err) assert.False(t, decision.Admitted) diff --git a/internal/watch/cluster_context.go b/internal/watch/cluster_context.go index 716f6b35..e08e9a2f 100644 --- a/internal/watch/cluster_context.go +++ b/internal/watch/cluster_context.go @@ -6,6 +6,7 @@ import ( "context" "errors" "fmt" + "slices" "sort" "strings" "sync" @@ -864,3 +865,30 @@ func (m *Manager) publishClusterOrderLocked() { } m.clusterOrder.Store(&out) } + +// ClusterResourceGroupsFor resolves, in the cluster target mirrors from, the API groups that serve a +// cluster-scoped resource by name: what a ClusterWatchRule item with apiGroups omitted resolves to. +// It answers only once the target's cluster has been captured and discovered; before that it +// resolves nothing, and the compiled rule's granted identities still bound what is planned. +func (m *Manager) ClusterResourceGroupsFor(target configv1alpha3.GitTarget) func(resource string) []string { + ref := types.NewResourceReference(target.Name, target.Namespace) + return func(resource string) []string { + clusterID, captured := m.watchPlane().clusters[ref.Key()] + if !captured { + return nil + } + cc := m.clusterContextByID(clusterID) + if cc == nil { + return nil + } + var groups []string + for _, rec := range cc.registry.Followable() { + gvr := rec.Identity.GVR + if rec.Identity.Scope != typeset.ScopeNamespaced && gvr.Resource == resource && + !slices.Contains(groups, gvr.Group) { + groups = append(groups, gvr.Group) + } + } + return groups + } +} diff --git a/internal/watch/cluster_resources_test.go b/internal/watch/cluster_resources_test.go new file mode 100644 index 00000000..b6bc1361 --- /dev/null +++ b/internal/watch/cluster_resources_test.go @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: Apache-2.0 + +package watch + +import ( + "context" + "testing" + + meta "github.com/fluxcd/pkg/apis/meta" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" +) + +// granting is the boot KubeProvider admitting the boot GitTarget's namespace with clusterResources +// identities; none at all gives it its own namespace and no cluster-scoped resource. +func granting(identities ...configv1alpha3.ClusterResourceIdentity) *configv1alpha3.KubeProvider { + grant := configv1alpha3.SourceGrant{ + From: configv1alpha3.GrantFromNames, Names: []configv1alpha3.GrantNamespace{bootTargetNS}, + ClusterResources: identities, + } + if len(identities) == 0 { + grant.SourceNamespaces = []configv1alpha3.ScopeNamespace{configv1alpha3.NamespaceVariable} + } + return bootKubeProvider([]configv1alpha3.SourceGrant{grant}) +} + +// clusterRuleFor is a ClusterWatchRule on the boot GitTarget with items. +func clusterRuleFor(name string, items ...configv1alpha3.ClusterResourceRule) configv1alpha3.ClusterWatchRule { + return configv1alpha3.ClusterWatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: configv1alpha3.ClusterWatchRuleSpec{ + GitTargetRef: meta.NamespacedObjectReference{Name: bootTargetName, Namespace: bootTargetNS}, + Rules: items, + }, + } +} + +func item(groups []string, resources ...string) configv1alpha3.ClusterResourceRule { + return configv1alpha3.ClusterResourceRule{APIGroups: groups, Resources: resources} +} + +var ( + rbacGroup = []string{"rbac.authorization.k8s.io"} + coreGroup = []string{""} +) + +// TestCompileClusterWatchRule_ClusterResources is decided on the rule's text: named pairs are checked +// pair by pair, a wildcard needs "*", and one forbidden type refuses the whole rule. +func TestCompileClusterWatchRule_ClusterResources(t *testing.T) { + clusterRoles := item(rbacGroup, "clusterroles") + tests := []struct { + name string + provider *configv1alpha3.KubeProvider + items []configv1alpha3.ClusterResourceRule + refused string + }{ + {"no clusterResources refuses clusterroles", granting(), []configv1alpha3.ClusterResourceRule{clusterRoles}, + `asks for "clusterroles.rbac.authorization.k8s.io"`}, + {"another identity refuses clusterroles", + granting("customresourcedefinitions.apiextensions.k8s.io"), + []configv1alpha3.ClusterResourceRule{clusterRoles}, `"clusterroles.rbac.authorization.k8s.io"`}, + {"a bare name is the core group only", granting("clusterroles"), + []configv1alpha3.ClusterResourceRule{clusterRoles}, `"clusterroles.rbac.authorization.k8s.io"`}, + {"its identity compiles clusterroles", granting("clusterroles.rbac.authorization.k8s.io"), + []configv1alpha3.ClusterResourceRule{clusterRoles}, ""}, + {"* compiles clusterroles", granting("*"), []configv1alpha3.ClusterResourceRule{clusterRoles}, ""}, + {"a bare name compiles a core resource", granting("nodes"), + []configv1alpha3.ClusterResourceRule{item(coreGroup, "nodes")}, ""}, + {"resources: * needs *", granting("clusterroles.rbac.authorization.k8s.io"), + []configv1alpha3.ClusterResourceRule{item(rbacGroup, "*")}, `needs clusterResources: ["*"]`}, + {"apiGroups: * needs *", granting("clusterroles.rbac.authorization.k8s.io"), + []configv1alpha3.ClusterResourceRule{item([]string{"*"}, "clusterroles")}, `needs clusterResources: ["*"]`}, + {"a wildcard compiles under *", granting("*"), + []configv1alpha3.ClusterResourceRule{item([]string{"*"}, "*")}, ""}, + {"one forbidden type refuses the whole rule, and is named", + granting("clusterroles.rbac.authorization.k8s.io"), + []configv1alpha3.ClusterResourceRule{clusterRoles, item(coreGroup, "nodes", "namespaces")}, + `spec.rules[1] asks for "nodes", "namespaces"`}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := bootManager(t, bootGitTarget(), tt.provider) + rule := clusterRuleFor("rule", tt.items...) + decision, err := CompileClusterWatchRule(context.Background(), m.Client, m.RuleStore, rule, + *bootGitTarget(), nil) + require.NoError(t, err) + if tt.refused == "" { + assert.True(t, decision.Admitted, decision.Message) + assert.Equal(t, []string{"rule"}, bootCompiledNames(m)) + return + } + assert.False(t, decision.Admitted) + assert.True(t, decision.ResourcesRefused) + assert.Equal(t, ClusterWatchRuleReasonClusterResourceNotAllowed, decision.Reason) + assert.Contains(t, decision.Message, tt.refused) + assert.Contains(t, decision.Message, "KubeProvider platform/prod-eu-1 lets namespace \"team-a\" mirror "+ + "cluster-scoped resources") + assert.Empty(t, bootCompiledNames(m), "a refused rule is never compiled") + }) + } +} + +// TestCompileClusterWatchRule_AnOmittedGroupIsCheckedByItsResolvedPair: with apiGroups omitted, the +// item is checked by the group discovery serves the resource from. +func TestCompileClusterWatchRule_AnOmittedGroupIsCheckedByItsResolvedPair(t *testing.T) { + m := bootManager(t, bootGitTarget(), granting("nodes")) + rule := clusterRuleFor("rule", item(nil, "clusterroles")) + rbac := func(string) []string { return rbacGroup } + + decision, err := CompileClusterWatchRule(context.Background(), m.Client, m.RuleStore, rule, *bootGitTarget(), rbac) + require.NoError(t, err) + assert.Equal(t, ClusterWatchRuleReasonClusterResourceNotAllowed, decision.Reason) + assert.Contains(t, decision.Message, `"clusterroles.rbac.authorization.k8s.io"`) + + // Unresolved, it watches nothing today, and compiles; the planner keeps it to the grant. + decision, err = CompileClusterWatchRule(context.Background(), m.Client, m.RuleStore, rule, *bootGitTarget(), nil) + require.NoError(t, err) + assert.True(t, decision.Admitted) + compiled := m.RuleStore.SnapshotClusterWatchRules() + require.Len(t, compiled, 1) + assert.Equal(t, []string{"nodes"}, compiled[0].ClusterResources) + assert.False(t, compiled[0].AllowsType("rbac.authorization.k8s.io", "clusterroles")) +} + +// TestCompileClusterWatchRule_RemovingAnIdentityRefusesOnlyTheRulesNamingIt: the GitTarget stays +// admitted, and its other rules keep compiling. +func TestCompileClusterWatchRule_RemovingAnIdentityRefusesOnlyTheRulesNamingIt(t *testing.T) { + m := bootManager(t, bootGitTarget(), granting("nodes", "clusterroles.rbac.authorization.k8s.io")) + nodes := clusterRuleFor("nodes", item(coreGroup, "nodes")) + roles := clusterRuleFor("roles", item(rbacGroup, "clusterroles")) + for _, rule := range []configv1alpha3.ClusterWatchRule{nodes, roles} { + decision, err := CompileClusterWatchRule( + context.Background(), + m.Client, + m.RuleStore, + rule, + *bootGitTarget(), + nil, + ) + require.NoError(t, err) + require.True(t, decision.Admitted, decision.Message) + } + + stored := &configv1alpha3.KubeProvider{} + require.NoError(t, m.Client.Get(context.Background(), granting().Key(), stored)) + stored.Spec.AllowedGitTargets[0].ClusterResources = []configv1alpha3.ClusterResourceIdentity{"nodes"} + require.NoError(t, m.Client.Update(context.Background(), stored)) + + decision, err := CompileClusterWatchRule(context.Background(), m.Client, m.RuleStore, roles, *bootGitTarget(), nil) + require.NoError(t, err) + assert.False(t, decision.Admitted) + decision, err = CompileClusterWatchRule(context.Background(), m.Client, m.RuleStore, nodes, *bootGitTarget(), nil) + require.NoError(t, err) + assert.True(t, decision.Admitted) + assert.Equal(t, []string{"nodes"}, bootCompiledNames(m)) + admitted, err := authz.GitTargetAdmitted(context.Background(), m.Client, bootGitTarget()) + require.NoError(t, err) + assert.True(t, admitted.Allowed, "the GitTarget stays admitted for its other rules") +} + +// TestRefreshWatchedTypeTables_DiscoveryNeverWidensAGrant: a compiled rule's matches outside the +// clusterResources it compiled under are never planned, so a type discovery serves later stays out. +func TestRefreshWatchedTypeTables_DiscoveryNeverWidensAGrant(t *testing.T) { + for _, tc := range []struct { + granted []string + types int + }{{[]string{"*"}, 1}, {[]string{"namespaces"}, 1}, {[]string{"nodes"}, 0}, {nil, 0}} { + manager, store := makeWatchedTypeManager(t) + store.AddOrUpdateClusterWatchRule(clusterRuleForResource("rule-1", "namespaces"), tc.granted, + "test-target", "test-ns", "test-provider", "test-ns", "test-path") + manager.refreshWatchedTypeTables() + table, _ := manager.watchedTypeTableForGitDest(gitDestRef("test-target")) + assert.Len(t, table.Types, tc.types, "granted %v", tc.granted) + } +} diff --git a/internal/watch/config_plane_split_review_fixes_test.go b/internal/watch/config_plane_split_review_fixes_test.go index 4bbc32f4..ab329f09 100644 --- a/internal/watch/config_plane_split_review_fixes_test.go +++ b/internal/watch/config_plane_split_review_fixes_test.go @@ -272,7 +272,7 @@ func TestRefreshWatchedTypeTables_RetargetReResolvesAtEqualRevisions(t *testing. "both remotes are one scan in, so their registry revisions are equal on purpose") store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "t", "test-ns", "test-provider", "test-ns", "test-path", ) m.rememberGitTargetCluster(gitDestRef("t"), clusterA) diff --git a/internal/watch/object_selector_test.go b/internal/watch/object_selector_test.go index 6cc4422f..5f921cb2 100644 --- a/internal/watch/object_selector_test.go +++ b/internal/watch/object_selector_test.go @@ -251,11 +251,11 @@ func TestCompileWatchRule_RefusesAnInvalidObjectSelector(t *testing.T) { func TestCompileClusterWatchRule_RefusesAnInvalidObjectSelector(t *testing.T) { store := rulestore.NewStore() rule := clusterRuleForResource("bad", "namespaces") - store.AddOrUpdateClusterWatchRule(rule, "t", "ns", "p", "ns", "path") + store.AddOrUpdateClusterWatchRule(rule, []string{"*"}, "t", "ns", "p", "ns", "path") rule.Spec.Rules[0].ObjectSelector = labels("bad key", "x") _, err := CompileClusterWatchRule(context.Background(), nil, store, rule, - configv1alpha3.GitTarget{}) + configv1alpha3.GitTarget{}, nil) var selectorErr *ObjectSelectorError require.ErrorAs(t, err, &selectorErr) diff --git a/internal/watch/watched_type_helpers_test.go b/internal/watch/watched_type_helpers_test.go index 649be200..d192595d 100644 --- a/internal/watch/watched_type_helpers_test.go +++ b/internal/watch/watched_type_helpers_test.go @@ -57,7 +57,7 @@ func watchRuleForTarget(name, gitTargetName, namespace string) configv1alpha3.Wa func TestRefreshWatchedTypeTables_ConcurrentRefreshesConverge(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) @@ -74,7 +74,7 @@ func TestRefreshWatchedTypeTables_ConcurrentRefreshesConverge(t *testing.T) { } // Concurrently add a second rule mid-flight. store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-2", "nodes"), + clusterRuleForResource("rule-2", "nodes"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) wg.Wait() diff --git a/internal/watch/watched_type_metrics_test.go b/internal/watch/watched_type_metrics_test.go index 4621e51a..01a48ae7 100644 --- a/internal/watch/watched_type_metrics_test.go +++ b/internal/watch/watched_type_metrics_test.go @@ -23,7 +23,7 @@ func TestWatchTypeSamples_SumIsTheResolvedTypeCount(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) manager.refreshWatchedTypeTables() @@ -58,7 +58,7 @@ func TestWatchTypeSamples_DepartedTargetStopsPublishing(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) manager.refreshWatchedTypeTables() @@ -93,7 +93,7 @@ func TestWatchTypeSamples_DoesNotResolveOnTheScrapePath(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) manager.refreshWatchedTypeTables() @@ -105,7 +105,7 @@ func TestWatchTypeSamples_DoesNotResolveOnTheScrapePath(t *testing.T) { // A second type is declared, and nothing refreshes the tables. store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-2", "nodes"), + clusterRuleForResource("rule-2", "nodes"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) diff --git a/internal/watch/watched_type_resolver.go b/internal/watch/watched_type_resolver.go index 7f7a0294..43c810f0 100644 --- a/internal/watch/watched_type_resolver.go +++ b/internal/watch/watched_type_resolver.go @@ -389,6 +389,11 @@ func (m *Manager) collectClusterWatchRuleSelections( matched := matchFollowableRecords( records, rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha3.ResourceScopeCluster) for _, rec := range matched { + // The compile refused every type the rule names outside its grant. This keeps a type + // discovery serves only later, matched through an omitted apiGroups, out as well. + if !rule.AllowsType(rec.Identity.GVR.Group, rec.Identity.GVR.Resource) { + continue + } ts.selections = append(ts.selections, watchSelection{ record: rec, namespace: "", labelSelector: rr.LabelSelector, rule: selecting, }) diff --git a/internal/watch/watched_type_resolver_test.go b/internal/watch/watched_type_resolver_test.go index f5ea4320..a6d7bd5d 100644 --- a/internal/watch/watched_type_resolver_test.go +++ b/internal/watch/watched_type_resolver_test.go @@ -38,7 +38,7 @@ func gitDestRef(name string) types.ResourceReference { func TestRefreshWatchedTypeTables_ClusterWatchRuleResolvesClusterWideType(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) @@ -79,7 +79,7 @@ func TestRefreshWatchedTypeTables_WatchRuleScopesTypeToItsNamespace(t *testing.T func TestRefreshWatchedTypeTables_RuleChangeReResolves(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) manager.refreshWatchedTypeTables() @@ -88,7 +88,7 @@ func TestRefreshWatchedTypeTables_RuleChangeReResolves(t *testing.T) { // A second rule selecting a different resource is reflected on the next refresh. store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-2", "nodes"), + clusterRuleForResource("rule-2", "nodes"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) manager.refreshWatchedTypeTables() @@ -108,7 +108,7 @@ func TestResolveWatchedTypeTables_NilRuleStoreIsEmpty(t *testing.T) { func TestRefreshWatchedTypeTables_NoChangeReusesResolvedTables(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) manager.refreshWatchedTypeTables() @@ -128,14 +128,14 @@ func TestRefreshWatchedTypeTables_NoChangeReusesResolvedTables(t *testing.T) { func TestRulesFingerprint_StableUntilRuleChanges(t *testing.T) { manager, store := makeWatchedTypeManager(t) store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "namespaces"), + clusterRuleForResource("rule-1", "namespaces"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) fp1 := manager.rulesFingerprint() assert.Equal(t, fp1, manager.rulesFingerprint(), "fingerprint must be stable for unchanged rules") store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-2", "nodes"), + clusterRuleForResource("rule-2", "nodes"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) assert.NotEqual(t, fp1, manager.rulesFingerprint(), "a new rule must move the fingerprint") @@ -146,7 +146,7 @@ func TestRefreshWatchedTypeTables_KeepsTargetWithUnresolvableRulesAsEmptyTable(t // "ghosts" is not served by the common catalog: the rule resolves to nothing, // but the GitTarget must still appear as an empty table, not vanish. store.AddOrUpdateClusterWatchRule( - clusterRuleForResource("rule-1", "ghosts"), + clusterRuleForResource("rule-1", "ghosts"), []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) manager.refreshWatchedTypeTables() @@ -174,6 +174,7 @@ func TestRefreshWatchedTypeTables_ExcludesAmbiguousGVK(t *testing.T) { }}, }, }, + []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", "test-path", ) diff --git a/internal/watch/watchrule_compile.go b/internal/watch/watchrule_compile.go index 3c6b0e94..6fa3655d 100644 --- a/internal/watch/watchrule_compile.go +++ b/internal/watch/watchrule_compile.go @@ -5,6 +5,9 @@ package watch import ( "context" "fmt" + "slices" + "strconv" + "strings" k8stypes "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -91,26 +94,30 @@ func itemNamespaces(resolved authz.ResolvedSourceScope) [][]string { return out } -// CompileClusterWatchRule is THE ONLY PATH from a ClusterWatchRule to a compiled cluster rule, and -// it is the compile-time half of the cluster-scope-only narrowing. +// CompileClusterWatchRule is THE ONLY PATH from a ClusterWatchRule to a compiled cluster rule. // // Two refusals, both terminal, in this order: // // 1. the referenced GitTarget's namespace must be admitted by that target's KubeProvider — a // ClusterWatchRule's gitTargetRef carries a namespace, so it can name a target in ANY namespace // and widen that target's mirror scope cluster-wide; -// 2. the rule must not carry a stored scope other than "Cluster". Admission rejects the value on -// write, but a pre-release object keeps it in etcd, and resolving it as if it had asked for -// cluster scope would silently change what a running rule mirrors. +// 2. every type the rule names must be in the clusterResources the grants that admit the target +// give it (clusterResourcesRefusal). One forbidden type refuses the whole rule. +// +// groupsFor resolves the resource of an item that omits apiGroups to the groups that serve it, by +// discovery; nil, or a cluster not discovered yet, resolves nothing. The compiled rule carries the +// granted clusterResources too, so the planner never watches a type the grant does not name, also +// one that discovery serves only later. // // Like CompileWatchRule it is shared by the reconciler and the startup bootstrap, so a restart -// cannot open an unauthorized or namespaced watch before the first reconcile can publish status. +// cannot open an unauthorized watch before the first reconcile can publish status. func CompileClusterWatchRule( ctx context.Context, reader client.Reader, store *rulestore.RuleStore, rule configv1alpha3.ClusterWatchRule, target configv1alpha3.GitTarget, + groupsFor func(resource string) []string, ) (ClusterWatchRuleDecision, error) { key := k8stypes.NamespacedName{Name: rule.Name} @@ -119,7 +126,7 @@ func CompileClusterWatchRule( return ClusterWatchRuleDecision{}, &ObjectSelectorError{Message: err.Error()} } - admitted, err := authz.GitTargetAdmitted(ctx, reader, &target) + admitted, scope, err := authz.GitTargetSourceScope(ctx, reader, &target) if err != nil { // Transient: leave whatever is compiled alone and let the caller requeue. return ClusterWatchRuleDecision{}, err @@ -132,9 +139,22 @@ func CompileClusterWatchRule( target.Namespace, target.Name, admitted.Message), }, nil } + if forbidden := clusterResourcesRefusal(&rule, scope, groupsFor); len(forbidden) > 0 { + store.DeleteClusterWatchRule(key) + return ClusterWatchRuleDecision{ + ResourcesRefused: true, + Reason: ClusterWatchRuleReasonClusterResourceNotAllowed, + Message: fmt.Sprintf( + "KubeProvider %s lets namespace %q mirror cluster-scoped resources %s "+ + "(spec.allowedGitTargets[].clusterResources); %s not allowed, so the ClusterWatchRule is refused", + target.KubeProviderKey(), target.Namespace, describeIdentities(scope.ClusterResources), + strings.Join(forbidden, "; ")), + }, nil + } store.AddOrUpdateClusterWatchRule( rule, + scope.ClusterResources, target.Name, target.Namespace, target.GitBranchKey().Name, target.GitBranchKey().Namespace, target.Spec.Path, @@ -146,6 +166,11 @@ func CompileClusterWatchRule( // bootstrap refuses on the same grounds without a controller in sight, and one vocabulary is what // keeps the two from drifting. const ( + // ClusterWatchRuleReasonClusterResourceNotAllowed is the terminal ResourcesResolved reason when + // the rule names a cluster-scoped type outside the clusterResources the KubeProvider's grants give + // the GitTarget's namespace, or "*" in apiGroups or resources without clusterResources: ["*"]. + ClusterWatchRuleReasonClusterResourceNotAllowed = "ClusterResourceNotAllowed" + // ClusterWatchRuleReasonGitTargetNamespaceNotAuthorized is the terminal reason when the // referenced GitTarget's namespace is not admitted by that target's KubeProvider — either // because no grant in its spec.allowedGitTargets admits it or because the provider does not @@ -161,6 +186,9 @@ const ( type ClusterWatchRuleDecision struct { // Admitted reports whether the rule compiled. Admitted bool + // ResourcesRefused reports a refusal of the rule's own resources rather than of its GitTarget, + // which lands on ResourcesResolved. + ResourcesRefused bool // Reason is the terminal condition reason when it did not. Reason string // Message explains the refusal to an operator. @@ -183,3 +211,68 @@ type ObjectSelectorError struct { } func (e *ObjectSelectorError) Error() string { return e.Message } + +// clusterResourcesRefusal names what a ClusterWatchRule asks for outside scope's clusterResources, +// decided on the rule's TEXT, as rules[].sourceNamespace: "*" is: an item with "*" in apiGroups or +// resources needs clusterResources: ["*"], and an item naming its groups and resources is checked +// pair by pair. An item that omits apiGroups is checked by the pair discovery resolves it to; one +// that resolves to no group, or to several, watches nothing today, and the planner's filter on the +// granted identities keeps it so when discovery changes. Under ["*"] nothing is refused. +func clusterResourcesRefusal( + rule *configv1alpha3.ClusterWatchRule, scope configv1alpha3.SourceScope, groupsFor func(string) []string, +) []string { + if scope.AllowsAllClusterResources() { + return nil + } + var forbidden []string + for i := range rule.Spec.Rules { + item := &rule.Spec.Rules[i] + if slices.Contains(item.APIGroups, "*") || slices.Contains(item.Resources, "*") { + forbidden = append(forbidden, fmt.Sprintf( + "spec.rules[%d] has \"*\" in apiGroups or resources, which needs clusterResources: [\"*\"]", i)) + continue + } + var types []string + for _, resource := range item.Resources { + for _, group := range itemGroups(item, resource, groupsFor) { + if !scope.AllowsClusterResource(group, resource) { + types = append(types, configv1alpha3.ClusterResourceIdentityFor(group, resource)) + } + } + } + if len(types) > 0 { + forbidden = append( + forbidden, + fmt.Sprintf("spec.rules[%d] asks for %s, which is", i, describeIdentities(types)), + ) + } + } + return forbidden +} + +// itemGroups is the groups an item's resource is checked in: the ones it names, or, with apiGroups +// omitted, the one group discovery serves it from. +func itemGroups(item *configv1alpha3.ClusterResourceRule, resource string, groupsFor func(string) []string) []string { + if len(item.APIGroups) > 0 { + return item.APIGroups + } + if groupsFor == nil { + return nil + } + if groups := groupsFor(resource); len(groups) == 1 { + return groups + } + return nil +} + +// describeIdentities renders cluster-resource identities for a message. +func describeIdentities(identities []string) string { + if len(identities) == 0 { + return "none" + } + quoted := make([]string, 0, len(identities)) + for _, identity := range identities { + quoted = append(quoted, strconv.Quote(identity)) + } + return strings.Join(quoted, ", ") +} From a47aac9ebc5fef8cd97b34278f63c573100b3109 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 17:55:18 +0000 Subject: [PATCH 14/34] feat(applier): hand an applier back when its claim is denied An applier claim that its KubeProvider's allowedApplierClaims refuse, by namespace or by applierNamespaces, takes the existing retire path: it stays the holder (Held=False, Releasing) while it hands back, keeps a Kustomization that was suspended before adoption suspended, and gives up after releaseDeadline with a Warning Event. A read error keeps the hold. A regrant during such a hand-back lets it finish; the applier claim then adopts its applier anew. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../controller/applierclaim_controller.go | 22 +- .../applierclaim_revocation_test.go | 211 ++++++++++++++++++ internal/controller/fluxclaim_hold.go | 51 ++++- internal/watch/source_namespace_test.go | 2 +- 4 files changed, 270 insertions(+), 16 deletions(-) create mode 100644 internal/controller/applierclaim_revocation_test.go diff --git a/internal/controller/applierclaim_controller.go b/internal/controller/applierclaim_controller.go index 8f32c8b8..f4828073 100644 --- a/internal/controller/applierclaim_controller.go +++ b/internal/controller/applierclaim_controller.go @@ -279,13 +279,22 @@ type claimCheck struct { // refs are the GitTargets naming the applier claim, judged; judged is false when they could not be. refs applier.References judged bool + // denied is a conclusive refusal by the KubeProvider's spec.allowedApplierClaims: the applier + // claim's namespace is not admitted, or its applier is outside the applierNamespaces it is given. + // A read error is never a denial. + denied bool } -// retiring reports whether claim holds its applier and no eligible GitTarget references it any -// more, conclusively: none names it, or each is refused. A reference that cannot be judged yet -// keeps the hold, since a release is not undone: Flux applies at once, and Argo CD syncs. +// retiring reports whether claim holds its applier and must hand it back, conclusively: its +// KubeProvider's grants refuse it, or no eligible GitTarget references it any more (none names it, +// or each is refused). A hand-back that began on a denial finishes even once a grant admits the +// applier claim again; it then adopts the applier anew. A reference that cannot be judged yet keeps +// the hold, since a release is not undone: Flux applies at once, and Argo CD syncs. func (c claimCheck) retiring(claim configbutleraiv1alpha3.ApplierClaim) bool { - return applier.Holding(claim) && c.judged && (!c.refs.Named() || c.refs.Refused()) + if !applier.Holding(claim) { + return false + } + return c.denied || releasingForDenial(claim) || (c.judged && (!c.refs.Named() || c.refs.Refused())) } // configuredVerdict judges the GitTargets naming the applier claim, and, while one may be eligible, @@ -321,7 +330,10 @@ func (r *ApplierClaimReconciler) configuredVerdict( case err != nil: return kept(fmt.Sprintf("Cannot check KubeProvider %s: %v", cluster, err)) case !decision.Allowed: - return falseCheck(decision.Reason, decision.Message, refs) + check := falseCheck(decision.Reason, decision.Message, refs) + check.denied = decision.Reason == authz.ReasonNamespaceNotAuthorized || + decision.Reason == authz.ReasonApplierNamespaceNotAllowed + return check case refs.Refused(): return falseCheck(refs.Reason(), refs.Message(), refs) } diff --git a/internal/controller/applierclaim_revocation_test.go b/internal/controller/applierclaim_revocation_test.go new file mode 100644 index 00000000..446e55d6 --- /dev/null +++ b/internal/controller/applierclaim_revocation_test.go @@ -0,0 +1,211 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + k8stypes "k8s.io/apimachinery/pkg/types" + k8stesting "k8s.io/client-go/testing" + ctrl "sigs.k8s.io/controller-runtime" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/interceptor" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/applier/fluxsim" + "github.com/ConfigButler/gitops-reverser/internal/authz" +) + +var localProviderKey = k8stypes.NamespacedName{Namespace: claimProviderNamespace, Name: claimLocalProvider} + +// setApplierGrants replaces the local KubeProvider's allowedApplierClaims. +func (f *holdFixture) setApplierGrants(t *testing.T, grants []configbutleraiv1alpha3.ApplierGrant) { + t.Helper() + provider := &configbutleraiv1alpha3.KubeProvider{} + require.NoError(t, f.r.Get(context.Background(), localProviderKey, provider)) + provider.Spec.AllowedApplierClaims = grants + require.NoError(t, f.r.Update(context.Background(), provider)) +} + +// reconcileUntil reconciles the applier claim until done reports true for it. +func (f *holdFixture) reconcileUntil(t *testing.T, done func(*configbutleraiv1alpha3.FluxClaim) bool) { + t.Helper() + require.Eventually(t, func() bool { + _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) + require.NoError(t, err) + return done(f.claim(t)) + }, 5*time.Second, 5*time.Millisecond) +} + +func handedBack(claim *configbutleraiv1alpha3.FluxClaim) bool { + return !controllerutil.ContainsFinalizer(claim, configbutleraiv1alpha3.FluxClaimFinalizer) +} + +// TestFluxClaim_ADenialHandsTheKustomizationBack: an applier claim its KubeProvider's +// allowedApplierClaims no longer admit takes the retire path: it hands its Kustomization back and lets +// go, and says why. +func TestFluxClaim_ADenialHandsTheKustomizationBack(t *testing.T) { + f := newHoldFixture(t, testFluxClaim(claimLocalProvider)) + reconcileClaim(t, f.r) + require.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization))) + f.events() + + f.setApplierGrants(t, nil) + f.reconcileUntil(t, handedBack) + + got := f.claim(t) + assert.False(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "handed back to Flux") + configured := findCondition(got.Status.Conditions, ApplierClaimConditionConfigured) + require.NotNil(t, configured) + assert.Equal(t, authz.ReasonNamespaceNotAuthorized, configured.Reason) + assert.Contains(t, configured.Message, "spec.allowedApplierClaims") + held := findCondition(got.Status.Conditions, ApplierClaimConditionHeld) + require.NotNil(t, held) + assert.Contains(t, held.Message, "held again once the KubeProvider's spec.allowedApplierClaims admit") + events := f.events() + require.Len(t, events, 1) + assert.Contains(t, events[0], EventReasonReleased) +} + +// TestFluxClaim_AReadErrorKeepsTheHold: a KubeProvider that cannot be read is no denial, so nothing +// is handed back. +func TestFluxClaim_AReadErrorKeepsTheHold(t *testing.T) { + f := newHoldFixture(t, testFluxClaim(claimLocalProvider)) + reconcileClaim(t, f.r) + f.events() + f.r.Client = interceptor.NewClient(f.r.Client.(ctrlclient.WithWatch), interceptor.Funcs{ + Get: func(ctx context.Context, c ctrlclient.WithWatch, key ctrlclient.ObjectKey, obj ctrlclient.Object, + opts ...ctrlclient.GetOption) error { + if _, ok := obj.(*configbutleraiv1alpha3.KubeProvider); ok { + return errors.New("apiserver unavailable") + } + return c.Get(ctx, key, obj, opts...) + }, + }) + + _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) + require.NoError(t, err) + + got := f.claim(t) + assert.True(t, controllerutil.ContainsFinalizer(got, configbutleraiv1alpha3.FluxClaimFinalizer)) + assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "still held") + assert.Empty(t, f.events()) +} + +// TestFluxClaim_ADenialKeepsASuspendedKustomizationSuspended: the hand-back restores the state +// recorded before adoption. +func TestFluxClaim_ADenialKeepsASuspendedKustomizationSuspended(t *testing.T) { + objs := applierFluxObjects() + objs[0] = fluxsim.OwnSuspend(t, objs[0].(*unstructured.Unstructured), true, "flux", + metav1.ManagedFieldsOperationUpdate) + f := newHoldFixture(t, testFluxClaim(claimLocalProvider), objs...) + got := reconcileClaim(t, f.r).(*configbutleraiv1alpha3.FluxClaim) + require.Equal(t, configbutleraiv1alpha3.SuspendStateSuspended, got.Status.PreviousSuspendState) + + f.setApplierGrants(t, nil) + f.reconcileUntil(t, handedBack) + + assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), + "suspended before adoption, suspended after the hand-back") +} + +// TestFluxClaim_ADenialThatCannotHandBackGivesUp takes the retire path's two ways out. A Kustomization +// the identity may no longer patch is let go at once; a cluster that answers with errors keeps the +// applier claim Releasing in status until releaseDeadline. Both say so in a Warning Event. +func TestFluxClaim_ADenialThatCannotHandBackGivesUp(t *testing.T) { + for name, tc := range map[string]struct { + err error + atOnce bool + wantEvent string + }{ + "the identity lost its RBAC": { + err: apierrors.NewForbidden(fluxsim.KustomizationGVR().GroupResource(), "checkout", + errors.New("no patch for you")), + atOnce: true, wantEvent: "Warning ReleaseFailed could not hand Kustomization", + }, + "the cluster keeps failing": { + err: apierrors.NewServiceUnavailable("unavailable"), + wantEvent: "Warning ReleaseFailed gave up handing Kustomization", + }, + } { + t.Run(name, func(t *testing.T) { + f := newHoldFixture(t, testFluxClaim(claimLocalProvider)) + reconcileClaim(t, f.r) + resume(t, f) // the hand-back must suspend once more, and that patch fails + f.events() + f.sim.Client.PrependReactor( + "patch", + "kustomizations", + func(k8stesting.Action) (bool, runtime.Object, error) { + return true, nil, tc.err + }, + ) + f.setApplierGrants(t, nil) + + _, _ = f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) + got := f.claim(t) + if !tc.atOnce { + require.False(t, handedBack(got)) + assert.Equal(t, ReasonReleasing, heldReason(t, got), "the hand-back shows in status") + assert.True(t, releasingForDenial(got)) + held := findCondition(got.Status.Conditions, ApplierClaimConditionHeld) + held.LastTransitionTime = metav1.NewTime(time.Now().Add(-releaseDeadline - time.Minute)) + require.NoError(t, f.r.Status().Update(context.Background(), got)) + _, _ = f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) + got = f.claim(t) + } + assert.True(t, handedBack(got), "it lets go") + events := f.events() + require.NotEmpty(t, events) + assert.True(t, strings.HasPrefix(events[len(events)-1], tc.wantEvent), events) + }) + } +} + +// TestFluxClaim_ARegrantDuringTheHandBackLetsItFinish: a regrant while the hand-back waits on its +// suspend lets the hand-back finish, and then the applier claim adopts the Kustomization anew. +func TestFluxClaim_ARegrantDuringTheHandBackLetsItFinish(t *testing.T) { + f := ruleFixture(t, testFluxClaim(claimLocalProvider), fluxTarget()) + f.settle(t, claimKey) + f.events() + grants := applierGrants([]configbutleraiv1alpha3.ScopeNamespace{"*"}, "team-a") + resume(t, f) + unpause := f.sim.PauseKustomizations() + f.setApplierGrants(t, nil) + _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) + require.NoError(t, err) + require.Equal(t, ReasonReleasing, heldReason(t, f.claim(t))) + assert.True(t, releasingForDenial(f.claim(t))) + + f.setApplierGrants(t, grants) + _, err = f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) + require.NoError(t, err) + assert.Equal(t, ReasonReleasing, heldReason(t, f.claim(t)), "the hand-back is not abandoned") + + unpause() + f.reconcileUntil(t, func(claim *configbutleraiv1alpha3.FluxClaim) bool { + return heldReason(t, claim) == ReasonSuspended + }) + assert.True(t, controllerutil.ContainsFinalizer(f.claim(t), configbutleraiv1alpha3.FluxClaimFinalizer)) + var reasons []string + for _, event := range f.events() { + for _, reason := range []string{EventReasonReleased, EventReasonAdopted} { + if strings.Contains(event, " "+reason+" ") { + reasons = append(reasons, reason) + } + } + } + assert.Equal(t, []string{EventReasonReleased, EventReasonAdopted}, reasons, + "handed back first, then adopted anew") +} diff --git a/internal/controller/fluxclaim_hold.go b/internal/controller/fluxclaim_hold.go index 2feb4fdd..5a9be3ae 100644 --- a/internal/controller/fluxclaim_hold.go +++ b/internal/controller/fluxclaim_hold.go @@ -5,6 +5,7 @@ package controller import ( "context" "fmt" + "strings" "sync" "time" @@ -554,22 +555,37 @@ func (r *ApplierClaimReconciler) retire( } r.letGoOfOperation(claim) clearPreviousSuspendState(claim) - held := conditionValue{Status: metav1.ConditionFalse, Reason: applier.ReasonNotReferenced, - Message: fmt.Sprintf("nothing to hold: no GitTarget names this applier claim, and its %s was handed back", - claim.ApplierKind())} - if check.refs.Named() { - held = conditionValue{Status: metav1.ConditionUnknown, Reason: ReasonNotConfigured, - Message: fmt.Sprintf("the %s was handed back, and is held again once an eligible GitTarget "+ - "references the applier claim", claim.ApplierKind())} - } + held := handedBackCondition(claim, check) st.setValue(ApplierClaimConditionHeld, held) st.applyReadiness(r.claimReadiness(claim, check.configured, &held)) if err := st.commit(ctx); err != nil { return ctrl.Result{}, err } + // A hand-back that a denial began and a regrant outlived adopts the applier anew at once. + if !check.denied && check.configured.Status == metav1.ConditionTrue { + return ctrl.Result{RequeueAfter: st.requeueAfter(applierClaimSuspendingInterval)}, nil + } return ctrl.Result{RequeueAfter: st.requeueAfter(heldClaimSteadyInterval)}, nil } +// handedBackCondition is the Held condition of a live applier claim that has handed its applier +// back, saying what makes it hold the applier again. +func handedBackCondition(claim configbutleraiv1alpha3.ApplierClaim, check *claimCheck) conditionValue { + switch { + case check.denied: + return conditionValue{Status: metav1.ConditionUnknown, Reason: ReasonNotConfigured, + Message: fmt.Sprintf("the %s was handed back, and is held again once the KubeProvider's "+ + "spec.allowedApplierClaims admit the applier claim", claim.ApplierKind())} + case check.refs.Named(): + return conditionValue{Status: metav1.ConditionUnknown, Reason: ReasonNotConfigured, + Message: fmt.Sprintf("the %s was handed back, and is held again once an eligible GitTarget "+ + "references the applier claim", claim.ApplierKind())} + } + return conditionValue{Status: metav1.ConditionFalse, Reason: applier.ReasonNotReferenced, + Message: fmt.Sprintf("nothing to hold: no GitTarget names this applier claim, and its %s was handed back", + claim.ApplierKind())} +} + // clearPreviousSuspendState drops what an applier claim recorded of its applier before it held it, once it // has handed it back (or on to a successor): a later adoption records anew. func clearPreviousSuspendState(claim configbutleraiv1alpha3.ApplierClaim) { @@ -600,9 +616,12 @@ func (r *ApplierClaimReconciler) publishReleasing( st *reconcileStatus, claim configbutleraiv1alpha3.ApplierClaim, check *claimCheck, since time.Time, ) { why := "the applier claim is being deleted" - if check != nil && check.refs.Named() { + switch { + case check != nil && (check.denied || releasingForDenial(claim)): + why = releasingForDenialWhy + case check != nil && check.refs.Named(): why = "no GitTarget naming the applier claim is eligible any more" - } else if check != nil { + case check != nil: why = "no GitTarget names the applier claim any more" } held := conditionValue{Status: metav1.ConditionFalse, Reason: ReasonReleasing, Message: fmt.Sprintf( @@ -622,6 +641,18 @@ func (r *ApplierClaimReconciler) publishReleasing( st.applyReadiness(r.claimReadiness(claim, check.configured, &held)) } +// releasingForDenialWhy opens the Held=Releasing message of a hand-back that a denial by the +// KubeProvider's spec.allowedApplierClaims began. releasingForDenial reads it back: such a hand-back +// finishes even after a regrant, and the applier is adopted anew once it has. +const releasingForDenialWhy = "the KubeProvider's spec.allowedApplierClaims no longer admit the applier claim" + +// releasingForDenial reports whether the applier claim is handing its applier back because its +// KubeProvider's grants refused it. +func releasingForDenial(claim configbutleraiv1alpha3.ApplierClaim) bool { + held := findCondition(*claim.StatusConditions(), ApplierClaimConditionHeld) + return held != nil && held.Reason == ReasonReleasing && strings.HasPrefix(held.Message, releasingForDenialWhy) +} + // releasingSince is when the applier claim's Held condition turned Releasing, or now when it has not. func releasingSince(claim configbutleraiv1alpha3.ApplierClaim) time.Time { held := findCondition(*claim.StatusConditions(), ApplierClaimConditionHeld) diff --git a/internal/watch/source_namespace_test.go b/internal/watch/source_namespace_test.go index 2010d912..a91aeb66 100644 --- a/internal/watch/source_namespace_test.go +++ b/internal/watch/source_namespace_test.go @@ -27,7 +27,7 @@ const ( snbRule = "repo-config-rule" snbProvider = "workspaces" // snbProviderNS is the KubeProvider's namespace: another than the tenant's, so the tenant is - // admitted through spec.accessFrom. + // admitted through a Names grant. snbProviderNS = "platform" ) From 65274aa97afc63c361cf5352a4c2c11787b1d6d9 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 17:59:49 +0000 Subject: [PATCH 15/34] feat(chart): the local KubeProvider grants nothing by default The chart renders kubeProvider.allowedGitTargets and allowedApplierClaims onto its local provider, both empty by default, so nothing mirrors or holds an applier through the operator's identity until the installer grants it. quickstart.enabled adds a grant for exactly the quickstart namespace, and values-playground.yaml grants every namespace its own namespace. No default grants clusterResources. The quickstart GitProvider and GitBranch carry their own grants. config/kubeprovider-local.yaml, the config-dir e2e install, grants what the suite needs. Co-Authored-By: Claude Opus 5.5 (1M context) --- Taskfile-build.yml | 2 +- charts/gitops-reverser/README.md | 10 +- .../templates/kubeprovider.yaml | 27 +++- .../gitops-reverser/templates/quickstart.yaml | 8 +- charts/gitops-reverser/values-playground.yaml | 18 +++ charts/gitops-reverser/values.schema.json | 28 ++-- charts/gitops-reverser/values.yaml | 47 ++++-- cmd/main_chart_kubeprovider_test.go | 142 ++++++++++++++++++ config/kubeprovider-local.yaml | 30 ++-- 9 files changed, 249 insertions(+), 63 deletions(-) create mode 100644 charts/gitops-reverser/values-playground.yaml create mode 100644 cmd/main_chart_kubeprovider_test.go diff --git a/Taskfile-build.yml b/Taskfile-build.yml index 9d34849e..67aad210 100644 --- a/Taskfile-build.yml +++ b/Taskfile-build.yml @@ -316,7 +316,7 @@ tasks: # # It exists because the table it guards was written by hand once and got three # defaults backwards, each in the direction that costs a reader an outage: - # accessFrom omitted DENIES every namespace, serializeNamespace omitted INFERS + # a grant list omitted DENIES every namespace, serializeNamespace omitted INFERS # per document rather than meaning false, and qps/burst fall back to the # operator-wide flags. The schema knew all three. Nothing was asking it. sources: diff --git a/charts/gitops-reverser/README.md b/charts/gitops-reverser/README.md index ccf98fcf..41fa2277 100644 --- a/charts/gitops-reverser/README.md +++ b/charts/gitops-reverser/README.md @@ -189,12 +189,13 @@ nodeSelector: | `auditService.type` | Service type for the dedicated audit Service | `NodePort` | | `auditService.nodePort` | Fixed NodePort for the audit Service when `auditService.type=NodePort` | `30444` | | `auditService.clusterIP` | Optional fixed ClusterIP for the dedicated audit Service | `""` | -| `quickstart.enabled` | Create starter `GitProvider`, `GitBranch`, `GitTarget`, and `WatchRule` resources. The `GitTarget` mirrors through the `KubeProvider` the chart renders, so it requires `kubeProvider.create` | `false` | +| `quickstart.enabled` | Create starter `GitProvider`, `GitBranch`, `GitTarget`, and `WatchRule` resources, and grant exactly `quickstart.namespace` its own namespace on the `KubeProvider` the chart renders. The `GitTarget` mirrors through that provider, so it requires `kubeProvider.create` | `false` | | `quickstart.namespace` | Namespace for the starter quickstart resources (create it, and the git-creds Secret, before install) | `gitops-reverser-quickstart-demo` | | `quickstart.createNamespace` | Let the chart create `quickstart.namespace` (Helm then owns it) | `false` | | `quickstart.gitProvider.url` | Repository URL used by the starter `GitProvider` | `""` | | `quickstart.gitProvider.secretRef.name` | Existing Secret name used by the starter `GitProvider` | `git-creds` | -| `quickstart.gitBranch.branch` | Branch the starter `GitBranch` writes. Must match `quickstart.gitProvider.allowedBranches` | `main` | +| `quickstart.gitProvider.allowedGitBranches` | Branch patterns the quickstart namespace may claim through the starter `GitProvider` (a `from: Same` grant) | `["*"]` | +| `quickstart.gitBranch.branch` | Branch the starter `GitBranch` writes. Must match `quickstart.gitProvider.allowedGitBranches` | `main` | | `quickstart.gitTarget.path` | Repository path used by the starter `GitTarget`; set `.` only to deliberately target the repo root | `live-cluster` | | `quickstart.watchRule.rules` | Rules used by the starter `WatchRule` | `configmaps create/update/delete` | | `queue.redis.addr` | Redis/Valkey endpoint (`host:port`). Optional but advised: empty means cold-replay on restart. Set it for warm-restart cursors and for the admission webhook to actually record CommitRequest authors (admission runs as a no-op without it); **required** when `attribution.enabled=true` unless `attribution.transport=memory` | `""` | @@ -217,9 +218,8 @@ nodeSelector: | `kubeProvider.name` | Name of the rendered provider. Its audit route is `.`, the `/audit-webhook/` path the API server posts to | `local` | | `kubeProvider.secretRef.name` | Secret in the release namespace holding a kubeconfig for a **remote** cluster. Empty renders `spec.inCluster`, the operator's **own** identity, and lists the provider in `--in-cluster-kube-providers`. Immutable once created: delete and recreate to repoint | `""` | | `kubeProvider.secretRef.key` | Key within that Secret. Empty reads `value` then `value.yaml` | `""` | -| `kubeProvider.accessFrom` | Policy (`names` and/or `selector`) for which **other** control-cluster namespaces may reference this provider; the release namespace always may. The default empty selector admits every namespace | `{selector: {}}` | -| `kubeProvider.allowCrossNamespace.sources` | Let a `WatchRule` in an admitted namespace watch other namespaces, `"*"` included. A delegation to every admitted namespace, not proof that it owns what it reads | `false` | -| `kubeProvider.allowCrossNamespace.appliers` | Let a `FluxClaim` or `ArgoCDClaim` in an admitted namespace suspend and apply a `Kustomization` or `Application` in another namespace, such as `flux-system`. A delegation to every admitted namespace, not proof that it owns the applier or the repository | `false` | +| `kubeProvider.allowedGitTargets` | Rendered as the provider's `spec.allowedGitTargets`: which namespaces' `GitTarget`s may mirror through it, and what. Each grant has a `from` (`Same`, `Names`, `Selector`, `All`), and `sourceNamespaces`, `clusterResources` or both. **Empty grants nothing, the release namespace included**: a `GitTarget` through `local` is refused with a message naming `spec.allowedGitTargets`. `quickstart.enabled` adds a grant for the quickstart namespace, and [`values-playground.yaml`](values-playground.yaml) grants every namespace its own. With an `inCluster` provider a grant bypasses live namespace RBAC through the operator's identity | `[]` | +| `kubeProvider.allowedApplierClaims` | Rendered as the provider's `spec.allowedApplierClaims`: which namespaces' `FluxClaim`s and `ArgoCDClaim`s may hold an applier through it, and in which `applierNamespaces` it may live, such as `flux-system`. Admission to mirror is no admission to apply. Empty grants nothing | `[]` | | `servers.admission.enabled` | Install the validate-operator-types admission webhook that captures CommitRequest authors (a form of author attribution). Enabled by default; a no-op until `queue.redis.addr` is set | `true` | | `rbac.create` | Create the manager ClusterRole and its binding | `true` | | `rbac.watchTypes.mode` | Which types a `WatchRule` may read. `any` grants cluster-wide read on everything, including every Secret in the cluster. `selected` grants read on `rbac.watchTypes.selected` only, so the reverser cannot list or watch Secrets (it keeps `get` on named Secrets it is pointed at). See [`docs/rbac.md`](../../docs/rbac.md) | `any` | diff --git a/charts/gitops-reverser/templates/kubeprovider.yaml b/charts/gitops-reverser/templates/kubeprovider.yaml index 83fbb525..23daeaa6 100644 --- a/charts/gitops-reverser/templates/kubeprovider.yaml +++ b/charts/gitops-reverser/templates/kubeprovider.yaml @@ -4,9 +4,10 @@ # makes Helm delete it again. Every GitTarget names its KubeProvider explicitly # (spec.kubeProviderRef); nothing falls back to this one. With kubeProvider.secretRef.name empty it # uses the operator's own identity (spec.inCluster), and the chart lists it in -# --in-cluster-kube-providers, which is the only way an inCluster provider is honored. accessFrom -# admits other namespaces; the chart default (an empty selector) admits every namespace — tighten -# it via kubeProvider.accessFrom. +# --in-cluster-kube-providers, which is the only way an inCluster provider is honored. Nothing may +# use it without a grant: by default it admits no namespace. kubeProvider.allowedGitTargets and +# kubeProvider.allowedApplierClaims grant it; quickstart.enabled adds a grant for exactly the +# quickstart namespace, and values-playground.yaml grants every namespace its own. apiVersion: configbutler.ai/v1alpha3 kind: KubeProvider metadata: @@ -26,9 +27,19 @@ spec: inCluster: {} {{- end }} {{- end }} - accessFrom: - {{- toYaml .Values.kubeProvider.accessFrom | nindent 4 }} - allowCrossNamespace: - sources: {{ .Values.kubeProvider.allowCrossNamespace.sources }} - appliers: {{ .Values.kubeProvider.allowCrossNamespace.appliers }} + {{- $gitTargets := .Values.kubeProvider.allowedGitTargets }} + {{- $applierClaims := .Values.kubeProvider.allowedApplierClaims }} + {{- if .Values.quickstart.enabled }} + {{- $names := list .Values.quickstart.namespace }} + {{- $gitTargets = append $gitTargets (dict "from" "Names" "names" $names "sourceNamespaces" (list "{namespace}")) }} + {{- $applierClaims = append $applierClaims (dict "from" "Names" "names" $names "applierNamespaces" (list "{namespace}")) }} + {{- end }} + {{- with $gitTargets }} + allowedGitTargets: + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with $applierClaims }} + allowedApplierClaims: + {{- toYaml . | nindent 4 }} + {{- end }} {{- end }} diff --git a/charts/gitops-reverser/templates/quickstart.yaml b/charts/gitops-reverser/templates/quickstart.yaml index a0700bff..3f2a53e8 100644 --- a/charts/gitops-reverser/templates/quickstart.yaml +++ b/charts/gitops-reverser/templates/quickstart.yaml @@ -20,8 +20,10 @@ metadata: app.kubernetes.io/component: quickstart spec: url: {{ .Values.quickstart.gitProvider.url | quote }} - allowedBranches: - {{- toYaml .Values.quickstart.gitProvider.allowedBranches | nindent 4 }} + allowedGitBranches: + - from: Same + branches: + {{- toYaml .Values.quickstart.gitProvider.allowedGitBranches | nindent 8 }} secretRef: name: {{ .Values.quickstart.gitProvider.secretRef.name }} --- @@ -37,6 +39,8 @@ spec: gitProviderRef: name: {{ .Values.quickstart.gitProvider.name }} branch: {{ .Values.quickstart.gitBranch.branch | quote }} + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/charts/gitops-reverser/values-playground.yaml b/charts/gitops-reverser/values-playground.yaml new file mode 100644 index 00000000..dc842853 --- /dev/null +++ b/charts/gitops-reverser/values-playground.yaml @@ -0,0 +1,18 @@ +# A playground install: every namespace may mirror its own namespace, and hold an applier in its own +# namespace, through the chart's `local` KubeProvider, which uses the operator's own identity. That +# is the reach of a single-cluster demo, chosen visibly: +# +# helm install gitops-reverser oci://ghcr.io/configbutler/charts/gitops-reverser \ +# -n gitops-reverser --create-namespace -f values-playground.yaml +# +# Every namespace then gains use of the operator's identity for its own namespace, bypassing live +# namespace RBAC. No grant here reaches another namespace or a cluster-scoped resource: a +# ClusterWatchRule through `local` needs a grant with clusterResources. Do not use it where tenants +# share a cluster. +kubeProvider: + allowedGitTargets: + - from: All + sourceNamespaces: ["{namespace}"] + allowedApplierClaims: + - from: All + applierNamespaces: ["{namespace}"] diff --git a/charts/gitops-reverser/values.schema.json b/charts/gitops-reverser/values.schema.json index 10412ad9..7d3058de 100644 --- a/charts/gitops-reverser/values.schema.json +++ b/charts/gitops-reverser/values.schema.json @@ -400,25 +400,15 @@ } } }, - "accessFrom": { - "type": "object", - "description": "Control-cluster namespace policy (names and/or selector) for which OTHER namespaces may reference this provider; the release namespace always may.", - "additionalProperties": true + "allowedGitTargets": { + "type": "array", + "description": "Rendered as the provider's spec.allowedGitTargets: which namespaces' GitTargets may mirror through it, and what (sourceNamespaces, clusterResources). Empty grants nothing. The CRD validates each grant.", + "items": { "type": "object" } }, - "allowCrossNamespace": { - "type": "object", - "additionalProperties": false, - "description": "What an admitted namespace may reach outside itself through this provider.", - "properties": { - "sources": { - "type": "boolean", - "description": "A WatchRule may watch other namespaces, \"*\" included." - }, - "appliers": { - "type": "boolean", - "description": "A FluxClaim or ArgoCDClaim may name a Kustomization or Application in another namespace." - } - } + "allowedApplierClaims": { + "type": "array", + "description": "Rendered as the provider's spec.allowedApplierClaims: which namespaces' applier claims may hold an applier through it, and where it may live (applierNamespaces). Empty grants nothing. The CRD validates each grant.", + "items": { "type": "object" } } } }, @@ -565,7 +555,7 @@ "properties": { "name": { "type": "string" }, "url": { "type": "string" }, - "allowedBranches": { "type": "array", "items": { "type": "string" } }, + "allowedGitBranches": { "type": "array", "items": { "type": "string" } }, "secretRef": { "type": "object", "additionalProperties": false, diff --git a/charts/gitops-reverser/values.yaml b/charts/gitops-reverser/values.yaml index e16bbf55..972816bf 100644 --- a/charts/gitops-reverser/values.yaml +++ b/charts/gitops-reverser/values.yaml @@ -363,20 +363,35 @@ kubeProvider: secretRef: name: "" key: "" - # Which OTHER namespaces may reference this provider; the release namespace always may. It is - # authorization in the CONTROL cluster (names OR selector), not a filter on the source cluster. - # The chart default is an empty selector, which matches EVERY namespace, so a single-cluster - # install works out of the box; tighten it to a names list or a label selector in a real install. - accessFrom: - selector: {} - # What a namespace this provider admits may reach OUTSIDE itself through the provider's - # identity. Each is a power of its own: sources lets a WatchRule read other namespaces' objects - # into its own Git ("*" included); appliers lets a FluxClaim or ArgoCDClaim suspend and apply - # a Kustomization or Application in another namespace. It is a delegation to every admitted - # namespace, not proof that a namespace owns what it reaches. - allowCrossNamespace: - sources: false - appliers: false + # Who may use the operator's identity, and for what: rendered as the provider's + # spec.allowedGitTargets and spec.allowedApplierClaims. Empty by default, so NOTHING mirrors or + # holds an applier through this provider until you grant it, the release namespace included; a + # refusal names spec.allowedGitTargets. The installer decides, never the chart. + # quickstart.enabled=true adds a grant for exactly the quickstart namespace, and + # values-playground.yaml grants every namespace its own namespace. + # + # Each grant has a `from` (Same, Names with names, Selector with a non-empty selector, or All) + # and a scope: + # allowedGitTargets[].sourceNamespaces which namespaces the WatchRules feeding an admitted + # GitTarget may watch: names, "{namespace}" for the + # GitTarget's own, or "*" + # allowedGitTargets[].clusterResources which cluster-scoped types a ClusterWatchRule may mirror: + # resource.group, a bare core resource, or "*" + # allowedApplierClaims[].applierNamespaces where an admitted applier claim's Kustomization or + # Application may live + # With an inCluster provider a grant bypasses live namespace RBAC through the operator's own + # identity: the owner of an admitted object acts on the namespaces in its scope. + # + # allowedGitTargets: + # - from: Selector + # selector: {matchLabels: {platform.example.com/tenant: "true"}} + # sourceNamespaces: ["{namespace}"] + # allowedApplierClaims: + # - from: Names + # names: [team-a] + # applierNamespaces: [flux-system] + allowedGitTargets: [] + allowedApplierClaims: [] # RBAC configuration rbac: @@ -491,7 +506,9 @@ quickstart: name: example-provider # Required when quickstart.enabled=true. url: "" - allowedBranches: + # The branch patterns the quickstart namespace may claim through this provider ("*" is every + # branch); rendered as a from: Same grant in spec.allowedGitBranches. + allowedGitBranches: - "*" secretRef: # Required when quickstart.enabled=true. diff --git a/cmd/main_chart_kubeprovider_test.go b/cmd/main_chart_kubeprovider_test.go new file mode 100644 index 00000000..2330d1a2 --- /dev/null +++ b/cmd/main_chart_kubeprovider_test.go @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "context" + "os" + "os/exec" + "testing" + + meta "github.com/fluxcd/pkg/apis/meta" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/yaml" + + configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" + "github.com/ConfigButler/gitops-reverser/internal/authz" +) + +// kubeProviderChartInputs are read in-process so `go test` treats them as cache inputs. +var kubeProviderChartInputs = []string{ + "../charts/gitops-reverser/values.yaml", + "../charts/gitops-reverser/values-playground.yaml", + "../charts/gitops-reverser/values.schema.json", + "../charts/gitops-reverser/templates/kubeprovider.yaml", +} + +// renderLocalKubeProvider renders the chart's `local` KubeProvider in namespace gitops-reverser. +func renderLocalKubeProvider(t *testing.T, args ...string) *configbutleraiv1alpha3.KubeProvider { + t.Helper() + for _, path := range kubeProviderChartInputs { + _, err := os.ReadFile(path) + require.NoError(t, err) + } + command := append([]string{"template", "gitops-reverser", "../charts/gitops-reverser", + "--namespace", "gitops-reverser", "--show-only", "templates/kubeprovider.yaml"}, args...) + out, err := exec.Command("helm", command...).CombinedOutput() + require.NoErrorf(t, err, "helm template failed: %s", out) + provider := &configbutleraiv1alpha3.KubeProvider{} + require.NoError(t, yaml.UnmarshalStrict(out, provider)) + return provider +} + +// mirrors reports whether a GitTarget in namespace may mirror through provider, and the refusal. +func mirrors(t *testing.T, provider *configbutleraiv1alpha3.KubeProvider, namespace string) authz.Decision { + t.Helper() + scheme := runtime.NewScheme() + require.NoError(t, configbutleraiv1alpha3.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + reader := fake.NewClientBuilder().WithScheme(scheme).WithObjects(provider).Build() + target := &configbutleraiv1alpha3.GitTarget{ + ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "apps"}, + Spec: configbutleraiv1alpha3.GitTargetSpec{ + KubeProviderRef: meta.NamespacedObjectReference{Name: provider.Name, Namespace: provider.Namespace}, + }, + } + decision, err := authz.GitTargetAdmitted(context.Background(), reader, target) + require.NoError(t, err) + return decision +} + +// TestChartKubeProvider_ADefaultInstallGrantsNothing: the installer chooses who may use the +// operator's identity; the chart never chooses for them, the release namespace included. +func TestChartKubeProvider_ADefaultInstallGrantsNothing(t *testing.T) { + provider := renderLocalKubeProvider(t) + assert.Empty(t, provider.Spec.AllowedGitTargets) + assert.Empty(t, provider.Spec.AllowedApplierClaims) + for _, namespace := range []string{"gitops-reverser", "team-a"} { + decision := mirrors(t, provider, namespace) + assert.False(t, decision.Allowed) + assert.Contains(t, decision.Message, + "KubeProvider gitops-reverser/local admits no namespace in spec.allowedGitTargets") + } +} + +// TestChartKubeProvider_TheQuickstartGrantsItsNamespaceOnly: quickstart.enabled renders a grant for +// exactly the quickstart namespace, to mirror and to hold appliers in its own namespace. +func TestChartKubeProvider_TheQuickstartGrantsItsNamespaceOnly(t *testing.T) { + provider := renderLocalKubeProvider(t, "--set", "quickstart.enabled=true", + "--set", "quickstart.gitProvider.url=https://example.com/repo.git") + + scope, err := provider.AdmitsGitTarget("gitops-reverser-quickstart-demo", nil) + require.NoError(t, err) + assert.True(t, scope.AllowsSourceNamespace("gitops-reverser-quickstart-demo")) + assert.False(t, scope.AllowsSourceNamespace("kube-system")) + assert.Empty(t, scope.ClusterResources) + appliers, err := provider.AdmitsApplierClaim("gitops-reverser-quickstart-demo", nil) + require.NoError(t, err) + assert.True(t, appliers.AllowsApplierNamespace("gitops-reverser-quickstart-demo")) + assert.False(t, appliers.AllowsApplierNamespace("flux-system")) + + assert.True(t, mirrors(t, provider, "gitops-reverser-quickstart-demo").Allowed) + assert.False(t, mirrors(t, provider, "team-a").Allowed, "and nothing else") +} + +// TestChartKubeProvider_ThePlaygroundGrantsEveryNamespaceItsOwn: values-playground.yaml is today's +// open default, chosen visibly: every namespace mirrors its own namespace, no other, and no +// cluster-scoped resource. +func TestChartKubeProvider_ThePlaygroundGrantsEveryNamespaceItsOwn(t *testing.T) { + provider := renderLocalKubeProvider(t, "-f", "../charts/gitops-reverser/values-playground.yaml") + for _, namespace := range []string{"team-a", "gitops-reverser"} { + scope, err := provider.AdmitsGitTarget(namespace, nil) + require.NoError(t, err) + assert.True(t, scope.AllowsSourceNamespace(namespace)) + assert.False(t, scope.AllowsSourceNamespace("kube-system"), "no other namespace") + assert.False(t, scope.AllowsSourceNamespace("*")) + assert.Empty(t, scope.ClusterResources, "no default grants clusterResources") + appliers, err := provider.AdmitsApplierClaim(namespace, nil) + require.NoError(t, err) + assert.Equal(t, []string{namespace}, appliers.ApplierNamespaces) + } +} + +// TestChartKubeProvider_GrantsRenderAsWritten: values grants reach the provider unchanged, and the +// quickstart grant is added beside them. +func TestChartKubeProvider_GrantsRenderAsWritten(t *testing.T) { + values := t.TempDir() + "/values.yaml" + require.NoError(t, os.WriteFile(values, []byte(`kubeProvider: + allowedGitTargets: + - from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} + sourceNamespaces: ["{namespace}"] + allowedApplierClaims: + - from: Names + names: [team-a] + applierNamespaces: [flux-system] +quickstart: + enabled: true + gitProvider: {url: "https://example.com/repo.git"} +`), 0o600)) + provider := renderLocalKubeProvider(t, "-f", values) + require.Len(t, provider.Spec.AllowedGitTargets, 2) + assert.Equal(t, configbutleraiv1alpha3.GrantFromSelector, provider.Spec.AllowedGitTargets[0].From) + assert.Equal(t, configbutleraiv1alpha3.GrantFromNames, provider.Spec.AllowedGitTargets[1].From) + require.Len(t, provider.Spec.AllowedApplierClaims, 2) + assert.Equal(t, []configbutleraiv1alpha3.ScopeNamespace{"flux-system"}, + provider.Spec.AllowedApplierClaims[0].ApplierNamespaces) +} diff --git a/config/kubeprovider-local.yaml b/config/kubeprovider-local.yaml index 17992190..d12ee1b1 100644 --- a/config/kubeprovider-local.yaml +++ b/config/kubeprovider-local.yaml @@ -1,19 +1,23 @@ -# The KubeProvider for the SUT / config-dir install, mirroring what the Helm chart renders when -# kubeProvider.create is true: "local" in the operator's namespace, using the operator's own -# identity. Every GitTarget names its KubeProvider (spec.kubeProviderRef), and the reconcile-time -# hard gate REQUIRES it to exist, so local mirroring needs this object — the operator never creates -# it. spec.inCluster is honored only because deployment.yaml lists gitops-reverser/local in -# --in-cluster-kube-providers. The empty selector admits every other namespace (matching the chart -# default); tighten in a real install. +# The KubeProvider for the SUT / config-dir install: "local" in the operator's namespace, using the +# operator's own identity, as the Helm chart renders it when kubeProvider.create is true. Every +# GitTarget names its KubeProvider (spec.kubeProviderRef), and the reconcile-time hard gate REQUIRES +# it to exist, so local mirroring needs this object — the operator never creates it. +# spec.inCluster is honored only because deployment.yaml lists gitops-reverser/local in +# --in-cluster-kube-providers. +# +# Unlike the chart, whose provider grants nothing by default, this one grants what the e2e suite +# needs: every namespace mirrors its own namespace and any cluster-scoped resource, and holds an +# applier in its own namespace. Do not copy it into a shared cluster. apiVersion: configbutler.ai/v1alpha3 kind: KubeProvider metadata: name: local spec: inCluster: {} - accessFrom: - selector: {} - # Nothing outside the admitted namespaces, as the chart renders it. - allowCrossNamespace: - sources: false - appliers: false + allowedGitTargets: + - from: All + sourceNamespaces: ["{namespace}"] + clusterResources: ["*"] + allowedApplierClaims: + - from: All + applierNamespaces: ["{namespace}"] From 6ff17ad142dc471aafe108c307e73aac64163137 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 18:03:45 +0000 Subject: [PATCH 16/34] test(e2e): grant the suite's access, and cover tenant branch grants The suite grants what it needs on the install's KubeProvider in its setup, whatever the install mode, since the chart grants nothing by default: every namespace mirrors its own namespace and any cluster-scoped resource, and holds an applier in its own namespace. Specs that need appliers elsewhere widen allowedApplierClaims. Fixtures carry allowedGitBranches and allowedGitTargets in place of allowedBranches, accessFrom and allowCrossNamespace. A new spec has a team claim teams//live through a platform provider and write it, share it with a second team through its GitBranch, and be refused main and another team's branch with messages that name the grant. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/e2e/applier_claim.go | 22 ++- test/e2e/audit_route_attribution_e2e_test.go | 26 +-- test/e2e/e2e_suite_test.go | 1 + test/e2e/gitprovider_validation_e2e_test.go | 4 +- test/e2e/helpers.go | 12 +- test/e2e/new_branch_parent_e2e_test.go | 4 +- test/e2e/provider_access_e2e_test.go | 161 ++++++++++++++++++ test/e2e/shared_gitbranch_e2e_test.go | 54 +++--- test/e2e/source_cluster_e2e_test.go | 36 ++-- test/e2e/source_namespace_e2e_test.go | 2 +- test/e2e/templates/gitprovider-signing.tmpl | 5 +- test/e2e/templates/gitprovider.tmpl | 5 +- test/e2e/templates/gittarget.tmpl | 2 + .../templates/manager/gittarget-prune.tmpl | 2 + 14 files changed, 273 insertions(+), 63 deletions(-) create mode 100644 test/e2e/provider_access_e2e_test.go diff --git a/test/e2e/applier_claim.go b/test/e2e/applier_claim.go index da00603f..53e82b73 100644 --- a/test/e2e/applier_claim.go +++ b/test/e2e/applier_claim.go @@ -57,16 +57,32 @@ spec: } // allowCrossNamespaceAppliers lets the applier claims that reach their applier through the install's -// KubeProvider name one in another namespace, as the e2e appliers are: in flux-system and argocd, -// while each spec's applier claims live in its own namespace. Granting it is idempotent, and no spec +// KubeProvider hold one in any namespace, as the e2e appliers are: in flux-system and argocd, while +// each spec's applier claims live in its own namespace. Granting it is idempotent, and no spec // relies on its absence on that provider, so parallel specs may all ask for it. func allowCrossNamespaceAppliers() { GinkgoHelper() _, err := kubectlRunInNamespace(resolveE2ENamespace(), "patch", "kubeprovider", e2eKubeProviderName, - "--type=merge", "-p", `{"spec":{"allowCrossNamespace":{"appliers":true}}}`) + "--type=merge", "-p", `{"spec":{"allowedApplierClaims":[{"from":"All","applierNamespaces":["*"]}]}}`) Expect(err).NotTo(HaveOccurred(), "failed to let the install KubeProvider's applier claims reach other namespaces") } +// suiteKubeProviderGrants are the grants the suite needs on the install's KubeProvider: every +// namespace mirrors its own namespace and any cluster-scoped resource, and holds an applier in its +// own namespace. config/kubeprovider-local.yaml carries them; the chart grants nothing by default, +// so grantSuiteAccess applies them whatever the install mode. +const suiteKubeProviderGrants = `{"spec":{` + + `"allowedGitTargets":[{"from":"All","sourceNamespaces":["{namespace}"],"clusterResources":["*"]}],` + + `"allowedApplierClaims":[{"from":"All","applierNamespaces":["{namespace}"]}]}}` + +// grantSuiteAccess gives the install's KubeProvider the grants the suite needs. +func grantSuiteAccess() { + GinkgoHelper() + _, err := kubectlRunInNamespace(resolveE2ENamespace(), "patch", "kubeprovider", e2eKubeProviderName, + "--type=merge", "-p", suiteKubeProviderGrants) + Expect(err).NotTo(HaveOccurred(), "failed to grant the suite's access on the install KubeProvider") +} + // nameApplierClaim points a GitTarget at the applier claim of kind (FluxClaim or ArgoCDClaim) and // name, and sets onRefusal when it is not empty. func nameApplierClaim(namespace, gitTarget, kind, claim, onRefusal string) { diff --git a/test/e2e/audit_route_attribution_e2e_test.go b/test/e2e/audit_route_attribution_e2e_test.go index e71f0840..2f3db201 100644 --- a/test/e2e/audit_route_attribution_e2e_test.go +++ b/test/e2e/audit_route_attribution_e2e_test.go @@ -247,15 +247,21 @@ func sameClusterKubeConfig() string { } // applyKubeProviderWithAuditRoute applies a KubeProvider in namespace reaching its cluster through -// the kubeconfig Secret secretName in that namespace, admitting the namespaces listed, and declaring -// auditRoute when it is not empty. The route is what lets several providers on one cluster share -// the single stream that cluster's apiserver posts. +// the kubeconfig Secret secretName in that namespace, letting its own namespace and the namespaces +// listed mirror their own namespace (every namespace when wide), and declaring auditRoute when it is +// not empty. The route is what lets several providers on one cluster share the single stream that +// cluster's apiserver posts. func applyKubeProviderWithAuditRoute( - namespace, name, secretName string, admits []string, delegate bool, auditRoute string, + namespace, name, secretName string, admits []string, wide bool, auditRoute string, ) (string, error) { - extra := "" + scope := `["{namespace}"]` + if wide { + scope = `["{namespace}", "*"]` + } + extra := fmt.Sprintf("\n allowedGitTargets:\n - from: Same\n sourceNamespaces: %s", scope) if len(admits) > 0 { - extra += fmt.Sprintf("\n accessFrom:\n names: [%s]", strings.Join(admits, ", ")) + extra += fmt.Sprintf("\n - from: Names\n names: [%s]\n sourceNamespaces: %s", + strings.Join(admits, ", "), scope) } if auditRoute != "" { extra += fmt.Sprintf("\n attribution:\n auditRoute: %s", auditRoute) @@ -267,16 +273,14 @@ metadata: namespace: %s spec: secretRef: - name: %s - allowCrossNamespace: - sources: %t%s -`, name, namespace, secretName, delegate, extra) + name: %s%s +`, name, namespace, secretName, extra) return kubectlRunWithStdin(namespace, manifest, "apply", "-f", "-") } // applyGitTargetForKubeProvider applies a GitTarget that mirrors through the KubeProvider // providerNS/provider. Which SOURCE namespaces may reach it is bounded by that provider's -// spec.allowCrossNamespace.sources plus the source credential's own RBAC, not by any field here. +// allowedGitTargets[].sourceNamespaces plus the source credential's own RBAC, not by any field here. func applyGitTargetForKubeProvider( ns, name, gitProvider, targetPath, providerNS, provider string, ) (string, error) { diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index fc148c30..1f87d44b 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -64,6 +64,7 @@ var _ = SynchronizedBeforeSuite(func() []byte { By("preparing e2e cluster prerequisites via Task target") prepareE2EClusterOnce() waitForPreviousRunNamespaces() + grantSuiteAccess() // After prepare, while this process still holds the exclusive cluster lock, so every spec runs // against a Prometheus with no retained samples and a controller whose counters start at zero. diff --git a/test/e2e/gitprovider_validation_e2e_test.go b/test/e2e/gitprovider_validation_e2e_test.go index 5c2ddc24..c1bf7c29 100644 --- a/test/e2e/gitprovider_validation_e2e_test.go +++ b/test/e2e/gitprovider_validation_e2e_test.go @@ -100,8 +100,8 @@ var _ = Describe("Manager GitProvider Validation", Label("manager"), Ordered, fu By("verifying the GitBranch fails branch validation") verifyResourceStatus("gitbranch", gitBranchName(gitProviderName, "different-branch"), testNs, - "False", "BranchNotAllowed", "allowedBranches") - verifyResourceStatus("gittarget", destName, testNs, "False", "GitBranchNotReady", "allowedBranches") + "False", "BranchNotAllowed", `requested branch "different-branch" is not allowed`) + verifyResourceStatus("gittarget", destName, testNs, "False", "GitBranchNotReady", "spec.allowedGitBranches") cleanupGitTarget(destName, testNs) deleteGitProvider(testNs, gitProviderName) diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go index 61d57974..5c4889a2 100644 --- a/test/e2e/helpers.go +++ b/test/e2e/helpers.go @@ -220,8 +220,13 @@ func deleteGitProvider(namespace, name string) { // gitBranchManifest is the GitBranch gitBranchName(providerName, "main") in namespace, as a YAML // document followed by a separator, for a spec that applies its GitTarget as an inline manifest. -func gitBranchManifest(namespace, providerName string) string { +// GitTargets in its own namespace may write through it, and in sharedWith. +func gitBranchManifest(namespace, providerName string, sharedWith ...string) string { const branch = "main" + shared := "" + if len(sharedWith) > 0 { + shared = fmt.Sprintf("\n - from: Names\n names: [%s]", strings.Join(sharedWith, ", ")) + } return fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: GitBranch metadata: @@ -233,8 +238,11 @@ spec: gitProviderRef: name: %s branch: %s + allowedGitTargets: + - from: Same%s --- -`, gitBranchName(providerName, branch), namespace, gitBranchProviderLabel, providerName, providerName, branch) +`, gitBranchName(providerName, branch), namespace, gitBranchProviderLabel, providerName, providerName, branch, + shared) } // createGitTarget creates a GitTarget on the GitBranch of a GitProvider's branch, creating that diff --git a/test/e2e/new_branch_parent_e2e_test.go b/test/e2e/new_branch_parent_e2e_test.go index 9b03bec0..5268fb82 100644 --- a/test/e2e/new_branch_parent_e2e_test.go +++ b/test/e2e/new_branch_parent_e2e_test.go @@ -78,7 +78,9 @@ metadata: namespace: %s spec: url: %s - allowedBranches: ["main", %q] + allowedGitBranches: + - from: Same + branches: ["main", %q] secretRef: name: %s `, providerName, namespace, repo.RepoURLHTTP, writeBranch, repo.GitSecretHTTP) diff --git a/test/e2e/provider_access_e2e_test.go b/test/e2e/provider_access_e2e_test.go new file mode 100644 index 00000000..aeed7a12 --- /dev/null +++ b/test/e2e/provider_access_e2e_test.go @@ -0,0 +1,161 @@ +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "fmt" + "path" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// Access between objects (docs/configuration.md): a platform keeps one repository credential and +// lets labelled tenant namespaces claim branches under their own prefix (GitProvider +// spec.allowedGitBranches). A tenant's GitBranch decides who writes it (spec.allowedGitTargets), so +// the tenant shares it with another team without asking the platform. Claims on main, and on +// another team's prefix, are refused with messages that name the grant. +var _ = Describe("Manager branch grants", Label("manager"), Ordered, func() { + const ( + providerName = "shared-repo" + tenantLabel = "e2e.configbutler.ai/tenant" + ) + + var ( + platformNs, teamA, teamB string + repo *RepoArtifacts + ) + + teamBranch := func(team string) string { return "teams/" + team + "/live" } + + BeforeAll(func() { + platformNs = testNamespaceFor("grants-platform") + teamA = testNamespaceFor("grants-team-a") + teamB = testNamespaceFor("grants-team-b") + for _, ns := range []string{platformNs, teamA, teamB} { + _, _ = kubectlRun("create", "namespace", ns) // idempotent; ignore AlreadyExists + } + for _, ns := range []string{teamA, teamB} { + _, err := kubectlRun("label", "namespace", ns, tenantLabel+"=true", "--overwrite") + Expect(err).NotTo(HaveOccurred()) + } + + By("setting up the repository, with its credential in the platform namespace only") + repo = SetupRepo(resolveE2EContext(), platformNs, fmt.Sprintf("e2e-branch-grants-%d", GinkgoRandomSeed())) + _, err := kubectlRunInNamespace(platformNs, "apply", "-f", repo.SecretsYAML) + Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to the platform namespace") + + By("creating the platform's GitProvider: main for itself, teams/{namespace}/* for tenants") + _, err = kubectlRunWithStdin("", fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 +kind: GitProvider +metadata: + name: %s + namespace: %s +spec: + url: %s + secretRef: + name: %s + allowedGitBranches: + - from: Same + branches: [main] + - from: Selector + selector: + matchLabels: + %s: "true" + branches: ["teams/{namespace}/*"] +`, providerName, platformNs, repo.RepoURLHTTP, repo.GitSecretHTTP, tenantLabel), "apply", "-f", "-") + Expect(err).NotTo(HaveOccurred()) + verifyResourceStatus("gitprovider", providerName, platformNs, + "True", "Succeeded", "Repository connectivity validated") + }) + + AfterAll(func() { + for _, ns := range []string{teamA, teamB, platformNs} { + cleanupNamespace(ns) + } + }) + + SetDefaultEventuallyTimeout(90 * time.Second) + SetDefaultEventuallyPollingInterval(2 * time.Second) + + // applyTenantGitBranch applies a GitBranch in namespace claiming branch through the platform's + // GitProvider, written by its own namespace and the namespaces in sharedWith. + applyTenantGitBranch := func(name, namespace, branch string, sharedWith ...string) { + GinkgoHelper() + writers := "\n - from: Same" + if len(sharedWith) > 0 { + writers += fmt.Sprintf("\n - from: Names\n names: [%s]", strings.Join(sharedWith, ", ")) + } + _, err := kubectlRunWithStdin("", fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 +kind: GitBranch +metadata: + name: %s + namespace: %s +spec: + gitProviderRef: + name: %s + namespace: %s + branch: %s + allowedGitTargets:%s +`, name, namespace, providerName, platformNs, branch, writers), "apply", "-f", "-") + Expect(err).NotTo(HaveOccurred(), "failed to apply GitBranch %s/%s", namespace, name) + } + + // expectFileOnBranch waits until file is in the tip of branch on the remote. + expectFileOnBranch := func(branch, file string) { + GinkgoHelper() + Eventually(func(g Gomega) { + _, err := gitRun(repo.CheckoutDir, "fetch", "origin", + "+refs/heads/"+branch+":refs/remotes/origin/"+branch) + g.Expect(err).NotTo(HaveOccurred()) + _, err = gitRun(repo.CheckoutDir, "cat-file", "-e", "origin/"+branch+":"+file) + g.Expect(err).NotTo(HaveOccurred(), "%s is on branch %s", file, branch) + }).Should(Succeed()) + } + + It("lets a tenant claim a branch under its own prefix and write it", func() { + applyTenantGitBranch("live", teamA, teamBranch(teamA)) + verifyResourceStatus("gitbranch", "live", teamA, "True", "Succeeded", "holds") + + applySharedGitTarget("apps", teamA, "live", "", "team-a/apps") + verifyResourceCondition("gittarget", "apps", teamA, "Validated", "True", "Succeeded", "") + applyIsolationWatchRule("apps-configmaps", teamA, "apps", `"configmaps"`) + waitForStreamsRunning("apps", teamA) + applyIsolationConfigMap("grants-cm", teamA) + + expectFileOnBranch(teamBranch(teamA), path.Join("team-a/apps", teamA, "configmaps", "grants-cm.yaml")) + + out, err := kubectlRunInNamespace(teamA, "get", "secrets", "-o", "name") + Expect(err).NotTo(HaveOccurred()) + Expect(out).NotTo(ContainSubstring(repo.GitSecretHTTP), "the tenant namespace holds no copy of the credential") + }) + + It("refuses a tenant's claims on main and on another team's branch", func() { + applyTenantGitBranch("main", teamA, "main") + verifyResourceCondition("gitbranch", "main", teamA, "Stalled", "True", "BranchNotAllowed", + `requested branch "main" is not allowed`) + verifyResourceCondition("gitbranch", "main", teamA, "Ready", "False", "BranchNotAllowed", + fmt.Sprintf(`namespace %q may claim "teams/%s/*"`, teamA, teamA)) + + applyTenantGitBranch("other", teamA, teamBranch(teamB)) + verifyResourceCondition("gitbranch", "other", teamA, "Stalled", "True", "BranchNotAllowed", + fmt.Sprintf(`requested branch %q is not allowed`, teamBranch(teamB))) + }) + + It("lets the tenant share its branch with a second team, without the platform", func() { + applySharedGitTarget("shared", teamB, "live", teamA, "team-b/apps") + verifyResourceCondition("gittarget", "shared", teamB, "Validated", "False", "NamespaceNotAuthorized", + fmt.Sprintf("GitBranch %s/live admits %s in spec.allowedGitTargets", teamA, teamA)) + + By("team A adds a from: Names grant for team B; the GitProvider does not change") + applyTenantGitBranch("live", teamA, teamBranch(teamA), teamB) + verifyResourceCondition("gittarget", "shared", teamB, "Validated", "True", "Succeeded", "") + applyIsolationWatchRule("shared-configmaps", teamB, "shared", `"configmaps"`) + waitForStreamsRunning("shared", teamB) + applyIsolationConfigMap("shared-cm", teamB) + + expectFileOnBranch(teamBranch(teamA), path.Join("team-b/apps", teamB, "configmaps", "shared-cm.yaml")) + }) +}) diff --git a/test/e2e/shared_gitbranch_e2e_test.go b/test/e2e/shared_gitbranch_e2e_test.go index dc52a0f3..4468847f 100644 --- a/test/e2e/shared_gitbranch_e2e_test.go +++ b/test/e2e/shared_gitbranch_e2e_test.go @@ -17,8 +17,8 @@ import ( // One GitBranch per repository branch, shared across namespaces (docs/architecture.md, // "GitBranch"). A GitBranch claims its branch once its GitProvider's credential opens a push // session, and holds it for every GitTarget that references it: those in other namespaces are -// admitted by the GitProvider's spec.accessFrom and write with its credential, so the tenant -// namespace holds no copy of it. A second GitBranch for the same repository branch, however it +// admitted by the GitBranch's spec.allowedGitTargets and write with its GitProvider's credential, so +// the tenant namespace holds no copy of it. A second GitBranch for the same repository branch, however it // spells the repository, holds nothing and reports BranchConflict; one with a bad credential // claims nothing at all, so creating it first blocks nobody. var _ = Describe("Manager shared GitBranch", Label("manager"), Ordered, func() { @@ -45,11 +45,11 @@ var _ = Describe("Manager shared GitBranch", Label("manager"), Ordered, func() { _, err := kubectlRunInNamespace(platformNs, "apply", "-f", repo.SecretsYAML) Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to the platform namespace") - By("creating the GitProvider, admitting the tenant namespace, and its GitBranch") - applySharedGitProvider(providerName, platformNs, repo.RepoURLHTTP, repo.GitSecretHTTP, tenantNs) + By("creating the GitProvider, and its GitBranch admitting the tenant namespace") + applySharedGitProvider(providerName, platformNs, repo.RepoURLHTTP, repo.GitSecretHTTP) verifyResourceStatus("gitprovider", providerName, platformNs, "True", "Succeeded", "Repository connectivity validated") - applySharedGitBranch(branchName, platformNs, providerName, "main") + applySharedGitBranch(branchName, platformNs, providerName, "main", tenantNs) verifyResourceStatus("gitbranch", branchName, platformNs, "True", "Succeeded", "holds") }) @@ -101,18 +101,18 @@ var _ = Describe("Manager shared GitBranch", Label("manager"), Ordered, func() { }).Should(Succeed()) }) - It("refuses a namespace the GitBranch's GitProvider does not admit", func() { + It("refuses a namespace the GitBranch does not admit", func() { applySharedGitTarget("intruder", outsiderNs, branchName, platformNs, "outsider") verifyResourceCondition("gittarget", "intruder", outsiderNs, - "Validated", "False", "NamespaceNotAuthorized", "spec.accessFrom") + "Validated", "False", "NamespaceNotAuthorized", "spec.allowedGitTargets") }) It("holds the branch against a second GitBranch, even through another GitProvider", func() { By("giving the tenant a GitProvider of its own, with a valid credential, for the same repository") _, err := kubectlRunInNamespace(tenantNs, "apply", "-f", repo.SecretsYAML) Expect(err).NotTo(HaveOccurred()) - applySharedGitProvider("own-monorepo", tenantNs, repo.RepoURLHTTP, repo.GitSecretHTTP, "") - applySharedGitBranch("own-main", tenantNs, "own-monorepo", "main") + applySharedGitProvider("own-monorepo", tenantNs, repo.RepoURLHTTP, repo.GitSecretHTTP) + applySharedGitBranch("own-main", tenantNs, "own-monorepo", "main", "") verifyResourceCondition("gitbranch", "own-main", tenantNs, "Stalled", "True", "BranchConflict", fmt.Sprintf("%s/%s", platformNs, branchName)) @@ -127,12 +127,12 @@ var _ = Describe("Manager shared GitBranch", Label("manager"), Ordered, func() { By("creating a GitBranch for the release branch whose GitProvider's credential is wrong") _, err := kubectlRunInNamespace(outsiderNs, "apply", "-f", repo.SecretsYAML) Expect(err).NotTo(HaveOccurred()) - applySharedGitProvider("squat", outsiderNs, repo.RepoURLHTTP, repo.GitSecretInvalid, "") - applySharedGitBranch("squat-release", outsiderNs, "squat", "release") + applySharedGitProvider("squat", outsiderNs, repo.RepoURLHTTP, repo.GitSecretInvalid) + applySharedGitBranch("squat-release", outsiderNs, "squat", "release", "") verifyResourceStatus("gitbranch", "squat-release", outsiderNs, "False", "PushSessionFailed", "") By("creating a valid GitBranch for the same branch afterwards") - applySharedGitBranch("monorepo-release", platformNs, providerName, "release") + applySharedGitBranch("monorepo-release", platformNs, providerName, "release", "") verifyResourceStatus("gitbranch", "monorepo-release", platformNs, "True", "Succeeded", "holds") }) @@ -155,13 +155,10 @@ var _ = Describe("Manager shared GitBranch", Label("manager"), Ordered, func() { }) }) -// applySharedGitProvider applies a GitProvider for url with secret, admitting accessFrom when set. -func applySharedGitProvider(name, namespace, url, secret, accessFrom string) { +// applySharedGitProvider applies a GitProvider for url with secret, letting GitBranches in its own +// namespace claim main and release. +func applySharedGitProvider(name, namespace, url, secret string) { By(fmt.Sprintf("creating GitProvider '%s/%s'", namespace, name)) - access := "" - if accessFrom != "" { - access = fmt.Sprintf("\n accessFrom:\n names: [%s]", accessFrom) - } _, err := kubectlRunWithStdin("", fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: GitProvider metadata: @@ -169,17 +166,24 @@ metadata: namespace: %s spec: url: %s - allowedBranches: ["main", "release"] + allowedGitBranches: + - from: Same + branches: ["main", "release"] secretRef: - name: %s%s -`, name, namespace, url, secret, access), "apply", "-f", "-") + name: %s +`, name, namespace, url, secret), "apply", "-f", "-") Expect(err).NotTo(HaveOccurred(), "failed to apply GitProvider %s/%s", namespace, name) } -// applySharedGitBranch applies a GitBranch writing branch through the GitProvider provider. -func applySharedGitBranch(name, namespace, provider, branch string) { +// applySharedGitBranch applies a GitBranch writing branch through the GitProvider provider, in its +// own namespace. GitTargets in its own namespace may write through it, and in sharedWith when set. +func applySharedGitBranch(name, namespace, provider, branch, sharedWith string) { By(fmt.Sprintf("creating GitBranch '%s/%s' for branch '%s' through GitProvider '%s'", namespace, name, branch, provider)) + shared := "" + if sharedWith != "" { + shared = fmt.Sprintf("\n - from: Names\n names: [%s]", sharedWith) + } _, err := kubectlRunWithStdin("", fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: GitBranch metadata: @@ -189,7 +193,9 @@ spec: gitProviderRef: name: %s branch: %s -`, name, namespace, provider, branch), "apply", "-f", "-") + allowedGitTargets: + - from: Same%s +`, name, namespace, provider, branch, shared), "apply", "-f", "-") Expect(err).NotTo(HaveOccurred(), "failed to apply GitBranch %s/%s", namespace, name) } diff --git a/test/e2e/source_cluster_e2e_test.go b/test/e2e/source_cluster_e2e_test.go index b9097f67..72b2bf1a 100644 --- a/test/e2e/source_cluster_e2e_test.go +++ b/test/e2e/source_cluster_e2e_test.go @@ -17,7 +17,7 @@ import ( // cluster it mirrors FROM by referencing a namespaced KubeProvider (spec.kubeProviderRef). The // KubeProvider is the home for that cluster's credential (spec.secretRef, a kubeconfig Secret in // the provider's OWN namespace, or spec.inCluster where the installation allows it), for which -// other namespaces may use it (spec.accessFrom), and for its audit route. +// other namespaces may use it (spec.allowedGitTargets), and for its audit route. // // Three kinds of spec live here: // - Input-validation specs (Scenario 1) assert the KubeProvider reconciler's Validated verdict on @@ -164,17 +164,18 @@ func writeKubeConfigSecret(namespace, name, key, kubeconfig string) { } // applyKubeProvider applies a KubeProvider in namespace whose secretRef names a kubeconfig Secret in -// that same namespace, admitting the other namespaces listed. It returns the kubectl error so a spec -// can assert the apply succeeded (the CR is well-formed; the kubeconfig, not the CR, is what a -// validation case makes bad). +// that same namespace, letting its own namespace and the other namespaces listed mirror their own +// namespace. It returns the kubectl error so a spec can assert the apply succeeded (the CR is +// well-formed; the kubeconfig, not the CR, is what a validation case makes bad). func applyKubeProvider(namespace, name, secretName, key string, admits ...string) (string, error) { keyLine := "" if key != "" { keyLine = "\n key: " + key } - accessFrom := "" + grants := "\n allowedGitTargets:\n - from: Same\n sourceNamespaces: [\"{namespace}\"]" if len(admits) > 0 { - accessFrom = fmt.Sprintf("\n accessFrom:\n names: [%s]", strings.Join(admits, ", ")) + grants += fmt.Sprintf("\n - from: Names\n names: [%s]\n sourceNamespaces: [\"{namespace}\"]", + strings.Join(admits, ", ")) } manifest := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: KubeProvider @@ -184,7 +185,7 @@ metadata: spec: secretRef: name: %s%s%s -`, name, namespace, secretName, keyLine, accessFrom) +`, name, namespace, secretName, keyLine, grants) return kubectlRunWithStdin(namespace, manifest, "apply", "-f", "-") } @@ -203,7 +204,10 @@ func applyGitTargetWithKubeProvider( manifest := "" if branchNS == "" { branchNS = ns - manifest = gitBranchManifest(ns, gitProvider) + // Applied only when missing: applying it again would drop a writer grant a spec added. + if _, err := kubectlRunInNamespace(ns, "get", "gitbranch", gitBranchName(gitProvider, "main")); err != nil { + manifest = gitBranchManifest(ns, gitProvider) + } } manifest += fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: GitTarget @@ -315,11 +319,10 @@ var _ = Describe("Manager source cluster / KubeProvider", Label("source-cluster" Expect(err).NotTo(HaveOccurred(), "failed to apply repo secrets") createReadyGitProvider(providerName, testNs, repo.GitSecretHTTP, repo.RepoURLHTTP) - // The tenant writes through this namespace's GitBranch, so the GitProvider admits it; which + // The tenant writes through this namespace's GitBranch, so the GitBranch admits it; which // KubeProvider it may READ through is what the specs below decide. - _, err = kubectlRunInNamespace(testNs, "patch", "gitprovider", providerName, "--type=merge", "-p", - fmt.Sprintf(`{"spec":{"accessFrom":{"names":[%q]}}}`, tenantNs)) - Expect(err).NotTo(HaveOccurred(), "failed to admit the tenant namespace to the GitProvider") + _, err = kubectlRunWithStdin(testNs, gitBranchManifest(testNs, providerName, tenantNs), "apply", "-f", "-") + Expect(err).NotTo(HaveOccurred(), "failed to admit the tenant namespace to the GitBranch") if kcpAvailable() { kcp = startKcpTunnel() @@ -433,6 +436,9 @@ kind: KubeProvider metadata: {name: %s, namespace: %s} spec: inCluster: {} + allowedGitTargets: + - from: Same + sourceNamespaces: ["{namespace}"] `, name, testNs), "apply", "-f", "-") Expect(err).NotTo(HaveOccurred()) verifyResourceCondition("kubeprovider", name, testNs, "Validated", "False", "InClusterNotAuthorized", "") @@ -465,8 +471,8 @@ spec: }) // Scenario 2b — a tenant's KubeProvider is the tenant's. Another namespace may not mirror - // through it until its accessFrom admits that namespace. - It("refuses a second tenant a KubeProvider whose accessFrom does not admit it", func() { + // through it until a grant in its allowedGitTargets admits that namespace. + It("refuses a second tenant a KubeProvider whose grants do not admit it", func() { const name = "sc-unreachable" target := "sc-tenant-target" _, err := applyGitTargetWithKubeProvider( @@ -488,7 +494,7 @@ spec: }) // Scenario 3 — the install's own provider: "local" in the operator's namespace mirrors the - // operator's own cluster, and admits a GitTarget in any namespace through its accessFrom. + // operator's own cluster, and admits a GitTarget in any namespace through the suite's grant. It("mirrors the operator's own cluster through the install's KubeProvider", func() { target := "sc-local-target" _, err := applyGitTargetWithKubeProvider(testNs, target, providerName, "", "clusters/local", diff --git a/test/e2e/source_namespace_e2e_test.go b/test/e2e/source_namespace_e2e_test.go index 921100df..0734862d 100644 --- a/test/e2e/source_namespace_e2e_test.go +++ b/test/e2e/source_namespace_e2e_test.go @@ -271,7 +271,7 @@ var _ = Describe("WatchRule source namespace", Label("manager"), Ordered, func() By("asserting the terminal refusal is published with a fix-naming message") verifyResourceCondition("watchrule", refusedRule, configNS, "SourceNamespaceAuthorized", "False", "SourceNamespaceNotAllowed", - "spec.allowCrossNamespace.sources") + "spec.allowedGitTargets[].sourceNamespaces") verifyResourceCondition("watchrule", refusedRule, configNS, "Stalled", "True", "SourceNamespaceNotAllowed", "") verifyResourceCondition("watchrule", refusedRule, configNS, diff --git a/test/e2e/templates/gitprovider-signing.tmpl b/test/e2e/templates/gitprovider-signing.tmpl index 577d1c95..a7cd732c 100644 --- a/test/e2e/templates/gitprovider-signing.tmpl +++ b/test/e2e/templates/gitprovider-signing.tmpl @@ -5,8 +5,9 @@ metadata: namespace: {{.Namespace}} spec: url: {{.RepoURL}} - allowedBranches: - - "{{.Branch}}" + allowedGitBranches: + - from: Same + branches: ["{{.Branch}}"] secretRef: name: {{.SecretName}} commit: diff --git a/test/e2e/templates/gitprovider.tmpl b/test/e2e/templates/gitprovider.tmpl index 08d53a3a..6da30c28 100644 --- a/test/e2e/templates/gitprovider.tmpl +++ b/test/e2e/templates/gitprovider.tmpl @@ -5,7 +5,8 @@ metadata: namespace: {{.Namespace}} spec: url: {{.RepoURL}} - allowedBranches: - - "{{.Branch}}" + allowedGitBranches: + - from: Same + branches: ["{{.Branch}}"] secretRef: name: {{.SecretName}} diff --git a/test/e2e/templates/gittarget.tmpl b/test/e2e/templates/gittarget.tmpl index 6e1f67c5..bc722131 100644 --- a/test/e2e/templates/gittarget.tmpl +++ b/test/e2e/templates/gittarget.tmpl @@ -9,6 +9,8 @@ spec: gitProviderRef: name: {{ .ProviderName }} branch: {{ .Branch }} + allowedGitTargets: + - from: Same {{- if .ParentBranch }} parentBranch: {{ .ParentBranch }} {{- end }} diff --git a/test/e2e/templates/manager/gittarget-prune.tmpl b/test/e2e/templates/manager/gittarget-prune.tmpl index 50dc7d06..265d11ff 100644 --- a/test/e2e/templates/manager/gittarget-prune.tmpl +++ b/test/e2e/templates/manager/gittarget-prune.tmpl @@ -9,6 +9,8 @@ spec: gitProviderRef: name: {{ .ProviderName }} branch: {{ .Branch }} + allowedGitTargets: + - from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget From 89ae4d40c547663b7cdab6ca33bb7e2eb19f64b3 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 18:09:44 +0000 Subject: [PATCH 17/34] docs: document access grants between providers, branches and their users configuration.md leads the sharing section with one tenant end to end, then the grant lists, from, the semantics of each scope, branch patterns, cluster resources, refusal messages, revocation, the boundaries grants do not replace, the limit of another provider on one repository, and a platform with its tenants. The GitProvider, GitBranch, KubeProvider, GitTarget, WatchRule and ClusterWatchRule sections, the guides, definitions, security model and RBAC pages follow. UPGRADING.md carries one entry for the change, and the unreleased KubeProvider and GitBranch entries stop naming accessFrom. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/TODO.md | 4 +- docs/UPGRADING.md | 127 +++- docs/architecture.md | 78 +-- docs/attribution-setup-guide.md | 8 +- docs/azure-devops-getting-started.md | 5 +- docs/bi-directional-argocd.md | 22 +- docs/bi-directional-flux.md | 21 +- docs/bi-directional.md | 10 +- docs/components.md | 2 +- docs/configuration.md | 556 +++++++++++++----- docs/definitions.md | 24 +- docs/demo/commit-window.md | 3 +- .../design/gitbranch-and-kubeprovider-plan.md | 5 + docs/design/one-writer-per-branch-plan.md | 4 +- docs/layout/model.md | 2 +- docs/quickstart.md | 5 +- docs/rbac.md | 5 +- docs/security-model.md | 24 +- docs/spec/e2e-bi-directional-corner.md | 2 +- docs/spec/status-conditions-guide.md | 14 +- docs/spec/where-validation-lives.md | 3 +- hack/crdfields/fields.yaml | 9 +- internal/controller/constants.go | 6 +- 23 files changed, 656 insertions(+), 283 deletions(-) diff --git a/docs/TODO.md b/docs/TODO.md index 31bdce51..8e5ad683 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -115,8 +115,8 @@ rebuild fails is no longer dropped: it stays pending for that retry refusing rather than reporting when a governing root disappears, a CRD default for `placement.default` (legibility only, since #319 removed the correctness objection), and a namespace-local `GitProvider` so three targets in three namespaces need not copy one credential. - That last one is answered: a `GitTarget` may name a `GitProvider` in another namespace, which - its `spec.accessFrom` admits. + That last one is answered: a `GitBranch` may name a `GitProvider` in another namespace, whose + `spec.allowedGitBranches` admit it. - [ ] Reduce duplication between `WatchRule` and `ClusterWatchRule` code paths where it makes sense. diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index f391f9a0..6e78b0c6 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -7,6 +7,79 @@ guidance that the changelog's breaking-change entries link to. We are pre-1.0, so breaking changes bump the **minor** version (release-please is configured with `bump-minor-pre-major`) rather than the major. Read the relevant entry before upgrading across it. +## Grants say who may use a provider or a branch; `allowedBranches` is `allowedGitBranches` + +**Breaking.** Who may use a `GitProvider`, a `GitBranch` or a `KubeProvider` is a list of grants on +that object, and **nothing is admitted automatically, the object's own namespace included**. An +omitted or empty list admits nobody. + +| Grant list | Admits | Replaces | +|---|---|---| +| `GitProvider.spec.allowedGitBranches` | `GitBranch`es claiming its `branches` patterns | `spec.allowedBranches`, and `spec.accessFrom` | +| `GitBranch.spec.allowedGitTargets` | `GitTarget`s writing through it | the `GitProvider`'s `spec.accessFrom` | +| `KubeProvider.spec.allowedGitTargets` | `GitTarget`s mirroring its `sourceNamespaces` and `clusterResources` | `spec.accessFrom`, `spec.allowCrossNamespace.sources` | +| `KubeProvider.spec.allowedApplierClaims` | applier claims holding an applier in its `applierNamespaces` | `spec.accessFrom`, `spec.allowCrossNamespace.appliers` | + +Every grant has a required `from`: `Same` (the namespace of the object holding the grant), `Names` +with `names`, `Selector` with a non-empty `selector`, or `All`. `spec.accessFrom`, +`spec.allowedBranches`, `spec.allowCrossNamespace` and their chart values are gone, and the API +server prunes them. The equivalents of the old defaults: + +```yaml +kind: GitProvider +spec: + allowedGitBranches: # was allowedBranches: [main], for this namespace's GitBranches + - from: Same + branches: [main] +--- +kind: GitBranch +spec: + allowedGitTargets: # this namespace's GitTargets, as before; add from: Names to share + - from: Same +--- +kind: KubeProvider +spec: + allowedGitTargets: # was its own namespace, plus accessFrom, without allowCrossNamespace + - from: Same + sourceNamespaces: ["{namespace}"] + allowedApplierClaims: + - from: Same + applierNamespaces: ["{namespace}"] +``` + +- **Every `GitBranch`, `GitTarget` and applier claim needs a grant, also in the provider's own + namespace.** Without one it reports `NamespaceNotAuthorized` and recovers once a grant admits it. +- **`allowedBranches` became `allowedGitBranches`, which also says who may claim.** A + `GitBranch.spec.gitProviderRef` may name a `GitProvider` in another namespace. Branch patterns match + the whole branch name, `*` crosses `/`, and `teams/{namespace}/*` gives each namespace its own + prefix. A branch outside the patterns is `BranchNotAllowed`. +- **Who writes a branch is the `GitBranch`'s decision.** A `GitTarget` in another namespace writes + through it only while its `spec.allowedGitTargets` admit that namespace. +- **The chart's `local` provider grants nothing** unless `kubeProvider.allowedGitTargets` and + `kubeProvider.allowedApplierClaims` do, or `quickstart.enabled=true`, which grants exactly the + quickstart namespace. To keep the old open default, every namespace mirroring its own, install with + the chart's `values-playground.yaml`. +- **A `ClusterWatchRule` needs `clusterResources`.** It mirrors only the types the grant admitting + its `GitTarget` names (`clusterroles.rbac.authorization.k8s.io`, a bare `nodes`, or `"*"`), and + one type outside them refuses the whole rule (`ClusterResourceNotAllowed`). No default grants any, + the chart's included, so a `ClusterWatchRule` through `local` stops mirroring until a grant does. +- **A `WatchRule`'s own namespace needs a grant too:** `sourceNamespaces: ["{namespace}"]`. An + explicit list is the whole scope, and `sourceNamespace: "*"` needs `"*"`. +- **An applier claim refused by `allowedApplierClaims` hands its applier back**, as one no eligible + `GitTarget` references does. A `GitBranch` no grant admits any more gives up its branch after its + branch worker pushed or refused what it held, and keeps its finalizer. +- `SourceNamespaceAuthorized` is `True` with reason `SourceNamespaceAllowed` for every admitted + rule; `LegacySourceNamespace` is gone. + +Before upgrading, list what each provider admits today, and write the grants that keep it: + +```bash +kubectl get gitproviders,kubeproviders -A -o yaml | grep -E '^ name:|namespace:|accessFrom|allowedBranches|allowCrossNamespace' +``` + +See [configuration.md](configuration.md#sharing-connections-across-namespaces) for the full model, +including a platform and its tenants. + ## `FluxTrigger` and `ArgoCDTrigger` are now `FluxClaim` and `ArgoCDClaim` **Breaking, with no migration path.** Before upgrading, delete every `FluxTrigger` and @@ -120,8 +193,10 @@ metadata: {name: prod-eu-1, namespace: platform} spec: secretRef: name: prod-eu-1-kubeconfig # a Secret in this namespace; key defaults to value, then value.yaml - accessFrom: - names: [team-a] # other namespaces; platform itself is always admitted + allowedGitTargets: # see "Grants say who may use a provider or a branch" + - from: Names + names: [team-a] + sourceNamespaces: ["{namespace}"] --- kind: GitTarget metadata: {name: apps, namespace: team-a} @@ -142,8 +217,8 @@ Migrate in this order: 2. Upgrade. The chart renders a `KubeProvider` named `local` in the release namespace, using the operator's own identity (see below). Its values are `kubeProvider.create`, `kubeProvider.name`, - `kubeProvider.secretRef`, `kubeProvider.accessFrom` and `kubeProvider.allowCrossNamespace`; the - `clusterProvider.*` values are gone. + `kubeProvider.secretRef`, `kubeProvider.allowedGitTargets` and `kubeProvider.allowedApplierClaims`; + the `clusterProvider.*` values are gone. 3. Create a `KubeProvider` for each other `ClusterProvider`, in the namespace that should own the credential. **The kubeconfig Secret is read from the provider's own namespace**, not from the operator's, so move or copy it there. `spec.kubeConfig.secretRef` is `spec.secretRef`. @@ -162,9 +237,9 @@ and `controllerManager.inClusterKubeProviders` adds others. Any other provider t `spec.inCluster` reports `Validated=False` and `Stalled=True` (`InClusterNotAuthorized`) and is never used. `spec.client` is refused beside `spec.inCluster`, where it was ignored. -**A provider admits its own namespace.** An empty `accessFrom` admitted nobody on a -`ClusterProvider`. On a `KubeProvider` it admits only the provider's own namespace, and `accessFrom` -admits the others. `allowCrossNamespace.sources` and `.appliers` keep their meaning. +**`accessFrom` and `allowCrossNamespace` are replaced by grants.** A `KubeProvider` admits nobody, +its own namespace included, until a grant in `spec.allowedGitTargets` or `spec.allowedApplierClaims` +does; see "Grants say who may use a provider or a branch". **Audit routes default to `.`, so the audit webhook URL changes.** The chart's provider posts under `/audit-webhook/.local`, `/audit-webhook/gitops-reverser.local` @@ -184,7 +259,7 @@ name, so two providers with one name in two namespaces are two series. ## `GitBranch` holds a repository branch; a `GitTarget` names one with `spec.gitBranchRef` -**Breaking.** A repository branch is its own object, `GitBranch`, in the namespace of the +**Breaking.** A repository branch is its own object, `GitBranch`, claiming the branch through the `GitProvider` whose credential writes it. A `GitTarget` names one with the required, immutable `spec.gitBranchRef`, and `spec.gitProviderRef`, `spec.branch` and `spec.parentBranch` are gone from `GitTarget`: setting one is refused at admission, with a message naming `spec.gitBranchRef`. @@ -197,9 +272,11 @@ Replace each GitTarget's three fields with a `GitBranch` and a reference to it: kind: GitBranch metadata: {name: platform-main, namespace: platform} spec: - gitProviderRef: {name: platform} # a GitProvider in this namespace + gitProviderRef: {name: platform} # namespace: omitted means this one branch: main # parentBranch: develop # was GitTarget.spec.parentBranch + allowedGitTargets: # who may write through it; see the entry on grants + - from: Same --- kind: GitTarget metadata: {name: apps, namespace: platform} @@ -218,10 +295,11 @@ kubectl get gittargets -A -o jsonpath='{range .items[*]}{.metadata.namespace}/{. Run it before upgrading the CRDs: the new schema no longer serves the old fields. -**One `GitBranch` holds a repository branch.** It holds it once its `GitProvider`'s credential -opens a push session on the remote; the oldest such `GitBranch` holds it, keeps it through a later -credential failure, and every other `GitBranch` for the same repository branch (compared as URLs, -so an `https://` and an SSH spelling of one repository are one) reports `Stalled=True` +**One `GitBranch` holds a repository branch.** It holds it once a grant on its `GitProvider` admits +it and the provider's credential opens a push session on the remote; the oldest such `GitBranch` +holds it, keeps it through a later credential failure, and every other `GitBranch` for the same +repository branch (compared as URLs, so an `https://` and an SSH spelling of one repository are one) +reports `Stalled=True` (`BranchConflict`) and writes nothing. Create one `GitBranch` per repository branch, and point every `GitTarget` on that branch at it. @@ -234,28 +312,15 @@ arrives. On a host that offers push sessions anonymously it proves only that the reachable. **Sharing a repository branch across namespaces.** A `GitTarget` names a `GitBranch` in another -namespace with `spec.gitBranchRef.namespace`; that `GitBranch`'s `GitProvider` must admit the -`GitTarget`'s namespace in `spec.accessFrom`, or the target reports `Validated=False` -(`NamespaceNotAuthorized`). The tenant namespace holds no copy of the credential: - -```yaml -kind: GitProvider -metadata: {name: monorepo, namespace: platform} -spec: - accessFrom: - names: [team-a, team-b] # or selector:, matched against Namespace labels ---- -kind: GitTarget -metadata: {name: apps, namespace: team-a} -spec: - gitBranchRef: {name: monorepo-main, namespace: platform} -``` +namespace with `spec.gitBranchRef.namespace`; that `GitBranch`'s `spec.allowedGitTargets` must admit +the `GitTarget`'s namespace, or the target reports `Validated=False` (`NamespaceNotAuthorized`). The +tenant namespace holds no copy of the credential. New reasons follow from it. A `GitTarget` whose `GitBranch` does not exist reports `Validated=False` (`GitBranchNotFound`); one whose `GitBranch` does not hold its branch, or is not `Ready`, reports `GitBranchReady=False` (`GitBranchNotReady`) and waits. That condition replaces -`GitProviderReady`. `BranchNotAllowed`, `ParentBranchNotFound` and `RecoveringParentBranch` are now -reported by the `GitBranch`, and `GitTarget`s on it show them in the `GitBranchReady` message. +`GitProviderReady`. `NamespaceNotAuthorized`, `BranchNotAllowed`, `ParentBranchNotFound` and +`RecoveringParentBranch` are now reported by the `GitBranch`, and `GitTarget`s on it show them in the `GitBranchReady` message. `TargetConflict` now means only overlapping paths on one `GitBranch`. A `WatchRule` or `ClusterWatchRule` no longer reads the GitProvider and no longer reports `GitProviderNotFound`; it follows its `GitTarget`'s readiness. The chart's quick start renders a `GitBranch` diff --git a/docs/architecture.md b/docs/architecture.md index 67799795..de4ddd5c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -20,9 +20,9 @@ credential belongs in the namespace of whoever owns it. A namespaced `GitProvide write boundary: its credential, branch policy, and targets usually belong together. A namespaced `KubeProvider` is one **logical source identity**: one client, discovery surface, watch state, and attribution partition, shared by every target that names it. Its kubeconfig Secret lives in its own -namespace, so a tenant's connection keeps its credential with the tenant. A provider admits -consumers in its own namespace, and `accessFrom` admits other control-cluster namespaces; neither -grants source-cluster RBAC or selects source namespaces. The source identity is the provider's +namespace, so a tenant's connection keeps its credential with the tenant. Its grants +(`spec.allowedGitTargets`, `spec.allowedApplierClaims`) admit control-cluster namespaces, its own +included only by a grant, and bound what they may reach; none grants source-cluster RBAC. The source identity is the provider's namespace and name, with no API-server identity probe: two providers configured for the same server remain separate source partitions, and so do two providers with one name in two namespaces. @@ -34,13 +34,14 @@ or forbidden credential falls through to the operator's identity. Keeping a repository's write credential in the namespace of whoever owns that repository is what makes a team able to start mirroring without a platform admin. Each branch of that repository is a -`GitBranch` in the credential's namespace, and **one `GitBranch` holds a repository branch**, -because one branch worker writes the branch, with one credential: the oldest `GitBranch` whose -credential opens a push session on the remote holds it, and any other reports `BranchConflict`. A -repository written from several namespaces needs no copy of the credential in each: their -`GitTarget`s name the shared `GitBranch` with -`spec.gitBranchRef.namespace`, admitted by its `GitProvider`'s `spec.accessFrom`, the same pattern a -`KubeProvider` uses. Its own namespace is always admitted. Neither kind can change scope later: a +`GitBranch`, in the credential's namespace or in one the `GitProvider`'s `spec.allowedGitBranches` +admit for that branch, and **one `GitBranch` holds a repository branch**, because one branch worker +writes the branch, with one credential: the oldest admitted `GitBranch` whose credential opens a +push session on the remote holds it, and any other reports `BranchConflict`. A repository written +from several namespaces needs no copy of the credential in each: their `GitTarget`s name the shared +`GitBranch` with `spec.gitBranchRef.namespace`, admitted by that `GitBranch`'s +`spec.allowedGitTargets`. Nothing is admitted automatically, the own namespace included +([configuration.md](configuration.md#sharing-connections-across-namespaces)). Neither kind can change scope later: a CRD's `spec.scope` is immutable once the definition is `Established`, so the change would mean deleting the definition (and every object of the kind with it) and recreating both. @@ -384,14 +385,14 @@ Subresources are rejected in rule resources. Mirroring operates on top level res made through the `/scale` subresource reaches Git as the parent's own watch event, and its audit event serves only to name who scaled the parent. -The two namespace controls intentionally live in different planes: +One `KubeProvider` grant answers two questions in different planes: -- `KubeProvider.spec.accessFrom` authorizes **control-cluster** namespaces other than its own to - reference the provider. It is a tenant/export boundary; it neither selects nor grants access to source namespaces. -- `KubeProvider.spec.allowCrossNamespace.sources` delegates the CHOICE of source namespace to the - `GitTarget`s that provider admits. It grants nothing: what may actually be read from the source - cluster is bounded by that credential's own Kubernetes RBAC, which is the only bound there is. The - operator reads no `Namespace` objects in a source cluster for authorization. +- `from` (with `names` or `selector`) authorizes **control-cluster** namespaces to reference the + provider. It is a tenant/export boundary; it neither selects nor grants access to source namespaces. +- `sourceNamespaces` and `clusterResources` bound what the `GitTarget`s it admits may watch. They + grant nothing: what may actually be read from the source cluster is bounded by that credential's + own Kubernetes RBAC. The operator reads no `Namespace` objects in a source cluster for + authorization. ### CommitRequest @@ -431,10 +432,10 @@ One materialization from a source provider to a Git destination: `(KubeProvider, Key fields: - `spec.gitBranchRef`: required; a `GitBranch` by name, in the `GitTarget`'s own namespace unless - `namespace` names another one, whose `GitProvider`'s `spec.accessFrom` must then admit it. + `namespace` names another one. Its `spec.allowedGitTargets` must admit the `GitTarget`'s namespace. - `spec.kubeProviderRef`: required; the source `KubeProvider`, in the `GitTarget`'s own namespace - unless `namespace` names another one, whose `spec.accessFrom` must then admit it. There is no - default provider: a missing one reports `Validated=False` (`KubeProviderNotFound`). + unless `namespace` names another one. Its `spec.allowedGitTargets` must admit the `GitTarget`'s + namespace. There is no default provider: a missing one reports `Validated=False` (`KubeProviderNotFound`). - `spec.path`: immutable, required path under the repo (`MinLength=1`; `.` means repo root and must be chosen explicitly). - `spec.encryption`: optional SOPS/age encryption settings for sensitive resources. @@ -499,12 +500,10 @@ Represents a Git repository and the credentials/configuration used to write it. - `spec.url`: immutable repository URL. - `spec.secretRef`: optional Secret in the same namespace for HTTP/SSH authentication. -- `spec.accessFrom`: which other namespaces' `GitTarget`s may write through this provider's - `GitBranch`es, by name or label selector, as on a `KubeProvider`. Its own namespace is always - admitted; omitted admits no other. A `GitTarget` admitted from another namespace writes with this - provider's credential and identity. Checked on every reconcile, so a tightened policy revokes. +- `spec.allowedGitBranches`: which namespaces' `GitBranch`es may claim which branches with this + credential, as grants with a `from` and branch patterns. Its own namespace needs a grant too. A + `GitBranch` it no longer admits gives its branch up, keeping its finalizer. - `spec.knownHostsRef`: optional SSH known hosts source. -- `spec.allowedBranches`: glob patterns that gate writable branches. - `spec.commit.committer`: committer identity (defaults to `GitOps Reverser` / `noreply@configbutler.ai`). - `spec.commit.signing`: SSH signing key reference and optional key generation. - `status.signingPublicKey`: populated when signing is configured and key material is available. @@ -521,15 +520,18 @@ Kubernetes native, Flux, and Argo CD Secret key dialects (see - **Controller**: [internal/controller/gitbranch_controller.go](../internal/controller/gitbranch_controller.go) One branch of the repository a `GitProvider` names, holding it for every `GitTarget` that references -it. Every field is immutable: +it. Every field but `spec.allowedGitTargets` is immutable: -- `spec.gitProviderRef`: a `GitProvider` in the `GitBranch`'s own namespace only, so creating one - takes the right to create objects where the credential lives. -- `spec.branch`: validated against the provider's `allowedBranches` (`BranchNotAllowed`). +- `spec.gitProviderRef`: the `GitProvider`, in the `GitBranch`'s own namespace unless `namespace` + names another. Its `spec.allowedGitBranches` must admit this namespace and the branch + (`NamespaceNotAuthorized`, `BranchNotAllowed`), checked before any push session. +- `spec.branch`: the branch it claims. - `spec.parentBranch`: optional branch the write branch is created from and compared against while it does not exist. Omitted means the remote's default branch. An explicit parent must exist when - creating the write branch (`ParentBranchNotFound`). It does not need to be in `allowedBranches` - because it is only read. + creating the write branch (`ParentBranchNotFound`). The grants need not allow it, because it is + only read. +- `spec.allowedGitTargets`: which namespaces' `GitTarget`s may write through it, its own included + only by a grant. - `status.repository`: the normalized repository URL, half of the key one `GitBranch` holds. - `status.lastPushNoticeAt`: when a `GitReceiver` last delivered a push to it. It records receipt, so it is published even when the look the delivery caused could not read the remote. @@ -591,11 +593,11 @@ immutable arms (delete and recreate the provider to repoint it): Several namespaces may mirror through one provider, but its source identity must not vary by target because it keys source clients, discovery, watches, and attribution. The identity is the provider's `namespace/name` rather than a deduplicated physical-cluster identity, so two providers pointing at the -same API server still have separate contexts and authorization boundaries. A provider admits -consumers in its own namespace; `spec.accessFrom` admits others, and an omitted one admits no other -namespace. The check runs on every reconcile before watches start (`NamespaceNotAuthorized`), so -tightening it also stops an already-existing `GitTarget`, which an admission-time check could not -do. It guards which tenant may cause the operator to export a source; it does not expand that source +same API server still have separate contexts and authorization boundaries. A provider admits only +the namespaces its grants admit, its own included only by a grant. The check runs on every reconcile +before watches start (`NamespaceNotAuthorized`), and a grant or Namespace label change triggers that +reconcile, so tightening it also stops an already-existing `GitTarget`, which an admission-time check +could not do. It guards which tenant may cause the operator to export a source; it does not expand that source credential's Kubernetes permissions. Its audit route, `spec.attribution.auditRoute`, defaults to `.` and is held by one @@ -1341,8 +1343,8 @@ Later attempts may resume a cursor; see There is no periodic object LIST or timer-driven object-drift sweep. Source-namespace selection uses delegated rule configuration and the source credential's permissions; it does not require -reading source Namespace labels. Control-cluster Namespace labels can govern `accessFrom`, which -is a separate provider-reference authorization boundary. +reading source Namespace labels. Control-cluster Namespace labels can govern a `Selector` grant, +which is a separate provider-reference authorization boundary. ### Rule change reconcile diff --git a/docs/attribution-setup-guide.md b/docs/attribution-setup-guide.md index a111ed0d..e76a13ab 100644 --- a/docs/attribution-setup-guide.md +++ b/docs/attribution-setup-guide.md @@ -29,9 +29,11 @@ and watches cold-replay. For a disposable single-pod setup, `attribution.transpo no Redis but loses facts whenever the reverser restarts. It also leaves Redis-backed features such as `CommitRequest` author capture inactive unless you configure Redis separately. -The chart's `KubeProvider`, `local` in the release namespace, admits targets from every namespace. -To restrict it, set `kubeProvider.accessFrom.names` and `selector: null`; Helm otherwise retains the -permissive `selector: {}`. Its own namespace is always admitted. See [RBAC](rbac.md) for the controller's read permissions. +The chart's `KubeProvider`, `local` in the release namespace, admits no namespace by default, its +own included. Grant the namespaces whose `GitTarget`s may mirror through it in +`kubeProvider.allowedGitTargets` (see [the grants](configuration.md#the-grants)), or install with +the chart's `values-playground.yaml` to let every namespace mirror its own. See [RBAC](rbac.md) for +the controller's read permissions. The chart creates the audit Service and cert-manager certificates. Wait for the release to be ready, then read its Secret names and kubeconfig generator: diff --git a/docs/azure-devops-getting-started.md b/docs/azure-devops-getting-started.md index d1e926be..a44e435a 100644 --- a/docs/azure-devops-getting-started.md +++ b/docs/azure-devops-getting-started.md @@ -44,8 +44,9 @@ spec: url: https://dev.azure.com///_git/ secretRef: name: ado-creds - allowedBranches: - - + allowedGitBranches: + - from: Same + branches: [] --- apiVersion: configbutler.ai/v1alpha3 kind: GitBranch diff --git a/docs/bi-directional-argocd.md b/docs/bi-directional-argocd.md index 9ba6e659..732e791e 100644 --- a/docs/bi-directional-argocd.md +++ b/docs/bi-directional-argocd.md @@ -270,14 +270,14 @@ spec: `kubeProviderRef` names the cluster that holds the `Application`: the operator's own cluster for an `inCluster` provider such as the chart's `local`, or a management cluster for a hub Argo CD that -deploys to other clusters. The provider must admit the applier claim's namespace: its own, or one its -`accessFrom` admits. The `GitTarget`'s own `spec.kubeProviderRef` stays the cluster it mirrors. An -`Application` in another namespace than the applier claim's, such as `argocd`, needs -`allowCrossNamespace.appliers: true` on that `KubeProvider`; without it the applier claim reports -`ApplierNamespaceNotAllowed`. That is a delegation, not owner consent: every namespace the provider -admits may then sync any `Application` in reach of its identity whose source is the repository its -`GitProvider` names, and nothing proves it owns the `Application` or may write to the repository. See -[what a namespace may reach outside itself](configuration.md#what-a-namespace-may-reach-outside-itself-specallowcrossnamespace). +deploys to other clusters. The provider's `spec.allowedApplierClaims` must admit the applier claim's +namespace, its own included only by a grant. The `GitTarget`'s own `spec.kubeProviderRef` stays the +cluster it mirrors. The `Application`'s namespace, such as `argocd`, must be in the +`applierNamespaces` that grant gives; otherwise the applier claim reports +`ApplierNamespaceNotAllowed`. That is a delegation, not owner consent: every namespace the grant +admits may then sync any `Application` in its scope whose source is the repository its +`GitProvider` names, and nothing proves it owns the `Application` or may write to the repository. A +denial hands a held `Application` back. See [the grants](configuration.md#the-grants). Both references are immutable: delete and recreate the applier claim to hold another `Application`. An applier claim that holds the switch carries the finalizer `configbutler.ai/argocd-claim`: deleting it, or no longer naming it from any eligible `GitTarget`, hands the switch back first: as it was @@ -327,10 +327,10 @@ The applier claim reports `Configured` and `Held`, and its `Ready` includes both | `OwnedByApplicationSet` | Automated sync is on, and an `ApplicationSet` generated the `Application`, so its template owns the switch | stalled: set `automated.enabled: false` in the template | | `AutomatedSyncOwnedElsewhere` | Automated sync is on, and another field manager applies the switch | stalled: set `enabled: false` in the manifest it applies, or leave it out | | `Forbidden` | The applier cluster's identity may not update or patch the `Application` | stalled | -| `NamespaceNotAuthorized` | The `kubeProviderRef` names a provider in another namespace whose `accessFrom` does not admit the applier claim's namespace | stalled | +| `NamespaceNotAuthorized` | No grant in the provider's `spec.allowedApplierClaims` admits the applier claim's namespace | stalled | | `KubeProviderNotFound` | The `kubeProviderRef` names a provider that does not exist | progressing | | `AuditRouteConflict` | The provider carries an audit route another namespace holds, so it is not used | stalled | -| `ApplierNamespaceNotAllowed` | The `Application` is in another namespace than the applier claim's, and the `KubeProvider` does not set `allowCrossNamespace.appliers` | stalled | +| `ApplierNamespaceNotAllowed` | The `Application`'s namespace is not in the `applierNamespaces` the provider's grants give the applier claim's namespace | stalled | | `HeldByAnotherClaim` | Another `ArgoCDClaim` in line for the same `Application` in the same cluster, one an eligible `GitTarget` references, holds it (it holds the switch already, or was created first); this one starts no sync, and takes over once that one lets go | stalled: delete one of the two | `Held` says whether automated sync is off: @@ -541,7 +541,7 @@ it. | `ArgoCDClaim` `Configured=False`, `ArgoCDVersionNotSupported` | Argo CD before 3.1, whose `Application` has no `enabled` | Upgrade Argo CD to 3.1 or later | | `ArgoCDClaim` `Configured=False`, `ApplierNotFound` | No `Application` with that namespace and name in the applier claim's cluster, or Argo CD is not installed there | Create it, or recreate the applier claim with the right `applicationRef`; an omitted namespace is the applier claim's | | `ApplierClaimReady=False`, `SourceMismatch`, or applier claim `UnsupportedSource` | The message names the broken [`Application` rule](#the-application) | Fix the `Application`; a sync that was waiting is asked for afterward | -| `ArgoCDClaim` `Configured=False`, `ApplierNamespaceNotAllowed` | The `Application` is in another namespace than the applier claim, such as `argocd` | Set `allowCrossNamespace.appliers: true` on the applier claim's `KubeProvider` (Helm: `kubeProvider.allowCrossNamespace.appliers`), or move the applier claim and `GitTarget` to the `Application`'s namespace | +| `ArgoCDClaim` `Configured=False`, `ApplierNamespaceNotAllowed` | The `Application` is in a namespace the grants do not give, such as `argocd` | Add it to `applierNamespaces` in the `KubeProvider`'s `spec.allowedApplierClaims` (Helm: `kubeProvider.allowedApplierClaims`) | | `ArgoCDClaim` `Configured=Unknown`, `NotBound` | A `GitTarget` names the applier claim, but cannot be judged yet: its `GitProvider` or `KubeProvider` does not exist | Create the missing object; the message names it | | `ArgoCDClaim` `Configured=False`, `Forbidden` | The identity lacks one of the [permissions](#permissions) | Grant it, or set `applierAccess.argocd: true` | | A Warning `ApplyRequestFailed` Event | A request could not be delivered, such as a sync somebody else started that did not end within 5 minutes | GitOps Reverser keeps asking after its backoff, at most every five minutes | diff --git a/docs/bi-directional-flux.md b/docs/bi-directional-flux.md index ac1e8888..a9db5345 100644 --- a/docs/bi-directional-flux.md +++ b/docs/bi-directional-flux.md @@ -219,14 +219,14 @@ Appliers on one branch that share a source apply one at a time. with its apply window open, for as long as it runs. - `spec.dependsOn`: a dependency whose apply failed is suspended with its generation unapplied, so its dependents wait until GitOps Reverser's retry of it succeeds. -- It lives in the applier claim's namespace unless the applier claim's `KubeProvider` sets - `allowCrossNamespace.appliers: true`; an applier claim naming one elsewhere without it reports - `ApplierNamespaceNotAllowed`. A `Kustomization` in `flux-system` therefore needs the grant. It is - a delegation, not owner consent: every namespace the provider admits may then - suspend and apply any `Kustomization` in reach of the provider's identity whose source fetches the +- It lives in a namespace the applier claim's `KubeProvider` grants it in `allowedApplierClaims` + (`applierNamespaces`); an applier claim naming one elsewhere reports + `ApplierNamespaceNotAllowed`. A `Kustomization` in `flux-system` therefore needs + `applierNamespaces: [flux-system]`. It is a delegation, not owner consent: every namespace the grant + admits may then suspend and apply any `Kustomization` in its scope whose source fetches the repository its `GitProvider` names. Nothing proves that the namespace owns the `Kustomization`, or may write to that repository: the binding compares URLs. See - [what a namespace may reach outside itself](configuration.md#what-a-namespace-may-reach-outside-itself-specallowcrossnamespace). + [the grants](configuration.md#the-grants). - Several `GitTarget`s may name one `FluxClaim`, for a `Kustomization` whose `path` covers several folders. It is applied once for all of them. - Two `GitProvider`s may name one repository. Before either branch worker asks, both flush: the @@ -258,7 +258,8 @@ spec: `kubeProviderRef` names the cluster that holds the `Kustomization`: the operator's own cluster for an `inCluster` provider such as the chart's `local`, or a management cluster where Flux runs. The -provider must admit the applier claim's namespace: its own, or one its `accessFrom` admits. Both +provider's `spec.allowedApplierClaims` must admit the applier claim's namespace, its own included only +by a grant; a denial hands a held `Kustomization` back. Both references are immutable: delete and recreate the applier claim to hold another `Kustomization`. ### How the applier claim holds the `Kustomization` @@ -350,10 +351,10 @@ The applier claim reports `Configured` and `Held`, and its `Ready` includes both | `Configured`: `UnsupportedSource` | The source is not a `GitRepository`, or breaks one of the [`GitRepository` rules](#the-gitrepository) | stalled | | `Configured`: `SuspendOwnedElsewhere` | Another field manager applies `spec.suspend` | stalled: remove the field from the manifest it applies | | `Configured`: `Forbidden` | The applier cluster's identity may not patch the `Kustomization` or its `GitRepository` | stalled | -| `Configured`: `NamespaceNotAuthorized` | The `kubeProviderRef` names a provider in another namespace whose `accessFrom` does not admit the applier claim's namespace | stalled | +| `Configured`: `NamespaceNotAuthorized` | No grant in the provider's `spec.allowedApplierClaims` admits the applier claim's namespace | stalled | | `Configured`: `KubeProviderNotFound` | The `kubeProviderRef` names a provider that does not exist | progressing | | `Configured`: `AuditRouteConflict` | The provider carries an audit route another namespace holds, so it is not used | stalled | -| `Configured`: `ApplierNamespaceNotAllowed` | The `Kustomization` is in another namespace than the applier claim's, and the `KubeProvider` does not set `allowCrossNamespace.appliers` | stalled | +| `Configured`: `ApplierNamespaceNotAllowed` | The `Kustomization`'s namespace is not in the `applierNamespaces` the provider's grants give the applier claim's namespace | stalled | | `Configured`: `HeldByAnotherClaim` | Another `FluxClaim` holds this `Kustomization` | stalled: delete one of the two | | `Held`: `Suspended` | Suspended under GitOps Reverser, and the last suspend is handled. Nothing applies | ready | | `Held`: `Applying` | An apply GitOps Reverser started is in flight: `status.operation` shows it | ready | @@ -503,7 +504,7 @@ flux get ks checkout -n flux-system | `FluxClaim` `Configured=False`, `ApplierNotFound` | No `Kustomization` with that namespace and name in the applier claim's cluster, or Flux is not installed there | Create it, or recreate the applier claim with the right `kustomizationRef`; an omitted namespace is the applier claim's | | `FluxClaim` `Configured=False`, `SuspendOwnedElsewhere` | The manifest that applies the `Kustomization` sets `spec.suspend` | Remove the field from that manifest | | `ApplierClaimReady=False`, `SourceMismatch`, or applier claim `UnsupportedSource` | The message names the broken [`GitRepository` rule](#the-gitrepository) | Fix the `GitRepository`; the applier claim then adopts the `Kustomization`, and the apply that was waiting is asked for | -| `FluxClaim` `Configured=False`, `ApplierNamespaceNotAllowed` | The `Kustomization` is in another namespace than the applier claim, such as `flux-system` | Set `allowCrossNamespace.appliers: true` on the applier claim's `KubeProvider` (Helm: `kubeProvider.allowCrossNamespace.appliers`), or move the applier claim and `GitTarget` to the `Kustomization`'s namespace | +| `FluxClaim` `Configured=False`, `ApplierNamespaceNotAllowed` | The `Kustomization` is in a namespace the grants do not give, such as `flux-system` | Add it to `applierNamespaces` in the `KubeProvider`'s `spec.allowedApplierClaims` (Helm: `kubeProvider.allowedApplierClaims`) | | `FluxClaim` `Configured=Unknown`, `NotBound` | A `GitTarget` names the applier claim, but cannot be judged yet: its `GitProvider` or `KubeProvider` does not exist | Create the missing object; the message names it | | `FluxClaim` `Ready=True`, `NotReferenced`, and the `Kustomization` is not suspended | No `GitTarget` names the applier claim, so it holds nothing | Name it in the `GitTarget`'s `spec.applierClaimRef` | | `FluxClaim` `Configured=False`, `Forbidden` | The identity lacks one of the [permissions](#permissions) | Grant it, or set `applierAccess.flux: true` | diff --git a/docs/bi-directional.md b/docs/bi-directional.md index 885856fd..724aafc5 100644 --- a/docs/bi-directional.md +++ b/docs/bi-directional.md @@ -32,8 +32,8 @@ flowchart TD Everything bi-directional is opt-in and off by default: the applier claims, the push receiver, `onRefusal: Reapply`, the chart's `applierAccess` grants, and the `KubeProvider`'s -`allowCrossNamespace.appliers`, which an applier claim needs to hold an applier outside its own namespace, -such as one in `flux-system` or `argocd`. A `GitTarget` without `spec.applierClaimRef` never reads, +`allowedApplierClaims`, which an applier claim needs to hold an applier at all, with `applierNamespaces` +naming where it may live, such as `flux-system` or `argocd`. A `GitTarget` without `spec.applierClaimRef` never reads, patches or syncs anything of Flux's or Argo CD's, and neither does an applier claim no `GitTarget` names, so a `Kustomization` keeps its interval and an `Application` keeps automated sync. @@ -445,9 +445,9 @@ returns to `Ready=True` only after a resync covering the refused scope. The elig wait: a push reaches the cluster only through a manual sync or `flux resume`. A graceful stop suspends a `Kustomization` it had resumed; after a crash, the apply that was running finishes, and the next process suspends it. Monitor the operator the way you monitor the applier. -- **An applier claim may hold any applier its `KubeProvider` reaches.** With - `allowCrossNamespace.appliers`, every namespace the provider admits may hold a `Kustomization` or - `Application` in another namespace whose source is the repository its `GitProvider` names. That +- **An applier claim may hold any applier its grant reaches.** A grant in `allowedApplierClaims` lets + every namespace it admits hold a `Kustomization` or `Application` in its `applierNamespaces` whose + source is the repository its `GitProvider` names. That is a delegation by the provider's owner: nothing proves the namespace owns the applier, or may write to the repository, since the binding compares URLs. - **Pending writes live in worker memory.** They do not survive a restart; recovery is a resync of diff --git a/docs/components.md b/docs/components.md index 9c4e0c45..3174d653 100644 --- a/docs/components.md +++ b/docs/components.md @@ -79,7 +79,7 @@ The operator's own API surface and the reconcilers behind it. | CRD types | [`api/v1alpha3/`](../api/v1alpha3/) | the ten kinds users apply | | Reconcilers | [`internal/controller/`](../internal/controller/) | one per kind, plus shared condition and status helpers | | Admission handlers | [`internal/webhook/`](../internal/webhook/) | the always-allow observer and `validate-operator-types`, which captures a `CommitRequest` submitter | -| Namespace admission | [`internal/authz/`](../internal/authz/) | the `KubeProvider` and `GitProvider` namespace-admission decisions (own namespace, then `spec.accessFrom`) and the `spec.allowCrossNamespace` gates, in their own package because several call sites need the same answer | +| Access grants | [`internal/authz/`](../internal/authz/) | one admission decision per act, read from the grant lists on the referenced object (`allowedGitBranches`, `allowedGitTargets`, `allowedApplierClaims`) with the scope that applies, in their own package because several call sites need the same answer | | Rule store | [`internal/rulestore/`](../internal/rulestore/) | compiled `WatchRule` and `ClusterWatchRule` cache the data plane reads | ### Type and scope resolution diff --git a/docs/configuration.md b/docs/configuration.md index 9cdbcbd2..d0d79eea 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,7 +68,7 @@ fields that need more than a line. | Field | Default | What it does | |---|---|---| | `url` | **required** | Repository URL, SSH or HTTPS | -| `allowedGitBranches` | **no `GitBranch`**, its own namespace included | Which namespaces' `GitBranch`es may claim which branches through this provider. Each grant has a `from` (`Same`, `Names`, `Selector`, `All`) and the `branches` patterns it allows | +| `allowedGitBranches` | **no `GitBranch`**, its own namespace included | Which namespaces' `GitBranch`es may claim which branches through this provider. Each grant has a `from` (`Same`, `Names`, `Selector`, `All`) and the `branches` patterns it allows. See [the grants](#the-grants) | | `secretRef` | anonymous access | Secret holding the write credential. See [the credentials Secret](#gitproviderspecsecretref-the-credentials-secret) | | `knownHostsRef` | the Secret's own `known_hosts` | Optional ConfigMap or Secret sharing SSH host keys across providers. Host-key verification fails closed either way | | `commit.committer` | the operator's built-in identity | Name and email written as the commit committer. See [committer identity](#committer-identity) | @@ -82,7 +82,7 @@ Every field but `allowedGitTargets` is immutable: to change one, delete the `Git |---|---|---| | `gitProviderRef` | **required** | The `GitProvider` whose repository and credential write this branch: in this namespace, or `namespace` names another. Its `allowedGitBranches` must admit this namespace and `branch` | | `branch` | **required** | Branch to write. Only one `GitBranch` holds a repository branch; see [who writes a repository branch](#who-writes-a-repository-branch) | -| `allowedGitTargets` | **no `GitTarget`**, its own namespace included | Which namespaces' `GitTarget`s may write through this branch, each grant with a `from`. The only mutable field | +| `allowedGitTargets` | **no `GitTarget`**, its own namespace included | Which namespaces' `GitTarget`s may write through this branch, each grant with a `from`. The only mutable field. See [the grants](#the-grants) | | `parentBranch` | the remote's default branch | Branch a `branch` the remote does not have yet is created from. Only read, so it need not be allowed. See [starting the write branch from another branch](#starting-the-write-branch-from-another-branch-specparentbranch) | ### `GitReceiver` (namespaced): the endpoint a Git host notifies on a push @@ -100,8 +100,8 @@ Set exactly one of `secretRef` and `inCluster`. Both are immutable: delete and r |---|---|---| | `secretRef` | none; set `inCluster` instead | Secret in this provider's namespace holding a kubeconfig for the source cluster. `key` defaults to `value`, then `value.yaml`. See [`KubeProvider`](#kubeprovider) | | `inCluster` | none; set `secretRef` instead | `{}` uses the operator's own identity. Honored only for a provider listed in `--in-cluster-kube-providers`. See [the operator's own identity](#the-operators-own-identity-specincluster) | -| `allowedGitTargets` | **no `GitTarget`**, its own namespace included | Which namespaces' `GitTarget`s may mirror through this provider, and what: each grant has a `from`, and `sourceNamespaces`, `clusterResources` or both | -| `allowedApplierClaims` | **no applier claim**, its own namespace included | Which namespaces' applier claims may hold an applier through this provider, and the `applierNamespaces` that applier may live in | +| `allowedGitTargets` | **no `GitTarget`**, its own namespace included | Which namespaces' `GitTarget`s may mirror through this provider, and what: each grant has a `from`, and `sourceNamespaces`, `clusterResources` or both. See [the grants](#the-grants) | +| `allowedApplierClaims` | **no applier claim**, its own namespace included | Which namespaces' applier claims may hold an applier through this provider, and the `applierNamespaces` that applier may live in. See [the grants](#the-grants) | | `client.qps` | `20` | Client rate limit for this source cluster. Refused beside `inCluster` | | `client.burst` | `30` | Client burst for this source cluster. Refused beside `inCluster` | | `attribution.auditRoute` | `.` | Audit route this provider joins. A route is held by one namespace. See [audit route](#audit-route-and-auditfactsreceived) | @@ -186,68 +186,334 @@ The references are immutable: to hold another `Application`, delete the applier ## Sharing connections across namespaces A platform team usually owns the repository credential, the cluster connections and the endpoint the -Git host calls; application teams only decide what to mirror where. The kinds are cut along that -line. What holds a credential (`GitProvider`, `KubeProvider`, `GitReceiver`, and the `GitBranch` -that claims a branch with that credential) sits in its owner's namespace with its Secret. What a team -decides (`GitTarget`, `WatchRule`) sits in the team's namespace, and reaches the shared objects by -reference. +Git host calls; application teams decide what to mirror where. The kinds are cut along that line. +What holds a credential (`GitProvider`, `KubeProvider`, `GitReceiver`) sits in its owner's namespace +with its Secret. What a team decides (`GitBranch`, `GitTarget`, `WatchRule`, applier claims) can sit +in the team's namespace, and reaches the shared objects by reference, as far as their grants allow. ![A platform namespace holds the shared GitProvider with its credentials Secret, a KubeProvider and a GitReceiver; each team namespace has its own WatchRule and GitTarget writing its own branch of that repository, and a push event is routed by repository and branch to the GitTarget that writes it](images/config-platform-shared.excalidraw.svg) -| Object | How other namespaces use it | -|---|---| -| `GitProvider` | Its `spec.accessFrom` admits other namespaces, by name or label selector. A `GitTarget` there writes through a `GitBranch` of the provider's namespace, named with `spec.gitBranchRef.namespace` | -| `GitBranch` | Lives beside its `GitProvider`, so claiming a branch is an act of the credential's owner. Only one `GitBranch` holds a repository branch; see [who writes a repository branch](#who-writes-a-repository-branch) | -| `KubeProvider` | Its `spec.accessFrom` admits the `GitTarget`s and applier claims of other control-cluster namespaces. It selects nothing in the source cluster and grants nothing there | -| `GitReceiver` | Nothing names it, and nothing needs admitting: it serves every `GitProvider`, in any namespace, whose `spec.url` names the repository a push reports | +### One tenant, end to end + +A platform team keeps one repository credential and one cluster connection in its namespace, +`platform`. Namespace `team-a` carries the label `platform.example.com/tenant: "true"`. + +1. The platform lets tenants claim `teams//*` and mirror their own namespace. +2. Team A creates a `GitBranch` for `teams/team-a/live`, and lets `GitTarget`s from its own + namespace write it. +3. Team A creates a `GitTarget` that references that branch and the platform's `KubeProvider`. +4. The `GitTarget` runs only while **both** grants allow it: the branch's writer grant and the + provider's source grant. ```yaml -# platform: the credential, the branch it claims, and the namespaces it admits +# platform: the credential, and who may claim which branches apiVersion: configbutler.ai/v1alpha3 kind: GitProvider -metadata: {name: platform-repo, namespace: platform} +metadata: {name: shared-repo, namespace: platform} spec: - url: git@github.com:example-org/platform.git - secretRef: {name: git-creds} - allowedBranches: ["team-a/*", "team-b/*"] - accessFrom: - names: [team-a, team-b] + url: https://git.example.com/platform/config.git + secretRef: {name: repository-auth} + allowedGitBranches: + - from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} + branches: ["teams/{namespace}/*"] --- +# platform: the cluster connection, and who may mirror which namespaces +apiVersion: configbutler.ai/v1alpha3 +kind: KubeProvider +metadata: {name: production, namespace: platform} +spec: + secretRef: {name: production-auth} + allowedGitTargets: + - from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} + sourceNamespaces: ["{namespace}"] +--- +# team-a: the branch it claims, and who may write it apiVersion: configbutler.ai/v1alpha3 kind: GitBranch -metadata: {name: team-a-live, namespace: platform} +metadata: {name: live, namespace: team-a} spec: - gitProviderRef: {name: platform-repo} - branch: team-a/live + gitProviderRef: {name: shared-repo, namespace: platform} + branch: teams/team-a/live + allowedGitTargets: + - from: Same --- -# team-a: what to mirror, and where +# team-a: what to mirror, and where; a WatchRule in team-a selects what apiVersion: configbutler.ai/v1alpha3 kind: GitTarget -metadata: {name: live, namespace: team-a} +metadata: {name: apps, namespace: team-a} spec: - gitBranchRef: {name: team-a-live, namespace: platform} - kubeProviderRef: {name: local, namespace: gitops-reverser} - path: live-cluster + gitBranchRef: {name: live} + kubeProviderRef: {name: production, namespace: platform} + path: apps ``` -**Push events route themselves.** The platform team points the Git host's push webhook, or a GitHub -App's or an organization's single webhook, at one [`GitReceiver`](#gitreceiver). A verified push -names a repository and a branch, and wakes the branch worker of every `GitProvider` whose -`spec.url` names that repository, whichever namespace it is in, for that branch only. Nobody wires a -team to the receiver: a team whose new `GitTarget` writes a new branch gets its push notices from -the first push on. A team that wants an endpoint of its own creates a `GitReceiver` in its own -namespace. +| Request | Result | Because | +|---|---|---| +| `team-a`'s `GitBranch` claims `teams/team-a/live` | Allowed | The provider's grant selects `team-a`, and `teams/{namespace}/*` expands to `teams/team-a/*` | +| `team-a`'s `GitBranch` claims `main` or `teams/team-b/live` | Refused, `BranchNotAllowed` | No grant gives `team-a` either branch | +| `team-a`'s `GitTarget` writes through `team-a/live` | Allowed | `from: Same` on the `GitBranch` | +| `team-b`'s `GitTarget` writes through `team-a/live` | Refused, until team A adds `from: Names, names: [team-b]` | The branch's owner decides who shares it | +| A `WatchRule` in `team-a` watches `team-a` | Allowed | `sourceNamespaces: ["{namespace}"]` | +| A `WatchRule` in `team-a` watches `kube-system` | Refused, `SourceNamespaceNotAllowed` | The grant's scope is `team-a` only | +| A `GitTarget` in `platform` mirrors through `production` | Refused, `NamespaceNotAuthorized` | No grant admits `platform`; its own namespace is not automatic | + +Two expressions name a namespace, and they name different ones: + +| Expression | Whose namespace | +|---|---| +| `from: Same` | The object **containing** the grant: for this `GitBranch`, `team-a`; for the providers, `platform` | +| `{namespace}` in a scope | The object **asking** for access: for `team-a`'s `GitBranch`, `team-a` | + +**Platform administrators manage provider grants and authorization labels. Branch owners manage +writer grants on their branches.** That split is why team A can share its branch without asking +the platform. + +### The grants + +Every grant answers one question: who may use this object, and for what? A grant list lives on the +object that lends something, and is named `allowed` plus the kind that refers to it: + +| Grant list | Admits | Scope | +|---|---|---| +| `GitProvider.spec.allowedGitBranches` | `GitBranch`es claiming a branch with its credential | `branches` (required): branch patterns | +| `GitBranch.spec.allowedGitTargets` | `GitTarget`s writing a folder into the branch | none | +| `KubeProvider.spec.allowedGitTargets` | `GitTarget`s mirroring with its credential | `sourceNamespaces`, `clusterResources`, or both | +| `KubeProvider.spec.allowedApplierClaims` | `FluxClaim`s and `ArgoCDClaim`s holding an applier with its credential | `applierNamespaces` (required) | + +- **Nothing is automatic, the object's own namespace included.** An omitted or empty list admits + nobody. GitOps Reverser is stricter than Flux here on purpose: administering a provider and + creating the objects that use it can then carry different RBAC permissions. +- **Grants combine by union**, and order never matters. +- **An explicit scope is the whole scope**, never "these plus your own": + `applierNamespaces: [flux-system]` means `flux-system` only. +- **Every grant list is `+listType=atomic`**, so one owner applies it whole. +- **References are checked when the referrer reconciles, not at admission.** A refused object is + accepted by the API server, reports its reason in status, and recovers on its own once a grant + admits it. + +Every grant has a required `from`, borrowed from Gateway API's `allowedRoutes.namespaces.from`: + +| `from` | Admits | Requires | +|---|---|---| +| `Same` | The namespace of the object containing the grant | nothing else | +| `Names` | Exactly these control-cluster namespaces | `names` | +| `Selector` | Control-cluster namespaces whose labels match | `selector` | +| `All` | Every namespace | nothing else | + +`names` goes only with `Names`, and `selector` only with `Selector`. "Team B or every tenant" is two +grants. `Same` admits an object's own namespace without writing its name, so a kustomize overlay +that moves the object moves the grant with it; a literal own-namespace name in `names` would not +follow. Admitting everyone takes `from: All`, a word a reviewer sees: `from: Selector` with an empty +`selector: {}` is refused. + +| Field | Omitted | `[]` | A value | `"*"` | +|---|---|---|---|---| +| A grant list | No access | No access | Grants, combined by union | (not a value) | +| `from` | Refused: required | (not a list) | `Same`, `Names`, `Selector` or `All` | Refused: use `All` | +| `selector` (with `Selector`) | Refused | (not a list) | A label selector on control-cluster Namespaces | `selector: {}` is refused: use `from: All` | +| `names` (with `Names`) | Refused | Refused | Exact control-cluster namespace names; `{namespace}` is refused, because it would admit every referrer (use `from: Same`) | Refused: use `from: All` | +| `allowedGitBranches[].branches` | Refused: required | Refused | Branch patterns | Every branch | +| `allowedGitTargets[].sourceNamespaces` | No namespace | Refused | Exactly these source-cluster namespaces | Every namespace; also allows `rules[].sourceNamespace: "*"` | +| `allowedGitTargets[].clusterResources` | No cluster-scoped resource | Refused | Exactly these, by identity | Every cluster-scoped resource, including CRDs installed later | +| `allowedApplierClaims[].applierNamespaces` | Refused: required | Refused | Exactly these namespaces | Every namespace | + +A `KubeProvider` `allowedGitTargets` grant sets `sourceNamespaces`, `clusterResources`, or both; +one that grants neither is refused. + +### `{namespace}` and branch patterns + +`{namespace}` in a scope always means the namespace, in the control cluster, of the object asking for +access: the `GitBranch`'s in `branches`, the `GitTarget`'s in `sourceNamespaces`, and the applier +claim's in `applierNamespaces`. In a source-namespace or applier scope it becomes a namespace of the +same name in the source cluster. The syntax is the one a [placement template](#template-variables) +uses, but the subject differs: there `{namespace}` is the mirrored resource's namespace, and can be +`_cluster`; here it is the referring object's, which always exists. A grant renders nothing, so +placement's `|fallback`, sanitizing and empty-segment dropping do not carry over. + +A branch pattern is a small language of its own, matched against the whole branch name: + +- **`*` matches any sequence of characters, `/` included, and the empty one.** `feature/*` matches + `feature/checkout/v2`, and `*` alone is every branch. `main` does not match `main-old`. +- **`{namespace}` must be a whole path segment:** at the start or after a `/`, and at the end or + before a `/`. `teams/{namespace}/*` is valid; `teams/{namespace}*` is refused, because for `team-a` + it would also match `teams/team-ab/live`. +- **No escaping, `?` or character classes.** Git refuses `*`, `?` and `[` in a branch name, so a + literal never needs escaping. +- Any other `{...}` is refused when the object is applied, naming it: + `branch pattern teams/{ns}/* references unknown variable {ns}; the only variable is {namespace}`. + +`sourceNamespaces` and `applierNamespaces` take exact names, `{namespace}` alone, or `"*"` alone. + +### Cluster-scoped resources + +`clusterResources` bounds what a [`ClusterWatchRule`](#clusterwatchrule) may mirror through a +`GitTarget` the grant admits. It names resource types: + +- **A non-core resource is `resource.group`:** `clusterroles.rbac.authorization.k8s.io`, + `customresourcedefinitions.apiextensions.k8s.io`. **A bare name is the core group only:** `nodes`, + `namespaces`. A bare `clusterroles` therefore matches nothing. +- **Exact identities or `"*"`,** with no aliases and no partial wildcards. An identity covers every + served version of its group and resource. +- **It is decided on the rule's text.** A `ClusterWatchRule` item with `"*"` in `apiGroups` or + `resources` needs `clusterResources: ["*"]`. An item that names its groups and resources is checked + pair by pair. An item that omits `apiGroups` is checked by the group discovery resolves it to. +- **One forbidden type refuses the whole `ClusterWatchRule`**, with `ResourcesResolved=False`, reason + `ClusterResourceNotAllowed`, and a message naming the type. Nothing is filtered silently. +- **Discovery never widens a grant.** Under a narrow grant no wildcard item compiles, so a CRD + installed later cannot slip in. Under `["*"]` it is included: that is what `"*"` grants. + +### Refusal messages name the grant + +A refusal says what the grants allow and what was asked, so a tenant never needs a broad grant to +find out why something failed: + +- `GitBranch`: `namespace "team-a" may claim "teams/team-a/*", "feature/*" through GitProvider + platform/shared-repo (spec.allowedGitBranches); requested branch "main" is not allowed` +- `GitTarget` on a branch: `GitBranch team-a/live admits team-a, team-b in spec.allowedGitTargets; + namespace "team-c" is not admitted` +- `WatchRule`: `KubeProvider platform/production lets namespace "team-a" watch "team-a" + (spec.allowedGitTargets[].sourceNamespaces); spec.rules[0] (...) is not allowed` + +The reasons are `NamespaceNotAuthorized` (no grant admits the namespace), `BranchNotAllowed`, +`SourceNamespaceNotAllowed`, `ClusterResourceNotAllowed` and `ApplierNamespaceNotAllowed`. + +### When a grant shrinks + +A grant shrinks through an edited list, a removed Namespace label, or a deleted object, and every +one of them reaches the objects it admitted through a watch. Once the controller observes it, it +admits no new work under the revoked permission. Work it already held may complete: + +- **A `GitBranch` no grant admits any more** gives up its branch the way a deleted one does, but + keeps its finalizer. Its branch worker closes its intake, pushes or refuses each pending write and + settles every `CommitRequest`; an event that arrives afterwards is refused. The `GitBranch` reports + `Stalled=True` with the grant's reason, and the next candidate takes the branch only after the old + worker has retired, with none of its work. Writes can therefore land after the API server accepted + the policy edit: Git cannot take back a push already sent. +- **A `GitTarget`, `WatchRule`, `ClusterWatchRule` or applier claim** that loses admission or scope is + refused on its next reconcile. A write already decided is still committed and pushed. +- **An applier claim its `allowedApplierClaims` refuse** hands its applier back, as one no eligible + `GitTarget` references does: it stays the holder (`Held=False`, `Releasing`) while it hands back, + keeps a `Kustomization` that was suspended before adoption suspended, and gives up after ten + minutes with a Warning Event if it cannot finish. A regrant during the hand-back lets it finish, and + the applier claim then adopts the applier anew. A read error never starts a hand-back. Once the + applier is resumed, Flux or Argo CD may apply Git over live edits not mirrored yet. + +Restoring a grant is enough to recover, also after a controller restart: nothing has to be edited on +the objects it admits. + +A `GitBranch` is a candidate for its branch only while a grant admits it, and the oldest admitted +candidate holds the branch, by `creationTimestamp` and then UID. Granting a namespace that holds an +old `GitBranch` for a branch someone else holds therefore hands the branch to the old one once it +proves its credential, and the live holder reports `BranchConflict`. **Remove stale `GitBranch`es +before granting.** + +### What grants do not replace + +- **Every operation runs as the provider's identity, never as the referrer's.** Grants decide who may + use that identity, and for what; they are not impersonation. A source-namespace grant permits + mirroring what the provider identity can read in that namespace. An applier-namespace grant + permits controlling matching appliers there, and the applier's own execution identity bounds what + it deploys: a Flux `Kustomization`'s `serviceAccountName`, or an Argo CD `AppProject`'s + destinations. +- **On an `inCluster` provider a scope bypasses live namespace RBAC.** The config plane *is* the + watched cluster, so the owner of an admitted object acts on the namespaces in its scope through the + operator's own credential. Grant `sourceNamespaces`, `clusterResources` and `applierNamespaces` + there on purpose. +- **A namespace grant trusts that namespace's administrators.** Anyone, or any reconciler, allowed + to create the referring kind there can use the grant. Labels a `Selector` grant matches must be + controlled by trusted administrators. A `Names` grant also admits a namespace that is deleted and + recreated with the same name. +- **Branch grants restrict claiming and writing; they do not make branches confidential from each + other.** A `parentBranch` may name any branch the provider's credential can read, so a permitted + parent's contents can be copied into the delegated branch. Inside one branch, the folder overlap + check coordinates writers; it is not a boundary between tenants. + +**The limit.** Grants decide who may claim a branch through this provider, and who may write through +this `GitBranch`. They cannot stop a claim on the same repository branch through another provider +whose credential opens a push session. The claim is keyed by the repository and the branch, not the +provider, so a `GitBranch` claiming `main` through a different `GitProvider` for the same URL, with a +copied token, holds it if it is older or first. On Gitea and Forgejo a token that can only read is +enough. Keep tenants from creating providers, as in the next example. + +### A platform and its tenants + +The model assumes one split, and RBAC enforces it: platform administrators manage providers, their +grants and the namespace labels; branch owners manage writer grants on their branches; tenants +manage the objects that use both. With Flux, objects are applied by a reconciler, so what counts is +what each reconciler's identity may create. The platform's reconciler applies: + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: team-a + labels: {platform.example.com/tenant: "true"} # the label the grants select on +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: {name: gitops-reverser-tenant} +rules: + - apiGroups: [configbutler.ai] + resources: [gitbranches, gittargets, watchrules, fluxclaims, argocdclaims, commitrequests] + verbs: [get, list, watch, create, update, patch, delete] +--- +apiVersion: v1 +kind: ServiceAccount +metadata: {name: tenant-reconciler, namespace: team-a} +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: {name: tenant-reconciler, namespace: team-a} +roleRef: {apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: gitops-reverser-tenant} +subjects: [{kind: ServiceAccount, name: tenant-reconciler, namespace: team-a}] +--- +apiVersion: kustomize.toolkit.fluxcd.io/v1 +kind: Kustomization +metadata: {name: team-a, namespace: team-a} +spec: + serviceAccountName: tenant-reconciler # team-a's repository is applied as this identity + sourceRef: {kind: GitRepository, name: team-a} + path: ./ + interval: 10m + prune: true +``` + +It also applies the `GitProvider` and `KubeProvider` above, with their grants, and a `GitBranch` for +`main` in `platform` that the platform keeps for itself (`from: Same` in `allowedGitBranches`). The +Role also needs whatever team A's own workloads require. + +| Identity | Manages | Cannot | +|---|---|---| +| The platform's reconciler | `GitProvider`, `KubeProvider` and their grants; Namespaces and their labels; tenant RBAC; platform `GitBranch`es such as `main` | | +| `team-a/tenant-reconciler` | `GitBranch`, `GitTarget`, `WatchRule`, applier claims and `CommitRequest`s in `team-a` | Create or edit a provider; label a Namespace; change RBAC | + +The last column is what makes the grants hold: a tenant cannot grant itself a label, and cannot +create the second provider the limit describes. Flux's multi-tenancy lockdown +(`--no-cross-namespace-refs` and `--default-service-account` on kustomize-controller) makes the +tenant identity mandatory, so no tenant `Kustomization` falls back to the controller's own. + +### Push events route themselves + +The platform team points the Git host's push webhook, or a GitHub App's or an organization's single +webhook, at one [`GitReceiver`](#gitreceiver). A verified push names a repository and a branch, and +wakes the branch worker of every `GitProvider` whose `spec.url` names that repository, whichever +namespace it is in, for that branch only. Nothing references a `GitReceiver` and nothing needs +admitting: a delivery only wakes a worker, which reads the remote with its own credential. A team +that wants an endpoint of its own creates a `GitReceiver` in its own namespace. Nothing has a default. The operator cannot infer a safe repository, branch, or write credential, nor which cluster a folder means, so every `GitTarget` names its `GitBranch` and its `KubeProvider`. -Nothing falls back to the provider the chart renders. +Nothing falls back to the provider the chart renders, and that provider grants nothing until the +installer says so. ## `GitProvider` -`GitProvider` defines the Git remote, credentials, allowed branches, and commit behavior. A +`GitProvider` defines the Git remote, credentials, who may claim which branches, and commit behavior. A [`GitReceiver`](#gitreceiver) tells it about pushes by matching its `spec.url`; the provider does not name one. @@ -256,7 +522,8 @@ The important fields are: - `spec.url`: repository URL - `spec.secretRef.name`: Secret with Git credentials such as SSH or HTTPS auth - `spec.knownHostsRef`: optional ConfigMap/Secret with SSH `known_hosts` shared across providers -- `spec.allowedBranches`: branches this provider is allowed to write +- `spec.allowedGitBranches`: which namespaces' `GitBranch`es may claim which branches with this + credential ([the grants](#the-grants)); its own namespace needs a grant too - `spec.commit`: committer identity and signing How writes are batched and phrased is **not** here: `spec.commit.window` and `spec.commit.message` @@ -274,8 +541,9 @@ spec: url: git@github.com:example-org/example-repo.git secretRef: name: git-creds - allowedBranches: - - main + allowedGitBranches: + - from: Same + branches: [main] ``` ### `GitProvider.spec.secretRef`: the credentials Secret @@ -414,8 +682,9 @@ metadata: namespace: default spec: url: git@github.com:example-org/example-repo.git - allowedBranches: - - main + allowedGitBranches: + - from: Same + branches: [main] secretRef: name: git-creds commit: @@ -585,11 +854,19 @@ metadata: spec: secretRef: name: prod-eu-1-kubeconfig - accessFrom: - names: [team-a] - selector: - matchLabels: - gitops.configbutler.ai/source-access: "true" + allowedGitTargets: + - from: Names + names: [team-a] + sourceNamespaces: ["{namespace}"] + - from: Selector + selector: + matchLabels: + gitops.configbutler.ai/source-access: "true" + sourceNamespaces: ["{namespace}"] + allowedApplierClaims: + - from: Names + names: [team-a] + applierNamespaces: [flux-system] ``` The Secret is read in the provider's namespace, so a team's connection keeps its credential with the @@ -604,16 +881,23 @@ before it uses it, without dialing the cluster, and reports the verdict as `Vali | `KubeConfigInsecureTLSNotAllowed` | sets `insecure-skip-tls-verify`. Allowed with `--insecure-kubeconfig-tls` | | `KubeConfigFileReferenceNotAllowed` | names a token, certificate or key by file path, which the operator would read from its own Pod. Always refused: embed the `*-data` form instead | -A `KubeProvider` admits the `GitTarget`s and applier claims in its own namespace. `accessFrom` admits -other namespaces, and is evaluated against namespaces in the **control cluster**, where those -objects live. In this example, a `GitTarget` in `platform-connections`, in `team-a`, or in a -control-cluster namespace with the shown label may reference `prod-eu-1`. `names` and `selector` are -ORed, and an omitted policy admits no other namespace. A selector is only as strong as the right to -set the labels it matches: anyone who can label a namespace can admit it, so select on labels only -the platform can set, or use `names`. The check runs when the referrer is -reconciled, not at admission: a `GitTarget` or applier claim the provider does not admit is accepted by -the API server and reports `NamespaceNotAuthorized`, and it recovers on its own once the provider -admits it. +A `KubeProvider` is used only through its [grants](#the-grants), its own namespace included: + +- **`spec.allowedGitTargets`** admits `GitTarget`s to mirror through it. Each grant's + `sourceNamespaces` bound what the `WatchRule`s feeding an admitted target may watch, and its + `clusterResources` what a `ClusterWatchRule` may mirror through it. +- **`spec.allowedApplierClaims`** admits `FluxClaim`s and `ArgoCDClaim`s to hold an applier through + it, and its `applierNamespaces` bound where that applier may live. A namespace admitted to mirror is + not admitted to apply. + +Grants are evaluated against namespaces in the **control cluster**, where the referring objects +live. In this example, a `GitTarget` in `team-a`, or in a control-cluster namespace with the shown +label, may mirror its own namespace through `prod-eu-1`, and an applier claim in `team-a` may hold a +`Kustomization` in `flux-system`. Nothing in `platform-connections` may use it: no grant admits it. +A selector is only as strong as the right to set the labels it matches: anyone who can label a +namespace can admit it, so select on labels only the platform can set, or use `Names`. A referrer +the provider does not admit is accepted by the API server and reports `NamespaceNotAuthorized`, and +it recovers on its own once a grant admits it. Which namespaces are read *from the source cluster* is bounded by the source connection's Kubernetes RBAC: the credential's own RBAC is the hard maximum, and there is no second allow-list beside it. @@ -649,34 +933,14 @@ to ask for by name. `spec.client` is refused beside `inCluster`: the operator's own client is shared by everything that uses it, so it is not throttled per provider. -### What a namespace may reach outside itself (`spec.allowCrossNamespace`) - -```yaml - allowCrossNamespace: # what an admitted namespace may reach outside itself - sources: false # a WatchRule may watch other namespaces, "*" included - appliers: false # an applier claim may name a Kustomization or Application in another namespace -``` +### Grants on an `inCluster` provider -Both default to `false`, and each is a power of its own: - -- **`sources`** lets a namespace *read* other namespaces' objects into its own Git. A `WatchRule` may - name a source namespace other than its own only while it is `true`; while it is `false`, it - watches only its own namespace, and `sourceNamespace: "*"` is refused outright. It delegates the - *choice* of source namespace and grants nothing on its own, since the credential still has to be - able to read what is chosen. -- **`appliers`** lets a namespace *suspend and apply* another namespace's deployments: a - `FluxClaim` or `ArgoCDClaim` may name a `Kustomization` or `Application` outside its own - namespace, such as `flux-system`. While it is `false`, an applier claim naming one elsewhere reports - `Configured=False` (`ApplierNamespaceNotAllowed`). The applier claim's own namespace is always allowed; - in a remote cluster that is the namespace of the same name. - -**It is a delegation, not owner consent.** You trust every namespace the provider admits, its own -and those `accessFrom` admits, with everything in reach of the provider's identity. Neither flag -proves that a namespace owns the objects it reaches, or the repository its `GitTarget` writes. -Setting either on an `inCluster` provider is a much sharper decision than on a remote one: there the -config plane *is* the watched cluster, so it deliberately bypasses live namespace RBAC and lets the -owner of an admitted object act on another namespace through the operator's own credential. That is -legitimate to grant on purpose, which is why each is explicit and defaults to false. +A grant on a provider that uses the operator's own identity is a much sharper decision than on a +remote one: there the config plane *is* the watched cluster, so a scope deliberately bypasses live +namespace RBAC and lets the owner of an admitted object act on the namespaces in its scope through +the operator's own credential. A grant is a delegation, not owner consent: it does not prove that a +namespace owns the objects it reaches, or the repository its `GitTarget` writes. That is legitimate +to grant on purpose, which is why the chart's `local` provider grants nothing by default. `spec.secretRef` and `spec.inCluster` are immutable, switching from one to the other included, and so is `GitTarget.spec.kubeProviderRef`: changing either would silently make an existing @@ -768,7 +1032,8 @@ There are two supported ways to get one, and both are fully declarative: the provider the chart renders. - **Let the chart render it.** With `kubeProvider.create` (on by default), the chart creates and owns a `KubeProvider` named `kubeProvider.name` (`local`) in the release namespace, using `inCluster` - unless `kubeProvider.secretRef.name` is set, with its `accessFrom` and `allowCrossNamespace`. See + unless `kubeProvider.secretRef.name` is set, with the grants in `kubeProvider.allowedGitTargets` and + `kubeProvider.allowedApplierClaims`, empty by default. See [charts/gitops-reverser/README.md](../charts/gitops-reverser/README.md). Turn `kubeProvider.create` off to manage the object yourself. Helm then deletes the provider it created on the next upgrade, so ownership never silently splits between Helm and you. Because a missing @@ -806,19 +1071,28 @@ metadata: namespace: default spec: gitProviderRef: - name: example-provider # a GitProvider in this namespace - branch: main # must match the provider's allowedBranches + name: example-provider # add namespace: for a GitProvider in another namespace + branch: main # a grant on the provider must allow it parentBranch: develop # optional; only used to create a missing branch + allowedGitTargets: # who may write through it, this namespace included + - from: Same ``` -- `spec.gitProviderRef` names a `GitProvider` in the `GitBranch`'s own namespace, never another one. - Creating a `GitBranch` therefore takes the right to create objects where the credential lives. -- `spec.branch` must match one of the provider's `allowedBranches`, or the `GitBranch` reports - `Stalled=True` with reason `BranchNotAllowed`. +- `spec.gitProviderRef` names the `GitProvider` whose credential writes the branch, in this + namespace or, with `namespace`, another. The `GitBranch` claims only while a grant in the provider's + `spec.allowedGitBranches` admits its namespace and a pattern matches `spec.branch` + (`NamespaceNotAuthorized` or `BranchNotAllowed` otherwise, both `Stalled=True`). That check comes + before any push session, so a `GitBranch` no grant admits never uses the credential. +- `spec.allowedGitTargets` says which namespaces' `GitTarget`s may write through it. Claiming a branch + includes the right to share it: sharing `teams/team-a/live` with `team-b` is a `from: Names` grant + here, and the provider does not change. To keep control of the writers of a branch such as `main`, + the platform owns its `GitBranch`. - `spec.parentBranch` is what the branch is created from while the remote does not have it. See [starting the write branch from another branch](#starting-the-write-branch-from-another-branch-specparentbranch). -- Every field is immutable. To write another branch, or through another `GitProvider`, create a new - `GitBranch` and delete the old one. +- `spec.gitProviderRef`, `spec.branch` and `spec.parentBranch` are immutable: to write another branch, + or through another `GitProvider`, create a new `GitBranch` and delete the old one. The reference is + compared as written, so spelling out the `GitBranch`'s own namespace where it was omitted is a + change too. `spec.allowedGitTargets` is mutable. ### Who writes a repository branch @@ -826,8 +1100,8 @@ Only one `GitBranch` writes a repository branch. The key is the provider's repos normalized (so `https://github.com/acme/platform.git` and `git@github.com:acme/platform` are one repository), plus the branch. -1. A `GitBranch` becomes a **candidate** once its `GitProvider`'s credential opens a push - (receive-pack) session on the remote. Nothing is pushed: the session is opened and closed. A +1. A `GitBranch` becomes a **candidate** once a grant admits it and its `GitProvider`'s credential + opens a push (receive-pack) session on the remote. Nothing is pushed: the session is opened and closed. A `GitBranch` whose credential cannot open one reports `Ready=False` with reason `PushSessionFailed` and claims nothing, so creating it first blocks nobody. 2. Among the candidates for one repository branch, the **oldest** (by `creationTimestamp`) holds it @@ -835,7 +1109,8 @@ repository), plus the branch. and a message naming the holder. Reference the holder from your `GitTarget`s, or delete it first. 3. The holder keeps the branch through a later credential or network failure. It reports `Ready=False` (`PushSessionFailed`) and keeps its pending writes, and the branch does not pass to - another `GitBranch` while it is down. + another `GitBranch` while it is down. A holder no grant admits any more gives the branch up; see + [when a grant shrinks](#when-a-grant-shrinks). 4. The claim lives in the operator's memory. After a restart it is decided again by 1 and 2. What the push session proves depends on the Git host: @@ -867,14 +1142,16 @@ status: than one means they share its branch worker; each still reports its own folder's health on its own conditions. -`kubectl get gitbranch` shows the provider, the branch, the commit the branch is at, and `Ready`; +`kubectl get gitbranch` shows the provider and its namespace, the branch, the commit the branch is +at, and `Ready`; `-o wide` adds the reason, the repository, the target count and when the remote was last verified. | Reason | What it means | |---|---| -| `GitProviderNotFound` | No `GitProvider` of that name in this namespace (`Stalled=True`) | +| `GitProviderNotFound` | The `GitProvider` `spec.gitProviderRef` names does not exist (`Stalled=True`) | | `GitProviderNotReady` | The `GitProvider` exists and is not `Ready` | -| `BranchNotAllowed` | `spec.branch` is outside the provider's `allowedBranches` (`Stalled=True`) | +| `NamespaceNotAuthorized` | No grant in the provider's `spec.allowedGitBranches` admits this namespace (`Stalled=True`) | +| `BranchNotAllowed` | The grants that admit this namespace give no pattern matching `spec.branch` (`Stalled=True`) | | `PushSessionFailed` | The credential cannot open a push session; a holder keeps the branch meanwhile | | `BranchConflict` | Another `GitBranch` holds this repository branch (`Stalled=True`) | | `ParentBranchNotFound` | Neither the branch nor its parent is on the remote (`Stalled=True`) | @@ -961,10 +1238,10 @@ If you enable `spec.encryption`, that applies to `Secret` resource writes for th and age details, see [sops-age-guide.md](sops-age-guide.md). `spec.gitBranchRef` references a `GitBranch` by `name`, in the `GitTarget`'s own namespace, and is -required and immutable. Set `namespace` to write through a `GitBranch` in another namespace; the -`GitProvider` that `GitBranch` names must admit this `GitTarget`'s namespace in its -`spec.accessFrom`, or the `GitTarget` is refused with `Validated=False` (`NamespaceNotAuthorized`). -One policy governs both hops. A `GitBranch` that does not exist is `Validated=False` +required and immutable. Set `namespace` to write through a `GitBranch` in another namespace. Either +way the `GitBranch`'s `spec.allowedGitTargets` must admit this `GitTarget`'s namespace, its own +included, or the `GitTarget` is refused with `Validated=False` (`NamespaceNotAuthorized`). A +`GitBranch` that does not exist is `Validated=False` (`GitBranchNotFound`), and one that does not hold its branch, or is not `Ready`, keeps the target at `Ready=False` with reason `GitBranchNotReady` until it does. Either clears on its own once the `GitBranch` appears or proves itself, so manifests can be applied in any order. @@ -973,15 +1250,14 @@ One policy governs both hops. A `GitBranch` that does not exist is `Validated=Fa on a `GitTarget` is refused at admission with a message naming `spec.gitBranchRef`. Two `GitTarget`s on one `GitBranch` must not write the same or nested paths: the later-created one -reports `Validated=False` with reason `TargetConflict`. Only a `GitTarget` that its providers admit -holds a path this way. One that its `GitBranch`'s `GitProvider` or its `KubeProvider` does not admit -writes nothing and holds no path, so creating it first cannot block a folder on someone else's -branch. +reports `Validated=False` with reason `TargetConflict`. Only an admitted `GitTarget` holds a path this +way. One that its `GitBranch` or its `KubeProvider` does not admit writes nothing and holds no path, +so creating it first cannot block a folder on someone else's branch. `spec.kubeProviderRef` references a `KubeProvider` by `name`, in the `GitTarget`'s own namespace -unless `namespace` names another, and is required and immutable. A provider in another namespace -must admit this `GitTarget`'s namespace in its `spec.accessFrom`, or the `GitTarget` is refused with -`Validated=False` (`NamespaceNotAuthorized`). A provider that does not exist is `Validated=False` +unless `namespace` names another, and is required and immutable. The provider's +`spec.allowedGitTargets` must admit this `GitTarget`'s namespace, its own included, or the +`GitTarget` is refused with `Validated=False` (`NamespaceNotAuthorized`). A provider that does not exist is `Validated=False` (`KubeProviderNotFound`), one whose audit route another namespace holds is refused with `AuditRouteConflict`, and one that is not `Ready` keeps the target at `KubeProviderReady=False` (`KubeProviderNotReady`). See [`KubeProvider`](#kubeprovider). @@ -1188,7 +1464,7 @@ spec: - Once the write branch exists, it is used as it is: new commits on the parent do not reach it. Delete the write branch after its changes are merged, and the next edit starts a fresh one. - `parentBranch` is immutable, including adding or removing it: delete and recreate the `GitBranch` - to change it. It is only read, so it does not need to be in `allowedBranches`. + to change it. It is only read, so the provider's `allowedGitBranches` need not allow it. - While the write branch is absent, the `GitBranch`'s `status.remote.parent` shows the parent, whether it was `Found`, `Missing` or `Unborn`, and the commit the first commit would build on. With `parentBranch` omitted, that is how you see which default branch the remote resolved. @@ -1493,11 +1769,12 @@ it under the other. Name one `KubeProvider` for every applier claim that reaches **Where the applier is looked up.** In the cluster the applier claim's `spec.kubeProviderRef` names, with the identity that [`KubeProvider`](#kubeprovider) uses. Name a provider for a management cluster when Flux or Argo CD runs there; an `inCluster` provider is the operator's own cluster. That -provider must admit the applier claim's namespace, as for mirroring, or the applier claim is -`Configured=False` with `NamespaceNotAuthorized` (or `KubeProviderNotFound`). An applier in another -namespace than the applier claim's, such as `flux-system` in the example above, also needs the -provider's [`allowCrossNamespace.appliers`](#what-a-namespace-may-reach-outside-itself-specallowcrossnamespace), -or the applier claim is `Configured=False` with `ApplierNamespaceNotAllowed`. For the operator's own +provider's `spec.allowedApplierClaims` must admit the applier claim's namespace, separately from +mirroring, or the applier claim is `Configured=False` with `NamespaceNotAuthorized` (or +`KubeProviderNotFound`). The applier's namespace, such as `flux-system` in the example above, must +be in the `applierNamespaces` those grants give it, or the applier claim is `Configured=False` with +`ApplierNamespaceNotAllowed`. A denial hands a held applier back; see +[when a grant shrinks](#when-a-grant-shrinks). For the operator's own cluster, the chart grants the applier permissions with `applierAccess.flux: true` and `applierAccess.argocd: true`. @@ -2344,16 +2621,17 @@ so one `WatchRule` can follow different resource types in different namespaces. | an exact name | one source namespace | | `"*"` | every namespace the source credential can read, as one cluster-wide watch | -Naming anything other than the rule's own namespace, **including `"*"`**, is authorized by two -things, both of which must hold: +Every item, the rule's own namespace included, is authorized by the grants of the `GitTarget`'s +`KubeProvider`: -1. the `GitTarget`'s namespace is admitted by its `KubeProvider`: the provider's own, or one its - `accessFrom` admits; and -2. that `KubeProvider` sets `allowCrossNamespace.sources: true`. +1. a grant in its `spec.allowedGitTargets` admits the `GitTarget`'s namespace; and +2. those grants' `sourceNamespaces` hold the namespace the item asks for: its name, `"{namespace}"` + for the `GitTarget`'s own (which is the `WatchRule`'s), or `"*"`. An item asking for `"*"` needs + `"*"` there. There is no third condition on the `GitTarget`. What a target may read from its source cluster is -bounded by that cluster's RBAC for the provider's credential: a request these two conditions permit -still fails with a clean 403 if the credential cannot read the namespace. +bounded by that cluster's RBAC for the provider's credential: a request the grants permit still +fails with a clean 403 if the credential cannot read the namespace. ```yaml apiVersion: configbutler.ai/v1alpha3 @@ -2375,7 +2653,7 @@ spec: `"*"` is one cluster-wide list and one cluster-wide watch per matched type, not one of each per namespace. Connection count is bounded per matched type, but object count, traffic, and memory still grow with the cluster. Access is bounded by the source credential's RBAC -and by nothing else, which is why it is refused outright while `allowCrossNamespace.sources` is false. +and by nothing else, which is why it is refused outright unless a grant gives `sourceNamespaces: ["*"]`. A `"*"` item and a named-namespace item for the same type **overlap**, so one `GitTarget` cannot hold both: the newer rule is refused (see [overlapping collections](#overlapping-collections)). Keep @@ -2385,16 +2663,16 @@ The outcome for all items is aggregated into one `SourceNamespaceAuthorized` con `kubectl get watchrules -o wide`. A **denied** explicit name refuses the whole `WatchRule` (`Ready=False`, `Stalled=True`, no streams) rather than silently trimming that item and mirroring part of what you asked for; the message names the failing item by index and by what it selects. -Authorization is re-evaluated on every reconcile, so withdrawing `allowCrossNamespace.sources` revokes a -running rule rather than only affecting new ones. +Authorization is re-evaluated whenever the provider's grants or the namespace's labels change, so +narrowing a grant revokes a running rule rather than only affecting new ones. This changes only which namespace is **watched**. Git placement always follows each mirrored object's own namespace, so the rule above writes secrets under `repo-config/…`, not `tenant-acme/…`. -A namespace allow-list cannot partition **cluster-scoped** objects, which have no namespace. A -`ClusterWatchRule` receives every such object its source credential can read. If a tenant must not -see another tenant's cluster-scoped objects, give each tenant its own `KubeProvider` and -credential, so that credential's RBAC is the boundary. +A namespace scope cannot partition **cluster-scoped** objects, which have no namespace. A +`ClusterWatchRule` mirrors only the types the grant's `clusterResources` name (see +[cluster-scoped resources](#cluster-scoped-resources)), and of those, every object its source +credential can read. Each entry in `spec.rules` is a logical OR. A resource matching any rule is watched. A rule selects whole resource collections: every selected collection is observed through creates, updates, and @@ -2530,7 +2808,11 @@ namespaced resources across namespaces, use a `WatchRule` with [`rules[].sourceNamespace`](#watching-a-different-source-namespace). Because it is cluster-scoped, its `gitTargetRef` must include the namespace of the referenced -`GitTarget`, and that namespace must be admitted by the target's `KubeProvider`. +`GitTarget`. That namespace must be admitted by the target's `KubeProvider`, and the grants that admit +it must name every type the rule asks for in `clusterResources` (see +[cluster-scoped resources](#cluster-scoped-resources)). The example needs +`clusterResources: [clusterroles.rbac.authorization.k8s.io, clusterrolebindings.rbac.authorization.k8s.io]`, +or `["*"]`. Example: @@ -2549,10 +2831,10 @@ spec: resources: ["clusterroles", "clusterrolebindings"] ``` -Cluster-scoped objects have no namespace, so no namespace policy bounds a `ClusterWatchRule` at all: -it is intentionally cluster-global, limited only by its source credential's Kubernetes RBAC. Use this -sparingly. It grants the widest reach of any rule kind and usually belongs to cluster-admin-managed -setups. +Cluster-scoped objects have no namespace, so no namespace scope bounds a `ClusterWatchRule`: the +types in `clusterResources` do, and for those types its source credential's Kubernetes RBAC. Only a +cluster admin can create one, and no default grant, the chart's included, gives `clusterResources`. +Use it sparingly. ## `CommitRequest` diff --git a/docs/definitions.md b/docs/definitions.md index 1041bd0f..adb6ac64 100644 --- a/docs/definitions.md +++ b/docs/definitions.md @@ -26,7 +26,8 @@ rather than per field: | condition reasons | Flux (`fluxcd/pkg/apis/meta`) | one alerting rule should span our kinds and the Flux kinds in the same cluster | | readiness shape (`Ready`, `Reconciling`, `Stalled`, `observedGeneration`) | Flux and kstatus | the tools that read a cluster's health read this shape | | flags and their chart values | Flux | [`config-flag-conventions.md`](config-flag-conventions.md) is built on it | -| a field we mirror verbatim | Flux | `spec.accessFrom`, `meta.KubeConfigReference`, `suspend`, `sops` | +| a field we mirror verbatim | Flux | `meta.KubeConfigReference`, `suspend`, `sops` | +| the source of a grant (`from: Same`, `Names`, `Selector`, `All`) | Gateway API | `allowedRoutes.namespaces.from`; `Names` is ours, a plain list where Gateway API matches a label | | enum value shape | Kubernetes `core/v1` | Flux is inconsistent here, and `core/v1` is not. See rule 7 | | field, block and timestamp shape | Kubernetes API conventions, then Flux | rules 2 through 4 | @@ -61,12 +62,12 @@ folder has exactly one `GitTarget`, and one `GitTarget` has exactly one folder. The two directions, which share the word "source" and must not share anything else: -- **Access** is the control-cluster direction: which control-cluster namespaces may reference a - provider (`KubeProvider` or `GitProvider`) from a `GitTarget` or applier claim. A provider's own - namespace always may; `spec.accessFrom` admits the others (the spelling is Flux's, on purpose). +- **Access** is the control-cluster direction: which control-cluster namespaces may reference an + object that lends something (a `GitProvider`, a `GitBranch`, a `KubeProvider`). It is granted on + the referenced object, in a **grant** list, and nothing is automatic, the own namespace included. - **Source namespace** is the source-cluster direction: which source-cluster namespace a rule reads - objects from. `WatchRule.spec.rules[].sourceNamespace`, gated by - `KubeProvider.spec.allowCrossNamespace.sources`. + objects from. `WatchRule.spec.rules[].sourceNamespace`, bounded by the `sourceNamespaces` of the + `KubeProvider` grant that admits the `GitTarget`. A sentence about namespace policy names one of those two. Never "namespace access" unqualified. @@ -144,8 +145,15 @@ branch; never a bare "claim", which names a rule's demand on the read side. `Bra The **applier claim** is the cluster side of the same idea: one holds an applier, and another reports `HeldByAnotherClaim`. -**Write branch.** The branch a `GitTarget` writes to: its `GitBranch`'s `spec.branch`. It must be in -the `GitProvider`'s `allowedBranches`. +**Write branch.** The branch a `GitTarget` writes to: its `GitBranch`'s `spec.branch`. A grant in the +`GitProvider`'s `spec.allowedGitBranches` must allow it for the `GitBranch`'s namespace. + +**Grant.** One entry in a list named `allowed` plus the kind that refers to it +(`GitProvider.spec.allowedGitBranches`, `GitBranch.spec.allowedGitTargets`, +`KubeProvider.spec.allowedGitTargets` and `spec.allowedApplierClaims`). It says where it admits from +(`from`) and, where the object lends reach, the scope it gives (`branches`, `sourceNamespaces`, +`clusterResources`, `applierNamespaces`). Grants combine by union; an explicit scope is the whole +scope. Not "permission" or "policy". **Parent branch.** The branch a write branch is created from, and compared against while the write branch does not exist on the remote: the `GitBranch`'s `spec.parentBranch`, or the remote's default diff --git a/docs/demo/commit-window.md b/docs/demo/commit-window.md index 189eec44..c7c58c31 100644 --- a/docs/demo/commit-window.md +++ b/docs/demo/commit-window.md @@ -7,7 +7,8 @@ resource types, so saving never commits the save button. **Run the [quickstart](../quickstart.md) first.** It leaves behind everything this example builds on: the `gitops-reverser-quickstart-demo` namespace, the `git-creds` Secret, a `GitProvider` named -`example-provider` whose `allowedBranches` is `"*"`, and the chart's `local` `KubeProvider` in +`example-provider` that lets its own namespace claim any branch (`allowedGitBranches` with +`branches: ["*"]`), and the chart's `local` `KubeProvider` in `gitops-reverser`. If you configured those yourself instead, substitute your own names below. The example writes to its own branch, `commit-window-demo`, which no reconciler deploys. The diff --git a/docs/design/gitbranch-and-kubeprovider-plan.md b/docs/design/gitbranch-and-kubeprovider-plan.md index 439f0e5a..3e3ee0bb 100644 --- a/docs/design/gitbranch-and-kubeprovider-plan.md +++ b/docs/design/gitbranch-and-kubeprovider-plan.md @@ -5,6 +5,11 @@ The current contract is in [architecture.md](../architecture.md) and [configuration.md](../configuration.md). This page records what we decided and in what order we build it. +> **Superseded in part.** That a `GitBranch` has no access list of its own, and that the +> `GitProvider`'s `accessFrom` governs both hops, no longer hold: grants on each referenced object +> replace `accessFrom`, `allowedBranches` and `allowCrossNamespace` +> ([who may reference what](../future/provider-access-rules.md)). + The plan breaks the API on purpose, more than once. There are no production users to carry across, so each phase replaces a shape outright instead of keeping a deprecated twin beside it. A removed field stays in the schema only so that setting it is refused by name, the pattern the API already diff --git a/docs/design/one-writer-per-branch-plan.md b/docs/design/one-writer-per-branch-plan.md index 4ee46e7f..db9dc9b9 100644 --- a/docs/design/one-writer-per-branch-plan.md +++ b/docs/design/one-writer-per-branch-plan.md @@ -112,8 +112,8 @@ Ordered by how much they matter now. `allowedBranches: [teams/team-a/*]` and `accessFrom: {names: [team-a]}`. Path isolation is not possible at all. [gitbranch-and-kubeprovider-plan.md](gitbranch-and-kubeprovider-plan.md) parks this as "Folder and branch grants per namespace", and - [who may use a provider](../future/provider-access-rules.md) is the proposal toward - it, and also covers item 2 below. + [who may use a provider](../future/provider-access-rules.md) built it as grants + (`allowedGitBranches`, `GitBranch.allowedGitTargets`), and also covers item 2 below. 2. **The claim is weaker than "one credential per branch, enforced".** Three gaps: - **The push-session proof proves less on some hosts.** On Gitea and Forgejo, a credential that diff --git a/docs/layout/model.md b/docs/layout/model.md index 084a942c..ba233fa7 100644 --- a/docs/layout/model.md +++ b/docs/layout/model.md @@ -530,5 +530,5 @@ one asserts a rule PR 1 ships; the second-namespace one is written and skipped n - Namespace-local `GitProvider` (answered): the homelab examples put a `GitTarget` in `argocd`, `flux-system` and `homelab-config`, each with its own `GitProvider`, three copies of one credential in a single-owner cluster. One `GitBranch` now serves all three: - `spec.gitBranchRef.namespace` names it, and its `GitProvider`'s `spec.accessFrom` admits the other + `spec.gitBranchRef.namespace` names it, and its `spec.allowedGitTargets` admit the other namespaces. A second `GitBranch` for the same repository branch holds nothing (`BranchConflict`). diff --git a/docs/quickstart.md b/docs/quickstart.md index 534ecaf4..ef078593 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -148,8 +148,9 @@ kubectl describe gitprovider,gittarget,watchrule -n gitops-reverser-quickstart-d ``` Two `GitTarget` conditions stop the data plane and are worth recognizing: `KubeProviderNotFound` -(the `local` `KubeProvider` in `gitops-reverser` is missing) and `NamespaceNotAuthorized` (its -`accessFrom` selector does not cover the demo namespace). +(the `local` `KubeProvider` in `gitops-reverser` is missing) and `NamespaceNotAuthorized` (no grant +in its `spec.allowedGitTargets` admits the demo namespace). `quickstart.enabled=true` adds the grant +for `quickstart.namespace`; the chart grants nothing else by default. ## Clean up diff --git a/docs/rbac.md b/docs/rbac.md index da617289..76e37092 100644 --- a/docs/rbac.md +++ b/docs/rbac.md @@ -75,8 +75,9 @@ it, so the list must cover every type your rules name. ### `namespaces` on a REMOTE source cluster The operator needs **no `Namespace` access at all** in a source cluster. The manager role's -`namespaces` `get`/`list`/`watch` covers the operator's own cluster, where `KubeProvider.accessFrom` -selectors match control-cluster labels. +`namespaces` `get`/`list`/`watch` covers the operator's own cluster, where `Selector` grants (in +`GitProvider.spec.allowedGitBranches`, `GitBranch.spec.allowedGitTargets` and the `KubeProvider`'s +grant lists) match control-cluster labels. This used to be different: `GitTarget.spec.allowedSourceNamespaces` could carry a label selector evaluated against `Namespace`s in the *source* cluster, so a remote provider's identity needed diff --git a/docs/security-model.md b/docs/security-model.md index 896206ee..20ad641c 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -55,11 +55,11 @@ age key under `generateWhenMissing`). RBAC above is the hard maximum; *within* it, scope is carried by the rule **kind**: - A **`WatchRule`** selects namespaced resources. Each `spec.rules[]` item watches the rule's own - namespace by default. Naming any other source namespace — including `sourceNamespace: "*"` — - requires `KubeProvider.spec.allowCrossNamespace.sources`, a delegation by the provider's owner that - is false by default. That flag is the only namespace policy in this direction: what may actually be - read is bounded by the provider credential's own Kubernetes RBAC, and a `"*"` reaches every - namespace that credential can read, as one cluster-wide watch. + namespace by default, and every item, that one included, needs the `KubeProvider`'s grant: the + `sourceNamespaces` of the `allowedGitTargets` grants that admit the `GitTarget`'s namespace. A + `sourceNamespace: "*"` needs `"*"` there. That scope is the only namespace policy in this direction: + what may actually be read is bounded by the provider credential's own Kubernetes RBAC, and a `"*"` + reaches every namespace that credential can read, as one cluster-wide watch. On an **`inCluster`** provider that delegation deliberately bypasses live namespace RBAC: the owner of an admitted `GitTarget` can then mirror another namespace's objects — read through the @@ -117,9 +117,10 @@ namespace it already knows. Narrowing that further is | Audit ingress (`/audit-webhook/`, such as `/audit-webhook/gitops-reverser.local` for the chart's provider) | Accepts audit traffic; protected by mutual TLS via cert-manager. A route is held by one namespace: a `KubeProvider` elsewhere that carries it is `Stalled` (`AuditRouteConflict`) and reads none of its facts. | | Push receiver (`--receiver-bind-address`, off by default) | Plain HTTP for Git hosts' push webhooks. Each `GitReceiver` route is a path derived from its token, whose Secret lives in the receiver's own namespace, and a delivery must carry a valid signature or token for it before its body is read. A delivery is only a hint: it wakes the branch worker of every `GitProvider`, in any namespace, whose `spec.url` names the repository it reports, and that worker reads the remote with its own provider's credential. The payload's repository URL is only compared with `spec.url`, never fetched, so a delivery cannot choose a URL, a credential or a commit. A worker holds at most one pending look however many deliveries arrive, so deliveries never queue up behind its writes; but they are not rate-limited, so a token holder, in whatever namespace, who keeps delivering can keep a matching worker reading its remote continuously. That availability cost is accepted: there is no per-worker cooldown. The `202` carries no body, so it does not say whether another namespace tracks a repository. | | Generated Secret material | Signing keys and generated age keys live in cluster Secrets. | -| `accessFrom` label selectors on `GitProvider` and `KubeProvider` | A selector admits every control-cluster namespace whose labels match, so it is only as strong as the right to set those labels. Whoever can label a namespace can admit it, including a tenant allowed to edit its own namespace. Select on labels only the platform can set, or list namespaces by `names`. | +| Grants (`allowedGitBranches`, `allowedGitTargets`, `allowedApplierClaims`) | Nothing is admitted automatically, an object's own namespace included. A `from: Selector` grant admits every control-cluster namespace whose labels match, so it is only as strong as the right to set those labels: whoever can label a namespace can admit it, including a tenant allowed to edit its own namespace. Select on labels only the platform can set, or use `from: Names`. A `Names` grant also admits a namespace deleted and recreated with the same name. Grants decide who may use a provider's identity; every operation still runs as that identity, never as the referrer. | +| Another provider on the same repository | The branch claim is keyed by repository and branch, not by provider, so grants cannot stop a `GitBranch` that claims a branch through a second `GitProvider` for the same URL with a copied credential. On Gitea and Forgejo a read-only token opens a push session. Keep tenants from creating `GitProvider`s where they share a repository. | | `KubeProvider.spec.inCluster` and `--in-cluster-kube-providers` | `inCluster` wields the operator's own identity in the cluster it runs in. It is honored only for a `KubeProvider` the flag lists by exact `namespace/name` (no wildcards, empty by default); any other is `Stalled` (`InClusterNotAuthorized`) and never used. Anyone who can create a `KubeProvider` under a listed name wields that identity through it, so list only names in namespaces you control. | -| `KubeProvider.spec.allowCrossNamespace.appliers` | Lets every namespace the provider admits suspend and apply a `Kustomization` or `Application` in another namespace, such as `flux-system`, through the provider's identity. A delegation, not owner consent: it does not prove the namespace owns the applier or its repository. An applier claim only acts while an eligible `GitTarget` names it, and the binding check compares the `GitProvider` URL, not write access. | +| `KubeProvider.spec.allowedApplierClaims` | Lets the namespaces a grant admits suspend and apply a `Kustomization` or `Application` in its `applierNamespaces`, such as `flux-system`, through the provider's identity. Admission to mirror does not admit to apply. A delegation, not owner consent: it does not prove the namespace owns the applier or its repository. An applier claim only acts while an eligible `GitTarget` names it, and the binding check compares the `GitProvider` URL, not write access. A denial hands the applier back. | ## Secret data the controller writes to Git @@ -139,10 +140,11 @@ resource types can opt into the same encryption path at controller startup. See ## Git credentials Secret shape -`GitProvider.spec.secretRef` points to a Secret in the provider's own namespace. A `GitTarget` in -another namespace that writes through the provider, which its `spec.accessFrom` must admit, uses -that Secret without being able to read it. The controller picks the auth -method from the keys present, preferring an SSH key, then HTTP basic auth, then a bearer token. The +`GitProvider.spec.secretRef` points to a Secret in the provider's own namespace. A `GitBranch` in +another namespace that claims a branch through the provider, which its `spec.allowedGitBranches` +must admit, and the `GitTarget`s that branch admits, use that Secret without being able to read it. +The controller picks the auth method from the keys present, preferring an SSH key, then HTTP basic +auth, then a bearer token. The examples below use the Kubernetes-native key names; the reader also accepts the Flux and Argo CD key names so an existing GitOps Secret works unchanged (see [`design/git-credentials-interop.md`](finished/git-credentials-interop.md)). diff --git a/docs/spec/e2e-bi-directional-corner.md b/docs/spec/e2e-bi-directional-corner.md index 391bae3c..bd17e52b 100644 --- a/docs/spec/e2e-bi-directional-corner.md +++ b/docs/spec/e2e-bi-directional-corner.md @@ -424,7 +424,7 @@ path would each stamp their own tracking-id on the same objects and raise The `Application` lives in the `argocd` namespace, targets `spec.destination.namespace = `, `spec.source.path = `, `targetRevision: main`, `directory.recurse: true`. The applier claim lives in the test -namespace, so each describe first grants `allowCrossNamespace.appliers` on the +namespace, so each describe first widens `allowedApplierClaims` on the install's `local` `KubeProvider` in the operator's namespace, as a user must for an `Application` in `argocd`. The `GitProvider` has a `gitea` receiver, registered as a Gitea webhook next to Argo diff --git a/docs/spec/status-conditions-guide.md b/docs/spec/status-conditions-guide.md index e5a5aa2b..2aadacb8 100644 --- a/docs/spec/status-conditions-guide.md +++ b/docs/spec/status-conditions-guide.md @@ -263,10 +263,10 @@ Canonical reads: `GitPathAccepted=False` with reason `AmbiguousLayout` rather than by a second gate - WatchRule and ClusterWatchRule carry target dependency health in `GitTargetReady` - WatchRule carries source-namespace authorization in `SourceNamespaceAuthorized`, a positive - state-style condition set even for legacy own-namespace rules (reason `LegacySourceNamespace`), so - the effective authorization is always visible and automation has one condition to inspect. It is an - additional prerequisite of `Ready`, and is deliberately kept out of `GitTargetReady`, which stays - the referenced target's own health. + state-style condition set for every rule, its own namespace included (reason + `SourceNamespaceAllowed`), so the effective authorization is always visible and automation has + one condition to inspect. It is an additional prerequisite of `Ready`, and is deliberately kept + out of `GitTargetReady`, which stays the referenced target's own health. Its values are `True` and `False`, plus an `Unknown` that means only "not evaluated yet, because an earlier gate blocked this reconcile" (reason `Progressing`). `False` is a **refusal**: @@ -287,9 +287,9 @@ Canonical reads: about a mixed rule. First match wins: 1. any item **denied** → `False` / `SourceNamespaceNotAllowed` / `Stalled=True` - 2. every item admitted, at least one naming a namespace other than the rule's own → `True` / - `SourceNamespaceAllowed` - 3. every item on its own namespace → `True` / `LegacySourceNamespace` + 2. every item admitted → `True` / `SourceNamespaceAllowed` + + Every item needs a grant, the rule's own namespace included, so there is no ungated case. A **denied explicit name refuses the whole rule**; the item is never trimmed away so the rest can run, because mirroring two of the three namespaces a rule asked for is worse than a loud failure. diff --git a/docs/spec/where-validation-lives.md b/docs/spec/where-validation-lives.md index dc1da7b9..c003c44a 100644 --- a/docs/spec/where-validation-lives.md +++ b/docs/spec/where-validation-lives.md @@ -37,7 +37,8 @@ Three things, in order of how often they get argued backwards: property, and it is worth less than the install cost of a webhook: cert wiring in the chart, a failure mode that can block tenant writes, and an extra moving part in every install path. -The worked example is `KubeProvider.spec.accessFrom`. An earlier design proposed +The worked example is the `KubeProvider`'s namespace admission (then `spec.accessFrom`, now +`spec.allowedGitTargets`). An earlier design proposed enforcing it "in two places", one of them a webhook. What shipped enforces it in **one**, on every reconcile, before `DeclareForGitTarget` ([`gittarget_controller.go:479`](../../internal/controller/gittarget_controller.go#L479), diff --git a/hack/crdfields/fields.yaml b/hack/crdfields/fields.yaml index 36b0044f..dde7c3f9 100644 --- a/hack/crdfields/fields.yaml +++ b/hack/crdfields/fields.yaml @@ -34,7 +34,8 @@ kinds: default: "**no `GitBranch`**, its own namespace included" doc: >- Which namespaces' `GitBranch`es may claim which branches through this provider. Each grant - has a `from` (`Same`, `Names`, `Selector`, `All`) and the `branches` patterns it allows + has a `from` (`Same`, `Names`, `Selector`, `All`) and the `branches` patterns it allows. See + [the grants](#the-grants) - path: secretRef default: anonymous access doc: >- @@ -72,7 +73,7 @@ kinds: default: "**no `GitTarget`**, its own namespace included" doc: >- Which namespaces' `GitTarget`s may write through this branch, each grant with a `from`. The - only mutable field + only mutable field. See [the grants](#the-grants) - path: parentBranch default: the remote's default branch doc: >- @@ -111,12 +112,12 @@ kinds: default: "**no `GitTarget`**, its own namespace included" doc: >- Which namespaces' `GitTarget`s may mirror through this provider, and what: each grant has a - `from`, and `sourceNamespaces`, `clusterResources` or both + `from`, and `sourceNamespaces`, `clusterResources` or both. See [the grants](#the-grants) - path: allowedApplierClaims default: "**no applier claim**, its own namespace included" doc: >- Which namespaces' applier claims may hold an applier through this provider, and the - `applierNamespaces` that applier may live in + `applierNamespaces` that applier may live in. See [the grants](#the-grants) - path: client.qps default: "`20`" doc: Client rate limit for this source cluster. Refused beside `inCluster` diff --git a/internal/controller/constants.go b/internal/controller/constants.go index 794c1f31..cd561b78 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -77,9 +77,9 @@ const ( // ConditionTypeGitTargetReady indicates whether the referenced GitTarget is ready for writes. ConditionTypeGitTargetReady = "GitTargetReady" // ConditionTypeSourceNamespaceAuthorized reports whether a rule's EFFECTIVE source namespace - // is authorized for the observed generation. It is positive and state-style, and it is set - // even for legacy own-namespace rules (reason LegacySourceNamespace) so the effective - // authorization is always visible and automation has ONE condition to inspect. + // is authorized for the observed generation. It is positive and state-style, and it is set for + // every rule, its own namespace included, so the effective authorization is always visible and + // automation has ONE condition to inspect. // // It is deliberately distinct from GitTargetReady, which stays the health of the referenced // GitTarget and must never be reused for source authorization. Its three values are not From 9aa5aaf66b54ff3b9d9510ab6301ec98cd746ab9 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 18:12:43 +0000 Subject: [PATCH 18/34] test: cover clusterResources recovery by watch, and applier own namespace A ClusterWatchRule leaves the rule store when its type leaves the grant's clusterResources, and returns when it is restored, on the KubeProvider event alone. A provider admits no applier claim in its own namespace by itself. The plan's test checklist is ticked, naming the two items covered only in part. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/future/provider-access-rules.md | 79 ++++++++++--------- internal/authz/kubeprovider_admission_test.go | 27 +++++++ .../wiring/access_grants_wiring_test.go | 56 +++++++++++++ 3 files changed, 123 insertions(+), 39 deletions(-) diff --git a/docs/future/provider-access-rules.md b/docs/future/provider-access-rules.md index 0191c6db..9d72d402 100644 --- a/docs/future/provider-access-rules.md +++ b/docs/future/provider-access-rules.md @@ -702,85 +702,86 @@ server and an applier. **Patterns and CEL** (unit, plus CEL tests on the CRD): -- [ ] `teams/{namespace}/*` for `team-a` matches `teams/team-a/live` and **does not** match +- [x] `teams/{namespace}/*` for `team-a` matches `teams/team-a/live` and **does not** match `teams/team-ab/live`. -- [ ] `teams/{namespace}*` is refused at admission (`{namespace}` is not a whole segment). -- [ ] `feature/*` matches `feature/checkout/v2`; `*` matches every branch, `main` included. -- [ ] `teams/*` matches `teams/a/b` (`*` crosses `/`); `main` does not match `main-old` (whole name). -- [ ] `teams/{ns}/*` is refused, and the message names `{ns}` and `{namespace}`. -- [ ] `[]` is refused for `names`, `branches`, `sourceNamespaces`, `clusterResources` and +- [x] `teams/{namespace}*` is refused at admission (`{namespace}` is not a whole segment). +- [x] `feature/*` matches `feature/checkout/v2`; `*` matches every branch, `main` included. +- [x] `teams/*` matches `teams/a/b` (`*` crosses `/`); `main` does not match `main-old` (whole name). +- [x] `teams/{ns}/*` is refused, and the message names `{ns}` and `{namespace}`. +- [x] `[]` is refused for `names`, `branches`, `sourceNamespaces`, `clusterResources` and `applierNamespaces`; a grant without `from` is refused, and so are `names` without `Names` and `selector` without `Selector`; a `KubeProvider` `allowedGitTargets` grant with neither `sourceNamespaces` nor `clusterResources` is refused. -- [ ] `names: ["{namespace}"]` is refused, with a message pointing to `from: Same`. -- [ ] `from: Selector` with no `selector`, or with `selector: {}`, is refused (use `from: All`); +- [x] `names: ["{namespace}"]` is refused, with a message pointing to `from: Same`. +- [x] `from: Selector` with no `selector`, or with `selector: {}`, is refused (use `from: All`); a non-empty selector is accepted. -- [ ] `from: Same` admits the object's own namespace after a kustomize overlay moves the object to +- [x] `from: Same` admits the object's own namespace after a kustomize overlay moves the object to another namespace, and admits nothing else; `from: All` admits every namespace. **Admission** (unit, `internal/authz`): -- [ ] The own namespace is refused when no grant names it, on all four lists. For a `GitBranch`, +- [x] The own namespace is refused when no grant names it, on all four lists. For a `GitBranch`, that includes a `GitTarget` in the `GitBranch`'s own namespace. -- [ ] `applierNamespaces: [flux-system]` refuses a claim on an applier in the claim's own +- [x] `applierNamespaces: [flux-system]` refuses a claim on an applier in the claim's own namespace. An explicit list is the whole scope. -- [ ] `sourceNamespaces: ["{namespace}"]` refuses `sourceNamespace: "*"`, and `["*"]` allows it. -- [ ] Mirroring allowed, applying denied, on one `KubeProvider`, with the `GitTarget` and its applier +- [x] `sourceNamespaces: ["{namespace}"]` refuses `sourceNamespace: "*"`, and `["*"]` allows it. +- [x] Mirroring allowed, applying denied, on one `KubeProvider`, with the `GitTarget` and its applier claim in one namespace. Today's `ApplierAdmitted` shortcut would allow the apply. -- [ ] A `ClusterWatchRule` for RBAC `clusterroles` is refused through a target whose grant has no +- [x] A `ClusterWatchRule` for RBAC `clusterroles` is refused through a target whose grant has no `clusterResources`, or only `[customresourcedefinitions.apiextensions.k8s.io]`, or a bare `[clusterroles]`. It compiles with `[clusterroles.rbac.authorization.k8s.io]` or `["*"]`. -- [ ] A `ClusterWatchRule` item with `resources: ["*"]` or `apiGroups: ["*"]` is refused under a +- [x] A `ClusterWatchRule` item with `resources: ["*"]` or `apiGroups: ["*"]` is refused under a narrow grant, and a CRD installed later is not mirrored. Under `["*"]` it is. -- [ ] A `ClusterWatchRule` naming one permitted and one forbidden type is refused whole, and the +- [x] A `ClusterWatchRule` naming one permitted and one forbidden type is refused whole, and the message names the forbidden type. -- [ ] Removing one identity from `clusterResources` refuses the rules naming it, while the +- [x] Removing one identity from `clusterResources` refuses the rules naming it, while the `GitTarget` stays admitted for its other rules. -- [ ] Refusal messages name the grants that apply and the requested value, for each act. +- [x] Refusal messages name the grants that apply and the requested value, for each act. **Claim and credential** (unit and envtest): -- [ ] An unadmitted `GitBranch` never opens a push session with the shared credential. -- [ ] A `GitBranch` admitted later becomes a candidate. If it is older than the holder, it takes the +- [x] An unadmitted `GitBranch` never opens a push session with the shared credential. +- [x] A `GitBranch` admitted later becomes a candidate. If it is older than the holder, it takes the branch (`creationTimestamp`, then UID), also after a restart. -- [ ] A cross-namespace `GitBranch` pushes with the platform provider's Secret, both when its own +- [x] A cross-namespace `GitBranch` pushes with the platform provider's Secret, both when its own namespace has no provider and when it has a same-name provider with a different credential. -- [ ] Receiver routing still wakes the worker of a cross-namespace `GitBranch`. +- [x] Receiver routing still wakes the worker of a cross-namespace `GitBranch`. **Revocation** (envtest, driven by watch events): -- [ ] Removing a namespace from `GitBranch.allowedGitTargets` refuses that namespace's `GitTarget`s; +- [x] Removing a namespace from `GitBranch.allowedGitTargets` refuses that namespace's `GitTarget`s; adding it back admits them. -- [ ] Scope narrowed while admission stays: a namespace matched by two grants loses the label of the +- [x] Scope narrowed while admission stays: a namespace matched by two grants loses the label of the one granting `"*"`. Its `WatchRule`s stop watching the other namespaces, and the `GitTarget` stays admitted. This is the new `WatchRule` Namespace watch. -- [ ] A label removed from a claimant's namespace revokes its `GitBranch` through the new +- [x] A label removed from a claimant's namespace revokes its `GitBranch` through the new `GitBranch` Namespace watch, and its `GitTarget`s in other namespaces follow. -- [ ] A revoked `GitBranch`'s worker drains what it held when the revocation was observed. An event +- [x] A revoked `GitBranch`'s worker drains what it held when the revocation was observed. An event arriving afterwards is refused. The successor starts only after the old worker retires, and - receives none of its work. -- [ ] One grant edit revokes two candidates; neither holds the branch afterwards. -- [ ] An applier claim denied by `allowedApplierClaims` hands its applier back. A read error on the - `KubeProvider` keeps the hold. -- [ ] A `Kustomization` suspended before adoption stays suspended after a denial's hand-back. -- [ ] A hand-back that cannot finish (the identity lost its RBAC) shows in status and a Warning + receives none of its work. (Unit, reconciling directly: `gitbranch_access_test.go`.) +- [x] One grant edit revokes two candidates; neither holds the branch afterwards. (Unit.) +- [x] An applier claim denied by `allowedApplierClaims` hands its applier back. A read error on the + `KubeProvider` keeps the hold. (Unit, `applierclaim_revocation_test.go`, as the next three.) +- [x] A `Kustomization` suspended before adoption stays suspended after a denial's hand-back. +- [x] A hand-back that cannot finish (the identity lost its RBAC) shows in status and a Warning Event, and gives up at `releaseDeadline`. -- [ ] A regrant during the hand-back lets it finish, then adopts the applier anew. +- [x] A regrant during the hand-back lets it finish, then adopts the applier anew. **Recovery** (envtest; fixing the grant must be enough, with no consumer edited): - [ ] Restoring a grant re-admits, each through its watch: a `GitBranch` (it proves itself and claims), a namespace's source scope, `clusterResources` for a `ClusterWatchRule`, and an applier - claim (it adopts its applier). + claim (it adopts its applier). Done through watches for the first three (`wiring/`); the applier + claim's re-adoption is covered by a unit test only. - [ ] The same after a controller restart: restored grants are read fresh, and nothing revoked - earlier stays refused. + earlier stays refused. Covered for the branch claim only (a fresh claim registry, unit). **Chart and end to end:** -- [ ] A default install grants nothing: a `GitTarget` through `local` is refused with the message +- [x] A default install grants nothing: a `GitTarget` through `local` is refused with the message that names `spec.allowedGitTargets`. -- [ ] `quickstart.enabled=true` mirrors the quickstart namespace and nothing else. -- [ ] `values-playground.yaml` lets any namespace mirror its own namespace, and no other. -- [ ] e2e: a team claims `teams//live` through a platform provider and writes it, then shares it +- [x] `quickstart.enabled=true` mirrors the quickstart namespace and nothing else. +- [x] `values-playground.yaml` lets any namespace mirror its own namespace, and no other. +- [x] e2e: a team claims `teams//live` through a platform provider and writes it, then shares it with a second team. Claims on `main` and on another team's branch are refused with their messages. diff --git a/internal/authz/kubeprovider_admission_test.go b/internal/authz/kubeprovider_admission_test.go index 0242d4c2..4d36b491 100644 --- a/internal/authz/kubeprovider_admission_test.go +++ b/internal/authz/kubeprovider_admission_test.go @@ -361,6 +361,33 @@ func TestApplierClaimAdmitted(t *testing.T) { } } +// TestApplierClaimAdmitted_OwnNamespaceNeedsAGrant: a provider admits no applier claim in its own +// namespace by itself, also one it admits to mirror. +func TestApplierClaimAdmitted_OwnNamespaceNeedsAGrant(t *testing.T) { + provider := &configv1alpha3.KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: testNS}, + Spec: configv1alpha3.KubeProviderSpec{AllowedGitTargets: []configv1alpha3.SourceGrant{{ + From: configv1alpha3.GrantFromSame, SourceNamespaces: []configv1alpha3.ScopeNamespace{"{namespace}"}, + }}}, + } + claim := &configv1alpha3.FluxClaim{ + ObjectMeta: metav1.ObjectMeta{Namespace: testNS, Name: "apps"}, + Spec: configv1alpha3.FluxClaimSpec{ + KubeProviderRef: meta.NamespacedObjectReference{Name: "local"}, + KustomizationRef: meta.NamespacedObjectReference{Name: "apps"}, + }, + } + reader := fake.NewClientBuilder(). + WithScheme(admissionScheme(t)). + WithObjects(provider, namespaceLabeled(nil)). + Build() + decision, err := ApplierClaimAdmitted(context.Background(), reader, claim) + require.NoError(t, err) + assert.False(t, decision.Allowed) + assert.Equal(t, ReasonNamespaceNotAuthorized, decision.Reason) + assert.Contains(t, decision.Message, "KubeProvider team-a/local admits no namespace in spec.allowedApplierClaims") +} + // TestApplierAdmitted: asking an applier to apply needs both admissions, always. Mirroring and // applying are granted separately, also on one KubeProvider from one namespace. func TestApplierAdmitted(t *testing.T) { diff --git a/internal/controller/wiring/access_grants_wiring_test.go b/internal/controller/wiring/access_grants_wiring_test.go index 5604bb47..06ffc4a3 100644 --- a/internal/controller/wiring/access_grants_wiring_test.go +++ b/internal/controller/wiring/access_grants_wiring_test.go @@ -302,3 +302,59 @@ func setWriters( return k8sClient.Update(ctx, stored) })) } + +// TestClusterWatchRule_ClusterResourcesFollowTheGrant: removing a type from the grant's +// clusterResources takes the ClusterWatchRule naming it out of the rule store, and restoring it puts +// the rule back, each on the KubeProvider event alone. +func TestClusterWatchRule_ClusterResourcesFollowTheGrant(t *testing.T) { + ctx := context.Background() + store := rulestore.NewStore() + startManager(t, func(m manager.Manager) error { + return (&controller.ClusterWatchRuleReconciler{ + Client: m.GetClient(), Scheme: m.GetScheme(), RuleStore: store, + }).SetupWithManager(m) + }) + + namespace := seedTarget(ctx, t, "wiring-cluster-resources") + setClusterResources := func(identities ...configbutleraiv1alpha3.ClusterResourceIdentity) { + t.Helper() + require.NoError(t, retry.RetryOnConflict(retry.DefaultRetry, func() error { + provider := &configbutleraiv1alpha3.KubeProvider{} + if err := k8sClient.Get(ctx, types.NamespacedName{Namespace: namespace, Name: "source"}, + provider); err != nil { + return err + } + provider.Spec.AllowedGitTargets[0].ClusterResources = identities + return k8sClient.Update(ctx, provider) + })) + } + compiled := func(want bool, budget time.Duration) { + t.Helper() + require.Eventually(t, func() bool { + return (len(store.SnapshotClusterWatchRules()) == 1) == want + }, budget, 50*time.Millisecond, "compiled=%t", want) + } + setClusterResources("clusterroles.rbac.authorization.k8s.io") + rule := &configbutleraiv1alpha3.ClusterWatchRule{ + ObjectMeta: metav1.ObjectMeta{Name: "wiring-cluster-roles"}, + Spec: configbutleraiv1alpha3.ClusterWatchRuleSpec{ + GitTargetRef: meta.NamespacedObjectReference{Name: "target", Namespace: namespace}, + Rules: []configbutleraiv1alpha3.ClusterResourceRule{{ + APIGroups: []string{"rbac.authorization.k8s.io"}, Resources: []string{"clusterroles"}, + }}, + }, + } + require.NoError(t, k8sClient.Create(ctx, rule)) + compiled(true, settleBudget) + + setClusterResources("nodes") + refused := awaitCondition(ctx, t, client.ObjectKeyFromObject(rule), + func() client.Object { return &configbutleraiv1alpha3.ClusterWatchRule{} }, + controller.ConditionTypeResourcesResolved, watch.ClusterWatchRuleReasonClusterResourceNotAllowed, + propagationBudget) + require.Contains(t, refused.Message, `"clusterroles.rbac.authorization.k8s.io"`) + compiled(false, propagationBudget) + + setClusterResources("clusterroles.rbac.authorization.k8s.io") + compiled(true, propagationBudget) +} From 5020949dd646d2bd5f23cc3489a3db11095a1ed7 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 18:43:52 +0000 Subject: [PATCH 19/34] test(e2e): pass the suite's grants to the Helm install as values A kubectl patch of the chart's KubeProvider made kubectl-patch a co-owner of its grant fields, so a later server-side helm upgrade (the README quickstart spec) refused the conflict. The Helm install now carries the grants as values, and the suite patches them in only for the other install modes. Co-Authored-By: Claude Opus 5.5 (1M context) --- test/e2e/Taskfile-e2e.yml | 2 ++ test/e2e/applier_claim.go | 12 +++++++++--- test/e2e/helm-values-suite-grants.yaml | 13 +++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 test/e2e/helm-values-suite-grants.yaml diff --git a/test/e2e/Taskfile-e2e.yml b/test/e2e/Taskfile-e2e.yml index ee0c0804..fa4d21a9 100644 --- a/test/e2e/Taskfile-e2e.yml +++ b/test/e2e/Taskfile-e2e.yml @@ -1268,6 +1268,7 @@ tasks: - charts/gitops-reverser/templates/** - charts/gitops-reverser/crds/*.yaml - charts/gitops-reverser/config/role.yaml + - test/e2e/helm-values-suite-grants.yaml generates: - '{{.CS}}/{{.NAMESPACE}}/helm/install.yaml' cmds: @@ -1292,6 +1293,7 @@ tasks: --set queue.redis.auth.existingSecret=valkey-auth \ --set queue.redis.auth.existingSecretKey=password \ --set service.clusterIP="{{.E2E_CONTROLLER_SERVICE_CLUSTER_IP}}" \ + --values test/e2e/helm-values-suite-grants.yaml \ ${skip_crds_arg} tmp_manifest="{{.CS}}/{{.NAMESPACE}}/helm/.install.yaml.tmp" {{.HELM}} --kube-context "{{.CTX}}" get manifest "{{.INSTALL_NAME}}" \ diff --git a/test/e2e/applier_claim.go b/test/e2e/applier_claim.go index 53e82b73..a1fc61c5 100644 --- a/test/e2e/applier_claim.go +++ b/test/e2e/applier_claim.go @@ -69,15 +69,21 @@ func allowCrossNamespaceAppliers() { // suiteKubeProviderGrants are the grants the suite needs on the install's KubeProvider: every // namespace mirrors its own namespace and any cluster-scoped resource, and holds an applier in its -// own namespace. config/kubeprovider-local.yaml carries them; the chart grants nothing by default, -// so grantSuiteAccess applies them whatever the install mode. +// own namespace. config/kubeprovider-local.yaml carries them, and the Helm install passes them as +// values (test/e2e/helm-values-suite-grants.yaml); the chart grants nothing by default, so the plain +// manifests need them patched in. const suiteKubeProviderGrants = `{"spec":{` + `"allowedGitTargets":[{"from":"All","sourceNamespaces":["{namespace}"],"clusterResources":["*"]}],` + `"allowedApplierClaims":[{"from":"All","applierNamespaces":["{namespace}"]}]}}` -// grantSuiteAccess gives the install's KubeProvider the grants the suite needs. +// grantSuiteAccess gives the install's KubeProvider the grants the suite needs. A Helm install already +// carries them as values, and a patch there would take the fields from Helm, whose next server-side +// upgrade then refuses the conflict. func grantSuiteAccess() { GinkgoHelper() + if mode, err := resolveE2EInstallMode(); err == nil && mode == "helm" { + return + } _, err := kubectlRunInNamespace(resolveE2ENamespace(), "patch", "kubeprovider", e2eKubeProviderName, "--type=merge", "-p", suiteKubeProviderGrants) Expect(err).NotTo(HaveOccurred(), "failed to grant the suite's access on the install KubeProvider") diff --git a/test/e2e/helm-values-suite-grants.yaml b/test/e2e/helm-values-suite-grants.yaml new file mode 100644 index 00000000..51ab440b --- /dev/null +++ b/test/e2e/helm-values-suite-grants.yaml @@ -0,0 +1,13 @@ +# The grants the e2e suite needs on the chart's `local` KubeProvider, which grants nothing by default: +# every namespace mirrors its own namespace and any cluster-scoped resource, and holds an applier in +# its own namespace. The Helm install passes them as values, so Helm owns the fields and a later +# upgrade (the quickstart spec, the image-refresh spec) keeps them. The other install modes patch the +# same grants in the suite's setup (grantSuiteAccess); config/kubeprovider-local.yaml carries them. +kubeProvider: + allowedGitTargets: + - from: All + sourceNamespaces: ["{namespace}"] + clusterResources: ["*"] + allowedApplierClaims: + - from: All + applierNamespaces: ["{namespace}"] From b5e5c7dd20a9a652b975ed8aebb629e95b0d5d3b Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 20:35:45 +0000 Subject: [PATCH 20/34] fix(git): a released branch claim hands the branch to nobody Releasing the holder elected the next candidate at once, on an admission it was given before the grant edit. A concurrent GitTarget reconcile could then start that successor's worker before the GitBranch reconciler's successor walk re-checked its grant, and the walk released the claim but left the worker running. Release now leaves the branch unheld and returns the GitBranches waiting for it, which relinquish wakes once the old worker has retired. Each takes the branch only through its own reconcile, so the grant check in evaluate is the only one; the successor walk and its second admission check are gone. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../provider-access-rules.md | 65 ++++++++++++++++--- internal/controller/gitbranch_access_test.go | 65 ++++++++++++++----- internal/controller/gitbranch_controller.go | 65 +++---------------- internal/git/branch_owner.go | 39 ++++++++--- internal/git/branch_owner_test.go | 25 ++++--- 5 files changed, 159 insertions(+), 100 deletions(-) rename docs/{future => finished}/provider-access-rules.md (91%) diff --git a/docs/future/provider-access-rules.md b/docs/finished/provider-access-rules.md similarity index 91% rename from docs/future/provider-access-rules.md rename to docs/finished/provider-access-rules.md index 9d72d402..c6f5b33c 100644 --- a/docs/future/provider-access-rules.md +++ b/docs/finished/provider-access-rules.md @@ -1,8 +1,11 @@ # Access between objects: who may reference what -> **future work**, 2026-10-23. Not built. The proposed contract for who may use a provider or a -> branch, to land before the next release while the names are still free (see [why now](#why-now)). -> It replaces three drafts: `docs/reviews/20261023-gitbranch-claims-from-other-namespaces.md`, +> **finished** — shipped or closed. Kept for context only; **nothing here binds**. For current behaviour see [`../configuration.md`](../configuration.md#sharing-connections-across-namespaces). Index: [`../INDEX.md`](../INDEX.md) + +> Shipped 2026-10-23 as [#441](https://github.com/ConfigButler/gitops-reverser/pull/441), before the +> names were ever released (see [why now](#why-now)). [What shipped](#what-shipped-and-where-it-departs) +> records where the code departs from this page, and two tests still missing. It replaced three +> drafts: `docs/reviews/20261023-gitbranch-claims-from-other-namespaces.md`, > `docs/reviews/20261023-gitbranch-claims-review.md` and `docs/future/gitprovider-branch-rules.md`, > and follows up items 1 and 2 of "Still missing" in the > [one-writer-per-branch status](../design/one-writer-per-branch-plan.md). @@ -216,8 +219,8 @@ does not reuse the placement parser, which carries fourteen variables, fallbacks served version of its group and resource. - **It is decided on the rule's text, as `sourceNamespace: "*"` is.** A `ClusterWatchRule` item whose `apiGroups` or `resources` holds `"*"` needs `clusterResources: ["*"]`. An item that names - its groups and resources is checked pair by pair. An item that omits `apiGroups` resolves to one - group by discovery, as today, and that pair is checked. + its groups and resources is checked pair by pair. An item that omits `apiGroups` is checked by + the types the planner selects for it (see [what shipped](#what-shipped-and-where-it-departs)). - **One forbidden type refuses the whole `ClusterWatchRule`,** with a message that names the forbidden types, as an invalid `objectSelector` refuses it today. Nothing is filtered silently. - **Discovery never widens a grant.** Under a narrow grant no wildcard item compiles, so a CRD @@ -481,7 +484,7 @@ drain: - **Deleting a `GitBranch`.** [`finalize`](../../internal/controller/gitbranch_controller.go) releases the claim first. It then retires the worker, which closes intake, pushes or refuses each pending write, and settles every - `CommitRequest`. Last, it wakes the next holder. + `CommitRequest`. Last, it wakes the `GitBranch`es waiting for the branch. - **A `GitTarget`, `WatchRule`, `ClusterWatchRule` or applier claim that loses admission or scope.** It is refused on its next reconcile. A write already decided is still committed and pushed ([`write_gate.go`](../../internal/git/write_gate.go) explains why: a write held back would come @@ -541,8 +544,8 @@ The implementation must get these right: - **A transient read error is not a revocation.** Keep the old answer, retry, and hand nothing off on an error alone. - **A successor starts only after the old worker retires.** `EnsureWorker` already refuses while - another `GitBranch`'s worker is live. Check the successor's admission again: one edit can revoke - several candidates. + another `GitBranch`'s worker is live. One edit can revoke several candidates, so a successor must + not hold the branch on an admission it was given before the edit. - **Pending work never moves to a new holder or credential.** ## The limit: another provider on the same repository @@ -682,6 +685,46 @@ two teams, which lets each write the other's branch; the page should say so. not allowed, proof failed, branch conflict. - **Status.** `kubectl get gitbranch` should show the provider's namespace. +## What shipped, and where it departs + +The API, the grants and the checklist below shipped as written. Where the code departs from the +page: + +- **`GitBranch.spec.gitProviderRef` is immutable as written.** CEL cannot read + `metadata.namespace`, and there is no mutating webhook to fill in the default, so writing out the + `GitBranch`'s own namespace where it was omitted counts as a change and is refused. + `GitTarget.spec.kubeProviderRef` already behaves this way. +- **A holder giving its branch up hands it to nobody.** The plan's "check the successor's admission + again" was first built as a walk down the candidates, re-reading each one's grant. A review found + a race in it: the claim registry elected the successor before the walk checked it, so a + concurrent `GitTarget` reconcile could start a revoked successor's worker, and the walk did not + stop it. Now `BranchClaims.Release` leaves the branch unheld and returns the waiting + `GitBranch`es, which are woken. Each takes the branch only through its own reconcile, whose grant + check is the only one (rule 5 in + [`branch_owner.go`](../../internal/git/branch_owner.go)). A waiting `GitBranch` is stalled on + `BranchConflict` and requeues within seconds anyway; the wake only saves that wait. If a younger + one reconciles first and holds the branch, an older one takes it over when it reconciles, as + after a restart. +- **`clusterResources` is checked against what the planner selects.** "Decided on the text" holds + for `"*"` and for named groups. An item that omits `apiGroups` is checked by the types the + planner's own matcher selects for it in the source cluster + (`Manager.ClusterResourcesSelectedFor`), with its `apiVersions` and its ambiguity rule. An earlier + version resolved the group by resource name alone and let through an item it could not pin to + one group. That was wrong when only one of the groups serves the named version. The compiled rule + also carries the granted identities: the planner watches nothing outside them, and they are part + of the plan's fingerprint, so a recompile under a narrower grant rebuilds the plan. A type + discovery serves only after the compile is not watched, and the rule is refused when it next + reconciles. +- **A denied applier claim hands back through `retire` as it is.** If the identity is Forbidden from + patching the applier, it lets go at once with a Warning Event. Other failures retry until + `releaseDeadline`. A hand-back a denial began is marked by the start of its `Held=Releasing` + message, which is how a regrant knows to let it finish before adopting the applier again. +- **A deleted `GitProvider` or `KubeProvider` is not a revocation.** The `GitBranch` keeps its claim, + and the applier claim keeps its hold, as before. +- **Watch-driven tests.** The drain, successor and two-candidate revocation tests, and the applier + claim's hand-back tests, call `Reconcile` directly rather than running in envtest. The two + recovery items left open below are the gaps. + ## Decided The questions earlier versions left open, closed without a new grant field: @@ -736,6 +779,9 @@ server and an applier. message names the forbidden type. - [x] Removing one identity from `clusterResources` refuses the rules naming it, while the `GitTarget` stays admitted for its other rules. +- [x] An item that omits `apiGroups`, whose resource two groups serve but only one at the version it + names, is checked by the type the planner selects; withdrawing that grant refuses the rule and + empties the plan. A rule recompiled under a narrower grant rebuilds the cached plan. - [x] Refusal messages name the grants that apply and the requested value, for each act. **Claim and credential** (unit and envtest): @@ -759,7 +805,8 @@ server and an applier. - [x] A revoked `GitBranch`'s worker drains what it held when the revocation was observed. An event arriving afterwards is refused. The successor starts only after the old worker retires, and receives none of its work. (Unit, reconciling directly: `gitbranch_access_test.go`.) -- [x] One grant edit revokes two candidates; neither holds the branch afterwards. (Unit.) +- [x] One grant edit revokes two candidates; neither holds the branch afterwards, and a `GitTarget` + reconcile racing the handoff cannot start the revoked successor's worker. (Unit.) - [x] An applier claim denied by `allowedApplierClaims` hands its applier back. A read error on the `KubeProvider` keeps the hold. (Unit, `applierclaim_revocation_test.go`, as the next three.) - [x] A `Kustomization` suspended before adoption stays suspended after a denial's hand-back. diff --git a/internal/controller/gitbranch_access_test.go b/internal/controller/gitbranch_access_test.go index 9b695bc2..51b374a6 100644 --- a/internal/controller/gitbranch_access_test.go +++ b/internal/controller/gitbranch_access_test.go @@ -4,6 +4,7 @@ package controller import ( "context" + "strings" "testing" "time" @@ -136,11 +137,14 @@ func TestGitBranch_RevocationDrainsAndKeepsTheFinalizer(t *testing.T) { assert.False(t, live, "the worker retired, pushing or refusing what it held") assert.False(t, old.Enqueue(git.Event{}), "an event arriving after the revocation is refused") + _, held := f.holder() + assert.False(t, held, "the branch passes to nobody until the successor has reconciled") + require.Len(t, f.workers.GitBranchEvents(), 1, "the successor is woken") + <-f.workers.GitBranchEvents() + f.reconcile(t, "team-b", "main") holder, held := f.holder() require.True(t, held) assert.Equal(t, "team-b/main", holder.GitBranch.String()) - require.Len(t, f.workers.GitBranchEvents(), 1, "the successor is woken") - <-f.workers.GitBranchEvents() successor := k8stypes.NamespacedName{Namespace: "team-b", Name: "main"} require.NoError(t, f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ GitBranch: successor, Provider: provider, Branch: "main", Repo: repo, @@ -155,24 +159,49 @@ func TestGitBranch_RevocationDrainsAndKeepsTheFinalizer(t *testing.T) { assert.Equal(t, metav1.ConditionTrue, readyOf(back).Status, readyOf(back).Message) } -// TestGitBranch_OneEditRevokesTwoCandidates: the holder's successor is checked again before it is -// handed the branch, so a grant edit that revokes both leaves neither holding it. +// TestGitBranch_OneEditRevokesTwoCandidates: a grant edit that revokes the holder and its successor +// together hands the branch to neither. The holder's release elects nobody, so a GitTarget reconcile +// racing the handoff cannot start the revoked successor's worker; each waiting GitBranch takes the +// branch only through its own reconcile, which checks its grant first, in whichever order they run. func TestGitBranch_OneEditRevokesTwoCandidates(t *testing.T) { - f := newGitBranchFixture(t, sharedProvider("team-a", "team-b", "team-c"), - tenantBranch("team-a", 3*time.Hour), tenantBranch("team-b", 2*time.Hour), - tenantBranch("team-c", time.Hour)) - for _, ns := range []string{"team-a", "team-b", "team-c"} { - f.reconcile(t, ns, "main") + for _, order := range [][]string{{"team-b", "team-c"}, {"team-c", "team-b"}} { + t.Run(strings.Join(order, "-then-"), func(t *testing.T) { + f := newGitBranchFixture(t, sharedProvider("team-a", "team-b", "team-c"), + tenantBranch("team-a", 3*time.Hour), tenantBranch("team-b", 2*time.Hour), + tenantBranch("team-c", time.Hour)) + for _, ns := range []string{"team-a", "team-b", "team-c"} { + f.reconcile(t, ns, "main") + } + + f.grantOnly(t, "team-c") + f.reconcile(t, "team-a", "main") + + revoked := k8stypes.NamespacedName{Namespace: "team-b", Name: "main"} + err := f.workers.EnsureWorker(context.Background(), git.WorkerRequest{ + GitBranch: revoked, Provider: k8stypes.NamespacedName{Namespace: "platform", Name: "shared-repo"}, + Branch: "main", Repo: git.RepoIdentity{ProviderUID: "uid-shared-repo", URL: gitBranchTestURL}, + }) + require.ErrorIs(t, err, git.ErrBranchOwnedElsewhere, "the revoked successor was handed nothing") + woken := []string{} + for len(f.workers.GitBranchEvents()) > 0 { + event := <-f.workers.GitBranchEvents() + woken = append(woken, event.Object.GetNamespace()) + } + assert.Equal(t, []string{"team-b", "team-c"}, woken, "the waiting GitBranches are woken, oldest first") + + for _, ns := range order { + f.reconcile(t, ns, "main") + } + holder, held := f.holder() + require.True(t, held) + assert.Equal(t, "team-c/main", holder.GitBranch.String(), "team-b, also revoked, never holds the branch") + _, live := f.workers.WorkerForGitBranch(revoked) + assert.False(t, live) + got := &configbutleraiv1alpha3.GitBranch{} + require.NoError(t, f.client.Get(context.Background(), revoked, got)) + assert.Equal(t, GitBranchReasonNamespaceNotAuthorized, readyOf(got).Reason) + }) } - - f.grantOnly(t, "team-c") - f.reconcile(t, "team-a", "main") - - holder, held := f.holder() - require.True(t, held) - assert.Equal(t, "team-c/main", holder.GitBranch.String(), "team-b, also revoked, never holds the branch") - _, b, bHolds := f.workers.Claims().HeldBy(k8stypes.NamespacedName{Namespace: "team-b", Name: "main"}) - assert.False(t, bHolds, "%v", b) } // TestGitBranch_ANamespaceLabelLossRevokesASelectorGrant: a label removed from a claimant's namespace diff --git a/internal/controller/gitbranch_controller.go b/internal/controller/gitbranch_controller.go index f04f33f0..92823428 100644 --- a/internal/controller/gitbranch_controller.go +++ b/internal/controller/gitbranch_controller.go @@ -201,7 +201,7 @@ func (r *GitBranchReconciler) evaluate( } if !admitted.Allowed { message := admitted.Message - if r.relinquish(ctx, branch) { + if r.relinquish(branch) { message += "; this GitBranch gave up the branch, and its branch worker pushed or refused the writes " + "it held" } @@ -378,7 +378,7 @@ func (r *GitBranchReconciler) finalize(ctx context.Context, branch *configbutler if !controllerutil.ContainsFinalizer(branch, GitBranchFinalizer) { return nil } - r.relinquish(ctx, branch) + r.relinquish(branch) r.proofsMu.Lock() delete(r.proofs, branch.UID) r.proofsMu.Unlock() @@ -396,68 +396,23 @@ func (r *GitBranchReconciler) finalize(ctx context.Context, branch *configbutler // worker again; then the worker is stopped, which closes its intake, pushes or refuses every pending // write and settles every CommitRequest it holds (BranchWorker.Stop). Release, not a failed proof: // Observe keeps a holder's candidacy through a failed proof, and a revoked GitBranch must stop being -// a candidate at all. Last, the next holder is woken, once the old worker has retired, so pending -// work never moves to it. -func (r *GitBranchReconciler) relinquish(ctx context.Context, branch *configbutleraiv1alpha3.GitBranch) bool { +// a candidate at all. Last, once the old worker has retired so pending work never moves on, the +// GitBranches waiting for the branch are woken. Release handed it to none of them: each takes it only +// through its own reconcile, whose grant check is the one in evaluate. +func (r *GitBranchReconciler) relinquish(branch *configbutleraiv1alpha3.GitBranch) bool { if r.WorkerManager == nil { return false } claims := r.WorkerManager.Claims() - key, _, held := claims.HeldBy(client.ObjectKeyFromObject(branch)) - claims.Release(branch.UID) + _, _, held := claims.HeldBy(client.ObjectKeyFromObject(branch)) + waiting := claims.Release(branch.UID) r.WorkerManager.RetireGitBranch(client.ObjectKeyFromObject(branch)) - if held { - r.wakeNextHolder(ctx, key) + for _, next := range waiting { + r.WorkerManager.WakeGitBranch(next.GitBranch) } return held } -// maxSuccessorChecks bounds wakeNextHolder's walk down the candidates for one branch. -const maxSuccessorChecks = 16 - -// wakeNextHolder wakes the GitBranch that holds key now: it may be one stalled on BranchConflict, -// which nothing else would reconcile before its steady requeue. One grant edit can revoke several -// candidates, so the successor's admission is checked again first, and a candidate no grant admits -// any more is released here rather than handed the branch until its own reconcile notices. A read -// error leaves the successor as it is: its own reconcile decides. -func (r *GitBranchReconciler) wakeNextHolder(ctx context.Context, key git.BranchKey) { - claims := r.WorkerManager.Claims() - for range maxSuccessorChecks { - next, taken := claims.Holder(key) - if !taken { - return - } - admitted, decided := r.stillAdmitted(ctx, next) - if !decided || admitted { - r.WorkerManager.WakeGitBranch(next.GitBranch) - return - } - claims.Release(next.UID) - } -} - -// stillAdmitted reports whether the GitBranch behind a claimant may still claim its branch, and -// whether that could be decided: a GitBranch that is gone, or replaced under its name, is no -// candidate; one whose GitProvider is gone keeps its claim, as the GitBranch reconcile keeps it. -func (r *GitBranchReconciler) stillAdmitted(ctx context.Context, claimant git.BranchClaimant) (bool, bool) { - var branch configbutleraiv1alpha3.GitBranch - if err := r.Get(ctx, claimant.GitBranch, &branch); err != nil { - return false, apierrors.IsNotFound(err) - } - if branch.UID != claimant.UID || !branch.DeletionTimestamp.IsZero() { - return false, true - } - var provider configbutleraiv1alpha3.GitProvider - if err := r.Get(ctx, branch.GitProviderKey(), &provider); err != nil { - return apierrors.IsNotFound(err), apierrors.IsNotFound(err) - } - decision, err := authz.GitBranchClaimAdmitted(ctx, r.Client, &branch, &provider) - if err != nil { - return false, false - } - return decision.Allowed, true -} - // parentBranchNotFoundMessage says which parent is missing and what fixes it. func parentBranchNotFoundMessage(branch *configbutleraiv1alpha3.GitBranch, missing string) string { switch { diff --git a/internal/git/branch_owner.go b/internal/git/branch_owner.go index 3462d202..65af9dd9 100644 --- a/internal/git/branch_owner.go +++ b/internal/git/branch_owner.go @@ -3,6 +3,7 @@ package git import ( + "sort" "sync" "time" @@ -23,9 +24,15 @@ import ( // claim and does not hand the branch to another GitBranch. Any other candidate whose proof // fails stops being one. // 4. The claim lives here, in the manager's memory. After a restart it is recomputed by 1 and 2. +// 5. A holder giving the branch up hands it to nobody. Every other candidate becomes one again only +// through its own GitBranch reconcile, which checks its grants before its proof, so a candidate +// whose grant was withdrawn since it last reconciled never holds the branch, and never gets a +// worker. The candidates waiting are stalled on BranchConflict, so they reconcile again soon on +// their own; the GitBranch reconciler also wakes them at once (Release returns them). // // The GitBranch reconciler feeds it (Observe, Release); the GitTarget reconciler, the worker sweep -// and EnsureWorker read it, so they cannot disagree about who writes a branch. +// and EnsureWorker read it, so they cannot disagree about who writes a branch. The grants are checked +// in one place only, the GitBranch reconcile, before it calls Observe. // BranchClaimant is a GitBranch as the claim sees it: which object, and how old. type BranchClaimant struct { @@ -73,6 +80,8 @@ func (c *BranchClaims) Observe(key BranchKey, claimant BranchClaimant, proven bo defer c.mu.Unlock() entry, known := c.entries[claimant.UID] if known && entry.key != key { + // The GitBranches waiting for the old key are stalled on BranchConflict, and reconcile again + // on their own. c.releaseLocked(claimant.UID) known = false } @@ -88,24 +97,34 @@ func (c *BranchClaims) Observe(key BranchKey, claimant BranchClaimant, proven bo return c.holderLocked(key) } -// Release forgets a GitBranch: it is being deleted, and its worker has stopped. If it held its key, -// the oldest remaining candidate takes it over. -func (c *BranchClaims) Release(uid k8stypes.UID) { +// Release forgets a GitBranch: it is being deleted, or no grant admits it any more. If it held its +// key, nobody holds the key afterwards (rule 5), and Release returns the GitBranches that wait for it, +// oldest first: they are candidates again once their own reconcile has checked their grants. +func (c *BranchClaims) Release(uid k8stypes.UID) []BranchClaimant { c.mu.Lock() defer c.mu.Unlock() - c.releaseLocked(uid) + return c.releaseLocked(uid) } -func (c *BranchClaims) releaseLocked(uid k8stypes.UID) { +func (c *BranchClaims) releaseLocked(uid k8stypes.UID) []BranchClaimant { entry, known := c.entries[uid] if !known { - return + return nil } delete(c.entries, uid) - if c.holders[entry.key] == uid { - delete(c.holders, entry.key) + if c.holders[entry.key] != uid { + return nil + } + delete(c.holders, entry.key) + var waiting []BranchClaimant + for _, other := range c.entries { + if other.key == entry.key { + other.candidate = false + waiting = append(waiting, other.claimant) + } } - c.electLocked(entry.key) + sort.Slice(waiting, func(i, j int) bool { return waiting[i].olderThan(waiting[j]) }) + return waiting } // electLocked applies rule 2: the oldest candidate for key holds it. A holder is always a candidate, diff --git a/internal/git/branch_owner_test.go b/internal/git/branch_owner_test.go index 0c86bc22..f3c19bfb 100644 --- a/internal/git/branch_owner_test.go +++ b/internal/git/branch_owner_test.go @@ -85,9 +85,10 @@ func TestBranchClaims_TheHolderKeepsTheBranchThroughAFailedProof(t *testing.T) { assert.Equal(t, younger, got) } -// TestBranchClaims_ReleaseHandsTheBranchToTheNextCandidate: a deleted holder gives the branch to the -// oldest remaining candidate, and HeldBy follows it. -func TestBranchClaims_ReleaseHandsTheBranchToTheNextCandidate(t *testing.T) { +// TestBranchClaims_ReleaseHandsTheBranchToNobody is rule 5: a holder giving the branch up elects no +// successor. It returns the GitBranches waiting, oldest first, and the first of them to confirm again +// holds the branch; an older one confirming later takes it over by rule 2. +func TestBranchClaims_ReleaseHandsTheBranchToNobody(t *testing.T) { claims := NewBranchClaims() key := BranchKeyFor("https://github.com/acme/platform.git", "main") first := claimant("platform", "main", 3*time.Hour) @@ -97,13 +98,21 @@ func TestBranchClaims_ReleaseHandsTheBranchToTheNextCandidate(t *testing.T) { claims.Observe(key, c, true) } - claims.Release(first.UID) - gotKey, got, held := claims.HeldBy(second.GitBranch) - require.True(t, held) - assert.Equal(t, key, gotKey) - assert.Equal(t, second, got) + assert.Empty(t, claims.Release(third.UID), "a candidate that does not hold the branch hands nothing on") + claims.Observe(key, third, true) + + waiting := claims.Release(first.UID) + assert.Equal(t, []BranchClaimant{second, third}, waiting) + _, held := claims.Holder(key) + assert.False(t, held, "nobody holds the branch until a waiting GitBranch confirms itself") _, _, held = claims.HeldBy(first.GitBranch) assert.False(t, held, "a released GitBranch holds nothing") + + got, held := claims.Observe(key, third, true) + require.True(t, held) + assert.Equal(t, third, got) + got, _ = claims.Observe(key, second, true) + assert.Equal(t, second, got, "the older candidate takes the branch over once it confirms") } // TestBranchClaims_AGitBranchThatNamesAnotherRepositoryGivesTheOldOneUp: its GitProvider was From 74183d5fe1077c847adf9b5ddd63662f7e5d926d Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 20:35:46 +0000 Subject: [PATCH 21/34] fix(watch): check clusterResources against what the planner selects An item that omits apiGroups was resolved by resource name alone, and let through when two groups served the name, on the assumption that the planner would watch nothing. The planner filters by apiVersions first, so it could watch one of them. The compile now checks the types the planner's own matcher selects for the item (ClusterResourcesSelectedFor). The plan fingerprint also omitted the granted clusterResources the planner filters by, so a recompile under a narrower grant could keep a revoked type in the cached plan. It carries them now. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../clusterwatchrule_admission_test.go | 8 +-- .../controller/clusterwatchrule_controller.go | 6 +- internal/controller/constants.go | 7 +- internal/watch/bootstrap.go | 2 +- internal/watch/cluster_context.go | 26 +++---- internal/watch/cluster_resources_test.go | 72 ++++++++++++++++++- internal/watch/watched_type_resolver.go | 20 ++++-- internal/watch/watchrule_compile.go | 67 +++++++++-------- 8 files changed, 137 insertions(+), 71 deletions(-) diff --git a/internal/controller/clusterwatchrule_admission_test.go b/internal/controller/clusterwatchrule_admission_test.go index 9dac0248..58f245d8 100644 --- a/internal/controller/clusterwatchrule_admission_test.go +++ b/internal/controller/clusterwatchrule_admission_test.go @@ -35,8 +35,8 @@ type cwaWatchManager struct { onReconcile func() // collectionOverlap, when set, is the refusal message both CollectionOverlap methods report. collectionOverlap string - // groupsFor is what ClusterResourceGroupsFor resolves an omitted apiGroups with. - groupsFor func(string) []string + // selected is what ClusterResourcesSelectedFor selects for an item. + selected watch.ClusterResourceSelection } func (m *cwaWatchManager) TriggerRuleChange(internaltypes.ResourceReference) { @@ -95,8 +95,8 @@ func (m *cwaWatchManager) StreamSummaryForClusterWatchRule( func (m *cwaWatchManager) StreamStateEvents() <-chan event.GenericEvent { return nil } -func (m *cwaWatchManager) ClusterResourceGroupsFor(configbutleraiv1alpha3.GitTarget) func(string) []string { - return m.groupsFor +func (m *cwaWatchManager) ClusterResourcesSelectedFor(configbutleraiv1alpha3.GitTarget) watch.ClusterResourceSelection { + return m.selected } func cwaRunningSummary() watch.StreamSummary { diff --git a/internal/controller/clusterwatchrule_controller.go b/internal/controller/clusterwatchrule_controller.go index fbce9c3c..90eed0af 100644 --- a/internal/controller/clusterwatchrule_controller.go +++ b/internal/controller/clusterwatchrule_controller.go @@ -234,12 +234,12 @@ func (r *ClusterWatchRuleReconciler) gateClusterWatchRule( target configbutleraiv1alpha3.GitTarget, log logr.Logger, ) (bool, ctrl.Result, error) { - var groupsFor func(string) []string + var selected watch.ClusterResourceSelection if r.WatchManager != nil { - groupsFor = r.WatchManager.ClusterResourceGroupsFor(target) + selected = r.WatchManager.ClusterResourcesSelectedFor(target) } decision, err := watch.CompileClusterWatchRule( - ctx, r.Client, r.RuleStore, *clusterRule, target, groupsFor) + ctx, r.Client, r.RuleStore, *clusterRule, target, selected) var selectorErr *watch.ObjectSelectorError if errors.As(err, &selectorErr) { result, stallErr := r.refuseRuleResources(ctx, st, clusterRule, watch.ReasonInvalidObjectSelector, diff --git a/internal/controller/constants.go b/internal/controller/constants.go index cd561b78..92d636cf 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -43,10 +43,9 @@ type WatchManagerInterface interface { StreamSummaryForWatchRule(rule configv1alpha3.WatchRule) watch.StreamSummary StreamSummaryForClusterWatchRule(rule configv1alpha3.ClusterWatchRule) watch.StreamSummary - // ClusterResourceGroupsFor resolves, in the cluster a GitTarget mirrors from, the groups that - // serve a cluster-scoped resource by name, so a ClusterWatchRule item that omits apiGroups is - // checked against the grant by the pair it resolves to. - ClusterResourceGroupsFor(target configv1alpha3.GitTarget) func(resource string) []string + // ClusterResourcesSelectedFor is what the watch planner selects for a ClusterWatchRule item in + // the cluster a GitTarget mirrors from, so the compile checks those types against the grant. + ClusterResourcesSelectedFor(target configv1alpha3.GitTarget) watch.ClusterResourceSelection // StreamStateEvents is the channel a rule controller wires via source.Channel so a stream // reaching (or leaving) Streaming re-reconciles the rules that project it, instead of leaving diff --git a/internal/watch/bootstrap.go b/internal/watch/bootstrap.go index 37e0d1c9..7e57338f 100644 --- a/internal/watch/bootstrap.go +++ b/internal/watch/bootstrap.go @@ -94,7 +94,7 @@ func (m *Manager) bootstrapClusterWatchRule(ctx context.Context, rule configv1al // 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, - m.ClusterResourceGroupsFor(target)) + m.ClusterResourcesSelectedFor(target)) if err != nil { return fmt.Errorf("evaluating admission for ClusterWatchRule %q: %w", rule.Name, err) } diff --git a/internal/watch/cluster_context.go b/internal/watch/cluster_context.go index e08e9a2f..2274a801 100644 --- a/internal/watch/cluster_context.go +++ b/internal/watch/cluster_context.go @@ -6,7 +6,6 @@ import ( "context" "errors" "fmt" - "slices" "sort" "strings" "sync" @@ -866,13 +865,13 @@ func (m *Manager) publishClusterOrderLocked() { m.clusterOrder.Store(&out) } -// ClusterResourceGroupsFor resolves, in the cluster target mirrors from, the API groups that serve a -// cluster-scoped resource by name: what a ClusterWatchRule item with apiGroups omitted resolves to. -// It answers only once the target's cluster has been captured and discovered; before that it -// resolves nothing, and the compiled rule's granted identities still bound what is planned. -func (m *Manager) ClusterResourceGroupsFor(target configv1alpha3.GitTarget) func(resource string) []string { +// ClusterResourcesSelectedFor is what the watch planner selects, in the cluster target mirrors from, +// for a ClusterWatchRule item: the same matching collectClusterWatchRuleSelections does, versions and +// the omitted-apiGroups ambiguity rule included, so the compile checks against the grant exactly the +// types the rule would watch. It selects nothing until the target's cluster has been captured. +func (m *Manager) ClusterResourcesSelectedFor(target configv1alpha3.GitTarget) ClusterResourceSelection { ref := types.NewResourceReference(target.Name, target.Namespace) - return func(resource string) []string { + return func(item configv1alpha3.ClusterResourceRule) []schema.GroupResource { clusterID, captured := m.watchPlane().clusters[ref.Key()] if !captured { return nil @@ -881,14 +880,11 @@ func (m *Manager) ClusterResourceGroupsFor(target configv1alpha3.GitTarget) func if cc == nil { return nil } - var groups []string - for _, rec := range cc.registry.Followable() { - gvr := rec.Identity.GVR - if rec.Identity.Scope != typeset.ScopeNamespaced && gvr.Resource == resource && - !slices.Contains(groups, gvr.Group) { - groups = append(groups, gvr.Group) - } + matched := matchClusterRuleItem(cc.registry.Followable(), item.APIGroups, item.APIVersions, item.Resources) + selected := make([]schema.GroupResource, 0, len(matched)) + for _, rec := range matched { + selected = append(selected, rec.Identity.GVR.GroupResource()) } - return groups + return selected } } diff --git a/internal/watch/cluster_resources_test.go b/internal/watch/cluster_resources_test.go index b6bc1361..6413ba9b 100644 --- a/internal/watch/cluster_resources_test.go +++ b/internal/watch/cluster_resources_test.go @@ -10,9 +10,11 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" "github.com/ConfigButler/gitops-reverser/internal/authz" + "github.com/ConfigButler/gitops-reverser/internal/types" ) // granting is the boot KubeProvider admitting the boot GitTarget's namespace with clusterResources @@ -105,18 +107,20 @@ func TestCompileClusterWatchRule_ClusterResources(t *testing.T) { } // TestCompileClusterWatchRule_AnOmittedGroupIsCheckedByItsResolvedPair: with apiGroups omitted, the -// item is checked by the group discovery serves the resource from. +// item is checked by the types the planner selects for it. func TestCompileClusterWatchRule_AnOmittedGroupIsCheckedByItsResolvedPair(t *testing.T) { m := bootManager(t, bootGitTarget(), granting("nodes")) rule := clusterRuleFor("rule", item(nil, "clusterroles")) - rbac := func(string) []string { return rbacGroup } + rbac := func(configv1alpha3.ClusterResourceRule) []schema.GroupResource { + return []schema.GroupResource{{Group: rbacGroup[0], Resource: "clusterroles"}} + } decision, err := CompileClusterWatchRule(context.Background(), m.Client, m.RuleStore, rule, *bootGitTarget(), rbac) require.NoError(t, err) assert.Equal(t, ClusterWatchRuleReasonClusterResourceNotAllowed, decision.Reason) assert.Contains(t, decision.Message, `"clusterroles.rbac.authorization.k8s.io"`) - // Unresolved, it watches nothing today, and compiles; the planner keeps it to the grant. + // Selecting nothing today, it compiles; the planner keeps it to the grant. decision, err = CompileClusterWatchRule(context.Background(), m.Client, m.RuleStore, rule, *bootGitTarget(), nil) require.NoError(t, err) assert.True(t, decision.Admitted) @@ -177,3 +181,65 @@ func TestRefreshWatchedTypeTables_DiscoveryNeverWidensAGrant(t *testing.T) { assert.Len(t, table.Types, tc.types, "granted %v", tc.granted) } } + +// TestRefreshWatchedTypeTables_ANarrowerGrantRebuildsThePlan: the granted clusterResources are part of +// the plan's fingerprint, so recompiling a rule under a narrower grant drops what it no longer covers. +func TestRefreshWatchedTypeTables_ANarrowerGrantRebuildsThePlan(t *testing.T) { + manager, store := makeWatchedTypeManager(t) + rule := clusterRuleForResource("rule-1", "namespaces") + store.AddOrUpdateClusterWatchRule(rule, []string{"*"}, "test-target", "test-ns", "test-provider", "test-ns", + "test-path") + manager.refreshWatchedTypeTables() + table, _ := manager.watchedTypeTableForGitDest(gitDestRef("test-target")) + require.Len(t, table.Types, 1) + + store.AddOrUpdateClusterWatchRule(rule, []string{"nodes"}, "test-target", "test-ns", "test-provider", "test-ns", + "test-path") + manager.refreshWatchedTypeTables() + table, _ = manager.watchedTypeTableForGitDest(gitDestRef("test-target")) + assert.Empty(t, table.Types, "a type the grant no longer covers leaves the plan") +} + +// TestCompileClusterWatchRule_TheCompileChecksWhatThePlannerSelects: an item omitting apiGroups whose +// resource two groups serve, but only one at the version it names, is no ambiguity to the planner. The +// compile checks the type the planner selects, so withdrawing its grant refuses the rule and empties +// the plan. +func TestCompileClusterWatchRule_TheCompileChecksWhatThePlannerSelects(t *testing.T) { + m, _ := makeWatchedTypeManager(t) + target := bootGitTarget() + m.Client = bootManager(t, target, granting("*")).Client + discovery := oneScopedResourceDiscovery("", "namespaces", "Namespace", false) + discovery.groups = append(discovery.groups, testAPIGroup("other.example", "v2")) + discovery.resources = append(discovery.resources, &metav1.APIResourceList{ + GroupVersion: "other.example/v2", APIResources: []metav1.APIResource{ + {Name: "namespaces", Kind: "Namespace", Verbs: metav1.Verbs{"get", "list", "watch"}}, + }, + }) + seedClusterCatalog(t, m, configPlaneClusterID, discovery) + dest := types.NewResourceReference(target.Name, target.Namespace) + m.rememberGitTargetCluster(dest, configPlaneClusterID) + rule := clusterRuleFor("rule", configv1alpha3.ClusterResourceRule{ + Resources: []string{"namespaces"}, APIVersions: []string{"v1"}, + }) + + decision, err := CompileClusterWatchRule(context.Background(), m.Client, m.RuleStore, rule, *target, + m.ClusterResourcesSelectedFor(*target)) + require.NoError(t, err) + require.True(t, decision.Admitted, decision.Message) + m.refreshWatchedTypeTables() + before, _ := m.watchedTypeTableForGitDest(dest) + require.Len(t, before.Types, 1) + + stored := &configv1alpha3.KubeProvider{} + require.NoError(t, m.Client.Get(context.Background(), granting().Key(), stored)) + stored.Spec.AllowedGitTargets = granting().Spec.AllowedGitTargets + require.NoError(t, m.Client.Update(context.Background(), stored)) + decision, err = CompileClusterWatchRule(context.Background(), m.Client, m.RuleStore, rule, *target, + m.ClusterResourcesSelectedFor(*target)) + require.NoError(t, err) + assert.Equal(t, ClusterWatchRuleReasonClusterResourceNotAllowed, decision.Reason) + assert.Contains(t, decision.Message, `spec.rules[0] asks for "namespaces"`) + m.refreshWatchedTypeTables() + after, _ := m.watchedTypeTableForGitDest(dest) + assert.Empty(t, after.Types, "the revoked type leaves the plan") +} diff --git a/internal/watch/watched_type_resolver.go b/internal/watch/watched_type_resolver.go index 43c810f0..020e5ed0 100644 --- a/internal/watch/watched_type_resolver.go +++ b/internal/watch/watched_type_resolver.go @@ -386,9 +386,7 @@ func (m *Manager) collectClusterWatchRuleSelections( ts := get(gitTargetRef, rule.GitBranchNamespace, rule.GitBranchRef, rule.Path) selecting := selectingRule{kind: ruleKindClusterWatchRule, name: rule.Source.Name, createdAt: rule.CreatedAt} for _, rr := range rule.Rules { - matched := matchFollowableRecords( - records, rr.APIGroups, rr.APIVersions, rr.Resources, configv1alpha3.ResourceScopeCluster) - for _, rec := range matched { + for _, rec := range matchClusterRuleItem(records, rr.APIGroups, rr.APIVersions, rr.Resources) { // The compile refused every type the rule names outside its grant. This keeps a type // discovery serves only later, matched through an omitted apiGroups, out as well. if !rule.AllowsType(rec.Identity.GVR.Group, rec.Identity.GVR.Resource) { @@ -402,6 +400,13 @@ func (m *Manager) collectClusterWatchRuleSelections( } } +// matchClusterRuleItem is the records a ClusterWatchRule item selects, before its grant: the planner +// filters them by the compiled rule's grant, and the compile refuses the rule when one falls outside +// it (ClusterResourcesSelectedFor), so the two check the same types. +func matchClusterRuleItem(records []typeset.TypeRecord, groups, versions, resources []string) []typeset.TypeRecord { + return matchFollowableRecords(records, groups, versions, resources, configv1alpha3.ResourceScopeCluster) +} + // matchFollowableRecords returns the followable records a rule selector names, applying // group/version/resource/scope semantics over the registry's already-followable set — so // a refused type (gvk-not-unique, denied-by-policy, verb-poor) simply never matches. It is @@ -581,14 +586,15 @@ func watchRuleFingerprint(rule rulestore.CompiledRule) string { } // clusterWatchRuleFingerprint hashes a compiled ClusterWatchRule. It carries no scope component: -// a ClusterWatchRule is cluster-scope-only, so there is no per-rule scope left that could change -// what it watches. +// a ClusterWatchRule is cluster-scope-only. It does carry the granted clusterResources, which the +// planner filters by (AllowsType): a recompile under a narrower grant must rebuild the plan. func clusterWatchRuleFingerprint(rule rulestore.CompiledClusterRule) string { var b strings.Builder - fmt.Fprintf(&b, "cwr|%s|created=%d|gt=%s/%s|dest=%s", + fmt.Fprintf(&b, "cwr|%s|created=%d|gt=%s/%s|dest=%s|grant=%s", rule.Source.Name, rule.CreatedAt.UnixNano(), rule.GitTargetNamespace, rule.GitTargetRef, - watchPlanDest(rule.GitBranchNamespace, rule.GitBranchRef, rule.Path)) + watchPlanDest(rule.GitBranchNamespace, rule.GitBranchRef, rule.Path), + strings.Join(rule.ClusterResources, ",")) for _, rr := range rule.Rules { fmt.Fprintf(&b, "|rr[g=%s;v=%s;r=%s;sel=%q]", strings.Join(rr.APIGroups, ","), strings.Join(rr.APIVersions, ","), diff --git a/internal/watch/watchrule_compile.go b/internal/watch/watchrule_compile.go index 6fa3655d..c3ded52b 100644 --- a/internal/watch/watchrule_compile.go +++ b/internal/watch/watchrule_compile.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" + "k8s.io/apimachinery/pkg/runtime/schema" k8stypes "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" @@ -104,10 +105,11 @@ func itemNamespaces(resolved authz.ResolvedSourceScope) [][]string { // 2. every type the rule names must be in the clusterResources the grants that admit the target // give it (clusterResourcesRefusal). One forbidden type refuses the whole rule. // -// groupsFor resolves the resource of an item that omits apiGroups to the groups that serve it, by -// discovery; nil, or a cluster not discovered yet, resolves nothing. The compiled rule carries the -// granted clusterResources too, so the planner never watches a type the grant does not name, also -// one that discovery serves only later. +// selected is what the planner selects for an item in the GitTarget's source cluster (see +// Manager.ClusterResourcesSelectedFor); nil, or a cluster not discovered yet, selects nothing. The +// compiled rule carries the granted clusterResources too, so the planner never watches a type the +// grant does not name, also one that discovery serves only after this compile. The rule reconciles +// again on its steady interval, and is refused then. // // Like CompileWatchRule it is shared by the reconciler and the startup bootstrap, so a restart // cannot open an unauthorized watch before the first reconcile can publish status. @@ -117,7 +119,7 @@ func CompileClusterWatchRule( store *rulestore.RuleStore, rule configv1alpha3.ClusterWatchRule, target configv1alpha3.GitTarget, - groupsFor func(resource string) []string, + selected ClusterResourceSelection, ) (ClusterWatchRuleDecision, error) { key := k8stypes.NamespacedName{Name: rule.Name} @@ -139,7 +141,7 @@ func CompileClusterWatchRule( target.Namespace, target.Name, admitted.Message), }, nil } - if forbidden := clusterResourcesRefusal(&rule, scope, groupsFor); len(forbidden) > 0 { + if forbidden := clusterResourcesRefusal(&rule, scope, selected); len(forbidden) > 0 { store.DeleteClusterWatchRule(key) return ClusterWatchRuleDecision{ ResourcesRefused: true, @@ -212,32 +214,44 @@ type ObjectSelectorError struct { func (e *ObjectSelectorError) Error() string { return e.Message } -// clusterResourcesRefusal names what a ClusterWatchRule asks for outside scope's clusterResources, -// decided on the rule's TEXT, as rules[].sourceNamespace: "*" is: an item with "*" in apiGroups or -// resources needs clusterResources: ["*"], and an item naming its groups and resources is checked -// pair by pair. An item that omits apiGroups is checked by the pair discovery resolves it to; one -// that resolves to no group, or to several, watches nothing today, and the planner's filter on the -// granted identities keeps it so when discovery changes. Under ["*"] nothing is refused. +// ClusterResourceSelection returns the cluster-scoped types the watch planner selects for a +// ClusterWatchRule item now. +type ClusterResourceSelection func(item configv1alpha3.ClusterResourceRule) []schema.GroupResource + +// clusterResourcesRefusal names what a ClusterWatchRule asks for outside scope's clusterResources. An +// item with "*" in apiGroups or resources needs clusterResources: ["*"], decided on the rule's TEXT as +// rules[].sourceNamespace: "*" is. Any other item asks for every pair its apiGroups and resources name, +// and for every type the planner selects for it, which is how an item that omits apiGroups is +// checked: by the planner's own matching, versions and ambiguity rule included. Under ["*"] nothing +// is refused. func clusterResourcesRefusal( - rule *configv1alpha3.ClusterWatchRule, scope configv1alpha3.SourceScope, groupsFor func(string) []string, + rule *configv1alpha3.ClusterWatchRule, scope configv1alpha3.SourceScope, selected ClusterResourceSelection, ) []string { if scope.AllowsAllClusterResources() { return nil } var forbidden []string for i := range rule.Spec.Rules { - item := &rule.Spec.Rules[i] + item := rule.Spec.Rules[i] if slices.Contains(item.APIGroups, "*") || slices.Contains(item.Resources, "*") { forbidden = append(forbidden, fmt.Sprintf( "spec.rules[%d] has \"*\" in apiGroups or resources, which needs clusterResources: [\"*\"]", i)) continue } - var types []string + var asked []schema.GroupResource for _, resource := range item.Resources { - for _, group := range itemGroups(item, resource, groupsFor) { - if !scope.AllowsClusterResource(group, resource) { - types = append(types, configv1alpha3.ClusterResourceIdentityFor(group, resource)) - } + for _, group := range item.APIGroups { + asked = append(asked, schema.GroupResource{Group: group, Resource: normalizeResource(resource)}) + } + } + if selected != nil { + asked = append(asked, selected(item)...) + } + var types []string + for _, gr := range asked { + identity := configv1alpha3.ClusterResourceIdentityFor(gr.Group, gr.Resource) + if !scope.AllowsClusterResource(gr.Group, gr.Resource) && !slices.Contains(types, identity) { + types = append(types, identity) } } if len(types) > 0 { @@ -250,21 +264,6 @@ func clusterResourcesRefusal( return forbidden } -// itemGroups is the groups an item's resource is checked in: the ones it names, or, with apiGroups -// omitted, the one group discovery serves it from. -func itemGroups(item *configv1alpha3.ClusterResourceRule, resource string, groupsFor func(string) []string) []string { - if len(item.APIGroups) > 0 { - return item.APIGroups - } - if groupsFor == nil { - return nil - } - if groups := groupsFor(resource); len(groups) == 1 { - return groups - } - return nil -} - // describeIdentities renders cluster-resource identities for a message. func describeIdentities(identities []string) string { if len(identities) == 0 { From cc839301b63f95d559f288d9aa4951f63dd5f91d Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 20:35:46 +0000 Subject: [PATCH 22/34] test(e2e): resolve the install mode outside a _test.go file applier_claim.go is not a test file, so calling a function defined in e2e_suite_test.go broke go build ./.... Co-Authored-By: Claude Opus 5.5 (1M context) --- test/e2e/e2e_suite_test.go | 8 -------- test/e2e/namespace.go | 8 ++++++++ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/e2e/e2e_suite_test.go b/test/e2e/e2e_suite_test.go index 1f87d44b..a9031f13 100644 --- a/test/e2e/e2e_suite_test.go +++ b/test/e2e/e2e_suite_test.go @@ -3,7 +3,6 @@ package e2e import ( - "errors" "fmt" "math" "os" @@ -556,13 +555,6 @@ func resolveE2EInstallName(namespace string) string { return namespace } -func resolveE2EInstallMode() (string, error) { - if value := strings.TrimSpace(os.Getenv("INSTALL_MODE")); value != "" { - return value, nil - } - return "", errors.New("INSTALL_MODE environment variable must be set") -} - func resolveE2EContext() string { if value := strings.TrimSpace(os.Getenv("CTX")); value != "" { return value diff --git a/test/e2e/namespace.go b/test/e2e/namespace.go index f71c9169..31202518 100644 --- a/test/e2e/namespace.go +++ b/test/e2e/namespace.go @@ -3,6 +3,7 @@ package e2e import ( + "errors" "fmt" "os" "strings" @@ -19,6 +20,13 @@ func resolveE2ENamespace() string { return defaultE2ENamespace } +func resolveE2EInstallMode() (string, error) { + if value := strings.TrimSpace(os.Getenv("INSTALL_MODE")); value != "" { + return value, nil + } + return "", errors.New("INSTALL_MODE environment variable must be set") +} + // testNamespaceFor returns the test namespace for a given suite label. // The name is scoped to the current Ginkgo run via the random seed, // tying it to the repo and other per-run resources. From 227c24a14a0f385a1ecdc91122a6b7ed5aec34aa Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 20:35:46 +0000 Subject: [PATCH 23/34] docs: move the provider access plan to finished, with what shipped Records where the code departs from the plan, including the two review fixes, and keeps the two recovery tests still missing. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/INDEX.md | 12 ++++++++++-- docs/configuration.md | 16 ++++++++++------ docs/design/gitbranch-and-kubeprovider-plan.md | 2 +- docs/design/one-writer-per-branch-plan.md | 2 +- internal/authz/gitbranch_admission_test.go | 2 +- 5 files changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/INDEX.md b/docs/INDEX.md index 42007fbd..43350db5 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -228,11 +228,19 @@ Four more ideas sit beside them. ## History — [`finished/`](finished/) -Twenty-three shipped plans and closed investigations. **Nothing here binds.** Read one +Twenty-four shipped plans and closed investigations. **Nothing here binds.** Read one only when you want to know *why* something is the way it is; the answer to *what it is* always lives in `spec/`. The newest is +[`provider-access-rules.md`](finished/provider-access-rules.md): why every reference that lends a +credential, a branch or reach needs an explicit grant on the object it reaches (`allowedGitBranches`, +`allowedGitTargets`, `allowedApplierClaims`, each with a required `from`), with nothing automatic, +not even the own namespace. It replaced `accessFrom`, `allowedBranches` and `allowCrossNamespace` +before any of them shipped, and records what revocation promises, where the code departs from the +plan, and the two recovery tests still missing. Shipped as #441. + +Before that, [`attribution-fact-stream.md`](finished/attribution-fact-stream.md): why attribution facts stopped being a keyspace the watch side polls and became a per-type log it follows into a bounded in-memory index. It deleted the `SET`/`GET` fact keys, the 150ms poll loop, and the `deletecollection` @@ -244,7 +252,7 @@ collection delete the API server sent no response body for used to lose its auth watch resume cursors still live in Redis, so this frees attribution from it rather than the install). Shipped as #283, #284, #286 and #287. -Before that, +Earlier, [`analyzer-consumer-contract-asks.md`](finished/analyzer-consumer-contract-asks.md): why a refusal carries whether it can be solved and by whom, why the analyzer's report is a KRM document that names the build that produced it, and why `ResourceIdentifier.Key()` is a diff --git a/docs/configuration.md b/docs/configuration.md index d0d79eea..225abb35 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -361,11 +361,13 @@ A branch pattern is a small language of its own, matched against the whole branc served version of its group and resource. - **It is decided on the rule's text.** A `ClusterWatchRule` item with `"*"` in `apiGroups` or `resources` needs `clusterResources: ["*"]`. An item that names its groups and resources is checked - pair by pair. An item that omits `apiGroups` is checked by the group discovery resolves it to. + pair by pair. An item that omits `apiGroups` is checked by the types it would watch: those it + matches in the source cluster's discovery, at the `apiVersions` it names. - **One forbidden type refuses the whole `ClusterWatchRule`**, with `ResourcesResolved=False`, reason `ClusterResourceNotAllowed`, and a message naming the type. Nothing is filtered silently. -- **Discovery never widens a grant.** Under a narrow grant no wildcard item compiles, so a CRD - installed later cannot slip in. Under `["*"]` it is included: that is what `"*"` grants. +- **Discovery never widens a grant.** Under a narrow grant no wildcard item compiles, and nothing + outside the grant is watched, so a CRD installed later cannot slip in; the rule is refused when it + next reconciles. Under `["*"]` it is included: that is what `"*"` grants. ### Refusal messages name the grant @@ -391,9 +393,11 @@ admits no new work under the revoked permission. Work it already held may comple - **A `GitBranch` no grant admits any more** gives up its branch the way a deleted one does, but keeps its finalizer. Its branch worker closes its intake, pushes or refuses each pending write and settles every `CommitRequest`; an event that arrives afterwards is refused. The `GitBranch` reports - `Stalled=True` with the grant's reason, and the next candidate takes the branch only after the old - worker has retired, with none of its work. Writes can therefore land after the API server accepted - the policy edit: Git cannot take back a push already sent. + `Stalled=True` with the grant's reason. Nobody holds the branch until a waiting `GitBranch` takes + it on its own reconcile, which checks its grant first, so a candidate revoked by the same edit + never holds it. The new holder starts a worker of its own, with none of the old one's work. Writes + can therefore land after the API server accepted the policy edit: Git cannot take back a push + already sent. - **A `GitTarget`, `WatchRule`, `ClusterWatchRule` or applier claim** that loses admission or scope is refused on its next reconcile. A write already decided is still committed and pushed. - **An applier claim its `allowedApplierClaims` refuse** hands its applier back, as one no eligible diff --git a/docs/design/gitbranch-and-kubeprovider-plan.md b/docs/design/gitbranch-and-kubeprovider-plan.md index 3e3ee0bb..abf877a0 100644 --- a/docs/design/gitbranch-and-kubeprovider-plan.md +++ b/docs/design/gitbranch-and-kubeprovider-plan.md @@ -8,7 +8,7 @@ build it. > **Superseded in part.** That a `GitBranch` has no access list of its own, and that the > `GitProvider`'s `accessFrom` governs both hops, no longer hold: grants on each referenced object > replace `accessFrom`, `allowedBranches` and `allowCrossNamespace` -> ([who may reference what](../future/provider-access-rules.md)). +> ([who may reference what](../finished/provider-access-rules.md)). The plan breaks the API on purpose, more than once. There are no production users to carry across, so each phase replaces a shape outright instead of keeping a deprecated twin beside it. A removed diff --git a/docs/design/one-writer-per-branch-plan.md b/docs/design/one-writer-per-branch-plan.md index db9dc9b9..3957f133 100644 --- a/docs/design/one-writer-per-branch-plan.md +++ b/docs/design/one-writer-per-branch-plan.md @@ -112,7 +112,7 @@ Ordered by how much they matter now. `allowedBranches: [teams/team-a/*]` and `accessFrom: {names: [team-a]}`. Path isolation is not possible at all. [gitbranch-and-kubeprovider-plan.md](gitbranch-and-kubeprovider-plan.md) parks this as "Folder and branch grants per namespace", and - [who may use a provider](../future/provider-access-rules.md) built it as grants + [who may use a provider](../finished/provider-access-rules.md) built it as grants (`allowedGitBranches`, `GitBranch.allowedGitTargets`), and also covers item 2 below. 2. **The claim is weaker than "one credential per branch, enforced".** Three gaps: diff --git a/internal/authz/gitbranch_admission_test.go b/internal/authz/gitbranch_admission_test.go index 8d005fb9..161091e8 100644 --- a/internal/authz/gitbranch_admission_test.go +++ b/internal/authz/gitbranch_admission_test.go @@ -140,7 +140,7 @@ func claiming(namespace, branch string) *configv1alpha3.GitBranch { } } -// TestGitBranchClaimAdmitted is the walkthrough of docs/future/provider-access-rules.md: tenants may +// TestGitBranchClaimAdmitted is the walkthrough of docs/finished/provider-access-rules.md: tenants may // claim their own prefix, and the platform keeps main. func TestGitBranchClaimAdmitted(t *testing.T) { tenant := map[string]string{"platform.example.com/tenant": "true"} From 7d35abff14ea20fa571aae26b5868dfc76234f33 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Fri, 23 Oct 2026 20:52:46 +0000 Subject: [PATCH 24/34] fix(applier): give a hand-back a denial began its own Held reason A hand-back that a denial by allowedApplierClaims began was told apart from an ordinary one by the opening words of its Held=Releasing message, which the controller read back. It now reads Held=False, reason Revoking, so the state lives in the reason and the message is free text again. A denial during a release continues the same hand-back, with the same deadline. Co-Authored-By: Claude Opus 5.5 (1M context) --- api/v1alpha3/argocdclaim_types.go | 3 +- api/v1alpha3/fluxclaim_types.go | 3 +- .../bases/configbutler.ai_argocdclaims.yaml | 3 +- .../crd/bases/configbutler.ai_fluxclaims.yaml | 3 +- docs/bi-directional-argocd.md | 1 + docs/bi-directional-flux.md | 9 ++++- docs/configuration.md | 4 +- docs/definitions.md | 3 +- docs/finished/provider-access-rules.md | 5 ++- .../controller/applierclaim_controller.go | 4 ++ .../applierclaim_revocation_test.go | 8 ++-- internal/controller/applierclaim_rule_test.go | 2 +- internal/controller/fluxclaim_hold.go | 38 ++++++++++--------- 13 files changed, 52 insertions(+), 34 deletions(-) diff --git a/api/v1alpha3/argocdclaim_types.go b/api/v1alpha3/argocdclaim_types.go index 8cb6b557..40b264f0 100644 --- a/api/v1alpha3/argocdclaim_types.go +++ b/api/v1alpha3/argocdclaim_types.go @@ -68,7 +68,8 @@ type ArgoCDClaimStatus struct { // syncs a single source, has automated sync off or a switch the applier claim may hold, may be // written, and no other ArgoCDClaim holds it), Held (AutomatedSyncOff: automated sync is off, // held by GitOps Reverser or kept off by the switch's owner; Releasing: being handed back; - // NotReferenced; NotConfigured), and the kstatus Ready/Reconciling/Stalled trio. An applier claim acts only while an + // Revoking: being handed back because a grant no longer admits it; NotReferenced; NotConfigured), + // and the kstatus Ready/Reconciling/Stalled trio. An applier claim acts only while an // eligible GitTarget references it: one in its namespace that names it, is admitted by its // KubeProviders, and writes the repository and branch the Application syncs. An applier claim no // GitTarget names reads nothing in the Application's cluster and is Configured=Unknown, diff --git a/api/v1alpha3/fluxclaim_types.go b/api/v1alpha3/fluxclaim_types.go index 80c6f249..ab1d5075 100644 --- a/api/v1alpha3/fluxclaim_types.go +++ b/api/v1alpha3/fluxclaim_types.go @@ -62,7 +62,8 @@ type FluxClaimStatus struct { // applies a GitRepository that follows a branch, may be patched, and no other FluxClaim holds // it), Held (Suspended: suspended under GitOps Reverser with the last suspend handled; Applying: // resumed for an apply GitOps Reverser started; Suspending: a suspend is not handled yet; - // Releasing: being handed back; NotReferenced; NotConfigured), and the kstatus + // Releasing: being handed back; Revoking: being handed back because a grant no longer admits it; + // NotReferenced; NotConfigured), and the kstatus // Ready/Reconciling/Stalled trio. An applier claim acts only while an eligible GitTarget references it: // one in its namespace that names it, is admitted by its KubeProviders, and writes the // repository and branch the GitRepository fetches. An applier claim no GitTarget names reads nothing diff --git a/config/crd/bases/configbutler.ai_argocdclaims.yaml b/config/crd/bases/configbutler.ai_argocdclaims.yaml index 73a00dbb..d237395a 100644 --- a/config/crd/bases/configbutler.ai_argocdclaims.yaml +++ b/config/crd/bases/configbutler.ai_argocdclaims.yaml @@ -163,7 +163,8 @@ spec: syncs a single source, has automated sync off or a switch the applier claim may hold, may be written, and no other ArgoCDClaim holds it), Held (AutomatedSyncOff: automated sync is off, held by GitOps Reverser or kept off by the switch's owner; Releasing: being handed back; - NotReferenced; NotConfigured), and the kstatus Ready/Reconciling/Stalled trio. An applier claim acts only while an + Revoking: being handed back because a grant no longer admits it; NotReferenced; NotConfigured), + and the kstatus Ready/Reconciling/Stalled trio. An applier claim acts only while an eligible GitTarget references it: one in its namespace that names it, is admitted by its KubeProviders, and writes the repository and branch the Application syncs. An applier claim no GitTarget names reads nothing in the Application's cluster and is Configured=Unknown, diff --git a/config/crd/bases/configbutler.ai_fluxclaims.yaml b/config/crd/bases/configbutler.ai_fluxclaims.yaml index 945630b4..c5a77b63 100644 --- a/config/crd/bases/configbutler.ai_fluxclaims.yaml +++ b/config/crd/bases/configbutler.ai_fluxclaims.yaml @@ -156,7 +156,8 @@ spec: applies a GitRepository that follows a branch, may be patched, and no other FluxClaim holds it), Held (Suspended: suspended under GitOps Reverser with the last suspend handled; Applying: resumed for an apply GitOps Reverser started; Suspending: a suspend is not handled yet; - Releasing: being handed back; NotReferenced; NotConfigured), and the kstatus + Releasing: being handed back; Revoking: being handed back because a grant no longer admits it; + NotReferenced; NotConfigured), and the kstatus Ready/Reconciling/Stalled trio. An applier claim acts only while an eligible GitTarget references it: one in its namespace that names it, is admitted by its KubeProviders, and writes the repository and branch the GitRepository fetches. An applier claim no GitTarget names reads nothing diff --git a/docs/bi-directional-argocd.md b/docs/bi-directional-argocd.md index 732e791e..6644e81b 100644 --- a/docs/bi-directional-argocd.md +++ b/docs/bi-directional-argocd.md @@ -339,6 +339,7 @@ The applier claim reports `Configured` and `Held`, and its `Ready` includes both | --- | --- | | `AutomatedSyncOff` (`True`) | Automated sync is off: held off by GitOps Reverser, or kept off by the `ApplicationSet` or field manager that owns the switch (the message says which) | | `Releasing` (`False`) | The switch is being handed back | +| `Revoking` (`False`) | The switch is being handed back because the `KubeProvider`'s `allowedApplierClaims` no longer admit the applier claim; it finishes even if a grant returns meanwhile | | `NotReferenced` (`False`), `NotConfigured` (`Unknown`) | Nothing is held: no `GitTarget` names the applier claim, or it is not `Configured` | A sync that has not completed within 5 minutes also makes the applier claim `Ready=False` with diff --git a/docs/bi-directional-flux.md b/docs/bi-directional-flux.md index a9db5345..b4e55ac6 100644 --- a/docs/bi-directional-flux.md +++ b/docs/bi-directional-flux.md @@ -288,6 +288,10 @@ stateDiagram-v2 Releasing --> NotReferenced: released, Flux applies on its own again Releasing --> NotConfigured: released, while a refused GitTarget still names it Releasing --> [*]: applier claim deleted + Suspended --> Revoking: allowedApplierClaims no longer admit the applier claim + Applying --> Revoking: the same, suspended first + Releasing --> Revoking: the same, during a release + Revoking --> NotConfigured: released; a regrant then adopts it anew note right of NotConfigured Configured says why @@ -330,7 +334,9 @@ stateDiagram-v2 does, or the hand-back has not finished 10 minutes after `Held` turned `Releasing`, the applier claim lets go with a `ReleaseFailed` Event saying where the `Kustomization` was left. A reference that returns before the release is done holds the `Kustomization` again; one that returns after adopts - it anew. + it anew. When the `KubeProvider`'s `allowedApplierClaims` no longer admit the applier claim, the + hand-back is the same, but `Held` reads `Revoking`, and it finishes even if a grant admits the + applier claim again meanwhile; the applier claim then adopts the `Kustomization` anew. - **Deleting or re-creating a `GitTarget` hands the `Kustomization` back.** Flux then applies the Git tip at once, which overwrites live edits not yet in Git, and the applier claim adopts the `Kustomization` again once the `GitTarget` is back. That is what Flux does without GitOps @@ -360,6 +366,7 @@ The applier claim reports `Configured` and `Held`, and its `Ready` includes both | `Held`: `Applying` | An apply GitOps Reverser started is in flight: `status.operation` shows it | ready | | `Held`: `Suspending` | Just suspended; waiting for the `Kustomization` to handle it | progressing | | `Held`: `Releasing` | Handing the `Kustomization` back: suspending it if needed, then releasing `spec.suspend`, within 10 minutes | progressing | +| `Held`: `Revoking` | The same hand-back, because the provider's `allowedApplierClaims` no longer admit the applier claim. It finishes even if a grant returns meanwhile | progressing | | `Held`: `NotReferenced` | Nothing to hold: no `GitTarget` names the applier claim | ready | | `Held`: `NotConfigured` | The applier claim is not `Configured`, so nothing is held, or the `Kustomization` is left suspended as the applier claim last held it (the message says which) | follows `Configured` | | `Ready`: `OperationUnresolved` | An apply has not completed within 5 minutes, or the `Kustomization` was [resumed by hand](#overriding-by-hand) | progressing; it clears when the apply does | diff --git a/docs/configuration.md b/docs/configuration.md index 225abb35..b71f8b66 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -401,7 +401,7 @@ admits no new work under the revoked permission. Work it already held may comple - **A `GitTarget`, `WatchRule`, `ClusterWatchRule` or applier claim** that loses admission or scope is refused on its next reconcile. A write already decided is still committed and pushed. - **An applier claim its `allowedApplierClaims` refuse** hands its applier back, as one no eligible - `GitTarget` references does: it stays the holder (`Held=False`, `Releasing`) while it hands back, + `GitTarget` references does: it stays the holder (`Held=False`, `Revoking`) while it hands back, keeps a `Kustomization` that was suspended before adoption suspended, and gives up after ten minutes with a Warning Event if it cannot finish. A regrant during the hand-back lets it finish, and the applier claim then adopts the applier anew. A read error never starts a hand-back. Once the @@ -1845,7 +1845,7 @@ status: message: Kustomization flux-system/checkout applies GitRepository flux-system/platform, ... - type: Held # FluxClaim only status: "True" - reason: Suspended # Suspended | Applying | Suspending | Releasing | NotReferenced | NotConfigured + reason: Suspended # Suspended | Applying | Suspending | Releasing | Revoking | NotReferenced | NotConfigured - type: Ready status: "True" source: diff --git a/docs/definitions.md b/docs/definitions.md index adb6ac64..b0b751aa 100644 --- a/docs/definitions.md +++ b/docs/definitions.md @@ -260,7 +260,8 @@ cluster, so name one `KubeProvider` for every applier claim that reaches a given applies), `Applying` (resumed for an apply GitOps Reverser started), `Suspending` (a suspend was sent, and Flux has not handled its token yet), `Releasing` (being handed back to Flux). For an `ArgoCDClaim`: `AutomatedSyncOff` (held off by GitOps Reverser, or kept off by whoever owns the -switch) and `Releasing`. Either may be `NotReferenced` (nothing to hold) or `NotConfigured`. A +switch) and `Releasing`. Either may be `Revoking` (being handed back because a grant no longer +admits it), `NotReferenced` (nothing to hold) or `NotConfigured`. A suspend counts as done only once it is handled: by then no reconcile that started before it is still running. Not "fenced", and not "resumed", which says what happens to the `Kustomization` rather than what the applier claim does. **Hand back** is releasing diff --git a/docs/finished/provider-access-rules.md b/docs/finished/provider-access-rules.md index c6f5b33c..4a5e9d25 100644 --- a/docs/finished/provider-access-rules.md +++ b/docs/finished/provider-access-rules.md @@ -717,8 +717,9 @@ page: reconciles. - **A denied applier claim hands back through `retire` as it is.** If the identity is Forbidden from patching the applier, it lets go at once with a Warning Event. Other failures retry until - `releaseDeadline`. A hand-back a denial began is marked by the start of its `Held=Releasing` - message, which is how a regrant knows to let it finish before adopting the applier again. + `releaseDeadline`. A hand-back a denial began reads `Held=False`, reason `Revoking`, rather than + `Releasing`, which is how a regrant knows to let it finish before adopting the applier again. A + first cut marked it by the opening words of the `Releasing` message instead. - **A deleted `GitProvider` or `KubeProvider` is not a revocation.** The `GitBranch` keeps its claim, and the applier claim keeps its hold, as before. - **Watch-driven tests.** The drain, successor and two-candidate revocation tests, and the applier diff --git a/internal/controller/applierclaim_controller.go b/internal/controller/applierclaim_controller.go index f4828073..550ef859 100644 --- a/internal/controller/applierclaim_controller.go +++ b/internal/controller/applierclaim_controller.go @@ -67,6 +67,10 @@ const ( // it is being deleted, or no eligible GitTarget references it any more. Held=False with // applier.ReasonNotReferenced is an applier claim with nothing to hold. ReasonReleasing = "Releasing" + // ReasonRevoking is Held=False on an applier claim that held its applier and is handing it back + // because its KubeProvider's spec.allowedApplierClaims no longer admit it. The hand-back finishes + // even if a grant admits the applier claim again meanwhile, which then adopts its applier anew. + ReasonRevoking = "Revoking" // EventReasonAdopted is a Kustomization suspended, or an Application's automated sync switched // off, for the first time. EventReasonAdopted = "Adopted" diff --git a/internal/controller/applierclaim_revocation_test.go b/internal/controller/applierclaim_revocation_test.go index 446e55d6..92be492c 100644 --- a/internal/controller/applierclaim_revocation_test.go +++ b/internal/controller/applierclaim_revocation_test.go @@ -157,8 +157,7 @@ func TestFluxClaim_ADenialThatCannotHandBackGivesUp(t *testing.T) { got := f.claim(t) if !tc.atOnce { require.False(t, handedBack(got)) - assert.Equal(t, ReasonReleasing, heldReason(t, got), "the hand-back shows in status") - assert.True(t, releasingForDenial(got)) + assert.Equal(t, ReasonRevoking, heldReason(t, got), "the hand-back shows in status") held := findCondition(got.Status.Conditions, ApplierClaimConditionHeld) held.LastTransitionTime = metav1.NewTime(time.Now().Add(-releaseDeadline - time.Minute)) require.NoError(t, f.r.Status().Update(context.Background(), got)) @@ -185,13 +184,12 @@ func TestFluxClaim_ARegrantDuringTheHandBackLetsItFinish(t *testing.T) { f.setApplierGrants(t, nil) _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) - require.Equal(t, ReasonReleasing, heldReason(t, f.claim(t))) - assert.True(t, releasingForDenial(f.claim(t))) + require.Equal(t, ReasonRevoking, heldReason(t, f.claim(t))) f.setApplierGrants(t, grants) _, err = f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) - assert.Equal(t, ReasonReleasing, heldReason(t, f.claim(t)), "the hand-back is not abandoned") + assert.Equal(t, ReasonRevoking, heldReason(t, f.claim(t)), "the hand-back is not abandoned") unpause() f.reconcileUntil(t, func(claim *configbutleraiv1alpha3.FluxClaim) bool { diff --git a/internal/controller/applierclaim_rule_test.go b/internal/controller/applierclaim_rule_test.go index c9d0e056..1f2df7be 100644 --- a/internal/controller/applierclaim_rule_test.go +++ b/internal/controller/applierclaim_rule_test.go @@ -57,7 +57,7 @@ func (f *holdFixture) settle(t *testing.T, key k8stypes.NamespacedName) *configb require.NoError(t, err) require.NoError(t, f.r.Get(context.Background(), key, claim)) held := findCondition(claim.Status.Conditions, ApplierClaimConditionHeld) - return held != nil && held.Reason != ReasonSuspending && held.Reason != ReasonReleasing + return held != nil && held.Reason != ReasonSuspending && !handingBack(held) }, 5*time.Second, 5*time.Millisecond) return claim } diff --git a/internal/controller/fluxclaim_hold.go b/internal/controller/fluxclaim_hold.go index 5a9be3ae..64cca7d2 100644 --- a/internal/controller/fluxclaim_hold.go +++ b/internal/controller/fluxclaim_hold.go @@ -5,7 +5,6 @@ package controller import ( "context" "fmt" - "strings" "sync" "time" @@ -609,26 +608,26 @@ func applierTool(claim configbutleraiv1alpha3.ApplierClaim) string { return "Flux" } -// publishReleasing sets Held=False (Releasing) and the trio on an applier claim whose hand-back is not -// done, its deadline counted from since. The condition is written anew when it was not Releasing -// before, so its lastTransitionTime says when the release began. +// publishReleasing sets Held=False (Releasing, or Revoking for a hand-back a denial began) and the +// trio on an applier claim whose hand-back is not done, its deadline counted from since. The condition +// is written anew when no hand-back was under way before, so its lastTransitionTime says when the +// release began. func (r *ApplierClaimReconciler) publishReleasing( st *reconcileStatus, claim configbutleraiv1alpha3.ApplierClaim, check *claimCheck, since time.Time, ) { - why := "the applier claim is being deleted" + why, reason := "the applier claim is being deleted", ReasonReleasing switch { case check != nil && (check.denied || releasingForDenial(claim)): - why = releasingForDenialWhy + why, reason = "the KubeProvider's spec.allowedApplierClaims no longer admit the applier claim", ReasonRevoking case check != nil && check.refs.Named(): why = "no GitTarget naming the applier claim is eligible any more" case check != nil: why = "no GitTarget names the applier claim any more" } - held := conditionValue{Status: metav1.ConditionFalse, Reason: ReasonReleasing, Message: fmt.Sprintf( + held := conditionValue{Status: metav1.ConditionFalse, Reason: reason, Message: fmt.Sprintf( "%s: handing %s %s back to %s, by %s at the latest", why, claim.ApplierKind(), claim.Applier(), applierTool(claim), since.Add(releaseDeadline).UTC().Format(time.RFC3339))} - if previous := findCondition(*claim.StatusConditions(), ApplierClaimConditionHeld); previous == nil || - previous.Reason != ReasonReleasing { + if !handingBack(findCondition(*claim.StatusConditions(), ApplierClaimConditionHeld)) { st.remove(ApplierClaimConditionHeld) } st.setValue(ApplierClaimConditionHeld, held) @@ -641,22 +640,25 @@ func (r *ApplierClaimReconciler) publishReleasing( st.applyReadiness(r.claimReadiness(claim, check.configured, &held)) } -// releasingForDenialWhy opens the Held=Releasing message of a hand-back that a denial by the -// KubeProvider's spec.allowedApplierClaims began. releasingForDenial reads it back: such a hand-back -// finishes even after a regrant, and the applier is adopted anew once it has. -const releasingForDenialWhy = "the KubeProvider's spec.allowedApplierClaims no longer admit the applier claim" - // releasingForDenial reports whether the applier claim is handing its applier back because its -// KubeProvider's grants refused it. +// KubeProvider's grants refused it (Held=False, Revoking): such a hand-back finishes even after a +// regrant, and the applier is adopted anew once it has. func releasingForDenial(claim configbutleraiv1alpha3.ApplierClaim) bool { held := findCondition(*claim.StatusConditions(), ApplierClaimConditionHeld) - return held != nil && held.Reason == ReasonReleasing && strings.HasPrefix(held.Message, releasingForDenialWhy) + return held != nil && held.Reason == ReasonRevoking +} + +// handingBack reports whether a Held condition says a hand-back is under way: Releasing, or Revoking. +// A denial arriving during a release continues the same hand-back, with the same deadline. +func handingBack(held *metav1.Condition) bool { + return held != nil && (held.Reason == ReasonReleasing || held.Reason == ReasonRevoking) } -// releasingSince is when the applier claim's Held condition turned Releasing, or now when it has not. +// releasingSince is when the applier claim's Held condition turned Releasing or Revoking, or now when +// it has not. func releasingSince(claim configbutleraiv1alpha3.ApplierClaim) time.Time { held := findCondition(*claim.StatusConditions(), ApplierClaimConditionHeld) - if held == nil || held.Reason != ReasonReleasing || held.LastTransitionTime.IsZero() { + if !handingBack(held) || held.LastTransitionTime.IsZero() { return time.Now() } return held.LastTransitionTime.Time From 433138ccb41ab66353acc2d453f32179412b3c0e Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sat, 24 Oct 2026 04:24:52 +0000 Subject: [PATCH 25/34] refactor: fold duplicated grant and mapper code, and fix the clusterResources refusal text - sourcesOf replaces six copies of the grant-to-source loop; slices.Sort and slices.Compact replace two hand-written sorted-unique helpers. - notAdmitted loses a branch that built the same message as the general case. - requestsFor replaces four list-to-request loops in the mappers. - WorkerRequest.provider() is gone: it returned the field. - releasingForDenial is revoking, after the Held reason it reads. - The ClusterWatchRule clusterResources refusal joined clauses that each ended in "which is" and appended "not allowed" once, so a refusal of two items, or of a '*' item, read as broken text. - Comments rewrapped, and the last delegation wording dropped. Co-Authored-By: Claude Opus 5.5 (1M context) --- api/v1alpha3/access_grants.go | 67 ++++++------------- internal/authz/kubeprovider_admission.go | 4 -- internal/authz/source_namespace.go | 31 +++------ .../controller/applierclaim_controller.go | 2 +- internal/controller/dependency_watches.go | 13 ++++ internal/controller/fluxclaim_hold.go | 6 +- internal/controller/gitbranch_controller.go | 15 +---- internal/controller/gittarget_controller.go | 14 ++-- .../controller/gittarget_source_cluster.go | 6 +- internal/controller/watchrule_controller.go | 8 +-- .../controller/watchrule_source_namespace.go | 2 +- internal/git/worker_manager.go | 8 +-- internal/watch/watchrule_compile.go | 7 +- 13 files changed, 63 insertions(+), 120 deletions(-) diff --git a/api/v1alpha3/access_grants.go b/api/v1alpha3/access_grants.go index 0d3297f3..7ca163e8 100644 --- a/api/v1alpha3/access_grants.go +++ b/api/v1alpha3/access_grants.go @@ -5,7 +5,7 @@ package v1alpha3 import ( "errors" "fmt" - "sort" + "slices" "strings" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -239,6 +239,18 @@ func (g *ApplierGrant) source() NamespaceGrant { return NamespaceGrant{From: g.From, Names: g.Names, Selector: g.Selector} } +// sourcesOf is the admitting half of each grant in a list. +func sourcesOf[G any, PG interface { + *G + source() NamespaceGrant +}](grants []G) []NamespaceGrant { + sources := make([]NamespaceGrant, len(grants)) + for i := range grants { + sources[i] = PG(&grants[i]).source() + } + return sources +} + // Admits reports whether this grant, held by an object in holderNamespace, admits a referring // object in namespace, by the labels that namespace carries in the control cluster. A selector that // does not convert is an error, never a silent allow; an unknown from admits nothing. @@ -339,21 +351,8 @@ func expandScope[S ~string](entries []S, namespace string) []string { for _, entry := range entries { out = append(out, strings.ReplaceAll(string(entry), NamespaceVariable, namespace)) } - return sortedUniqueStrings(out) -} - -func sortedUniqueStrings(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 + slices.Sort(out) + return slices.Compact(out) } // MatchBranchPattern reports whether branch matches pattern for a GitBranch in namespace: the @@ -411,11 +410,7 @@ func (p *GitProvider) AdmitsGitBranch( namespace string, nsLabels map[string]string, branch string, ) (GitBranchAdmission, error) { grants := p.Spec.AllowedGitBranches - sources := make([]NamespaceGrant, len(grants)) - for i := range grants { - sources[i] = grants[i].source() - } - admitting, err := admitAll(sources, p.Namespace, namespace, nsLabels) + admitting, err := admitAll(sourcesOf(grants), p.Namespace, namespace, nsLabels) if err != nil || len(admitting) == 0 { return GitBranchAdmission{}, err } @@ -435,11 +430,7 @@ func (p *GitProvider) AdmitsGitBranch( // DescribeAllowedGitBranches names the namespaces spec.allowedGitBranches admits. func (p *GitProvider) DescribeAllowedGitBranches() string { - sources := make([]NamespaceGrant, len(p.Spec.AllowedGitBranches)) - for i := range p.Spec.AllowedGitBranches { - sources[i] = p.Spec.AllowedGitBranches[i].source() - } - return describeAdmitted(sources, p.Namespace) + return describeAdmitted(sourcesOf(p.Spec.AllowedGitBranches), p.Namespace) } // AdmitsGitTarget reports whether spec.allowedGitTargets admits a GitTarget in namespace, by the @@ -498,11 +489,7 @@ func scopeAllows(scope []string, value string) bool { // that namespace carries in the control cluster. func (p *KubeProvider) AdmitsGitTarget(namespace string, nsLabels map[string]string) (SourceScope, error) { grants := p.Spec.AllowedGitTargets - sources := make([]NamespaceGrant, len(grants)) - for i := range grants { - sources[i] = grants[i].source() - } - admitting, err := admitAll(sources, p.Namespace, namespace, nsLabels) + admitting, err := admitAll(sourcesOf(grants), p.Namespace, namespace, nsLabels) if err != nil || len(admitting) == 0 { return SourceScope{}, err } @@ -521,11 +508,7 @@ func (p *KubeProvider) AdmitsGitTarget(namespace string, nsLabels map[string]str // DescribeAllowedGitTargets names the namespaces spec.allowedGitTargets admits. func (p *KubeProvider) DescribeAllowedGitTargets() string { - sources := make([]NamespaceGrant, len(p.Spec.AllowedGitTargets)) - for i := range p.Spec.AllowedGitTargets { - sources[i] = p.Spec.AllowedGitTargets[i].source() - } - return describeAdmitted(sources, p.Namespace) + return describeAdmitted(sourcesOf(p.Spec.AllowedGitTargets), p.Namespace) } // ApplierScope is what a KubeProvider's allowedApplierClaims give an applier claim's namespace. @@ -547,11 +530,7 @@ func (s ApplierScope) AllowsApplierNamespace(namespace string) bool { // the labels that namespace carries in the control cluster. func (p *KubeProvider) AdmitsApplierClaim(namespace string, nsLabels map[string]string) (ApplierScope, error) { grants := p.Spec.AllowedApplierClaims - sources := make([]NamespaceGrant, len(grants)) - for i := range grants { - sources[i] = grants[i].source() - } - admitting, err := admitAll(sources, p.Namespace, namespace, nsLabels) + admitting, err := admitAll(sourcesOf(grants), p.Namespace, namespace, nsLabels) if err != nil || len(admitting) == 0 { return ApplierScope{}, err } @@ -564,9 +543,5 @@ func (p *KubeProvider) AdmitsApplierClaim(namespace string, nsLabels map[string] // DescribeAllowedApplierClaims names the namespaces spec.allowedApplierClaims admits. func (p *KubeProvider) DescribeAllowedApplierClaims() string { - sources := make([]NamespaceGrant, len(p.Spec.AllowedApplierClaims)) - for i := range p.Spec.AllowedApplierClaims { - sources[i] = p.Spec.AllowedApplierClaims[i].source() - } - return describeAdmitted(sources, p.Namespace) + return describeAdmitted(sourcesOf(p.Spec.AllowedApplierClaims), p.Namespace) } diff --git a/internal/authz/kubeprovider_admission.go b/internal/authz/kubeprovider_admission.go index 583173e7..4d60301b 100644 --- a/internal/authz/kubeprovider_admission.go +++ b/internal/authz/kubeprovider_admission.go @@ -271,10 +271,6 @@ func notAdmitted( kind string, key k8stypes.NamespacedName, admits, field, namespace string, selErr error, ) Decision { message := fmt.Sprintf("%s %s %s in %s; namespace %q is not admitted", kind, key, admits, field, namespace) - if admits == "admits no namespace" { - message = fmt.Sprintf("%s %s admits no namespace in %s; namespace %q is not admitted", kind, key, field, - namespace) - } if selErr != nil { message += fmt.Sprintf(" (a grant's selector is invalid: %v)", selErr) } diff --git a/internal/authz/source_namespace.go b/internal/authz/source_namespace.go index 5b54bee1..b3fc9933 100644 --- a/internal/authz/source_namespace.go +++ b/internal/authz/source_namespace.go @@ -5,7 +5,7 @@ package authz import ( "context" "fmt" - "sort" + "slices" "strings" "sigs.k8s.io/controller-runtime/pkg/client" @@ -52,9 +52,8 @@ type SourceNamespaceDecision struct { // item, index-aligned, plus the aggregate the SourceNamespaceAuthorized condition publishes. // // It is a pure function of (rule spec, the grants that admit the GitTarget) and is recomputed on -// every compile. Nothing -// per-item is persisted across a spec change, which is what lets rule items have no stable API -// identity: no state outlives the spec that produced it. +// every compile. Nothing per-item is persisted across a spec change, which is what lets rule items +// have no stable API identity: no state outlives the spec that produced it. type ResolvedSourceScope struct { // Items is index-aligned with spec.rules. Items []SourceNamespaceDecision @@ -80,10 +79,9 @@ func (s ResolvedSourceScope) NamespacesFor(index int) []string { // Fingerprint renders the resolved scope as a stable string, per item, for the watched-type // re-projection gate. // -// It is now derivable from the rule spec alone, since the resolution reads no cluster state. It is -// still computed from the RESOLVED set rather than the requested one, because the two differ for a -// wildcard — "*" resolves to the empty string — and the fingerprint's job is to describe what is -// actually watched. +// It is computed from the RESOLVED set rather than the requested one, because the two differ for a +// wildcard — "*" resolves to the empty string — and for a denied item, which resolves to nothing: +// the fingerprint's job is to describe what is actually watched. func (s ResolvedSourceScope) Fingerprint() string { parts := make([]string, 0, len(s.Items)) for _, item := range s.Items { @@ -231,21 +229,8 @@ func summariseAdmitted(items []SourceNamespaceDecision) string { all = append(all, ns) } } - all = sortedUnique(all) + slices.Sort(all) + all = slices.Compact(all) return fmt.Sprintf("all %d rule item(s) are authorized; watching source namespace(s) %s", len(items), strings.Join(all, ", ")) } - -func sortedUnique(in []string) []string { - seen := make(map[string]struct{}, len(in)) - out := make([]string, 0, len(in)) - for _, v := range in { - if _, dup := seen[v]; dup { - continue - } - seen[v] = struct{}{} - out = append(out, v) - } - sort.Strings(out) - return out -} diff --git a/internal/controller/applierclaim_controller.go b/internal/controller/applierclaim_controller.go index 550ef859..6681e191 100644 --- a/internal/controller/applierclaim_controller.go +++ b/internal/controller/applierclaim_controller.go @@ -298,7 +298,7 @@ func (c claimCheck) retiring(claim configbutleraiv1alpha3.ApplierClaim) bool { if !applier.Holding(claim) { return false } - return c.denied || releasingForDenial(claim) || (c.judged && (!c.refs.Named() || c.refs.Refused())) + return c.denied || revoking(claim) || (c.judged && (!c.refs.Named() || c.refs.Refused())) } // configuredVerdict judges the GitTargets naming the applier claim, and, while one may be eligible, diff --git a/internal/controller/dependency_watches.go b/internal/controller/dependency_watches.go index 96391bc9..e6e6e2a3 100644 --- a/internal/controller/dependency_watches.go +++ b/internal/controller/dependency_watches.go @@ -11,6 +11,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/handler" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/predicate" + ctrlreconcile "sigs.k8s.io/controller-runtime/pkg/reconcile" configbutleraiv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" ) @@ -60,6 +61,18 @@ func gitBranchesOf( return list.Items, nil } +// requestsFor is a reconcile request for each object in a list. +func requestsFor[T any, PT interface { + *T + client.Object +}](objects []T) []ctrlreconcile.Request { + requests := make([]ctrlreconcile.Request, 0, len(objects)) + for i := range objects { + requests = append(requests, ctrlreconcile.Request{NamespacedName: client.ObjectKeyFromObject(PT(&objects[i]))}) + } + return requests +} + // ruleDependencyWatches adds the watches a WatchRule and a ClusterWatchRule share: their GitTarget, // its KubeProvider and the Namespace whose labels its grants read. // diff --git a/internal/controller/fluxclaim_hold.go b/internal/controller/fluxclaim_hold.go index 64cca7d2..99cbdb81 100644 --- a/internal/controller/fluxclaim_hold.go +++ b/internal/controller/fluxclaim_hold.go @@ -617,7 +617,7 @@ func (r *ApplierClaimReconciler) publishReleasing( ) { why, reason := "the applier claim is being deleted", ReasonReleasing switch { - case check != nil && (check.denied || releasingForDenial(claim)): + case check != nil && (check.denied || revoking(claim)): why, reason = "the KubeProvider's spec.allowedApplierClaims no longer admit the applier claim", ReasonRevoking case check != nil && check.refs.Named(): why = "no GitTarget naming the applier claim is eligible any more" @@ -640,10 +640,10 @@ func (r *ApplierClaimReconciler) publishReleasing( st.applyReadiness(r.claimReadiness(claim, check.configured, &held)) } -// releasingForDenial reports whether the applier claim is handing its applier back because its +// revoking reports whether the applier claim is handing its applier back because its // KubeProvider's grants refused it (Held=False, Revoking): such a hand-back finishes even after a // regrant, and the applier is adopted anew once it has. -func releasingForDenial(claim configbutleraiv1alpha3.ApplierClaim) bool { +func revoking(claim configbutleraiv1alpha3.ApplierClaim) bool { held := findCondition(*claim.StatusConditions(), ApplierClaimConditionHeld) return held != nil && held.Reason == ReasonRevoking } diff --git a/internal/controller/gitbranch_controller.go b/internal/controller/gitbranch_controller.go index 92823428..fe86b80b 100644 --- a/internal/controller/gitbranch_controller.go +++ b/internal/controller/gitbranch_controller.go @@ -465,11 +465,7 @@ func (r *GitBranchReconciler) gitProviderToGitBranches(ctx context.Context, obj logDependencyListError(ctx, err, "GitBranches", obj) return nil } - requests := make([]ctrlreconcile.Request, 0, len(branches)) - for i := range branches { - requests = append(requests, ctrlreconcile.Request{NamespacedName: client.ObjectKeyFromObject(&branches[i])}) - } - return requests + return requestsFor(branches) } // namespaceToGitBranches enqueues the GitBranches in a Namespace whose labels changed: a Selector @@ -481,14 +477,7 @@ func (r *GitBranchReconciler) namespaceToGitBranches(ctx context.Context, obj cl logDependencyListError(ctx, err, "GitBranches", obj) return nil } - requests := make([]ctrlreconcile.Request, 0, len(branches.Items)) - for i := range branches.Items { - requests = append( - requests, - ctrlreconcile.Request{NamespacedName: client.ObjectKeyFromObject(&branches.Items[i])}, - ) - } - return requests + return requestsFor(branches.Items) } // gitTargetToGitBranch enqueues the GitBranch a GitTarget references. diff --git a/internal/controller/gittarget_controller.go b/internal/controller/gittarget_controller.go index 85ac27ce..0eff8ae9 100644 --- a/internal/controller/gittarget_controller.go +++ b/internal/controller/gittarget_controller.go @@ -481,8 +481,8 @@ func (r *GitTargetReconciler) evaluateValidatedGate( // Namespace authorization: a GitTarget may reference a KubeProvider only while a grant in its // spec.allowedGitTargets admits the GitTarget's namespace, and only while the provider holds its - // audit route. Enforced HERE and only here, on every reconcile — which - // also covers a policy tightened after the GitTarget was created. Failing this gate returns + // audit route. Enforced HERE and only here, on every reconcile — which also covers a policy + // tightened after the GitTarget was created. Failing this gate returns // before DeclareForGitTarget below, so an unauthorized target starts no watch and writes no Git. authorized, authReason, authMsg, authErr := r.checkSourceAuthorization(ctx, target) if authErr != nil { @@ -1465,8 +1465,8 @@ func (r *GitTargetReconciler) SetupWithManager(mgr ctrl.Manager) error { handler.EnqueueRequestsFromMapFunc(r.kubeProviderToGitTargets), builder.WithPredicates(kubeProviderReadyOrSpecChanged()), ). - // React to a Namespace's LABELS changing: a Selector grant is - // evaluated against namespace labels, so a label change can grant or revoke a GitTarget's + // React to a Namespace's LABELS changing: a Selector grant is evaluated against namespace + // labels, so a label change can grant or revoke a GitTarget's // authorization. Re-enqueue the GitTargets in that namespace so the reconcile-time refusal // converges instead of waiting for the periodic reconcile. LabelChangedPredicate ignores the // unrelated namespace churn (annotations, status). @@ -1613,11 +1613,7 @@ func (r *GitTargetReconciler) gitDestinationToGitTargets( logDependencyListError(ctx, err, "GitTargets", obj) return nil } - requests := make([]ctrlreconcile.Request, 0, len(targets)) - for i := range targets { - requests = append(requests, ctrlreconcile.Request{NamespacedName: client.ObjectKeyFromObject(&targets[i])}) - } - return requests + return requestsFor(targets) } // kubeProviderToGitTargets maps a KubeProvider event to every GitTarget that references it as diff --git a/internal/controller/gittarget_source_cluster.go b/internal/controller/gittarget_source_cluster.go index 42fd2b03..9175cd01 100644 --- a/internal/controller/gittarget_source_cluster.go +++ b/internal/controller/gittarget_source_cluster.go @@ -13,9 +13,9 @@ import ( ) // GitTargetReasonNamespaceNotAuthorized is the Validated=False reason when a GitTarget's namespace -// is not admitted by its referenced KubeProvider's spec.allowedGitTargets, or by its GitBranch's. -// It runs on every -// reconcile, so a policy tightened AFTER a GitTarget was created stops that target's watches too. +// is not admitted by its referenced KubeProvider's spec.allowedGitTargets, or by its GitBranch's. It +// runs on every reconcile, so a policy tightened AFTER a GitTarget was created stops that target's +// watches too. const GitTargetReasonNamespaceNotAuthorized = authz.ReasonNamespaceNotAuthorized // GitTargetReasonKubeProviderNotFound is the Validated=False reason when a GitTarget's diff --git a/internal/controller/watchrule_controller.go b/internal/controller/watchrule_controller.go index 816ef021..61252c73 100644 --- a/internal/controller/watchrule_controller.go +++ b/internal/controller/watchrule_controller.go @@ -297,15 +297,11 @@ func (r *WatchRuleReconciler) namespaceToWatchRules(ctx context.Context, obj cli logDependencyListError(ctx, err, "WatchRules", obj) return nil } - requests := make([]ctrlreconcile.Request, 0, len(rules.Items)) - for i := range rules.Items { - requests = append(requests, ctrlreconcile.Request{NamespacedName: client.ObjectKeyFromObject(&rules.Items[i])}) - } - return requests + return requestsFor(rules.Items) } // kubeProviderToWatchRules maps a KubeProvider change to every WatchRule whose GitTarget -// mirrors through that provider, so a delegation grant or revocation converges on the event rather +// mirrors through that provider, so a grant or revocation converges on the event rather // than on the periodic reconcile. func (r *WatchRuleReconciler) kubeProviderToWatchRules( ctx context.Context, diff --git a/internal/controller/watchrule_source_namespace.go b/internal/controller/watchrule_source_namespace.go index 8b3d98de..ab015c29 100644 --- a/internal/controller/watchrule_source_namespace.go +++ b/internal/controller/watchrule_source_namespace.go @@ -33,7 +33,7 @@ const ( // The gate is cross-object (WatchRule → GitTarget → KubeProvider), so it is not expressible in // CEL and is a reconciler check rather than a webhook, per docs/spec/where-validation-lives.md — // the same shape and ordering as checkSourceAuthorization. Running it on every reconcile is what -// makes a delegation WITHDRAWN after a rule was accepted revoke that rule. +// makes a grant WITHDRAWN after a rule was accepted revoke that rule. // // Every item is resolved, and the aggregate is published as one condition. A DENIED item refuses // the whole rule rather than being trimmed away: mirroring two of the three namespaces a rule asked diff --git a/internal/git/worker_manager.go b/internal/git/worker_manager.go index 514f7b5c..e6af9909 100644 --- a/internal/git/worker_manager.go +++ b/internal/git/worker_manager.go @@ -379,10 +379,6 @@ type WorkerRequest struct { ParentBranch string } -func (r WorkerRequest) provider() k8stypes.NamespacedName { - return r.Provider -} - // EnsureWorker ensures a worker exists for the repository branch req names, keyed by that branch // (BranchKey), and that it is the worker for the repository the GitProvider names NOW. Only the // GitBranch holding the branch's claim (branch_owner.go) gets one: any other is refused @@ -416,7 +412,7 @@ func (m *WorkerManager) EnsureWorker(_ context.Context, req WorkerRequest) error repo := req.Repo key := BranchKeyFor(repo.URL, req.Branch) if key == (BranchKey{}) { - return fmt.Errorf("GitProvider %s names no repository", req.provider()) + return fmt.Errorf("GitProvider %s names no repository", req.Provider) } if err := m.refuseUnclaimed(key, req.GitBranch); err != nil { return err @@ -484,7 +480,7 @@ func (m *WorkerManager) refuseUnclaimed(key BranchKey, gitBranch k8stypes.Namesp // startWorkerLocked creates and starts the worker for key, writing through provider, and records // it. Called with lifecycleMu and mu held. func (m *WorkerManager) startWorkerLocked(key BranchKey, req WorkerRequest) error { - provider, repo, parentBranch := req.provider(), req.Repo, req.ParentBranch + provider, repo, parentBranch := req.Provider, req.Repo, req.ParentBranch providerName, providerNamespace, branch := provider.Name, provider.Namespace, key.Branch m.Log.Info("Creating new branch worker", "key", key.String(), "repository", repo.String()) worker := NewBranchWorker( diff --git a/internal/watch/watchrule_compile.go b/internal/watch/watchrule_compile.go index c3ded52b..c3e3af57 100644 --- a/internal/watch/watchrule_compile.go +++ b/internal/watch/watchrule_compile.go @@ -148,7 +148,7 @@ func CompileClusterWatchRule( Reason: ClusterWatchRuleReasonClusterResourceNotAllowed, Message: fmt.Sprintf( "KubeProvider %s lets namespace %q mirror cluster-scoped resources %s "+ - "(spec.allowedGitTargets[].clusterResources); %s not allowed, so the ClusterWatchRule is refused", + "(spec.allowedGitTargets[].clusterResources), so the ClusterWatchRule is refused: %s", target.KubeProviderKey(), target.Namespace, describeIdentities(scope.ClusterResources), strings.Join(forbidden, "; ")), }, nil @@ -255,10 +255,7 @@ func clusterResourcesRefusal( } } if len(types) > 0 { - forbidden = append( - forbidden, - fmt.Sprintf("spec.rules[%d] asks for %s, which is", i, describeIdentities(types)), - ) + forbidden = append(forbidden, fmt.Sprintf("spec.rules[%d] asks for %s", i, describeIdentities(types))) } } return forbidden From 5ccb58b9d6de72fe028070185f1496b9d9c1f710 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sat, 24 Oct 2026 04:24:53 +0000 Subject: [PATCH 26/34] test: name tests after grants, and share the grant fixtures The WatchRule and applier-namespace tests still described the removed delegation flag and allowCrossNamespace.appliers; one claimed an own-namespace rule needs no grant, which the fixture contradicts. They are renamed and reworded after what they now check. branchesFromSame, grantNames (authz), pollCondition (wiring) and gitBranchWritersYAML (e2e) replace repeated literals and loops; allowCrossNamespaceAppliers is grantAppliersInAnyNamespace. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/authz/kubeprovider_admission_test.go | 17 ++-- internal/controller/applierclaim_rule_test.go | 32 +++---- .../controller/gitprovider_controller_test.go | 27 ++---- .../gitprovider_immutability_test.go | 9 +- .../controller/gittarget_controller_test.go | 90 +++++-------------- .../gittarget_placement_validation_test.go | 4 +- .../gittarget_source_cluster_test.go | 4 +- internal/controller/grant_helpers_test.go | 6 ++ .../superseded_fields_admission_test.go | 11 +-- .../controller/watchrule_controller_test.go | 18 +--- internal/controller/watchrule_kstatus_test.go | 2 +- .../watchrule_source_namespace_test.go | 35 ++++---- .../wiring/access_grants_wiring_test.go | 33 ++++--- .../wiring/readiness_wiring_test.go | 25 +----- internal/git/claim_helpers_test.go | 2 +- test/e2e/applier_claim.go | 7 +- test/e2e/argocd_bi_directional_e2e_test.go | 4 +- test/e2e/bi_directional_echoes_e2e_test.go | 4 +- test/e2e/bi_directional_push_race_e2e_test.go | 4 +- test/e2e/bi_directional_restart_e2e_test.go | 4 +- test/e2e/bi_directional_timing_e2e_test.go | 4 +- test/e2e/flux_applier_e2e_test.go | 2 +- test/e2e/flux_bi_directional_e2e_test.go | 2 +- test/e2e/flux_restart_recovery_e2e_test.go | 2 +- test/e2e/helpers.go | 19 ++-- test/e2e/provider_access_e2e_test.go | 7 +- test/e2e/refusal_reapply_e2e_test.go | 2 +- test/e2e/shared_gitbranch_e2e_test.go | 9 +- 28 files changed, 140 insertions(+), 245 deletions(-) diff --git a/internal/authz/kubeprovider_admission_test.go b/internal/authz/kubeprovider_admission_test.go index 4d36b491..7bb39dd5 100644 --- a/internal/authz/kubeprovider_admission_test.go +++ b/internal/authz/kubeprovider_admission_test.go @@ -56,13 +56,18 @@ func providerNamed(name string, grants ...configv1alpha3.SourceGrant) *configv1a } } -// mirrorFrom is a grant that admits namespaces by name to mirror their own namespace. -func mirrorFrom(namespaces ...string) configv1alpha3.SourceGrant { +// grantNames converts namespace names into a grant's names. +func grantNames(namespaces ...string) []configv1alpha3.GrantNamespace { names := make([]configv1alpha3.GrantNamespace, 0, len(namespaces)) for _, ns := range namespaces { names = append(names, configv1alpha3.GrantNamespace(ns)) } - return configv1alpha3.SourceGrant{From: configv1alpha3.GrantFromNames, Names: names, + return names +} + +// mirrorFrom is a grant that admits namespaces by name to mirror their own namespace. +func mirrorFrom(namespaces ...string) configv1alpha3.SourceGrant { + return configv1alpha3.SourceGrant{From: configv1alpha3.GrantFromNames, Names: grantNames(namespaces...), SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.NamespaceVariable}} } @@ -298,11 +303,9 @@ func claimOn(cluster, applierNamespace string) configv1alpha3.ApplierClaim { // appliersFrom is a grant that admits namespaces by name to hold appliers in scope. func appliersFrom(scope []configv1alpha3.ScopeNamespace, namespaces ...string) configv1alpha3.ApplierGrant { - names := make([]configv1alpha3.GrantNamespace, 0, len(namespaces)) - for _, ns := range namespaces { - names = append(names, configv1alpha3.GrantNamespace(ns)) + return configv1alpha3.ApplierGrant{ + From: configv1alpha3.GrantFromNames, Names: grantNames(namespaces...), ApplierNamespaces: scope, } - return configv1alpha3.ApplierGrant{From: configv1alpha3.GrantFromNames, Names: names, ApplierNamespaces: scope} } // TestApplierClaimAdmitted: an applier claim needs its own grant, and the applier must live in the diff --git a/internal/controller/applierclaim_rule_test.go b/internal/controller/applierclaim_rule_test.go index 1f2df7be..8941d79a 100644 --- a/internal/controller/applierclaim_rule_test.go +++ b/internal/controller/applierclaim_rule_test.go @@ -437,27 +437,27 @@ func TestApplierClaimRule_AGitTargetEventRerunsTheClaimsItNames(t *testing.T) { assert.Empty(t, argo.gitDestinationToClaims(context.Background(), platformProvider()), "another kind's") } -// TestApplierClaimRule_AnApplierInAnotherNamespaceNeedsTheOptIn is B1: a namespace a KubeProvider -// admits may suspend and apply an applier in another namespace only when the provider sets -// spec.allowCrossNamespace.appliers; one in the applier claim's own namespace needs nothing. Refused, the -// applier claim never asks the applier's cluster, and holds nothing. -func TestApplierClaimRule_AnApplierInAnotherNamespaceNeedsTheOptIn(t *testing.T) { +// TestApplierClaimRule_AnApplierInAnotherNamespaceNeedsItInTheGrant is B1: a namespace a KubeProvider +// admits may suspend and apply an applier in another namespace only when the applierNamespaces its +// spec.allowedApplierClaims give it include that namespace; '{namespace}' covers the applier claim's own. +// Refused, the applier claim never asks the applier's cluster, and holds nothing. +func TestApplierClaimRule_AnApplierInAnotherNamespaceNeedsItInTheGrant(t *testing.T) { for _, kind := range []string{configbutleraiv1alpha3.ApplierClaimKindFlux, configbutleraiv1alpha3.ApplierClaimKindArgoCD} { for name, tc := range map[string]struct { - sameNamespace, optIn bool - wantStatus metav1.ConditionStatus - wantReason string + sameNamespace, granted bool + wantStatus metav1.ConditionStatus + wantReason string }{ - "another namespace, without the opt-in": { + "another namespace, outside applierNamespaces": { wantStatus: metav1.ConditionFalse, wantReason: authz.ReasonApplierNamespaceNotAllowed, }, - "another namespace, with the opt-in": {optIn: true, wantStatus: metav1.ConditionTrue, wantReason: ReasonSucceeded}, - "its own namespace, which needs nothing": { + "another namespace, granted": {granted: true, wantStatus: metav1.ConditionTrue, wantReason: ReasonSucceeded}, + "its own namespace, which '{namespace}' grants": { sameNamespace: true, wantStatus: metav1.ConditionTrue, wantReason: ReasonSucceeded, }, } { t.Run(kind+"/"+name, func(t *testing.T) { - r, calls := applierNamespaceReconciler(t, kind, tc.sameNamespace, tc.optIn) + r, calls := applierNamespaceReconciler(t, kind, tc.sameNamespace, tc.granted) got := reconcileClaim(t, r) @@ -478,10 +478,10 @@ func TestApplierClaimRule_AnApplierInAnotherNamespaceNeedsTheOptIn(t *testing.T) // applierNamespaceReconciler is a reconciler of kind over the applier claim team-a/checkout, whose // applier is in team-a when sameNamespace is set and in flux-system or argocd otherwise, with the -// KubeProvider claimLocalProvider setting allowCrossNamespace.appliers when optIn is set. It counts the -// lookups of the applier's cluster. +// KubeProvider claimLocalProvider giving applierNamespaces "*" when granted is set, and only +// '{namespace}' otherwise. It counts the lookups of the applier's cluster. func applierNamespaceReconciler( - t *testing.T, kind string, sameNamespace, optIn bool, + t *testing.T, kind string, sameNamespace, granted bool, ) (*ApplierClaimReconciler, *int) { t.Helper() claim := ctrlclient.Object(testFluxClaim(claimLocalProvider)) @@ -502,7 +502,7 @@ func applierNamespaceReconciler( } } provider := admitting(claimLocalProvider, "team-a") - if !optIn { + if !granted { provider.Spec.AllowedApplierClaims = applierGrants( []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.NamespaceVariable}, "team-a") } diff --git a/internal/controller/gitprovider_controller_test.go b/internal/controller/gitprovider_controller_test.go index a3109cf5..939ac9f8 100644 --- a/internal/controller/gitprovider_controller_test.go +++ b/internal/controller/gitprovider_controller_test.go @@ -315,13 +315,8 @@ var _ = Describe("GitProvider Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "git@github.com:test/repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, - }, - }, + URL: "git@github.com:test/repo.git", + AllowedGitBranches: branchesFromSame("main"), SecretRef: &meta.LocalObjectReference{ Name: "nonexistent-secret", }, @@ -381,13 +376,8 @@ var _ = Describe("GitProvider Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "git@github.com:test/repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, - }, - }, + URL: "git@github.com:test/repo.git", + AllowedGitBranches: branchesFromSame("main"), SecretRef: &meta.LocalObjectReference{ Name: "malformed-secret", }, @@ -446,13 +436,8 @@ var _ = Describe("GitProvider Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "git@github.com:test/repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, - }, - }, + URL: "git@github.com:test/repo.git", + AllowedGitBranches: branchesFromSame("main"), Commit: &configbutleraiv1alpha3.CommitSpec{ Signing: &configbutleraiv1alpha3.CommitSigningSpec{ SecretRef: meta.LocalObjectReference{ diff --git a/internal/controller/gitprovider_immutability_test.go b/internal/controller/gitprovider_immutability_test.go index d5828b12..86f60c31 100644 --- a/internal/controller/gitprovider_immutability_test.go +++ b/internal/controller/gitprovider_immutability_test.go @@ -31,13 +31,8 @@ var _ = Describe("GitProvider URL Immutability", func() { provider := &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: key.Name, Namespace: key.Namespace}, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://example.com/repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, - }, - }, + URL: "https://example.com/repo.git", + AllowedGitBranches: branchesFromSame("main"), }, } Expect(k8sClient.Create(ctx, provider)).Should(Succeed()) diff --git a/internal/controller/gittarget_controller_test.go b/internal/controller/gittarget_controller_test.go index 815902a7..0d4ebfcd 100644 --- a/internal/controller/gittarget_controller_test.go +++ b/internal/controller/gittarget_controller_test.go @@ -70,13 +70,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, - }, - }, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, @@ -135,13 +130,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"main", "develop"}, - }, - }, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main", "develop"), SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, @@ -260,13 +250,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, - }, - }, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, @@ -354,13 +339,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, - }, - }, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), SecretRef: &meta.LocalObjectReference{ Name: "test-secret", }, @@ -478,13 +458,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, - }, - }, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -549,13 +524,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, - }, - }, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -632,13 +602,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, - }, - }, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -719,13 +684,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, - }, - }, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -797,13 +757,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, - }, - }, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) @@ -882,13 +837,8 @@ var _ = Describe("GitTarget Controller Security", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test-org/test-repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, - }, - }, + URL: "https://github.com/test-org/test-repo.git", + AllowedGitBranches: branchesFromSame("main"), }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) diff --git a/internal/controller/gittarget_placement_validation_test.go b/internal/controller/gittarget_placement_validation_test.go index 206be1a5..93e31e5a 100644 --- a/internal/controller/gittarget_placement_validation_test.go +++ b/internal/controller/gittarget_placement_validation_test.go @@ -262,9 +262,7 @@ func TestEvaluateValidatedGate_InvalidPlacementPolicy(t *testing.T) { provider := &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "provider-a", Namespace: ns}, Spec: configbutleraiv1alpha3.GitProviderSpec{ - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - {From: configbutleraiv1alpha3.GrantFromSame, Branches: []configbutleraiv1alpha3.BranchPattern{"main"}}, - }, + AllowedGitBranches: branchesFromSame("main"), }, } target := &configbutleraiv1alpha3.GitTarget{ diff --git a/internal/controller/gittarget_source_cluster_test.go b/internal/controller/gittarget_source_cluster_test.go index 50472355..8321e135 100644 --- a/internal/controller/gittarget_source_cluster_test.go +++ b/internal/controller/gittarget_source_cluster_test.go @@ -410,9 +410,7 @@ func TestReconcile_UnauthorizedNamespaceStartsNoWatch(t *testing.T) { gitProvider := &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "gp", Namespace: ns}, Spec: configbutleraiv1alpha3.GitProviderSpec{ - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - {From: configbutleraiv1alpha3.GrantFromSame, Branches: []configbutleraiv1alpha3.BranchPattern{"main"}}, - }, + AllowedGitBranches: branchesFromSame("main"), }, } target := &configbutleraiv1alpha3.GitTarget{ diff --git a/internal/controller/grant_helpers_test.go b/internal/controller/grant_helpers_test.go index d5525b3a..e037d863 100644 --- a/internal/controller/grant_helpers_test.go +++ b/internal/controller/grant_helpers_test.go @@ -34,6 +34,12 @@ func applierGrants( }} } +// branchesFromSame is the allowedGitBranches of a GitProvider that lets GitBranches in its own namespace +// claim the branches patterns match. +func branchesFromSame(patterns ...configbutleraiv1alpha3.BranchPattern) []configbutleraiv1alpha3.GitBranchGrant { + return []configbutleraiv1alpha3.GitBranchGrant{{From: configbutleraiv1alpha3.GrantFromSame, Branches: patterns}} +} + // writersFromSame is the allowedGitTargets of a GitBranch shared with its own namespace only. func writersFromSame() []configbutleraiv1alpha3.NamespaceGrant { return []configbutleraiv1alpha3.NamespaceGrant{{From: configbutleraiv1alpha3.GrantFromSame}} diff --git a/internal/controller/superseded_fields_admission_test.go b/internal/controller/superseded_fields_admission_test.go index 7de723ac..d620cfd2 100644 --- a/internal/controller/superseded_fields_admission_test.go +++ b/internal/controller/superseded_fields_admission_test.go @@ -183,14 +183,9 @@ var _ = Describe("Reference names must not be empty", func() { provider := &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "empty-secret-name", Namespace: "default"}, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://example.com/repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, - }, - }, - SecretRef: &meta.LocalObjectReference{Name: ""}, + URL: "https://example.com/repo.git", + AllowedGitBranches: branchesFromSame("main"), + SecretRef: &meta.LocalObjectReference{Name: ""}, }, } diff --git a/internal/controller/watchrule_controller_test.go b/internal/controller/watchrule_controller_test.go index f9dfd392..f6d4eda7 100644 --- a/internal/controller/watchrule_controller_test.go +++ b/internal/controller/watchrule_controller_test.go @@ -63,13 +63,8 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/test/repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"*"}, - }, - }, + URL: "https://github.com/test/repo.git", + AllowedGitBranches: branchesFromSame("*"), SecretRef: &meta.LocalObjectReference{ Name: "git-credentials", }, @@ -184,13 +179,8 @@ var _ = Describe("WatchRule Controller", func() { Namespace: "default", }, Spec: configbutleraiv1alpha3.GitProviderSpec{ - URL: "https://github.com/octocat/Hello-World", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - { - From: configbutleraiv1alpha3.GrantFromSame, - Branches: []configbutleraiv1alpha3.BranchPattern{"main"}, - }, - }, + URL: "https://github.com/octocat/Hello-World", + AllowedGitBranches: branchesFromSame("main"), }, } Expect(k8sClient.Create(ctx, gitProvider)).Should(Succeed()) diff --git a/internal/controller/watchrule_kstatus_test.go b/internal/controller/watchrule_kstatus_test.go index e7d1009d..ac07b495 100644 --- a/internal/controller/watchrule_kstatus_test.go +++ b/internal/controller/watchrule_kstatus_test.go @@ -62,7 +62,7 @@ func TestWatchRuleSourceNamespaceKstatusContract(t *testing.T) { }, wantStatus: kstatus.CurrentStatus, }, { - name: "delegation disabled: the refusal is terminal", + name: "namespace outside the grant: the refusal is terminal", conds: []map[string]interface{}{ conditionMap(ConditionTypeSourceNamespaceAuthorized, "False", WatchRuleReasonSourceNamespaceNotAllowed, diff --git a/internal/controller/watchrule_source_namespace_test.go b/internal/controller/watchrule_source_namespace_test.go index 08dcc291..b0050866 100644 --- a/internal/controller/watchrule_source_namespace_test.go +++ b/internal/controller/watchrule_source_namespace_test.go @@ -144,23 +144,22 @@ func wrsnCondition(t *testing.T, rule *configbutleraiv1alpha3.WatchRule, conditi } func wrsnBaseObjects( - delegate bool, + wide bool, sourceNamespaces ...string, ) []client.Object { return []client.Object{ wrsnGitTarget(), wrsnGitProvider(), - wrsnKubeProvider(delegate), + wrsnKubeProvider(wide), wrsnWatchRule(sourceNamespaces...), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: wrsnTenantNS}}, } } -// TestReconcile_LegacyWatchRuleNeedsNoPolicyOrFlag is THE test, and it is first on purpose. -// -// A WatchRule that omits sourceNamespace must compile with no GitTarget policy and no delegation -// flag. If this fails, deny-by-default has broken every existing WatchRule on upgrade. -func TestReconcile_LegacyWatchRuleNeedsNoPolicyOrFlag(t *testing.T) { +// TestReconcile_OwnNamespaceRuleNeedsOnlyANamespaceGrant: a WatchRule that omits sourceNamespace +// watches its own namespace, which a grant giving sourceNamespaces '{namespace}' covers. It needs no +// wider grant. +func TestReconcile_OwnNamespaceRuleNeedsOnlyANamespaceGrant(t *testing.T) { ctx := context.Background() f := newWRSNFixture(t, wrsnBaseObjects(false, "")) @@ -168,7 +167,7 @@ func TestReconcile_LegacyWatchRuleNeedsNoPolicyOrFlag(t *testing.T) { require.NoError(t, err) assert.Equal(t, []string{wrsnRule}, f.compiledNames(), - "an existing own-namespace WatchRule must keep working with no new configuration") + "an own-namespace WatchRule needs only a '{namespace}' grant") cond := wrsnCondition(t, f.reloadRule(ctx, t), ConditionTypeSourceNamespaceAuthorized) assert.Equal(t, metav1.ConditionTrue, cond.Status) @@ -180,7 +179,7 @@ func TestReconcile_LegacyWatchRuleNeedsNoPolicyOrFlag(t *testing.T) { // The gate has to stop the data plane, not just describe it. func TestReconcile_DeniedSourceNamespaceStartsNoWatch(t *testing.T) { ctx := context.Background() - // The target names the namespace, but the provider does not delegate. + // The rule asks for another namespace, but the grant gives only '{namespace}'. f := newWRSNFixture(t, wrsnBaseObjects( false, wrsnSourceNS)) @@ -248,7 +247,7 @@ func TestReconcile_AuthorizedOverrideCompilesWithItsSourceNamespace(t *testing.T } // TestReconcile_RevokedSourceNamespaceRemovesTheCompiledRuleAndReplans is the REVOCATION contract. -// A rule accepted and then denied by a withdrawn delegation must have its compiled rule REMOVED and +// A rule accepted and then denied by a withdrawn grant must have its compiled rule REMOVED and // the watch manager replanned — and the removal must already have happened by the time the replan // runs, because status is published only after that. A gate that reports without stopping is not a // gate. @@ -284,11 +283,9 @@ func TestReconcile_RevokedSourceNamespaceRemovesTheCompiledRuleAndReplans(t *tes assert.Equal(t, metav1.ConditionFalse, cond.Status) } -// A KubeProvider that delegates puts NO further namespace policy in the way: the source -// credential's own RBAC is the bound, and this gate does not try to predict it. That is the whole -// simplification, so it is asserted rather than left implied — the previous release refused this -// exact rule unless a GitTarget policy also listed the namespace. -func TestReconcile_DelegationIsTheOnlyPolicyLeft(t *testing.T) { +// A grant giving sourceNamespaces "*" puts NO further namespace policy in the way: the source +// credential's own RBAC is the bound, and this gate does not try to predict it. +func TestReconcile_WildcardGrantIsTheOnlyPolicy(t *testing.T) { ctx := context.Background() f := newWRSNFixture(t, wrsnBaseObjects(true, "some-namespace-nobody-declared")) @@ -296,7 +293,7 @@ func TestReconcile_DelegationIsTheOnlyPolicyLeft(t *testing.T) { require.NoError(t, err) assert.Equal(t, []string{wrsnRule}, f.compiledNames(), - "with the delegation granted, no second allow-list stands between the rule and the watch") + "with \"*\" granted, no second allow-list stands between the rule and the watch") cond := wrsnCondition(t, f.reloadRule(ctx, t), ConditionTypeSourceNamespaceAuthorized) assert.Equal(t, metav1.ConditionTrue, cond.Status) assert.Equal(t, WatchRuleReasonSourceNamespaceAllowed, cond.Reason) @@ -373,7 +370,7 @@ func TestReconcile_MixedItemsCompileTheirOwnScopes(t *testing.T) { // rule asked for is worse than a loud failure — and the message must name the offending item. func TestReconcile_DeniedItemRefusesTheWholeRule(t *testing.T) { ctx := context.Background() - // Item 0 is the free legacy case; item 1 asks for a namespace the provider does not delegate. + // Item 0 watches the rule's own namespace, which the grant gives; item 1 asks for one it does not. f := newWRSNFixture(t, wrsnBaseObjects(false, "", "tenant-zen")) _, err := f.reconcile(ctx) @@ -389,10 +386,10 @@ func TestReconcile_DeniedItemRefusesTheWholeRule(t *testing.T) { assert.Contains(t, cond.Message, "tenant-zen", "...and by what it asked for") } -// "*" is refused outright while the provider does not delegate, and the refusal names the flag. It +// "*" is refused outright while no grant gives sourceNamespaces "*", and the refusal names the field. It // is the widest request in the API — every namespace the credential can read — so it must not be // the one that slips past a provider granting nothing. -func TestReconcile_WildcardWithoutDelegationIsRefused(t *testing.T) { +func TestReconcile_WildcardWithoutAWildcardGrantIsRefused(t *testing.T) { ctx := context.Background() f := newWRSNFixture(t, wrsnBaseObjects(false, configbutleraiv1alpha3.SourceNamespaceWildcard)) diff --git a/internal/controller/wiring/access_grants_wiring_test.go b/internal/controller/wiring/access_grants_wiring_test.go index 06ffc4a3..ffa41d4b 100644 --- a/internal/controller/wiring/access_grants_wiring_test.go +++ b/internal/controller/wiring/access_grants_wiring_test.go @@ -43,8 +43,6 @@ func conditionsOf(obj client.Object) []metav1.Condition { return typed.Status.Conditions case *configbutleraiv1alpha3.GitTarget: return typed.Status.Conditions - case *configbutleraiv1alpha3.FluxClaim: - return typed.Status.Conditions } return nil } @@ -53,6 +51,17 @@ func conditionsOf(obj client.Object) []metav1.Condition { func awaitCondition( ctx context.Context, t *testing.T, key types.NamespacedName, fresh func() client.Object, conditionType, want string, budget time.Duration, +) *metav1.Condition { + t.Helper() + return pollCondition(ctx, t, key, fresh, conditionType, "reason "+want, + func(c *metav1.Condition) bool { return c.Reason == want }, budget) +} + +// pollCondition polls key until its condition conditionType satisfies want, or fails naming what, the +// state waited for. +func pollCondition( + ctx context.Context, t *testing.T, key types.NamespacedName, fresh func() client.Object, + conditionType, what string, want func(*metav1.Condition) bool, budget time.Duration, ) *metav1.Condition { t.Helper() deadline := time.Now().Add(budget) @@ -61,15 +70,15 @@ func awaitCondition( obj := fresh() if err := k8sClient.Get(ctx, key, obj); err == nil { if c := apimeta.FindStatusCondition(conditionsOf(obj), conditionType); c != nil { - if c.Reason == want { + if want(c) { return c } - last = c.Reason + ": " + c.Message + last = string(c.Status) + " " + c.Reason + ": " + c.Message } } time.Sleep(50 * time.Millisecond) } - t.Fatalf("%s %s never reached reason %q within %s (last seen %q)", key, conditionType, want, budget, last) + t.Fatalf("%s %s never reached %s within %s (last seen %q)", key, conditionType, what, budget, last) return nil } @@ -274,18 +283,8 @@ func awaitTarget( want func(*metav1.Condition) bool, ) { t.Helper() - deadline := time.Now().Add(propagationBudget) - var last *metav1.Condition - for time.Now().Before(deadline) { - got := &configbutleraiv1alpha3.GitTarget{} - require.NoError(t, k8sClient.Get(ctx, key, got)) - if last = apimeta.FindStatusCondition(got.Status.Conditions, conditionType); last != nil && want(last) { - return - } - time.Sleep(50 * time.Millisecond) - } - t.Fatalf("GitTarget %s %s never reached what was waited for within %s: %+v", key, conditionType, - propagationBudget, last) + pollCondition(ctx, t, key, func() client.Object { return &configbutleraiv1alpha3.GitTarget{} }, + conditionType, "what was waited for", want, propagationBudget) } // setWriters replaces a GitBranch's allowedGitTargets. diff --git a/internal/controller/wiring/readiness_wiring_test.go b/internal/controller/wiring/readiness_wiring_test.go index 8d2b14a6..fa5f8256 100644 --- a/internal/controller/wiring/readiness_wiring_test.go +++ b/internal/controller/wiring/readiness_wiring_test.go @@ -196,29 +196,8 @@ func awaitMirroredReady( budget time.Duration, ) { t.Helper() - - deadline := time.Now().Add(budget) - last := "absent" - for time.Now().Before(deadline) { - obj := fresh() - if err := k8sClient.Get(ctx, key, obj); err == nil { - var conditions []metav1.Condition - switch typed := obj.(type) { - case *configbutleraiv1alpha3.WatchRule: - conditions = typed.Status.Conditions - case *configbutleraiv1alpha3.ClusterWatchRule: - conditions = typed.Status.Conditions - } - if c := apimeta.FindStatusCondition(conditions, controller.ConditionTypeGitTargetReady); c != nil { - if c.Status == want { - return - } - last = string(c.Status) - } - } - time.Sleep(50 * time.Millisecond) - } - t.Fatalf("GitTargetReady never reached %q within %s (last seen %q)", want, budget, last) + pollCondition(ctx, t, key, fresh, controller.ConditionTypeGitTargetReady, "status "+string(want), + func(c *metav1.Condition) bool { return c.Status == want }, budget) } // TestWatchRule_FollowsGitTargetStatusOnlyRecovery is the regression test for a rule whose mirrored diff --git a/internal/git/claim_helpers_test.go b/internal/git/claim_helpers_test.go index b68254eb..bac3f0de 100644 --- a/internal/git/claim_helpers_test.go +++ b/internal/git/claim_helpers_test.go @@ -9,7 +9,7 @@ import ( ) // testGitBranch is the GitBranch a test worker writes for: one per provider and branch, in the -// provider's namespace, as a GitBranch's gitProviderRef is always local. +// provider's namespace. func testGitBranch(provider, namespace, branch string) k8stypes.NamespacedName { return k8stypes.NamespacedName{Namespace: namespace, Name: testGitBranchName(provider, branch)} } diff --git a/test/e2e/applier_claim.go b/test/e2e/applier_claim.go index a1fc61c5..b5560c42 100644 --- a/test/e2e/applier_claim.go +++ b/test/e2e/applier_claim.go @@ -56,11 +56,11 @@ spec: Expect(err).NotTo(HaveOccurred(), "failed to create ArgoCDClaim %s/%s", namespace, name) } -// allowCrossNamespaceAppliers lets the applier claims that reach their applier through the install's +// grantAppliersInAnyNamespace lets the applier claims that reach their applier through the install's // KubeProvider hold one in any namespace, as the e2e appliers are: in flux-system and argocd, while // each spec's applier claims live in its own namespace. Granting it is idempotent, and no spec // relies on its absence on that provider, so parallel specs may all ask for it. -func allowCrossNamespaceAppliers() { +func grantAppliersInAnyNamespace() { GinkgoHelper() _, err := kubectlRunInNamespace(resolveE2ENamespace(), "patch", "kubeprovider", e2eKubeProviderName, "--type=merge", "-p", `{"spec":{"allowedApplierClaims":[{"from":"All","applierNamespaces":["*"]}]}}`) @@ -71,7 +71,8 @@ func allowCrossNamespaceAppliers() { // namespace mirrors its own namespace and any cluster-scoped resource, and holds an applier in its // own namespace. config/kubeprovider-local.yaml carries them, and the Helm install passes them as // values (test/e2e/helm-values-suite-grants.yaml); the chart grants nothing by default, so the plain -// manifests need them patched in. +// manifests need them patched in. On a config-dir install the patch restates what the manifest +// already carries. const suiteKubeProviderGrants = `{"spec":{` + `"allowedGitTargets":[{"from":"All","sourceNamespaces":["{namespace}"],"clusterResources":["*"]}],` + `"allowedApplierClaims":[{"from":"All","applierNamespaces":["{namespace}"]}]}}` diff --git a/test/e2e/argocd_bi_directional_e2e_test.go b/test/e2e/argocd_bi_directional_e2e_test.go index 12597b23..8c199ce3 100644 --- a/test/e2e/argocd_bi_directional_e2e_test.go +++ b/test/e2e/argocd_bi_directional_e2e_test.go @@ -115,7 +115,7 @@ var _ = Describe("Bi Directional (Argo CD)", Label("bi-directional", "argocd"), BeforeAll(func() { skipUnlessBiDirectionalEnabled() requireArgoCDInstalled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() By("creating test namespace") testNs = testNamespaceFor("argo-bi-directional") @@ -405,7 +405,7 @@ var _ = Describe("Bi Directional (Argo CD) refusal", Label("bi-directional", "ar BeforeAll(func() { skipUnlessBiDirectionalEnabled() requireArgoCDInstalled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("argo-refusal") _, _ = kubectlRun("create", "namespace", testNs) diff --git a/test/e2e/bi_directional_echoes_e2e_test.go b/test/e2e/bi_directional_echoes_e2e_test.go index 57a885f2..e7ed6516 100644 --- a/test/e2e/bi_directional_echoes_e2e_test.go +++ b/test/e2e/bi_directional_echoes_e2e_test.go @@ -426,7 +426,7 @@ var _ = Describe("Bi Directional applier echoes (Flux)", Label("bi-directional", BeforeAll(func() { skipUnlessBiDirectionalEnabled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("echoes-flux") _, _ = kubectlRun("create", "namespace", testNs) fluxName = "echoes-" + testNs @@ -471,7 +471,7 @@ var _ = Describe("Bi Directional applier echoes (Argo CD)", Label("bi-directiona BeforeAll(func() { skipUnlessBiDirectionalEnabled() requireArgoCDInstalled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("echoes-argo") _, _ = kubectlRun("create", "namespace", testNs) diff --git a/test/e2e/bi_directional_push_race_e2e_test.go b/test/e2e/bi_directional_push_race_e2e_test.go index d7b7efa9..21c07301 100644 --- a/test/e2e/bi_directional_push_race_e2e_test.go +++ b/test/e2e/bi_directional_push_race_e2e_test.go @@ -73,7 +73,7 @@ var _ = Describe("Bi Directional push race (Flux)", Label("bi-directional", "flu BeforeAll(func() { skipUnlessBiDirectionalEnabled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("push-race-flux") _, _ = kubectlRun("create", "namespace", testNs) fluxName = "push-race-" + testNs @@ -120,7 +120,7 @@ var _ = Describe("Bi Directional push race (Argo CD)", Label("bi-directional", " BeforeAll(func() { skipUnlessBiDirectionalEnabled() requireArgoCDInstalled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("push-race-argo") _, _ = kubectlRun("create", "namespace", testNs) diff --git a/test/e2e/bi_directional_restart_e2e_test.go b/test/e2e/bi_directional_restart_e2e_test.go index bf010a48..e00719f4 100644 --- a/test/e2e/bi_directional_restart_e2e_test.go +++ b/test/e2e/bi_directional_restart_e2e_test.go @@ -93,7 +93,7 @@ var _ = Describe("Bi Directional restart with work made while down (Flux)", BeforeAll(func() { skipUnlessBiDirectionalEnabled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("echoes-restart-flux") _, _ = kubectlRun("create", "namespace", testNs) fluxName = "echoes-restart-" + testNs @@ -138,7 +138,7 @@ var _ = Describe("Bi Directional restart with work made while down (Argo CD)", BeforeAll(func() { skipUnlessBiDirectionalEnabled() requireArgoCDInstalled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("echoes-restart-argo") _, _ = kubectlRun("create", "namespace", testNs) diff --git a/test/e2e/bi_directional_timing_e2e_test.go b/test/e2e/bi_directional_timing_e2e_test.go index 381717f1..ed089d35 100644 --- a/test/e2e/bi_directional_timing_e2e_test.go +++ b/test/e2e/bi_directional_timing_e2e_test.go @@ -254,7 +254,7 @@ var _ = Describe("Bi Directional timing (Flux)", Label("bi-directional", "flux", BeforeAll(func() { skipUnlessBiDirectionalEnabled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("bi-timing-flux") _, _ = kubectlRun("create", "namespace", testNs) fluxName = "bi-timing-" + testNs @@ -338,7 +338,7 @@ var _ = Describe("Bi Directional timing (Argo CD)", Label("bi-directional", "arg BeforeAll(func() { skipUnlessBiDirectionalEnabled() requireArgoCDInstalled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("bi-timing-argo") _, _ = kubectlRun("create", "namespace", testNs) diff --git a/test/e2e/flux_applier_e2e_test.go b/test/e2e/flux_applier_e2e_test.go index 7bf79410..2c030afd 100644 --- a/test/e2e/flux_applier_e2e_test.go +++ b/test/e2e/flux_applier_e2e_test.go @@ -39,7 +39,7 @@ var _ = Describe("Manager Flux Applier", Label("manager", "flux-applier"), Order ) BeforeAll(func() { - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("manager-flux-applier") appliedNs = testNs + "-applied" fluxName = "flux-applier-" + testNs diff --git a/test/e2e/flux_bi_directional_e2e_test.go b/test/e2e/flux_bi_directional_e2e_test.go index 4e645fb9..4f561eb9 100644 --- a/test/e2e/flux_bi_directional_e2e_test.go +++ b/test/e2e/flux_bi_directional_e2e_test.go @@ -96,7 +96,7 @@ var _ = Describe("Bi Directional (Flux)", Label("bi-directional", "flux"), Order BeforeAll(func() { skipUnlessBiDirectionalEnabled() - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() By("creating test namespace") testNs = testNamespaceFor("bi-directional") diff --git a/test/e2e/flux_restart_recovery_e2e_test.go b/test/e2e/flux_restart_recovery_e2e_test.go index eec7d0bd..27facff8 100644 --- a/test/e2e/flux_restart_recovery_e2e_test.go +++ b/test/e2e/flux_restart_recovery_e2e_test.go @@ -39,7 +39,7 @@ var _ = Describe("Flux Restart Recovery", Label("flux-restart-recovery"), Serial ) BeforeAll(func() { - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() testNs = testNamespaceFor("flux-restart-recovery") appliedNs = testNs + "-applied" fluxName = "flux-recovery-" + testNs diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go index 5c4889a2..1ac1bb8a 100644 --- a/test/e2e/helpers.go +++ b/test/e2e/helpers.go @@ -223,10 +223,6 @@ func deleteGitProvider(namespace, name string) { // GitTargets in its own namespace may write through it, and in sharedWith. func gitBranchManifest(namespace, providerName string, sharedWith ...string) string { const branch = "main" - shared := "" - if len(sharedWith) > 0 { - shared = fmt.Sprintf("\n - from: Names\n names: [%s]", strings.Join(sharedWith, ", ")) - } return fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: GitBranch metadata: @@ -238,11 +234,20 @@ spec: gitProviderRef: name: %s branch: %s - allowedGitTargets: - - from: Same%s + allowedGitTargets:%s --- `, gitBranchName(providerName, branch), namespace, gitBranchProviderLabel, providerName, providerName, branch, - shared) + gitBranchWritersYAML(sharedWith...)) +} + +// gitBranchWritersYAML is the entries of a GitBranch's allowedGitTargets, each on its own line and +// indented under that key: its own namespace, and the namespaces in sharedWith. +func gitBranchWritersYAML(sharedWith ...string) string { + writers := "\n - from: Same" + if len(sharedWith) > 0 { + writers += fmt.Sprintf("\n - from: Names\n names: [%s]", strings.Join(sharedWith, ", ")) + } + return writers } // createGitTarget creates a GitTarget on the GitBranch of a GitProvider's branch, creating that diff --git a/test/e2e/provider_access_e2e_test.go b/test/e2e/provider_access_e2e_test.go index aeed7a12..c0665588 100644 --- a/test/e2e/provider_access_e2e_test.go +++ b/test/e2e/provider_access_e2e_test.go @@ -5,7 +5,6 @@ package e2e import ( "fmt" "path" - "strings" "time" . "github.com/onsi/ginkgo/v2" @@ -84,10 +83,6 @@ spec: // GitProvider, written by its own namespace and the namespaces in sharedWith. applyTenantGitBranch := func(name, namespace, branch string, sharedWith ...string) { GinkgoHelper() - writers := "\n - from: Same" - if len(sharedWith) > 0 { - writers += fmt.Sprintf("\n - from: Names\n names: [%s]", strings.Join(sharedWith, ", ")) - } _, err := kubectlRunWithStdin("", fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: GitBranch metadata: @@ -99,7 +94,7 @@ spec: namespace: %s branch: %s allowedGitTargets:%s -`, name, namespace, providerName, platformNs, branch, writers), "apply", "-f", "-") +`, name, namespace, providerName, platformNs, branch, gitBranchWritersYAML(sharedWith...)), "apply", "-f", "-") Expect(err).NotTo(HaveOccurred(), "failed to apply GitBranch %s/%s", namespace, name) } diff --git a/test/e2e/refusal_reapply_e2e_test.go b/test/e2e/refusal_reapply_e2e_test.go index d7a6ec01..1eb1e922 100644 --- a/test/e2e/refusal_reapply_e2e_test.go +++ b/test/e2e/refusal_reapply_e2e_test.go @@ -45,7 +45,7 @@ var _ = Describe("Manager Refusal Reapply", Label("manager", "refusal-reapply"), ) BeforeAll(func() { - allowCrossNamespaceAppliers() + grantAppliersInAnyNamespace() By("creating the test namespace") testNs = testNamespaceFor("manager-refusal-reapply") _, _ = kubectlRun("create", "namespace", testNs) diff --git a/test/e2e/shared_gitbranch_e2e_test.go b/test/e2e/shared_gitbranch_e2e_test.go index 4468847f..ad69b754 100644 --- a/test/e2e/shared_gitbranch_e2e_test.go +++ b/test/e2e/shared_gitbranch_e2e_test.go @@ -180,9 +180,9 @@ spec: func applySharedGitBranch(name, namespace, provider, branch, sharedWith string) { By(fmt.Sprintf("creating GitBranch '%s/%s' for branch '%s' through GitProvider '%s'", namespace, name, branch, provider)) - shared := "" + var shared []string if sharedWith != "" { - shared = fmt.Sprintf("\n - from: Names\n names: [%s]", sharedWith) + shared = []string{sharedWith} } _, err := kubectlRunWithStdin("", fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: GitBranch @@ -193,9 +193,8 @@ spec: gitProviderRef: name: %s branch: %s - allowedGitTargets: - - from: Same%s -`, name, namespace, provider, branch, shared), "apply", "-f", "-") + allowedGitTargets:%s +`, name, namespace, provider, branch, gitBranchWritersYAML(shared...)), "apply", "-f", "-") Expect(err).NotTo(HaveOccurred(), "failed to apply GitBranch %s/%s", namespace, name) } From dbfa1dde97284922ed92d126de489b2c8d0ae3a7 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sat, 24 Oct 2026 04:24:53 +0000 Subject: [PATCH 27/34] docs: point two plans at the grants that replaced accessFrom Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/design/one-writer-per-branch-plan.md | 21 +++++++++++-------- docs/future/ha-gittarget-distribution-plan.md | 5 +++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/design/one-writer-per-branch-plan.md b/docs/design/one-writer-per-branch-plan.md index 3957f133..dd2a3fcb 100644 --- a/docs/design/one-writer-per-branch-plan.md +++ b/docs/design/one-writer-per-branch-plan.md @@ -57,20 +57,23 @@ explicit object: - A younger `GitBranch` for a held branch reports `Stalled=True`, reason `BranchConflict`, naming the holder. Status carries this; the plan's admission refusal was dropped so that a folder of manifests can be applied in any order. -- `GitBranch.spec.gitProviderRef` is a `LocalObjectReference`, so creating a claim takes the right - to create objects where the credential lives. +- `GitBranch.spec.gitProviderRef` was a `LocalObjectReference`, so creating a claim took the right + to create objects where the credential lives. Since #441 it may name a `GitProvider` in another + namespace, and the right to claim comes from that provider's `spec.allowedGitBranches` instead. So option B's per-write credential selection never had to exist, which was the purpose of rule 2. ### The shared credential (rule 3, option F): done, with a different reference, #428 + #429 -- `GitProvider.spec.accessFrom` admits other namespaces by name or label selector. A provider - always admits its own namespace. -- The cross-namespace reference is **`GitTarget.spec.gitBranchRef`** (namespaced), not - `gitProviderRef` as the plan drew it. One policy governs both hops: a `GitTarget` in another - namespace may use a `GitBranch` while that branch's `GitProvider` admits the target's namespace - ([`gitBranchAdmitted`](../../internal/authz/kubeprovider_admission.go)). This is checked on every - reconcile, so a policy that is tightened later revokes access. +- #428 shipped `GitProvider.spec.accessFrom`; #441 replaced it with explicit grants, and nothing is + admitted automatically, the object's own namespace included. A `GitProvider`'s + `spec.allowedGitBranches` decide which namespaces may claim which branches through it, and a + `GitBranch`'s `spec.allowedGitTargets` decide which namespaces may write through it. +- The cross-namespace reference is **`GitTarget.spec.gitBranchRef`** (namespaced), as well as + `gitProviderRef` since #441. Each hop is checked against the grants of the object it reaches + ([`gitBranchAdmitted`](../../internal/authz/kubeprovider_admission.go), + [`GitBranchClaimAdmitted`](../../internal/authz/kubeprovider_admission.go)). This is checked on + every reconcile, so a grant that is narrowed later revokes access. - One Secret in the platform namespace serves every team. Self-service stays: a team can still own a `GitProvider`, a `GitBranch` and a Secret in its own namespace. - Committer and signing come with the credential, so a branch is signed by one key. diff --git a/docs/future/ha-gittarget-distribution-plan.md b/docs/future/ha-gittarget-distribution-plan.md index 2fcefde4..2ee6c009 100644 --- a/docs/future/ha-gittarget-distribution-plan.md +++ b/docs/future/ha-gittarget-distribution-plan.md @@ -147,8 +147,9 @@ target incarnations alongside the shard key; sharing a destination must not sile incompatible policy or reroute old work to a replacement object with the same name. Providers are not combined: the branch's one `GitProvider` decides credential, branch -permissions and signing policy, and `GitTarget`s in other namespaces share it through its -`spec.accessFrom`. A shared destination does not authorize one provider's work to use another +permissions and signing policy. `GitBranch`es in other namespaces claim through it by its +`spec.allowedGitBranches`, and `GitTarget`s write through a `GitBranch` by that branch's +`spec.allowedGitTargets`. A shared destination does not authorize one provider's work to use another provider's permissions. ### Journal record From fd34097067f4e539c93550a62c11c5a4ca946d58 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sat, 24 Oct 2026 04:24:53 +0000 Subject: [PATCH 28/34] test(e2e): time out a hung k3d image import so the retry runs A closed docker socket mid-stream left the node's ctr import waiting on stdin, and k3d never returned: the full-manager leg sat in attempt 1 of 3 until the 30m suite timeout and ran 0 specs. Co-Authored-By: Claude Opus 5.5 (1M context) --- hack/e2e/load-image.sh | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/hack/e2e/load-image.sh b/hack/e2e/load-image.sh index f675df58..66af1a42 100755 --- a/hack/e2e/load-image.sh +++ b/hack/e2e/load-image.sh @@ -178,10 +178,17 @@ import_image() { # # Trust nothing either way: the pin step below verifies the image really # landed in every node, and any failed attempt (loud or silent) is retried. + # + # Direct mode can also hang: when the docker socket closes mid-stream ("Failed + # to copy read stream ... use of closed network connection"), the node's + # `ctr image import` waits on stdin forever and k3d never returns, so a run + # sat until the suite timeout without reaching attempt 2. A healthy import + # takes seconds; the timeout turns a hang into a failed attempt. for attempt in 1 2 3; do echo "Importing ${PROJECT_IMAGE} into k3d cluster ${CLUSTER_NAME} (attempt ${attempt}/3)" pinned=1 - if "${K3D}" image import -m direct -c "${CLUSTER_NAME}" "${PROJECT_IMAGE}"; then + if timeout --kill-after=10s "${IMAGE_IMPORT_TIMEOUT:-300s}" \ + "${K3D}" image import -m direct -c "${CLUSTER_NAME}" "${PROJECT_IMAGE}"; then while IFS= read -r node_name; do if ! pin_imported_image "${node_name}" "${ref}" "${PROJECT_IMAGE}" "${IMAGE_REPO}" "${IMAGE_TAG}"; then pinned=0 From 093bf850196f1eb83ca0a43e5e57f5b861a7e9bd Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sat, 24 Oct 2026 04:55:00 +0000 Subject: [PATCH 29/34] feat(api): name the grant consumer with {consumer.namespace} and {consumer.name} A grant's scope variable was {namespace}. It did not say whose namespace it was, of the three a grant involves, and it collided with the placement template's {namespace}, the mirrored resource's. The object a grant admits is now the consumer: {consumer.namespace} replaces {namespace}, and {consumer.name} lets one grant give each GitTarget, applier claim or GitBranch the source namespace, applier namespace or branch segment named after it. Both are allowed alone in sourceNamespaces and applierNamespaces, and each as a whole path segment in a branch pattern; {namespace} is now refused as an unknown variable. The Admits methods take the consumer as a NamespacedName. FluxClaim status.source.otherConsumers is status.source.alsoAppliedBy, and Flux.Consumers is Flux.AppliedBy, so consumer keeps one meaning. configuration.md explains the variables, and definitions.md defines the consumer. Co-Authored-By: Claude Opus 5.5 (1M context) --- api/v1alpha3/access_grants.go | 136 ++++++++++-------- api/v1alpha3/access_grants_test.go | 114 ++++++++++----- api/v1alpha3/fluxclaim_types.go | 6 +- api/v1alpha3/gittarget_types.go | 4 +- api/v1alpha3/watchrule_types.go | 4 +- api/v1alpha3/zz_generated.deepcopy.go | 4 +- charts/gitops-reverser/README.md | 2 +- .../templates/applier-rbac.yaml | 2 +- .../templates/kubeprovider.yaml | 4 +- charts/gitops-reverser/values-playground.yaml | 4 +- charts/gitops-reverser/values.yaml | 11 +- cmd/main_chart_kubeprovider_test.go | 17 ++- cmd/main_chart_receiver_test.go | 2 +- .../crd/bases/configbutler.ai_fluxclaims.yaml | 38 ++--- .../bases/configbutler.ai_gitbranches.yaml | 13 +- .../bases/configbutler.ai_gitproviders.yaml | 47 +++--- .../crd/bases/configbutler.ai_gittargets.yaml | 4 +- .../bases/configbutler.ai_kubeproviders.yaml | 62 ++++---- .../crd/bases/configbutler.ai_watchrules.yaml | 4 +- config/kubeprovider-local.yaml | 4 +- config/rbac/flux-applier-role.yaml | 2 +- config/samples/kubeprovider.yaml | 8 +- config/samples/quickstart-gitprovider.yaml | 4 +- docs/UPGRADING.md | 19 +-- docs/bi-directional-argocd.md | 2 +- docs/bi-directional-flux.md | 14 +- docs/configuration.md | 90 ++++++++---- docs/definitions.md | 7 + docs/design/apply-trigger.md | 4 +- docs/finished/provider-access-rules.md | 6 + internal/applier/dispatcher_test.go | 2 +- .../{flux_consumers.go => flux_applied_by.go} | 35 ++--- ...sumers_test.go => flux_applied_by_test.go} | 40 +++--- internal/authz/gitbranch_admission_test.go | 11 +- internal/authz/kubeprovider_admission.go | 6 +- internal/authz/kubeprovider_admission_test.go | 29 ++-- internal/authz/source_namespace.go | 6 +- internal/authz/source_namespace_test.go | 8 +- .../access_grants_admission_test.go | 39 +++-- .../controller/applierclaim_controller.go | 2 +- internal/controller/applierclaim_rule_test.go | 8 +- internal/controller/fluxclaim_hold.go | 22 +-- internal/controller/fluxclaim_hold_test.go | 12 +- internal/controller/gitbranch_access_test.go | 2 +- .../controller/gittarget_gitbranch_test.go | 2 +- .../gittarget_shared_branch_test.go | 2 +- .../gittarget_source_cluster_test.go | 6 +- internal/controller/grant_helpers_test.go | 2 +- internal/controller/suite_test.go | 14 +- .../watchrule_source_namespace_test.go | 8 +- .../wiring/access_grants_wiring_test.go | 6 +- .../wiring/readiness_wiring_test.go | 12 +- internal/watch/bootstrap_admission_test.go | 2 +- internal/watch/cluster_resources_test.go | 2 +- internal/watch/manager_startup_test.go | 2 +- internal/watch/source_namespace_test.go | 4 +- test/e2e/applier_claim.go | 4 +- test/e2e/audit_route_attribution_e2e_test.go | 4 +- test/e2e/flux_applier_e2e_test.go | 8 +- test/e2e/helm-values-suite-grants.yaml | 4 +- test/e2e/provider_access_e2e_test.go | 4 +- test/e2e/source_cluster_e2e_test.go | 10 +- .../specific-examples/prerequisites/README.md | 2 +- .../e2e/configmap_scenarios_test.go | 6 +- test/mutationlab/e2e/crd_conversion_test.go | 12 +- .../e2e/workload_scenarios_test.go | 8 +- 66 files changed, 588 insertions(+), 396 deletions(-) rename internal/applier/{flux_consumers.go => flux_applied_by.go} (87%) rename internal/applier/{flux_consumers_test.go => flux_applied_by_test.go} (87%) diff --git a/api/v1alpha3/access_grants.go b/api/v1alpha3/access_grants.go index 7ca163e8..8a6f6ebc 100644 --- a/api/v1alpha3/access_grants.go +++ b/api/v1alpha3/access_grants.go @@ -10,13 +10,15 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/types" ) // Access between objects (docs/configuration.md): consent lives on the object // that lends something, in a list named "allowed" plus the referring kind. Every grant says where // it admits from with an enum borrowed from Gateway API's allowedRoutes.namespaces.from, required // here so that no grant admits anyone by omission. Nothing is automatic, the object's own namespace -// included: an omitted or empty list admits nobody, and grants combine by union. +// included: an omitted or empty list admits nobody, and grants combine by union. The object +// asking for access is the consumer, and a scope or branch pattern can name it with a variable. // GrantFrom is where a grant admits referring objects from. // +kubebuilder:validation:Enum=Same;Names;Selector;All @@ -33,21 +35,26 @@ const ( GrantFromAll GrantFrom = "All" ) -// NamespaceVariable is the one variable a grant's scope may hold. It stands for the namespace of the -// object asking for access, in the control cluster. -const NamespaceVariable = "{namespace}" +// ConsumerNamespaceVariable stands, in a grant's scope or branch pattern, for the consumer's +// namespace: the control-cluster namespace of the object asking for access. +const ConsumerNamespaceVariable = "{consumer.namespace}" + +// ConsumerNameVariable stands, in a grant's scope or branch pattern, for the consumer's name: the +// name of the object asking for access. +const ConsumerNameVariable = "{consumer.name}" // GrantNamespace is one control-cluster namespace a grant admits by name. // +kubebuilder:validation:MaxLength=63 -// +kubebuilder:validation:XValidation:rule="self != '{namespace}'",message="names: '{namespace}' is refused, because it would admit every referring namespace; use from: Same for the namespace of the object holding the grant" +// +kubebuilder:validation:XValidation:rule="self != '{consumer.namespace}' && self != '{consumer.name}'",message="names: a variable is refused, because names are matched against the consumer's namespace and '{consumer.namespace}' would admit every consumer; use from: All to admit every namespace, or from: Same for the namespace of the object holding the grant" // +kubebuilder:validation:XValidation:rule="self != '*'",message="names: '*' is refused; use from: All to admit every namespace" -// +kubebuilder:validation:XValidation:rule="self == '{namespace}' || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')",message="names holds namespace names (DNS labels)" +// +kubebuilder:validation:XValidation:rule="self == '{consumer.namespace}' || self == '{consumer.name}' || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')",message="names holds namespace names (DNS labels)" type GrantNamespace string -// ScopeNamespace is one namespace in a source or applier scope: a namespace name, "{namespace}" for -// the namespace of the object asking for access, or "*" for every namespace. +// ScopeNamespace is one namespace in a source or applier scope: a namespace name, +// "{consumer.namespace}" or "{consumer.name}" for the namespace of the same name as the consumer's +// namespace or name, or "*" for every namespace. // +kubebuilder:validation:MaxLength=63 -// +kubebuilder:validation:XValidation:rule="self == '*' || self == '{namespace}' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')",message="a namespace scope holds a namespace name, '{namespace}' alone, or '*' alone" +// +kubebuilder:validation:XValidation:rule="self == '*' || self == '{consumer.namespace}' || self == '{consumer.name}' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')",message="a namespace scope holds a namespace name, '{consumer.namespace}' or '{consumer.name}' alone, or '*' alone" type ScopeNamespace string // ClusterResourceIdentity names a cluster-scoped resource type: resource.group, a bare resource for @@ -61,13 +68,14 @@ type ClusterResourceIdentity string // one could never match, and with no '?' or '[' nothing in a pattern ever needs escaping. // BranchPattern is a pattern matched against a whole branch name: literal text, '*' for any -// sequence of characters ('/' included), and '{namespace}' as a whole path segment. +// sequence of characters ('/' included), and '{consumer.namespace}' and '{consumer.name}' as whole +// path segments. // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=255 // +kubebuilder:validation:Pattern=`^[^\s~^:?\[\\]*$` -// +kubebuilder:validation:XValidation:rule="!self.replace('{namespace}', 'x').matches('[{][^{}]*[}]')",messageExpression="'branch pattern ' + self + ' references unknown variable ' + self.replace('{namespace}', 'x').find('[{][^{}]*[}]') + '; the only variable is {namespace}'" -// +kubebuilder:validation:XValidation:rule="self.replace('{namespace}', 'x').matches('[{][^{}]*[}]') || !self.replace('{namespace}', 'x').matches('[{}]')",messageExpression="'branch pattern ' + self + ' has an unmatched brace; the only variable is {namespace}'" -// +kubebuilder:validation:XValidation:rule="!self.matches('[^/][{]namespace[}]') && !self.matches('[{]namespace[}][^/]')",messageExpression="'branch pattern ' + self + ': {namespace} must be a whole path segment, at the start or after a /, and at the end or before a /'" +// +kubebuilder:validation:XValidation:rule="!self.replace('{consumer.namespace}', 'x').replace('{consumer.name}', 'x').matches('[{][^{}]*[}]')",messageExpression="'branch pattern ' + self + ' references unknown variable ' + self.replace('{consumer.namespace}', 'x').replace('{consumer.name}', 'x').find('[{][^{}]*[}]') + '; the variables are {consumer.namespace} and {consumer.name}'" +// +kubebuilder:validation:XValidation:rule="self.replace('{consumer.namespace}', 'x').replace('{consumer.name}', 'x').matches('[{][^{}]*[}]') || !self.replace('{consumer.namespace}', 'x').replace('{consumer.name}', 'x').matches('[{}]')",messageExpression="'branch pattern ' + self + ' has an unmatched brace; the variables are {consumer.namespace} and {consumer.name}'" +// +kubebuilder:validation:XValidation:rule="!self.matches('[^/][{]consumer[.]name(space)?[}]') && !self.matches('[{]consumer[.]name(space)?[}][^/]')",messageExpression="'branch pattern ' + self + ': {consumer.namespace} and {consumer.name} must each be a whole path segment, at the start or after a /, and at the end or before a /'" // +kubebuilder:validation:XValidation:rule="!self.contains('..') && !self.contains('@{') && !self.startsWith('/') && !self.endsWith('/') && !self.contains('//')",messageExpression="'branch pattern ' + self + ' can match no branch name: git refuses .., @{, a leading or trailing / and //'" type BranchPattern string @@ -130,8 +138,9 @@ type GitBranchGrant struct { // Branches are the branches an admitted GitBranch may claim, as patterns matched against the // whole branch name. '*' matches any sequence of characters, '/' included, so "*" alone is every - // branch. '{namespace}' is the claiming GitBranch's namespace, and must be a whole path segment: - // "teams/{namespace}/*". There is no escaping, '?' or character class. + // branch. '{consumer.namespace}' and '{consumer.name}' are the claiming GitBranch's namespace and + // name, each a whole path segment: "teams/{consumer.namespace}/*". There is no escaping, '?' or + // character class. // +required // +listType=set // +kubebuilder:validation:MinItems=1 @@ -167,9 +176,10 @@ type SourceGrant struct { Selector *metav1.LabelSelector `json:"selector,omitempty"` // SourceNamespaces are the source-cluster namespaces the WatchRules feeding an admitted GitTarget - // may watch: exact names, '{namespace}' for the GitTarget's own namespace, or '*' for every - // namespace, which also allows rules[].sourceNamespace: "*". The list is the whole scope; the - // GitTarget's own namespace is in it only when listed. Omitted, no namespace. + // may watch: exact names, '{consumer.namespace}' or '{consumer.name}' for the namespace named like + // the GitTarget's namespace or name, or '*' for every namespace, which also allows + // rules[].sourceNamespace: "*". The list is the whole scope; the GitTarget's own namespace is in it + // only when listed. Omitted, no namespace. // +optional // +listType=set // +kubebuilder:validation:MinItems=1 @@ -215,8 +225,9 @@ type ApplierGrant struct { Selector *metav1.LabelSelector `json:"selector,omitempty"` // ApplierNamespaces are the source-cluster namespaces in which an admitted applier claim's - // Kustomization or Application may live: exact names, '{namespace}' for the applier claim's own - // namespace, or '*' for every namespace. The list is the whole scope. + // Kustomization or Application may live: exact names, '{consumer.namespace}' or '{consumer.name}' + // for the namespace named like the applier claim's namespace or name, or '*' for every namespace. + // The list is the whole scope. // +required // +listType=set // +kubebuilder:validation:MinItems=1 @@ -344,22 +355,29 @@ func describeAdmitted(sources []NamespaceGrant, holderNamespace string) string { return "admits " + strings.Join(parts, ", ") } -// expandScope replaces '{namespace}' in each scope entry with namespace, and returns the entries -// sorted and without duplicates. -func expandScope[S ~string](entries []S, namespace string) []string { +// expandConsumer replaces the consumer variables in s with consumer's namespace and name. +func expandConsumer(s string, consumer types.NamespacedName) string { + return strings.NewReplacer(ConsumerNamespaceVariable, consumer.Namespace, ConsumerNameVariable, consumer.Name). + Replace(s) +} + +// expandScope expands the consumer variables in each scope entry, and returns the entries sorted and +// without duplicates. +func expandScope[S ~string](entries []S, consumer types.NamespacedName) []string { out := make([]string, 0, len(entries)) for _, entry := range entries { - out = append(out, strings.ReplaceAll(string(entry), NamespaceVariable, namespace)) + out = append(out, expandConsumer(string(entry), consumer)) } slices.Sort(out) return slices.Compact(out) } -// MatchBranchPattern reports whether branch matches pattern for a GitBranch in namespace: the -// pattern is matched against the whole branch name, '*' matches any sequence of characters, '/' -// included, and '{namespace}' is namespace. Nothing else is special. -func MatchBranchPattern(pattern BranchPattern, namespace, branch string) bool { - return globMatch(strings.ReplaceAll(string(pattern), NamespaceVariable, namespace), branch) +// MatchBranchPattern reports whether branch matches pattern for the GitBranch consumer: the pattern +// is matched against the whole branch name, '*' matches any sequence of characters, '/' included, +// and '{consumer.namespace}' and '{consumer.name}' are consumer's namespace and name. Nothing else is +// special. +func MatchBranchPattern(pattern BranchPattern, consumer types.NamespacedName, branch string) bool { + return globMatch(expandConsumer(string(pattern), consumer), branch) } // globMatch matches name against a pattern in which '*' is the only special character, and matches @@ -393,24 +411,24 @@ func ClusterResourceIdentityFor(group, resource string) string { return resource + "." + group } -// GitBranchAdmission is what a GitProvider's allowedGitBranches give a GitBranch's namespace. +// GitBranchAdmission is what a GitProvider's allowedGitBranches give a GitBranch. // +kubebuilder:object:generate=false type GitBranchAdmission struct { - // Admitted reports whether some grant admits the namespace. + // Admitted reports whether some grant admits the GitBranch's namespace. Admitted bool - // Branches are the patterns the admitting grants give the namespace, '{namespace}' expanded. + // Branches are the patterns the admitting grants give the GitBranch, consumer variables expanded. Branches []string // Allowed reports whether the namespace is admitted and the branch matches one of Branches. Allowed bool } -// AdmitsGitBranch reports what spec.allowedGitBranches gives a GitBranch in namespace, by the labels -// that namespace carries in the control cluster, and whether it may claim branch. +// AdmitsGitBranch reports what spec.allowedGitBranches gives the GitBranch consumer, by the labels +// its namespace carries in the control cluster, and whether it may claim branch. func (p *GitProvider) AdmitsGitBranch( - namespace string, nsLabels map[string]string, branch string, + consumer types.NamespacedName, nsLabels map[string]string, branch string, ) (GitBranchAdmission, error) { grants := p.Spec.AllowedGitBranches - admitting, err := admitAll(sourcesOf(grants), p.Namespace, namespace, nsLabels) + admitting, err := admitAll(sourcesOf(grants), p.Namespace, consumer.Namespace, nsLabels) if err != nil || len(admitting) == 0 { return GitBranchAdmission{}, err } @@ -419,12 +437,12 @@ func (p *GitProvider) AdmitsGitBranch( for _, i := range admitting { for _, pattern := range grants[i].Branches { patterns = append(patterns, pattern) - if MatchBranchPattern(pattern, namespace, branch) { + if MatchBranchPattern(pattern, consumer, branch) { out.Allowed = true } } } - out.Branches = expandScope(patterns, namespace) + out.Branches = expandScope(patterns, consumer) return out, nil } @@ -445,14 +463,14 @@ func (b *GitBranch) DescribeAllowedGitTargets() string { return describeAdmitted(b.Spec.AllowedGitTargets, b.Namespace) } -// SourceScope is what a KubeProvider's allowedGitTargets give a GitTarget's namespace: whether it may -// mirror at all, and what. +// SourceScope is what a KubeProvider's allowedGitTargets give a GitTarget: whether it may mirror at +// all, and what. // +kubebuilder:object:generate=false type SourceScope struct { - // Admitted reports whether some grant admits the namespace. + // Admitted reports whether some grant admits the GitTarget's namespace. Admitted bool - // SourceNamespaces is the union of the admitting grants' sourceNamespaces, '{namespace}' expanded. - // "*" is every namespace. + // SourceNamespaces is the union of the admitting grants' sourceNamespaces, consumer variables + // expanded. "*" is every namespace. SourceNamespaces []string // ClusterResources is the union of the admitting grants' clusterResources. "*" is every // cluster-scoped resource. @@ -485,11 +503,13 @@ func scopeAllows(scope []string, value string) bool { return false } -// AdmitsGitTarget reports what spec.allowedGitTargets gives a GitTarget in namespace, by the labels -// that namespace carries in the control cluster. -func (p *KubeProvider) AdmitsGitTarget(namespace string, nsLabels map[string]string) (SourceScope, error) { +// AdmitsGitTarget reports what spec.allowedGitTargets gives the GitTarget consumer, by the labels its +// namespace carries in the control cluster. +func (p *KubeProvider) AdmitsGitTarget( + consumer types.NamespacedName, nsLabels map[string]string, +) (SourceScope, error) { grants := p.Spec.AllowedGitTargets - admitting, err := admitAll(sourcesOf(grants), p.Namespace, namespace, nsLabels) + admitting, err := admitAll(sourcesOf(grants), p.Namespace, consumer.Namespace, nsLabels) if err != nil || len(admitting) == 0 { return SourceScope{}, err } @@ -501,8 +521,8 @@ func (p *KubeProvider) AdmitsGitTarget(namespace string, nsLabels map[string]str } return SourceScope{ Admitted: true, - SourceNamespaces: expandScope(namespaces, namespace), - ClusterResources: expandScope(resources, namespace), + SourceNamespaces: expandScope(namespaces, consumer), + ClusterResources: expandScope(resources, consumer), }, nil } @@ -511,12 +531,12 @@ func (p *KubeProvider) DescribeAllowedGitTargets() string { return describeAdmitted(sourcesOf(p.Spec.AllowedGitTargets), p.Namespace) } -// ApplierScope is what a KubeProvider's allowedApplierClaims give an applier claim's namespace. +// ApplierScope is what a KubeProvider's allowedApplierClaims give an applier claim. // +kubebuilder:object:generate=false type ApplierScope struct { - // Admitted reports whether some grant admits the namespace. + // Admitted reports whether some grant admits the applier claim's namespace. Admitted bool - // ApplierNamespaces is the union of the admitting grants' applierNamespaces, '{namespace}' + // ApplierNamespaces is the union of the admitting grants' applierNamespaces, consumer variables // expanded. "*" is every namespace. ApplierNamespaces []string } @@ -526,11 +546,13 @@ func (s ApplierScope) AllowsApplierNamespace(namespace string) bool { return scopeAllows(s.ApplierNamespaces, namespace) } -// AdmitsApplierClaim reports what spec.allowedApplierClaims gives an applier claim in namespace, by -// the labels that namespace carries in the control cluster. -func (p *KubeProvider) AdmitsApplierClaim(namespace string, nsLabels map[string]string) (ApplierScope, error) { +// AdmitsApplierClaim reports what spec.allowedApplierClaims gives the applier claim consumer, by the +// labels its namespace carries in the control cluster. +func (p *KubeProvider) AdmitsApplierClaim( + consumer types.NamespacedName, nsLabels map[string]string, +) (ApplierScope, error) { grants := p.Spec.AllowedApplierClaims - admitting, err := admitAll(sourcesOf(grants), p.Namespace, namespace, nsLabels) + admitting, err := admitAll(sourcesOf(grants), p.Namespace, consumer.Namespace, nsLabels) if err != nil || len(admitting) == 0 { return ApplierScope{}, err } @@ -538,7 +560,7 @@ func (p *KubeProvider) AdmitsApplierClaim(namespace string, nsLabels map[string] for _, i := range admitting { namespaces = append(namespaces, grants[i].ApplierNamespaces...) } - return ApplierScope{Admitted: true, ApplierNamespaces: expandScope(namespaces, namespace)}, nil + return ApplierScope{Admitted: true, ApplierNamespaces: expandScope(namespaces, consumer)}, nil } // DescribeAllowedApplierClaims names the namespaces spec.allowedApplierClaims admits. diff --git a/api/v1alpha3/access_grants_test.go b/api/v1alpha3/access_grants_test.go index 7c806173..56ac490b 100644 --- a/api/v1alpha3/access_grants_test.go +++ b/api/v1alpha3/access_grants_test.go @@ -8,38 +8,50 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" ) +// consumer is the object asking for access, by namespace and name. +func consumer(namespace, name string) types.NamespacedName { + return types.NamespacedName{Namespace: namespace, Name: name} +} + func TestMatchBranchPattern(t *testing.T) { tests := []struct { pattern BranchPattern namespace string + name string branch string matches bool }{ - {"teams/{namespace}/*", "team-a", "teams/team-a/live", true}, - {"teams/{namespace}/*", "team-a", "teams/team-ab/live", false}, - {"teams/{namespace}/*", "team-a", "teams/team-a", false}, - {"teams/{namespace}/*", "team-a", "teams/team-a/", true}, - {"feature/*", "x", "feature/checkout/v2", true}, - {"feature/*", "x", "feature", false}, - {"*", "x", "main", true}, - {"*", "x", "teams/a/b", true}, - {"teams/*", "x", "teams/a/b", true}, - {"main", "x", "main", true}, - {"main", "x", "main-old", false}, - {"main", "x", "old-main", false}, - {"release/*/hotfix", "x", "release/1.2/hotfix", true}, - {"release/*/hotfix", "x", "release/1.2/hotfix/2", false}, - {"*-old", "x", "main-old", true}, - {"a*a", "x", "a", false}, - {"a*a", "x", "aa", true}, - {"{namespace}", "team-a", "team-a", true}, - {"{namespace}/*", "team-a", "team-b/x", false}, + {"teams/{consumer.namespace}/*", "team-a", "live", "teams/team-a/live", true}, + {"teams/{consumer.namespace}/*", "team-a", "live", "teams/team-ab/live", false}, + {"teams/{consumer.namespace}/*", "team-a", "live", "teams/team-a", false}, + {"teams/{consumer.namespace}/*", "team-a", "live", "teams/team-a/", true}, + {"feature/*", "x", "live", "feature/checkout/v2", true}, + {"feature/*", "x", "live", "feature", false}, + {"*", "x", "live", "main", true}, + {"*", "x", "live", "teams/a/b", true}, + {"teams/*", "x", "live", "teams/a/b", true}, + {"main", "x", "live", "main", true}, + {"main", "x", "live", "main-old", false}, + {"main", "x", "live", "old-main", false}, + {"release/*/hotfix", "x", "live", "release/1.2/hotfix", true}, + {"release/*/hotfix", "x", "live", "release/1.2/hotfix/2", false}, + {"*-old", "x", "live", "main-old", true}, + {"a*a", "x", "live", "a", false}, + {"a*a", "x", "live", "aa", true}, + {"{consumer.namespace}", "team-a", "live", "team-a", true}, + {"{consumer.namespace}/*", "team-a", "live", "team-b/x", false}, + {"teams/{consumer.namespace}/{consumer.name}", "team-a", "checkout", "teams/team-a/checkout", true}, + {"teams/{consumer.namespace}/{consumer.name}", "team-a", "checkout", "teams/team-a/payments", false}, + {"{consumer.name}/*", "team-a", "checkout", "checkout/feature", true}, + {"{consumer.name}/*", "team-a", "checkout", "team-a/feature", false}, + {"{namespace}", "team-a", "live", "team-a", false}, } for _, tt := range tests { t.Run(string(tt.pattern)+"~"+tt.branch, func(t *testing.T) { - assert.Equal(t, tt.matches, MatchBranchPattern(tt.pattern, tt.namespace, tt.branch)) + assert.Equal(t, tt.matches, MatchBranchPattern(tt.pattern, consumer(tt.namespace, tt.name), tt.branch)) }) } } @@ -138,35 +150,35 @@ func TestGitProvider_AdmitsGitBranch(t *testing.T) { Spec: GitProviderSpec{AllowedGitBranches: []GitBranchGrant{ {From: GrantFromSame, Branches: []BranchPattern{"main", "release/*"}}, {From: GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tenant": "true"}}, - Branches: []BranchPattern{"teams/{namespace}/*", "feature/*"}}, + Branches: []BranchPattern{"teams/{consumer.namespace}/*", "feature/*"}}, }}, } tenant := map[string]string{"tenant": "true"} - got, err := provider.AdmitsGitBranch("team-a", tenant, "teams/team-a/live") + got, err := provider.AdmitsGitBranch(consumer("team-a", "live"), tenant, "teams/team-a/live") require.NoError(t, err) assert.Equal(t, GitBranchAdmission{Admitted: true, Allowed: true, Branches: []string{"feature/*", "teams/team-a/*"}}, got) - got, err = provider.AdmitsGitBranch("team-a", tenant, "main") + got, err = provider.AdmitsGitBranch(consumer("team-a", "live"), tenant, "main") require.NoError(t, err) assert.True(t, got.Admitted) assert.False(t, got.Allowed, "main is the platform's") - got, err = provider.AdmitsGitBranch("team-a", tenant, "teams/team-b/live") + got, err = provider.AdmitsGitBranch(consumer("team-a", "live"), tenant, "teams/team-b/live") require.NoError(t, err) assert.False(t, got.Allowed, "another team's prefix") - got, err = provider.AdmitsGitBranch("team-c", nil, "teams/team-c/live") + got, err = provider.AdmitsGitBranch(consumer("team-c", "live"), nil, "teams/team-c/live") require.NoError(t, err) assert.False(t, got.Admitted, "no grant admits an unlabelled namespace") - got, err = provider.AdmitsGitBranch("platform", nil, "release/1.0") + got, err = provider.AdmitsGitBranch(consumer("platform", "live"), nil, "release/1.0") require.NoError(t, err) assert.True(t, got.Allowed) provider.Spec.AllowedGitBranches = nil - got, err = provider.AdmitsGitBranch("platform", nil, "main") + got, err = provider.AdmitsGitBranch(consumer("platform", "live"), nil, "main") require.NoError(t, err) assert.False(t, got.Admitted, "nothing is automatic, the provider's own namespace included") } @@ -177,7 +189,7 @@ func TestKubeProvider_AdmitsGitTarget(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "production"}, Spec: KubeProviderSpec{AllowedGitTargets: []SourceGrant{ {From: GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}, - SourceNamespaces: []ScopeNamespace{"{namespace}"}}, + SourceNamespaces: []ScopeNamespace{"{consumer.namespace}"}}, {From: GrantFromNames, Names: []GrantNamespace{"platform-audit"}, SourceNamespaces: []ScopeNamespace{"*"}, ClusterResources: []ClusterResourceIdentity{"*"}}, {From: GrantFromNames, Names: []GrantNamespace{"rbac-mirror"}, @@ -185,21 +197,21 @@ func TestKubeProvider_AdmitsGitTarget(t *testing.T) { }}, } - scope, err := provider.AdmitsGitTarget("team-a", tenant) + scope, err := provider.AdmitsGitTarget(consumer("team-a", "mirror"), tenant) require.NoError(t, err) assert.True(t, scope.Admitted) assert.True(t, scope.AllowsSourceNamespace("team-a")) assert.False(t, scope.AllowsSourceNamespace("kube-system")) - assert.False(t, scope.AllowsSourceNamespace("*"), "{namespace} does not allow a wildcard") + assert.False(t, scope.AllowsSourceNamespace("*"), "{consumer.namespace} does not allow a wildcard") assert.False(t, scope.AllowsClusterResource("", "nodes")) - scope, err = provider.AdmitsGitTarget("platform-audit", nil) + scope, err = provider.AdmitsGitTarget(consumer("platform-audit", "mirror"), nil) require.NoError(t, err) assert.True(t, scope.AllowsSourceNamespace("*")) assert.True(t, scope.AllowsSourceNamespace("kube-system")) assert.True(t, scope.AllowsAllClusterResources()) - scope, err = provider.AdmitsGitTarget("rbac-mirror", nil) + scope, err = provider.AdmitsGitTarget(consumer("rbac-mirror", "mirror"), nil) require.NoError(t, err) assert.True(t, scope.Admitted) assert.Empty(t, scope.SourceNamespaces, "clusterResources alone grants no namespace") @@ -208,7 +220,7 @@ func TestKubeProvider_AdmitsGitTarget(t *testing.T) { assert.False(t, scope.AllowsClusterResource("", "clusterroles"), "a bare name is the core group only") assert.False(t, scope.AllowsAllClusterResources()) - scope, err = provider.AdmitsGitTarget("platform", nil) + scope, err = provider.AdmitsGitTarget(consumer("platform", "mirror"), nil) require.NoError(t, err) assert.False(t, scope.Admitted, "the provider's own namespace needs a grant") } @@ -218,16 +230,16 @@ func TestKubeProvider_AdmitsGitTarget_UnionOfScopes(t *testing.T) { provider := &KubeProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform"}, Spec: KubeProviderSpec{AllowedGitTargets: []SourceGrant{ - {From: GrantFromAll, SourceNamespaces: []ScopeNamespace{"{namespace}"}}, + {From: GrantFromAll, SourceNamespaces: []ScopeNamespace{"{consumer.namespace}"}}, {From: GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"audit": "true"}}, SourceNamespaces: []ScopeNamespace{"*"}}, }}, } - scope, err := provider.AdmitsGitTarget("team-a", map[string]string{"audit": "true"}) + scope, err := provider.AdmitsGitTarget(consumer("team-a", "mirror"), map[string]string{"audit": "true"}) require.NoError(t, err) assert.Equal(t, []string{"*", "team-a"}, scope.SourceNamespaces) - scope, err = provider.AdmitsGitTarget("team-a", nil) + scope, err = provider.AdmitsGitTarget(consumer("team-a", "mirror"), nil) require.NoError(t, err) assert.Equal(t, []string{"team-a"}, scope.SourceNamespaces, "losing the label narrows the scope") } @@ -236,7 +248,9 @@ func TestKubeProvider_AdmitsApplierClaim(t *testing.T) { provider := &KubeProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform"}, Spec: KubeProviderSpec{ - AllowedGitTargets: []SourceGrant{{From: GrantFromAll, SourceNamespaces: []ScopeNamespace{"{namespace}"}}}, + AllowedGitTargets: []SourceGrant{ + {From: GrantFromAll, SourceNamespaces: []ScopeNamespace{"{consumer.namespace}"}}, + }, AllowedApplierClaims: []ApplierGrant{ { From: GrantFromNames, @@ -246,17 +260,39 @@ func TestKubeProvider_AdmitsApplierClaim(t *testing.T) { }, }, } - scope, err := provider.AdmitsApplierClaim("team-a", nil) + scope, err := provider.AdmitsApplierClaim(consumer("team-a", "checkout"), nil) require.NoError(t, err) assert.True(t, scope.AllowsApplierNamespace("flux-system")) assert.False(t, scope.AllowsApplierNamespace("team-a"), "an explicit list is the whole scope") - scope, err = provider.AdmitsApplierClaim("team-b", nil) + scope, err = provider.AdmitsApplierClaim(consumer("team-b", "checkout"), nil) require.NoError(t, err) assert.False(t, scope.Admitted, "admission to mirror does not admit to apply") assert.Equal(t, "admits team-a", provider.DescribeAllowedApplierClaims()) } +// TestKubeProvider_ConsumerNameInAScope: {consumer.name} is the consumer's own name, so one grant +// gives each GitTarget, and each applier claim, the namespace named after it. +func TestKubeProvider_ConsumerNameInAScope(t *testing.T) { + provider := &KubeProvider{ + ObjectMeta: metav1.ObjectMeta{Namespace: "platform"}, + Spec: KubeProviderSpec{ + AllowedGitTargets: []SourceGrant{{From: GrantFromAll, + SourceNamespaces: []ScopeNamespace{ConsumerNameVariable, ConsumerNamespaceVariable}}}, + AllowedApplierClaims: []ApplierGrant{{From: GrantFromAll, + ApplierNamespaces: []ScopeNamespace{ConsumerNameVariable}}}, + }, + } + scope, err := provider.AdmitsGitTarget(consumer("team-a", "payments"), nil) + require.NoError(t, err) + assert.Equal(t, []string{"payments", "team-a"}, scope.SourceNamespaces) + assert.False(t, scope.AllowsSourceNamespace("checkout"), "another GitTarget's name") + + appliers, err := provider.AdmitsApplierClaim(consumer("team-a", "checkout"), nil) + require.NoError(t, err) + assert.Equal(t, []string{"checkout"}, appliers.ApplierNamespaces) +} + func TestGitBranch_GitProviderKey(t *testing.T) { branch := &GitBranch{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a"}} branch.Spec.GitProviderRef.Name = "shared-repo" diff --git a/api/v1alpha3/fluxclaim_types.go b/api/v1alpha3/fluxclaim_types.go index ab1d5075..291fc4ab 100644 --- a/api/v1alpha3/fluxclaim_types.go +++ b/api/v1alpha3/fluxclaim_types.go @@ -119,15 +119,15 @@ type FluxClaimSource struct { Branch string `json:"branch,omitempty"` // Why information only (decision 2 of 2026-10-17 in docs/design/apply-trigger.md): one - // GitRepository for several Kustomizations is a normal setup, and a consumer no applier claim names + // GitRepository for several Kustomizations is a normal setup, and an object no applier claim names // applies a folder that has no promise of protection anyway. - // OtherConsumers are the other objects in the cluster that apply this GitRepository and that no + // AlsoAppliedBy are the other objects in the cluster that apply this GitRepository and that no // FluxClaim holds: Kustomizations, and HelmReleases whose chart comes from it. Each applies a // new artifact the moment it arrives. Information only: it never affects Ready. // +optional // +listType=atomic - OtherConsumers []meta.NamespacedObjectKindReference `json:"otherConsumers,omitempty"` + AlsoAppliedBy []meta.NamespacedObjectKindReference `json:"alsoAppliedBy,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/v1alpha3/gittarget_types.go b/api/v1alpha3/gittarget_types.go index ae47d834..aff29180 100644 --- a/api/v1alpha3/gittarget_types.go +++ b/api/v1alpha3/gittarget_types.go @@ -102,8 +102,8 @@ type GitTargetSpec struct { // apply the current tip after publishing the edits pending on the branch, which reverts the // refused edit and nothing that was waiting to be published. Only this // target's applier is asked. If its GitRepository has to fetch to reach the tip, the - // Kustomizations a FluxClaim holds stay suspended and ignore the new artifact; only the - // consumers no applier claim holds, listed in the applier claim's status.source.otherConsumers, apply it, as + // Kustomizations a FluxClaim holds stay suspended and ignore the new artifact; only the objects + // no applier claim holds, listed in the applier claim's status.source.alsoAppliedBy, apply it, as // they would for any push. // // It fires only for an edit to a document the folder ALREADY holds, in a write that removes no diff --git a/api/v1alpha3/watchrule_types.go b/api/v1alpha3/watchrule_types.go index 7fb6c996..98ba2594 100644 --- a/api/v1alpha3/watchrule_types.go +++ b/api/v1alpha3/watchrule_types.go @@ -111,8 +111,8 @@ type ResourceRule struct { // "*" is one cluster-wide list and watch rather than a set of per-namespace ones, so its bound // is the credential's own RBAC and nothing else. Every value, this rule's own namespace // included, needs a grant: the GitTarget's KubeProvider must admit the target's namespace in - // spec.allowedGitTargets with that namespace in sourceNamespaces ("{namespace}" is the target's - // own). "*" needs "*" there. + // spec.allowedGitTargets with that namespace in sourceNamespaces ("{consumer.namespace}" is the + // target's own). "*" needs "*" there. // // This changes only which namespace is WATCHED, never where objects are written: Git placement // follows each mirrored object's own namespace. diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index 0b3f4320..de50cc80 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -697,8 +697,8 @@ func (in *FluxClaimList) DeepCopyObject() runtime.Object { func (in *FluxClaimSource) DeepCopyInto(out *FluxClaimSource) { *out = *in out.GitRepository = in.GitRepository - if in.OtherConsumers != nil { - in, out := &in.OtherConsumers, &out.OtherConsumers + if in.AlsoAppliedBy != nil { + in, out := &in.AlsoAppliedBy, &out.AlsoAppliedBy *out = make([]meta.NamespacedObjectKindReference, len(*in)) copy(*out, *in) } diff --git a/charts/gitops-reverser/README.md b/charts/gitops-reverser/README.md index 41fa2277..bdd092d7 100644 --- a/charts/gitops-reverser/README.md +++ b/charts/gitops-reverser/README.md @@ -184,7 +184,7 @@ nodeSelector: | `controllerManager.branchBufferMaxSize` | Maximum in-memory event buffer per branch worker. Bounds the open commit window plus the writes retained for replay until a push succeeds. While the remote cannot be reached, retained writes that reach it close the worker to new writes, saves and resyncs until they are published; the watch delivers a refused event again | `8Mi` | | `controllerManager.commitRequestTTL` | How long a `CommitRequest` is kept once it finishes. Written onto the request as the `configbutler.ai/delete-after` annotation; remove or edit that to keep one. `"0"` writes none | `48h` | | `git.refreshInterval` | How often an idle `GitTarget` re-proves where its branch is on the Git remote. `"0"` disables it | `10m` | -| `applierAccess.flux` | Grant the operator `get`, `list` and `patch` on Flux `Kustomizations`, `get` and `patch` on `GitRepositories`, `list` on `HelmReleases` and `HelmCharts` (to find a source's other consumers), and `create` on `SelfSubjectAccessReviews`, so it can hold the `Kustomization` a `FluxClaim` names suspended and resume it for each apply | `false` | +| `applierAccess.flux` | Grant the operator `get`, `list` and `patch` on Flux `Kustomizations`, `get` and `patch` on `GitRepositories`, `list` on `HelmReleases` and `HelmCharts` (to find what else applies a source), and `create` on `SelfSubjectAccessReviews`, so it can hold the `Kustomization` a `FluxClaim` names suspended and resume it for each apply | `false` | | `applierAccess.argocd` | Grant the operator `get`, `update` and `patch` on Argo CD `Applications`, and `create` on `SelfSubjectAccessReviews`, so it can hold the automated sync of the `Application` an `ArgoCDClaim` names off and start its syncs | `false` | | `auditService.type` | Service type for the dedicated audit Service | `NodePort` | | `auditService.nodePort` | Fixed NodePort for the audit Service when `auditService.type=NodePort` | `30444` | diff --git a/charts/gitops-reverser/templates/applier-rbac.yaml b/charts/gitops-reverser/templates/applier-rbac.yaml index 14c47692..9d895128 100644 --- a/charts/gitops-reverser/templates/applier-rbac.yaml +++ b/charts/gitops-reverser/templates/applier-rbac.yaml @@ -2,7 +2,7 @@ # What GitOps Reverser needs to drive a Kustomization a FluxClaim holds: read and patch the # Kustomization and its GitRepository (it suspends and resumes the one and asks the other to fetch), # list the GitRepository's consumers (Kustomizations, and HelmReleases with the HelmCharts built -# from it; without it the applier claim lists none in status.source.otherConsumers), and create SelfSubjectAccessReviews so the operator can check it holds that +# from it; without it the applier claim lists none in status.source.alsoAppliedBy), and create SelfSubjectAccessReviews so the operator can check it holds that # access before relying on it. Separate from the manager role because it is optional and # names another project's API: an install without Flux grants nothing on it. See # docs/design/apply-trigger.md. diff --git a/charts/gitops-reverser/templates/kubeprovider.yaml b/charts/gitops-reverser/templates/kubeprovider.yaml index 23daeaa6..518fcd4f 100644 --- a/charts/gitops-reverser/templates/kubeprovider.yaml +++ b/charts/gitops-reverser/templates/kubeprovider.yaml @@ -31,8 +31,8 @@ spec: {{- $applierClaims := .Values.kubeProvider.allowedApplierClaims }} {{- if .Values.quickstart.enabled }} {{- $names := list .Values.quickstart.namespace }} - {{- $gitTargets = append $gitTargets (dict "from" "Names" "names" $names "sourceNamespaces" (list "{namespace}")) }} - {{- $applierClaims = append $applierClaims (dict "from" "Names" "names" $names "applierNamespaces" (list "{namespace}")) }} + {{- $gitTargets = append $gitTargets (dict "from" "Names" "names" $names "sourceNamespaces" (list "{consumer.namespace}")) }} + {{- $applierClaims = append $applierClaims (dict "from" "Names" "names" $names "applierNamespaces" (list "{consumer.namespace}")) }} {{- end }} {{- with $gitTargets }} allowedGitTargets: diff --git a/charts/gitops-reverser/values-playground.yaml b/charts/gitops-reverser/values-playground.yaml index dc842853..758b85a4 100644 --- a/charts/gitops-reverser/values-playground.yaml +++ b/charts/gitops-reverser/values-playground.yaml @@ -12,7 +12,7 @@ kubeProvider: allowedGitTargets: - from: All - sourceNamespaces: ["{namespace}"] + sourceNamespaces: ["{consumer.namespace}"] allowedApplierClaims: - from: All - applierNamespaces: ["{namespace}"] + applierNamespaces: ["{consumer.namespace}"] diff --git a/charts/gitops-reverser/values.yaml b/charts/gitops-reverser/values.yaml index 972816bf..f6b56227 100644 --- a/charts/gitops-reverser/values.yaml +++ b/charts/gitops-reverser/values.yaml @@ -125,8 +125,8 @@ git: # Application, synced for each apply. Off unless asked for, since one-way mirroring needs neither. applierAccess: # Grant get/patch on kustomize.toolkit.fluxcd.io Kustomizations and source.toolkit.fluxcd.io - # GitRepositories, list on Kustomizations, HelmReleases and HelmCharts (to find a - # GitRepository's other consumers), plus create on SelfSubjectAccessReviews (to check that access + # GitRepositories, list on Kustomizations, HelmReleases and HelmCharts (to find what else applies + # a GitRepository), plus create on SelfSubjectAccessReviews (to check that access # before relying on it). Cluster-wide, because an applier claim's applier can live in any namespace. flux: false # Grant get/update/patch on argoproj.io Applications (a sync is started by updating the @@ -373,8 +373,9 @@ kubeProvider: # Each grant has a `from` (Same, Names with names, Selector with a non-empty selector, or All) # and a scope: # allowedGitTargets[].sourceNamespaces which namespaces the WatchRules feeding an admitted - # GitTarget may watch: names, "{namespace}" for the - # GitTarget's own, or "*" + # GitTarget may watch: names, "{consumer.namespace}" or + # "{consumer.name}" for the GitTarget's namespace or + # name, or "*" # allowedGitTargets[].clusterResources which cluster-scoped types a ClusterWatchRule may mirror: # resource.group, a bare core resource, or "*" # allowedApplierClaims[].applierNamespaces where an admitted applier claim's Kustomization or @@ -385,7 +386,7 @@ kubeProvider: # allowedGitTargets: # - from: Selector # selector: {matchLabels: {platform.example.com/tenant: "true"}} - # sourceNamespaces: ["{namespace}"] + # sourceNamespaces: ["{consumer.namespace}"] # allowedApplierClaims: # - from: Names # names: [team-a] diff --git a/cmd/main_chart_kubeprovider_test.go b/cmd/main_chart_kubeprovider_test.go index 2330d1a2..4d6626d1 100644 --- a/cmd/main_chart_kubeprovider_test.go +++ b/cmd/main_chart_kubeprovider_test.go @@ -14,6 +14,7 @@ import ( corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/yaml" @@ -83,12 +84,18 @@ func TestChartKubeProvider_TheQuickstartGrantsItsNamespaceOnly(t *testing.T) { provider := renderLocalKubeProvider(t, "--set", "quickstart.enabled=true", "--set", "quickstart.gitProvider.url=https://example.com/repo.git") - scope, err := provider.AdmitsGitTarget("gitops-reverser-quickstart-demo", nil) + scope, err := provider.AdmitsGitTarget( + types.NamespacedName{Namespace: "gitops-reverser-quickstart-demo", Name: "mirror"}, + nil, + ) require.NoError(t, err) assert.True(t, scope.AllowsSourceNamespace("gitops-reverser-quickstart-demo")) assert.False(t, scope.AllowsSourceNamespace("kube-system")) assert.Empty(t, scope.ClusterResources) - appliers, err := provider.AdmitsApplierClaim("gitops-reverser-quickstart-demo", nil) + appliers, err := provider.AdmitsApplierClaim( + types.NamespacedName{Namespace: "gitops-reverser-quickstart-demo", Name: "checkout"}, + nil, + ) require.NoError(t, err) assert.True(t, appliers.AllowsApplierNamespace("gitops-reverser-quickstart-demo")) assert.False(t, appliers.AllowsApplierNamespace("flux-system")) @@ -103,13 +110,13 @@ func TestChartKubeProvider_TheQuickstartGrantsItsNamespaceOnly(t *testing.T) { func TestChartKubeProvider_ThePlaygroundGrantsEveryNamespaceItsOwn(t *testing.T) { provider := renderLocalKubeProvider(t, "-f", "../charts/gitops-reverser/values-playground.yaml") for _, namespace := range []string{"team-a", "gitops-reverser"} { - scope, err := provider.AdmitsGitTarget(namespace, nil) + scope, err := provider.AdmitsGitTarget(types.NamespacedName{Namespace: namespace, Name: "mirror"}, nil) require.NoError(t, err) assert.True(t, scope.AllowsSourceNamespace(namespace)) assert.False(t, scope.AllowsSourceNamespace("kube-system"), "no other namespace") assert.False(t, scope.AllowsSourceNamespace("*")) assert.Empty(t, scope.ClusterResources, "no default grants clusterResources") - appliers, err := provider.AdmitsApplierClaim(namespace, nil) + appliers, err := provider.AdmitsApplierClaim(types.NamespacedName{Namespace: namespace, Name: "checkout"}, nil) require.NoError(t, err) assert.Equal(t, []string{namespace}, appliers.ApplierNamespaces) } @@ -123,7 +130,7 @@ func TestChartKubeProvider_GrantsRenderAsWritten(t *testing.T) { allowedGitTargets: - from: Selector selector: {matchLabels: {platform.example.com/tenant: "true"}} - sourceNamespaces: ["{namespace}"] + sourceNamespaces: ["{consumer.namespace}"] allowedApplierClaims: - from: Names names: [team-a] diff --git a/cmd/main_chart_receiver_test.go b/cmd/main_chart_receiver_test.go index f06964d8..452a175c 100644 --- a/cmd/main_chart_receiver_test.go +++ b/cmd/main_chart_receiver_test.go @@ -164,7 +164,7 @@ func TestChartReceiver_MalformedBindAddressFailsTheRender(t *testing.T) { } // applierAccess.flux is off by default, and on it grants exactly what holding a Flux Kustomization -// needs (suspend and resume it, fetch its source, list the source's consumers), to the operator's own +// needs (suspend and resume it, fetch its source, list what else applies the source), to the operator's own // ServiceAccount. func TestChartApplierAccess_Flux(t *testing.T) { assert.Empty(t, helmTemplateDocs(t, "templates/applier-rbac.yaml"), "off unless asked for") diff --git a/config/crd/bases/configbutler.ai_fluxclaims.yaml b/config/crd/bases/configbutler.ai_fluxclaims.yaml index c5a77b63..a5609208 100644 --- a/config/crd/bases/configbutler.ai_fluxclaims.yaml +++ b/config/crd/bases/configbutler.ai_fluxclaims.yaml @@ -316,26 +316,9 @@ spec: eligible GitTarget references the applier claim. A GitTarget naming this applier claim must write the repository and branch it fetches. properties: - branch: - description: Branch is the GitRepository's spec.ref.branch. - type: string - gitRepository: - description: GitRepository is the Kustomization's spec.sourceRef, - its namespace defaulted. - properties: - name: - description: Name of the referent. - type: string - namespace: - description: Namespace of the referent, when not specified - it acts as LocalObjectReference. - type: string - required: - - name - type: object - otherConsumers: + alsoAppliedBy: description: |- - OtherConsumers are the other objects in the cluster that apply this GitRepository and that no + AlsoAppliedBy are the other objects in the cluster that apply this GitRepository and that no FluxClaim holds: Kustomizations, and HelmReleases whose chart comes from it. Each applies a new artifact the moment it arrives. Information only: it never affects Ready. items: @@ -363,6 +346,23 @@ spec: type: object type: array x-kubernetes-list-type: atomic + branch: + description: Branch is the GitRepository's spec.ref.branch. + type: string + gitRepository: + description: GitRepository is the Kustomization's spec.sourceRef, + its namespace defaulted. + properties: + name: + description: Name of the referent. + type: string + namespace: + description: Namespace of the referent, when not specified + it acts as LocalObjectReference. + type: string + required: + - name + type: object url: description: URL is the GitRepository's spec.url. type: string diff --git a/config/crd/bases/configbutler.ai_gitbranches.yaml b/config/crd/bases/configbutler.ai_gitbranches.yaml index 1cf52b18..ccb08e46 100644 --- a/config/crd/bases/configbutler.ai_gitbranches.yaml +++ b/config/crd/bases/configbutler.ai_gitbranches.yaml @@ -112,15 +112,18 @@ spec: maxLength: 63 type: string x-kubernetes-validations: - - message: 'names: ''{namespace}'' is refused, because it - would admit every referring namespace; use from: Same - for the namespace of the object holding the grant' - rule: self != '{namespace}' + - message: 'names: a variable is refused, because names are + matched against the consumer''s namespace and ''{consumer.namespace}'' + would admit every consumer; use from: All to admit every + namespace, or from: Same for the namespace of the object + holding the grant' + rule: self != '{consumer.namespace}' && self != '{consumer.name}' - message: 'names: ''*'' is refused; use from: All to admit every namespace' rule: self != '*' - message: names holds namespace names (DNS labels) - rule: self == '{namespace}' || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + rule: self == '{consumer.namespace}' || self == '{consumer.name}' + || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') maxItems: 64 minItems: 1 type: array diff --git a/config/crd/bases/configbutler.ai_gitproviders.yaml b/config/crd/bases/configbutler.ai_gitproviders.yaml index 252b2949..e3751202 100644 --- a/config/crd/bases/configbutler.ai_gitproviders.yaml +++ b/config/crd/bases/configbutler.ai_gitproviders.yaml @@ -77,29 +77,37 @@ spec: description: |- Branches are the branches an admitted GitBranch may claim, as patterns matched against the whole branch name. '*' matches any sequence of characters, '/' included, so "*" alone is every - branch. '{namespace}' is the claiming GitBranch's namespace, and must be a whole path segment: - "teams/{namespace}/*". There is no escaping, '?' or character class. + branch. '{consumer.namespace}' and '{consumer.name}' are the claiming GitBranch's namespace and + name, each a whole path segment: "teams/{consumer.namespace}/*". There is no escaping, '?' or + character class. items: description: |- BranchPattern is a pattern matched against a whole branch name: literal text, '*' for any - sequence of characters ('/' included), and '{namespace}' as a whole path segment. + sequence of characters ('/' included), and '{consumer.namespace}' and '{consumer.name}' as whole + path segments. maxLength: 255 minLength: 1 pattern: ^[^\s~^:?\[\\]*$ type: string x-kubernetes-validations: - messageExpression: '''branch pattern '' + self + '' references - unknown variable '' + self.replace(''{namespace}'', ''x'').find(''[{][^{}]*[}]'') - + ''; the only variable is {namespace}''' - rule: '!self.replace(''{namespace}'', ''x'').matches(''[{][^{}]*[}]'')' + unknown variable '' + self.replace(''{consumer.namespace}'', + ''x'').replace(''{consumer.name}'', ''x'').find(''[{][^{}]*[}]'') + + ''; the variables are {consumer.namespace} and {consumer.name}''' + rule: '!self.replace(''{consumer.namespace}'', ''x'').replace(''{consumer.name}'', + ''x'').matches(''[{][^{}]*[}]'')' - messageExpression: '''branch pattern '' + self + '' has - an unmatched brace; the only variable is {namespace}''' - rule: self.replace('{namespace}', 'x').matches('[{][^{}]*[}]') - || !self.replace('{namespace}', 'x').matches('[{}]') - - messageExpression: '''branch pattern '' + self + '': {namespace} - must be a whole path segment, at the start or after a - /, and at the end or before a /''' - rule: '!self.matches(''[^/][{]namespace[}]'') && !self.matches(''[{]namespace[}][^/]'')' + an unmatched brace; the variables are {consumer.namespace} + and {consumer.name}''' + rule: self.replace('{consumer.namespace}', 'x').replace('{consumer.name}', + 'x').matches('[{][^{}]*[}]') || !self.replace('{consumer.namespace}', + 'x').replace('{consumer.name}', 'x').matches('[{}]') + - messageExpression: '''branch pattern '' + self + '': {consumer.namespace} + and {consumer.name} must each be a whole path segment, + at the start or after a /, and at the end or before a + /''' + rule: '!self.matches(''[^/][{]consumer[.]name(space)?[}]'') + && !self.matches(''[{]consumer[.]name(space)?[}][^/]'')' - messageExpression: '''branch pattern '' + self + '' can match no branch name: git refuses .., @{, a leading or trailing / and //''' @@ -130,15 +138,18 @@ spec: maxLength: 63 type: string x-kubernetes-validations: - - message: 'names: ''{namespace}'' is refused, because it - would admit every referring namespace; use from: Same - for the namespace of the object holding the grant' - rule: self != '{namespace}' + - message: 'names: a variable is refused, because names are + matched against the consumer''s namespace and ''{consumer.namespace}'' + would admit every consumer; use from: All to admit every + namespace, or from: Same for the namespace of the object + holding the grant' + rule: self != '{consumer.namespace}' && self != '{consumer.name}' - message: 'names: ''*'' is refused; use from: All to admit every namespace' rule: self != '*' - message: names holds namespace names (DNS labels) - rule: self == '{namespace}' || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + rule: self == '{consumer.namespace}' || self == '{consumer.name}' + || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') maxItems: 64 minItems: 1 type: array diff --git a/config/crd/bases/configbutler.ai_gittargets.yaml b/config/crd/bases/configbutler.ai_gittargets.yaml index ff2aa6a4..96f0082c 100644 --- a/config/crd/bases/configbutler.ai_gittargets.yaml +++ b/config/crd/bases/configbutler.ai_gittargets.yaml @@ -377,8 +377,8 @@ spec: apply the current tip after publishing the edits pending on the branch, which reverts the refused edit and nothing that was waiting to be published. Only this target's applier is asked. If its GitRepository has to fetch to reach the tip, the - Kustomizations a FluxClaim holds stay suspended and ignore the new artifact; only the - consumers no applier claim holds, listed in the applier claim's status.source.otherConsumers, apply it, as + Kustomizations a FluxClaim holds stay suspended and ignore the new artifact; only the objects + no applier claim holds, listed in the applier claim's status.source.alsoAppliedBy, apply it, as they would for any push. It fires only for an edit to a document the folder ALREADY holds, in a write that removes no diff --git a/config/crd/bases/configbutler.ai_kubeproviders.yaml b/config/crd/bases/configbutler.ai_kubeproviders.yaml index 1845b7aa..c5a48721 100644 --- a/config/crd/bases/configbutler.ai_kubeproviders.yaml +++ b/config/crd/bases/configbutler.ai_kubeproviders.yaml @@ -83,18 +83,21 @@ spec: applierNamespaces: description: |- ApplierNamespaces are the source-cluster namespaces in which an admitted applier claim's - Kustomization or Application may live: exact names, '{namespace}' for the applier claim's own - namespace, or '*' for every namespace. The list is the whole scope. + Kustomization or Application may live: exact names, '{consumer.namespace}' or '{consumer.name}' + for the namespace named like the applier claim's namespace or name, or '*' for every namespace. + The list is the whole scope. items: description: |- - ScopeNamespace is one namespace in a source or applier scope: a namespace name, "{namespace}" for - the namespace of the object asking for access, or "*" for every namespace. + ScopeNamespace is one namespace in a source or applier scope: a namespace name, + "{consumer.namespace}" or "{consumer.name}" for the namespace of the same name as the consumer's + namespace or name, or "*" for every namespace. maxLength: 63 type: string x-kubernetes-validations: - - message: a namespace scope holds a namespace name, '{namespace}' - alone, or '*' alone - rule: self == '*' || self == '{namespace}' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + - message: a namespace scope holds a namespace name, '{consumer.namespace}' + or '{consumer.name}' alone, or '*' alone + rule: self == '*' || self == '{consumer.namespace}' || self + == '{consumer.name}' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') maxItems: 64 minItems: 1 type: array @@ -119,15 +122,18 @@ spec: maxLength: 63 type: string x-kubernetes-validations: - - message: 'names: ''{namespace}'' is refused, because it - would admit every referring namespace; use from: Same - for the namespace of the object holding the grant' - rule: self != '{namespace}' + - message: 'names: a variable is refused, because names are + matched against the consumer''s namespace and ''{consumer.namespace}'' + would admit every consumer; use from: All to admit every + namespace, or from: Same for the namespace of the object + holding the grant' + rule: self != '{consumer.namespace}' && self != '{consumer.name}' - message: 'names: ''*'' is refused; use from: All to admit every namespace' rule: self != '*' - message: names holds namespace names (DNS labels) - rule: self == '{namespace}' || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + rule: self == '{consumer.namespace}' || self == '{consumer.name}' + || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') maxItems: 64 minItems: 1 type: array @@ -252,15 +258,18 @@ spec: maxLength: 63 type: string x-kubernetes-validations: - - message: 'names: ''{namespace}'' is refused, because it - would admit every referring namespace; use from: Same - for the namespace of the object holding the grant' - rule: self != '{namespace}' + - message: 'names: a variable is refused, because names are + matched against the consumer''s namespace and ''{consumer.namespace}'' + would admit every consumer; use from: All to admit every + namespace, or from: Same for the namespace of the object + holding the grant' + rule: self != '{consumer.namespace}' && self != '{consumer.name}' - message: 'names: ''*'' is refused; use from: All to admit every namespace' rule: self != '*' - message: names holds namespace names (DNS labels) - rule: self == '{namespace}' || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + rule: self == '{consumer.namespace}' || self == '{consumer.name}' + || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') maxItems: 64 minItems: 1 type: array @@ -316,19 +325,22 @@ spec: sourceNamespaces: description: |- SourceNamespaces are the source-cluster namespaces the WatchRules feeding an admitted GitTarget - may watch: exact names, '{namespace}' for the GitTarget's own namespace, or '*' for every - namespace, which also allows rules[].sourceNamespace: "*". The list is the whole scope; the - GitTarget's own namespace is in it only when listed. Omitted, no namespace. + may watch: exact names, '{consumer.namespace}' or '{consumer.name}' for the namespace named like + the GitTarget's namespace or name, or '*' for every namespace, which also allows + rules[].sourceNamespace: "*". The list is the whole scope; the GitTarget's own namespace is in it + only when listed. Omitted, no namespace. items: description: |- - ScopeNamespace is one namespace in a source or applier scope: a namespace name, "{namespace}" for - the namespace of the object asking for access, or "*" for every namespace. + ScopeNamespace is one namespace in a source or applier scope: a namespace name, + "{consumer.namespace}" or "{consumer.name}" for the namespace of the same name as the consumer's + namespace or name, or "*" for every namespace. maxLength: 63 type: string x-kubernetes-validations: - - message: a namespace scope holds a namespace name, '{namespace}' - alone, or '*' alone - rule: self == '*' || self == '{namespace}' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + - message: a namespace scope holds a namespace name, '{consumer.namespace}' + or '{consumer.name}' alone, or '*' alone + rule: self == '*' || self == '{consumer.namespace}' || self + == '{consumer.name}' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') maxItems: 64 minItems: 1 type: array diff --git a/config/crd/bases/configbutler.ai_watchrules.yaml b/config/crd/bases/configbutler.ai_watchrules.yaml index c845a22e..0bcecb3c 100644 --- a/config/crd/bases/configbutler.ai_watchrules.yaml +++ b/config/crd/bases/configbutler.ai_watchrules.yaml @@ -225,8 +225,8 @@ spec: "*" is one cluster-wide list and watch rather than a set of per-namespace ones, so its bound is the credential's own RBAC and nothing else. Every value, this rule's own namespace included, needs a grant: the GitTarget's KubeProvider must admit the target's namespace in - spec.allowedGitTargets with that namespace in sourceNamespaces ("{namespace}" is the target's - own). "*" needs "*" there. + spec.allowedGitTargets with that namespace in sourceNamespaces ("{consumer.namespace}" is the + target's own). "*" needs "*" there. This changes only which namespace is WATCHED, never where objects are written: Git placement follows each mirrored object's own namespace. diff --git a/config/kubeprovider-local.yaml b/config/kubeprovider-local.yaml index d12ee1b1..777ffba6 100644 --- a/config/kubeprovider-local.yaml +++ b/config/kubeprovider-local.yaml @@ -16,8 +16,8 @@ spec: inCluster: {} allowedGitTargets: - from: All - sourceNamespaces: ["{namespace}"] + sourceNamespaces: ["{consumer.namespace}"] clusterResources: ["*"] allowedApplierClaims: - from: All - applierNamespaces: ["{namespace}"] + applierNamespaces: ["{consumer.namespace}"] diff --git a/config/rbac/flux-applier-role.yaml b/config/rbac/flux-applier-role.yaml index 5d1f89c8..6b83377b 100644 --- a/config/rbac/flux-applier-role.yaml +++ b/config/rbac/flux-applier-role.yaml @@ -2,7 +2,7 @@ # Kustomization and the GitRepository it applies from, suspend and resume the Kustomization and # stamp reconcile.fluxcd.io/requestedAt on either, list the consumers of the GitRepository # (Kustomizations, and HelmReleases with the HelmCharts built from it; without it the applier claim lists -# none in status.source.otherConsumers), and check that it may. +# none in status.source.alsoAppliedBy), and check that it may. # # It is kept OUT of role.yaml because no GitTarget needs it until one names an applier, and an # install that does not use Flux should not hold patch rights on Flux objects. Drop this pair when diff --git a/config/samples/kubeprovider.yaml b/config/samples/kubeprovider.yaml index 3e113805..029863b6 100644 --- a/config/samples/kubeprovider.yaml +++ b/config/samples/kubeprovider.yaml @@ -19,8 +19,8 @@ spec: name: prod-eu-1-kubeconfig # Nothing uses this provider without a grant, its own namespace included. Each grant says where it # admits from (Same, Names, Selector or All) and what: the source namespaces a GitTarget's - # WatchRules may watch ("{namespace}" is the GitTarget's own; "*" is every namespace, which - # `sourceNamespace: "*"` needs), and the cluster-scoped resources a ClusterWatchRule may mirror. + # WatchRules may watch ("{consumer.namespace}" is the GitTarget's own; "*" is every namespace, + # which `sourceNamespace: "*"` needs), and the cluster-scoped resources a ClusterWatchRule may mirror. # Here: every namespace labelled tier=trusted in the control cluster mirrors its own namespace, # and this namespace mirrors every namespace and the CRDs. allowedGitTargets: @@ -28,7 +28,7 @@ spec: selector: matchLabels: tier: trusted - sourceNamespaces: ["{namespace}"] + sourceNamespaces: ["{consumer.namespace}"] - from: Same sourceNamespaces: ["*"] clusterResources: [customresourcedefinitions.apiextensions.k8s.io] @@ -36,4 +36,4 @@ spec: # provider, and where it may live. Admission to mirror is no admission to apply. allowedApplierClaims: - from: Same - applierNamespaces: ["{namespace}"] + applierNamespaces: ["{consumer.namespace}"] diff --git a/config/samples/quickstart-gitprovider.yaml b/config/samples/quickstart-gitprovider.yaml index 3b4a172b..de950002 100644 --- a/config/samples/quickstart-gitprovider.yaml +++ b/config/samples/quickstart-gitprovider.yaml @@ -7,8 +7,8 @@ spec: # Replace with your own repository URL url: "git@github.com:YOUR_USERNAME/my-k8s-audit.git" # Which GitBranches may claim which branches through this provider. Nothing is automatic, this - # namespace included. '*' matches any branch; "teams/{namespace}/*" would give each namespace its - # own prefix. + # namespace included. '*' matches any branch; "teams/{consumer.namespace}/*" would give each + # namespace its own prefix. allowedGitBranches: - from: Same branches: ["*"] diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 6e78b0c6..6c9b73e6 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -41,18 +41,20 @@ kind: KubeProvider spec: allowedGitTargets: # was its own namespace, plus accessFrom, without allowCrossNamespace - from: Same - sourceNamespaces: ["{namespace}"] + sourceNamespaces: ["{consumer.namespace}"] allowedApplierClaims: - from: Same - applierNamespaces: ["{namespace}"] + applierNamespaces: ["{consumer.namespace}"] ``` - **Every `GitBranch`, `GitTarget` and applier claim needs a grant, also in the provider's own namespace.** Without one it reports `NamespaceNotAuthorized` and recovers once a grant admits it. - **`allowedBranches` became `allowedGitBranches`, which also says who may claim.** A `GitBranch.spec.gitProviderRef` may name a `GitProvider` in another namespace. Branch patterns match - the whole branch name, `*` crosses `/`, and `teams/{namespace}/*` gives each namespace its own - prefix. A branch outside the patterns is `BranchNotAllowed`. + the whole branch name, `*` crosses `/`, and `teams/{consumer.namespace}/*` gives each namespace its + own prefix. A branch outside the patterns is `BranchNotAllowed`. A grant's scope names the object + asking for access, the consumer, with `{consumer.namespace}` and `{consumer.name}`; see + [the consumer variables](configuration.md#the-consumer-variables). - **Who writes a branch is the `GitBranch`'s decision.** A `GitTarget` in another namespace writes through it only while its `spec.allowedGitTargets` admit that namespace. - **The chart's `local` provider grants nothing** unless `kubeProvider.allowedGitTargets` and @@ -63,7 +65,7 @@ spec: its `GitTarget` names (`clusterroles.rbac.authorization.k8s.io`, a bare `nodes`, or `"*"`), and one type outside them refuses the whole rule (`ClusterResourceNotAllowed`). No default grants any, the chart's included, so a `ClusterWatchRule` through `local` stops mirroring until a grant does. -- **A `WatchRule`'s own namespace needs a grant too:** `sourceNamespaces: ["{namespace}"]`. An +- **A `WatchRule`'s own namespace needs a grant too:** `sourceNamespaces: ["{consumer.namespace}"]`. An explicit list is the whole scope, and `sourceNamespace: "*"` needs `"*"`. - **An applier claim refused by `allowedApplierClaims` hands its applier back**, as one no eligible `GitTarget` references does. A `GitBranch` no grant admits any more gives up its branch after its @@ -87,8 +89,9 @@ including a platform and its tenants. `Application`'s automated sync), then delete their CRDs. After upgrading, recreate them under the new kinds with the same `spec`, and on each `GitTarget` replace `spec.applyTriggerRef` with `spec.applierClaimRef`. The `GitTarget` condition is `ApplierClaimReady` (reason -`ApplierClaimNotFound`), the reason `HeldByAnotherTrigger` is `HeldByAnotherClaim`, and the -finalizers are `configbutler.ai/flux-claim` and `configbutler.ai/argocd-claim`. The entries below +`ApplierClaimNotFound`), the reason `HeldByAnotherTrigger` is `HeldByAnotherClaim`, the +finalizers are `configbutler.ai/flux-claim` and `configbutler.ai/argocd-claim`, and a `FluxClaim`'s +`status.source.otherConsumers` is `status.source.alsoAppliedBy`. The entries below use the old names. See [applier claim](definitions.md#applying-back). ## An `ArgoCDTrigger` holds its `Application`'s automated sync off @@ -196,7 +199,7 @@ spec: allowedGitTargets: # see "Grants say who may use a provider or a branch" - from: Names names: [team-a] - sourceNamespaces: ["{namespace}"] + sourceNamespaces: ["{consumer.namespace}"] --- kind: GitTarget metadata: {name: apps, namespace: team-a} diff --git a/docs/bi-directional-argocd.md b/docs/bi-directional-argocd.md index 6644e81b..71f9ceed 100644 --- a/docs/bi-directional-argocd.md +++ b/docs/bi-directional-argocd.md @@ -141,7 +141,7 @@ spec: allowedGitTargets: - from: Names names: [team-a] - sourceNamespaces: ["{namespace}"] + sourceNamespaces: ["{consumer.namespace}"] allowedApplierClaims: - from: Names names: [team-a] diff --git a/docs/bi-directional-flux.md b/docs/bi-directional-flux.md index b4e55ac6..3d1d9506 100644 --- a/docs/bi-directional-flux.md +++ b/docs/bi-directional-flux.md @@ -91,7 +91,7 @@ spec: allowedGitTargets: - from: Names names: [team-a] - sourceNamespaces: ["{namespace}"] + sourceNamespaces: ["{consumer.namespace}"] allowedApplierClaims: - from: Names names: [team-a] @@ -199,7 +199,7 @@ covered by the apply window, but it is avoidable. An apply of an *older* revisio checked counts as failed, and GitOps Reverser asks again. **Shared sources.** Several `Kustomization`s may read one `GitRepository`. The applier claim lists its -source's other consumers in `status.source.otherConsumers`: the `Kustomization`s no `FluxClaim` +source's other users in `status.source.alsoAppliedBy`: the `Kustomization`s no `FluxClaim` holds and the `HelmRelease`s whose chart comes from it, directly or through a `HelmChart` their `chartRef` names. Each of them applies a new artifact the moment it arrives. The list is information only and never affects `Ready`: one source for `apps` and `infra` is a normal setup. @@ -390,7 +390,7 @@ with the identity of that `KubeProvider`. That identity needs: | --- | --- | --- | | `kustomize.toolkit.fluxcd.io` | `kustomizations` | `get`, `list`, `patch` | | `source.toolkit.fluxcd.io` | `gitrepositories` | `get`, `patch` | -| `helm.toolkit.fluxcd.io` | `helmreleases` | `list`, to find the source's other consumers; without it `status.source.otherConsumers` stays empty | +| `helm.toolkit.fluxcd.io` | `helmreleases` | `list`, to find what else applies the source; without it `status.source.alsoAppliedBy` stays empty | | `source.toolkit.fluxcd.io` | `helmcharts` | `list`, for the same reason | | `authorization.k8s.io` | `selfsubjectaccessreviews` | `create`, which every authenticated identity has | @@ -468,8 +468,8 @@ counted as `ambiguous`. **Reverting refused edits.** With `onRefusal: Reapply`, the target's `Kustomization` is resumed for one apply of the tip; no new commit is needed. If its `GitRepository` has to fetch to reach the -tip, the consumers GitOps Reverser holds ignore the new artifact, and one in -`status.source.otherConsumers` applies it, as it would for any push. +tip, the `Kustomization`s GitOps Reverser holds ignore the new artifact, and one in +`status.source.alsoAppliedBy` applies it, as it would for any push. ## Overriding by hand @@ -552,8 +552,8 @@ applied the tip is asked nothing. | --- | --- | | The [complete example](#a-complete-example) decodes strictly, reports `Configured=True`, binds the `GitTarget`, and an apply fetches and resumes the `Kustomization`; the manifest omits `spec.suspend` | [`flux_guide_test.go`](../internal/applier/flux_guide_test.go) | | Every fact of kustomize-controller v1.9.6 the lifecycle relies on, in a fake that applies them | [`fluxsim`](../internal/applier/fluxsim/fluxsim_test.go) | -| An operation is not finished when the revision is attempted; a token handled while suspended is no apply; a resume alone reverts a refusal; a suspend does not stop a running apply; the artifact is rechecked before the resume; a failed apply is suspended and Flux's requeue applies nothing; a shared source applies only the consumer asked | [`flux_lifecycle_test.go`](../internal/applier/flux_lifecycle_test.go), [`flux_consumers_test.go`](../internal/applier/flux_consumers_test.go) | -| Adoption, an external resume suspended again with an Event each time, `SuspendOwnedElsewhere`, the hand-back on deletion (a `KubeProvider` gone included), and the source's other consumers | [`fluxclaim_hold_test.go`](../internal/controller/fluxclaim_hold_test.go) | +| An operation is not finished when the revision is attempted; a token handled while suspended is no apply; a resume alone reverts a refusal; a suspend does not stop a running apply; the artifact is rechecked before the resume; a failed apply is suspended and Flux's requeue applies nothing; a shared source applies only the consumer asked | [`flux_lifecycle_test.go`](../internal/applier/flux_lifecycle_test.go), [`flux_applied_by_test.go`](../internal/applier/flux_applied_by_test.go) | +| Adoption, an external resume suspended again with an Event each time, `SuspendOwnedElsewhere`, the hand-back on deletion (a `KubeProvider` gone included), and what else applies the source | [`fluxclaim_hold_test.go`](../internal/controller/fluxclaim_hold_test.go) | | An applier claim nothing names reads nothing and is `Ready`; the first eligible `GitTarget` adopts and the last releases, suspending an apply in flight first; a reference returning mid-release holds again; the binding over several `GitTarget`s; an older applier claim not in line keeps none out, and a retiring holder stays first until its release is done; `ApplierNamespaceNotAllowed` | [`applierclaim_rule_test.go`](../internal/controller/applierclaim_rule_test.go) | | Every operation checks the target again (incarnation, branch, admission, consent) and the source binding before it resumes, records itself first, and never asks for something weaker; a stop mid-apply suspends the `Kustomization` | [`dispatcher_test.go`](../internal/applier/dispatcher_test.go) | | A failed attempt is decided again; each `Kustomization` keeps its own backoff; a shared one is asked once; appliers sharing a source apply one at a time; nothing is sent while no flush can be made; an undo of an edit in the apply window is mirrored, and the applier's own write is not | [`apply_attempt_test.go`](../internal/git/apply_attempt_test.go), [`apply_groups_test.go`](../internal/git/apply_groups_test.go), [`applier_claim_test.go`](../internal/git/applier_claim_test.go), [`apply_window_test.go`](../internal/git/apply_window_test.go) | diff --git a/docs/configuration.md b/docs/configuration.md index b71f8b66..1762616e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -201,7 +201,7 @@ it](images/config-platform-shared.excalidraw.svg) A platform team keeps one repository credential and one cluster connection in its namespace, `platform`. Namespace `team-a` carries the label `platform.example.com/tenant: "true"`. -1. The platform lets tenants claim `teams//*` and mirror their own namespace. +1. The platform lets tenants claim `teams//*` and mirror their own namespace. 2. Team A creates a `GitBranch` for `teams/team-a/live`, and lets `GitTarget`s from its own namespace write it. 3. Team A creates a `GitTarget` that references that branch and the platform's `KubeProvider`. @@ -219,7 +219,7 @@ spec: allowedGitBranches: - from: Selector selector: {matchLabels: {platform.example.com/tenant: "true"}} - branches: ["teams/{namespace}/*"] + branches: ["teams/{consumer.namespace}/*"] --- # platform: the cluster connection, and who may mirror which namespaces apiVersion: configbutler.ai/v1alpha3 @@ -230,7 +230,7 @@ spec: allowedGitTargets: - from: Selector selector: {matchLabels: {platform.example.com/tenant: "true"}} - sourceNamespaces: ["{namespace}"] + sourceNamespaces: ["{consumer.namespace}"] --- # team-a: the branch it claims, and who may write it apiVersion: configbutler.ai/v1alpha3 @@ -254,11 +254,11 @@ spec: | Request | Result | Because | |---|---|---| -| `team-a`'s `GitBranch` claims `teams/team-a/live` | Allowed | The provider's grant selects `team-a`, and `teams/{namespace}/*` expands to `teams/team-a/*` | +| `team-a`'s `GitBranch` claims `teams/team-a/live` | Allowed | The provider's grant selects `team-a`, and `teams/{consumer.namespace}/*` expands to `teams/team-a/*` | | `team-a`'s `GitBranch` claims `main` or `teams/team-b/live` | Refused, `BranchNotAllowed` | No grant gives `team-a` either branch | | `team-a`'s `GitTarget` writes through `team-a/live` | Allowed | `from: Same` on the `GitBranch` | | `team-b`'s `GitTarget` writes through `team-a/live` | Refused, until team A adds `from: Names, names: [team-b]` | The branch's owner decides who shares it | -| A `WatchRule` in `team-a` watches `team-a` | Allowed | `sourceNamespaces: ["{namespace}"]` | +| A `WatchRule` in `team-a` watches `team-a` | Allowed | `sourceNamespaces: ["{consumer.namespace}"]` | | A `WatchRule` in `team-a` watches `kube-system` | Refused, `SourceNamespaceNotAllowed` | The grant's scope is `team-a` only | | A `GitTarget` in `platform` mirrors through `production` | Refused, `NamespaceNotAuthorized` | No grant admits `platform`; its own namespace is not automatic | @@ -267,7 +267,7 @@ Two expressions name a namespace, and they name different ones: | Expression | Whose namespace | |---|---| | `from: Same` | The object **containing** the grant: for this `GitBranch`, `team-a`; for the providers, `platform` | -| `{namespace}` in a scope | The object **asking** for access: for `team-a`'s `GitBranch`, `team-a` | +| `{consumer.namespace}` in a scope | The **consumer**, the object asking for access: for `team-a`'s `GitBranch`, `team-a` | **Platform administrators manage provider grants and authorization labels. Branch owners manage writer grants on their branches.** That split is why team A can share its branch without asking @@ -316,7 +316,7 @@ follow. Admitting everyone takes `from: All`, a word a reviewer sees: `from: Sel | A grant list | No access | No access | Grants, combined by union | (not a value) | | `from` | Refused: required | (not a list) | `Same`, `Names`, `Selector` or `All` | Refused: use `All` | | `selector` (with `Selector`) | Refused | (not a list) | A label selector on control-cluster Namespaces | `selector: {}` is refused: use `from: All` | -| `names` (with `Names`) | Refused | Refused | Exact control-cluster namespace names; `{namespace}` is refused, because it would admit every referrer (use `from: Same`) | Refused: use `from: All` | +| `names` (with `Names`) | Refused | Refused | Exact control-cluster namespace names; a consumer variable is refused, because `{consumer.namespace}` would admit every consumer (use `from: All`, or `from: Same` for the grant's own namespace) | Refused: use `from: All` | | `allowedGitBranches[].branches` | Refused: required | Refused | Branch patterns | Every branch | | `allowedGitTargets[].sourceNamespaces` | No namespace | Refused | Exactly these source-cluster namespaces | Every namespace; also allows `rules[].sourceNamespace: "*"` | | `allowedGitTargets[].clusterResources` | No cluster-scoped resource | Refused | Exactly these, by identity | Every cluster-scoped resource, including CRDs installed later | @@ -325,29 +325,60 @@ follow. Admitting everyone takes `from: All`, a word a reviewer sees: `from: Sel A `KubeProvider` `allowedGitTargets` grant sets `sourceNamespaces`, `clusterResources`, or both; one that grants neither is refused. -### `{namespace}` and branch patterns +### The consumer variables -`{namespace}` in a scope always means the namespace, in the control cluster, of the object asking for -access: the `GitBranch`'s in `branches`, the `GitTarget`'s in `sourceNamespaces`, and the applier -claim's in `applierNamespaces`. In a source-namespace or applier scope it becomes a namespace of the -same name in the source cluster. The syntax is the one a [placement template](#template-variables) -uses, but the subject differs: there `{namespace}` is the mirrored resource's namespace, and can be -`_cluster`; here it is the referring object's, which always exists. A grant renders nothing, so -placement's `|fallback`, sanitizing and empty-segment dropping do not carry over. +The object asking for access is the **consumer**: the `GitBranch` for `allowedGitBranches`, the +`GitTarget` for `allowedGitTargets`, and the applier claim for `allowedApplierClaims`. One grant +serves many consumers, so its scope can name the consumer instead of listing each one: -A branch pattern is a small language of its own, matched against the whole branch name: +| Variable | Is | For `team-a`'s `GitTarget` `payments` | +|---|---|---| +| `{consumer.namespace}` | The consumer's namespace, in the control cluster | `team-a` | +| `{consumer.name}` | The consumer's name | `payments` | + +They are the only variables. Each is replaced with the consumer's value before the scope is +compared, and nothing else about them is special. A grant has no variable for its own namespace: +the grant sits on its object, so that namespace is known when the grant is written, and +`from: Same` admits it. + +**In `sourceNamespaces` and `applierNamespaces`** an entry is an exact name, one variable alone, or +`"*"` alone. The variable names a namespace **of the same name in the source cluster**: +`sourceNamespaces: ["{consumer.namespace}"]` lets each `GitTarget` mirror the source namespace that +shares its own namespace's name. For a `KubeProvider` that reaches another cluster, that is a +namespace over there that happens to share the name. `{consumer.name}` lets each consumer reach the +namespace named after it: + +```yaml +# Each GitTarget mirrors the source namespace named after it: team-a's GitTarget payments mirrors +# payments, and nothing else. +allowedGitTargets: + - from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} + sourceNamespaces: ["{consumer.name}"] +``` + +`{consumer.name}` puts the consumer's name in charge of what it reaches, so whoever may create +consumers in an admitted namespace may pick a name that matches any source namespace. Grant it only +to namespaces whose owners you would let mirror any namespace by that name. + +**In a branch pattern** both variables may appear, each as a whole path segment, and the pattern is +matched against the whole branch name: - **`*` matches any sequence of characters, `/` included, and the empty one.** `feature/*` matches `feature/checkout/v2`, and `*` alone is every branch. `main` does not match `main-old`. -- **`{namespace}` must be a whole path segment:** at the start or after a `/`, and at the end or - before a `/`. `teams/{namespace}/*` is valid; `teams/{namespace}*` is refused, because for `team-a` - it would also match `teams/team-ab/live`. +- **A variable is a whole path segment:** at the start or after a `/`, and at the end or before a + `/`. `teams/{consumer.namespace}/*` and `teams/{consumer.namespace}/{consumer.name}` are valid. + `teams/{consumer.namespace}*` is refused, because for `team-a` it would also match + `teams/team-ab/live`, and so is `{consumer.namespace}-{consumer.name}`. - **No escaping, `?` or character classes.** Git refuses `*`, `?` and `[` in a branch name, so a literal never needs escaping. - Any other `{...}` is refused when the object is applied, naming it: - `branch pattern teams/{ns}/* references unknown variable {ns}; the only variable is {namespace}`. + `branch pattern teams/{namespace}/* references unknown variable {namespace}; the variables are + {consumer.namespace} and {consumer.name}`. -`sourceNamespaces` and `applierNamespaces` take exact names, `{namespace}` alone, or `"*"` alone. +A [placement template](#template-variables) also has a `{namespace}`, and it means something else: +the mirrored resource's namespace, which is `_cluster` for a cluster-scoped resource. A grant +renders nothing, so placement's `|fallback`, sanitizing and empty-segment dropping do not apply. ### Cluster-scoped resources @@ -861,12 +892,12 @@ spec: allowedGitTargets: - from: Names names: [team-a] - sourceNamespaces: ["{namespace}"] + sourceNamespaces: ["{consumer.namespace}"] - from: Selector selector: matchLabels: gitops.configbutler.ai/source-access: "true" - sourceNamespaces: ["{namespace}"] + sourceNamespaces: ["{consumer.namespace}"] allowedApplierClaims: - from: Names names: [team-a] @@ -1852,7 +1883,7 @@ status: gitRepository: {namespace: flux-system, name: platform} url: https://github.com/acme/platform branch: main - otherConsumers: # what else applies this GitRepository, held by no FluxClaim + alsoAppliedBy: # what else applies this GitRepository, held by no FluxClaim - {kind: HelmRelease, namespace: platform, name: ingress} operation: phase: Succeeded # Requested | Running | Settling | Unresolved | Succeeded | Failed @@ -1873,7 +1904,7 @@ status: | `operation.suspendToken` | Flux only: the token of the suspend after the apply. Once it is handled, nothing that started before the suspend is still running | | `operation.appliedRevision` | The revision the applier reports it applied. It can differ from `commit` when the branch moved between the check and the apply | | `repairPending` | `true` while an apply reverted an API edit made after its flush: Git kept the edit, and the cluster runs the older value until the `Repair` | -| `source.otherConsumers` | Flux only: the `Kustomization`s and `HelmRelease`s that apply the same `GitRepository` and that no `FluxClaim` holds. Information only; it never affects `Ready` | +| `source.alsoAppliedBy` | Flux only: the `Kustomization`s and `HelmRelease`s that apply the same `GitRepository` and that no `FluxClaim` holds. Information only; it never affects `Ready` | A `Kustomization` resumed outside GitOps Reverser (`flux resume`) is suspended again and recorded as an `Unresolved` operation with no `reason` and no `commit`, which completes once the suspend is @@ -1935,7 +1966,7 @@ to this target's applier alone, and it goes out after a flush, so an allowed edi commit window is published before the apply and is not reverted with the refused one. An `Application` is asked to sync the tip. When a `Kustomization`'s `GitRepository` has to fetch to reach the tip, the `Kustomization`s a `FluxClaim` holds stay suspended and ignore the new artifact; -only the consumers no applier claim holds, listed in the applier claim's `status.source.otherConsumers`, apply +only the objects no applier claim holds, listed in the applier claim's `status.source.alsoAppliedBy`, apply it, as they would for any push. The [bi-directional guide](bi-directional.md#choosing-speconrefusal) covers when to choose it. @@ -2629,9 +2660,10 @@ Every item, the rule's own namespace included, is authorized by the grants of th `KubeProvider`: 1. a grant in its `spec.allowedGitTargets` admits the `GitTarget`'s namespace; and -2. those grants' `sourceNamespaces` hold the namespace the item asks for: its name, `"{namespace}"` - for the `GitTarget`'s own (which is the `WatchRule`'s), or `"*"`. An item asking for `"*"` needs - `"*"` there. +2. those grants' `sourceNamespaces` hold the namespace the item asks for: its name, + `"{consumer.namespace}"` for the `GitTarget`'s own (which is the `WatchRule`'s), + `"{consumer.name}"` for the one named after the `GitTarget`, or `"*"`. An item asking for `"*"` + needs `"*"` there. There is no third condition on the `GitTarget`. What a target may read from its source cluster is bounded by that cluster's RBAC for the provider's credential: a request the grants permit still diff --git a/docs/definitions.md b/docs/definitions.md index b0b751aa..c6bcfed5 100644 --- a/docs/definitions.md +++ b/docs/definitions.md @@ -155,6 +155,12 @@ reports `HeldByAnotherClaim`. `clusterResources`, `applierNamespaces`). Grants combine by union; an explicit scope is the whole scope. Not "permission" or "policy". +**Consumer.** The object a grant admits or refuses: the `GitBranch` for `allowedGitBranches`, the +`GitTarget` for `allowedGitTargets`, the applier claim for `allowedApplierClaims`. A grant's scope +names it with `{consumer.namespace}` and `{consumer.name}`. The grant's own object is its holder, +and `from: Same` is the holder's namespace, never the consumer's. Not "referrer", "requester" or +"caller". + **Parent branch.** The branch a write branch is created from, and compared against while the write branch does not exist on the remote: the `GitBranch`'s `spec.parentBranch`, or the remote's default branch when that is omitted. Say "parent branch"; a bare "parent" is fine where the subject is @@ -531,3 +537,4 @@ outage. | fence, fenced (for what an applier claim does to its applier) | suspend, held | the `Kustomization` is suspended, and the applier claim holds it; say which, and whether the suspend is handled | | trigger, apply trigger (for an applier claim) | applier claim | the resource does not declare when to apply, and "trigger" already names watch-plan, API-surface and Argo Events triggers | | quiet branch | flush | no apply waits for a branch with nothing pending; it goes after a flush | +| consumer (for what applies a `GitRepository`) | what applies it, `alsoAppliedBy` | consumer names the object a grant admits | diff --git a/docs/design/apply-trigger.md b/docs/design/apply-trigger.md index f208f635..3046bb52 100644 --- a/docs/design/apply-trigger.md +++ b/docs/design/apply-trigger.md @@ -865,12 +865,12 @@ adopted by one apply. | Retries are decided by the worker; recovered refusals, withdrawn consent and recreated targets are not retried; each applier keeps its own clock; one attempt for a shared applier; a stale attempt is decided again without backoff | [`apply_attempt_test.go`](../../internal/git/apply_attempt_test.go) | | The operation is recorded before the write that starts it, moves through its phases on the trigger's status, an attempt that asked nothing restores the record before it, and a withheld rollback makes a `Repair` due | [`apply_operation_test.go`](../../internal/git/apply_operation_test.go), [`status_writer_test.go`](../../internal/applier/status_writer_test.go) | | Every attempt rechecks targets, asks for nothing weaker than it was told, and resumes no `Kustomization` on another branch; a stop mid-apply suspends the `Kustomization` | [`dispatcher_test.go`](../../internal/applier/dispatcher_test.go), [`flux_test.go`](../../internal/applier/flux_test.go) | -| The suspended lifecycle against the source facts: an apply is not finished when attempted; a token handled while suspended is no apply; a resume alone reverts a refusal; a suspend does not stop a running apply; the artifact is rechecked before the resume; a failed apply is suspended and Flux's requeue applies nothing; a shared source applies only the consumer asked | [`flux_lifecycle_test.go`](../../internal/applier/flux_lifecycle_test.go), [`flux_consumers_test.go`](../../internal/applier/flux_consumers_test.go), [`fluxsim`](../../internal/applier/fluxsim/fluxsim_test.go) | +| The suspended lifecycle against the source facts: an apply is not finished when attempted; a token handled while suspended is no apply; a resume alone reverts a refusal; a suspend does not stop a running apply; the artifact is rechecked before the resume; a failed apply is suspended and Flux's requeue applies nothing; a shared source applies only the consumer asked | [`flux_lifecycle_test.go`](../../internal/applier/flux_lifecycle_test.go), [`flux_applied_by_test.go`](../../internal/applier/flux_applied_by_test.go), [`fluxsim`](../../internal/applier/fluxsim/fluxsim_test.go) | | Adoption, `SuspendOwnedElsewhere`, an external resume suspended again with an Event every time, the hand-back on deletion (a `ClusterProvider` gone included), and the source's other consumers | [`fluxtrigger_hold_test.go`](../../internal/controller/fluxclaim_hold_test.go) | | The rule: a trigger nothing names reads nothing and is `Ready`; the first eligible reference adopts and the last releases, suspending an apply in flight first; a reference returning mid-release holds again; a re-created `GitTarget` releases and re-adopts; the binding over several `GitTarget`s; an older trigger not in line keeps none out, and a retiring holder stays first until its release is done; a holder left unconfigured says the `Kustomization` is suspended; `ApplierNamespaceNotAllowed` for both kinds | [`applytrigger_rule_test.go`](../../internal/controller/applierclaim_rule_test.go) | | `allowAnySourceNamespace: true` is refused on create and on any spec change, a stored one included; `false` and its removal are accepted; a `WatchRule` that relied on it is refused naming the new field | `removed_allow_any_source_namespace_test.go` (removed with `ClusterProvider`), [`watchrule_source_namespace_test.go`](../../internal/controller/watchrule_source_namespace_test.go) | | A restarted worker follows an operation left in flight with its window open, restores `repairPending`, and follows an apply started elsewhere; one recorded but never resumed ends `Failed` | [`apply_recovery_test.go`](../../internal/git/apply_recovery_test.go), [`dispatcher_test.go`](../../internal/applier/dispatcher_test.go) | -| Appliers sharing a source have one operation at a time and take turns, across workers too; a shared source applies only the consumer asked | [`apply_groups_test.go`](../../internal/git/apply_groups_test.go), [`flux_consumers_test.go`](../../internal/applier/flux_consumers_test.go) | +| Appliers sharing a source have one operation at a time and take turns, across workers too; a shared source applies only the consumer asked | [`apply_groups_test.go`](../../internal/git/apply_groups_test.go), [`flux_applied_by_test.go`](../../internal/applier/flux_applied_by_test.go) | | An applier in another cluster is reached through its own `ClusterProvider`, behind that provider's `accessFrom` | [`dispatcher_test.go`](../../internal/applier/dispatcher_test.go), [`kubeprovider_admission_test.go`](../../internal/authz/kubeprovider_admission_test.go), [`applytrigger_controller_test.go`](../../internal/controller/applierclaim_controller_test.go) | | The applier's late write over a newer edit is withheld, and nothing that could be the edit is: a tie, and an undo that leaves an older applier entry, are mirrored; the apply itself is not mirrored, a delayed echo of the attempt before included; an applier entry older than the send is an edit; an applier write with no window open is late | [`apply_window_test.go`](../../internal/git/apply_window_test.go), [`last_field_managers_test.go`](../../internal/watch/last_field_managers_test.go) | | A newly named applier is caught up | [`apply_trigger_test.go`](../../internal/git/applier_claim_test.go) | diff --git a/docs/finished/provider-access-rules.md b/docs/finished/provider-access-rules.md index 4a5e9d25..2f47470a 100644 --- a/docs/finished/provider-access-rules.md +++ b/docs/finished/provider-access-rules.md @@ -720,6 +720,12 @@ page: `releaseDeadline`. A hand-back a denial began reads `Held=False`, reason `Revoking`, rather than `Releasing`, which is how a regrant knows to let it finish before adopting the applier again. A first cut marked it by the opening words of the `Releasing` message instead. +- **The scope variable is `{consumer.namespace}`, and `{consumer.name}` joined it.** This page + writes `{namespace}`. That collided with the placement template's `{namespace}`, the mirrored + resource's namespace, and did not say whose namespace it was among the three a grant involves. + The object a grant admits is now the consumer, and `{consumer.name}` lets one grant give each + consumer the source namespace, applier namespace or branch segment named after it. `{namespace}` + is refused as an unknown variable. - **A deleted `GitProvider` or `KubeProvider` is not a revocation.** The `GitBranch` keeps its claim, and the applier claim keeps its hold, as before. - **Watch-driven tests.** The drain, successor and two-candidate revocation tests, and the applier diff --git a/internal/applier/dispatcher_test.go b/internal/applier/dispatcher_test.go index d3373407..c7fbdbba 100644 --- a/internal/applier/dispatcher_test.go +++ b/internal/applier/dispatcher_test.go @@ -166,7 +166,7 @@ func kubeProvider(name string, admits ...string) *v1alpha3.KubeProvider { Spec: v1alpha3.KubeProviderSpec{ InCluster: &v1alpha3.KubeProviderInCluster{}, AllowedGitTargets: []v1alpha3.SourceGrant{{From: v1alpha3.GrantFromNames, Names: names, - SourceNamespaces: []v1alpha3.ScopeNamespace{v1alpha3.NamespaceVariable}}}, + SourceNamespaces: []v1alpha3.ScopeNamespace{v1alpha3.ConsumerNamespaceVariable}}}, // The test appliers live in flux-system and argocd, not in the applier claims' namespace. AllowedApplierClaims: []v1alpha3.ApplierGrant{{From: v1alpha3.GrantFromNames, Names: names, ApplierNamespaces: []v1alpha3.ScopeNamespace{"*"}}}, diff --git a/internal/applier/flux_consumers.go b/internal/applier/flux_applied_by.go similarity index 87% rename from internal/applier/flux_consumers.go rename to internal/applier/flux_applied_by.go index e72fb38f..07e5e060 100644 --- a/internal/applier/flux_consumers.go +++ b/internal/applier/flux_applied_by.go @@ -16,9 +16,10 @@ import ( "k8s.io/apimachinery/pkg/types" ) -// A GitRepository's consumers (docs/design/apply-trigger.md, "Shared sources"). A suspended -// Kustomization ignores a new artifact; one consumer that is not held applies each new artifact the -// moment it arrives, so a FluxClaim lists the ones no applier claim holds in its status. +// What else applies a GitRepository (docs/design/apply-trigger.md, "Shared sources"). A suspended +// Kustomization ignores a new artifact; an object applying the source that is not held applies each +// new artifact the moment it arrives, so a FluxClaim lists the ones no applier claim holds in its +// status.alsoAppliedBy. // A HelmRelease applies a GitRepository through a HelmChart that source-controller builds from it: // one helm-controller creates from the release's spec.chart, or one the release names in @@ -44,15 +45,15 @@ func helmChartGVR() schema.GroupVersionResource { func olderHelmReleaseVersions() []string { return []string{"v2beta2", "v2beta1"} } func olderHelmChartVersions() []string { return []string{"v1beta2"} } -// Consumers lists the objects in the cluster that apply the GitRepository source, sorted: +// AppliedBy lists the objects in the cluster that apply the GitRepository source, sorted: // Kustomizations whose spec.sourceRef names it, and HelmReleases whose chart is built from it, // through spec.chart.spec.sourceRef or through a HelmChart spec.chartRef names. Any failure is an // error, and then nothing is known: the caller must treat the source as shared. // // A kind the cluster does not serve has no objects. For HelmReleases that holds only when no // HelmChart is built from the source either: a release at a version not listed here would still -// apply through one, so then the consumers are unknown and that is an error. -func (f *Flux) Consumers( +// apply through one, so then what applies the source is unknown and that is an error. +func (f *Flux) AppliedBy( ctx context.Context, source types.NamespacedName, ) ([]fluxmeta.NamespacedObjectKindReference, error) { @@ -60,22 +61,22 @@ func (f *Flux) Consumers( if err != nil { return nil, err } - var consumers []fluxmeta.NamespacedObjectKindReference + var appliedBy []fluxmeta.NamespacedObjectKindReference for i := range kustomizations { if consumes(&kustomizations[i], []string{"spec", "sourceRef"}, source) { - consumers = append(consumers, kindReference(kindKustomization, &kustomizations[i])) + appliedBy = append(appliedBy, kindReference(kindKustomization, &kustomizations[i])) } } releases, err := f.helmReleasesOf(ctx, source) if err != nil { return nil, err } - consumers = append(consumers, releases...) - sort.Slice(consumers, func(i, j int) bool { - a, b := consumers[i], consumers[j] + appliedBy = append(appliedBy, releases...) + sort.Slice(appliedBy, func(i, j int) bool { + a, b := appliedBy[i], appliedBy[j] return a.Kind+"/"+a.Namespace+"/"+a.Name < b.Kind+"/"+b.Namespace+"/"+b.Name }) - return consumers, nil + return appliedBy, nil } // helmReleasesOf is the HelmReleases whose chart is built from the GitRepository source. @@ -98,19 +99,19 @@ func (f *Flux) helmReleasesOf( } return nil, nil } - var consumers []fluxmeta.NamespacedObjectKindReference + var appliedBy []fluxmeta.NamespacedObjectKindReference var viaChart []*unstructured.Unstructured for i := range releases { release := &releases[i] switch { case consumes(release, []string{"spec", "chart", "spec", "sourceRef"}, source): - consumers = append(consumers, kindReference(kindHelmRelease, release)) + appliedBy = append(appliedBy, kindReference(kindHelmRelease, release)) case namesHelmChart(release): viaChart = append(viaChart, release) } } if len(viaChart) == 0 { - return consumers, nil + return appliedBy, nil } charts, served, err := f.chartsBuiltFrom(ctx, source) if err != nil { @@ -122,10 +123,10 @@ func (f *Flux) helmReleasesOf( } for _, release := range viaChart { if charts[chartRefOf(release)] { - consumers = append(consumers, kindReference(kindHelmRelease, release)) + appliedBy = append(appliedBy, kindReference(kindHelmRelease, release)) } } - return consumers, nil + return appliedBy, nil } // chartsBuiltFrom is the HelmCharts whose spec.sourceRef names the GitRepository source; a diff --git a/internal/applier/flux_consumers_test.go b/internal/applier/flux_applied_by_test.go similarity index 87% rename from internal/applier/flux_consumers_test.go rename to internal/applier/flux_applied_by_test.go index 5bbe0961..11704fbf 100644 --- a/internal/applier/flux_consumers_test.go +++ b/internal/applier/flux_applied_by_test.go @@ -38,9 +38,9 @@ func helmReleaseObj(namespace, name, sourceNamespace string) *unstructured.Unstr }} } -// TestFluxConsumers_ListsEveryObjectApplyingTheSource: Kustomizations and HelmReleases, their source +// TestFluxAppliedBy_ListsEveryObjectApplyingTheSource: Kustomizations and HelmReleases, their source // namespace defaulted to their own, and nothing that applies another source. -func TestFluxConsumers_ListsEveryObjectApplyingTheSource(t *testing.T) { +func TestFluxAppliedBy_ListsEveryObjectApplyingTheSource(t *testing.T) { client := newSourceClient( kustomizationObj(fluxSystemNS, "checkout", gitRepoSourceRef("platform")), kustomizationObj("apps", "elsewhere", gitRepoSourceRef("platform")), @@ -51,14 +51,14 @@ func TestFluxConsumers_ListsEveryObjectApplyingTheSource(t *testing.T) { helmReleaseObj("apps", "podinfo", fluxSystemNS), helmReleaseObj(fluxSystemNS, "local", ""), ) - consumers, err := pinnedFlux(client).Consumers(context.Background(), platformKey) + appliedBy, err := pinnedFlux(client).AppliedBy(context.Background(), platformKey) require.NoError(t, err) assert.Equal(t, []fluxmeta.NamespacedObjectKindReference{ {Kind: kindHelmRelease, Namespace: "apps", Name: "podinfo"}, {Kind: kindHelmRelease, Namespace: fluxSystemNS, Name: "local"}, {Kind: kindKustomization, Namespace: "apps", Name: "across"}, {Kind: kindKustomization, Namespace: fluxSystemNS, Name: "checkout"}, - }, consumers) + }, appliedBy) } // helmChartObj is a HelmChart built from the GitRepository sourceName in its own namespace. @@ -117,9 +117,9 @@ func everyVersion(gvr schema.GroupVersionResource, older []string) []schema.Grou return gvrs } -// TestFluxConsumers_AHelmReleaseThroughAHelmChart is the fourth review's finding J: a HelmRelease +// TestFluxAppliedBy_AHelmReleaseThroughAHelmChart is the fourth review's finding J: a HelmRelease // whose spec.chartRef names a HelmChart built from the source applies it too. -func TestFluxConsumers_AHelmReleaseThroughAHelmChart(t *testing.T) { +func TestFluxAppliedBy_AHelmReleaseThroughAHelmChart(t *testing.T) { client := helmClient(nil, helmChartObj(fluxSystemNS, "podinfo", "platform"), helmChartObj(fluxSystemNS, "elsewhere", "another"), @@ -129,50 +129,50 @@ func TestFluxConsumers_AHelmReleaseThroughAHelmChart(t *testing.T) { chartRefRelease("apps", "missing-chart", kindHelmChart, "podinfo", ""), chartRefRelease("apps", "oci", "OCIRepository", "podinfo", fluxSystemNS), ) - consumers, err := pinnedFlux(client).Consumers(context.Background(), platformKey) + appliedBy, err := pinnedFlux(client).AppliedBy(context.Background(), platformKey) require.NoError(t, err) assert.Equal(t, []fluxmeta.NamespacedObjectKindReference{ {Kind: kindHelmRelease, Namespace: "apps", Name: "podinfo"}, {Kind: kindHelmRelease, Namespace: fluxSystemNS, Name: "local"}, - }, consumers) + }, appliedBy) } -// TestFluxConsumers_AClusterServingOnlyAnOlderHelmRelease: a Flux that serves HelmReleases only at +// TestFluxAppliedBy_AClusterServingOnlyAnOlderHelmRelease: a Flux that serves HelmReleases only at // v2beta2 has them listed there, not read as having none. -func TestFluxConsumers_AClusterServingOnlyAnOlderHelmRelease(t *testing.T) { +func TestFluxAppliedBy_AClusterServingOnlyAnOlderHelmRelease(t *testing.T) { release := helmReleaseObj("apps", "podinfo", fluxSystemNS) release.SetAPIVersion("helm.toolkit.fluxcd.io/v2beta2") client := helmClient([]schema.GroupVersionResource{helmReleaseGVR()}, release) - consumers, err := pinnedFlux(client).Consumers(context.Background(), platformKey) + appliedBy, err := pinnedFlux(client).AppliedBy(context.Background(), platformKey) require.NoError(t, err) assert.Equal(t, []fluxmeta.NamespacedObjectKindReference{ {Kind: kindHelmRelease, Namespace: "apps", Name: "podinfo"}, - }, consumers) + }, appliedBy) } -// TestFluxConsumers_NoHelmReleaseAPIIsNoneOnlyWithoutAChart: a cluster that serves HelmReleases at +// TestFluxAppliedBy_NoHelmReleaseAPIIsNoneOnlyWithoutAChart: a cluster that serves HelmReleases at // no version has none, unless a HelmChart is built from the source: then a release at some other -// version may apply it, and the consumers are unknown. -func TestFluxConsumers_NoHelmReleaseAPIIsNoneOnlyWithoutAChart(t *testing.T) { +// version may apply it, and the appliedBy are unknown. +func TestFluxAppliedBy_NoHelmReleaseAPIIsNoneOnlyWithoutAChart(t *testing.T) { unserved := everyVersion(helmReleaseGVR(), olderHelmReleaseVersions()) ks := kustomizationObj(fluxSystemNS, "checkout", gitRepoSourceRef("platform")) - consumers, err := pinnedFlux(helmClient(unserved, ks)).Consumers(context.Background(), platformKey) + appliedBy, err := pinnedFlux(helmClient(unserved, ks)).AppliedBy(context.Background(), platformKey) require.NoError(t, err, "no Helm at all: none") assert.Equal(t, []fluxmeta.NamespacedObjectKindReference{ {Kind: kindKustomization, Namespace: fluxSystemNS, Name: "checkout"}, - }, consumers) + }, appliedBy) _, err = pinnedFlux(helmClient(unserved, ks, helmChartObj(fluxSystemNS, "podinfo", "platform"))). - Consumers(context.Background(), platformKey) + AppliedBy(context.Background(), platformKey) require.Error(t, err, "a chart is built from the source, by a release that cannot be listed") } -// TestFluxProbe_ASharedSourceAppliesOnlyTheConsumerAsked is the third review's third finding: A and +// TestFluxProbe_ASharedSourceAppliesOnlyTheKustomizationAsked is the third review's third finding: A and // B share a GitRepository, and A's request fetched it, which woke B too, outside any window. With // both suspended, A's fetch and apply leave B alone: an edit to B's objects keeps its value. -func TestFluxProbe_ASharedSourceAppliesOnlyTheConsumerAsked(t *testing.T) { +func TestFluxProbe_ASharedSourceAppliesOnlyTheKustomizationAsked(t *testing.T) { a := fluxsim.OwnSuspend(t, kustomizationObj(fluxSystemNS, "checkout", gitRepoSourceRef("platform")), true, SuspendFieldManager, metav1.ManagedFieldsOperationApply) b := fluxsim.OwnSuspend(t, kustomizationObj(fluxSystemNS, "infra", gitRepoSourceRef("platform")), true, diff --git a/internal/authz/gitbranch_admission_test.go b/internal/authz/gitbranch_admission_test.go index 161091e8..c511efda 100644 --- a/internal/authz/gitbranch_admission_test.go +++ b/internal/authz/gitbranch_admission_test.go @@ -149,7 +149,7 @@ func TestGitBranchClaimAdmitted(t *testing.T) { Branches: []configv1alpha3.BranchPattern{"main"}}, configv1alpha3.GitBranchGrant{From: configv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}, - Branches: []configv1alpha3.BranchPattern{"teams/{namespace}/*", "feature/*"}}, + Branches: []configv1alpha3.BranchPattern{"teams/{consumer.namespace}/*", "feature/*"}}, ) namespaces := []client.Object{ &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testNS, Labels: tenant}}, @@ -169,8 +169,13 @@ func TestGitBranchClaimAdmitted(t *testing.T) { `(spec.allowedGitBranches); requested branch "main" is not allowed`}, {"a tenant may not claim another team's branch", claiming(testNS, "teams/team-b/live"), provider, ReasonBranchNotAllowed, `requested branch "teams/team-b/live" is not allowed`}, - {"{namespace} is a whole segment: team-a never matches team-ab", claiming(testNS, "teams/team-ab/live"), - provider, ReasonBranchNotAllowed, ""}, + { + "{consumer.namespace} is a whole segment: team-a never matches team-ab", + claiming(testNS, "teams/team-ab/live"), + provider, + ReasonBranchNotAllowed, + "", + }, {"an unlabelled namespace is not admitted at all", claiming("team-c", "teams/team-c/live"), provider, ReasonNamespaceNotAuthorized, `GitProvider platform/shared-repo admits platform, namespaces labelled ` + `platform.example.com/tenant=true in spec.allowedGitBranches; namespace "team-c" is not admitted`}, diff --git a/internal/authz/kubeprovider_admission.go b/internal/authz/kubeprovider_admission.go index 4d60301b..5e79f30e 100644 --- a/internal/authz/kubeprovider_admission.go +++ b/internal/authz/kubeprovider_admission.go @@ -105,7 +105,7 @@ func GitTargetSourceScope( if err != nil { return Decision{}, configv1alpha3.SourceScope{}, err } - scope, selErr := provider.AdmitsGitTarget(target.Namespace, nsLabels) + scope, selErr := provider.AdmitsGitTarget(client.ObjectKeyFromObject(target), nsLabels) if !scope.Admitted { return notAdmitted("KubeProvider", key, provider.DescribeAllowedGitTargets(), "spec.allowedGitTargets", target.Namespace, selErr), configv1alpha3.SourceScope{}, nil @@ -165,7 +165,7 @@ func GitBranchClaimAdmitted( return Decision{}, err } key := client.ObjectKeyFromObject(provider) - admission, selErr := provider.AdmitsGitBranch(branch.Namespace, nsLabels, branch.Spec.Branch) + admission, selErr := provider.AdmitsGitBranch(client.ObjectKeyFromObject(branch), nsLabels, branch.Spec.Branch) switch { case !admission.Admitted: return notAdmitted("GitProvider", key, provider.DescribeAllowedGitBranches(), "spec.allowedGitBranches", @@ -202,7 +202,7 @@ func ApplierClaimAdmitted( if err != nil { return Decision{}, err } - scope, selErr := provider.AdmitsApplierClaim(claim.GetNamespace(), nsLabels) + scope, selErr := provider.AdmitsApplierClaim(client.ObjectKeyFromObject(claim), nsLabels) if !scope.Admitted { return notAdmitted("KubeProvider", key, provider.DescribeAllowedApplierClaims(), "spec.allowedApplierClaims", claim.GetNamespace(), selErr), nil diff --git a/internal/authz/kubeprovider_admission_test.go b/internal/authz/kubeprovider_admission_test.go index 7bb39dd5..3be59c34 100644 --- a/internal/authz/kubeprovider_admission_test.go +++ b/internal/authz/kubeprovider_admission_test.go @@ -68,7 +68,7 @@ func grantNames(namespaces ...string) []configv1alpha3.GrantNamespace { // mirrorFrom is a grant that admits namespaces by name to mirror their own namespace. func mirrorFrom(namespaces ...string) configv1alpha3.SourceGrant { return configv1alpha3.SourceGrant{From: configv1alpha3.GrantFromNames, Names: grantNames(namespaces...), - SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.NamespaceVariable}} + SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable}} } func namespaceLabeled(labels map[string]string) *corev1.Namespace { @@ -123,7 +123,7 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { { name: "from: Same admits the provider's own namespace", objects: []client.Object{ownNamespace(configv1alpha3.SourceGrant{From: configv1alpha3.GrantFromSame, - SourceNamespaces: []configv1alpha3.ScopeNamespace{"{namespace}"}}), namespaceLabeled(nil)}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}}), namespaceLabeled(nil)}, target: ownTarget, wantAllowed: true, }, @@ -163,7 +163,7 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { name: "a Selector grant admits a namespace by its labels", objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.SourceGrant{ From: configv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}, - SourceNamespaces: []configv1alpha3.ScopeNamespace{"{namespace}"}}), namespaceLabeled(tenant)}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}}), namespaceLabeled(tenant)}, target: targetIn("prod-eu-1"), wantAllowed: true, }, @@ -171,7 +171,7 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { name: "a Selector grant does not admit a namespace without the labels", objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.SourceGrant{ From: configv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}, - SourceNamespaces: []configv1alpha3.ScopeNamespace{"{namespace}"}}), namespaceLabeled(nil)}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}}), namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), wantReason: ReasonNamespaceNotAuthorized, wantMessage: "admits namespaces labelled tenant=true", @@ -197,7 +197,9 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { { name: "from: All admits every namespace", objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.SourceGrant{ - From: configv1alpha3.GrantFromAll, SourceNamespaces: []configv1alpha3.ScopeNamespace{"{namespace}"}}), + From: configv1alpha3.GrantFromAll, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}, + }), namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), wantAllowed: true, @@ -311,7 +313,7 @@ func appliersFrom(scope []configv1alpha3.ScopeNamespace, namespaces ...string) c // TestApplierClaimAdmitted: an applier claim needs its own grant, and the applier must live in the // scope that grant gives. An explicit list is the whole scope. func TestApplierClaimAdmitted(t *testing.T) { - own := []configv1alpha3.ScopeNamespace{"{namespace}"} + own := []configv1alpha3.ScopeNamespace{"{consumer.namespace}"} provider := func(grants ...configv1alpha3.ApplierGrant) *configv1alpha3.KubeProvider { p := providerNamed("hub", mirrorFrom(testNS)) p.Spec.AllowedApplierClaims = grants @@ -327,9 +329,9 @@ func TestApplierClaimAdmitted(t *testing.T) { }{ {"admitted to mirror is not admitted to apply", provider(), testNS, false, ReasonNamespaceNotAuthorized, `KubeProvider platform/hub admits no namespace in spec.allowedApplierClaims; namespace "team-a"`}, - {"{namespace} admits an applier in the claim's own namespace", + {"{consumer.namespace} admits an applier in the claim's own namespace", provider(appliersFrom(own, testNS)), testNS, true, "", ""}, - {"{namespace} refuses an applier elsewhere", provider(appliersFrom(own, testNS)), "flux-system", false, + {"{consumer.namespace} refuses an applier elsewhere", provider(appliersFrom(own, testNS)), "flux-system", false, ReasonApplierNamespaceNotAllowed, `KubeProvider platform/hub lets namespace "team-a" hold appliers in ` + `"team-a" (spec.allowedApplierClaims); Kustomization flux-system/apps is in namespace "flux-system"`}, {"an explicit list refuses the claim's own namespace", @@ -369,9 +371,12 @@ func TestApplierClaimAdmitted(t *testing.T) { func TestApplierClaimAdmitted_OwnNamespaceNeedsAGrant(t *testing.T) { provider := &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: testNS}, - Spec: configv1alpha3.KubeProviderSpec{AllowedGitTargets: []configv1alpha3.SourceGrant{{ - From: configv1alpha3.GrantFromSame, SourceNamespaces: []configv1alpha3.ScopeNamespace{"{namespace}"}, - }}}, + Spec: configv1alpha3.KubeProviderSpec{AllowedGitTargets: []configv1alpha3.SourceGrant{ + { + From: configv1alpha3.GrantFromSame, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}, + }, + }}, } claim := &configv1alpha3.FluxClaim{ ObjectMeta: metav1.ObjectMeta{Namespace: testNS, Name: "apps"}, @@ -394,7 +399,7 @@ func TestApplierClaimAdmitted_OwnNamespaceNeedsAGrant(t *testing.T) { // TestApplierAdmitted: asking an applier to apply needs both admissions, always. Mirroring and // applying are granted separately, also on one KubeProvider from one namespace. func TestApplierAdmitted(t *testing.T) { - own := []configv1alpha3.ScopeNamespace{"{namespace}"} + own := []configv1alpha3.ScopeNamespace{"{consumer.namespace}"} withAppliers := func(p *configv1alpha3.KubeProvider, grants ...configv1alpha3.ApplierGrant) *configv1alpha3.KubeProvider { p.Spec.AllowedApplierClaims = grants return p diff --git a/internal/authz/source_namespace.go b/internal/authz/source_namespace.go index b3fc9933..60b123a5 100644 --- a/internal/authz/source_namespace.go +++ b/internal/authz/source_namespace.go @@ -99,9 +99,9 @@ func (s ResolvedSourceScope) Fingerprint() string { // policy TIGHTENED after a rule was accepted revokes it. // // An item may watch a namespace only while the KubeProvider's spec.allowedGitTargets admit the -// GitTarget's namespace and give it that namespace in sourceNamespaces: a name, '{namespace}' for the -// GitTarget's own namespace (which is the WatchRule's), or "*" for every namespace, which is also -// what an item asking for "*" needs. The rule's own namespace is in scope only when a grant lists it. +// GitTarget's namespace and give it that namespace in sourceNamespaces: a name, '{consumer.namespace}' +// for the GitTarget's own namespace (which is the WatchRule's), '{consumer.name}' for the one named +// after the GitTarget, or "*" for every namespace, which is also what an item asking for "*" needs. The rule's own namespace is in scope only when a grant lists it. // The source credential's RBAC stays the hard maximum: a request this gate permits still fails 403 // at the apiserver if the credential cannot read that namespace. // diff --git a/internal/authz/source_namespace_test.go b/internal/authz/source_namespace_test.go index 23713a7a..cc3e5cc6 100644 --- a/internal/authz/source_namespace_test.go +++ b/internal/authz/source_namespace_test.go @@ -86,7 +86,7 @@ func snKubeProvider(scope ...configv1alpha3.ScopeNamespace) *configv1alpha3.Kube } const ( - own = configv1alpha3.ScopeNamespace(configv1alpha3.NamespaceVariable) + own = configv1alpha3.ScopeNamespace(configv1alpha3.ConsumerNamespaceVariable) all = configv1alpha3.ScopeNamespace("*") ) @@ -113,7 +113,7 @@ func TestResolveWatchRuleSourceScope(t *testing.T) { says string }{ { - name: "an omitted item watches the rule's own namespace, granted by {namespace}", + name: "an omitted item watches the rule's own namespace, granted by {consumer.namespace}", items: []string{""}, scope: []configv1alpha3.ScopeNamespace{own}, admitted: true, @@ -146,7 +146,7 @@ func TestResolveWatchRuleSourceScope(t *testing.T) { namespaces: [][]string{{snSourceNS}}, }, { - name: "a named namespace is refused under {namespace}", + name: "a named namespace is refused under {consumer.namespace}", items: []string{snSourceNS}, scope: []configv1alpha3.ScopeNamespace{own}, admitted: false, @@ -160,7 +160,7 @@ func TestResolveWatchRuleSourceScope(t *testing.T) { says: `"*" is one cluster-wide list and watch, which is the empty namespace`, }, { - name: "the wildcard is refused under {namespace}", + name: "the wildcard is refused under {consumer.namespace}", items: []string{snWildcard}, scope: []configv1alpha3.ScopeNamespace{own}, admitted: false, diff --git a/internal/controller/access_grants_admission_test.go b/internal/controller/access_grants_admission_test.go index eeb08776..6b524eb0 100644 --- a/internal/controller/access_grants_admission_test.go +++ b/internal/controller/access_grants_admission_test.go @@ -64,19 +64,23 @@ var _ = Describe("Access grants at admission", func() { } It("accepts the branch patterns the grammar allows", func() { - accepted(gitProvider(branches("main", "release/*", "teams/{namespace}/*", "{namespace}", "*", - "feature/*/v2"))) + accepted(gitProvider(branches("main", "release/*", "teams/{consumer.namespace}/*", "{consumer.namespace}", + "teams/{consumer.namespace}/{consumer.name}", "{consumer.name}/*", "*", "feature/*/v2"))) }) - It("refuses {namespace} that is not a whole path segment", func() { - refused(gitProvider(branches("teams/{namespace}*")), "must be a whole path segment") - refused(gitProvider(branches("teams/x{namespace}/live")), "must be a whole path segment") + It("refuses a consumer variable that is not a whole path segment", func() { + refused(gitProvider(branches("teams/{consumer.namespace}*")), "must each be a whole path segment") + refused(gitProvider(branches("teams/x{consumer.namespace}/live")), "must each be a whole path segment") + refused(gitProvider(branches("teams/{consumer.namespace}-{consumer.name}")), + "must each be a whole path segment") + refused(gitProvider(branches("{consumer.namespace}{consumer.name}")), "must each be a whole path segment") }) - It("refuses an unknown variable, naming it and the one that exists", func() { - refused(gitProvider(branches("teams/{ns}/*")), - "branch pattern teams/{ns}/* references unknown variable {ns}; the only variable is {namespace}") - refused(gitProvider(branches("teams/{namespace")), "unmatched brace") + It("refuses an unknown variable, naming it and the ones that exist", func() { + refused(gitProvider(branches("teams/{namespace}/*")), + "branch pattern teams/{namespace}/* references unknown variable {namespace}; "+ + "the variables are {consumer.namespace} and {consumer.name}") + refused(gitProvider(branches("teams/{consumer.namespace")), "unmatched brace") }) It("refuses what no branch name can hold", func() { @@ -114,14 +118,16 @@ var _ = Describe("Access grants at admission", func() { refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same"}), "sets sourceNamespaces, clusterResources, or both") accepted( - kubeProvider("allowedGitTargets", map[string]any{"from": "Same", "sourceNamespaces": []any{"{namespace}"}}), + kubeProvider("allowedGitTargets", map[string]any{"from": "Same", + "sourceNamespaces": []any{"{consumer.namespace}", "{consumer.name}"}}), ) accepted(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", "clusterResources": []any{"clusterroles.rbac.authorization.k8s.io", "nodes", "*"}})) }) - It("refuses {namespace} in names, pointing to from: Same, and * pointing to from: All", func() { - refused(gitBranch(map[string]any{"from": "Names", "names": []any{"{namespace}"}}), "use from: Same") + It("refuses a variable in names, pointing to from: All and Same, and * pointing to from: All", func() { + refused(gitBranch(map[string]any{"from": "Names", "names": []any{"{consumer.namespace}"}}), "use from: All") + refused(gitBranch(map[string]any{"from": "Names", "names": []any{"{consumer.name}"}}), "or from: Same") refused(gitBranch(map[string]any{"from": "Names", "names": []any{"*"}}), "use from: All") refused(gitBranch(map[string]any{"from": "Names", "names": []any{"Team_A"}}), "namespace names") }) @@ -139,11 +145,14 @@ var _ = Describe("Access grants at admission", func() { accepted(gitBranch(map[string]any{"from": "All"}, map[string]any{"from": "Same"})) }) - It("refuses a scope entry that is neither a name, {namespace} nor *", func() { + It("refuses a scope entry that is neither a name, a consumer variable alone, nor *", func() { + alone := "'{consumer.namespace}' or '{consumer.name}' alone, or '*' alone" refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", - "sourceNamespaces": []any{"team-{namespace}"}}), "'{namespace}' alone, or '*' alone") + "sourceNamespaces": []any{"team-{consumer.namespace}"}}), alone) + refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", + "sourceNamespaces": []any{"{namespace}"}}), alone) refused(kubeProvider("allowedApplierClaims", map[string]any{"from": "Same", - "applierNamespaces": []any{"flux-*"}}), "'{namespace}' alone, or '*' alone") + "applierNamespaces": []any{"flux-*"}}), alone) refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", "clusterResources": []any{"cluster*"}}), "clusterResources[0]") }) diff --git a/internal/controller/applierclaim_controller.go b/internal/controller/applierclaim_controller.go index 6681e191..e08d0a52 100644 --- a/internal/controller/applierclaim_controller.go +++ b/internal/controller/applierclaim_controller.go @@ -248,7 +248,7 @@ func (r *ApplierClaimReconciler) holdFlux( } held := r.holdKustomization(ctx, st, claim) if check.checked { - r.publishConsumers(ctx, claim) + r.publishAlsoAppliedBy(ctx, claim) } return held, nil case configured.Reason == applier.ReasonNotReferenced: diff --git a/internal/controller/applierclaim_rule_test.go b/internal/controller/applierclaim_rule_test.go index 8941d79a..19483c02 100644 --- a/internal/controller/applierclaim_rule_test.go +++ b/internal/controller/applierclaim_rule_test.go @@ -439,7 +439,7 @@ func TestApplierClaimRule_AGitTargetEventRerunsTheClaimsItNames(t *testing.T) { // TestApplierClaimRule_AnApplierInAnotherNamespaceNeedsItInTheGrant is B1: a namespace a KubeProvider // admits may suspend and apply an applier in another namespace only when the applierNamespaces its -// spec.allowedApplierClaims give it include that namespace; '{namespace}' covers the applier claim's own. +// spec.allowedApplierClaims give it include that namespace; '{consumer.namespace}' covers the applier claim's own. // Refused, the applier claim never asks the applier's cluster, and holds nothing. func TestApplierClaimRule_AnApplierInAnotherNamespaceNeedsItInTheGrant(t *testing.T) { for _, kind := range []string{configbutleraiv1alpha3.ApplierClaimKindFlux, configbutleraiv1alpha3.ApplierClaimKindArgoCD} { @@ -452,7 +452,7 @@ func TestApplierClaimRule_AnApplierInAnotherNamespaceNeedsItInTheGrant(t *testin wantStatus: metav1.ConditionFalse, wantReason: authz.ReasonApplierNamespaceNotAllowed, }, "another namespace, granted": {granted: true, wantStatus: metav1.ConditionTrue, wantReason: ReasonSucceeded}, - "its own namespace, which '{namespace}' grants": { + "its own namespace, which '{consumer.namespace}' grants": { sameNamespace: true, wantStatus: metav1.ConditionTrue, wantReason: ReasonSucceeded, }, } { @@ -479,7 +479,7 @@ func TestApplierClaimRule_AnApplierInAnotherNamespaceNeedsItInTheGrant(t *testin // applierNamespaceReconciler is a reconciler of kind over the applier claim team-a/checkout, whose // applier is in team-a when sameNamespace is set and in flux-system or argocd otherwise, with the // KubeProvider claimLocalProvider giving applierNamespaces "*" when granted is set, and only -// '{namespace}' otherwise. It counts the lookups of the applier's cluster. +// '{consumer.namespace}' otherwise. It counts the lookups of the applier's cluster. func applierNamespaceReconciler( t *testing.T, kind string, sameNamespace, granted bool, ) (*ApplierClaimReconciler, *int) { @@ -504,7 +504,7 @@ func applierNamespaceReconciler( provider := admitting(claimLocalProvider, "team-a") if !granted { provider.Spec.AllowedApplierClaims = applierGrants( - []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.NamespaceVariable}, "team-a") + []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.ConsumerNamespaceVariable}, "team-a") } r := claimReconciler(claimTestClient(t, claim, provider), kind, applierFakeSource(t, appliers...), nil, true) calls := new(int) diff --git a/internal/controller/fluxclaim_hold.go b/internal/controller/fluxclaim_hold.go index 99cbdb81..505b7b72 100644 --- a/internal/controller/fluxclaim_hold.go +++ b/internal/controller/fluxclaim_hold.go @@ -905,27 +905,27 @@ func (r *ApplierClaimReconciler) event(object client.Object, eventType, reason, } } -// publishConsumers sets the applier claim's status.source.otherConsumers: the consumers of its GitRepository -// in the applier's cluster, other than its own Kustomization, that no FluxClaim on the same -// KubeProvider holds. A listing that fails publishes none. Information only (decision 2): it never +// publishAlsoAppliedBy sets the applier claim's status.source.alsoAppliedBy: the objects applying its +// GitRepository in the applier's cluster, other than its own Kustomization, that no FluxClaim on the +// same KubeProvider holds. A listing that fails publishes none. Information only (decision 2): it never // affects Ready. -func (r *ApplierClaimReconciler) publishConsumers(ctx context.Context, claim *configbutleraiv1alpha3.FluxClaim) { +func (r *ApplierClaimReconciler) publishAlsoAppliedBy(ctx context.Context, claim *configbutleraiv1alpha3.FluxClaim) { source := claim.Status.Source if source == nil || r.ApplierClients == nil { return } - source.OtherConsumers = nil + source.AlsoAppliedBy = nil ctx, cancel := context.WithTimeout(ctx, applierCheckTimeout) defer cancel() flux, err := r.fluxClient(ctx, claim) if err != nil { return } - consumers, err := flux.Consumers(ctx, k8stypes.NamespacedName{ + appliedBy, err := flux.AppliedBy(ctx, k8stypes.NamespacedName{ Namespace: source.GitRepository.Namespace, Name: source.GitRepository.Name, }) if err != nil { - logf.FromContext(ctx).V(1).Info("cannot list the GitRepository's consumers", + logf.FromContext(ctx).V(1).Info("cannot list what applies the GitRepository", "gitRepository", source.GitRepository.Namespace+"/"+source.GitRepository.Name, "error", err.Error()) return } @@ -933,13 +933,13 @@ func (r *ApplierClaimReconciler) publishConsumers(ctx context.Context, claim *co if err != nil { return } - for _, consumer := range consumers { - key := k8stypes.NamespacedName{Namespace: consumer.Namespace, Name: consumer.Name} + for _, applying := range appliedBy { + key := k8stypes.NamespacedName{Namespace: applying.Namespace, Name: applying.Name} itself := key == claim.Applier() - if consumer.Kind == configbutleraiv1alpha3.ApplierKindKustomization && (itself || others[key]) { + if applying.Kind == configbutleraiv1alpha3.ApplierKindKustomization && (itself || others[key]) { continue } - source.OtherConsumers = append(source.OtherConsumers, consumer) + source.AlsoAppliedBy = append(source.AlsoAppliedBy, applying) } } diff --git a/internal/controller/fluxclaim_hold_test.go b/internal/controller/fluxclaim_hold_test.go index 5477edd6..706d8191 100644 --- a/internal/controller/fluxclaim_hold_test.go +++ b/internal/controller/fluxclaim_hold_test.go @@ -456,9 +456,9 @@ func kustomizationManagers(ks *unstructured.Unstructured) []string { return managers } -// TestFluxClaim_PublishesTheSourcesOtherConsumers is decision 2: the consumers of the +// TestFluxClaim_PublishesWhatElseAppliesTheSource is decision 2: the objects applying the // GitRepository that no FluxClaim holds are listed, information only. -func TestFluxClaim_PublishesTheSourcesOtherConsumers(t *testing.T) { +func TestFluxClaim_PublishesWhatElseAppliesTheSource(t *testing.T) { objs := applierFluxObjects() infra := objs[0].(*unstructured.Unstructured).DeepCopy() infra.SetName("infra") @@ -478,7 +478,7 @@ func TestFluxClaim_PublishesTheSourcesOtherConsumers(t *testing.T) { assert.Equal(t, []meta.NamespacedObjectKindReference{ {Kind: "HelmRelease", Namespace: "apps", Name: "podinfo"}, {Kind: "Kustomization", Namespace: "flux-system", Name: "infra"}, - }, source.OtherConsumers, "itself is not another consumer") + }, source.AlsoAppliedBy, "it does not list itself") assert.True(t, conditionIsTrue(f.claim(t).Status.Conditions, ConditionTypeReady), "information only") // The fourth review's finding C: an applier claim holds its Kustomization only once it says so @@ -498,7 +498,7 @@ func TestFluxClaim_PublishesTheSourcesOtherConsumers(t *testing.T) { setClaimCondition(t, f.r, "infra", suspended) _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) - assert.Equal(t, infraOther, f.claim(t).Status.Source.OtherConsumers, + assert.Equal(t, infraOther, f.claim(t).Status.Source.AlsoAppliedBy, "an applier claim that is %s does not hold its Kustomization", suspended.Reason) } @@ -508,14 +508,14 @@ func TestFluxClaim_PublishesTheSourcesOtherConsumers(t *testing.T) { _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) assert.Equal(t, []meta.NamespacedObjectKindReference{{Kind: "HelmRelease", Namespace: "apps", Name: "podinfo"}}, - f.claim(t).Status.Source.OtherConsumers, "a Kustomization another applier claim holds is not listed") + f.claim(t).Status.Source.AlsoAppliedBy, "a Kustomization another applier claim holds is not listed") require.NoError(t, f.sim.Client.Resource(k8sschema.GroupVersionResource{ Group: "helm.toolkit.fluxcd.io", Version: "v2", Resource: "helmreleases", }).Namespace("apps").Delete(context.Background(), "podinfo", metav1.DeleteOptions{})) _, err = f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) - assert.Empty(t, f.claim(t).Status.Source.OtherConsumers, "every consumer is held") + assert.Empty(t, f.claim(t).Status.Source.AlsoAppliedBy, "everything applying the source is held") } // setClaimCondition publishes a condition on the FluxClaim team-a/name. diff --git a/internal/controller/gitbranch_access_test.go b/internal/controller/gitbranch_access_test.go index 51b374a6..6c489096 100644 --- a/internal/controller/gitbranch_access_test.go +++ b/internal/controller/gitbranch_access_test.go @@ -211,7 +211,7 @@ func TestGitBranch_ANamespaceLabelLossRevokesASelectorGrant(t *testing.T) { provider := branchProvider("platform", "shared-repo") provider.Spec.AllowedGitBranches = []configbutleraiv1alpha3.GitBranchGrant{{ From: configbutleraiv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}, - Branches: []configbutleraiv1alpha3.BranchPattern{"teams/{namespace}/*", "main"}, + Branches: []configbutleraiv1alpha3.BranchPattern{"teams/{consumer.namespace}/*", "main"}, }} namespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-a", Labels: tenant}} f := newGitBranchFixture(t, provider, namespace, tenantBranch("team-a", time.Hour)) diff --git a/internal/controller/gittarget_gitbranch_test.go b/internal/controller/gittarget_gitbranch_test.go index 26d3ce3f..cb11bd4c 100644 --- a/internal/controller/gittarget_gitbranch_test.go +++ b/internal/controller/gittarget_gitbranch_test.go @@ -250,7 +250,7 @@ func TestReconcile_ACrossNamespaceGitBranchWritesWithItsProvidersCredential(t *t platform := branchProvider("platform", "acme") platform.Spec.AllowedGitBranches[0] = configbutleraiv1alpha3.GitBranchGrant{ From: configbutleraiv1alpha3.GrantFromNames, Names: grantNames("team-a"), - Branches: []configbutleraiv1alpha3.BranchPattern{"teams/{namespace}/*"}, + Branches: []configbutleraiv1alpha3.BranchPattern{"teams/{consumer.namespace}/*"}, } decoy := branchProvider("team-a", "acme") decoy.UID = "uid-decoy" diff --git a/internal/controller/gittarget_shared_branch_test.go b/internal/controller/gittarget_shared_branch_test.go index 13480a97..abc58b3a 100644 --- a/internal/controller/gittarget_shared_branch_test.go +++ b/internal/controller/gittarget_shared_branch_test.go @@ -49,7 +49,7 @@ func sharedProviderReconciler(t *testing.T, objects ...ctrlclient.Object) *GitTa // The KubeProvider every GitTarget here mirrors through admits every namespace. local := admitting(claimLocalProvider) local.Spec.AllowedGitTargets = []configbutleraiv1alpha3.SourceGrant{{From: configbutleraiv1alpha3.GrantFromAll, - SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.NamespaceVariable}}} + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.ConsumerNamespaceVariable}}} objects = append(objects, local) for _, ns := range []string{"platform", "team-b"} { objects = append(objects, &configbutleraiv1alpha3.GitProvider{ diff --git a/internal/controller/gittarget_source_cluster_test.go b/internal/controller/gittarget_source_cluster_test.go index 8321e135..715e53c1 100644 --- a/internal/controller/gittarget_source_cluster_test.go +++ b/internal/controller/gittarget_source_cluster_test.go @@ -122,7 +122,9 @@ func TestCheckSourceAuthorization(t *testing.T) { provider := func(grant configbutleraiv1alpha3.SourceGrant) *configbutleraiv1alpha3.KubeProvider { p := remoteKubeProvider("kc", "") p.Namespace = "platform" - grant.SourceNamespaces = []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.NamespaceVariable} + grant.SourceNamespaces = []configbutleraiv1alpha3.ScopeNamespace{ + configbutleraiv1alpha3.ConsumerNamespaceVariable, + } p.Spec.AllowedGitTargets = []configbutleraiv1alpha3.SourceGrant{grant} return p } @@ -256,7 +258,7 @@ func TestCheckSourceAuthorization(t *testing.T) { func TestCheckSourceAuthorization_OwnNamespaceAndAuditRoute(t *testing.T) { own := inClusterKubeProvider("local") own.Spec.AllowedGitTargets = []configbutleraiv1alpha3.SourceGrant{{From: configbutleraiv1alpha3.GrantFromSame, - SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.NamespaceVariable}}} + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.ConsumerNamespaceVariable}}} target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: kpNamespace}, Spec: configbutleraiv1alpha3.GitTargetSpec{ diff --git a/internal/controller/grant_helpers_test.go b/internal/controller/grant_helpers_test.go index e037d863..ba3a7a12 100644 --- a/internal/controller/grant_helpers_test.go +++ b/internal/controller/grant_helpers_test.go @@ -20,7 +20,7 @@ func grantNames(namespaces ...string) []configbutleraiv1alpha3.GrantNamespace { func mirrorGrants(namespaces ...string) []configbutleraiv1alpha3.SourceGrant { return []configbutleraiv1alpha3.SourceGrant{{ From: configbutleraiv1alpha3.GrantFromNames, Names: grantNames(namespaces...), - SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.NamespaceVariable}, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.ConsumerNamespaceVariable}, ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}, }} } diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 6160a668..e71a611d 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -205,11 +205,15 @@ var _ = BeforeSuite(func() { ObjectMeta: metav1.ObjectMeta{Namespace: testKubeProviderNamespace, Name: testKubeProviderName}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, - AllowedGitTargets: []configbutleraiv1alpha3.SourceGrant{{ - From: configbutleraiv1alpha3.GrantFromAll, - SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.NamespaceVariable}, - ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}, - }}, + AllowedGitTargets: []configbutleraiv1alpha3.SourceGrant{ + { + From: configbutleraiv1alpha3.GrantFromAll, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{ + configbutleraiv1alpha3.ConsumerNamespaceVariable, + }, + ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}, + }, + }, AllowedApplierClaims: []configbutleraiv1alpha3.ApplierGrant{{ From: configbutleraiv1alpha3.GrantFromAll, ApplierNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"*"}, diff --git a/internal/controller/watchrule_source_namespace_test.go b/internal/controller/watchrule_source_namespace_test.go index b0050866..a1c9da19 100644 --- a/internal/controller/watchrule_source_namespace_test.go +++ b/internal/controller/watchrule_source_namespace_test.go @@ -157,7 +157,7 @@ func wrsnBaseObjects( } // TestReconcile_OwnNamespaceRuleNeedsOnlyANamespaceGrant: a WatchRule that omits sourceNamespace -// watches its own namespace, which a grant giving sourceNamespaces '{namespace}' covers. It needs no +// watches its own namespace, which a grant giving sourceNamespaces '{consumer.namespace}' covers. It needs no // wider grant. func TestReconcile_OwnNamespaceRuleNeedsOnlyANamespaceGrant(t *testing.T) { ctx := context.Background() @@ -167,7 +167,7 @@ func TestReconcile_OwnNamespaceRuleNeedsOnlyANamespaceGrant(t *testing.T) { require.NoError(t, err) assert.Equal(t, []string{wrsnRule}, f.compiledNames(), - "an own-namespace WatchRule needs only a '{namespace}' grant") + "an own-namespace WatchRule needs only a '{consumer.namespace}' grant") cond := wrsnCondition(t, f.reloadRule(ctx, t), ConditionTypeSourceNamespaceAuthorized) assert.Equal(t, metav1.ConditionTrue, cond.Status) @@ -179,7 +179,7 @@ func TestReconcile_OwnNamespaceRuleNeedsOnlyANamespaceGrant(t *testing.T) { // The gate has to stop the data plane, not just describe it. func TestReconcile_DeniedSourceNamespaceStartsNoWatch(t *testing.T) { ctx := context.Background() - // The rule asks for another namespace, but the grant gives only '{namespace}'. + // The rule asks for another namespace, but the grant gives only '{consumer.namespace}'. f := newWRSNFixture(t, wrsnBaseObjects( false, wrsnSourceNS)) @@ -265,7 +265,7 @@ func TestReconcile_RevokedSourceNamespaceRemovesTheCompiledRuleAndReplans(t *tes require.NoError(t, f.client.Get(ctx, k8stypes.NamespacedName{Namespace: wrsnProviderNS, Name: wrsnProvider}, &provider)) provider.Spec.AllowedGitTargets[0].SourceNamespaces = []configbutleraiv1alpha3.ScopeNamespace{ - configbutleraiv1alpha3.NamespaceVariable} + configbutleraiv1alpha3.ConsumerNamespaceVariable} require.NoError(t, f.client.Update(ctx, &provider)) // Observe the world at the exact moment the data plane is replanned. diff --git a/internal/controller/wiring/access_grants_wiring_test.go b/internal/controller/wiring/access_grants_wiring_test.go index ffa41d4b..d134abc4 100644 --- a/internal/controller/wiring/access_grants_wiring_test.go +++ b/internal/controller/wiring/access_grants_wiring_test.go @@ -125,7 +125,7 @@ func TestWatchRule_ANarrowedScopeRefusesOnALabelChange(t *testing.T) { AllowedGitTargets: []configbutleraiv1alpha3.SourceGrant{ {From: configbutleraiv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirror": "yes"}}, - SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"{namespace}"}}, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"{consumer.namespace}"}}, {From: configbutleraiv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"audit": "yes"}}, SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"*"}}, @@ -214,7 +214,7 @@ func TestGitBranch_ALabelRemovedRevokesTheClaimAndItsGitTargetsFollow(t *testing AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{{ From: configbutleraiv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}, - Branches: []configbutleraiv1alpha3.BranchPattern{"teams/{namespace}/*"}, + Branches: []configbutleraiv1alpha3.BranchPattern{"teams/{consumer.namespace}/*"}, }}, }, })) @@ -237,7 +237,7 @@ func TestGitBranch_ALabelRemovedRevokesTheClaimAndItsGitTargetsFollow(t *testing Spec: configbutleraiv1alpha3.KubeProviderSpec{ InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, AllowedGitTargets: []configbutleraiv1alpha3.SourceGrant{{From: configbutleraiv1alpha3.GrantFromSame, - SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"{namespace}"}}}, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"{consumer.namespace}"}}}, }, })) target := &configbutleraiv1alpha3.GitTarget{ diff --git a/internal/controller/wiring/readiness_wiring_test.go b/internal/controller/wiring/readiness_wiring_test.go index fa5f8256..07f00761 100644 --- a/internal/controller/wiring/readiness_wiring_test.go +++ b/internal/controller/wiring/readiness_wiring_test.go @@ -137,9 +137,15 @@ func seedTarget(ctx context.Context, t *testing.T, name string) string { ObjectMeta: metav1.ObjectMeta{Name: "source", Namespace: namespace}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, - AllowedGitTargets: []configbutleraiv1alpha3.SourceGrant{{From: configbutleraiv1alpha3.GrantFromSame, - SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.NamespaceVariable}, - ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}}}, + AllowedGitTargets: []configbutleraiv1alpha3.SourceGrant{ + { + From: configbutleraiv1alpha3.GrantFromSame, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{ + configbutleraiv1alpha3.ConsumerNamespaceVariable, + }, + ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}, + }, + }, }, })) require.NoError(t, k8sClient.Create(ctx, &configbutleraiv1alpha3.GitTarget{ diff --git a/internal/watch/bootstrap_admission_test.go b/internal/watch/bootstrap_admission_test.go index f6fa5826..f2d9b703 100644 --- a/internal/watch/bootstrap_admission_test.go +++ b/internal/watch/bootstrap_admission_test.go @@ -60,7 +60,7 @@ func mirrorGrants(namespaces ...string) []configv1alpha3.SourceGrant { } return []configv1alpha3.SourceGrant{{ From: configv1alpha3.GrantFromNames, Names: names, - SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.NamespaceVariable}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable}, ClusterResources: []configv1alpha3.ClusterResourceIdentity{"*"}, }} } diff --git a/internal/watch/cluster_resources_test.go b/internal/watch/cluster_resources_test.go index 6413ba9b..52fc22ab 100644 --- a/internal/watch/cluster_resources_test.go +++ b/internal/watch/cluster_resources_test.go @@ -25,7 +25,7 @@ func granting(identities ...configv1alpha3.ClusterResourceIdentity) *configv1alp ClusterResources: identities, } if len(identities) == 0 { - grant.SourceNamespaces = []configv1alpha3.ScopeNamespace{configv1alpha3.NamespaceVariable} + grant.SourceNamespaces = []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable} } return bootKubeProvider([]configv1alpha3.SourceGrant{grant}) } diff --git a/internal/watch/manager_startup_test.go b/internal/watch/manager_startup_test.go index b35e6926..27983a37 100644 --- a/internal/watch/manager_startup_test.go +++ b/internal/watch/manager_startup_test.go @@ -52,7 +52,7 @@ func TestManagerStart_MustSeedRuleStoreFromExistingWatchRules(t *testing.T) { Spec: configv1alpha3.KubeProviderSpec{ InCluster: &configv1alpha3.KubeProviderInCluster{}, AllowedGitTargets: []configv1alpha3.SourceGrant{{From: configv1alpha3.GrantFromSame, - SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.NamespaceVariable}}}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable}}}, }, } existingGitProvider := &configv1alpha3.GitProvider{ diff --git a/internal/watch/source_namespace_test.go b/internal/watch/source_namespace_test.go index a91aeb66..df7a646d 100644 --- a/internal/watch/source_namespace_test.go +++ b/internal/watch/source_namespace_test.go @@ -52,7 +52,7 @@ func snbGitProvider() *configv1alpha3.GitProvider { func snbKubeProvider(wide bool) *configv1alpha3.KubeProvider { grant := configv1alpha3.SourceGrant{ From: configv1alpha3.GrantFromNames, Names: []configv1alpha3.GrantNamespace{snbTenantNS}, - SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.NamespaceVariable}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable}, } if wide { grant.SourceNamespaces = append(grant.SourceNamespaces, "*") @@ -221,7 +221,7 @@ func TestCompileWatchRule_TerminalRefusalRemovesAnAlreadyCompiledRule(t *testing providerKey := k8stypes.NamespacedName{Namespace: snbProviderNS, Name: snbProvider} require.NoError(t, m.Client.Get(ctx, providerKey, &stored)) stored.Spec.AllowedGitTargets[0].SourceNamespaces = []configv1alpha3.ScopeNamespace{ - configv1alpha3.NamespaceVariable} + configv1alpha3.ConsumerNamespaceVariable} require.NoError(t, m.Client.Update(ctx, &stored)) resolved, err = CompileWatchRule(ctx, m.Client, m.RuleStore, rule, target) diff --git a/test/e2e/applier_claim.go b/test/e2e/applier_claim.go index b5560c42..1374b2a8 100644 --- a/test/e2e/applier_claim.go +++ b/test/e2e/applier_claim.go @@ -74,8 +74,8 @@ func grantAppliersInAnyNamespace() { // manifests need them patched in. On a config-dir install the patch restates what the manifest // already carries. const suiteKubeProviderGrants = `{"spec":{` + - `"allowedGitTargets":[{"from":"All","sourceNamespaces":["{namespace}"],"clusterResources":["*"]}],` + - `"allowedApplierClaims":[{"from":"All","applierNamespaces":["{namespace}"]}]}}` + `"allowedGitTargets":[{"from":"All","sourceNamespaces":["{consumer.namespace}"],"clusterResources":["*"]}],` + + `"allowedApplierClaims":[{"from":"All","applierNamespaces":["{consumer.namespace}"]}]}}` // grantSuiteAccess gives the install's KubeProvider the grants the suite needs. A Helm install already // carries them as values, and a patch there would take the fields from Helm, whose next server-side diff --git a/test/e2e/audit_route_attribution_e2e_test.go b/test/e2e/audit_route_attribution_e2e_test.go index 2f3db201..afb23f94 100644 --- a/test/e2e/audit_route_attribution_e2e_test.go +++ b/test/e2e/audit_route_attribution_e2e_test.go @@ -254,9 +254,9 @@ func sameClusterKubeConfig() string { func applyKubeProviderWithAuditRoute( namespace, name, secretName string, admits []string, wide bool, auditRoute string, ) (string, error) { - scope := `["{namespace}"]` + scope := `["{consumer.namespace}"]` if wide { - scope = `["{namespace}", "*"]` + scope = `["{consumer.namespace}", "*"]` } extra := fmt.Sprintf("\n allowedGitTargets:\n - from: Same\n sourceNamespaces: %s", scope) if len(admits) > 0 { diff --git a/test/e2e/flux_applier_e2e_test.go b/test/e2e/flux_applier_e2e_test.go index 2c030afd..0e8cf8d0 100644 --- a/test/e2e/flux_applier_e2e_test.go +++ b/test/e2e/flux_applier_e2e_test.go @@ -156,10 +156,12 @@ var _ = Describe("Manager Flux Applier", Label("manager", "flux-applier"), Order }, 5*time.Minute, 5*time.Second).Should(Succeed()) verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "True", "Suspended", "") Expect(kustomizationSuspended(Default, fluxName)).To(BeTrue(), "the applier claim holds its Kustomization") - others, err := kubectlRunInNamespace(testNs, "get", fluxClaimResource, fluxName, - "-o", "jsonpath={.status.source.otherConsumers}") + alsoAppliedBy, err := kubectlRunInNamespace(testNs, "get", fluxClaimResource, fluxName, + "-o", "jsonpath={.status.source.alsoAppliedBy}") Expect(err).NotTo(HaveOccurred()) - Expect(strings.TrimSpace(others)).To(BeEmpty(), "its Kustomization is the GitRepository's only consumer") + Expect( + strings.TrimSpace(alsoAppliedBy), + ).To(BeEmpty(), "nothing but its Kustomization applies the GitRepository") By("dropping the reference: the applier claim hands the Kustomization back, and is idle again") _, err = kubectlRunInNamespace(testNs, "patch", "gittarget", destName, "--type=merge", "-p", diff --git a/test/e2e/helm-values-suite-grants.yaml b/test/e2e/helm-values-suite-grants.yaml index 51ab440b..14b51093 100644 --- a/test/e2e/helm-values-suite-grants.yaml +++ b/test/e2e/helm-values-suite-grants.yaml @@ -6,8 +6,8 @@ kubeProvider: allowedGitTargets: - from: All - sourceNamespaces: ["{namespace}"] + sourceNamespaces: ["{consumer.namespace}"] clusterResources: ["*"] allowedApplierClaims: - from: All - applierNamespaces: ["{namespace}"] + applierNamespaces: ["{consumer.namespace}"] diff --git a/test/e2e/provider_access_e2e_test.go b/test/e2e/provider_access_e2e_test.go index c0665588..11ab5649 100644 --- a/test/e2e/provider_access_e2e_test.go +++ b/test/e2e/provider_access_e2e_test.go @@ -46,7 +46,7 @@ var _ = Describe("Manager branch grants", Label("manager"), Ordered, func() { _, err := kubectlRunInNamespace(platformNs, "apply", "-f", repo.SecretsYAML) Expect(err).NotTo(HaveOccurred(), "failed to apply git secrets to the platform namespace") - By("creating the platform's GitProvider: main for itself, teams/{namespace}/* for tenants") + By("creating the platform's GitProvider: main for itself, teams/{consumer.namespace}/* for tenants") _, err = kubectlRunWithStdin("", fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: GitProvider metadata: @@ -63,7 +63,7 @@ spec: selector: matchLabels: %s: "true" - branches: ["teams/{namespace}/*"] + branches: ["teams/{consumer.namespace}/*"] `, providerName, platformNs, repo.RepoURLHTTP, repo.GitSecretHTTP, tenantLabel), "apply", "-f", "-") Expect(err).NotTo(HaveOccurred()) verifyResourceStatus("gitprovider", providerName, platformNs, diff --git a/test/e2e/source_cluster_e2e_test.go b/test/e2e/source_cluster_e2e_test.go index 72b2bf1a..e7eb8e93 100644 --- a/test/e2e/source_cluster_e2e_test.go +++ b/test/e2e/source_cluster_e2e_test.go @@ -172,10 +172,12 @@ func applyKubeProvider(namespace, name, secretName, key string, admits ...string if key != "" { keyLine = "\n key: " + key } - grants := "\n allowedGitTargets:\n - from: Same\n sourceNamespaces: [\"{namespace}\"]" + grants := "\n allowedGitTargets:\n - from: Same\n sourceNamespaces: [\"{consumer.namespace}\"]" if len(admits) > 0 { - grants += fmt.Sprintf("\n - from: Names\n names: [%s]\n sourceNamespaces: [\"{namespace}\"]", - strings.Join(admits, ", ")) + grants += fmt.Sprintf( + "\n - from: Names\n names: [%s]\n sourceNamespaces: [\"{consumer.namespace}\"]", + strings.Join(admits, ", "), + ) } manifest := fmt.Sprintf(`apiVersion: configbutler.ai/v1alpha3 kind: KubeProvider @@ -438,7 +440,7 @@ spec: inCluster: {} allowedGitTargets: - from: Same - sourceNamespaces: ["{namespace}"] + sourceNamespaces: ["{consumer.namespace}"] `, name, testNs), "apply", "-f", "-") Expect(err).NotTo(HaveOccurred()) verifyResourceCondition("kubeprovider", name, testNs, "Validated", "False", "InClusterNotAuthorized", "") diff --git a/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md b/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md index 055a6857..915f5c03 100644 --- a/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md +++ b/test/fixtures/layout-corpus/specific-examples/prerequisites/README.md @@ -26,7 +26,7 @@ Every `GitTarget` names its source cluster with `spec.kubeProviderRef`. The exam `KubeProvider` the chart renders, `local` in `gitops-reverser`, which uses the operator's own identity. Every target needs a grant from the owner of the `KubeProvider` (`spec.allowedGitTargets`), and the grant's `sourceNamespaces` bound which source namespaces its -`WatchRule`s may watch, its own included only when listed (`"{namespace}"`). What may actually be read +`WatchRule`s may watch, its own included only when listed (`"{consumer.namespace}"`). What may actually be read is bounded by that provider credential's own RBAC in the source cluster. Concrete specimens are in [`../../shapes/1-flat-serialized/config/`](../../shapes/1-flat-serialized/config/kubeprovider.yaml) and [`../../shapes/3-tree-serialized/config/`](../../shapes/3-tree-serialized/config/kubeprovider.yaml). diff --git a/test/mutationlab/e2e/configmap_scenarios_test.go b/test/mutationlab/e2e/configmap_scenarios_test.go index 31d8634d..f0d1abd3 100644 --- a/test/mutationlab/e2e/configmap_scenarios_test.go +++ b/test/mutationlab/e2e/configmap_scenarios_test.go @@ -450,7 +450,11 @@ func TestDeletecollection(t *testing.T) { // N watch DELETED + one name-less audit deletecollection + N per-object // admission DELETE (validating admission fires once per object). - records := h.drain(t, s.id, drainSpec{minCount: 2*len(names) + 1, settle: 3 * time.Second, timeout: 60 * time.Second}) + records := h.drain( + t, + s.id, + drainSpec{minCount: 2*len(names) + 1, settle: 3 * time.Second, timeout: 60 * time.Second}, + ) if got := countSource(records, mutationlab.SourceWatch); got != len(names) { t.Errorf("deletecollection produced %d watch deletes; want %d (per-object fan-out)", got, len(names)) } diff --git a/test/mutationlab/e2e/crd_conversion_test.go b/test/mutationlab/e2e/crd_conversion_test.go index 6517650b..f92f6abd 100644 --- a/test/mutationlab/e2e/crd_conversion_test.go +++ b/test/mutationlab/e2e/crd_conversion_test.go @@ -32,8 +32,16 @@ const ( ) var ( - crdGVR = schema.GroupVersionResource{Group: "apiextensions.k8s.io", Version: "v1", Resource: "customresourcedefinitions"} - vwcGVR = schema.GroupVersionResource{Group: "admissionregistration.k8s.io", Version: "v1", Resource: "validatingwebhookconfigurations"} + crdGVR = schema.GroupVersionResource{ + Group: "apiextensions.k8s.io", + Version: "v1", + Resource: "customresourcedefinitions", + } + vwcGVR = schema.GroupVersionResource{ + Group: "admissionregistration.k8s.io", + Version: "v1", + Resource: "validatingwebhookconfigurations", + } widgetV1 = schema.GroupVersionResource{Group: widgetGroup, Version: "v1", Resource: "widgets"} widgetV2 = schema.GroupVersionResource{Group: widgetGroup, Version: "v2", Resource: "widgets"} vwcName = "gitops-reverser-validating-webhook" diff --git a/test/mutationlab/e2e/workload_scenarios_test.go b/test/mutationlab/e2e/workload_scenarios_test.go index cb1d6be0..7010c613 100644 --- a/test/mutationlab/e2e/workload_scenarios_test.go +++ b/test/mutationlab/e2e/workload_scenarios_test.go @@ -76,7 +76,9 @@ func TestStatusSubresource(t *testing.T) { ctx := context.Background() s := h.newScenario(ctx, t, "status-update") - if _, err := h.kube.AppsV1().Deployments(s.ns).Create(ctx, pausedDeployment(s, "d1"), metav1.CreateOptions{}); err != nil { + if _, err := h.kube.AppsV1(). + Deployments(s.ns). + Create(ctx, pausedDeployment(s, "d1"), metav1.CreateOptions{}); err != nil { t.Fatalf("create deployment: %v", err) } // create => admission CREATE + audit create + watch ADDED + the controller's @@ -113,7 +115,9 @@ func TestScaleSubresource(t *testing.T) { ctx := context.Background() s := h.newScenario(ctx, t, "scale-patch") - if _, err := h.kube.AppsV1().Deployments(s.ns).Create(ctx, pausedDeployment(s, "d1"), metav1.CreateOptions{}); err != nil { + if _, err := h.kube.AppsV1(). + Deployments(s.ns). + Create(ctx, pausedDeployment(s, "d1"), metav1.CreateOptions{}); err != nil { t.Fatalf("create deployment: %v", err) } h.quiesceAndClear(t, s.id, 3) From 6ba88dc3f5fadc51bcea8b3dca15dd3b4e7a45a5 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sat, 24 Oct 2026 05:08:46 +0000 Subject: [PATCH 30/34] feat(api): allow {consumer.name} only beside {consumer.namespace} in a branch pattern The consumer picks its own name, so a scope bounded by {consumer.name} alone bounds nothing: a GitTarget named kube-system would reach kube-system, and env/{consumer.name} would let any admitted namespace claim env/prod. {consumer.name} is now refused in sourceNamespaces and applierNamespaces, and a branch pattern holding it must also hold {consumer.namespace}, whose segment does the bounding. Beside it the name is a naming convention, and the docs say so. Co-Authored-By: Claude Opus 5.5 (1M context) --- api/v1alpha3/access_grants.go | 31 ++++++------ api/v1alpha3/access_grants_test.go | 26 +--------- charts/gitops-reverser/values.yaml | 5 +- .../bases/configbutler.ai_gitproviders.yaml | 13 +++-- .../bases/configbutler.ai_kubeproviders.yaml | 32 ++++++++----- docs/UPGRADING.md | 4 +- docs/configuration.md | 47 +++++++++---------- docs/definitions.md | 3 +- docs/finished/provider-access-rules.md | 7 +-- internal/authz/source_namespace.go | 4 +- .../access_grants_admission_test.go | 16 +++++-- 11 files changed, 96 insertions(+), 92 deletions(-) diff --git a/api/v1alpha3/access_grants.go b/api/v1alpha3/access_grants.go index 8a6f6ebc..54505e32 100644 --- a/api/v1alpha3/access_grants.go +++ b/api/v1alpha3/access_grants.go @@ -39,8 +39,9 @@ const ( // namespace: the control-cluster namespace of the object asking for access. const ConsumerNamespaceVariable = "{consumer.namespace}" -// ConsumerNameVariable stands, in a grant's scope or branch pattern, for the consumer's name: the -// name of the object asking for access. +// ConsumerNameVariable stands, in a branch pattern that also holds ConsumerNamespaceVariable, for the +// consumer's name: the name of the object asking for access. The consumer picks its own name, so the +// name bounds nothing on its own; beside the namespace it is a naming convention. const ConsumerNameVariable = "{consumer.name}" // GrantNamespace is one control-cluster namespace a grant admits by name. @@ -51,10 +52,11 @@ const ConsumerNameVariable = "{consumer.name}" type GrantNamespace string // ScopeNamespace is one namespace in a source or applier scope: a namespace name, -// "{consumer.namespace}" or "{consumer.name}" for the namespace of the same name as the consumer's -// namespace or name, or "*" for every namespace. +// "{consumer.namespace}" for the namespace of the same name as the consumer's, or "*" for every +// namespace. // +kubebuilder:validation:MaxLength=63 -// +kubebuilder:validation:XValidation:rule="self == '*' || self == '{consumer.namespace}' || self == '{consumer.name}' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')",message="a namespace scope holds a namespace name, '{consumer.namespace}' or '{consumer.name}' alone, or '*' alone" +// +kubebuilder:validation:XValidation:rule="self != '{consumer.name}'",message="'{consumer.name}' is refused in a namespace scope: the consumer picks its own name, so any namespace could be reached by naming an object after it; use '{consumer.namespace}', namespace names, or '*'" +// +kubebuilder:validation:XValidation:rule="self == '*' || self == '{consumer.namespace}' || self == '{consumer.name}' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$')",message="a namespace scope holds a namespace name, '{consumer.namespace}' alone, or '*' alone" type ScopeNamespace string // ClusterResourceIdentity names a cluster-scoped resource type: resource.group, a bare resource for @@ -69,13 +71,14 @@ type ClusterResourceIdentity string // BranchPattern is a pattern matched against a whole branch name: literal text, '*' for any // sequence of characters ('/' included), and '{consumer.namespace}' and '{consumer.name}' as whole -// path segments. +// path segments. '{consumer.name}' needs '{consumer.namespace}' in the same pattern. // +kubebuilder:validation:MinLength=1 // +kubebuilder:validation:MaxLength=255 // +kubebuilder:validation:Pattern=`^[^\s~^:?\[\\]*$` // +kubebuilder:validation:XValidation:rule="!self.replace('{consumer.namespace}', 'x').replace('{consumer.name}', 'x').matches('[{][^{}]*[}]')",messageExpression="'branch pattern ' + self + ' references unknown variable ' + self.replace('{consumer.namespace}', 'x').replace('{consumer.name}', 'x').find('[{][^{}]*[}]') + '; the variables are {consumer.namespace} and {consumer.name}'" // +kubebuilder:validation:XValidation:rule="self.replace('{consumer.namespace}', 'x').replace('{consumer.name}', 'x').matches('[{][^{}]*[}]') || !self.replace('{consumer.namespace}', 'x').replace('{consumer.name}', 'x').matches('[{}]')",messageExpression="'branch pattern ' + self + ' has an unmatched brace; the variables are {consumer.namespace} and {consumer.name}'" // +kubebuilder:validation:XValidation:rule="!self.matches('[^/][{]consumer[.]name(space)?[}]') && !self.matches('[{]consumer[.]name(space)?[}][^/]')",messageExpression="'branch pattern ' + self + ': {consumer.namespace} and {consumer.name} must each be a whole path segment, at the start or after a /, and at the end or before a /'" +// +kubebuilder:validation:XValidation:rule="!self.contains('{consumer.name}') || self.contains('{consumer.namespace}')",messageExpression="'branch pattern ' + self + ': {consumer.name} needs {consumer.namespace} in the same pattern; the consumer picks its own name, so on its own it would let any admitted namespace claim any branch the pattern leaves open'" // +kubebuilder:validation:XValidation:rule="!self.contains('..') && !self.contains('@{') && !self.startsWith('/') && !self.endsWith('/') && !self.contains('//')",messageExpression="'branch pattern ' + self + ' can match no branch name: git refuses .., @{, a leading or trailing / and //'" type BranchPattern string @@ -139,8 +142,10 @@ type GitBranchGrant struct { // Branches are the branches an admitted GitBranch may claim, as patterns matched against the // whole branch name. '*' matches any sequence of characters, '/' included, so "*" alone is every // branch. '{consumer.namespace}' and '{consumer.name}' are the claiming GitBranch's namespace and - // name, each a whole path segment: "teams/{consumer.namespace}/*". There is no escaping, '?' or - // character class. + // name, each a whole path segment: "teams/{consumer.namespace}/*". '{consumer.name}' needs + // '{consumer.namespace}' in the same pattern, "teams/{consumer.namespace}/{consumer.name}": it is a + // naming convention, not a bound, since a GitBranch picks its own name. There is no escaping, '?' + // or character class. // +required // +listType=set // +kubebuilder:validation:MinItems=1 @@ -176,9 +181,8 @@ type SourceGrant struct { Selector *metav1.LabelSelector `json:"selector,omitempty"` // SourceNamespaces are the source-cluster namespaces the WatchRules feeding an admitted GitTarget - // may watch: exact names, '{consumer.namespace}' or '{consumer.name}' for the namespace named like - // the GitTarget's namespace or name, or '*' for every namespace, which also allows - // rules[].sourceNamespace: "*". The list is the whole scope; the GitTarget's own namespace is in it + // may watch: exact names, '{consumer.namespace}' for the namespace named like the GitTarget's, or + // '*' for every namespace, which also allows rules[].sourceNamespace: "*". The list is the whole scope; the GitTarget's own namespace is in it // only when listed. Omitted, no namespace. // +optional // +listType=set @@ -225,9 +229,8 @@ type ApplierGrant struct { Selector *metav1.LabelSelector `json:"selector,omitempty"` // ApplierNamespaces are the source-cluster namespaces in which an admitted applier claim's - // Kustomization or Application may live: exact names, '{consumer.namespace}' or '{consumer.name}' - // for the namespace named like the applier claim's namespace or name, or '*' for every namespace. - // The list is the whole scope. + // Kustomization or Application may live: exact names, '{consumer.namespace}' for the namespace + // named like the applier claim's, or '*' for every namespace. The list is the whole scope. // +required // +listType=set // +kubebuilder:validation:MinItems=1 diff --git a/api/v1alpha3/access_grants_test.go b/api/v1alpha3/access_grants_test.go index 56ac490b..60e5d80b 100644 --- a/api/v1alpha3/access_grants_test.go +++ b/api/v1alpha3/access_grants_test.go @@ -45,8 +45,8 @@ func TestMatchBranchPattern(t *testing.T) { {"{consumer.namespace}/*", "team-a", "live", "team-b/x", false}, {"teams/{consumer.namespace}/{consumer.name}", "team-a", "checkout", "teams/team-a/checkout", true}, {"teams/{consumer.namespace}/{consumer.name}", "team-a", "checkout", "teams/team-a/payments", false}, - {"{consumer.name}/*", "team-a", "checkout", "checkout/feature", true}, - {"{consumer.name}/*", "team-a", "checkout", "team-a/feature", false}, + {"{consumer.namespace}/{consumer.name}/*", "team-a", "checkout", "team-a/checkout/feature", true}, + {"{consumer.namespace}/{consumer.name}/*", "team-a", "checkout", "team-a/payments/feature", false}, {"{namespace}", "team-a", "live", "team-a", false}, } for _, tt := range tests { @@ -271,28 +271,6 @@ func TestKubeProvider_AdmitsApplierClaim(t *testing.T) { assert.Equal(t, "admits team-a", provider.DescribeAllowedApplierClaims()) } -// TestKubeProvider_ConsumerNameInAScope: {consumer.name} is the consumer's own name, so one grant -// gives each GitTarget, and each applier claim, the namespace named after it. -func TestKubeProvider_ConsumerNameInAScope(t *testing.T) { - provider := &KubeProvider{ - ObjectMeta: metav1.ObjectMeta{Namespace: "platform"}, - Spec: KubeProviderSpec{ - AllowedGitTargets: []SourceGrant{{From: GrantFromAll, - SourceNamespaces: []ScopeNamespace{ConsumerNameVariable, ConsumerNamespaceVariable}}}, - AllowedApplierClaims: []ApplierGrant{{From: GrantFromAll, - ApplierNamespaces: []ScopeNamespace{ConsumerNameVariable}}}, - }, - } - scope, err := provider.AdmitsGitTarget(consumer("team-a", "payments"), nil) - require.NoError(t, err) - assert.Equal(t, []string{"payments", "team-a"}, scope.SourceNamespaces) - assert.False(t, scope.AllowsSourceNamespace("checkout"), "another GitTarget's name") - - appliers, err := provider.AdmitsApplierClaim(consumer("team-a", "checkout"), nil) - require.NoError(t, err) - assert.Equal(t, []string{"checkout"}, appliers.ApplierNamespaces) -} - func TestGitBranch_GitProviderKey(t *testing.T) { branch := &GitBranch{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a"}} branch.Spec.GitProviderRef.Name = "shared-repo" diff --git a/charts/gitops-reverser/values.yaml b/charts/gitops-reverser/values.yaml index f6b56227..83d0e0b4 100644 --- a/charts/gitops-reverser/values.yaml +++ b/charts/gitops-reverser/values.yaml @@ -373,9 +373,8 @@ kubeProvider: # Each grant has a `from` (Same, Names with names, Selector with a non-empty selector, or All) # and a scope: # allowedGitTargets[].sourceNamespaces which namespaces the WatchRules feeding an admitted - # GitTarget may watch: names, "{consumer.namespace}" or - # "{consumer.name}" for the GitTarget's namespace or - # name, or "*" + # GitTarget may watch: names, "{consumer.namespace}" for + # the GitTarget's own, or "*" # allowedGitTargets[].clusterResources which cluster-scoped types a ClusterWatchRule may mirror: # resource.group, a bare core resource, or "*" # allowedApplierClaims[].applierNamespaces where an admitted applier claim's Kustomization or diff --git a/config/crd/bases/configbutler.ai_gitproviders.yaml b/config/crd/bases/configbutler.ai_gitproviders.yaml index e3751202..d45a1f0a 100644 --- a/config/crd/bases/configbutler.ai_gitproviders.yaml +++ b/config/crd/bases/configbutler.ai_gitproviders.yaml @@ -78,13 +78,15 @@ spec: Branches are the branches an admitted GitBranch may claim, as patterns matched against the whole branch name. '*' matches any sequence of characters, '/' included, so "*" alone is every branch. '{consumer.namespace}' and '{consumer.name}' are the claiming GitBranch's namespace and - name, each a whole path segment: "teams/{consumer.namespace}/*". There is no escaping, '?' or - character class. + name, each a whole path segment: "teams/{consumer.namespace}/*". '{consumer.name}' needs + '{consumer.namespace}' in the same pattern, "teams/{consumer.namespace}/{consumer.name}": it is a + naming convention, not a bound, since a GitBranch picks its own name. There is no escaping, '?' + or character class. items: description: |- BranchPattern is a pattern matched against a whole branch name: literal text, '*' for any sequence of characters ('/' included), and '{consumer.namespace}' and '{consumer.name}' as whole - path segments. + path segments. '{consumer.name}' needs '{consumer.namespace}' in the same pattern. maxLength: 255 minLength: 1 pattern: ^[^\s~^:?\[\\]*$ @@ -108,6 +110,11 @@ spec: /''' rule: '!self.matches(''[^/][{]consumer[.]name(space)?[}]'') && !self.matches(''[{]consumer[.]name(space)?[}][^/]'')' + - messageExpression: '''branch pattern '' + self + '': {consumer.name} + needs {consumer.namespace} in the same pattern; the consumer + picks its own name, so on its own it would let any admitted + namespace claim any branch the pattern leaves open''' + rule: '!self.contains(''{consumer.name}'') || self.contains(''{consumer.namespace}'')' - messageExpression: '''branch pattern '' + self + '' can match no branch name: git refuses .., @{, a leading or trailing / and //''' diff --git a/config/crd/bases/configbutler.ai_kubeproviders.yaml b/config/crd/bases/configbutler.ai_kubeproviders.yaml index c5a48721..ab0e6c6e 100644 --- a/config/crd/bases/configbutler.ai_kubeproviders.yaml +++ b/config/crd/bases/configbutler.ai_kubeproviders.yaml @@ -83,19 +83,23 @@ spec: applierNamespaces: description: |- ApplierNamespaces are the source-cluster namespaces in which an admitted applier claim's - Kustomization or Application may live: exact names, '{consumer.namespace}' or '{consumer.name}' - for the namespace named like the applier claim's namespace or name, or '*' for every namespace. - The list is the whole scope. + Kustomization or Application may live: exact names, '{consumer.namespace}' for the namespace + named like the applier claim's, or '*' for every namespace. The list is the whole scope. items: description: |- ScopeNamespace is one namespace in a source or applier scope: a namespace name, - "{consumer.namespace}" or "{consumer.name}" for the namespace of the same name as the consumer's - namespace or name, or "*" for every namespace. + "{consumer.namespace}" for the namespace of the same name as the consumer's, or "*" for every + namespace. maxLength: 63 type: string x-kubernetes-validations: + - message: '''{consumer.name}'' is refused in a namespace + scope: the consumer picks its own name, so any namespace + could be reached by naming an object after it; use ''{consumer.namespace}'', + namespace names, or ''*''' + rule: self != '{consumer.name}' - message: a namespace scope holds a namespace name, '{consumer.namespace}' - or '{consumer.name}' alone, or '*' alone + alone, or '*' alone rule: self == '*' || self == '{consumer.namespace}' || self == '{consumer.name}' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') maxItems: 64 @@ -325,20 +329,24 @@ spec: sourceNamespaces: description: |- SourceNamespaces are the source-cluster namespaces the WatchRules feeding an admitted GitTarget - may watch: exact names, '{consumer.namespace}' or '{consumer.name}' for the namespace named like - the GitTarget's namespace or name, or '*' for every namespace, which also allows - rules[].sourceNamespace: "*". The list is the whole scope; the GitTarget's own namespace is in it + may watch: exact names, '{consumer.namespace}' for the namespace named like the GitTarget's, or + '*' for every namespace, which also allows rules[].sourceNamespace: "*". The list is the whole scope; the GitTarget's own namespace is in it only when listed. Omitted, no namespace. items: description: |- ScopeNamespace is one namespace in a source or applier scope: a namespace name, - "{consumer.namespace}" or "{consumer.name}" for the namespace of the same name as the consumer's - namespace or name, or "*" for every namespace. + "{consumer.namespace}" for the namespace of the same name as the consumer's, or "*" for every + namespace. maxLength: 63 type: string x-kubernetes-validations: + - message: '''{consumer.name}'' is refused in a namespace + scope: the consumer picks its own name, so any namespace + could be reached by naming an object after it; use ''{consumer.namespace}'', + namespace names, or ''*''' + rule: self != '{consumer.name}' - message: a namespace scope holds a namespace name, '{consumer.namespace}' - or '{consumer.name}' alone, or '*' alone + alone, or '*' alone rule: self == '*' || self == '{consumer.namespace}' || self == '{consumer.name}' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') maxItems: 64 diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index 6c9b73e6..e827c9eb 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -53,8 +53,8 @@ spec: `GitBranch.spec.gitProviderRef` may name a `GitProvider` in another namespace. Branch patterns match the whole branch name, `*` crosses `/`, and `teams/{consumer.namespace}/*` gives each namespace its own prefix. A branch outside the patterns is `BranchNotAllowed`. A grant's scope names the object - asking for access, the consumer, with `{consumer.namespace}` and `{consumer.name}`; see - [the consumer variables](configuration.md#the-consumer-variables). + asking for access, the consumer, with `{consumer.namespace}`; a branch pattern may add + `{consumer.name}` beside it. See [the consumer variables](configuration.md#the-consumer-variables). - **Who writes a branch is the `GitBranch`'s decision.** A `GitTarget` in another namespace writes through it only while its `spec.allowedGitTargets` admit that namespace. - **The chart's `local` provider grants nothing** unless `kubeProvider.allowedGitTargets` and diff --git a/docs/configuration.md b/docs/configuration.md index 1762616e..fd6f6c9f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -331,35 +331,28 @@ The object asking for access is the **consumer**: the `GitBranch` for `allowedGi `GitTarget` for `allowedGitTargets`, and the applier claim for `allowedApplierClaims`. One grant serves many consumers, so its scope can name the consumer instead of listing each one: -| Variable | Is | For `team-a`'s `GitTarget` `payments` | -|---|---|---| -| `{consumer.namespace}` | The consumer's namespace, in the control cluster | `team-a` | -| `{consumer.name}` | The consumer's name | `payments` | +| Variable | Is | Allowed in | For `team-a`'s `GitBranch` `live` | +|---|---|---|---| +| `{consumer.namespace}` | The consumer's namespace, in the control cluster | every scope and branch pattern | `team-a` | +| `{consumer.name}` | The consumer's name | a branch pattern that also holds `{consumer.namespace}` | `live` | They are the only variables. Each is replaced with the consumer's value before the scope is compared, and nothing else about them is special. A grant has no variable for its own namespace: the grant sits on its object, so that namespace is known when the grant is written, and `from: Same` admits it. -**In `sourceNamespaces` and `applierNamespaces`** an entry is an exact name, one variable alone, or -`"*"` alone. The variable names a namespace **of the same name in the source cluster**: -`sourceNamespaces: ["{consumer.namespace}"]` lets each `GitTarget` mirror the source namespace that -shares its own namespace's name. For a `KubeProvider` that reaches another cluster, that is a -namespace over there that happens to share the name. `{consumer.name}` lets each consumer reach the -namespace named after it: - -```yaml -# Each GitTarget mirrors the source namespace named after it: team-a's GitTarget payments mirrors -# payments, and nothing else. -allowedGitTargets: - - from: Selector - selector: {matchLabels: {platform.example.com/tenant: "true"}} - sourceNamespaces: ["{consumer.name}"] -``` +**Only the namespace bounds anything.** Creating an object in a namespace takes RBAC there, and a +grant's `from` decides which namespaces count. A name is whatever the object's creator types, so a +scope bounded by `{consumer.name}` alone would be no bound: a `GitTarget` named `kube-system` would +reach `kube-system`. That is why `{consumer.name}` is refused in `sourceNamespaces` and +`applierNamespaces`, and allowed in a branch pattern only beside `{consumer.namespace}`, where the +namespace segment does the bounding. -`{consumer.name}` puts the consumer's name in charge of what it reaches, so whoever may create -consumers in an admitted namespace may pick a name that matches any source namespace. Grant it only -to namespaces whose owners you would let mirror any namespace by that name. +**In `sourceNamespaces` and `applierNamespaces`** an entry is an exact name, +`"{consumer.namespace}"` alone, or `"*"` alone. The variable names a namespace **of the same name in +the source cluster**: `sourceNamespaces: ["{consumer.namespace}"]` lets each `GitTarget` mirror the +source namespace that shares its own namespace's name. For a `KubeProvider` that reaches another +cluster, that is a namespace over there that happens to share the name. **In a branch pattern** both variables may appear, each as a whole path segment, and the pattern is matched against the whole branch name: @@ -370,6 +363,11 @@ matched against the whole branch name: `/`. `teams/{consumer.namespace}/*` and `teams/{consumer.namespace}/{consumer.name}` are valid. `teams/{consumer.namespace}*` is refused, because for `team-a` it would also match `teams/team-ab/live`, and so is `{consumer.namespace}-{consumer.name}`. +- **`{consumer.name}` needs `{consumer.namespace}` in the same pattern.** `env/{consumer.name}` is + refused: any admitted namespace could claim `env/prod` by naming a `GitBranch` `prod`. Beside the + namespace the name is a naming convention, not a bound: `teams/{consumer.namespace}/{consumer.name}` + gives each `GitBranch` the one branch named after it, so a branch says which object claims it, but + a team that may create `GitBranch`es can still reach every branch under its own prefix. - **No escaping, `?` or character classes.** Git refuses `*`, `?` and `[` in a branch name, so a literal never needs escaping. - Any other `{...}` is refused when the object is applied, naming it: @@ -2661,9 +2659,8 @@ Every item, the rule's own namespace included, is authorized by the grants of th 1. a grant in its `spec.allowedGitTargets` admits the `GitTarget`'s namespace; and 2. those grants' `sourceNamespaces` hold the namespace the item asks for: its name, - `"{consumer.namespace}"` for the `GitTarget`'s own (which is the `WatchRule`'s), - `"{consumer.name}"` for the one named after the `GitTarget`, or `"*"`. An item asking for `"*"` - needs `"*"` there. + `"{consumer.namespace}"` for the `GitTarget`'s own (which is the `WatchRule`'s), or `"*"`. An item + asking for `"*"` needs `"*"` there. There is no third condition on the `GitTarget`. What a target may read from its source cluster is bounded by that cluster's RBAC for the provider's credential: a request the grants permit still diff --git a/docs/definitions.md b/docs/definitions.md index c6bcfed5..e1d795dc 100644 --- a/docs/definitions.md +++ b/docs/definitions.md @@ -157,7 +157,8 @@ scope. Not "permission" or "policy". **Consumer.** The object a grant admits or refuses: the `GitBranch` for `allowedGitBranches`, the `GitTarget` for `allowedGitTargets`, the applier claim for `allowedApplierClaims`. A grant's scope -names it with `{consumer.namespace}` and `{consumer.name}`. The grant's own object is its holder, +names it with `{consumer.namespace}`, and a branch pattern may add `{consumer.name}` beside it; the +name alone bounds nothing, since the consumer picks it. The grant's own object is its holder, and `from: Same` is the holder's namespace, never the consumer's. Not "referrer", "requester" or "caller". diff --git a/docs/finished/provider-access-rules.md b/docs/finished/provider-access-rules.md index 2f47470a..9f4a5e4c 100644 --- a/docs/finished/provider-access-rules.md +++ b/docs/finished/provider-access-rules.md @@ -723,9 +723,10 @@ page: - **The scope variable is `{consumer.namespace}`, and `{consumer.name}` joined it.** This page writes `{namespace}`. That collided with the placement template's `{namespace}`, the mirrored resource's namespace, and did not say whose namespace it was among the three a grant involves. - The object a grant admits is now the consumer, and `{consumer.name}` lets one grant give each - consumer the source namespace, applier namespace or branch segment named after it. `{namespace}` - is refused as an unknown variable. + The object a grant admits is now the consumer. `{consumer.name}` is allowed only in a branch + pattern that also holds `{consumer.namespace}`, as a naming convention: the consumer picks its own + name, so in a namespace scope, or alone in a pattern, it would bound nothing. `{namespace}` is + refused as an unknown variable. - **A deleted `GitProvider` or `KubeProvider` is not a revocation.** The `GitBranch` keeps its claim, and the applier claim keeps its hold, as before. - **Watch-driven tests.** The drain, successor and two-candidate revocation tests, and the applier diff --git a/internal/authz/source_namespace.go b/internal/authz/source_namespace.go index 60b123a5..3ad3129d 100644 --- a/internal/authz/source_namespace.go +++ b/internal/authz/source_namespace.go @@ -100,8 +100,8 @@ func (s ResolvedSourceScope) Fingerprint() string { // // An item may watch a namespace only while the KubeProvider's spec.allowedGitTargets admit the // GitTarget's namespace and give it that namespace in sourceNamespaces: a name, '{consumer.namespace}' -// for the GitTarget's own namespace (which is the WatchRule's), '{consumer.name}' for the one named -// after the GitTarget, or "*" for every namespace, which is also what an item asking for "*" needs. The rule's own namespace is in scope only when a grant lists it. +// for the GitTarget's own namespace (which is the WatchRule's), or "*" for every namespace, which is +// also what an item asking for "*" needs. The rule's own namespace is in scope only when a grant lists it. // The source credential's RBAC stays the hard maximum: a request this gate permits still fails 403 // at the apiserver if the credential cannot read that namespace. // diff --git a/internal/controller/access_grants_admission_test.go b/internal/controller/access_grants_admission_test.go index 6b524eb0..3b911812 100644 --- a/internal/controller/access_grants_admission_test.go +++ b/internal/controller/access_grants_admission_test.go @@ -65,7 +65,13 @@ var _ = Describe("Access grants at admission", func() { It("accepts the branch patterns the grammar allows", func() { accepted(gitProvider(branches("main", "release/*", "teams/{consumer.namespace}/*", "{consumer.namespace}", - "teams/{consumer.namespace}/{consumer.name}", "{consumer.name}/*", "*", "feature/*/v2"))) + "teams/{consumer.namespace}/{consumer.name}", "{consumer.namespace}/{consumer.name}/*", "*", + "feature/*/v2"))) + }) + + It("refuses {consumer.name} without {consumer.namespace} in the same pattern", func() { + refused(gitProvider(branches("env/{consumer.name}")), "{consumer.name} needs {consumer.namespace}") + refused(gitProvider(branches("{consumer.name}/*")), "{consumer.name} needs {consumer.namespace}") }) It("refuses a consumer variable that is not a whole path segment", func() { @@ -119,7 +125,7 @@ var _ = Describe("Access grants at admission", func() { "sets sourceNamespaces, clusterResources, or both") accepted( kubeProvider("allowedGitTargets", map[string]any{"from": "Same", - "sourceNamespaces": []any{"{consumer.namespace}", "{consumer.name}"}}), + "sourceNamespaces": []any{"{consumer.namespace}"}}), ) accepted(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", "clusterResources": []any{"clusterroles.rbac.authorization.k8s.io", "nodes", "*"}})) @@ -146,13 +152,17 @@ var _ = Describe("Access grants at admission", func() { }) It("refuses a scope entry that is neither a name, a consumer variable alone, nor *", func() { - alone := "'{consumer.namespace}' or '{consumer.name}' alone, or '*' alone" + alone := "'{consumer.namespace}' alone, or '*' alone" refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", "sourceNamespaces": []any{"team-{consumer.namespace}"}}), alone) refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", "sourceNamespaces": []any{"{namespace}"}}), alone) refused(kubeProvider("allowedApplierClaims", map[string]any{"from": "Same", "applierNamespaces": []any{"flux-*"}}), alone) + refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", + "sourceNamespaces": []any{"{consumer.name}"}}), "the consumer picks its own name") + refused(kubeProvider("allowedApplierClaims", map[string]any{"from": "Same", + "applierNamespaces": []any{"{consumer.name}"}}), "the consumer picks its own name") refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", "clusterResources": []any{"cluster*"}}), "clusterResources[0]") }) From fb16a98e6e94ee9b88dbcffcc70785e815a923f2 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sat, 24 Oct 2026 05:17:04 +0000 Subject: [PATCH 31/34] refactor(api): rename the applier claim's Held condition to InControl "Held" named two things that disagree on one state: holding the applier (first in line, carrying the finalizer, what HeldByAnotherClaim reports) and the condition (GitOps Reverser decides when the applier applies). A claim that is Releasing or Revoking still holds its applier but reported Held=False. InControl names only the second, so "hold" keeps the line's meaning. The printer column is now CONTROL; the reasons are unchanged. docs/definitions.md defines "in control" by the question it answers and points to the reason tables in the Flux and Argo CD guides instead of listing every reason again. It also drops the stale "FluxClaim only" for a condition both kinds have reported since Argo CD support landed. No users yet, so this is a hard cut with no alias. Co-Authored-By: Claude Opus 5.5 (1M context) --- api/v1alpha3/argocdclaim_types.go | 12 +- api/v1alpha3/fluxclaim_types.go | 11 +- .../bases/configbutler.ai_argocdclaims.yaml | 14 ++- .../crd/bases/configbutler.ai_fluxclaims.yaml | 13 ++- docs/INDEX.md | 2 +- docs/architecture.md | 2 +- docs/bi-directional-argocd.md | 7 +- docs/bi-directional-flux.md | 25 +++-- docs/configuration.md | 11 +- docs/definitions.md | 30 ++--- docs/design/apply-trigger.md | 16 +-- docs/spec/e2e-bi-directional-corner.md | 4 +- docs/spec/status-conditions-guide.md | 8 +- internal/applier/applier.go | 10 +- internal/applier/dispatcher.go | 32 +++--- internal/applier/dispatcher_test.go | 12 +- internal/applier/flux.go | 4 +- .../controller/applierclaim_controller.go | 69 ++++++------ .../applierclaim_controller_test.go | 8 +- .../controller/applierclaim_holder_test.go | 18 +-- .../applierclaim_revocation_test.go | 18 +-- internal/controller/applierclaim_rule_test.go | 48 ++++---- .../controller/applierclaim_schema_test.go | 6 +- internal/controller/argocdclaim_hold.go | 34 +++--- internal/controller/argocdclaim_hold_test.go | 28 ++--- internal/controller/fluxclaim_hold.go | 105 +++++++++--------- internal/controller/fluxclaim_hold_test.go | 54 ++++----- .../fluxclaim_suspend_apiserver_test.go | 16 +-- test/e2e/argocd_bi_directional_e2e_test.go | 4 +- test/e2e/bi_directional_echoes_e2e_test.go | 2 +- test/e2e/bi_directional_push_race_e2e_test.go | 2 +- test/e2e/bi_directional_timing_e2e_test.go | 2 +- test/e2e/flux_applier_e2e_test.go | 10 +- 33 files changed, 327 insertions(+), 310 deletions(-) diff --git a/api/v1alpha3/argocdclaim_types.go b/api/v1alpha3/argocdclaim_types.go index 40b264f0..56abedc7 100644 --- a/api/v1alpha3/argocdclaim_types.go +++ b/api/v1alpha3/argocdclaim_types.go @@ -66,10 +66,12 @@ type ArgoCDClaimStatus struct { // Conditions are Configured (an eligible GitTarget names the applier claim; the Application exists, // syncs a single source, has automated sync off or a switch the applier claim may hold, may be - // written, and no other ArgoCDClaim holds it), Held (AutomatedSyncOff: automated sync is off, - // held by GitOps Reverser or kept off by the switch's owner; Releasing: being handed back; - // Revoking: being handed back because a grant no longer admits it; NotReferenced; NotConfigured), - // and the kstatus Ready/Reconciling/Stalled trio. An applier claim acts only while an + // written, and no other ArgoCDClaim holds it), InControl (whether GitOps Reverser decides, right + // now, when the Application syncs. True: AutomatedSyncOff, automated sync is off, held by GitOps + // Reverser or kept off by the switch's owner. False: Releasing, being handed back; Revoking, being + // handed back because a grant no longer admits it; NotReferenced. NotConfigured: the applier claim + // is not Configured, or automated sync was switched on since it was), and the kstatus + // Ready/Reconciling/Stalled trio. An applier claim acts only while an // eligible GitTarget references it: one in its namespace that names it, is admitted by its // KubeProviders, and writes the repository and branch the Application syncs. An applier claim no // GitTarget names reads nothing in the Application's cluster and is Configured=Unknown, @@ -127,7 +129,7 @@ type ArgoCDClaimSource struct { // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` // +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason` -// +kubebuilder:printcolumn:name="Held",type=string,JSONPath=`.status.conditions[?(@.type=="Held")].reason` +// +kubebuilder:printcolumn:name="Control",type=string,JSONPath=`.status.conditions[?(@.type=="InControl")].reason` // +kubebuilder:printcolumn:name="Application",type=string,JSONPath=`.spec.applicationRef.name` // +kubebuilder:printcolumn:name="Operation",type=string,JSONPath=`.status.operation.phase` // +kubebuilder:printcolumn:name="Commit",type=string,JSONPath=`.status.operation.commit`,priority=1 diff --git a/api/v1alpha3/fluxclaim_types.go b/api/v1alpha3/fluxclaim_types.go index 291fc4ab..dfe3b82c 100644 --- a/api/v1alpha3/fluxclaim_types.go +++ b/api/v1alpha3/fluxclaim_types.go @@ -60,10 +60,11 @@ type FluxClaimStatus struct { // Conditions are Configured (an eligible GitTarget names the applier claim; the Kustomization exists, // applies a GitRepository that follows a branch, may be patched, and no other FluxClaim holds - // it), Held (Suspended: suspended under GitOps Reverser with the last suspend handled; Applying: - // resumed for an apply GitOps Reverser started; Suspending: a suspend is not handled yet; - // Releasing: being handed back; Revoking: being handed back because a grant no longer admits it; - // NotReferenced; NotConfigured), and the kstatus + // it), InControl (whether GitOps Reverser decides, right now, when the Kustomization applies. True: + // Suspended, suspended under GitOps Reverser with the last suspend handled; Applying, resumed for + // an apply GitOps Reverser started. False: Suspending, a suspend is not handled yet; Releasing, + // being handed back; Revoking, being handed back because a grant no longer admits it; + // NotReferenced. Unknown: NotConfigured), and the kstatus // Ready/Reconciling/Stalled trio. An applier claim acts only while an eligible GitTarget references it: // one in its namespace that names it, is admitted by its KubeProviders, and writes the // repository and branch the GitRepository fetches. An applier claim no GitTarget names reads nothing @@ -134,7 +135,7 @@ type FluxClaimSource struct { // +kubebuilder:subresource:status // +kubebuilder:printcolumn:name="Ready",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].status` // +kubebuilder:printcolumn:name="Reason",type=string,JSONPath=`.status.conditions[?(@.type=="Ready")].reason` -// +kubebuilder:printcolumn:name="Held",type=string,JSONPath=`.status.conditions[?(@.type=="Held")].reason` +// +kubebuilder:printcolumn:name="Control",type=string,JSONPath=`.status.conditions[?(@.type=="InControl")].reason` // +kubebuilder:printcolumn:name="Kustomization",type=string,JSONPath=`.spec.kustomizationRef.name` // +kubebuilder:printcolumn:name="Operation",type=string,JSONPath=`.status.operation.phase` // +kubebuilder:printcolumn:name="Commit",type=string,JSONPath=`.status.operation.commit`,priority=1 diff --git a/config/crd/bases/configbutler.ai_argocdclaims.yaml b/config/crd/bases/configbutler.ai_argocdclaims.yaml index d237395a..ffbc38ce 100644 --- a/config/crd/bases/configbutler.ai_argocdclaims.yaml +++ b/config/crd/bases/configbutler.ai_argocdclaims.yaml @@ -21,8 +21,8 @@ spec: - jsonPath: .status.conditions[?(@.type=="Ready")].reason name: Reason type: string - - jsonPath: .status.conditions[?(@.type=="Held")].reason - name: Held + - jsonPath: .status.conditions[?(@.type=="InControl")].reason + name: Control type: string - jsonPath: .spec.applicationRef.name name: Application @@ -161,10 +161,12 @@ spec: description: |- Conditions are Configured (an eligible GitTarget names the applier claim; the Application exists, syncs a single source, has automated sync off or a switch the applier claim may hold, may be - written, and no other ArgoCDClaim holds it), Held (AutomatedSyncOff: automated sync is off, - held by GitOps Reverser or kept off by the switch's owner; Releasing: being handed back; - Revoking: being handed back because a grant no longer admits it; NotReferenced; NotConfigured), - and the kstatus Ready/Reconciling/Stalled trio. An applier claim acts only while an + written, and no other ArgoCDClaim holds it), InControl (whether GitOps Reverser decides, right + now, when the Application syncs. True: AutomatedSyncOff, automated sync is off, held by GitOps + Reverser or kept off by the switch's owner. False: Releasing, being handed back; Revoking, being + handed back because a grant no longer admits it; NotReferenced. NotConfigured: the applier claim + is not Configured, or automated sync was switched on since it was), and the kstatus + Ready/Reconciling/Stalled trio. An applier claim acts only while an eligible GitTarget references it: one in its namespace that names it, is admitted by its KubeProviders, and writes the repository and branch the Application syncs. An applier claim no GitTarget names reads nothing in the Application's cluster and is Configured=Unknown, diff --git a/config/crd/bases/configbutler.ai_fluxclaims.yaml b/config/crd/bases/configbutler.ai_fluxclaims.yaml index a5609208..caf0015f 100644 --- a/config/crd/bases/configbutler.ai_fluxclaims.yaml +++ b/config/crd/bases/configbutler.ai_fluxclaims.yaml @@ -21,8 +21,8 @@ spec: - jsonPath: .status.conditions[?(@.type=="Ready")].reason name: Reason type: string - - jsonPath: .status.conditions[?(@.type=="Held")].reason - name: Held + - jsonPath: .status.conditions[?(@.type=="InControl")].reason + name: Control type: string - jsonPath: .spec.kustomizationRef.name name: Kustomization @@ -154,10 +154,11 @@ spec: description: |- Conditions are Configured (an eligible GitTarget names the applier claim; the Kustomization exists, applies a GitRepository that follows a branch, may be patched, and no other FluxClaim holds - it), Held (Suspended: suspended under GitOps Reverser with the last suspend handled; Applying: - resumed for an apply GitOps Reverser started; Suspending: a suspend is not handled yet; - Releasing: being handed back; Revoking: being handed back because a grant no longer admits it; - NotReferenced; NotConfigured), and the kstatus + it), InControl (whether GitOps Reverser decides, right now, when the Kustomization applies. True: + Suspended, suspended under GitOps Reverser with the last suspend handled; Applying, resumed for + an apply GitOps Reverser started. False: Suspending, a suspend is not handled yet; Releasing, + being handed back; Revoking, being handed back because a grant no longer admits it; + NotReferenced. Unknown: NotConfigured), and the kstatus Ready/Reconciling/Stalled trio. An applier claim acts only while an eligible GitTarget references it: one in its namespace that names it, is admitted by its KubeProviders, and writes the repository and branch the GitRepository fetches. An applier claim no GitTarget names reads nothing diff --git a/docs/INDEX.md b/docs/INDEX.md index 43350db5..9793c9a1 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -153,7 +153,7 @@ so you never have to read a proposal to find out it already shipped. | [`metrics-observability-plan.md`](design/metrics-observability-plan.md) | **built.** the canonical metrics document, rewritten 2026-09 and shipped in the same wave. Reads the product as two ingestion paths — object state through watch, authorship through audit — joining at the resolver, and gives each stage one counter with a bounded `outcome` so the flow and its loss paths are drawable. Records what three review rounds corrected, including two reversals worth not re-litigating: the fact-loss counters must NOT be merged (an eviction counts a fact, a trim gap an occurrence, a decode error a whole batch), and a cumulative counter cannot replace a current count, which is why `status.streams` and `status.retainedDocuments` stayed. The instrument inventory lives in [`interpreting-metrics.md`](interpreting-metrics.md), not here. Open: the Grafana JSON and alert rules | | [`reconcile-triggering.md`](design/reconcile-triggering.md) | which controllers still fail to wake up | | [`applier-echoes.md`](design/applier-echoes.md) | **built 2026-10-20, released as one change.** The applier's own write, back through the watch, is mirrored and re-planned onto a newer tip on replay, reverting a Git-side push. Two ideas close every way a stale picture can reach a commit: a write that changes nothing does not exist, and a pending write carries the actions decided on its tree, not a picture. The applier's write in the apply window is not mirrored (F1); a write is upserts and deletes decided once, so a replay never sweeps again and a save keeps its objects (F2); a resync never decides from a snapshot collected while Git was ahead or across an apply, by an apply epoch (F3); another applier's object is not mirrored into a coordinated folder (F4); flush, then apply (S1); `CatchUp` only to adopt API creates and to converge, for Flux and Argo CD alike, and no apply before every initial snapshot is published (S2). Carries the collisions left open, the promises, the code plan, thirteen e2e scenarios, and the twelve decisions taken while building (among them: siblings do not count for F3, a 5-minute hold, initial snapshots exempt from F3, the prefetch removed, no heal resync, and the flush covering the sibling workers) | -| [`apply-trigger.md`](design/apply-trigger.md) | **built for Flux and Argo CD; a webhook applier is the aim.** Why GitOps Reverser decides when Git is applied, and why the applier never applies on its own: a Flux `Kustomization` is held suspended by its `FluxClaim` and resumed for each apply, an Argo CD `Application`'s automated sync is held off by its `ArgoCDClaim`. Carries what building it taught (timers are triggers, a long interval is not exclusive control, a decision must hold at the write, a retry is a decision so only the worker makes one, an apply is an operation whose end is observed), the kustomize-controller v1.9.6 facts the suspended lifecycle rests on, its operation (resume token, outcome, the suspend after the apply), who owns `spec.suspend` (an applier claim acts only while an eligible `GitTarget` references it, one applier claim per applier), restart recovery, shared sources, the measured ~50 ms cost, flush then apply, `CatchUp` as adopt and converge for Flux and Argo CD alike, the apply window with the applier's own writes kept out of Git, resyncs held off while Git is ahead, another applier's objects kept out of a coordinated folder, and the decisions of 2026-10-17, 2026-10-19, 2026-10-20 and 2026-10-23 (the rule, the `allowCrossNamespace.appliers` grant, `Held`, the reversals of 2026-10-20, Argo CD's automated sync held off like a suspend) | +| [`apply-trigger.md`](design/apply-trigger.md) | **built for Flux and Argo CD; a webhook applier is the aim.** Why GitOps Reverser decides when Git is applied, and why the applier never applies on its own: a Flux `Kustomization` is held suspended by its `FluxClaim` and resumed for each apply, an Argo CD `Application`'s automated sync is held off by its `ArgoCDClaim`. Carries what building it taught (timers are triggers, a long interval is not exclusive control, a decision must hold at the write, a retry is a decision so only the worker makes one, an apply is an operation whose end is observed), the kustomize-controller v1.9.6 facts the suspended lifecycle rests on, its operation (resume token, outcome, the suspend after the apply), who owns `spec.suspend` (an applier claim acts only while an eligible `GitTarget` references it, one applier claim per applier), restart recovery, shared sources, the measured ~50 ms cost, flush then apply, `CatchUp` as adopt and converge for Flux and Argo CD alike, the apply window with the applier's own writes kept out of Git, resyncs held off while Git is ahead, another applier's objects kept out of a coordinated folder, and the decisions of 2026-10-17, 2026-10-19, 2026-10-20 and 2026-10-23 (the rule, the `allowCrossNamespace.appliers` grant, `InControl`, the reversals of 2026-10-20, Argo CD's automated sync held off like a suspend) | | [`push-cooldown.md`](design/push-cooldown.md) | **partly built.** Whether the fixed `5s` push cooldown still earns its place now that the commit window exists. Ordinary timer closures already space one author writing one target, but identity changes, saves with shorter timers and buffer-limit closures commit sooner, and serialization does not batch queued commits, so the cooldown is what batches them. Option C, the failure backoff, is built in #412 and shares the push timer; removing the success wait would now remove only `lastPushAt` and its branch, so that decision waits on the event-loop measurements in §9 | | [`multi-source-audit-ingress-hardening.md`](design/multi-source-audit-ingress-hardening.md) | how independent sources authenticate to a named audit route, when annotation routing is trustworthy, and how multi-provider ingestion remains fair | | [`release-image-reuse-plan.md`](design/release-image-reuse-plan.md) | PRs 2–5 unstarted | diff --git a/docs/architecture.md b/docs/architecture.md index de4ddd5c..e0710f46 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -483,7 +483,7 @@ Status has a kstatus-compatible summary layer plus domain conditions: applier applies this repository and branch. It is a gate. - `status.streams` is a bounded count summary, not a per-type list. -An applier claim has `Configured` (a `FluxClaim` also `Held`) and the kstatus trio, `status.source` +An applier claim has `Configured`, `InControl` and the kstatus trio, `status.source` (what its applier applies, as last read), and `status.operation` and `status.repairPending`, which the branch worker writes at every move of the operation it started. diff --git a/docs/bi-directional-argocd.md b/docs/bi-directional-argocd.md index 71f9ceed..2b3675ab 100644 --- a/docs/bi-directional-argocd.md +++ b/docs/bi-directional-argocd.md @@ -55,7 +55,7 @@ writes a switch that is somebody else's, and only checks it: `ServerSideApply=true`): it sets the switch on every apply. Set `enabled: false` in the manifest it applies, or leave `enabled` out of it and the applier claim holds the switch. -While that owner keeps automated sync off, the applier claim is `Held` and GitOps Reverser syncs as usual. +While that owner keeps automated sync off, the applier claim is `InControl` and GitOps Reverser syncs as usual. While it has it on, the applier claim reports `Configured=False` with `OwnedByApplicationSet` or `AutomatedSyncOwnedElsewhere`, and GitOps Reverser never syncs the `Application`. @@ -313,7 +313,7 @@ names it, is admitted by its `KubeProvider`s, and writes the repository and bran `Application` syncs. An applier claim nothing eligible references starts no sync and is not in line for the `Application`; one no `GitTarget` names also reads nothing in the `Application`'s cluster. -The applier claim reports `Configured` and `Held`, and its `Ready` includes both: +The applier claim reports `Configured` and `InControl`, and its `Ready` includes both: | Reason | Means | Readiness | | --- | --- | --- | @@ -333,7 +333,8 @@ The applier claim reports `Configured` and `Held`, and its `Ready` includes both | `ApplierNamespaceNotAllowed` | The `Application`'s namespace is not in the `applierNamespaces` the provider's grants give the applier claim's namespace | stalled | | `HeldByAnotherClaim` | Another `ArgoCDClaim` in line for the same `Application` in the same cluster, one an eligible `GitTarget` references, holds it (it holds the switch already, or was created first); this one starts no sync, and takes over once that one lets go | stalled: delete one of the two | -`Held` says whether automated sync is off: +`InControl` says whether GitOps Reverser decides, right now, when the `Application` syncs, which +is whether automated sync is off: | Reason | Means | | --- | --- | diff --git a/docs/bi-directional-flux.md b/docs/bi-directional-flux.md index 3d1d9506..a77647ec 100644 --- a/docs/bi-directional-flux.md +++ b/docs/bi-directional-flux.md @@ -319,7 +319,7 @@ stateDiagram-v2 `HeldByAnotherClaim`, does nothing, and takes over once the holder lets go. An applier claim nothing eligible references is not in line, so it keeps no other out. - **The applier claim hands the `Kustomization` back** when no eligible `GitTarget` references it any - more, and when it is deleted. `Held` reads `Releasing`: it suspends the `Kustomization` once more + more, and when it is deleted. `InControl` reads `Releasing`: it suspends the `Kustomization` once more if an apply has it resumed, waits until that is handled, and releases `spec.suspend`, so Flux applies it on its own again (a `Released` Event). A `Kustomization` that somebody suspended before the applier claim held it stays suspended. The applier claim records how it stood in @@ -331,21 +331,22 @@ stateDiagram-v2 which inherits `status.previousSuspendState`. A `KubeProvider` that is gone does not stop the hand-back: it goes on through whatever still reaches the cluster, which for an `inCluster` provider is the operator's own. When nothing - does, or the hand-back has not finished 10 minutes after `Held` turned `Releasing`, the applier claim + does, or the hand-back has not finished 10 minutes after `InControl` turned `Releasing`, the applier claim lets go with a `ReleaseFailed` Event saying where the `Kustomization` was left. A reference that returns before the release is done holds the `Kustomization` again; one that returns after adopts it anew. When the `KubeProvider`'s `allowedApplierClaims` no longer admit the applier claim, the - hand-back is the same, but `Held` reads `Revoking`, and it finishes even if a grant admits the + hand-back is the same, but `InControl` reads `Revoking`, and it finishes even if a grant admits the applier claim again meanwhile; the applier claim then adopts the `Kustomization` anew. - **Deleting or re-creating a `GitTarget` hands the `Kustomization` back.** Flux then applies the Git tip at once, which overwrites live edits not yet in Git, and the applier claim adopts the `Kustomization` again once the `GitTarget` is back. That is what Flux does without GitOps Reverser, and with no `GitTarget` nobody would carry those edits to Git anyway. -- **An applier claim whose configuration turns invalid keeps its hold.** It reports `Held=Unknown` +- **An applier claim whose configuration turns invalid keeps its hold.** It reports `InControl=Unknown` (`NotConfigured`), and the message says the `Kustomization` is left suspended under GitOps Reverser's field manager; deleting the applier claim, or its `GitTarget`s, hands it back. -The applier claim reports `Configured` and `Held`, and its `Ready` includes both: +The applier claim reports `Configured` and `InControl` (whether GitOps Reverser decides, right now, +when the `Kustomization` applies), and its `Ready` includes both: | Reason | Means | Readiness | | --- | --- | --- | @@ -362,13 +363,13 @@ The applier claim reports `Configured` and `Held`, and its `Ready` includes both | `Configured`: `AuditRouteConflict` | The provider carries an audit route another namespace holds, so it is not used | stalled | | `Configured`: `ApplierNamespaceNotAllowed` | The `Kustomization`'s namespace is not in the `applierNamespaces` the provider's grants give the applier claim's namespace | stalled | | `Configured`: `HeldByAnotherClaim` | Another `FluxClaim` holds this `Kustomization` | stalled: delete one of the two | -| `Held`: `Suspended` | Suspended under GitOps Reverser, and the last suspend is handled. Nothing applies | ready | -| `Held`: `Applying` | An apply GitOps Reverser started is in flight: `status.operation` shows it | ready | -| `Held`: `Suspending` | Just suspended; waiting for the `Kustomization` to handle it | progressing | -| `Held`: `Releasing` | Handing the `Kustomization` back: suspending it if needed, then releasing `spec.suspend`, within 10 minutes | progressing | -| `Held`: `Revoking` | The same hand-back, because the provider's `allowedApplierClaims` no longer admit the applier claim. It finishes even if a grant returns meanwhile | progressing | -| `Held`: `NotReferenced` | Nothing to hold: no `GitTarget` names the applier claim | ready | -| `Held`: `NotConfigured` | The applier claim is not `Configured`, so nothing is held, or the `Kustomization` is left suspended as the applier claim last held it (the message says which) | follows `Configured` | +| `InControl`: `Suspended` | Suspended under GitOps Reverser, and the last suspend is handled. Nothing applies | ready | +| `InControl`: `Applying` | An apply GitOps Reverser started is in flight: `status.operation` shows it | ready | +| `InControl`: `Suspending` | Just suspended; waiting for the `Kustomization` to handle it | progressing | +| `InControl`: `Releasing` | Handing the `Kustomization` back: suspending it if needed, then releasing `spec.suspend`, within 10 minutes | progressing | +| `InControl`: `Revoking` | The same hand-back, because the provider's `allowedApplierClaims` no longer admit the applier claim. It finishes even if a grant returns meanwhile | progressing | +| `InControl`: `NotReferenced` | Nothing to hold: no `GitTarget` names the applier claim | ready | +| `InControl`: `NotConfigured` | The applier claim is not `Configured`, so nothing is held, or the `Kustomization` is left suspended as the applier claim last held it (the message says which) | follows `Configured` | | `Ready`: `OperationUnresolved` | An apply has not completed within 5 minutes, or the `Kustomization` was [resumed by hand](#overriding-by-hand) | progressing; it clears when the apply does | `Ready=True` for an applier claim no `GitTarget` names is deliberate: it does all it is asked to, which is diff --git a/docs/configuration.md b/docs/configuration.md index fd6f6c9f..ad0269a2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -430,7 +430,7 @@ admits no new work under the revoked permission. Work it already held may comple - **A `GitTarget`, `WatchRule`, `ClusterWatchRule` or applier claim** that loses admission or scope is refused on its next reconcile. A write already decided is still committed and pushed. - **An applier claim its `allowedApplierClaims` refuse** hands its applier back, as one no eligible - `GitTarget` references does: it stays the holder (`Held=False`, `Revoking`) while it hands back, + `GitTarget` references does: it stays the holder (`InControl=False`, `Revoking`) while it hands back, keeps a `Kustomization` that was suspended before adoption suspended, and gives up after ten minutes with a Warning Event if it cannot finish. A regrant during the hand-back lets it finish, and the applier claim then adopts the applier anew. A read error never starts a hand-back. Once the @@ -1872,7 +1872,7 @@ status: status: "True" reason: Succeeded message: Kustomization flux-system/checkout applies GitRepository flux-system/platform, ... - - type: Held # FluxClaim only + - type: InControl status: "True" reason: Suspended # Suspended | Applying | Suspending | Releasing | Revoking | NotReferenced | NotConfigured - type: Ready @@ -1908,9 +1908,10 @@ A `Kustomization` resumed outside GitOps Reverser (`flux resume`) is suspended a an `Unresolved` operation with no `reason` and no `commit`, which completes once the suspend is handled. -**Conditions.** `Configured` is a gate, and the applier claim's `Ready` aggregates it; a `FluxClaim` is -also `Ready` only once `Held` is `True` (`Suspended`, or `Applying` while an apply of GitOps -Reverser's is in flight). Either kind is `Ready=False` (`OperationUnresolved`) while its operation +**Conditions.** `Configured` is a gate, and the applier claim's `Ready` aggregates it; either kind is +also `Ready` only once `InControl` is `True`: GitOps Reverser decides when the applier applies +(`Suspended`, or `Applying` while an apply of GitOps Reverser's is in flight; `AutomatedSyncOff` +for an `ArgoCDClaim`). Either kind is `Ready=False` (`OperationUnresolved`) while its operation is `Unresolved`. An unreferenced applier claim is `Ready=True` (`NotReferenced`). The `GitTarget`s naming the applier claim are reported as `NotBound` (`Unknown` while one cannot be judged yet, `False` once each is refused for different reasons), or as the refusal they share, diff --git a/docs/definitions.md b/docs/definitions.md index e1d795dc..f84f4a00 100644 --- a/docs/definitions.md +++ b/docs/definitions.md @@ -234,8 +234,8 @@ timing and nothing else: the applier still fetches, renders, applies, prunes and The applier claim does not say when to apply either; the branch worker decides that. Among applier claims that reach an applier through the same `KubeProvider`, one holds it and the others report `HeldByAnotherClaim`; applier claims made through two `KubeProvider`s for one cluster are not -arbitrated (see **In line**). Whether an applier claim holds its applier is **Held**, not -**Bound**. While it holds a `Kustomization`, a `FluxClaim` keeps it suspended and resumes it only +arbitrated (see **In line**). Holding the applier is not the same as being **in control** of it, +and neither is being **Bound**. While it holds a `Kustomization`, a `FluxClaim` keeps it suspended and resumes it only for an apply GitOps Reverser asks for. While it holds an `Application`, an `ArgoCDClaim` keeps automated sync off and starts each sync itself. Deleting either hands the applier back as `status.previousSuspendState` recorded it. A `GitTarget` names one with `spec.applierClaimRef` and @@ -254,7 +254,8 @@ claim's namespace, not being deleted, admitted by its `KubeProvider`s, and write and branch the applier fetches. An applier claim acts only while an eligible `GitTarget` references it; one nothing references is `NotReferenced`. Eligibility is judged from those inputs alone, never from the `GitTarget`'s `Ready`. **Bound** names an applier claim with at least one eligible -`GitTarget`; it says nothing about whether the applier claim holds its applier, which is **Held**. +`GitTarget`; it says nothing about whether the applier claim holds its applier, or is **in +control** of it. **In line.** The applier claims that may hold one applier: those an eligible `GitTarget` references, and one that holds the applier or is handing it back. One of them holds it; the others @@ -262,16 +263,19 @@ report `HeldByAnotherClaim`. Applier claims are in line together only when they through the same `KubeProvider`. GitOps Reverser cannot tell that two `KubeProvider`s reach the same cluster, so name one `KubeProvider` for every applier claim that reaches a given cluster. -**Held.** How an applier claim reports its applier, from its own side. For a `FluxClaim`: -`Suspended` (suspended under GitOps Reverser's field manager, the last suspend handled, nothing -applies), `Applying` (resumed for an apply GitOps Reverser started), `Suspending` (a suspend was -sent, and Flux has not handled its token yet), `Releasing` (being handed back to Flux). For an -`ArgoCDClaim`: `AutomatedSyncOff` (held off by GitOps Reverser, or kept off by whoever owns the -switch) and `Releasing`. Either may be `Revoking` (being handed back because a grant no longer -admits it), `NotReferenced` (nothing to hold) or `NotConfigured`. A -suspend counts as done only once it is handled: by then no -reconcile that started before it is still running. Not "fenced", and not "resumed", which says what -happens to the `Kustomization` rather than what the applier claim does. **Hand back** is releasing +**In control.** An applier claim is in control when it decides, right now, when its applier +applies: nothing the applier starts on its own can apply. Its `InControl` condition reports this, +and the reason says how the applier stands. The reasons and their values are listed with each kind, +in [The `FluxClaim`](bi-directional-flux.md#the-fluxclaim) and +[The `ArgoCDClaim`](bi-directional-argocd.md#the-argocdclaim) (and in +`kubectl explain fluxclaim.status.conditions`). In control is not **holding**: an applier claim +holds its applier from the moment it is first in line until it has handed it back, and while it +hands it back (`Releasing`, `Revoking`) it still holds it but is no longer in control. Nor is it +**Bound**. For a `FluxClaim`, a suspend counts only once it is handled: by then no reconcile that +started before it is still running, so a `Kustomization` suspended but not yet handled is not in +control. Not "held", which names holding; not "controlled", which reads as if the applier claim +were the one controlled; not "fenced"; and not "suspended", which says what happens to a +`Kustomization` rather than what the applier claim does. **Hand back** is releasing `spec.suspend`, or switching automated sync back, so the applier applies on its own again. **Apply request.** What GitOps Reverser sends an applier: apply this commit, for this reason diff --git a/docs/design/apply-trigger.md b/docs/design/apply-trigger.md index 3046bb52..6d6635d5 100644 --- a/docs/design/apply-trigger.md +++ b/docs/design/apply-trigger.md @@ -177,7 +177,7 @@ flowchart LR in flight). A recorded attempt that then asks for nothing withdraws its record, which puts back what the status held: the trigger never shows an operation nothing could have applied. The writer matches operations by their token, never by time. A `FluxTrigger` that is not - `Held=True` (still adopting, releasing, or being deleted) is not resumed (`RequestFailed`). A source + `InControl=True` (still adopting, releasing, or being deleted) is not resumed (`RequestFailed`). A source on another repository or branch, a `Kustomization` whose `spec.suspend` another manager applies, or an `Application` with automated sync on whose switch an `ApplicationSet` or another manager owns, is `Rejected`; one whose switch the trigger holds was switched on by somebody, and fails @@ -398,8 +398,8 @@ would not be pruned when Git later deletes it. started is never replaced. - **Adoption.** A `Configured` `FluxTrigger`, so one an eligible `GitTarget` references, puts its finalizer in place and then suspends its `Kustomization` with a server-side apply under - `gitops-reverser`, and a token. A `Normal` `Adopted` Event records it. It is `Held=True` - (`Suspended`), and `Ready`, once that token is handled, and `Held=True` (`Applying`) while an + `gitops-reverser`, and a token. A `Normal` `Adopted` Event records it. It is `InControl=True` + (`Suspended`), and `Ready`, once that token is handled, and `InControl=True` (`Applying`) while an operation of a branch worker is in flight and followed. A trigger that never adopted has no finalizer, and deletes at once. - **The manifest omits `suspend`.** Whatever applies the `Kustomization` object (a parent @@ -425,11 +425,11 @@ would not be pruned when Git later deletes it. nothing watching it. - **Hand-back.** The trigger carries the finalizer `configbutler.ai/flux-trigger` from before its first adoption write until it has handed the `Kustomization` back or over, also while its - configuration is invalid (then `Held=Unknown`, `NotConfigured`, and the message says the + configuration is invalid (then `InControl=Unknown`, `NotConfigured`, and the message says the `Kustomization` is left suspended under `gitops-reverser`). It hands it back when it is deleted, and when no eligible `GitTarget` references it anymore, conclusively: a reference that cannot be judged yet keeps the hold, since a release is not undone. Until the hand-back is done the trigger - is `Held=False`, `Releasing`, and stays the holder in line. It suspends the `Kustomization` with a + is `InControl=False`, `Releasing`, and stays the holder in line. It suspends the `Kustomization` with a token if an apply has it resumed, waits until that is handled, then releases `spec.suspend` with an empty apply, so Flux owns it again (`Released`). A `Kustomization` that was suspended when the trigger first held it (`status.previousSuspendState: Suspended`) stays suspended instead ([decisions of @@ -449,7 +449,7 @@ would not be pruned when Git later deletes it. `Kustomization` that is gone lets the trigger go. A `ClusterProvider` that is gone does not end the hand-back: it goes on through whatever client still reaches the cluster, which for a provider without `kubeConfig` is the operator's own. A cluster no client reaches anymore, a suspend or - release the identity may not make, or a hand-back still unfinished 10 minutes after `Held` turned + release the identity may not make, or a hand-back still unfinished 10 minutes after `InControl` turned `Releasing` (each a `Warning` `ReleaseFailed` Event, which says where the `Kustomization` was left) lets the trigger go. @@ -482,7 +482,7 @@ trigger's `status.operation`, so that converge asks nothing of an applier alread `Kustomization`s with that `sourceRef`, and `HelmRelease`s whose chart comes from it, directly or through a `HelmChart` their `chartRef` names (at whichever `HelmRelease` version the cluster serves; a listing that fails is logged and publishes nothing). Those no `FluxTrigger` on the same - `ClusterProvider` holds, that is reports `Held=True`, are `status.source.otherConsumers`, + `ClusterProvider` is in control of, that is reports `InControl=True`, are `status.source.otherConsumers`, information only: it never affects `Ready`, and no `Warning` is sent, because one `GitRepository` for `apps` and `infra` is a normal setup. Each of them applies a new artifact the moment it arrives. @@ -548,7 +548,7 @@ Taken 2026-10-19, after the seventh review: applier itself (`ReferenceGrant` style) stays a possible follow-up. 3. **`allowAnySourceNamespace` is removed, not migrated.** It is never read, and kept in the schema only so a rule on `spec` refuses `true` on any spec change. -4. **The trigger's status speaks from its own side:** `Held` with `Suspended`, `Applying`, +4. **The trigger's status speaks from its own side:** `InControl` with `Suspended`, `Applying`, `Suspending`, `Releasing`, `NotReferenced`, `NotConfigured`; "fence" is retired for "suspend". The operation phases keep `Running` and `Settling`. 5. **A holder keeps its `Kustomization` until it lets go:** a trigger that carries its finalizer diff --git a/docs/spec/e2e-bi-directional-corner.md b/docs/spec/e2e-bi-directional-corner.md index bd17e52b..c28153a5 100644 --- a/docs/spec/e2e-bi-directional-corner.md +++ b/docs/spec/e2e-bi-directional-corner.md @@ -494,7 +494,7 @@ env var no overlay declaration can express. Request an applier claim reconcile, since the applier is read on reconcile rather than watched. 2. Assert the switch is `false` again, with a `ClaimedResourceReconciled` Event on the - applier claim, `Held=True` (`AutomatedSyncOff`), and `gitops-reverser` among the + applier claim, `InControl=True` (`AutomatedSyncOff`), and `gitops-reverser` among the `Application`'s field managers. 3. Push a Git-side change. Assert GitOps Reverser syncs it as a `ForeignCommit` pinned to the pushed commit, and the order is live. @@ -509,7 +509,7 @@ env var no overlay declaration can express. `GitTarget`'s `ApplierClaimReady=False` with the same reason, and no `gitops-reverser` field manager on the `Application`. 3. Re-apply the `ApplicationSet` with `automated: {enabled: false}` in its template. - Assert `Held=True` (`AutomatedSyncOff`, kept off by the `ApplicationSet`), + Assert `InControl=True` (`AutomatedSyncOff`, kept off by the `ApplicationSet`), `ApplierClaimReady=True`, still no `gitops-reverser` field manager, and no finalizer on the applier claim. diff --git a/docs/spec/status-conditions-guide.md b/docs/spec/status-conditions-guide.md index 2aadacb8..938df9c5 100644 --- a/docs/spec/status-conditions-guide.md +++ b/docs/spec/status-conditions-guide.md @@ -302,7 +302,7 @@ Canonical reads: ### Applier claims -`FluxClaim` and `ArgoCDClaim` use the trio with `Configured` as their gate, and `Held` as a second +`FluxClaim` and `ArgoCDClaim` use the trio with `Configured` as their gate, and `InControl` as a second one. Canonical reads: @@ -311,15 +311,15 @@ Canonical reads: reason `NotReferenced`, `Reconciling=False`, `Stalled=False`. As for a `GitTarget` with nothing to mirror, doing nothing when nothing is asked is converged, and a Flux health check that waits on the applier claim must not wait for the `GitTarget` that will name it -- holding its applier: `Configured=True`, for a `FluxClaim` `Held=True` (`Suspended` or - `Applying`), and `Ready=True` +- in control of its applier: `Configured=True`, `InControl=True` (`Suspended` or `Applying` for a + `FluxClaim`, `AutomatedSyncOff` for an `ArgoCDClaim`), and `Ready=True` - applier or `KubeProvider` missing (`ApplierNotFound`, `KubeProviderNotFound`): `Ready=False`, `Reconciling=True`, `Stalled=False`, because Flux and Argo CD objects are often created after the applier claim. Every other `Configured=False` reason is `Stalled=True` - an operation past its time (`status.operation.phase: Unresolved`): `Ready=False`, `Reconciling=True`, reason `OperationUnresolved`. It clears when the operation completes - a `FluxClaim` whose suspend is not handled yet, or that is handing its `Kustomization` back: - `Held=False` (`Suspending`, `Releasing`), `Ready=False`, `Reconciling=True` + `InControl=False` (`Suspending`, `Releasing`), `Ready=False`, `Reconciling=True` A `GitTarget` naming an applier claim gains `ApplierClaimReady`, a gate on its own trio. `SourceMismatch`, or an applier claim that is itself `Stalled`, stalls the target; a missing applier diff --git a/internal/applier/applier.go b/internal/applier/applier.go index f8fd5d00..13bfeb52 100644 --- a/internal/applier/applier.go +++ b/internal/applier/applier.go @@ -74,11 +74,11 @@ type Ask struct { // Current is asked again after it, since it can wait on the status writer and the API server. // Nil records nothing. Record func(ctx context.Context) error - // Held reports, from a fresh read, whether the applier claim holds its Flux Kustomization suspended - // (Held=True), and errs when it does not: a resume while the applier claim's reconciler is still - // suspending it (an adoption, an external resume) would race that suspend. It is asked just - // before Record. Nil always holds; the Argo CD client never asks it. - Held func(ctx context.Context) error + // InControl reports, from a fresh read, whether the applier claim is in control of its Flux + // Kustomization (InControl=True), and errs when it is not: a resume while the applier claim's + // reconciler is still suspending it (an adoption, an external resume) would race that suspend. It + // is asked just before Record. Nil always is; the Argo CD client never asks it. + InControl func(ctx context.Context) error } // recordOnce is Record, called the first time only: a request that writes more than once (an Argo diff --git a/internal/applier/dispatcher.go b/internal/applier/dispatcher.go index 851ea2a8..c780c235 100644 --- a/internal/applier/dispatcher.go +++ b/internal/applier/dispatcher.go @@ -126,7 +126,7 @@ type Dispatcher struct { // Now is the clock request tokens are taken from. Now func() time.Time // APIReader reads a FluxClaim uncached just before its Kustomization is resumed, so a suspend - // its reconciler is making is seen (Ask.Held). NewDispatcher sets it to the reader it was given. + // its reconciler is making is seen (Ask.InControl). NewDispatcher sets it to the reader it was given. APIReader client.Reader runMu sync.RWMutex @@ -374,7 +374,7 @@ func (d *Dispatcher) attempt(ctx context.Context, req git.ApplyRequest) (git.App applier := d.client(req.Applier.Kind, applierClient) started, err := applier.Request(ctx, applierName(req.Applier), Ask{ Want: want, Commit: req.Commit, Reason: req.Reason, Token: req.Token, Current: req.Current, Record: req.Record, - Held: func(ctx context.Context) error { return d.claimHolds(ctx, req.Applier.ApplierClaim) }, + InControl: func(ctx context.Context) error { return d.claimInControl(ctx, req.Applier.ApplierClaim) }, }) switch { case errors.Is(err, errStale): @@ -565,20 +565,20 @@ func (d *Dispatcher) progress(ctx context.Context, req git.ApplyRequest, op *ope return progress, err } -// ApplierClaimConditionHeld is a FluxClaim's Held condition: True (reason Suspended) once its -// Kustomization is suspended under GitOps Reverser's field manager and that suspend's token is -// handled, and True (reason Applying) while an operation of a branch worker has it resumed. The -// applier claim's reconciler sets it. -const ApplierClaimConditionHeld = "Held" +// ApplierClaimConditionInControl is an applier claim's InControl condition. For a FluxClaim: True +// (reason Suspended) once its Kustomization is suspended under GitOps Reverser's field manager and +// that suspend's token is handled, and True (reason Applying) while an operation of a branch worker +// has it resumed. The applier claim's reconciler sets it. +const ApplierClaimConditionInControl = "InControl" -// claimHolds is Ask.Held: it reads the FluxClaim uncached, and errs unless it is -// Held=True and not being deleted. Its reconciler is otherwise suspending the Kustomization, +// claimInControl is Ask.InControl: it reads the FluxClaim uncached, and errs unless it is +// InControl=True and not being deleted. Its reconciler is otherwise suspending the Kustomization, // or about to (an adoption, an external resume it suspends again, a hand-back), and that suspend would land // on the resume: Flux would then handle the resume's token on a suspended return, and apply // nothing. It also errs when another applier claim comes first in line for the Kustomization, read // uncached as well: the recheck's cached read can still miss one. The attempt fails, and the worker // asks again after its backoff. -func (d *Dispatcher) claimHolds(ctx context.Context, ref git.ApplierClaimRef) error { +func (d *Dispatcher) claimInControl(ctx context.Context, ref git.ApplierClaimRef) error { if ref.Kind != v1alpha3.ApplierClaimKindFlux { return nil } @@ -586,7 +586,7 @@ func (d *Dispatcher) claimHolds(ctx context.Context, ref git.ApplierClaimRef) er if err := d.APIReader.Get(ctx, client.ObjectKey{Namespace: ref.Namespace, Name: ref.Name}, claim); err != nil { return fmt.Errorf("read %s: %w", ref, err) } - held := apimeta.FindStatusCondition(claim.Status.Conditions, ApplierClaimConditionHeld) + inControl := apimeta.FindStatusCondition(claim.Status.Conditions, ApplierClaimConditionInControl) holder, err := HeldByAnother(ctx, d.APIReader, claim) switch { case err != nil: @@ -597,12 +597,12 @@ func (d *Dispatcher) claimHolds(ctx context.Context, ref git.ApplierClaimRef) er case !claim.DeletionTimestamp.IsZero(): return fmt.Errorf("FluxClaim %s/%s is being deleted, and hands its Kustomization back", ref.Namespace, ref.Name) - case held == nil || held.Status != metav1.ConditionTrue: - message := "it reports no Held condition yet" - if held != nil { - message = fmt.Sprintf("it is Held=%s, %s: %s", held.Status, held.Reason, held.Message) + case inControl == nil || inControl.Status != metav1.ConditionTrue: + message := "it reports no InControl condition yet" + if inControl != nil { + message = fmt.Sprintf("it is InControl=%s, %s: %s", inControl.Status, inControl.Reason, inControl.Message) } - return fmt.Errorf("FluxClaim %s/%s does not hold its Kustomization suspended: %s", + return fmt.Errorf("FluxClaim %s/%s is not in control of its Kustomization: %s", ref.Namespace, ref.Name, message) } return nil diff --git a/internal/applier/dispatcher_test.go b/internal/applier/dispatcher_test.go index c7fbdbba..ea16bbc5 100644 --- a/internal/applier/dispatcher_test.go +++ b/internal/applier/dispatcher_test.go @@ -135,12 +135,12 @@ func claimsFor(objects []client.Object) []client.Object { } if ks, ok := target.Annotations[testKustomizationAnnotation]; ok { namespace, name, _ := strings.Cut(ks, "/") - // Its reconciler holds the Kustomization (Ask.Held), unless a test says otherwise. + // Its reconciler is in control of the Kustomization (Ask.InControl), unless a test says otherwise. claims = append(claims, &v1alpha3.FluxClaim{ObjectMeta: meta, Spec: v1alpha3.FluxClaimSpec{ KubeProviderRef: provider, KustomizationRef: fluxmeta.NamespacedObjectReference{Namespace: namespace, Name: name}, }, Status: v1alpha3.FluxClaimStatus{Conditions: []metav1.Condition{{ - Type: ApplierClaimConditionHeld, Status: metav1.ConditionTrue, Reason: "Suspended", + Type: ApplierClaimConditionInControl, Status: metav1.ConditionTrue, Reason: "Suspended", LastTransitionTime: metav1.Now(), }}}}) } @@ -866,11 +866,11 @@ func TestDispatcher_AStopMidApplySuspendsTheKustomization(t *testing.T) { f.sim.Release() } -// TestDispatcher_AnUnheldClaimIsNotResumed is review 4's F: the applier claim's reconciler suspends the +// TestDispatcher_AClaimNotInControlIsNotResumed is review 4's F: the applier claim's reconciler suspends the // Kustomization on its own reads (an adoption, an external resume), and a resume between its read // and its suspend would be suspended under the apply. So a request resumes only a Kustomization its -// applier claim reports Held=True, read uncached; any other is a failed attempt the worker retries. -func TestDispatcher_AnUnheldClaimIsNotResumed(t *testing.T) { +// applier claim reports InControl=True, read uncached; any other is a failed attempt the worker retries. +func TestDispatcher_AClaimNotInControlIsNotResumed(t *testing.T) { f := newDispatcherFixture(t, []client.Object{gitTarget("a", checkoutRef(), "")}, behindSource()...) claim := &v1alpha3.FluxClaim{} require.NoError(t, f.control.Get(context.Background(), client.ObjectKey{Namespace: targetNS, Name: "checkout"}, @@ -881,7 +881,7 @@ func TestDispatcher_AnUnheldClaimIsNotResumed(t *testing.T) { res := f.attempt(request(checkout, v1alpha3.ApplyReasonForeignCommit, "a")) assert.Equal(t, git.ApplyRequestFailed, res.Outcome) - require.ErrorContains(t, res.Err, "does not hold its Kustomization suspended: it is Held=False, Suspending") + require.ErrorContains(t, res.Err, "is not in control of its Kustomization: it is InControl=False, Suspending") for _, write := range f.patches() { assert.NotContains(t, write, "suspend=false", "nothing is resumed") } diff --git a/internal/applier/flux.go b/internal/applier/flux.go index 9fa3fd9c..215f5e84 100644 --- a/internal/applier/flux.go +++ b/internal/applier/flux.go @@ -265,8 +265,8 @@ func (f *Flux) mayResume( if !artifactHolds(source, ask.Commit) || !ask.holds() { return errStale } - if ask.Held != nil { - if err := ask.Held(ctx); err != nil { + if ask.InControl != nil { + if err := ask.InControl(ctx); err != nil { return fmt.Errorf("not resuming %s %s: %w", kindKustomization, ref, err) } } diff --git a/internal/controller/applierclaim_controller.go b/internal/controller/applierclaim_controller.go index e08d0a52..ee0df400 100644 --- a/internal/controller/applierclaim_controller.go +++ b/internal/controller/applierclaim_controller.go @@ -43,31 +43,33 @@ const ApplierClaimConditionConfigured = "Configured" // time it was given. It clears when the operation completes. const ReasonOperationUnresolved = "OperationUnresolved" -// ApplierClaimConditionHeld reports whether an applier claim holds its applier. A FluxClaim holds its -// Kustomization suspended under GitOps Reverser's field manager, with the last suspend's token -// handled, so nothing that started before the suspend is still running, or resumed for an apply of -// GitOps Reverser's own. An ArgoCDClaim holds its Application's automated sync off -// (argocdclaim_hold.go). An applier claim is Ready only once it holds its applier. -const ApplierClaimConditionHeld = applier.ApplierClaimConditionHeld - -// Held reasons, and the Events of the hold lifecycle on an applier claim. +// ApplierClaimConditionInControl reports whether an applier claim decides, right now, when its applier +// applies. A FluxClaim is in control while its Kustomization is suspended under GitOps Reverser's +// field manager, with the last suspend's token handled, so nothing that started before the suspend +// is still running, or resumed for an apply of GitOps Reverser's own. An ArgoCDClaim is in control +// while its Application's automated sync is off (argocdclaim_hold.go). Holding the applier is +// something else: an applier claim handing its applier back still holds it, and is not in control. +// An applier claim is Ready only once it is in control. +const ApplierClaimConditionInControl = applier.ApplierClaimConditionInControl + +// InControl reasons, and the Events of the hold lifecycle on an applier claim. const ( - // ReasonSuspended is Held=True: the Kustomization is suspended, the last suspend handled, and + // ReasonSuspended is InControl=True: the Kustomization is suspended, the last suspend handled, and // nothing applies. ReasonSuspended = "Suspended" - // ReasonApplying is Held=True while an operation of a branch worker is in flight and followed: + // ReasonApplying is InControl=True while an operation of a branch worker is in flight and followed: // the Kustomization is resumed for it, and suspended again when it ends. ReasonApplying = "Applying" - // ReasonSuspending is Held=False while the token of the suspend is not handled yet. + // ReasonSuspending is InControl=False while the token of the suspend is not handled yet. ReasonSuspending = "Suspending" - // ReasonNotConfigured is Held=Unknown on an applier claim that is not Configured: nothing is held, or + // ReasonNotConfigured is InControl=Unknown on an applier claim that is not Configured: nothing is held, or // the Kustomization is left suspended as the applier claim last held it (the message says which). ReasonNotConfigured = "NotConfigured" - // ReasonReleasing is Held=False on an applier claim that held its Kustomization and is handing it back: - // it is being deleted, or no eligible GitTarget references it any more. Held=False with + // ReasonReleasing is InControl=False on an applier claim that held its Kustomization and is handing + // it back: it is being deleted, or no eligible GitTarget references it any more. InControl=False with // applier.ReasonNotReferenced is an applier claim with nothing to hold. ReasonReleasing = "Releasing" - // ReasonRevoking is Held=False on an applier claim that held its applier and is handing it back + // ReasonRevoking is InControl=False on an applier claim that held its applier and is handing it back // because its KubeProvider's spec.allowedApplierClaims no longer admit it. The hand-back finishes // even if a grant admits the applier claim again meanwhile, which then adopts its applier anew. ReasonRevoking = "Revoking" @@ -154,7 +156,7 @@ type ApplierClaimReconciler struct { // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch // +kubebuilder:rbac:groups="",resources=events,verbs=create;patch -// Reconcile publishes one applier claim's Configured and Held conditions, its source and the readiness +// Reconcile publishes one applier claim's Configured and InControl conditions, its source and the readiness // trio. func (r *ApplierClaimReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { claim := configbutleraiv1alpha3.NewApplierClaim(r.Kind) @@ -195,12 +197,12 @@ func (r *ApplierClaimReconciler) reconcileFlux( if check.retiring(claim) { return r.retire(ctx, st, claim, &check) } - held, err := r.holdFlux(ctx, st, claim, check) + inControl, err := r.holdFlux(ctx, st, claim, check) if err != nil { return ctrl.Result{}, err } - st.setValue(ApplierClaimConditionHeld, held) - return r.commitClaim(ctx, st, r.claimReadiness(claim, check.configured, &held), &held, + st.setValue(ApplierClaimConditionInControl, inControl) + return r.commitClaim(ctx, st, r.claimReadiness(claim, check.configured, &inControl), &inControl, heldClaimSteadyInterval) } @@ -216,7 +218,7 @@ func publishCheck(st *reconcileStatus, claim configbutleraiv1alpha3.ApplierClaim // applier claim again on: steady, or sooner while a suspend is waited for or something may clear on its // own. func (r *ApplierClaimReconciler) commitClaim( - ctx context.Context, st *reconcileStatus, rd *readiness, held *conditionValue, steady time.Duration, + ctx context.Context, st *reconcileStatus, rd *readiness, inControl *conditionValue, steady time.Duration, ) (ctrl.Result, error) { st.applyReadiness(rd) if err := st.commit(ctx); err != nil { @@ -224,7 +226,7 @@ func (r *ApplierClaimReconciler) commitClaim( } requeue := steady switch { - case held != nil && held.Reason == ReasonSuspending: + case inControl != nil && inControl.Reason == ReasonSuspending: requeue = applierClaimSuspendingInterval case rd.converging(): requeue = applierClaimSettleInterval @@ -232,7 +234,7 @@ func (r *ApplierClaimReconciler) commitClaim( return ctrl.Result{RequeueAfter: st.requeueAfter(requeue)}, nil } -// holdFlux returns a FluxClaim's Held condition. A Configured applier claim holds its Kustomization, +// holdFlux returns a FluxClaim's InControl condition. A Configured applier claim holds its Kustomization, // with its finalizer put in place before the first adoption write, and then publishes its source // consumers. One that is not Configured holds nothing, // and one in line behind another holder lets go of a finalizer it carries, handing nothing back. @@ -246,11 +248,11 @@ func (r *ApplierClaimReconciler) holdFlux( if err := r.setFinalizer(ctx, st, claim, true); err != nil { return conditionValue{}, err } - held := r.holdKustomization(ctx, st, claim) + inControl := r.holdKustomization(ctx, st, claim) if check.checked { r.publishAlsoAppliedBy(ctx, claim) } - return held, nil + return inControl, nil case configured.Reason == applier.ReasonNotReferenced: return conditionValue{Status: metav1.ConditionFalse, Reason: applier.ReasonNotReferenced, Message: "nothing to hold: no GitTarget names this applier claim"}, nil @@ -464,15 +466,15 @@ func claimSourceSummary(claim configbutleraiv1alpha3.ApplierClaim) string { // claimReadiness is the trio's accumulator for an applier claim, its gates contributed. func (r *ApplierClaimReconciler) claimReadiness( - claim configbutleraiv1alpha3.ApplierClaim, configured conditionValue, held *conditionValue, + claim configbutleraiv1alpha3.ApplierClaim, configured conditionValue, inControl *conditionValue, ) *readiness { rd := newReadiness(fmt.Sprintf("%s %s %s can be asked to apply", claim.ApplierKind(), claim.Applier(), claimSourceSummary(claim)), claim.ClaimKind()+" is not stalled") - applierClaimReadinessGates(rd, configured, held, claim.Operation()) + applierClaimReadinessGates(rd, configured, inControl, claim.Operation()) return rd } -// applierClaimReadinessGates contributes Configured, the operation, and Held to the trio, in that +// applierClaimReadinessGates contributes Configured, the operation, and InControl to the trio, in that // order. // An applier that is missing, or a KubeProvider that is, may be on its way (Flux and Argo CD // objects are often created after the applier claim); anything else Configured=False needs a human. An @@ -480,7 +482,8 @@ func (r *ApplierClaimReconciler) claimReadiness( // names is Ready (NotReferenced): it does all it is asked to, which is nothing, and a Flux health // check that waits on it must not wait for the GitTarget that will name it. func applierClaimReadinessGates( - rd *readiness, configured conditionValue, held *conditionValue, operation *configbutleraiv1alpha3.ApplyOperation, + rd *readiness, configured conditionValue, inControl *conditionValue, + operation *configbutleraiv1alpha3.ApplyOperation, ) { if configured.Reason == applier.ReasonNotReferenced { rd.convergesAs(conditionValue{Status: metav1.ConditionTrue, Reason: applier.ReasonNotReferenced, @@ -502,9 +505,9 @@ func applierClaimReadinessGates( rd.progressing(metav1.ConditionFalse, ReasonOperationUnresolved, fmt.Sprintf( "%s %s, has not completed: %s", what, operation.RequestedAt.UTC().Format(time.RFC3339), operation.Message)) } - if held != nil { - rd.progressingIf(held.Status != metav1.ConditionTrue && held.Reason != applier.ReasonNotReferenced, - held.Status, held.Reason, held.Message) + if inControl != nil { + rd.progressingIf(inControl.Status != metav1.ConditionTrue && inControl.Reason != applier.ReasonNotReferenced, + inControl.Status, inControl.Reason, inControl.Message) } } @@ -597,7 +600,7 @@ func (r *ApplierClaimReconciler) listClaims( } // claimLineChanged passes the event of an applier claim that is gone, and an update that can move it in -// or out of line for its applier: its finalizer, its Configured condition, or its Held one. +// or out of line for its applier: its finalizer, its Configured condition, or its InControl one. func claimLineChanged() predicate.Predicate { return predicate.Funcs{ CreateFunc: func(event.CreateEvent) bool { return false }, @@ -615,7 +618,7 @@ func claimLineChanged() predicate.Predicate { func lineSummary(claim configbutleraiv1alpha3.ApplierClaim) string { var summary strings.Builder summary.WriteString(strconv.FormatBool(applier.Holding(claim))) - for _, conditionType := range []string{ApplierClaimConditionConfigured, ApplierClaimConditionHeld} { + for _, conditionType := range []string{ApplierClaimConditionConfigured, ApplierClaimConditionInControl} { if cond := findCondition(*claim.StatusConditions(), conditionType); cond != nil { summary.WriteString("|" + string(cond.Status) + "/" + cond.Reason) } diff --git a/internal/controller/applierclaim_controller_test.go b/internal/controller/applierclaim_controller_test.go index 028c9044..1def4d89 100644 --- a/internal/controller/applierclaim_controller_test.go +++ b/internal/controller/applierclaim_controller_test.go @@ -281,13 +281,13 @@ func reconcileClaim(t *testing.T, r *ApplierClaimReconciler) configbutleraiv1alp require.NoError(t, r.Get(context.Background(), claimKey, claim)) require.NotNil(t, findCondition(*claim.StatusConditions(), ConditionTypeReady), "every pass publishes Ready") - held := findCondition(*claim.StatusConditions(), ApplierClaimConditionHeld) - if conditionIsTrue(*claim.StatusConditions(), ApplierClaimConditionConfigured) && held != nil && - !strings.Contains(held.Message, "which owns the switch") { + inControl := findCondition(*claim.StatusConditions(), ApplierClaimConditionInControl) + if conditionIsTrue(*claim.StatusConditions(), ApplierClaimConditionConfigured) && inControl != nil && + !strings.Contains(inControl.Message, "which owns the switch") { require.True(t, controllerutil.ContainsFinalizer(claim, claimFinalizer(claim)), "an applier claim that holds its applier carries the finalizer") } - if held != nil && held.Reason == ReasonSuspending { + if inControl != nil && inControl.Reason == ReasonSuspending { assert.Equal(t, applierClaimSuspendingInterval, result.RequeueAfter, "a suspend is waited for closely") return false } diff --git a/internal/controller/applierclaim_holder_test.go b/internal/controller/applierclaim_holder_test.go index a6b9f020..214751cc 100644 --- a/internal/controller/applierclaim_holder_test.go +++ b/internal/controller/applierclaim_holder_test.go @@ -64,8 +64,8 @@ func (f *holdFixture) reconcileKey(t *testing.T, key k8stypes.NamespacedName) *c _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: key}) require.NoError(t, err) require.NoError(t, f.r.Get(context.Background(), key, claim)) - held := findCondition(claim.Status.Conditions, ApplierClaimConditionHeld) - return held == nil || held.Reason != ReasonSuspending + inControl := findCondition(claim.Status.Conditions, ApplierClaimConditionInControl) + return inControl == nil || inControl.Reason != ReasonSuspending }, 5*time.Second, 5*time.Millisecond) return claim } @@ -94,7 +94,7 @@ func TestFluxClaim_OneClaimHoldsAKustomization(t *testing.T) { second := f.reconcileKey(t, inLineKey) first := f.reconcileKey(t, claimKey) - assert.True(t, conditionIsTrue(first.Status.Conditions, ApplierClaimConditionHeld), "the first holds it") + assert.True(t, conditionIsTrue(first.Status.Conditions, ApplierClaimConditionInControl), "the first holds it") assert.True(t, conditionIsTrue(first.Status.Conditions, ConditionTypeReady)) configured := findCondition(second.Status.Conditions, ApplierClaimConditionConfigured) require.NotNil(t, configured) @@ -108,10 +108,10 @@ func TestFluxClaim_OneClaimHoldsAKustomization(t *testing.T) { assert.Equal(t, metav1.ConditionFalse, ready.Status) assert.Equal(t, applier.ReasonHeldByAnotherClaim, ready.Reason) assert.True(t, conditionIsTrue(second.Status.Conditions, ConditionTypeStalled), "a human removes one of them") - held := findCondition(second.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Equal(t, metav1.ConditionUnknown, held.Status) - assert.Equal(t, ReasonNotConfigured, held.Reason) + inControl := findCondition(second.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Equal(t, metav1.ConditionUnknown, inControl.Status) + assert.Equal(t, ReasonNotConfigured, inControl.Reason) events := f.events() require.Len(t, events, 1, "one adoption, by the holder: %v", events) @@ -174,7 +174,7 @@ func TestFluxClaim_DeletingTheClaimInLineHandsNothingBack(t *testing.T) { assert.Contains(t, kustomizationManagers(ks), applier.SuspendFieldManager) assert.Empty(t, f.events(), "nothing released") holder := f.reconcileKey(t, claimKey) - assert.True(t, conditionIsTrue(holder.Status.Conditions, ApplierClaimConditionHeld)) + assert.True(t, conditionIsTrue(holder.Status.Conditions, ApplierClaimConditionInControl)) } // TestFluxClaim_DeletingTheHolderHandsOverToTheNextInLine: the holder suspends the Kustomization as @@ -205,7 +205,7 @@ func TestFluxClaim_DeletingTheHolderHandsOverToTheNextInLine(t *testing.T) { second := f.reconcileKey(t, inLineKey) assert.True(t, conditionIsTrue(second.Status.Conditions, ApplierClaimConditionConfigured)) - assert.True(t, conditionIsTrue(second.Status.Conditions, ApplierClaimConditionHeld)) + assert.True(t, conditionIsTrue(second.Status.Conditions, ApplierClaimConditionInControl)) assert.True(t, conditionIsTrue(second.Status.Conditions, ConditionTypeReady)) assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization))) assert.Equal(t, []string{"Normal Released left Kustomization flux-system/checkout suspended for FluxClaim " + diff --git a/internal/controller/applierclaim_revocation_test.go b/internal/controller/applierclaim_revocation_test.go index 92be492c..5df2ca80 100644 --- a/internal/controller/applierclaim_revocation_test.go +++ b/internal/controller/applierclaim_revocation_test.go @@ -70,9 +70,9 @@ func TestFluxClaim_ADenialHandsTheKustomizationBack(t *testing.T) { require.NotNil(t, configured) assert.Equal(t, authz.ReasonNamespaceNotAuthorized, configured.Reason) assert.Contains(t, configured.Message, "spec.allowedApplierClaims") - held := findCondition(got.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Contains(t, held.Message, "held again once the KubeProvider's spec.allowedApplierClaims admit") + inControl := findCondition(got.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Contains(t, inControl.Message, "held again once the KubeProvider's spec.allowedApplierClaims admit") events := f.events() require.Len(t, events, 1) assert.Contains(t, events[0], EventReasonReleased) @@ -157,9 +157,9 @@ func TestFluxClaim_ADenialThatCannotHandBackGivesUp(t *testing.T) { got := f.claim(t) if !tc.atOnce { require.False(t, handedBack(got)) - assert.Equal(t, ReasonRevoking, heldReason(t, got), "the hand-back shows in status") - held := findCondition(got.Status.Conditions, ApplierClaimConditionHeld) - held.LastTransitionTime = metav1.NewTime(time.Now().Add(-releaseDeadline - time.Minute)) + assert.Equal(t, ReasonRevoking, inControlReason(t, got), "the hand-back shows in status") + inControl := findCondition(got.Status.Conditions, ApplierClaimConditionInControl) + inControl.LastTransitionTime = metav1.NewTime(time.Now().Add(-releaseDeadline - time.Minute)) require.NoError(t, f.r.Status().Update(context.Background(), got)) _, _ = f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) got = f.claim(t) @@ -184,16 +184,16 @@ func TestFluxClaim_ARegrantDuringTheHandBackLetsItFinish(t *testing.T) { f.setApplierGrants(t, nil) _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) - require.Equal(t, ReasonRevoking, heldReason(t, f.claim(t))) + require.Equal(t, ReasonRevoking, inControlReason(t, f.claim(t))) f.setApplierGrants(t, grants) _, err = f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) - assert.Equal(t, ReasonRevoking, heldReason(t, f.claim(t)), "the hand-back is not abandoned") + assert.Equal(t, ReasonRevoking, inControlReason(t, f.claim(t)), "the hand-back is not abandoned") unpause() f.reconcileUntil(t, func(claim *configbutleraiv1alpha3.FluxClaim) bool { - return heldReason(t, claim) == ReasonSuspended + return inControlReason(t, claim) == ReasonSuspended }) assert.True(t, controllerutil.ContainsFinalizer(f.claim(t), configbutleraiv1alpha3.FluxClaimFinalizer)) var reasons []string diff --git a/internal/controller/applierclaim_rule_test.go b/internal/controller/applierclaim_rule_test.go index 19483c02..15bac30a 100644 --- a/internal/controller/applierclaim_rule_test.go +++ b/internal/controller/applierclaim_rule_test.go @@ -47,7 +47,7 @@ func fluxTarget() *configbutleraiv1alpha3.GitTarget { return referencingTarget("checkout-flux", configbutleraiv1alpha3.ApplierClaimKindFlux) } -// settle reconciles the applier claim at key until its Held condition is neither Suspending nor +// settle reconciles the applier claim at key until its InControl condition is neither Suspending nor // Releasing, and returns it. func (f *holdFixture) settle(t *testing.T, key k8stypes.NamespacedName) *configbutleraiv1alpha3.FluxClaim { t.Helper() @@ -56,18 +56,18 @@ func (f *holdFixture) settle(t *testing.T, key k8stypes.NamespacedName) *configb _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: key}) require.NoError(t, err) require.NoError(t, f.r.Get(context.Background(), key, claim)) - held := findCondition(claim.Status.Conditions, ApplierClaimConditionHeld) - return held != nil && held.Reason != ReasonSuspending && !handingBack(held) + inControl := findCondition(claim.Status.Conditions, ApplierClaimConditionInControl) + return inControl != nil && inControl.Reason != ReasonSuspending && !handingBack(inControl) }, 5*time.Second, 5*time.Millisecond) return claim } -// heldReason is the applier claim's Held reason. -func heldReason(t *testing.T, claim *configbutleraiv1alpha3.FluxClaim) string { +// inControlReason is the applier claim's InControl reason. +func inControlReason(t *testing.T, claim *configbutleraiv1alpha3.FluxClaim) string { t.Helper() - held := findCondition(claim.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - return held.Reason + inControl := findCondition(claim.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + return inControl.Reason } // TestApplierClaimRule_AnUnreferencedClaimDoesNothingAndIsReady: no call reaches the applier's @@ -103,7 +103,7 @@ func TestApplierClaimRule_AnUnreferencedClaimDoesNothingAndIsReady(t *testing.T) url, _, known := got.SourceBinding() assert.False(t, known, "no source is published: %s", url) if flux, ok := got.(*configbutleraiv1alpha3.FluxClaim); ok { - assert.Equal(t, applier.ReasonNotReferenced, heldReason(t, flux)) + assert.Equal(t, applier.ReasonNotReferenced, inControlReason(t, flux)) } }) } @@ -114,13 +114,13 @@ func TestApplierClaimRule_AnUnreferencedClaimDoesNothingAndIsReady(t *testing.T) func TestApplierClaimRule_TheFirstEligibleReferenceAdoptsAndTheLastReleases(t *testing.T) { f := ruleFixture(t, testFluxClaim(claimLocalProvider)) idle := f.settle(t, claimKey) - assert.Equal(t, applier.ReasonNotReferenced, heldReason(t, idle)) + assert.Equal(t, applier.ReasonNotReferenced, inControlReason(t, idle)) assert.False(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "Flux applies it on its own") target := fluxTarget() require.NoError(t, f.r.Create(context.Background(), target)) held := f.settle(t, claimKey) - assert.Equal(t, ReasonSuspended, heldReason(t, held)) + assert.Equal(t, ReasonSuspended, inControlReason(t, held)) assert.True(t, conditionIsTrue(held.Status.Conditions, ConditionTypeReady)) assert.True(t, controllerutil.ContainsFinalizer(held, configbutleraiv1alpha3.FluxClaimFinalizer)) assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization))) @@ -130,7 +130,7 @@ func TestApplierClaimRule_TheFirstEligibleReferenceAdoptsAndTheLastReleases(t *t require.NoError(t, f.r.Delete(context.Background(), target)) released := f.settle(t, claimKey) - assert.Equal(t, applier.ReasonNotReferenced, heldReason(t, released)) + assert.Equal(t, applier.ReasonNotReferenced, inControlReason(t, released)) ready := findCondition(released.Status.Conditions, ConditionTypeReady) require.NotNil(t, ready) assert.Equal(t, metav1.ConditionTrue, ready.Status) @@ -160,7 +160,7 @@ func TestApplierClaimRule_ARecreatedGitTargetReleasesAndReadopts(t *testing.T) { require.NoError(t, f.r.Create(context.Background(), fluxTarget())) again := f.settle(t, claimKey) - assert.Equal(t, ReasonSuspended, heldReason(t, again)) + assert.Equal(t, ReasonSuspended, inControlReason(t, again)) assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization))) var reasons []string for _, event := range f.events() { @@ -264,7 +264,7 @@ func TestApplierClaimRule_AnOlderClaimNotInLineKeepsNoneOut(t *testing.T) { assert.True(t, conditionIsTrue(got.Status.Conditions, ApplierClaimConditionConfigured), findCondition(got.Status.Conditions, ApplierClaimConditionConfigured).Message) - assert.Equal(t, ReasonSuspended, heldReason(t, got)) + assert.Equal(t, ReasonSuspended, inControlReason(t, got)) strayConfigured := findCondition(f.settle(t, inLineKey).Status.Conditions, ApplierClaimConditionConfigured) require.NotNil(t, strayConfigured) assert.NotEqual(t, metav1.ConditionTrue, strayConfigured.Status) @@ -276,7 +276,7 @@ func TestApplierClaimRule_AnOlderClaimNotInLineKeepsNoneOut(t *testing.T) { // TestApplierClaimRule_ARetiringHolderStaysInLineUntilItsReleaseIsDone: the holder's GitTarget goes // while a successor waits. Until the holder has suspended the Kustomization and seen that handled, -// it stays first in line, Held=False (Releasing), and the successor does nothing; then the holder +// it stays first in line, InControl=False (Releasing), and the successor does nothing; then the holder // leaves the Kustomization suspended for it, and the successor takes it over. func TestApplierClaimRule_ARetiringHolderStaysInLineUntilItsReleaseIsDone(t *testing.T) { holder := testFluxClaim(claimLocalProvider) @@ -304,7 +304,7 @@ func TestApplierClaimRule_ARetiringHolderStaysInLineUntilItsReleaseIsDone(t *tes _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) retiring := f.claim(t) - assert.Equal(t, ReasonReleasing, heldReason(t, retiring)) + assert.Equal(t, ReasonReleasing, inControlReason(t, retiring)) assert.True(t, controllerutil.ContainsFinalizer(retiring, configbutleraiv1alpha3.FluxClaimFinalizer)) assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "suspended first") _, err = f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: inLineKey}) @@ -322,7 +322,7 @@ func TestApplierClaimRule_ARetiringHolderStaysInLineUntilItsReleaseIsDone(t *tes assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "left suspended for the successor") taken := f.settle(t, inLineKey) assert.True(t, conditionIsTrue(taken.Status.Conditions, ApplierClaimConditionConfigured)) - assert.Equal(t, ReasonSuspended, heldReason(t, taken)) + assert.Equal(t, ReasonSuspended, inControlReason(t, taken)) assert.Equal(t, configbutleraiv1alpha3.SuspendStateNotSuspended, taken.Status.PreviousSuspendState, "passed on by the holder: the successor took the Kustomization over suspended already") events := f.events() @@ -355,13 +355,13 @@ func TestApplierClaimRule_AReferenceReturningDuringTheReleaseHoldsAgain(t *testi require.NoError(t, f.r.Delete(context.Background(), target)) _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) - assert.Equal(t, ReasonReleasing, heldReason(t, f.claim(t))) + assert.Equal(t, ReasonReleasing, inControlReason(t, f.claim(t))) require.NoError(t, f.r.Create(context.Background(), fluxTarget())) unpause() back := f.settle(t, claimKey) - assert.Equal(t, ReasonSuspended, heldReason(t, back)) + assert.Equal(t, ReasonSuspended, inControlReason(t, back)) assert.True(t, controllerutil.ContainsFinalizer(back, configbutleraiv1alpha3.FluxClaimFinalizer)) assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "never handed back") for _, event := range f.events() { @@ -413,11 +413,11 @@ func TestApplierClaimRule_AHolderLeftUnconfiguredSaysTheKustomizationIsSuspended configured := findCondition(got.Status.Conditions, ApplierClaimConditionConfigured) require.NotNil(t, configured) assert.Equal(t, applier.ReasonForbidden, configured.Reason) - held := findCondition(got.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Equal(t, metav1.ConditionUnknown, held.Status) - assert.Equal(t, ReasonNotConfigured, held.Reason) - assert.Contains(t, held.Message, "is left suspended under GitOps Reverser's field manager") + inControl := findCondition(got.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Equal(t, metav1.ConditionUnknown, inControl.Status) + assert.Equal(t, ReasonNotConfigured, inControl.Reason) + assert.Contains(t, inControl.Message, "is left suspended under GitOps Reverser's field manager") assert.True(t, controllerutil.ContainsFinalizer(got, configbutleraiv1alpha3.FluxClaimFinalizer)) assert.True(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization))) } diff --git a/internal/controller/applierclaim_schema_test.go b/internal/controller/applierclaim_schema_test.go index 014b50a0..ed7c2b7b 100644 --- a/internal/controller/applierclaim_schema_test.go +++ b/internal/controller/applierclaim_schema_test.go @@ -77,9 +77,9 @@ var _ = Describe("Applier claim schema", func() { g.Expect(ready.Reason).To(Equal(applier.ReasonNotReferenced)) }, 20*time.Second, 100*time.Millisecond).Should(Succeed()) Expect(claim.Finalizers).NotTo(ContainElement(configbutleraiv1alpha3.FluxClaimFinalizer)) - held := findCondition(claim.Status.Conditions, ApplierClaimConditionHeld) - Expect(held).NotTo(BeNil()) - Expect(held.Reason).To(Equal(applier.ReasonNotReferenced)) + inControl := findCondition(claim.Status.Conditions, ApplierClaimConditionInControl) + Expect(inControl).NotTo(BeNil()) + Expect(inControl.Reason).To(Equal(applier.ReasonNotReferenced)) }) It("never points an applier claim at another applier or cluster", func() { diff --git a/internal/controller/argocdclaim_hold.go b/internal/controller/argocdclaim_hold.go index a173097a..909d37d2 100644 --- a/internal/controller/argocdclaim_hold.go +++ b/internal/controller/argocdclaim_hold.go @@ -35,9 +35,9 @@ import ( // The switch of an Application an ApplicationSet generated, or whose switch another field manager // applies, is not the applier claim's (applier.AutomatedSync.Owner): the applier claim never writes it and // carries no finalizer for it. Configured refuses such an Application while its automated sync is -// on, and the applier claim is Held while the owner keeps it off. +// on, and the applier claim is InControl while the owner keeps it off. -// ReasonAutomatedSyncOff is Held=True on an ArgoCDClaim whose Application has automated sync off: +// ReasonAutomatedSyncOff is InControl=True on an ArgoCDClaim whose Application has automated sync off: // held off by GitOps Reverser, or kept off by the switch's owner (the message says which). const ReasonAutomatedSyncOff = "AutomatedSyncOff" @@ -58,16 +58,16 @@ func (r *ApplierClaimReconciler) reconcileArgoCD( if check.retiring(claim) { return r.retire(ctx, st, claim, &check) } - held, err := r.holdArgoCD(ctx, st, claim, check) + inControl, err := r.holdArgoCD(ctx, st, claim, check) if err != nil { return ctrl.Result{}, err } - st.setValue(ApplierClaimConditionHeld, held) - return r.commitClaim(ctx, st, r.claimReadiness(claim, check.configured, &held), &held, + st.setValue(ApplierClaimConditionInControl, inControl) + return r.commitClaim(ctx, st, r.claimReadiness(claim, check.configured, &inControl), &inControl, heldClaimSteadyInterval) } -// holdArgoCD returns an ArgoCDClaim's Held condition. A Configured applier claim holds its +// holdArgoCD returns an ArgoCDClaim's InControl condition. A Configured applier claim holds its // Application's automated sync off. One that is not Configured holds nothing, and one in line // behind another holder lets go of a finalizer it carries, handing nothing back. func (r *ApplierClaimReconciler) holdArgoCD( @@ -97,8 +97,8 @@ func (r *ApplierClaimReconciler) holdArgoCD( } // holdAutomatedSync holds a Configured ArgoCDClaim's Application's automated sync off, with the -// applier claim's finalizer put in place before the first write, and returns the Held condition. A switch -// somebody else owns is left to them: Configured is True only while they keep it off. +// applier claim's finalizer put in place before the first write, and returns the InControl condition. +// A switch somebody else owns is left to them: Configured is True only while they keep it off. func (r *ApplierClaimReconciler) holdAutomatedSync( ctx context.Context, st *reconcileStatus, claim *configbutleraiv1alpha3.ArgoCDClaim, ) (conditionValue, error) { @@ -107,12 +107,12 @@ func (r *ApplierClaimReconciler) holdAutomatedSync( ref := claim.Applier() argo, err := r.argoClient(ctx, claim) if err != nil { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, fmt.Sprintf("cannot reach the cluster of Application %s: %v", ref, err)), nil } state, err := argo.AutomatedSync(ctx, ref) if err != nil { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, fmt.Sprintf("cannot read Application %s: %v", ref, err)), nil } if state.Owner != "" { @@ -138,20 +138,20 @@ func (r *ApplierClaimReconciler) holdAutomatedSync( } // switchOff switches an ArgoCDClaim's Application's automated sync off, having recorded how it stood -// when the applier claim adopts it, and reports false with the Held condition when it could not. +// when the applier claim adopts it, and reports false with the InControl condition when it could not. func (r *ApplierClaimReconciler) switchOff( ctx context.Context, st *reconcileStatus, claim *configbutleraiv1alpha3.ArgoCDClaim, argo *applier.ArgoCD, state applier.AutomatedSync, adopt bool, ) (conditionValue, bool) { ref := claim.Applier() if err := r.recordPreviousSuspendState(ctx, st, claim, state.Held && !adopt, !state.On); err != nil { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, fmt.Sprintf( + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, fmt.Sprintf( "cannot record how Application %s stood before it is held: %v", ref, err)), false } written, err := argo.SwitchOff(ctx, ref) switch { case err != nil: - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, err.Error()), false + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, err.Error()), false case !written: // Configured checks this first; an Argo CD downgraded since is caught here, before a write. return conditionValue{Status: metav1.ConditionFalse, Reason: applier.ReasonArgoCDVersionNotSupported, @@ -159,7 +159,7 @@ func (r *ApplierClaimReconciler) switchOff( "before 3.1), so GitOps Reverser writes nothing", ref)}, false } switch { - case !heldBefore(claim): + case !wasInControl(claim): r.event(claim, corev1.EventTypeNormal, EventReasonAdopted, "switched automated sync of Application %s off: from now on GitOps Reverser starts every sync", ref) case state.On: @@ -170,9 +170,9 @@ func (r *ApplierClaimReconciler) switchOff( return conditionValue{}, true } -// ownedSwitchCondition is the Held condition of an applier claim whose Application's switch somebody else -// owns. Nothing is written: a share of the switch the applier claim took before the owner did is handed -// back by the finalizer it carries. +// ownedSwitchCondition is the InControl condition of an applier claim whose Application's switch +// somebody else owns. Nothing is written: a share of the switch the applier claim took before the owner +// did is handed back by the finalizer it carries. func ownedSwitchCondition(ref k8stypes.NamespacedName, state applier.AutomatedSync) conditionValue { if state.On { // Switched on since Configured read it: the next reconcile refuses it. diff --git a/internal/controller/argocdclaim_hold_test.go b/internal/controller/argocdclaim_hold_test.go index 885e25b6..83e52d2b 100644 --- a/internal/controller/argocdclaim_hold_test.go +++ b/internal/controller/argocdclaim_hold_test.go @@ -124,11 +124,11 @@ func TestArgoCDClaim_SwitchesAutomatedSyncOff(t *testing.T) { got := reconcileClaim(t, f.r).(*configbutleraiv1alpha3.ArgoCDClaim) - held := findCondition(got.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Equal(t, metav1.ConditionTrue, held.Status, held.Message) - assert.Equal(t, ReasonAutomatedSyncOff, held.Reason) - assert.Contains(t, held.Message, "held off by GitOps Reverser") + inControl := findCondition(got.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Equal(t, metav1.ConditionTrue, inControl.Status, inControl.Message) + assert.Equal(t, ReasonAutomatedSyncOff, inControl.Reason) + assert.Contains(t, inControl.Message, "held off by GitOps Reverser") assert.True(t, conditionIsTrue(got.Status.Conditions, ConditionTypeReady)) assert.True(t, controllerutil.ContainsFinalizer(got, configbutleraiv1alpha3.ArgoCDClaimFinalizer)) automated, _ := f.automated(t) @@ -221,9 +221,9 @@ func TestArgoCDClaim_NoLongerReferencedHandsTheSwitchBack(t *testing.T) { got := reconcileClaim(t, f.r) assert.False(t, controllerutil.ContainsFinalizer(got, configbutleraiv1alpha3.ArgoCDClaimFinalizer)) - held := findCondition(*got.StatusConditions(), ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Equal(t, applier.ReasonNotReferenced, held.Reason) + inControl := findCondition(*got.StatusConditions(), ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Equal(t, applier.ReasonNotReferenced, inControl.Reason) automated, _ := f.automated(t) assert.NotContains(t, automated, "enabled", "automated sync is on again") events := f.events() @@ -266,7 +266,7 @@ func TestArgoCDClaim_ASuccessorInLineKeepsTheSwitchOff(t *testing.T) { } // TestArgoCDClaim_AnApplicationSetOwnsTheSwitch: the switch of an Application an ApplicationSet -// generated is the ApplicationSet's template's. With automated sync off the applier claim is Held and +// generated is the ApplicationSet's template's. With automated sync off the applier claim is InControl and // writes nothing; with it on, it is refused with a reason and message that say what to change. func TestArgoCDClaim_AnApplicationSetOwnsTheSwitch(t *testing.T) { t.Run("kept off by the ApplicationSet", func(t *testing.T) { @@ -277,10 +277,10 @@ func TestArgoCDClaim_AnApplicationSetOwnsTheSwitch(t *testing.T) { got := reconcileClaim(t, f.r).(*configbutleraiv1alpha3.ArgoCDClaim) assert.True(t, conditionIsTrue(got.Status.Conditions, ConditionTypeReady)) - held := findCondition(got.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Equal(t, ReasonAutomatedSyncOff, held.Reason) - assert.Contains(t, held.Message, "kept off by ApplicationSet argocd/checkouts, which owns the switch") + inControl := findCondition(got.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Equal(t, ReasonAutomatedSyncOff, inControl.Reason) + assert.Contains(t, inControl.Message, "kept off by ApplicationSet argocd/checkouts, which owns the switch") assert.False(t, controllerutil.ContainsFinalizer(got, configbutleraiv1alpha3.ArgoCDClaimFinalizer), "nothing of the applier claim's to hand back") assert.Empty(t, f.application(t).GetManagedFields(), "the switch is never written") @@ -308,7 +308,7 @@ func TestArgoCDClaim_AnApplicationSetOwnsTheSwitch(t *testing.T) { } // TestArgoCDClaim_AManagerThatAppliesTheSwitchOwnsIt: whatever applies the Application's manifest -// with the switch in it sets it on every apply, so the applier claim leaves it alone: Held while it is +// with the switch in it sets it on every apply, so the applier claim leaves it alone: InControl while it is // off, refused while it is on. func TestArgoCDClaim_AManagerThatAppliesTheSwitchOwnsIt(t *testing.T) { for name, tc := range map[string]struct { diff --git a/internal/controller/fluxclaim_hold.go b/internal/controller/fluxclaim_hold.go index 505b7b72..a17adee3 100644 --- a/internal/controller/fluxclaim_hold.go +++ b/internal/controller/fluxclaim_hold.go @@ -29,7 +29,7 @@ import ( // lifecycle, from docs/design/apply-trigger.md ("Who owns spec.suspend"): // // - Adoption: a Configured applier claim suspends its Kustomization, under GitOps Reverser's field -// manager, with a token; Held (and so Ready) waits until the Kustomization handled it. +// manager, with a token; InControl (and so Ready) waits until the Kustomization handled it. // - External resume: a held Kustomization found resumed with no operation of a branch worker in // flight was resumed by somebody else (`flux resume`). It is suspended again with a token, every // time, with a Normal ClaimedResourceReconciled Event, and recorded as an Unresolved operation @@ -43,7 +43,7 @@ import ( // once the holder is gone. // holdKustomization holds a Configured FluxClaim's Kustomization suspended, and returns the -// applier claim's Held condition. It writes nothing while a branch worker's operation is in flight +// applier claim's InControl condition. It writes nothing while a branch worker's operation is in flight // and followed: the worker resumes the Kustomization and suspends it again itself. One nobody // follows any more is suspended here (suspendUnfollowed), so the Kustomization is never left resumed // with nothing guarding it. @@ -54,7 +54,7 @@ func (r *ApplierClaimReconciler) holdKustomization( defer cancel() held, err := r.readHold(ctx, claim) if err != nil { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, err.Error()) + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, err.Error()) } if !held.inFlight() || held.operation.Token == "" { r.unfollowed.forget(claim, "") @@ -68,7 +68,7 @@ func (r *ApplierClaimReconciler) holdKustomization( return r.hold(ctx, st, claim, held) } -// applyingCondition is Held=True (Applying) for a branch worker's operation in flight that is +// applyingCondition is InControl=True (Applying) for a branch worker's operation in flight that is // followed: the Kustomization is resumed for it, or about to be, and suspended again when it ends. func applyingCondition(operation *configbutleraiv1alpha3.ApplyOperation) conditionValue { return conditionValue{Status: metav1.ConditionTrue, Reason: ReasonApplying, Message: fmt.Sprintf( @@ -123,7 +123,7 @@ func (r *ApplierClaimReconciler) suspendUnfollowed( case !held.suspension.Suspended || !held.suspension.Held: token, err := held.flux.Suspend(ctx, ref) if err != nil { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, err.Error()) + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, err.Error()) } r.event(claim, corev1.EventTypeWarning, EventReasonOperationUnfollowed, "the %s apply of %s is followed by nobody; suspended Kustomization %s again", operation.Reason, @@ -255,7 +255,7 @@ func (r *ApplierClaimReconciler) hold( return conditionValue{Status: metav1.ConditionFalse, Reason: ReasonSuspending, Message: message} } unrecorded := func(err error) conditionValue { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, fmt.Sprintf( + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, fmt.Sprintf( "cannot record how Kustomization %s stood before it is held: %v", ref, err)) } adopt := adopting(claim, held.operation) @@ -272,9 +272,9 @@ func (r *ApplierClaimReconciler) hold( } token, err := held.flux.Suspend(ctx, ref) if err != nil { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, err.Error()) + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, err.Error()) } - r.suspended(claim, token, heldBefore(claim) || held.inFlight()) + r.suspended(claim, token, wasInControl(claim) || held.inFlight()) return suspending(fmt.Sprintf("Kustomization %s is being suspended", ref)) case !held.suspension.Held || adopt: return r.takeOverSuspend(ctx, st, claim, held) @@ -303,18 +303,18 @@ func (r *ApplierClaimReconciler) takeOverSuspend( } } if err := r.recordPreviousSuspendState(ctx, st, claim, false, true); err != nil { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, fmt.Sprintf( + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, fmt.Sprintf( "cannot record how Kustomization %s stood before it is held: %v", ref, err)) } if _, err := held.flux.Suspend(ctx, ref); err != nil { - return keptCondition(claim.Status.Conditions, ApplierClaimConditionHeld, err.Error()) + return keptCondition(claim.Status.Conditions, ApplierClaimConditionInControl, err.Error()) } return conditionValue{Status: metav1.ConditionFalse, Reason: ReasonSuspending, Message: fmt.Sprintf("Kustomization %s is being suspended under GitOps Reverser", ref)} } // heldElsewhere reports, from an uncached read, whether the applier claim's applier is free of any -// other applier claim's hold, and when it is not (or cannot be told) the Held condition that says so. +// other applier claim's hold, and when it is not (or cannot be told) the InControl condition that says so. // The cached read Configured was judged on can predate another applier claim coming first in line, and // that one's hold looks like a hand-back's or an apply's from the applier alone. func (r *ApplierClaimReconciler) heldElsewhere( @@ -333,13 +333,13 @@ func (r *ApplierClaimReconciler) heldElsewhere( } // adopting reports whether the applier claim takes its applier anew: it records nothing of how the -// applier stood, did not last say it held it, and has no operation in flight (operation, read +// applier stood, did not last say it was in control, and has no operation in flight (operation, read // uncached where it matters). A hand-back forgets the record and ends the operation, and a successor // is passed its predecessor's record. So a field GitOps Reverser's field manager holds when an // applier claim adopts is no hold in place but one a hand-back kept, set to how the applier stood: // it is recorded as such, and held anew. func adopting(claim configbutleraiv1alpha3.ApplierClaim, operation *configbutleraiv1alpha3.ApplyOperation) bool { - return claim.PreviousSuspendState() == "" && !heldBefore(claim) && + return claim.PreviousSuspendState() == "" && !wasInControl(claim) && (operation == nil || operation.Phase.Completed()) } @@ -352,7 +352,7 @@ func adopting(claim configbutleraiv1alpha3.ApplierClaim, operation *configbutler func (r *ApplierClaimReconciler) recordPreviousSuspendState( ctx context.Context, st *reconcileStatus, claim configbutleraiv1alpha3.ApplierClaim, held, suspended bool, ) error { - if held || claim.PreviousSuspendState() != "" || heldBefore(claim) { + if held || claim.PreviousSuspendState() != "" || wasInControl(claim) { return nil } state := suspendState(suspended) @@ -453,9 +453,9 @@ func completeExternalOperation( claim.Status.Operation = operation } -// heldBefore reports whether the applier claim last said it held its applier. -func heldBefore(claim configbutleraiv1alpha3.ApplierClaim) bool { - return apimeta.IsStatusConditionTrue(*claim.StatusConditions(), ApplierClaimConditionHeld) +// wasInControl reports whether the applier claim last said it was in control of its applier. +func wasInControl(claim configbutleraiv1alpha3.ApplierClaim) bool { + return apimeta.IsStatusConditionTrue(*claim.StatusConditions(), ApplierClaimConditionInControl) } // liveOperation reads the applier claim's status.operation uncached: a branch worker writes it just before @@ -490,7 +490,7 @@ func (r *ApplierClaimReconciler) fluxClient( } // releaseDeadline is how long a retiring applier claim tries to hand its applier back, counted from when -// its Held condition turned Releasing, before it lets go anyway with a Warning Event that says what +// its InControl condition turned Releasing, before it lets go anyway with a Warning Event that says what // the applier is left as. A Kustomization's suspend is handled once the apply running then ends, so // the deadline is longer than an apply usually takes; a kustomize-controller that is down never // handles it, and the deletion must not wait on that forever. @@ -501,8 +501,8 @@ const releaseDeadline = 10 * time.Minute // FluxClaim suspends its Kustomization once more if it is resumed (a branch worker's apply may // still be running), waits until that suspend's token is handled, and releases spec.suspend; an // ArgoCDClaim gives its Application's automated sync switch up (releaseStep). Neither hands back -// what an applier claim in line takes over. Until that is done the applier claim stays the holder, Held=False -// (Releasing), so no successor acts on the applier meanwhile. An applier that is gone has nothing +// what an applier claim in line takes over. Until that is done the applier claim stays the holder, +// InControl=False (Releasing), so no successor acts on the applier meanwhile. An applier that is gone has nothing // to hand back; a cluster no client reaches any more since its KubeProvider went (releaseClient), // and an applier the identity may not write, are let go with a Warning Event: none should hold the // applier claim forever. Nor does anything else past releaseDeadline. An applier claim behind another in line @@ -554,9 +554,9 @@ func (r *ApplierClaimReconciler) retire( } r.letGoOfOperation(claim) clearPreviousSuspendState(claim) - held := handedBackCondition(claim, check) - st.setValue(ApplierClaimConditionHeld, held) - st.applyReadiness(r.claimReadiness(claim, check.configured, &held)) + inControl := handedBackCondition(claim, check) + st.setValue(ApplierClaimConditionInControl, inControl) + st.applyReadiness(r.claimReadiness(claim, check.configured, &inControl)) if err := st.commit(ctx); err != nil { return ctrl.Result{}, err } @@ -567,7 +567,7 @@ func (r *ApplierClaimReconciler) retire( return ctrl.Result{RequeueAfter: st.requeueAfter(heldClaimSteadyInterval)}, nil } -// handedBackCondition is the Held condition of a live applier claim that has handed its applier +// handedBackCondition is the InControl condition of a live applier claim that has handed its applier // back, saying what makes it hold the applier again. func handedBackCondition(claim configbutleraiv1alpha3.ApplierClaim, check *claimCheck) conditionValue { switch { @@ -608,7 +608,7 @@ func applierTool(claim configbutleraiv1alpha3.ApplierClaim) string { return "Flux" } -// publishReleasing sets Held=False (Releasing, or Revoking for a hand-back a denial began) and the +// publishReleasing sets InControl=False (Releasing, or Revoking for a hand-back a denial began) and the // trio on an applier claim whose hand-back is not done, its deadline counted from since. The condition // is written anew when no hand-back was under way before, so its lastTransitionTime says when the // release began. @@ -624,44 +624,44 @@ func (r *ApplierClaimReconciler) publishReleasing( case check != nil: why = "no GitTarget names the applier claim any more" } - held := conditionValue{Status: metav1.ConditionFalse, Reason: reason, Message: fmt.Sprintf( + inControl := conditionValue{Status: metav1.ConditionFalse, Reason: reason, Message: fmt.Sprintf( "%s: handing %s %s back to %s, by %s at the latest", why, claim.ApplierKind(), claim.Applier(), applierTool(claim), since.Add(releaseDeadline).UTC().Format(time.RFC3339))} - if !handingBack(findCondition(*claim.StatusConditions(), ApplierClaimConditionHeld)) { - st.remove(ApplierClaimConditionHeld) + if !handingBack(findCondition(*claim.StatusConditions(), ApplierClaimConditionInControl)) { + st.remove(ApplierClaimConditionInControl) } - st.setValue(ApplierClaimConditionHeld, held) + st.setValue(ApplierClaimConditionInControl, inControl) if check == nil { rd := newReadiness("", claim.ClaimKind()+" is not stalled") - rd.progressing(held.Status, held.Reason, held.Message) + rd.progressing(inControl.Status, inControl.Reason, inControl.Message) st.applyReadiness(rd) return } - st.applyReadiness(r.claimReadiness(claim, check.configured, &held)) + st.applyReadiness(r.claimReadiness(claim, check.configured, &inControl)) } // revoking reports whether the applier claim is handing its applier back because its -// KubeProvider's grants refused it (Held=False, Revoking): such a hand-back finishes even after a +// KubeProvider's grants refused it (InControl=False, Revoking): such a hand-back finishes even after a // regrant, and the applier is adopted anew once it has. func revoking(claim configbutleraiv1alpha3.ApplierClaim) bool { - held := findCondition(*claim.StatusConditions(), ApplierClaimConditionHeld) - return held != nil && held.Reason == ReasonRevoking + inControl := findCondition(*claim.StatusConditions(), ApplierClaimConditionInControl) + return inControl != nil && inControl.Reason == ReasonRevoking } -// handingBack reports whether a Held condition says a hand-back is under way: Releasing, or Revoking. -// A denial arriving during a release continues the same hand-back, with the same deadline. -func handingBack(held *metav1.Condition) bool { - return held != nil && (held.Reason == ReasonReleasing || held.Reason == ReasonRevoking) +// handingBack reports whether an InControl condition says a hand-back is under way: Releasing, or +// Revoking. A denial arriving during a release continues the same hand-back, with the same deadline. +func handingBack(inControl *metav1.Condition) bool { + return inControl != nil && (inControl.Reason == ReasonReleasing || inControl.Reason == ReasonRevoking) } -// releasingSince is when the applier claim's Held condition turned Releasing or Revoking, or now when -// it has not. +// releasingSince is when the applier claim's InControl condition turned Releasing or Revoking, or now +// when it has not. func releasingSince(claim configbutleraiv1alpha3.ApplierClaim) time.Time { - held := findCondition(*claim.StatusConditions(), ApplierClaimConditionHeld) - if !handingBack(held) || held.LastTransitionTime.IsZero() { + inControl := findCondition(*claim.StatusConditions(), ApplierClaimConditionInControl) + if !handingBack(inControl) || inControl.LastTransitionTime.IsZero() { return time.Now() } - return held.LastTransitionTime.Time + return inControl.LastTransitionTime.Time } // letGoOfOperation completes an operation a retired applier claim still records in flight, when this @@ -929,7 +929,7 @@ func (r *ApplierClaimReconciler) publishAlsoAppliedBy(ctx context.Context, claim "gitRepository", source.GitRepository.Namespace+"/"+source.GitRepository.Name, "error", err.Error()) return } - others, err := r.heldKustomizations(ctx, claim.KubeProviderKey()) + others, err := r.kustomizationsInControl(ctx, claim.KubeProviderKey()) if err != nil { return } @@ -943,10 +943,11 @@ func (r *ApplierClaimReconciler) publishAlsoAppliedBy(ctx context.Context, claim } } -// heldKustomizations is every Kustomization a FluxClaim on the KubeProvider holds: one whose -// Held condition is True. An applier claim that is not Configured (SuspendOwnedElsewhere), or whose -// Kustomization has not handled its suspend yet, does not hold it: Flux may still apply a new artifact on its own. -func (r *ApplierClaimReconciler) heldKustomizations( +// kustomizationsInControl is every Kustomization a FluxClaim on the KubeProvider is in control of: one +// whose InControl condition is True. An applier claim that is not Configured (SuspendOwnedElsewhere), +// or whose Kustomization has not handled its suspend yet, is not in control of it: Flux may still +// apply a new artifact on its own. +func (r *ApplierClaimReconciler) kustomizationsInControl( ctx context.Context, cluster k8stypes.NamespacedName, ) (map[k8stypes.NamespacedName]bool, error) { @@ -954,12 +955,12 @@ func (r *ApplierClaimReconciler) heldKustomizations( if err := r.List(ctx, &claims); err != nil { return nil, err } - held := map[k8stypes.NamespacedName]bool{} + inControl := map[k8stypes.NamespacedName]bool{} for i := range claims.Items { claim := &claims.Items[i] - if claim.DeletionTimestamp.IsZero() && claim.KubeProviderKey() == cluster && heldBefore(claim) { - held[claim.Applier()] = true + if claim.DeletionTimestamp.IsZero() && claim.KubeProviderKey() == cluster && wasInControl(claim) { + inControl[claim.Applier()] = true } } - return held, nil + return inControl, nil } diff --git a/internal/controller/fluxclaim_hold_test.go b/internal/controller/fluxclaim_hold_test.go index 706d8191..9641e718 100644 --- a/internal/controller/fluxclaim_hold_test.go +++ b/internal/controller/fluxclaim_hold_test.go @@ -95,10 +95,10 @@ func TestFluxClaim_AdoptsItsKustomization(t *testing.T) { got := reconcileClaim(t, f.r).(*configbutleraiv1alpha3.FluxClaim) - held := findCondition(got.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Equal(t, metav1.ConditionTrue, held.Status, held.Message) - assert.Equal(t, ReasonSuspended, held.Reason) + inControl := findCondition(got.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Equal(t, metav1.ConditionTrue, inControl.Status, inControl.Message) + assert.Equal(t, ReasonSuspended, inControl.Reason) assert.True(t, conditionIsTrue(got.Status.Conditions, ConditionTypeReady)) ks := f.sim.Kustomization(checkoutKustomization) assert.True(t, suspendedSpec(ks)) @@ -146,7 +146,7 @@ func TestFluxClaim_AnExternalResumeIsSuspendedAgain(t *testing.T) { "complete once the suspend's token is handled") assert.NotNil(t, got.Status.Operation.FinishedAt) assert.Equal(t, recorded.SuspendToken, got.Status.Operation.SuspendToken) - assert.True(t, conditionIsTrue(got.Status.Conditions, ApplierClaimConditionHeld)) + assert.True(t, conditionIsTrue(got.Status.Conditions, ApplierClaimConditionInControl)) } assert.Len(t, f.sim.Applied(), 2, "each resume applied once, unchecked; that is what the record says") } @@ -158,7 +158,7 @@ func TestFluxClaim_LeavesABranchWorkersOperationAlone(t *testing.T) { claim := testFluxClaim(claimLocalProvider) claim.Finalizers = []string{configbutleraiv1alpha3.FluxClaimFinalizer} claim.Status.Conditions = []metav1.Condition{{ - Type: ApplierClaimConditionHeld, Status: metav1.ConditionTrue, Reason: ReasonSuspended, + Type: ApplierClaimConditionInControl, Status: metav1.ConditionTrue, Reason: ReasonSuspended, LastTransitionTime: metav1.Now(), }} claim.Status.Operation = &configbutleraiv1alpha3.ApplyOperation{ @@ -185,7 +185,7 @@ func workerOperationClaim() *configbutleraiv1alpha3.FluxClaim { claim := testFluxClaim(claimLocalProvider) claim.Finalizers = []string{configbutleraiv1alpha3.FluxClaimFinalizer} claim.Status.Conditions = []metav1.Condition{{ - Type: ApplierClaimConditionHeld, Status: metav1.ConditionTrue, Reason: ReasonSuspended, + Type: ApplierClaimConditionInControl, Status: metav1.ConditionTrue, Reason: ReasonSuspended, LastTransitionTime: metav1.Now(), }} claim.Status.Operation = &configbutleraiv1alpha3.ApplyOperation{ @@ -210,10 +210,10 @@ func TestFluxClaim_AnOperationNobodyFollowsIsSuspended(t *testing.T) { assert.False(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "followed: left to the worker") applying := &configbutleraiv1alpha3.FluxClaim{} require.NoError(t, f.r.Get(context.Background(), claimKey, applying)) - held := findCondition(applying.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Equal(t, metav1.ConditionTrue, held.Status, "an operation of ours in flight is held") - assert.Equal(t, ReasonApplying, held.Reason) + inControl := findCondition(applying.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Equal(t, metav1.ConditionTrue, inControl.Status, "an operation of ours in flight is held") + assert.Equal(t, ReasonApplying, inControl.Reason) f.r.Follower = followerStub{} got := reconcileClaim(t, f.r).(*configbutleraiv1alpha3.FluxClaim) @@ -224,7 +224,7 @@ func TestFluxClaim_AnOperationNobodyFollowsIsSuspended(t *testing.T) { assert.NotEmpty(t, got.Status.Operation.SuspendToken) assert.NotNil(t, got.Status.Operation.FinishedAt) assert.Contains(t, got.Status.Operation.Message, "followed by nobody") - assert.True(t, conditionIsTrue(got.Status.Conditions, ApplierClaimConditionHeld)) + assert.True(t, conditionIsTrue(got.Status.Conditions, ApplierClaimConditionInControl)) events := f.events() require.Len(t, events, 1) assert.True(t, strings.HasPrefix(events[0], "Warning OperationUnfollowed the Refusal apply"), events[0]) @@ -420,7 +420,7 @@ func TestFluxClaim_TakesOverASuspendBySomebodyElse(t *testing.T) { got := reconcileClaim(t, f.r).(*configbutleraiv1alpha3.FluxClaim) - assert.True(t, conditionIsTrue(got.Status.Conditions, ApplierClaimConditionHeld)) + assert.True(t, conditionIsTrue(got.Status.Conditions, ApplierClaimConditionInControl)) assert.Contains(t, kustomizationManagers(f.sim.Kustomization(checkoutKustomization)), applier.SuspendFieldManager) assert.Empty(t, f.events(), "neither an adoption of a running Kustomization nor an external resume") } @@ -482,7 +482,7 @@ func TestFluxClaim_PublishesWhatElseAppliesTheSource(t *testing.T) { assert.True(t, conditionIsTrue(f.claim(t).Status.Conditions, ConditionTypeReady), "information only") // The fourth review's finding C: an applier claim holds its Kustomization only once it says so - // (Held=True). One that is not Configured (SuspendOwnedElsewhere), or still suspending, leaves + // (InControl=True). One that is not Configured (SuspendOwnedElsewhere), or still suspending, leaves // a Kustomization Flux applies on its own. infraClaim := testFluxClaim(claimLocalProvider) infraClaim.Name, infraClaim.Spec.KustomizationRef.Name = "infra", "infra" @@ -492,8 +492,8 @@ func TestFluxClaim_PublishesWhatElseAppliesTheSource(t *testing.T) { {Kind: "Kustomization", Namespace: "flux-system", Name: "infra"}, } for _, suspended := range []metav1.Condition{ - {Type: ApplierClaimConditionHeld, Status: metav1.ConditionUnknown, Reason: ReasonNotConfigured}, - {Type: ApplierClaimConditionHeld, Status: metav1.ConditionFalse, Reason: ReasonSuspending}, + {Type: ApplierClaimConditionInControl, Status: metav1.ConditionUnknown, Reason: ReasonNotConfigured}, + {Type: ApplierClaimConditionInControl, Status: metav1.ConditionFalse, Reason: ReasonSuspending}, } { setClaimCondition(t, f.r, "infra", suspended) _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) @@ -503,7 +503,7 @@ func TestFluxClaim_PublishesWhatElseAppliesTheSource(t *testing.T) { } setClaimCondition(t, f.r, "infra", metav1.Condition{ - Type: ApplierClaimConditionHeld, Status: metav1.ConditionTrue, Reason: ReasonSuspended, + Type: ApplierClaimConditionInControl, Status: metav1.ConditionTrue, Reason: ReasonSuspended, }) _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) @@ -602,7 +602,7 @@ func TestFluxClaim_AHandBackThatNeverEndsLetsGoAtTheDeadline(t *testing.T) { claim.DeletionTimestamp = &metav1.Time{Time: time.Now()} // The deadline counts from when Held turned Releasing, not from the deletion. claim.Status.Conditions = []metav1.Condition{{ - Type: ApplierClaimConditionHeld, Status: metav1.ConditionFalse, Reason: ReasonReleasing, + Type: ApplierClaimConditionInControl, Status: metav1.ConditionFalse, Reason: ReasonReleasing, LastTransitionTime: metav1.NewTime(time.Now().Add(-deleted)), }} f := newHoldFixture(t, claim, ks, objs[1]) @@ -737,10 +737,10 @@ func TestFluxClaim_RecordsNothingOfAKustomizationAnotherHolds(t *testing.T) { got := reconcileClaim(t, f.r).(*configbutleraiv1alpha3.FluxClaim) - held := findCondition(got.Status.Conditions, ApplierClaimConditionHeld) - require.NotNil(t, held) - assert.Equal(t, ReasonNotConfigured, held.Reason) - assert.Contains(t, held.Message, "team-a/checkout-first") + inControl := findCondition(got.Status.Conditions, ApplierClaimConditionInControl) + require.NotNil(t, inControl) + assert.Equal(t, ReasonNotConfigured, inControl.Reason) + assert.Contains(t, inControl.Message, "team-a/checkout-first") assert.Empty(t, got.Status.PreviousSuspendState) assert.False(t, suspendedSpec(f.sim.Kustomization(checkoutKustomization)), "nothing written") } @@ -784,17 +784,17 @@ func TestFluxClaim_ReadoptsAKustomizationItHandedBackSuspended(t *testing.T) { f.events() setReferenced(t, f.r.Client, "checkout-flux", configbutleraiv1alpha3.ApplierClaimKindFlux, true) - var held *metav1.Condition + var inControl *metav1.Condition for range 50 { _, err := f.r.Reconcile(context.Background(), ctrl.Request{NamespacedName: claimKey}) require.NoError(t, err) - held = findCondition(f.claim(t).Status.Conditions, ApplierClaimConditionHeld) - if held != nil && held.Reason == ReasonSuspended { + inControl = findCondition(f.claim(t).Status.Conditions, ApplierClaimConditionInControl) + if inControl != nil && inControl.Reason == ReasonSuspended { break } } - require.NotNil(t, held) - assert.Equal(t, ReasonSuspended, held.Reason, held.Message) + require.NotNil(t, inControl) + assert.Equal(t, ReasonSuspended, inControl.Reason, inControl.Message) assert.Equal(t, configbutleraiv1alpha3.SuspendStateSuspended, f.claim(t).Status.PreviousSuspendState, "adopted anew: suspended before the applier claim held it") diff --git a/internal/controller/fluxclaim_suspend_apiserver_test.go b/internal/controller/fluxclaim_suspend_apiserver_test.go index ed9317d6..9ed1d2cb 100644 --- a/internal/controller/fluxclaim_suspend_apiserver_test.go +++ b/internal/controller/fluxclaim_suspend_apiserver_test.go @@ -144,15 +144,15 @@ var _ = Describe("A Kustomization resume guarded by uid and resourceVersion, on _, err = applier.NewFlux(dyn).Suspend(ctx, ref) Expect(err).NotTo(HaveOccurred()) } - // request asks Flux.Request for an apply of commit, with held run where the applier claim's hold is - // checked: after the resume's read of the Kustomization, before its write. - request := func(held func(context.Context) error) (applier.Started, error) { + // request asks Flux.Request for an apply of commit, with inControl run where the applier claim's + // InControl is checked: after the resume's read of the Kustomization, before its write. + request := func(inControl func(context.Context) error) (applier.Started, error) { return applier.NewFlux(dyn).Request(ctx, ref, applier.Ask{ - Want: applier.Binding{RepositoryURL: repositoryURL, Branch: branch}, - Commit: commit, - Reason: configbutleraiv1alpha3.ApplyReasonRepair, - Token: "guarded-resume", - Held: held, + Want: applier.Binding{RepositoryURL: repositoryURL, Branch: branch}, + Commit: commit, + Reason: configbutleraiv1alpha3.ApplyReasonRepair, + Token: "guarded-resume", + InControl: inControl, }) } read := func() *unstructured.Unstructured { diff --git a/test/e2e/argocd_bi_directional_e2e_test.go b/test/e2e/argocd_bi_directional_e2e_test.go index 8c199ce3..ecfb7a15 100644 --- a/test/e2e/argocd_bi_directional_e2e_test.go +++ b/test/e2e/argocd_bi_directional_e2e_test.go @@ -307,7 +307,7 @@ var _ = Describe("Bi Directional (Argo CD)", Label("bi-directional", "argocd"), g.Expect(claimEventMessages(g, testNs, "ArgoCDClaim", run.appName, "ClaimedResourceReconciled")). To(ContainSubstring("was switched on outside GitOps Reverser, which switched it off again")) }, biEventuallyTimeout, biPollInterval).Should(Succeed()) - verifyResourceCondition(argoCDClaimResource, run.appName, testNs, "Held", "True", "AutomatedSyncOff", + verifyResourceCondition(argoCDClaimResource, run.appName, testNs, "InControl", "True", "AutomatedSyncOff", "held off by GitOps Reverser") Expect(run.applicationManagers()).To(ContainElement(argoReverserInitiator), "the switch is held under GitOps Reverser's field manager") @@ -376,7 +376,7 @@ var _ = Describe("Bi Directional (Argo CD)", Label("bi-directional", "argocd"), g.Expect(strings.TrimSpace(enabled)).To(Equal("false")) }, argoSyncTimeout, biPollInterval).Should(Succeed()) requestClaimReconcile(testNs, argoCDClaimResource, run.appSetClaimName) - verifyResourceCondition(argoCDClaimResource, run.appSetClaimName, testNs, "Held", "True", + verifyResourceCondition(argoCDClaimResource, run.appSetClaimName, testNs, "InControl", "True", "AutomatedSyncOff", "kept off by ApplicationSet "+run.argoNs+"/"+run.appSetName) verifyResourceCondition("gittarget", run.appSetTargetName, testNs, "ApplierClaimReady", "True", "Succeeded", "") diff --git a/test/e2e/bi_directional_echoes_e2e_test.go b/test/e2e/bi_directional_echoes_e2e_test.go index e7ed6516..c65c1f63 100644 --- a/test/e2e/bi_directional_echoes_e2e_test.go +++ b/test/e2e/bi_directional_echoes_e2e_test.go @@ -440,7 +440,7 @@ var _ = Describe("Bi Directional applier echoes (Flux)", Label("bi-directional", nameApplierClaim(testNs, pipeline.targetName, "FluxClaim", fluxName, "") verifyResourceCondition("gittarget", pipeline.targetName, testNs, "ApplierClaimReady", "True", "Succeeded", "") - verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "True", "Suspended", "") + verifyResourceCondition(fluxClaimResource, fluxName, testNs, "InControl", "True", "Suspended", "") r = &echoRun{timingPipeline: pipeline, applier: fluxEchoApplier(testNs, fluxName, repo)} }) diff --git a/test/e2e/bi_directional_push_race_e2e_test.go b/test/e2e/bi_directional_push_race_e2e_test.go index 21c07301..10758dea 100644 --- a/test/e2e/bi_directional_push_race_e2e_test.go +++ b/test/e2e/bi_directional_push_race_e2e_test.go @@ -87,7 +87,7 @@ var _ = Describe("Bi Directional push race (Flux)", Label("bi-directional", "flu nameApplierClaim(testNs, pipeline.targetName, "FluxClaim", fluxName, "") verifyResourceCondition("gittarget", pipeline.targetName, testNs, "ApplierClaimReady", "True", "Succeeded", "") - verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "True", "Suspended", "") + verifyResourceCondition(fluxClaimResource, fluxName, testNs, "InControl", "True", "Suspended", "") }) AfterAll(func() { diff --git a/test/e2e/bi_directional_timing_e2e_test.go b/test/e2e/bi_directional_timing_e2e_test.go index ed089d35..e85f3558 100644 --- a/test/e2e/bi_directional_timing_e2e_test.go +++ b/test/e2e/bi_directional_timing_e2e_test.go @@ -269,7 +269,7 @@ var _ = Describe("Bi Directional timing (Flux)", Label("bi-directional", "flux", nameApplierClaim(testNs, pipeline.targetName, "FluxClaim", fluxName, "") verifyResourceCondition("gittarget", pipeline.targetName, testNs, "ApplierClaimReady", "True", "Succeeded", "") - verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "True", "Suspended", "") + verifyResourceCondition(fluxClaimResource, fluxName, testNs, "InControl", "True", "Suspended", "") }) AfterAll(func() { diff --git a/test/e2e/flux_applier_e2e_test.go b/test/e2e/flux_applier_e2e_test.go index 0e8cf8d0..7534ca92 100644 --- a/test/e2e/flux_applier_e2e_test.go +++ b/test/e2e/flux_applier_e2e_test.go @@ -91,7 +91,7 @@ var _ = Describe("Manager Flux Applier", Label("manager", "flux-applier"), Order createGitTarget(destName, testNs, providerName, gitPath, "main") createFluxClaim(testNs, fluxName, fluxName) verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Ready", "True", "NotReferenced", "") - verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "False", "NotReferenced", "") + verifyResourceCondition(fluxClaimResource, fluxName, testNs, "InControl", "False", "NotReferenced", "") Expect(kustomizationSuspended(Default, fluxName)).To(BeFalse(), "Flux applies it on its own") By("naming it from a GitTarget that writes main: the applier claim is refused, and holds nothing") @@ -154,7 +154,7 @@ var _ = Describe("Manager Flux Applier", Label("manager", "flux-applier"), Order g.Expect(fluxLastAppliedRevision(g, fluxName)).To(ContainSubstring(head)) g.Expect(appliedColor(g, appliedNs)).To(Equal("green")) }, 5*time.Minute, 5*time.Second).Should(Succeed()) - verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "True", "Suspended", "") + verifyResourceCondition(fluxClaimResource, fluxName, testNs, "InControl", "True", "Suspended", "") Expect(kustomizationSuspended(Default, fluxName)).To(BeTrue(), "the applier claim holds its Kustomization") alsoAppliedBy, err := kubectlRunInNamespace(testNs, "get", fluxClaimResource, fluxName, "-o", "jsonpath={.status.source.alsoAppliedBy}") @@ -167,7 +167,7 @@ var _ = Describe("Manager Flux Applier", Label("manager", "flux-applier"), Order _, err = kubectlRunInNamespace(testNs, "patch", "gittarget", destName, "--type=merge", "-p", `{"spec":{"applierClaimRef":null}}`) Expect(err).NotTo(HaveOccurred(), "failed to drop the GitTarget's applierClaimRef") - verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "False", "NotReferenced", "") + verifyResourceCondition(fluxClaimResource, fluxName, testNs, "InControl", "False", "NotReferenced", "") verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Ready", "True", "NotReferenced", "") Eventually(func(g Gomega) { g.Expect(kustomizationSuspended(g, fluxName)).To(BeFalse()) @@ -178,7 +178,7 @@ var _ = Describe("Manager Flux Applier", Label("manager", "flux-applier"), Order By("naming the applier claim again: it adopts the Kustomization anew") nameApplierClaim(testNs, destName, "FluxClaim", fluxName, "") - verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "True", "Suspended", "") + verifyResourceCondition(fluxClaimResource, fluxName, testNs, "InControl", "True", "Suspended", "") Expect(kustomizationSuspended(Default, fluxName)).To(BeTrue()) }) @@ -292,7 +292,7 @@ data: To(ContainSubstring("was resumed outside GitOps Reverser")) g.Expect(kustomizationSuspended(g, fluxName)).To(BeTrue(), "suspended again") }, 2*time.Minute, 2*time.Second).Should(Succeed()) - verifyResourceCondition(fluxClaimResource, fluxName, testNs, "Held", "True", "Suspended", "") + verifyResourceCondition(fluxClaimResource, fluxName, testNs, "InControl", "True", "Suspended", "") }) // Deleting the applier claim hands the Kustomization back: its finalizer waits until nothing is From 81d0aedcec14301921d0b79f6973730562d1cb6a Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sat, 24 Oct 2026 06:02:20 +0000 Subject: [PATCH 32/34] docs(api): say what each grant scope field permits, in the CRDs The scope field doc comments become the kubectl explain text, and they undersold what the code does. sourceNamespaces said "*" "also allows" a "*" rule item; the code requires it: scopeAllows is a string match, so a "*" item is refused under a narrower union, never narrowed to it. Each scope field (sourceNamespaces, clusterResources, applierNamespaces, and WatchRule rules[].sourceNamespace) now states, checked against internal/authz/source_namespace.go, internal/watch/watchrule_compile.go and KubeProvider.AdmitsGitTarget/AdmitsApplierClaim: - the effective scope is the union across admitting grants; - it is a permission, not a selection; - "*" needs "*" in at least one admitting grant, refused, never narrowed; - one item outside the union refuses the whole rule; - it is checked at reconcile, never at admission; - it bounds reading, never Git placement. applierNamespaces also loses "source-cluster": the same KubeProvider can be a GitTarget's source cluster and an applier claim's management cluster, so it now says "namespaces in this provider's cluster". Descriptions only; no schema or behaviour change. Co-Authored-By: Claude Opus 5.5 (1M context) --- api/v1alpha3/access_grants.go | 43 +++++++++++++++---- api/v1alpha3/watchrule_types.go | 12 ++++-- .../bases/configbutler.ai_kubeproviders.yaml | 43 +++++++++++++++---- .../crd/bases/configbutler.ai_watchrules.yaml | 12 ++++-- 4 files changed, 86 insertions(+), 24 deletions(-) diff --git a/api/v1alpha3/access_grants.go b/api/v1alpha3/access_grants.go index 54505e32..ee9351ab 100644 --- a/api/v1alpha3/access_grants.go +++ b/api/v1alpha3/access_grants.go @@ -180,10 +180,24 @@ type SourceGrant struct { // +optional Selector *metav1.LabelSelector `json:"selector,omitempty"` - // SourceNamespaces are the source-cluster namespaces the WatchRules feeding an admitted GitTarget - // may watch: exact names, '{consumer.namespace}' for the namespace named like the GitTarget's, or - // '*' for every namespace, which also allows rules[].sourceNamespace: "*". The list is the whole scope; the GitTarget's own namespace is in it - // only when listed. Omitted, no namespace. + // SourceNamespaces are source-cluster namespaces that a WatchRule feeding an admitted GitTarget may + // name in rules[].sourceNamespace. Entries are exact names, '{consumer.namespace}' for the + // namespace named like the GitTarget's, or '*'. Omitted, this grant gives no namespace. + // + // The effective scope is the union of sourceNamespaces from all grants admitting the GitTarget, + // after expanding variables. Every WatchRule item must be allowed by that union, an omitted + // sourceNamespace included (it names the WatchRule's own namespace). This is a permission, not a + // selection: listing a namespace watches nothing until a WatchRule names it. + // + // It is not a filter on "*". A rule item with sourceNamespace "*" needs '*' in at least one + // admitting grant; otherwise it is refused, never narrowed to the namespaces listed. With '*' + // granted, such an item is one cluster-wide watch, bounded only by this KubeProvider credential's + // RBAC. + // + // The check runs when the WatchRule reconciles, and again when a grant changes, never at + // admission. A WatchRule with any item outside the union is refused as a whole, in its status + // (SourceNamespaceNotAllowed), and watches nothing. The scope bounds what is read, never where + // objects are written in Git, and the credential's RBAC still applies inside it. // +optional // +listType=set // +kubebuilder:validation:MinItems=1 @@ -193,8 +207,14 @@ type SourceGrant struct { // ClusterResources are the cluster-scoped resource types a ClusterWatchRule may mirror through an // admitted GitTarget: resource.group (clusterroles.rbac.authorization.k8s.io), a bare resource for // the core group only (nodes), or '*' for every one, including CRDs installed later. An identity - // covers every served version. A ClusterWatchRule item with '*' in apiGroups or resources needs - // '*' here. Omitted, none. + // covers every served version. Omitted, this grant gives none. + // + // The effective scope is the union of clusterResources from all grants admitting the GitTarget. + // This is a permission, not a selection: listing a type mirrors nothing until a ClusterWatchRule + // asks for it. A ClusterWatchRule item with '*' in apiGroups or resources needs '*' in at least + // one admitting grant; otherwise it is refused, never narrowed to the types listed. The check runs + // when the ClusterWatchRule reconciles, never at admission, and a rule asking for any type outside + // the union is refused as a whole, in its status (ClusterResourceNotAllowed). // +optional // +listType=set // +kubebuilder:validation:MinItems=1 @@ -228,9 +248,16 @@ type ApplierGrant struct { // +optional Selector *metav1.LabelSelector `json:"selector,omitempty"` - // ApplierNamespaces are the source-cluster namespaces in which an admitted applier claim's + // ApplierNamespaces are namespaces in this provider's cluster in which an admitted applier claim's // Kustomization or Application may live: exact names, '{consumer.namespace}' for the namespace - // named like the applier claim's, or '*' for every namespace. The list is the whole scope. + // named like the applier claim's, or '*' for every namespace. + // + // The effective scope is the union of applierNamespaces from all grants admitting the applier + // claim, after expanding variables. This is a permission, not a selection: it is checked against + // one namespace, that of the applier claim's kustomizationRef or applicationRef. The check runs + // when the applier claim reconciles, and again when a grant changes, never at admission. An + // applier outside the union is refused, in the applier claim's status + // (ApplierNamespaceNotAllowed), and an applier claim holding it hands it back. // +required // +listType=set // +kubebuilder:validation:MinItems=1 diff --git a/api/v1alpha3/watchrule_types.go b/api/v1alpha3/watchrule_types.go index 98ba2594..0dc03d0d 100644 --- a/api/v1alpha3/watchrule_types.go +++ b/api/v1alpha3/watchrule_types.go @@ -109,10 +109,14 @@ type ResourceRule struct { // "*" for every namespace the source credential can read. // // "*" is one cluster-wide list and watch rather than a set of per-namespace ones, so its bound - // is the credential's own RBAC and nothing else. Every value, this rule's own namespace - // included, needs a grant: the GitTarget's KubeProvider must admit the target's namespace in - // spec.allowedGitTargets with that namespace in sourceNamespaces ("{consumer.namespace}" is the - // target's own). "*" needs "*" there. + // is the credential's own RBAC and nothing else. + // + // Every value, this rule's own namespace included, must be in the union of sourceNamespaces from + // all grants in the GitTarget's KubeProvider spec.allowedGitTargets that admit the GitTarget + // ("{consumer.namespace}" there is the target's own namespace). "*" needs "*" in at least one of + // them; a union without it refuses the rule, it does not narrow the watch. One item outside the + // union refuses the whole WatchRule (SourceNamespaceNotAllowed). The check runs when the rule + // reconciles, never at admission. // // This changes only which namespace is WATCHED, never where objects are written: Git placement // follows each mirrored object's own namespace. diff --git a/config/crd/bases/configbutler.ai_kubeproviders.yaml b/config/crd/bases/configbutler.ai_kubeproviders.yaml index ab0e6c6e..aac936dc 100644 --- a/config/crd/bases/configbutler.ai_kubeproviders.yaml +++ b/config/crd/bases/configbutler.ai_kubeproviders.yaml @@ -82,9 +82,16 @@ spec: properties: applierNamespaces: description: |- - ApplierNamespaces are the source-cluster namespaces in which an admitted applier claim's + ApplierNamespaces are namespaces in this provider's cluster in which an admitted applier claim's Kustomization or Application may live: exact names, '{consumer.namespace}' for the namespace - named like the applier claim's, or '*' for every namespace. The list is the whole scope. + named like the applier claim's, or '*' for every namespace. + + The effective scope is the union of applierNamespaces from all grants admitting the applier + claim, after expanding variables. This is a permission, not a selection: it is checked against + one namespace, that of the applier claim's kustomizationRef or applicationRef. The check runs + when the applier claim reconciles, and again when a grant changes, never at admission. An + applier outside the union is refused, in the applier claim's status + (ApplierNamespaceNotAllowed), and an applier claim holding it hands it back. items: description: |- ScopeNamespace is one namespace in a source or applier scope: a namespace name, @@ -229,8 +236,14 @@ spec: ClusterResources are the cluster-scoped resource types a ClusterWatchRule may mirror through an admitted GitTarget: resource.group (clusterroles.rbac.authorization.k8s.io), a bare resource for the core group only (nodes), or '*' for every one, including CRDs installed later. An identity - covers every served version. A ClusterWatchRule item with '*' in apiGroups or resources needs - '*' here. Omitted, none. + covers every served version. Omitted, this grant gives none. + + The effective scope is the union of clusterResources from all grants admitting the GitTarget. + This is a permission, not a selection: listing a type mirrors nothing until a ClusterWatchRule + asks for it. A ClusterWatchRule item with '*' in apiGroups or resources needs '*' in at least + one admitting grant; otherwise it is refused, never narrowed to the types listed. The check runs + when the ClusterWatchRule reconciles, never at admission, and a rule asking for any type outside + the union is refused as a whole, in its status (ClusterResourceNotAllowed). items: description: |- ClusterResourceIdentity names a cluster-scoped resource type: resource.group, a bare resource for @@ -328,10 +341,24 @@ spec: x-kubernetes-map-type: atomic sourceNamespaces: description: |- - SourceNamespaces are the source-cluster namespaces the WatchRules feeding an admitted GitTarget - may watch: exact names, '{consumer.namespace}' for the namespace named like the GitTarget's, or - '*' for every namespace, which also allows rules[].sourceNamespace: "*". The list is the whole scope; the GitTarget's own namespace is in it - only when listed. Omitted, no namespace. + SourceNamespaces are source-cluster namespaces that a WatchRule feeding an admitted GitTarget may + name in rules[].sourceNamespace. Entries are exact names, '{consumer.namespace}' for the + namespace named like the GitTarget's, or '*'. Omitted, this grant gives no namespace. + + The effective scope is the union of sourceNamespaces from all grants admitting the GitTarget, + after expanding variables. Every WatchRule item must be allowed by that union, an omitted + sourceNamespace included (it names the WatchRule's own namespace). This is a permission, not a + selection: listing a namespace watches nothing until a WatchRule names it. + + It is not a filter on "*". A rule item with sourceNamespace "*" needs '*' in at least one + admitting grant; otherwise it is refused, never narrowed to the namespaces listed. With '*' + granted, such an item is one cluster-wide watch, bounded only by this KubeProvider credential's + RBAC. + + The check runs when the WatchRule reconciles, and again when a grant changes, never at + admission. A WatchRule with any item outside the union is refused as a whole, in its status + (SourceNamespaceNotAllowed), and watches nothing. The scope bounds what is read, never where + objects are written in Git, and the credential's RBAC still applies inside it. items: description: |- ScopeNamespace is one namespace in a source or applier scope: a namespace name, diff --git a/config/crd/bases/configbutler.ai_watchrules.yaml b/config/crd/bases/configbutler.ai_watchrules.yaml index 0bcecb3c..cda73c96 100644 --- a/config/crd/bases/configbutler.ai_watchrules.yaml +++ b/config/crd/bases/configbutler.ai_watchrules.yaml @@ -223,10 +223,14 @@ spec: "*" for every namespace the source credential can read. "*" is one cluster-wide list and watch rather than a set of per-namespace ones, so its bound - is the credential's own RBAC and nothing else. Every value, this rule's own namespace - included, needs a grant: the GitTarget's KubeProvider must admit the target's namespace in - spec.allowedGitTargets with that namespace in sourceNamespaces ("{consumer.namespace}" is the - target's own). "*" needs "*" there. + is the credential's own RBAC and nothing else. + + Every value, this rule's own namespace included, must be in the union of sourceNamespaces from + all grants in the GitTarget's KubeProvider spec.allowedGitTargets that admit the GitTarget + ("{consumer.namespace}" there is the target's own namespace). "*" needs "*" in at least one of + them; a union without it refuses the rule, it does not narrow the watch. One item outside the + union refuses the whole WatchRule (SourceNamespaceNotAllowed). The check runs when the rule + reconciles, never at admission. This changes only which namespace is WATCHED, never where objects are written: Git placement follows each mirrored object's own namespace. From c84ca816a84ffaadda4a9693ff62a6c890dae651 Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sat, 24 Oct 2026 06:19:17 +0000 Subject: [PATCH 33/34] refactor(api)!: nest each grant's "who" in a required consumerNamespaces block BREAKING CHANGE: every grant in GitProvider.spec.allowedGitBranches, GitBranch.spec.allowedGitTargets, KubeProvider.spec.allowedGitTargets and KubeProvider.spec.allowedApplierClaims moves from/names/selector into a required consumerNamespaces block, and allowedGitBranches[].branches is renamed branchPatterns: allowedGitTargets: - consumerNamespaces: from: Names names: [team-a] sourceNamespaces: ["{consumer.namespace}"] Why the block (docs/reviews/20261024-grant-shapes.md, F1 to F3): - A bare `names` did not say what it named. On a GitBranch, `allowedGitTargets: [{from: Names, names: [team-b]}]` reads as well as "the GitTarget called team-b". Under consumerNamespaces, names can only mean namespace names, in all four lists. - In both KubeProvider lists the control-cluster "who" sat flat beside a scope in the provider's cluster; only field prefixes told the two clusters apart. The block marks the control side, so the fields beside it are the scope. - It is Gateway API's AllowedRoutes shape (a namespaces block with a restriction beside it), named for the consumer so it does not sit as a bare `namespaces` next to sourceNamespaces, and so it pairs with {consumer.namespace}. - `branches` inside allowedGitBranches read as GitBranch object names, the same flaw; the values are patterns, which the type (BranchPattern), the CEL refusals and the docs already call them. Nothing defaults, the reverse of Gateway API: consumerNamespaces is +required on every grant type, from stays +required inside it, and neither has a +kubebuilder:default. A missing block, consumerNamespaces: null, consumerNamespaces: {} and a block with names but no from are all refused; the admission suite now checks those four cases on each of the four lists, since the block's own CEL rules cannot make it required. Code: NamespaceGrant becomes ConsumerNamespaces and carries the five CEL rules once, held as a named field so the type-level rules apply; the three copies of from/names/selector and their rules are gone. Grant types are named Grant (GitProviderGitBranchGrant, GitBranchGitTargetGrant, KubeProviderGitTargetGrant, KubeProviderApplierClaimGrant), so the two allowedGitTargets types no longer share a name, and SourceScope/ApplierScope follow (KubeProviderGitTargetScope, KubeProviderApplierClaimScope). admitAll, describeAdmitted and expandScope keep their signatures; sourcesOf is consumersOf, and each source() adapter is a one-line consumers() accessor ("source" is reserved for the source cluster). Evaluation, refusal reasons (BranchNotAllowed included) and condition names do not change. Everything a unit test reads moves with it: the chart (rendered by cmd/main_chart_kubeprovider_test.go; quickstart.gitProvider .allowedGitBranches, a list of patterns, is now quickstart.gitProvider.branchPatterns), config/samples, the layout corpus, the playground and test/e2e/setup (strict-decoded by TestExamplesDecodeStrictly), the bi-directional guide examples, and the generated settings index in docs/configuration.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- api/v1alpha3/access_grants.go | 246 +++++------ api/v1alpha3/access_grants_test.go | 120 ++++-- api/v1alpha3/audit_route_test.go | 4 +- api/v1alpha3/gitbranch_types.go | 6 +- api/v1alpha3/gitprovider_types.go | 4 +- api/v1alpha3/kubeprovider_types.go | 4 +- api/v1alpha3/zz_generated.deepcopy.go | 215 +++++----- charts/gitops-reverser/README.md | 8 +- .../templates/kubeprovider.yaml | 4 +- .../gitops-reverser/templates/quickstart.yaml | 10 +- charts/gitops-reverser/values-playground.yaml | 4 +- charts/gitops-reverser/values.schema.json | 6 +- charts/gitops-reverser/values.yaml | 24 +- cmd/main_chart_kubeprovider_test.go | 18 +- .../bases/configbutler.ai_gitbranches.yaml | 202 ++++----- .../bases/configbutler.ai_gitproviders.yaml | 219 +++++----- .../bases/configbutler.ai_kubeproviders.yaml | 395 +++++++++--------- config/kubeprovider-local.yaml | 4 +- config/samples/kubeprovider.yaml | 13 +- config/samples/quickstart-gitbranch.yaml | 2 +- config/samples/quickstart-gitprovider.yaml | 4 +- docs/bi-directional-argocd.md | 16 +- docs/bi-directional-flux.md | 16 +- docs/configuration.md | 6 +- hack/crdfields/fields.yaml | 11 +- internal/applier/dispatcher_test.go | 19 +- internal/applier/holder_test.go | 10 +- internal/authz/gitbranch_admission_test.go | 36 +- internal/authz/kubeprovider_admission.go | 14 +- internal/authz/kubeprovider_admission_test.go | 91 ++-- internal/authz/source_namespace.go | 2 +- internal/authz/source_namespace_test.go | 13 +- .../access_grants_admission_test.go | 146 ++++--- .../applierclaim_revocation_test.go | 2 +- .../clusterwatchrule_admission_test.go | 20 +- internal/controller/gitbranch_access_test.go | 26 +- .../controller/gitbranch_controller_test.go | 9 +- .../gitprovider_immutability_test.go | 5 +- .../controller/gittarget_gitbranch_test.go | 18 +- .../gittarget_shared_branch_test.go | 16 +- .../gittarget_source_cluster_test.go | 62 +-- internal/controller/grant_helpers_test.go | 48 ++- internal/controller/suite_test.go | 18 +- .../wiring/access_grants_wiring_test.go | 68 ++- .../wiring/readiness_wiring_test.go | 15 +- internal/rulestore/store.go | 3 +- internal/watch/bootstrap_admission_test.go | 12 +- internal/watch/cluster_resources_test.go | 9 +- internal/watch/manager_startup_test.go | 26 +- internal/watch/source_namespace_test.go | 9 +- internal/watch/watchrule_compile.go | 4 +- internal/webhook/receiver_test.go | 7 +- .../podinfos-intent/2-reverse-gitops.yaml | 6 +- .../test/coffeeconfig-reverse-gitops.yaml | 6 +- .../1-flat-serialized/config/gittarget.yaml | 2 +- .../config/kubeprovider.yaml | 2 +- .../config/gittarget-second-namespace.yaml | 2 +- .../config/gittarget.yaml | 2 +- .../3-tree-serialized/config/gittarget.yaml | 2 +- .../config/kubeprovider.yaml | 2 +- .../config/gittarget.yaml | 2 +- .../config/gittarget-empty-folder.yaml | 2 +- .../config/gittarget.yaml | 2 +- .../config/gittarget-app-root.yaml | 2 +- .../config/gittarget-prod.yaml | 2 +- .../config/gittarget-test.yaml | 2 +- .../config/gittarget-prod.yaml | 2 +- .../config/gittarget-prod.yaml | 2 +- .../homelab-argocd/config/gittarget.yaml | 2 +- .../homelab-flux/config/gittarget-media.yaml | 2 +- .../homelab-flux/config/gittarget.yaml | 2 +- .../prerequisites/config/gitprovider.yaml | 4 +- test/playground/config/gitprovider.yaml | 4 +- test/playground/config/gittarget.yaml | 2 +- 74 files changed, 1297 insertions(+), 1028 deletions(-) diff --git a/api/v1alpha3/access_grants.go b/api/v1alpha3/access_grants.go index ee9351ab..b6ba70c8 100644 --- a/api/v1alpha3/access_grants.go +++ b/api/v1alpha3/access_grants.go @@ -82,21 +82,21 @@ type ClusterResourceIdentity string // +kubebuilder:validation:XValidation:rule="!self.contains('..') && !self.contains('@{') && !self.startsWith('/') && !self.endsWith('/') && !self.contains('//')",messageExpression="'branch pattern ' + self + ' can match no branch name: git refuses .., @{, a leading or trailing / and //'" type BranchPattern string -// The source half (from, names, selector) is repeated in each grant type rather than embedded: the -// CEL rules sit on the type, and an inline-embedded struct's type-level rules do not carry over to -// the struct embedding it. +// Each grant type holds ConsumerNamespaces as a named field, never inline-embedded: the CEL rules +// sit on the type, and an inline-embedded struct's type-level rules do not carry over to the struct +// embedding it. -// NamespaceGrant admits the objects in some control-cluster namespaces, for the act the list holding -// it names. +// ConsumerNamespaces says which control-cluster namespaces a grant admits consumers from, for the act +// the list holding the grant names. // +kubebuilder:validation:XValidation:rule="self.from != 'Names' || has(self.names)",message="from: Names needs names" // +kubebuilder:validation:XValidation:rule="!has(self.names) || self.from == 'Names'",message="names goes only with from: Names" // +kubebuilder:validation:XValidation:rule="self.from != 'Selector' || has(self.selector)",message="from: Selector needs a selector; to admit every namespace, use from: All" // +kubebuilder:validation:XValidation:rule="!has(self.selector) || self.from == 'Selector'",message="selector goes only with from: Selector" // +kubebuilder:validation:XValidation:rule="!has(self.selector) || (has(self.selector.matchLabels) && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) && size(self.selector.matchExpressions) > 0)",message="selector: {} is refused; to admit every namespace, use from: All" -type NamespaceGrant struct { - // From is where this grant admits from: Same (the namespace of the object holding the grant), - // Names (the namespaces in names), Selector (the namespaces whose labels match selector) or All - // (every namespace). +type ConsumerNamespaces struct { + // From is where this grant admits from: Same (the namespace of the object holding the grant, not + // the consumer's), Names (the namespaces in names), Selector (the namespaces whose labels match + // selector) or All (every namespace). // +required From GrantFrom `json:"from"` @@ -113,72 +113,46 @@ type NamespaceGrant struct { Selector *metav1.LabelSelector `json:"selector,omitempty"` } -// GitBranchGrant admits GitBranches in some control-cluster namespaces to claim the branches its -// patterns match through a GitProvider. -// +kubebuilder:validation:XValidation:rule="self.from != 'Names' || has(self.names)",message="from: Names needs names" -// +kubebuilder:validation:XValidation:rule="!has(self.names) || self.from == 'Names'",message="names goes only with from: Names" -// +kubebuilder:validation:XValidation:rule="self.from != 'Selector' || has(self.selector)",message="from: Selector needs a selector; to admit every namespace, use from: All" -// +kubebuilder:validation:XValidation:rule="!has(self.selector) || self.from == 'Selector'",message="selector goes only with from: Selector" -// +kubebuilder:validation:XValidation:rule="!has(self.selector) || (has(self.selector.matchLabels) && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) && size(self.selector.matchExpressions) > 0)",message="selector: {} is refused; to admit every namespace, use from: All" -type GitBranchGrant struct { - // From is where this grant admits from: Same (this GitProvider's namespace), Names (the - // namespaces in names), Selector (the namespaces whose labels match selector) or All (every - // namespace). +// GitProviderGitBranchGrant admits GitBranches in some control-cluster namespaces to claim the +// branches its patterns match through a GitProvider. +type GitProviderGitBranchGrant struct { + // ConsumerNamespaces are the control-cluster namespaces whose GitBranches this grant admits. + // from: Same is this GitProvider's namespace. + // +required + ConsumerNamespaces ConsumerNamespaces `json:"consumerNamespaces"` + + // BranchPatterns are the branches an admitted GitBranch may claim, as patterns matched against + // the whole branch name. '*' matches any sequence of characters, '/' included, so "*" alone is + // every branch, and a name with no '*', such as "main", matches only itself. + // '{consumer.namespace}' and '{consumer.name}' are the claiming GitBranch's namespace and name, + // each a whole path segment: "teams/{consumer.namespace}/*". '{consumer.name}' needs + // '{consumer.namespace}' in the same pattern, "teams/{consumer.namespace}/{consumer.name}": it is + // a naming convention, not a bound, since a GitBranch picks its own name. There is no escaping, + // '?' or character class. // +required - From GrantFrom `json:"from"` - - // Names are the control-cluster namespaces admitted with from: Names, by exact name. - // +optional // +listType=set // +kubebuilder:validation:MinItems=1 // +kubebuilder:validation:MaxItems=64 - Names []GrantNamespace `json:"names,omitempty"` - - // Selector matches labels on control-cluster Namespaces, with from: Selector. It must select - // something: to admit every namespace, use from: All. - // +optional - Selector *metav1.LabelSelector `json:"selector,omitempty"` + BranchPatterns []BranchPattern `json:"branchPatterns"` +} - // Branches are the branches an admitted GitBranch may claim, as patterns matched against the - // whole branch name. '*' matches any sequence of characters, '/' included, so "*" alone is every - // branch. '{consumer.namespace}' and '{consumer.name}' are the claiming GitBranch's namespace and - // name, each a whole path segment: "teams/{consumer.namespace}/*". '{consumer.name}' needs - // '{consumer.namespace}' in the same pattern, "teams/{consumer.namespace}/{consumer.name}": it is a - // naming convention, not a bound, since a GitBranch picks its own name. There is no escaping, '?' - // or character class. +// GitBranchGitTargetGrant admits GitTargets in some control-cluster namespaces to write through a +// GitBranch. +type GitBranchGitTargetGrant struct { + // ConsumerNamespaces are the control-cluster namespaces whose GitTargets this grant admits. + // from: Same is this GitBranch's namespace. // +required - // +listType=set - // +kubebuilder:validation:MinItems=1 - // +kubebuilder:validation:MaxItems=64 - Branches []BranchPattern `json:"branches"` + ConsumerNamespaces ConsumerNamespaces `json:"consumerNamespaces"` } -// SourceGrant admits GitTargets in some control-cluster namespaces to mirror through a KubeProvider, -// and bounds what they may mirror. -// +kubebuilder:validation:XValidation:rule="self.from != 'Names' || has(self.names)",message="from: Names needs names" -// +kubebuilder:validation:XValidation:rule="!has(self.names) || self.from == 'Names'",message="names goes only with from: Names" -// +kubebuilder:validation:XValidation:rule="self.from != 'Selector' || has(self.selector)",message="from: Selector needs a selector; to admit every namespace, use from: All" -// +kubebuilder:validation:XValidation:rule="!has(self.selector) || self.from == 'Selector'",message="selector goes only with from: Selector" -// +kubebuilder:validation:XValidation:rule="!has(self.selector) || (has(self.selector.matchLabels) && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) && size(self.selector.matchExpressions) > 0)",message="selector: {} is refused; to admit every namespace, use from: All" +// KubeProviderGitTargetGrant admits GitTargets in some control-cluster namespaces to mirror through a +// KubeProvider, and bounds what they may mirror. // +kubebuilder:validation:XValidation:rule="has(self.sourceNamespaces) || has(self.clusterResources)",message="a grant in allowedGitTargets sets sourceNamespaces, clusterResources, or both; one that grants neither grants nothing" -type SourceGrant struct { - // From is where this grant admits from: Same (this KubeProvider's namespace), Names (the - // namespaces in names), Selector (the namespaces whose labels match selector) or All (every - // namespace). +type KubeProviderGitTargetGrant struct { + // ConsumerNamespaces are the control-cluster namespaces whose GitTargets this grant admits. + // from: Same is this KubeProvider's namespace. // +required - From GrantFrom `json:"from"` - - // Names are the control-cluster namespaces admitted with from: Names, by exact name. - // +optional - // +listType=set - // +kubebuilder:validation:MinItems=1 - // +kubebuilder:validation:MaxItems=64 - Names []GrantNamespace `json:"names,omitempty"` - - // Selector matches labels on control-cluster Namespaces, with from: Selector. It must select - // something: to admit every namespace, use from: All. - // +optional - Selector *metav1.LabelSelector `json:"selector,omitempty"` + ConsumerNamespaces ConsumerNamespaces `json:"consumerNamespaces"` // SourceNamespaces are source-cluster namespaces that a WatchRule feeding an admitted GitTarget may // name in rules[].sourceNamespace. Entries are exact names, '{consumer.namespace}' for the @@ -222,31 +196,13 @@ type SourceGrant struct { ClusterResources []ClusterResourceIdentity `json:"clusterResources,omitempty"` } -// ApplierGrant admits applier claims in some control-cluster namespaces to hold an applier through a -// KubeProvider, and bounds where that applier may live. -// +kubebuilder:validation:XValidation:rule="self.from != 'Names' || has(self.names)",message="from: Names needs names" -// +kubebuilder:validation:XValidation:rule="!has(self.names) || self.from == 'Names'",message="names goes only with from: Names" -// +kubebuilder:validation:XValidation:rule="self.from != 'Selector' || has(self.selector)",message="from: Selector needs a selector; to admit every namespace, use from: All" -// +kubebuilder:validation:XValidation:rule="!has(self.selector) || self.from == 'Selector'",message="selector goes only with from: Selector" -// +kubebuilder:validation:XValidation:rule="!has(self.selector) || (has(self.selector.matchLabels) && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) && size(self.selector.matchExpressions) > 0)",message="selector: {} is refused; to admit every namespace, use from: All" -type ApplierGrant struct { - // From is where this grant admits from: Same (this KubeProvider's namespace), Names (the - // namespaces in names), Selector (the namespaces whose labels match selector) or All (every - // namespace). +// KubeProviderApplierClaimGrant admits applier claims in some control-cluster namespaces to hold an +// applier through a KubeProvider, and bounds where that applier may live. +type KubeProviderApplierClaimGrant struct { + // ConsumerNamespaces are the control-cluster namespaces whose applier claims this grant admits. + // from: Same is this KubeProvider's namespace. // +required - From GrantFrom `json:"from"` - - // Names are the control-cluster namespaces admitted with from: Names, by exact name. - // +optional - // +listType=set - // +kubebuilder:validation:MinItems=1 - // +kubebuilder:validation:MaxItems=64 - Names []GrantNamespace `json:"names,omitempty"` - - // Selector matches labels on control-cluster Namespaces, with from: Selector. It must select - // something: to admit every namespace, use from: All. - // +optional - Selector *metav1.LabelSelector `json:"selector,omitempty"` + ConsumerNamespaces ConsumerNamespaces `json:"consumerNamespaces"` // ApplierNamespaces are namespaces in this provider's cluster in which an admitted applier claim's // Kustomization or Application may live: exact names, '{consumer.namespace}' for the namespace @@ -265,37 +221,34 @@ type ApplierGrant struct { ApplierNamespaces []ScopeNamespace `json:"applierNamespaces"` } -// source is the admitting half of a GitBranchGrant. -func (g *GitBranchGrant) source() NamespaceGrant { - return NamespaceGrant{From: g.From, Names: g.Names, Selector: g.Selector} -} +// consumers is the admitting half of a GitProviderGitBranchGrant. +func (g *GitProviderGitBranchGrant) consumers() ConsumerNamespaces { return g.ConsumerNamespaces } -// source is the admitting half of a SourceGrant. -func (g *SourceGrant) source() NamespaceGrant { - return NamespaceGrant{From: g.From, Names: g.Names, Selector: g.Selector} -} +// consumers is the admitting half of a GitBranchGitTargetGrant. +func (g *GitBranchGitTargetGrant) consumers() ConsumerNamespaces { return g.ConsumerNamespaces } -// source is the admitting half of an ApplierGrant. -func (g *ApplierGrant) source() NamespaceGrant { - return NamespaceGrant{From: g.From, Names: g.Names, Selector: g.Selector} -} +// consumers is the admitting half of a KubeProviderGitTargetGrant. +func (g *KubeProviderGitTargetGrant) consumers() ConsumerNamespaces { return g.ConsumerNamespaces } + +// consumers is the admitting half of a KubeProviderApplierClaimGrant. +func (g *KubeProviderApplierClaimGrant) consumers() ConsumerNamespaces { return g.ConsumerNamespaces } -// sourcesOf is the admitting half of each grant in a list. -func sourcesOf[G any, PG interface { +// consumersOf is the admitting half of each grant in a list. +func consumersOf[G any, PG interface { *G - source() NamespaceGrant -}](grants []G) []NamespaceGrant { - sources := make([]NamespaceGrant, len(grants)) + consumers() ConsumerNamespaces +}](grants []G) []ConsumerNamespaces { + consumers := make([]ConsumerNamespaces, len(grants)) for i := range grants { - sources[i] = PG(&grants[i]).source() + consumers[i] = PG(&grants[i]).consumers() } - return sources + return consumers } // Admits reports whether this grant, held by an object in holderNamespace, admits a referring // object in namespace, by the labels that namespace carries in the control cluster. A selector that // does not convert is an error, never a silent allow; an unknown from admits nothing. -func (g *NamespaceGrant) Admits(holderNamespace, namespace string, nsLabels map[string]string) (bool, error) { +func (g *ConsumerNamespaces) Admits(holderNamespace, namespace string, nsLabels map[string]string) (bool, error) { switch g.From { case GrantFromSame: return namespace == holderNamespace, nil @@ -326,7 +279,7 @@ func (g *NamespaceGrant) Admits(holderNamespace, namespace string, nsLabels map[ } // Describe names the namespaces this grant admits, for a refusal message. -func (g *NamespaceGrant) Describe(holderNamespace string) string { +func (g *ConsumerNamespaces) Describe(holderNamespace string) string { switch g.From { case GrantFromSame: return holderNamespace @@ -347,17 +300,17 @@ func (g *NamespaceGrant) Describe(holderNamespace string) string { return fmt.Sprintf("no namespace (from %q is unknown)", g.From) } -// admitAll evaluates every grant's source for namespace and returns which admit it. A grant whose +// admitAll evaluates every grant's consumerNamespaces for namespace and returns which admit it. A grant whose // selector does not convert admits nothing; its error is returned only when no grant admits, so // one malformed grant cannot widen anything, and an admitted namespace is not refused over a grant // that did not apply to it. func admitAll( - sources []NamespaceGrant, holderNamespace, namespace string, nsLabels map[string]string, + consumers []ConsumerNamespaces, holderNamespace, namespace string, nsLabels map[string]string, ) ([]int, error) { var admitting []int var errs []error - for i := range sources { - admitted, err := sources[i].Admits(holderNamespace, namespace, nsLabels) + for i := range consumers { + admitted, err := consumers[i].Admits(holderNamespace, namespace, nsLabels) if err != nil { errs = append(errs, fmt.Errorf("grant %d: %w", i, err)) continue @@ -374,13 +327,13 @@ func admitAll( // describeAdmitted names the namespaces a list of grants admits, for a refusal message: "admits // team-a, team-b" or "admits no namespace". -func describeAdmitted(sources []NamespaceGrant, holderNamespace string) string { - if len(sources) == 0 { +func describeAdmitted(consumers []ConsumerNamespaces, holderNamespace string) string { + if len(consumers) == 0 { return "admits no namespace" } - parts := make([]string, 0, len(sources)) - for i := range sources { - parts = append(parts, sources[i].Describe(holderNamespace)) + parts := make([]string, 0, len(consumers)) + for i := range consumers { + parts = append(parts, consumers[i].Describe(holderNamespace)) } return "admits " + strings.Join(parts, ", ") } @@ -446,9 +399,10 @@ func ClusterResourceIdentityFor(group, resource string) string { type GitBranchAdmission struct { // Admitted reports whether some grant admits the GitBranch's namespace. Admitted bool - // Branches are the patterns the admitting grants give the GitBranch, consumer variables expanded. - Branches []string - // Allowed reports whether the namespace is admitted and the branch matches one of Branches. + // BranchPatterns are the patterns the admitting grants give the GitBranch, consumer variables + // expanded. + BranchPatterns []string + // Allowed reports whether the namespace is admitted and the branch matches one of BranchPatterns. Allowed bool } @@ -458,45 +412,45 @@ func (p *GitProvider) AdmitsGitBranch( consumer types.NamespacedName, nsLabels map[string]string, branch string, ) (GitBranchAdmission, error) { grants := p.Spec.AllowedGitBranches - admitting, err := admitAll(sourcesOf(grants), p.Namespace, consumer.Namespace, nsLabels) + admitting, err := admitAll(consumersOf(grants), p.Namespace, consumer.Namespace, nsLabels) if err != nil || len(admitting) == 0 { return GitBranchAdmission{}, err } out := GitBranchAdmission{Admitted: true} var patterns []BranchPattern for _, i := range admitting { - for _, pattern := range grants[i].Branches { + for _, pattern := range grants[i].BranchPatterns { patterns = append(patterns, pattern) if MatchBranchPattern(pattern, consumer, branch) { out.Allowed = true } } } - out.Branches = expandScope(patterns, consumer) + out.BranchPatterns = expandScope(patterns, consumer) return out, nil } // DescribeAllowedGitBranches names the namespaces spec.allowedGitBranches admits. func (p *GitProvider) DescribeAllowedGitBranches() string { - return describeAdmitted(sourcesOf(p.Spec.AllowedGitBranches), p.Namespace) + return describeAdmitted(consumersOf(p.Spec.AllowedGitBranches), p.Namespace) } // AdmitsGitTarget reports whether spec.allowedGitTargets admits a GitTarget in namespace, by the // labels that namespace carries in the control cluster. func (b *GitBranch) AdmitsGitTarget(namespace string, nsLabels map[string]string) (bool, error) { - admitting, err := admitAll(b.Spec.AllowedGitTargets, b.Namespace, namespace, nsLabels) + admitting, err := admitAll(consumersOf(b.Spec.AllowedGitTargets), b.Namespace, namespace, nsLabels) return len(admitting) > 0, err } // DescribeAllowedGitTargets names the namespaces spec.allowedGitTargets admits. func (b *GitBranch) DescribeAllowedGitTargets() string { - return describeAdmitted(b.Spec.AllowedGitTargets, b.Namespace) + return describeAdmitted(consumersOf(b.Spec.AllowedGitTargets), b.Namespace) } -// SourceScope is what a KubeProvider's allowedGitTargets give a GitTarget: whether it may mirror at +// KubeProviderGitTargetScope is what a KubeProvider's allowedGitTargets give a GitTarget: whether it may mirror at // all, and what. // +kubebuilder:object:generate=false -type SourceScope struct { +type KubeProviderGitTargetScope struct { // Admitted reports whether some grant admits the GitTarget's namespace. Admitted bool // SourceNamespaces is the union of the admitting grants' sourceNamespaces, consumer variables @@ -509,17 +463,17 @@ type SourceScope struct { // AllowsSourceNamespace reports whether a WatchRule may watch namespace; "*" asks for every // namespace, which only a "*" in the scope allows. -func (s SourceScope) AllowsSourceNamespace(namespace string) bool { +func (s KubeProviderGitTargetScope) AllowsSourceNamespace(namespace string) bool { return scopeAllows(s.SourceNamespaces, namespace) } // AllowsAllClusterResources reports whether the scope holds "*" in clusterResources. -func (s SourceScope) AllowsAllClusterResources() bool { +func (s KubeProviderGitTargetScope) AllowsAllClusterResources() bool { return scopeAllows(s.ClusterResources, "*") } // AllowsClusterResource reports whether a ClusterWatchRule may mirror resource in group. -func (s SourceScope) AllowsClusterResource(group, resource string) bool { +func (s KubeProviderGitTargetScope) AllowsClusterResource(group, resource string) bool { return scopeAllows(s.ClusterResources, ClusterResourceIdentityFor(group, resource)) } @@ -537,11 +491,11 @@ func scopeAllows(scope []string, value string) bool { // namespace carries in the control cluster. func (p *KubeProvider) AdmitsGitTarget( consumer types.NamespacedName, nsLabels map[string]string, -) (SourceScope, error) { +) (KubeProviderGitTargetScope, error) { grants := p.Spec.AllowedGitTargets - admitting, err := admitAll(sourcesOf(grants), p.Namespace, consumer.Namespace, nsLabels) + admitting, err := admitAll(consumersOf(grants), p.Namespace, consumer.Namespace, nsLabels) if err != nil || len(admitting) == 0 { - return SourceScope{}, err + return KubeProviderGitTargetScope{}, err } var namespaces []ScopeNamespace var resources []ClusterResourceIdentity @@ -549,7 +503,7 @@ func (p *KubeProvider) AdmitsGitTarget( namespaces = append(namespaces, grants[i].SourceNamespaces...) resources = append(resources, grants[i].ClusterResources...) } - return SourceScope{ + return KubeProviderGitTargetScope{ Admitted: true, SourceNamespaces: expandScope(namespaces, consumer), ClusterResources: expandScope(resources, consumer), @@ -558,12 +512,12 @@ func (p *KubeProvider) AdmitsGitTarget( // DescribeAllowedGitTargets names the namespaces spec.allowedGitTargets admits. func (p *KubeProvider) DescribeAllowedGitTargets() string { - return describeAdmitted(sourcesOf(p.Spec.AllowedGitTargets), p.Namespace) + return describeAdmitted(consumersOf(p.Spec.AllowedGitTargets), p.Namespace) } -// ApplierScope is what a KubeProvider's allowedApplierClaims give an applier claim. +// KubeProviderApplierClaimScope is what a KubeProvider's allowedApplierClaims give an applier claim. // +kubebuilder:object:generate=false -type ApplierScope struct { +type KubeProviderApplierClaimScope struct { // Admitted reports whether some grant admits the applier claim's namespace. Admitted bool // ApplierNamespaces is the union of the admitting grants' applierNamespaces, consumer variables @@ -572,7 +526,7 @@ type ApplierScope struct { } // AllowsApplierNamespace reports whether an applier in namespace may be held. -func (s ApplierScope) AllowsApplierNamespace(namespace string) bool { +func (s KubeProviderApplierClaimScope) AllowsApplierNamespace(namespace string) bool { return scopeAllows(s.ApplierNamespaces, namespace) } @@ -580,20 +534,20 @@ func (s ApplierScope) AllowsApplierNamespace(namespace string) bool { // labels its namespace carries in the control cluster. func (p *KubeProvider) AdmitsApplierClaim( consumer types.NamespacedName, nsLabels map[string]string, -) (ApplierScope, error) { +) (KubeProviderApplierClaimScope, error) { grants := p.Spec.AllowedApplierClaims - admitting, err := admitAll(sourcesOf(grants), p.Namespace, consumer.Namespace, nsLabels) + admitting, err := admitAll(consumersOf(grants), p.Namespace, consumer.Namespace, nsLabels) if err != nil || len(admitting) == 0 { - return ApplierScope{}, err + return KubeProviderApplierClaimScope{}, err } var namespaces []ScopeNamespace for _, i := range admitting { namespaces = append(namespaces, grants[i].ApplierNamespaces...) } - return ApplierScope{Admitted: true, ApplierNamespaces: expandScope(namespaces, consumer)}, nil + return KubeProviderApplierClaimScope{Admitted: true, ApplierNamespaces: expandScope(namespaces, consumer)}, nil } // DescribeAllowedApplierClaims names the namespaces spec.allowedApplierClaims admits. func (p *KubeProvider) DescribeAllowedApplierClaims() string { - return describeAdmitted(sourcesOf(p.Spec.AllowedApplierClaims), p.Namespace) + return describeAdmitted(consumersOf(p.Spec.AllowedApplierClaims), p.Namespace) } diff --git a/api/v1alpha3/access_grants_test.go b/api/v1alpha3/access_grants_test.go index 60e5d80b..58663bd1 100644 --- a/api/v1alpha3/access_grants_test.go +++ b/api/v1alpha3/access_grants_test.go @@ -56,34 +56,34 @@ func TestMatchBranchPattern(t *testing.T) { } } -func TestNamespaceGrant_Admits(t *testing.T) { +func TestConsumerNamespaces_Admits(t *testing.T) { tenant := map[string]string{"tenant": "true"} tests := []struct { name string - grant NamespaceGrant + grant ConsumerNamespaces namespace string labels map[string]string admits bool }{ - {"Same admits the holder's namespace", NamespaceGrant{From: GrantFromSame}, "platform", nil, true}, - {"Same admits nothing else", NamespaceGrant{From: GrantFromSame}, "team-a", tenant, false}, + {"Same admits the holder's namespace", ConsumerNamespaces{From: GrantFromSame}, "platform", nil, true}, + {"Same admits nothing else", ConsumerNamespaces{From: GrantFromSame}, "team-a", tenant, false}, {"Names admits a listed namespace", - NamespaceGrant{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "team-a", nil, true}, + ConsumerNamespaces{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "team-a", nil, true}, {"Names admits nothing else", - NamespaceGrant{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "team-b", nil, false}, + ConsumerNamespaces{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "team-b", nil, false}, // An exact allow-list, never a prefix: otherwise "team-a-evil" would inherit team-a's grant. {"Names is no prefix match", - NamespaceGrant{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "team-a-evil", nil, false}, + ConsumerNamespaces{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "team-a-evil", nil, false}, {"Names is case-sensitive", - NamespaceGrant{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "Team-A", nil, false}, - {"Selector admits a matching namespace", NamespaceGrant{From: GrantFromSelector, + ConsumerNamespaces{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, "Team-A", nil, false}, + {"Selector admits a matching namespace", ConsumerNamespaces{From: GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}}, "team-a", tenant, true}, - {"Selector admits nothing else", NamespaceGrant{From: GrantFromSelector, + {"Selector admits nothing else", ConsumerNamespaces{From: GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}}, "team-a", nil, false}, - {"an empty selector admits nobody", NamespaceGrant{From: GrantFromSelector, + {"an empty selector admits nobody", ConsumerNamespaces{From: GrantFromSelector, Selector: &metav1.LabelSelector{}}, "team-a", tenant, false}, - {"All admits every namespace", NamespaceGrant{From: GrantFromAll}, "anything", nil, true}, - {"an unknown from admits nothing", NamespaceGrant{From: "Everyone"}, "platform", nil, false}, + {"All admits every namespace", ConsumerNamespaces{From: GrantFromAll}, "anything", nil, true}, + {"an unknown from admits nothing", ConsumerNamespaces{From: "Everyone"}, "platform", nil, false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -94,12 +94,14 @@ func TestNamespaceGrant_Admits(t *testing.T) { } } -// TestNamespaceGrant_SameFollowsTheHolder: Same names no namespace, so a kustomize overlay that moves +// TestConsumerNamespaces_SameFollowsTheHolder: Same names no namespace, so a kustomize overlay that moves // the object holding the grant moves what it admits with it. -func TestNamespaceGrant_SameFollowsTheHolder(t *testing.T) { +func TestConsumerNamespaces_SameFollowsTheHolder(t *testing.T) { branch := &GitBranch{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a"}, - Spec: GitBranchSpec{AllowedGitTargets: []NamespaceGrant{{From: GrantFromSame}}}, + Spec: GitBranchSpec{ + AllowedGitTargets: []GitBranchGitTargetGrant{{ConsumerNamespaces: ConsumerNamespaces{From: GrantFromSame}}}, + }, } admits, err := branch.AdmitsGitTarget("team-a", nil) require.NoError(t, err) @@ -114,8 +116,8 @@ func TestNamespaceGrant_SameFollowsTheHolder(t *testing.T) { assert.False(t, admits, "and nothing else") } -func TestNamespaceGrant_InvalidSelectorIsAnError(t *testing.T) { - grant := NamespaceGrant{From: GrantFromSelector, Selector: &metav1.LabelSelector{ +func TestConsumerNamespaces_InvalidSelectorIsAnError(t *testing.T) { + grant := ConsumerNamespaces{From: GrantFromSelector, Selector: &metav1.LabelSelector{ MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "tenant", Operator: "Bogus"}}, }} _, err := grant.Admits("platform", "team-a", nil) @@ -123,7 +125,9 @@ func TestNamespaceGrant_InvalidSelectorIsAnError(t *testing.T) { // One malformed grant refuses nobody another grant admits, and admits nobody itself. branch := &GitBranch{ObjectMeta: metav1.ObjectMeta{Namespace: "platform"}, - Spec: GitBranchSpec{AllowedGitTargets: []NamespaceGrant{grant, {From: GrantFromSame}}}} + Spec: GitBranchSpec{AllowedGitTargets: []GitBranchGitTargetGrant{ + {ConsumerNamespaces: grant}, {ConsumerNamespaces: ConsumerNamespaces{From: GrantFromSame}}, + }}} admits, err := branch.AdmitsGitTarget("platform", nil) require.NoError(t, err) assert.True(t, admits) @@ -139,18 +143,28 @@ func TestGitBranch_AdmitsGitTarget_NothingAutomatic(t *testing.T) { assert.False(t, admits, "an omitted list admits nobody, the GitBranch's own namespace included") assert.Equal(t, "admits no namespace", branch.DescribeAllowedGitTargets()) - branch.Spec.AllowedGitTargets = []NamespaceGrant{{From: GrantFromSame}, - {From: GrantFromNames, Names: []GrantNamespace{"team-b"}}} + branch.Spec.AllowedGitTargets = []GitBranchGitTargetGrant{ + {ConsumerNamespaces: ConsumerNamespaces{From: GrantFromSame}}, + {ConsumerNamespaces: ConsumerNamespaces{From: GrantFromNames, Names: []GrantNamespace{"team-b"}}}, + } assert.Equal(t, "admits team-a, team-b", branch.DescribeAllowedGitTargets()) } func TestGitProvider_AdmitsGitBranch(t *testing.T) { provider := &GitProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "shared-repo"}, - Spec: GitProviderSpec{AllowedGitBranches: []GitBranchGrant{ - {From: GrantFromSame, Branches: []BranchPattern{"main", "release/*"}}, - {From: GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tenant": "true"}}, - Branches: []BranchPattern{"teams/{consumer.namespace}/*", "feature/*"}}, + Spec: GitProviderSpec{AllowedGitBranches: []GitProviderGitBranchGrant{ + { + ConsumerNamespaces: ConsumerNamespaces{From: GrantFromSame}, + BranchPatterns: []BranchPattern{"main", "release/*"}, + }, + { + ConsumerNamespaces: ConsumerNamespaces{ + From: GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tenant": "true"}}, + }, + BranchPatterns: []BranchPattern{"teams/{consumer.namespace}/*", "feature/*"}, + }, }}, } tenant := map[string]string{"tenant": "true"} @@ -158,7 +172,7 @@ func TestGitProvider_AdmitsGitBranch(t *testing.T) { got, err := provider.AdmitsGitBranch(consumer("team-a", "live"), tenant, "teams/team-a/live") require.NoError(t, err) assert.Equal(t, GitBranchAdmission{Admitted: true, Allowed: true, - Branches: []string{"feature/*", "teams/team-a/*"}}, got) + BranchPatterns: []string{"feature/*", "teams/team-a/*"}}, got) got, err = provider.AdmitsGitBranch(consumer("team-a", "live"), tenant, "main") require.NoError(t, err) @@ -187,13 +201,23 @@ func TestKubeProvider_AdmitsGitTarget(t *testing.T) { tenant := map[string]string{"tenant": "true"} provider := &KubeProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "production"}, - Spec: KubeProviderSpec{AllowedGitTargets: []SourceGrant{ - {From: GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}, - SourceNamespaces: []ScopeNamespace{"{consumer.namespace}"}}, - {From: GrantFromNames, Names: []GrantNamespace{"platform-audit"}, - SourceNamespaces: []ScopeNamespace{"*"}, ClusterResources: []ClusterResourceIdentity{"*"}}, - {From: GrantFromNames, Names: []GrantNamespace{"rbac-mirror"}, - ClusterResources: []ClusterResourceIdentity{"clusterroles.rbac.authorization.k8s.io", "nodes"}}, + Spec: KubeProviderSpec{AllowedGitTargets: []KubeProviderGitTargetGrant{ + { + ConsumerNamespaces: ConsumerNamespaces{ + From: GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: tenant}, + }, + SourceNamespaces: []ScopeNamespace{"{consumer.namespace}"}, + }, + { + ConsumerNamespaces: ConsumerNamespaces{From: GrantFromNames, Names: []GrantNamespace{"platform-audit"}}, + SourceNamespaces: []ScopeNamespace{"*"}, + ClusterResources: []ClusterResourceIdentity{"*"}, + }, + { + ConsumerNamespaces: ConsumerNamespaces{From: GrantFromNames, Names: []GrantNamespace{"rbac-mirror"}}, + ClusterResources: []ClusterResourceIdentity{"clusterroles.rbac.authorization.k8s.io", "nodes"}, + }, }}, } @@ -229,10 +253,18 @@ func TestKubeProvider_AdmitsGitTarget(t *testing.T) { func TestKubeProvider_AdmitsGitTarget_UnionOfScopes(t *testing.T) { provider := &KubeProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform"}, - Spec: KubeProviderSpec{AllowedGitTargets: []SourceGrant{ - {From: GrantFromAll, SourceNamespaces: []ScopeNamespace{"{consumer.namespace}"}}, - {From: GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"audit": "true"}}, - SourceNamespaces: []ScopeNamespace{"*"}}, + Spec: KubeProviderSpec{AllowedGitTargets: []KubeProviderGitTargetGrant{ + { + ConsumerNamespaces: ConsumerNamespaces{From: GrantFromAll}, + SourceNamespaces: []ScopeNamespace{"{consumer.namespace}"}, + }, + { + ConsumerNamespaces: ConsumerNamespaces{ + From: GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"audit": "true"}}, + }, + SourceNamespaces: []ScopeNamespace{"*"}, + }, }}, } scope, err := provider.AdmitsGitTarget(consumer("team-a", "mirror"), map[string]string{"audit": "true"}) @@ -248,14 +280,16 @@ func TestKubeProvider_AdmitsApplierClaim(t *testing.T) { provider := &KubeProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: "platform"}, Spec: KubeProviderSpec{ - AllowedGitTargets: []SourceGrant{ - {From: GrantFromAll, SourceNamespaces: []ScopeNamespace{"{consumer.namespace}"}}, + AllowedGitTargets: []KubeProviderGitTargetGrant{ + { + ConsumerNamespaces: ConsumerNamespaces{From: GrantFromAll}, + SourceNamespaces: []ScopeNamespace{"{consumer.namespace}"}, + }, }, - AllowedApplierClaims: []ApplierGrant{ + AllowedApplierClaims: []KubeProviderApplierClaimGrant{ { - From: GrantFromNames, - Names: []GrantNamespace{"team-a"}, - ApplierNamespaces: []ScopeNamespace{"flux-system"}, + ConsumerNamespaces: ConsumerNamespaces{From: GrantFromNames, Names: []GrantNamespace{"team-a"}}, + ApplierNamespaces: []ScopeNamespace{"flux-system"}, }, }, }, diff --git a/api/v1alpha3/audit_route_test.go b/api/v1alpha3/audit_route_test.go index 21743494..94675a53 100644 --- a/api/v1alpha3/audit_route_test.go +++ b/api/v1alpha3/audit_route_test.go @@ -27,8 +27,8 @@ func TestKubeProvider_AuditRoute_SharedBySeveralProviders(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Namespace: "platform", Name: "srcns-delegating"}, Spec: KubeProviderSpec{ InCluster: &KubeProviderInCluster{}, - AllowedGitTargets: []SourceGrant{ - {From: GrantFromSame, SourceNamespaces: []ScopeNamespace{"*"}}, + AllowedGitTargets: []KubeProviderGitTargetGrant{ + {ConsumerNamespaces: ConsumerNamespaces{From: GrantFromSame}, SourceNamespaces: []ScopeNamespace{"*"}}, }, Attribution: &KubeProviderAttribution{AuditRoute: "platform.local"}, }, diff --git a/api/v1alpha3/gitbranch_types.go b/api/v1alpha3/gitbranch_types.go index bf2b3430..018346d8 100644 --- a/api/v1alpha3/gitbranch_types.go +++ b/api/v1alpha3/gitbranch_types.go @@ -58,12 +58,12 @@ type GitBranchSpec struct { // AllowedGitTargets says which GitTargets may write through this GitBranch. Nothing is admitted // automatically, this GitBranch's own namespace included: a GitTarget there needs a grant too, - // such as from: Same. Omitted or empty, no GitTarget writes through it. Grants combine by union. - // Mutable: claiming a branch includes the right to share it. + // such as consumerNamespaces: {from: Same}. Omitted or empty, no GitTarget writes through it. + // Grants combine by union. Mutable: claiming a branch includes the right to share it. // +optional // +listType=atomic // +kubebuilder:validation:MaxItems=64 - AllowedGitTargets []NamespaceGrant `json:"allowedGitTargets,omitempty"` + AllowedGitTargets []GitBranchGitTargetGrant `json:"allowedGitTargets,omitempty"` } // GitBranchStatus defines the observed state of GitBranch. diff --git a/api/v1alpha3/gitprovider_types.go b/api/v1alpha3/gitprovider_types.go index 0dad5786..0525e2a7 100644 --- a/api/v1alpha3/gitprovider_types.go +++ b/api/v1alpha3/gitprovider_types.go @@ -53,14 +53,14 @@ type GitProviderSpec struct { // AllowedGitBranches says which GitBranches may claim a branch of this repository through this // GitProvider, and which branches: a GitBranch claims only while a grant admits its namespace and - // one of that grant's branches matches its spec.branch. Nothing is admitted automatically, this + // one of that grant's branchPatterns matches its spec.branch. Nothing is admitted automatically, this // GitProvider's own namespace included; omitted or empty, no GitBranch claims through it. Grants // combine by union. Who may write through a claimed branch is that GitBranch's // spec.allowedGitTargets. // +optional // +listType=atomic // +kubebuilder:validation:MaxItems=64 - AllowedGitBranches []GitBranchGrant `json:"allowedGitBranches,omitempty"` + AllowedGitBranches []GitProviderGitBranchGrant `json:"allowedGitBranches,omitempty"` // Commit configures the commit identity and signing behavior this connection uses. Message // formatting moved to GitTarget.spec.commit.message. diff --git a/api/v1alpha3/kubeprovider_types.go b/api/v1alpha3/kubeprovider_types.go index 6e6c94e3..716b6dbc 100644 --- a/api/v1alpha3/kubeprovider_types.go +++ b/api/v1alpha3/kubeprovider_types.go @@ -51,7 +51,7 @@ type KubeProviderSpec struct { // +optional // +listType=atomic // +kubebuilder:validation:MaxItems=64 - AllowedGitTargets []SourceGrant `json:"allowedGitTargets,omitempty"` + AllowedGitTargets []KubeProviderGitTargetGrant `json:"allowedGitTargets,omitempty"` // AllowedApplierClaims says which applier claims (FluxClaims and ArgoCDClaims) may hold an applier // through this provider, and in which namespaces that applier may live (applierNamespaces). @@ -60,7 +60,7 @@ type KubeProviderSpec struct { // +optional // +listType=atomic // +kubebuilder:validation:MaxItems=64 - AllowedApplierClaims []ApplierGrant `json:"allowedApplierClaims,omitempty"` + AllowedApplierClaims []KubeProviderApplierClaimGrant `json:"allowedApplierClaims,omitempty"` // Client overrides the operator's outgoing kube-client throttles for this cluster. Omitted, // the built-in defaults apply (20 QPS, burst 30). Refused beside inCluster. diff --git a/api/v1alpha3/zz_generated.deepcopy.go b/api/v1alpha3/zz_generated.deepcopy.go index de50cc80..057072f6 100644 --- a/api/v1alpha3/zz_generated.deepcopy.go +++ b/api/v1alpha3/zz_generated.deepcopy.go @@ -63,36 +63,6 @@ func (in *ApplierClaimReference) DeepCopy() *ApplierClaimReference { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *ApplierGrant) DeepCopyInto(out *ApplierGrant) { - *out = *in - if in.Names != nil { - in, out := &in.Names, &out.Names - *out = make([]GrantNamespace, len(*in)) - copy(*out, *in) - } - if in.Selector != nil { - in, out := &in.Selector, &out.Selector - *out = new(v1.LabelSelector) - (*in).DeepCopyInto(*out) - } - if in.ApplierNamespaces != nil { - in, out := &in.ApplierNamespaces, &out.ApplierNamespaces - *out = make([]ScopeNamespace, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ApplierGrant. -func (in *ApplierGrant) DeepCopy() *ApplierGrant { - if in == nil { - return nil - } - out := new(ApplierGrant) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ApplyOperation) DeepCopyInto(out *ApplyOperation) { *out = *in @@ -613,6 +583,31 @@ func (in *CommitterSpec) DeepCopy() *CommitterSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConsumerNamespaces) DeepCopyInto(out *ConsumerNamespaces) { + *out = *in + if in.Names != nil { + in, out := &in.Names, &out.Names + *out = make([]GrantNamespace, len(*in)) + copy(*out, *in) + } + if in.Selector != nil { + in, out := &in.Selector, &out.Selector + *out = new(v1.LabelSelector) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConsumerNamespaces. +func (in *ConsumerNamespaces) DeepCopy() *ConsumerNamespaces { + if in == nil { + return nil + } + out := new(ConsumerNamespaces) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *EncryptionSpec) DeepCopyInto(out *EncryptionSpec) { *out = *in @@ -796,31 +791,17 @@ func (in *GitBranch) DeepCopyObject() runtime.Object { } // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *GitBranchGrant) DeepCopyInto(out *GitBranchGrant) { +func (in *GitBranchGitTargetGrant) DeepCopyInto(out *GitBranchGitTargetGrant) { *out = *in - if in.Names != nil { - in, out := &in.Names, &out.Names - *out = make([]GrantNamespace, len(*in)) - copy(*out, *in) - } - if in.Selector != nil { - in, out := &in.Selector, &out.Selector - *out = new(v1.LabelSelector) - (*in).DeepCopyInto(*out) - } - if in.Branches != nil { - in, out := &in.Branches, &out.Branches - *out = make([]BranchPattern, len(*in)) - copy(*out, *in) - } + in.ConsumerNamespaces.DeepCopyInto(&out.ConsumerNamespaces) } -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitBranchGrant. -func (in *GitBranchGrant) DeepCopy() *GitBranchGrant { +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitBranchGitTargetGrant. +func (in *GitBranchGitTargetGrant) DeepCopy() *GitBranchGitTargetGrant { if in == nil { return nil } - out := new(GitBranchGrant) + out := new(GitBranchGitTargetGrant) in.DeepCopyInto(out) return out } @@ -902,7 +883,7 @@ func (in *GitBranchSpec) DeepCopyInto(out *GitBranchSpec) { out.GitProviderRef = in.GitProviderRef if in.AllowedGitTargets != nil { in, out := &in.AllowedGitTargets, &out.AllowedGitTargets - *out = make([]NamespaceGrant, len(*in)) + *out = make([]GitBranchGitTargetGrant, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } @@ -977,6 +958,27 @@ func (in *GitProvider) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitProviderGitBranchGrant) DeepCopyInto(out *GitProviderGitBranchGrant) { + *out = *in + in.ConsumerNamespaces.DeepCopyInto(&out.ConsumerNamespaces) + if in.BranchPatterns != nil { + in, out := &in.BranchPatterns, &out.BranchPatterns + *out = make([]BranchPattern, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitProviderGitBranchGrant. +func (in *GitProviderGitBranchGrant) DeepCopy() *GitProviderGitBranchGrant { + if in == nil { + return nil + } + out := new(GitProviderGitBranchGrant) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *GitProviderList) DeepCopyInto(out *GitProviderList) { *out = *in @@ -1024,7 +1026,7 @@ func (in *GitProviderSpec) DeepCopyInto(out *GitProviderSpec) { } if in.AllowedGitBranches != nil { in, out := &in.AllowedGitBranches, &out.AllowedGitBranches - *out = make([]GitBranchGrant, len(*in)) + *out = make([]GitProviderGitBranchGrant, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } @@ -1459,6 +1461,27 @@ func (in *KubeProvider) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubeProviderApplierClaimGrant) DeepCopyInto(out *KubeProviderApplierClaimGrant) { + *out = *in + in.ConsumerNamespaces.DeepCopyInto(&out.ConsumerNamespaces) + if in.ApplierNamespaces != nil { + in, out := &in.ApplierNamespaces, &out.ApplierNamespaces + *out = make([]ScopeNamespace, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeProviderApplierClaimGrant. +func (in *KubeProviderApplierClaimGrant) DeepCopy() *KubeProviderApplierClaimGrant { + if in == nil { + return nil + } + out := new(KubeProviderApplierClaimGrant) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KubeProviderAttribution) DeepCopyInto(out *KubeProviderAttribution) { *out = *in @@ -1499,6 +1522,32 @@ func (in *KubeProviderClient) DeepCopy() *KubeProviderClient { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KubeProviderGitTargetGrant) DeepCopyInto(out *KubeProviderGitTargetGrant) { + *out = *in + in.ConsumerNamespaces.DeepCopyInto(&out.ConsumerNamespaces) + if in.SourceNamespaces != nil { + in, out := &in.SourceNamespaces, &out.SourceNamespaces + *out = make([]ScopeNamespace, len(*in)) + copy(*out, *in) + } + if in.ClusterResources != nil { + in, out := &in.ClusterResources, &out.ClusterResources + *out = make([]ClusterResourceIdentity, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KubeProviderGitTargetGrant. +func (in *KubeProviderGitTargetGrant) DeepCopy() *KubeProviderGitTargetGrant { + if in == nil { + return nil + } + out := new(KubeProviderGitTargetGrant) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *KubeProviderInCluster) DeepCopyInto(out *KubeProviderInCluster) { *out = *in @@ -1561,14 +1610,14 @@ func (in *KubeProviderSpec) DeepCopyInto(out *KubeProviderSpec) { } if in.AllowedGitTargets != nil { in, out := &in.AllowedGitTargets, &out.AllowedGitTargets - *out = make([]SourceGrant, len(*in)) + *out = make([]KubeProviderGitTargetGrant, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } } if in.AllowedApplierClaims != nil { in, out := &in.AllowedApplierClaims, &out.AllowedApplierClaims - *out = make([]ApplierGrant, len(*in)) + *out = make([]KubeProviderApplierClaimGrant, len(*in)) for i := range *in { (*in)[i].DeepCopyInto(&(*out)[i]) } @@ -1617,31 +1666,6 @@ func (in *KubeProviderStatus) DeepCopy() *KubeProviderStatus { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *NamespaceGrant) DeepCopyInto(out *NamespaceGrant) { - *out = *in - if in.Names != nil { - in, out := &in.Names, &out.Names - *out = make([]GrantNamespace, len(*in)) - copy(*out, *in) - } - if in.Selector != nil { - in, out := &in.Selector, &out.Selector - *out = new(v1.LabelSelector) - (*in).DeepCopyInto(*out) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NamespaceGrant. -func (in *NamespaceGrant) DeepCopy() *NamespaceGrant { - if in == nil { - return nil - } - out := new(NamespaceGrant) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PrunePolicy) DeepCopyInto(out *PrunePolicy) { *out = *in @@ -1707,41 +1731,6 @@ func (in *ResourceRule) DeepCopy() *ResourceRule { return out } -// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. -func (in *SourceGrant) DeepCopyInto(out *SourceGrant) { - *out = *in - if in.Names != nil { - in, out := &in.Names, &out.Names - *out = make([]GrantNamespace, len(*in)) - copy(*out, *in) - } - if in.Selector != nil { - in, out := &in.Selector, &out.Selector - *out = new(v1.LabelSelector) - (*in).DeepCopyInto(*out) - } - if in.SourceNamespaces != nil { - in, out := &in.SourceNamespaces, &out.SourceNamespaces - *out = make([]ScopeNamespace, len(*in)) - copy(*out, *in) - } - if in.ClusterResources != nil { - in, out := &in.ClusterResources, &out.ClusterResources - *out = make([]ClusterResourceIdentity, len(*in)) - copy(*out, *in) - } -} - -// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SourceGrant. -func (in *SourceGrant) DeepCopy() *SourceGrant { - if in == nil { - return nil - } - out := new(SourceGrant) - in.DeepCopyInto(out) - return out -} - // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *StreamsStatus) DeepCopyInto(out *StreamsStatus) { *out = *in diff --git a/charts/gitops-reverser/README.md b/charts/gitops-reverser/README.md index bdd092d7..047b8866 100644 --- a/charts/gitops-reverser/README.md +++ b/charts/gitops-reverser/README.md @@ -194,8 +194,8 @@ nodeSelector: | `quickstart.createNamespace` | Let the chart create `quickstart.namespace` (Helm then owns it) | `false` | | `quickstart.gitProvider.url` | Repository URL used by the starter `GitProvider` | `""` | | `quickstart.gitProvider.secretRef.name` | Existing Secret name used by the starter `GitProvider` | `git-creds` | -| `quickstart.gitProvider.allowedGitBranches` | Branch patterns the quickstart namespace may claim through the starter `GitProvider` (a `from: Same` grant) | `["*"]` | -| `quickstart.gitBranch.branch` | Branch the starter `GitBranch` writes. Must match `quickstart.gitProvider.allowedGitBranches` | `main` | +| `quickstart.gitProvider.branchPatterns` | Branch patterns the quickstart namespace may claim through the starter `GitProvider` (the `branchPatterns` of a `from: Same` grant) | `["*"]` | +| `quickstart.gitBranch.branch` | Branch the starter `GitBranch` writes. Must match `quickstart.gitProvider.branchPatterns` | `main` | | `quickstart.gitTarget.path` | Repository path used by the starter `GitTarget`; set `.` only to deliberately target the repo root | `live-cluster` | | `quickstart.watchRule.rules` | Rules used by the starter `WatchRule` | `configmaps create/update/delete` | | `queue.redis.addr` | Redis/Valkey endpoint (`host:port`). Optional but advised: empty means cold-replay on restart. Set it for warm-restart cursors and for the admission webhook to actually record CommitRequest authors (admission runs as a no-op without it); **required** when `attribution.enabled=true` unless `attribution.transport=memory` | `""` | @@ -218,8 +218,8 @@ nodeSelector: | `kubeProvider.name` | Name of the rendered provider. Its audit route is `.`, the `/audit-webhook/` path the API server posts to | `local` | | `kubeProvider.secretRef.name` | Secret in the release namespace holding a kubeconfig for a **remote** cluster. Empty renders `spec.inCluster`, the operator's **own** identity, and lists the provider in `--in-cluster-kube-providers`. Immutable once created: delete and recreate to repoint | `""` | | `kubeProvider.secretRef.key` | Key within that Secret. Empty reads `value` then `value.yaml` | `""` | -| `kubeProvider.allowedGitTargets` | Rendered as the provider's `spec.allowedGitTargets`: which namespaces' `GitTarget`s may mirror through it, and what. Each grant has a `from` (`Same`, `Names`, `Selector`, `All`), and `sourceNamespaces`, `clusterResources` or both. **Empty grants nothing, the release namespace included**: a `GitTarget` through `local` is refused with a message naming `spec.allowedGitTargets`. `quickstart.enabled` adds a grant for the quickstart namespace, and [`values-playground.yaml`](values-playground.yaml) grants every namespace its own. With an `inCluster` provider a grant bypasses live namespace RBAC through the operator's identity | `[]` | -| `kubeProvider.allowedApplierClaims` | Rendered as the provider's `spec.allowedApplierClaims`: which namespaces' `FluxClaim`s and `ArgoCDClaim`s may hold an applier through it, and in which `applierNamespaces` it may live, such as `flux-system`. Admission to mirror is no admission to apply. Empty grants nothing | `[]` | +| `kubeProvider.allowedGitTargets` | Rendered as the provider's `spec.allowedGitTargets`: which namespaces' `GitTarget`s may mirror through it, and what. Each grant has a `consumerNamespaces` block (`from`: `Same`, `Names`, `Selector`, `All`), and `sourceNamespaces`, `clusterResources` or both beside it. **Empty grants nothing, the release namespace included**: a `GitTarget` through `local` is refused with a message naming `spec.allowedGitTargets`. `quickstart.enabled` adds a grant for the quickstart namespace, and [`values-playground.yaml`](values-playground.yaml) grants every namespace its own. With an `inCluster` provider a grant bypasses live namespace RBAC through the operator's identity | `[]` | +| `kubeProvider.allowedApplierClaims` | Rendered as the provider's `spec.allowedApplierClaims`: which namespaces' `FluxClaim`s and `ArgoCDClaim`s may hold an applier through it (`consumerNamespaces`), and in which `applierNamespaces` it may live, such as `flux-system`. Admission to mirror is no admission to apply. Empty grants nothing | `[]` | | `servers.admission.enabled` | Install the validate-operator-types admission webhook that captures CommitRequest authors (a form of author attribution). Enabled by default; a no-op until `queue.redis.addr` is set | `true` | | `rbac.create` | Create the manager ClusterRole and its binding | `true` | | `rbac.watchTypes.mode` | Which types a `WatchRule` may read. `any` grants cluster-wide read on everything, including every Secret in the cluster. `selected` grants read on `rbac.watchTypes.selected` only, so the reverser cannot list or watch Secrets (it keeps `get` on named Secrets it is pointed at). See [`docs/rbac.md`](../../docs/rbac.md) | `any` | diff --git a/charts/gitops-reverser/templates/kubeprovider.yaml b/charts/gitops-reverser/templates/kubeprovider.yaml index 518fcd4f..b720a3a1 100644 --- a/charts/gitops-reverser/templates/kubeprovider.yaml +++ b/charts/gitops-reverser/templates/kubeprovider.yaml @@ -31,8 +31,8 @@ spec: {{- $applierClaims := .Values.kubeProvider.allowedApplierClaims }} {{- if .Values.quickstart.enabled }} {{- $names := list .Values.quickstart.namespace }} - {{- $gitTargets = append $gitTargets (dict "from" "Names" "names" $names "sourceNamespaces" (list "{consumer.namespace}")) }} - {{- $applierClaims = append $applierClaims (dict "from" "Names" "names" $names "applierNamespaces" (list "{consumer.namespace}")) }} + {{- $gitTargets = append $gitTargets (dict "consumerNamespaces" (dict "from" "Names" "names" $names) "sourceNamespaces" (list "{consumer.namespace}")) }} + {{- $applierClaims = append $applierClaims (dict "consumerNamespaces" (dict "from" "Names" "names" $names) "applierNamespaces" (list "{consumer.namespace}")) }} {{- end }} {{- with $gitTargets }} allowedGitTargets: diff --git a/charts/gitops-reverser/templates/quickstart.yaml b/charts/gitops-reverser/templates/quickstart.yaml index 3f2a53e8..e058dbee 100644 --- a/charts/gitops-reverser/templates/quickstart.yaml +++ b/charts/gitops-reverser/templates/quickstart.yaml @@ -21,9 +21,10 @@ metadata: spec: url: {{ .Values.quickstart.gitProvider.url | quote }} allowedGitBranches: - - from: Same - branches: - {{- toYaml .Values.quickstart.gitProvider.allowedGitBranches | nindent 8 }} + - consumerNamespaces: + from: Same + branchPatterns: + {{- toYaml .Values.quickstart.gitProvider.branchPatterns | nindent 8 }} secretRef: name: {{ .Values.quickstart.gitProvider.secretRef.name }} --- @@ -40,7 +41,8 @@ spec: name: {{ .Values.quickstart.gitProvider.name }} branch: {{ .Values.quickstart.gitBranch.branch | quote }} allowedGitTargets: - - from: Same + - consumerNamespaces: + from: Same --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/charts/gitops-reverser/values-playground.yaml b/charts/gitops-reverser/values-playground.yaml index 758b85a4..8ff9bd09 100644 --- a/charts/gitops-reverser/values-playground.yaml +++ b/charts/gitops-reverser/values-playground.yaml @@ -11,8 +11,8 @@ # share a cluster. kubeProvider: allowedGitTargets: - - from: All + - consumerNamespaces: {from: All} sourceNamespaces: ["{consumer.namespace}"] allowedApplierClaims: - - from: All + - consumerNamespaces: {from: All} applierNamespaces: ["{consumer.namespace}"] diff --git a/charts/gitops-reverser/values.schema.json b/charts/gitops-reverser/values.schema.json index 7d3058de..3220c4fc 100644 --- a/charts/gitops-reverser/values.schema.json +++ b/charts/gitops-reverser/values.schema.json @@ -402,12 +402,12 @@ }, "allowedGitTargets": { "type": "array", - "description": "Rendered as the provider's spec.allowedGitTargets: which namespaces' GitTargets may mirror through it, and what (sourceNamespaces, clusterResources). Empty grants nothing. The CRD validates each grant.", + "description": "Rendered as the provider's spec.allowedGitTargets: which namespaces' GitTargets may mirror through it (consumerNamespaces), and what (sourceNamespaces, clusterResources). Empty grants nothing. The CRD validates each grant.", "items": { "type": "object" } }, "allowedApplierClaims": { "type": "array", - "description": "Rendered as the provider's spec.allowedApplierClaims: which namespaces' applier claims may hold an applier through it, and where it may live (applierNamespaces). Empty grants nothing. The CRD validates each grant.", + "description": "Rendered as the provider's spec.allowedApplierClaims: which namespaces' applier claims may hold an applier through it (consumerNamespaces), and where it may live (applierNamespaces). Empty grants nothing. The CRD validates each grant.", "items": { "type": "object" } } } @@ -555,7 +555,7 @@ "properties": { "name": { "type": "string" }, "url": { "type": "string" }, - "allowedGitBranches": { "type": "array", "items": { "type": "string" } }, + "branchPatterns": { "type": "array", "items": { "type": "string" } }, "secretRef": { "type": "object", "additionalProperties": false, diff --git a/charts/gitops-reverser/values.yaml b/charts/gitops-reverser/values.yaml index 83d0e0b4..bbc26ed7 100644 --- a/charts/gitops-reverser/values.yaml +++ b/charts/gitops-reverser/values.yaml @@ -370,11 +370,13 @@ kubeProvider: # quickstart.enabled=true adds a grant for exactly the quickstart namespace, and # values-playground.yaml grants every namespace its own namespace. # - # Each grant has a `from` (Same, Names with names, Selector with a non-empty selector, or All) - # and a scope: + # Each grant has a consumerNamespaces block saying which namespaces' objects it admits (from: + # Same, Names with names, Selector with a non-empty selector, or All), and a scope beside it. A + # scope is a permission, not a selection, and an object's scope is the union across the grants + # admitting it: # allowedGitTargets[].sourceNamespaces which namespaces the WatchRules feeding an admitted - # GitTarget may watch: names, "{consumer.namespace}" for - # the GitTarget's own, or "*" + # GitTarget may name: names, "{consumer.namespace}" for + # the GitTarget's own, or "*" (which a "*" rule item needs) # allowedGitTargets[].clusterResources which cluster-scoped types a ClusterWatchRule may mirror: # resource.group, a bare core resource, or "*" # allowedApplierClaims[].applierNamespaces where an admitted applier claim's Kustomization or @@ -383,12 +385,14 @@ kubeProvider: # identity: the owner of an admitted object acts on the namespaces in its scope. # # allowedGitTargets: - # - from: Selector - # selector: {matchLabels: {platform.example.com/tenant: "true"}} + # - consumerNamespaces: + # from: Selector + # selector: {matchLabels: {platform.example.com/tenant: "true"}} # sourceNamespaces: ["{consumer.namespace}"] # allowedApplierClaims: - # - from: Names - # names: [team-a] + # - consumerNamespaces: + # from: Names + # names: [team-a] # applierNamespaces: [flux-system] allowedGitTargets: [] allowedApplierClaims: [] @@ -507,8 +511,8 @@ quickstart: # Required when quickstart.enabled=true. url: "" # The branch patterns the quickstart namespace may claim through this provider ("*" is every - # branch); rendered as a from: Same grant in spec.allowedGitBranches. - allowedGitBranches: + # branch); rendered as the branchPatterns of a from: Same grant in spec.allowedGitBranches. + branchPatterns: - "*" secretRef: # Required when quickstart.enabled=true. diff --git a/cmd/main_chart_kubeprovider_test.go b/cmd/main_chart_kubeprovider_test.go index 4d6626d1..e9c78292 100644 --- a/cmd/main_chart_kubeprovider_test.go +++ b/cmd/main_chart_kubeprovider_test.go @@ -128,12 +128,14 @@ func TestChartKubeProvider_GrantsRenderAsWritten(t *testing.T) { values := t.TempDir() + "/values.yaml" require.NoError(t, os.WriteFile(values, []byte(`kubeProvider: allowedGitTargets: - - from: Selector - selector: {matchLabels: {platform.example.com/tenant: "true"}} + - consumerNamespaces: + from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} sourceNamespaces: ["{consumer.namespace}"] allowedApplierClaims: - - from: Names - names: [team-a] + - consumerNamespaces: + from: Names + names: [team-a] applierNamespaces: [flux-system] quickstart: enabled: true @@ -141,8 +143,12 @@ quickstart: `), 0o600)) provider := renderLocalKubeProvider(t, "-f", values) require.Len(t, provider.Spec.AllowedGitTargets, 2) - assert.Equal(t, configbutleraiv1alpha3.GrantFromSelector, provider.Spec.AllowedGitTargets[0].From) - assert.Equal(t, configbutleraiv1alpha3.GrantFromNames, provider.Spec.AllowedGitTargets[1].From) + assert.Equal( + t, + configbutleraiv1alpha3.GrantFromSelector, + provider.Spec.AllowedGitTargets[0].ConsumerNamespaces.From, + ) + assert.Equal(t, configbutleraiv1alpha3.GrantFromNames, provider.Spec.AllowedGitTargets[1].ConsumerNamespaces.From) require.Len(t, provider.Spec.AllowedApplierClaims, 2) assert.Equal(t, []configbutleraiv1alpha3.ScopeNamespace{"flux-system"}, provider.Spec.AllowedApplierClaims[0].ApplierNamespaces) diff --git a/config/crd/bases/configbutler.ai_gitbranches.yaml b/config/crd/bases/configbutler.ai_gitbranches.yaml index ccb08e46..e8ad2607 100644 --- a/config/crd/bases/configbutler.ai_gitbranches.yaml +++ b/config/crd/bases/configbutler.ai_gitbranches.yaml @@ -85,115 +85,123 @@ spec: description: |- AllowedGitTargets says which GitTargets may write through this GitBranch. Nothing is admitted automatically, this GitBranch's own namespace included: a GitTarget there needs a grant too, - such as from: Same. Omitted or empty, no GitTarget writes through it. Grants combine by union. - Mutable: claiming a branch includes the right to share it. + such as consumerNamespaces: {from: Same}. Omitted or empty, no GitTarget writes through it. + Grants combine by union. Mutable: claiming a branch includes the right to share it. items: description: |- - NamespaceGrant admits the objects in some control-cluster namespaces, for the act the list holding - it names. + GitBranchGitTargetGrant admits GitTargets in some control-cluster namespaces to write through a + GitBranch. properties: - from: + consumerNamespaces: description: |- - From is where this grant admits from: Same (the namespace of the object holding the grant), - Names (the namespaces in names), Selector (the namespaces whose labels match selector) or All - (every namespace). - enum: - - Same - - Names - - Selector - - All - type: string - names: - description: 'Names are the control-cluster namespaces admitted - with from: Names, by exact name.' - items: - description: GrantNamespace is one control-cluster namespace - a grant admits by name. - maxLength: 63 - type: string - x-kubernetes-validations: - - message: 'names: a variable is refused, because names are - matched against the consumer''s namespace and ''{consumer.namespace}'' - would admit every consumer; use from: All to admit every - namespace, or from: Same for the namespace of the object - holding the grant' - rule: self != '{consumer.namespace}' && self != '{consumer.name}' - - message: 'names: ''*'' is refused; use from: All to admit - every namespace' - rule: self != '*' - - message: names holds namespace names (DNS labels) - rule: self == '{consumer.namespace}' || self == '{consumer.name}' - || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') - maxItems: 64 - minItems: 1 - type: array - x-kubernetes-list-type: set - selector: - description: |- - Selector matches labels on control-cluster Namespaces, with from: Selector. It must select - something: to admit every namespace, use from: All. + ConsumerNamespaces are the control-cluster namespaces whose GitTargets this grant admits. + from: Same is this GitBranch's namespace. properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. + from: + description: |- + From is where this grant admits from: Same (the namespace of the object holding the grant, not + the consumer's), Names (the namespaces in names), Selector (the namespaces whose labels match + selector) or All (every namespace). + enum: + - Same + - Names + - Selector + - All + type: string + names: + description: 'Names are the control-cluster namespaces admitted + with from: Names, by exact name.' items: - description: |- - 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: + description: GrantNamespace is one control-cluster namespace + a grant admits by name. + maxLength: 63 type: string + x-kubernetes-validations: + - message: 'names: a variable is refused, because names + are matched against the consumer''s namespace and + ''{consumer.namespace}'' would admit every consumer; + use from: All to admit every namespace, or from: Same + for the namespace of the object holding the grant' + rule: self != '{consumer.namespace}' && self != '{consumer.name}' + - message: 'names: ''*'' is refused; use from: All to + admit every namespace' + rule: self != '*' + - message: names holds namespace names (DNS labels) + rule: self == '{consumer.namespace}' || self == '{consumer.name}' + || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + selector: description: |- - 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. + Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + something: to admit every namespace, use from: All. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object type: object + x-kubernetes-map-type: atomic + required: + - from type: object - x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: 'from: Names needs names' + rule: self.from != 'Names' || has(self.names) + - message: 'names goes only with from: Names' + rule: '!has(self.names) || self.from == ''Names''' + - message: 'from: Selector needs a selector; to admit every + namespace, use from: All' + rule: self.from != 'Selector' || has(self.selector) + - message: 'selector goes only with from: Selector' + rule: '!has(self.selector) || self.from == ''Selector''' + - message: 'selector: {} is refused; to admit every namespace, + use from: All' + rule: '!has(self.selector) || (has(self.selector.matchLabels) + && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) + && size(self.selector.matchExpressions) > 0)' required: - - from + - consumerNamespaces type: object - x-kubernetes-validations: - - message: 'from: Names needs names' - rule: self.from != 'Names' || has(self.names) - - message: 'names goes only with from: Names' - rule: '!has(self.names) || self.from == ''Names''' - - message: 'from: Selector needs a selector; to admit every namespace, - use from: All' - rule: self.from != 'Selector' || has(self.selector) - - message: 'selector goes only with from: Selector' - rule: '!has(self.selector) || self.from == ''Selector''' - - message: 'selector: {} is refused; to admit every namespace, use - from: All' - rule: '!has(self.selector) || (has(self.selector.matchLabels) - && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) - && size(self.selector.matchExpressions) > 0)' maxItems: 64 type: array x-kubernetes-list-type: atomic diff --git a/config/crd/bases/configbutler.ai_gitproviders.yaml b/config/crd/bases/configbutler.ai_gitproviders.yaml index d45a1f0a..7bf535b7 100644 --- a/config/crd/bases/configbutler.ai_gitproviders.yaml +++ b/config/crd/bases/configbutler.ai_gitproviders.yaml @@ -64,24 +64,25 @@ spec: description: |- AllowedGitBranches says which GitBranches may claim a branch of this repository through this GitProvider, and which branches: a GitBranch claims only while a grant admits its namespace and - one of that grant's branches matches its spec.branch. Nothing is admitted automatically, this + one of that grant's branchPatterns matches its spec.branch. Nothing is admitted automatically, this GitProvider's own namespace included; omitted or empty, no GitBranch claims through it. Grants combine by union. Who may write through a claimed branch is that GitBranch's spec.allowedGitTargets. items: description: |- - GitBranchGrant admits GitBranches in some control-cluster namespaces to claim the branches its - patterns match through a GitProvider. + GitProviderGitBranchGrant admits GitBranches in some control-cluster namespaces to claim the + branches its patterns match through a GitProvider. properties: - branches: + branchPatterns: description: |- - Branches are the branches an admitted GitBranch may claim, as patterns matched against the - whole branch name. '*' matches any sequence of characters, '/' included, so "*" alone is every - branch. '{consumer.namespace}' and '{consumer.name}' are the claiming GitBranch's namespace and - name, each a whole path segment: "teams/{consumer.namespace}/*". '{consumer.name}' needs - '{consumer.namespace}' in the same pattern, "teams/{consumer.namespace}/{consumer.name}": it is a - naming convention, not a bound, since a GitBranch picks its own name. There is no escaping, '?' - or character class. + BranchPatterns are the branches an admitted GitBranch may claim, as patterns matched against + the whole branch name. '*' matches any sequence of characters, '/' included, so "*" alone is + every branch, and a name with no '*', such as "main", matches only itself. + '{consumer.namespace}' and '{consumer.name}' are the claiming GitBranch's namespace and name, + each a whole path segment: "teams/{consumer.namespace}/*". '{consumer.name}' needs + '{consumer.namespace}' in the same pattern, "teams/{consumer.namespace}/{consumer.name}": it is + a naming convention, not a bound, since a GitBranch picks its own name. There is no escaping, + '?' or character class. items: description: |- BranchPattern is a pattern matched against a whole branch name: literal text, '*' for any @@ -125,109 +126,117 @@ spec: minItems: 1 type: array x-kubernetes-list-type: set - from: + consumerNamespaces: description: |- - From is where this grant admits from: Same (this GitProvider's namespace), Names (the - namespaces in names), Selector (the namespaces whose labels match selector) or All (every - namespace). - enum: - - Same - - Names - - Selector - - All - type: string - names: - description: 'Names are the control-cluster namespaces admitted - with from: Names, by exact name.' - items: - description: GrantNamespace is one control-cluster namespace - a grant admits by name. - maxLength: 63 - type: string - x-kubernetes-validations: - - message: 'names: a variable is refused, because names are - matched against the consumer''s namespace and ''{consumer.namespace}'' - would admit every consumer; use from: All to admit every - namespace, or from: Same for the namespace of the object - holding the grant' - rule: self != '{consumer.namespace}' && self != '{consumer.name}' - - message: 'names: ''*'' is refused; use from: All to admit - every namespace' - rule: self != '*' - - message: names holds namespace names (DNS labels) - rule: self == '{consumer.namespace}' || self == '{consumer.name}' - || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') - maxItems: 64 - minItems: 1 - type: array - x-kubernetes-list-type: set - selector: - description: |- - Selector matches labels on control-cluster Namespaces, with from: Selector. It must select - something: to admit every namespace, use from: All. + ConsumerNamespaces are the control-cluster namespaces whose GitBranches this grant admits. + from: Same is this GitProvider's namespace. properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. + from: + description: |- + From is where this grant admits from: Same (the namespace of the object holding the grant, not + the consumer's), Names (the namespaces in names), Selector (the namespaces whose labels match + selector) or All (every namespace). + enum: + - Same + - Names + - Selector + - All + type: string + names: + description: 'Names are the control-cluster namespaces admitted + with from: Names, by exact name.' items: - description: |- - 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: + description: GrantNamespace is one control-cluster namespace + a grant admits by name. + maxLength: 63 type: string + x-kubernetes-validations: + - message: 'names: a variable is refused, because names + are matched against the consumer''s namespace and + ''{consumer.namespace}'' would admit every consumer; + use from: All to admit every namespace, or from: Same + for the namespace of the object holding the grant' + rule: self != '{consumer.namespace}' && self != '{consumer.name}' + - message: 'names: ''*'' is refused; use from: All to + admit every namespace' + rule: self != '*' + - message: names holds namespace names (DNS labels) + rule: self == '{consumer.namespace}' || self == '{consumer.name}' + || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + selector: description: |- - 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. + Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + something: to admit every namespace, use from: All. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object type: object + x-kubernetes-map-type: atomic + required: + - from type: object - x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: 'from: Names needs names' + rule: self.from != 'Names' || has(self.names) + - message: 'names goes only with from: Names' + rule: '!has(self.names) || self.from == ''Names''' + - message: 'from: Selector needs a selector; to admit every + namespace, use from: All' + rule: self.from != 'Selector' || has(self.selector) + - message: 'selector goes only with from: Selector' + rule: '!has(self.selector) || self.from == ''Selector''' + - message: 'selector: {} is refused; to admit every namespace, + use from: All' + rule: '!has(self.selector) || (has(self.selector.matchLabels) + && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) + && size(self.selector.matchExpressions) > 0)' required: - - branches - - from + - branchPatterns + - consumerNamespaces type: object - x-kubernetes-validations: - - message: 'from: Names needs names' - rule: self.from != 'Names' || has(self.names) - - message: 'names goes only with from: Names' - rule: '!has(self.names) || self.from == ''Names''' - - message: 'from: Selector needs a selector; to admit every namespace, - use from: All' - rule: self.from != 'Selector' || has(self.selector) - - message: 'selector goes only with from: Selector' - rule: '!has(self.selector) || self.from == ''Selector''' - - message: 'selector: {} is refused; to admit every namespace, use - from: All' - rule: '!has(self.selector) || (has(self.selector.matchLabels) - && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) - && size(self.selector.matchExpressions) > 0)' maxItems: 64 type: array x-kubernetes-list-type: atomic diff --git a/config/crd/bases/configbutler.ai_kubeproviders.yaml b/config/crd/bases/configbutler.ai_kubeproviders.yaml index aac936dc..ed83d30a 100644 --- a/config/crd/bases/configbutler.ai_kubeproviders.yaml +++ b/config/crd/bases/configbutler.ai_kubeproviders.yaml @@ -77,8 +77,8 @@ spec: empty, no applier claim holds an applier through it. Grants combine by union. items: description: |- - ApplierGrant admits applier claims in some control-cluster namespaces to hold an applier through a - KubeProvider, and bounds where that applier may live. + KubeProviderApplierClaimGrant admits applier claims in some control-cluster namespaces to hold an + applier through a KubeProvider, and bounds where that applier may live. properties: applierNamespaces: description: |- @@ -113,109 +113,117 @@ spec: minItems: 1 type: array x-kubernetes-list-type: set - from: + consumerNamespaces: description: |- - From is where this grant admits from: Same (this KubeProvider's namespace), Names (the - namespaces in names), Selector (the namespaces whose labels match selector) or All (every - namespace). - enum: - - Same - - Names - - Selector - - All - type: string - names: - description: 'Names are the control-cluster namespaces admitted - with from: Names, by exact name.' - items: - description: GrantNamespace is one control-cluster namespace - a grant admits by name. - maxLength: 63 - type: string - x-kubernetes-validations: - - message: 'names: a variable is refused, because names are - matched against the consumer''s namespace and ''{consumer.namespace}'' - would admit every consumer; use from: All to admit every - namespace, or from: Same for the namespace of the object - holding the grant' - rule: self != '{consumer.namespace}' && self != '{consumer.name}' - - message: 'names: ''*'' is refused; use from: All to admit - every namespace' - rule: self != '*' - - message: names holds namespace names (DNS labels) - rule: self == '{consumer.namespace}' || self == '{consumer.name}' - || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') - maxItems: 64 - minItems: 1 - type: array - x-kubernetes-list-type: set - selector: - description: |- - Selector matches labels on control-cluster Namespaces, with from: Selector. It must select - something: to admit every namespace, use from: All. + ConsumerNamespaces are the control-cluster namespaces whose applier claims this grant admits. + from: Same is this KubeProvider's namespace. properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. + from: + description: |- + From is where this grant admits from: Same (the namespace of the object holding the grant, not + the consumer's), Names (the namespaces in names), Selector (the namespaces whose labels match + selector) or All (every namespace). + enum: + - Same + - Names + - Selector + - All + type: string + names: + description: 'Names are the control-cluster namespaces admitted + with from: Names, by exact name.' items: - description: |- - 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: + description: GrantNamespace is one control-cluster namespace + a grant admits by name. + maxLength: 63 type: string + x-kubernetes-validations: + - message: 'names: a variable is refused, because names + are matched against the consumer''s namespace and + ''{consumer.namespace}'' would admit every consumer; + use from: All to admit every namespace, or from: Same + for the namespace of the object holding the grant' + rule: self != '{consumer.namespace}' && self != '{consumer.name}' + - message: 'names: ''*'' is refused; use from: All to + admit every namespace' + rule: self != '*' + - message: names holds namespace names (DNS labels) + rule: self == '{consumer.namespace}' || self == '{consumer.name}' + || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + selector: description: |- - 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. + Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + something: to admit every namespace, use from: All. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object type: object + x-kubernetes-map-type: atomic + required: + - from type: object - x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: 'from: Names needs names' + rule: self.from != 'Names' || has(self.names) + - message: 'names goes only with from: Names' + rule: '!has(self.names) || self.from == ''Names''' + - message: 'from: Selector needs a selector; to admit every + namespace, use from: All' + rule: self.from != 'Selector' || has(self.selector) + - message: 'selector goes only with from: Selector' + rule: '!has(self.selector) || self.from == ''Selector''' + - message: 'selector: {} is refused; to admit every namespace, + use from: All' + rule: '!has(self.selector) || (has(self.selector.matchLabels) + && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) + && size(self.selector.matchExpressions) > 0)' required: - applierNamespaces - - from + - consumerNamespaces type: object - x-kubernetes-validations: - - message: 'from: Names needs names' - rule: self.from != 'Names' || has(self.names) - - message: 'names goes only with from: Names' - rule: '!has(self.names) || self.from == ''Names''' - - message: 'from: Selector needs a selector; to admit every namespace, - use from: All' - rule: self.from != 'Selector' || has(self.selector) - - message: 'selector goes only with from: Selector' - rule: '!has(self.selector) || self.from == ''Selector''' - - message: 'selector: {} is refused; to admit every namespace, use - from: All' - rule: '!has(self.selector) || (has(self.selector.matchLabels) - && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) - && size(self.selector.matchExpressions) > 0)' maxItems: 64 type: array x-kubernetes-list-type: atomic @@ -228,8 +236,8 @@ spec: included; omitted or empty, no GitTarget mirrors through it. Grants combine by union. items: description: |- - SourceGrant admits GitTargets in some control-cluster namespaces to mirror through a KubeProvider, - and bounds what they may mirror. + KubeProviderGitTargetGrant admits GitTargets in some control-cluster namespaces to mirror through a + KubeProvider, and bounds what they may mirror. properties: clusterResources: description: |- @@ -255,90 +263,113 @@ spec: minItems: 1 type: array x-kubernetes-list-type: set - from: + consumerNamespaces: description: |- - From is where this grant admits from: Same (this KubeProvider's namespace), Names (the - namespaces in names), Selector (the namespaces whose labels match selector) or All (every - namespace). - enum: - - Same - - Names - - Selector - - All - type: string - names: - description: 'Names are the control-cluster namespaces admitted - with from: Names, by exact name.' - items: - description: GrantNamespace is one control-cluster namespace - a grant admits by name. - maxLength: 63 - type: string - x-kubernetes-validations: - - message: 'names: a variable is refused, because names are - matched against the consumer''s namespace and ''{consumer.namespace}'' - would admit every consumer; use from: All to admit every - namespace, or from: Same for the namespace of the object - holding the grant' - rule: self != '{consumer.namespace}' && self != '{consumer.name}' - - message: 'names: ''*'' is refused; use from: All to admit - every namespace' - rule: self != '*' - - message: names holds namespace names (DNS labels) - rule: self == '{consumer.namespace}' || self == '{consumer.name}' - || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') - maxItems: 64 - minItems: 1 - type: array - x-kubernetes-list-type: set - selector: - description: |- - Selector matches labels on control-cluster Namespaces, with from: Selector. It must select - something: to admit every namespace, use from: All. + ConsumerNamespaces are the control-cluster namespaces whose GitTargets this grant admits. + from: Same is this KubeProvider's namespace. properties: - matchExpressions: - description: matchExpressions is a list of label selector - requirements. The requirements are ANDed. + from: + description: |- + From is where this grant admits from: Same (the namespace of the object holding the grant, not + the consumer's), Names (the namespaces in names), Selector (the namespaces whose labels match + selector) or All (every namespace). + enum: + - Same + - Names + - Selector + - All + type: string + names: + description: 'Names are the control-cluster namespaces admitted + with from: Names, by exact name.' items: - description: |- - 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: + description: GrantNamespace is one control-cluster namespace + a grant admits by name. + maxLength: 63 type: string + x-kubernetes-validations: + - message: 'names: a variable is refused, because names + are matched against the consumer''s namespace and + ''{consumer.namespace}'' would admit every consumer; + use from: All to admit every namespace, or from: Same + for the namespace of the object holding the grant' + rule: self != '{consumer.namespace}' && self != '{consumer.name}' + - message: 'names: ''*'' is refused; use from: All to + admit every namespace' + rule: self != '*' + - message: names holds namespace names (DNS labels) + rule: self == '{consumer.namespace}' || self == '{consumer.name}' + || self == '*' || self.matches('^[a-z0-9]([-a-z0-9]*[a-z0-9])?$') + maxItems: 64 + minItems: 1 + type: array + x-kubernetes-list-type: set + selector: description: |- - 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. + Selector matches labels on control-cluster Namespaces, with from: Selector. It must select + something: to admit every namespace, use from: All. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object type: object + x-kubernetes-map-type: atomic + required: + - from type: object - x-kubernetes-map-type: atomic + x-kubernetes-validations: + - message: 'from: Names needs names' + rule: self.from != 'Names' || has(self.names) + - message: 'names goes only with from: Names' + rule: '!has(self.names) || self.from == ''Names''' + - message: 'from: Selector needs a selector; to admit every + namespace, use from: All' + rule: self.from != 'Selector' || has(self.selector) + - message: 'selector goes only with from: Selector' + rule: '!has(self.selector) || self.from == ''Selector''' + - message: 'selector: {} is refused; to admit every namespace, + use from: All' + rule: '!has(self.selector) || (has(self.selector.matchLabels) + && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) + && size(self.selector.matchExpressions) > 0)' sourceNamespaces: description: |- SourceNamespaces are source-cluster namespaces that a WatchRule feeding an admitted GitTarget may @@ -381,23 +412,9 @@ spec: type: array x-kubernetes-list-type: set required: - - from + - consumerNamespaces type: object x-kubernetes-validations: - - message: 'from: Names needs names' - rule: self.from != 'Names' || has(self.names) - - message: 'names goes only with from: Names' - rule: '!has(self.names) || self.from == ''Names''' - - message: 'from: Selector needs a selector; to admit every namespace, - use from: All' - rule: self.from != 'Selector' || has(self.selector) - - message: 'selector goes only with from: Selector' - rule: '!has(self.selector) || self.from == ''Selector''' - - message: 'selector: {} is refused; to admit every namespace, use - from: All' - rule: '!has(self.selector) || (has(self.selector.matchLabels) - && size(self.selector.matchLabels) > 0) || (has(self.selector.matchExpressions) - && size(self.selector.matchExpressions) > 0)' - message: a grant in allowedGitTargets sets sourceNamespaces, clusterResources, or both; one that grants neither grants nothing rule: has(self.sourceNamespaces) || has(self.clusterResources) diff --git a/config/kubeprovider-local.yaml b/config/kubeprovider-local.yaml index 777ffba6..6171bd9f 100644 --- a/config/kubeprovider-local.yaml +++ b/config/kubeprovider-local.yaml @@ -15,9 +15,9 @@ metadata: spec: inCluster: {} allowedGitTargets: - - from: All + - consumerNamespaces: {from: All} sourceNamespaces: ["{consumer.namespace}"] clusterResources: ["*"] allowedApplierClaims: - - from: All + - consumerNamespaces: {from: All} applierNamespaces: ["{consumer.namespace}"] diff --git a/config/samples/kubeprovider.yaml b/config/samples/kubeprovider.yaml index 029863b6..0c1e444d 100644 --- a/config/samples/kubeprovider.yaml +++ b/config/samples/kubeprovider.yaml @@ -24,16 +24,17 @@ spec: # Here: every namespace labelled tier=trusted in the control cluster mirrors its own namespace, # and this namespace mirrors every namespace and the CRDs. allowedGitTargets: - - from: Selector - selector: - matchLabels: - tier: trusted + - consumerNamespaces: + from: Selector + selector: + matchLabels: + tier: trusted sourceNamespaces: ["{consumer.namespace}"] - - from: Same + - consumerNamespaces: {from: Same} sourceNamespaces: ["*"] clusterResources: [customresourcedefinitions.apiextensions.k8s.io] # Which applier claims may hold a Flux Kustomization or an Argo CD Application through this # provider, and where it may live. Admission to mirror is no admission to apply. allowedApplierClaims: - - from: Same + - consumerNamespaces: {from: Same} applierNamespaces: ["{consumer.namespace}"] diff --git a/config/samples/quickstart-gitbranch.yaml b/config/samples/quickstart-gitbranch.yaml index 58938092..fe02f312 100644 --- a/config/samples/quickstart-gitbranch.yaml +++ b/config/samples/quickstart-gitbranch.yaml @@ -16,4 +16,4 @@ spec: # parentBranch: develop # Which GitTargets may write through this branch. Its own namespace needs a grant too. allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} diff --git a/config/samples/quickstart-gitprovider.yaml b/config/samples/quickstart-gitprovider.yaml index de950002..055d8680 100644 --- a/config/samples/quickstart-gitprovider.yaml +++ b/config/samples/quickstart-gitprovider.yaml @@ -10,8 +10,8 @@ spec: # namespace included. '*' matches any branch; "teams/{consumer.namespace}/*" would give each # namespace its own prefix. allowedGitBranches: - - from: Same - branches: ["*"] + - consumerNamespaces: {from: Same} + branchPatterns: ["*"] secretRef: name: git-creds diff --git a/docs/bi-directional-argocd.md b/docs/bi-directional-argocd.md index 2b3675ab..fd1ae1f3 100644 --- a/docs/bi-directional-argocd.md +++ b/docs/bi-directional-argocd.md @@ -139,12 +139,14 @@ metadata: spec: inCluster: {} allowedGitTargets: - - from: Names - names: [team-a] + - consumerNamespaces: + from: Names + names: [team-a] sourceNamespaces: ["{consumer.namespace}"] allowedApplierClaims: - - from: Names - names: [team-a] + - consumerNamespaces: + from: Names + names: [team-a] applierNamespaces: [argocd] --- apiVersion: v1 @@ -173,8 +175,8 @@ metadata: spec: url: git@github.com:acme/platform.git allowedGitBranches: - - from: Same - branches: [main] + - consumerNamespaces: {from: Same} + branchPatterns: [main] secretRef: name: platform-git --- @@ -188,7 +190,7 @@ spec: name: platform branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/docs/bi-directional-flux.md b/docs/bi-directional-flux.md index a77647ec..ff529e1d 100644 --- a/docs/bi-directional-flux.md +++ b/docs/bi-directional-flux.md @@ -89,12 +89,14 @@ metadata: spec: inCluster: {} allowedGitTargets: - - from: Names - names: [team-a] + - consumerNamespaces: + from: Names + names: [team-a] sourceNamespaces: ["{consumer.namespace}"] allowedApplierClaims: - - from: Names - names: [team-a] + - consumerNamespaces: + from: Names + names: [team-a] applierNamespaces: [flux-system] --- apiVersion: v1 @@ -123,8 +125,8 @@ metadata: spec: url: git@github.com:acme/platform.git allowedGitBranches: - - from: Same - branches: [main] + - consumerNamespaces: {from: Same} + branchPatterns: [main] secretRef: name: platform-git --- @@ -138,7 +140,7 @@ spec: name: platform branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/docs/configuration.md b/docs/configuration.md index ad0269a2..5c34f14a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -68,7 +68,7 @@ fields that need more than a line. | Field | Default | What it does | |---|---|---| | `url` | **required** | Repository URL, SSH or HTTPS | -| `allowedGitBranches` | **no `GitBranch`**, its own namespace included | Which namespaces' `GitBranch`es may claim which branches through this provider. Each grant has a `from` (`Same`, `Names`, `Selector`, `All`) and the `branches` patterns it allows. See [the grants](#the-grants) | +| `allowedGitBranches` | **no `GitBranch`**, its own namespace included | Which namespaces' `GitBranch`es may claim which branches through this provider. Each grant has a `consumerNamespaces` block (`from`: `Same`, `Names`, `Selector`, `All`) and the `branchPatterns` it allows. See [the grants](#the-grants) | | `secretRef` | anonymous access | Secret holding the write credential. See [the credentials Secret](#gitproviderspecsecretref-the-credentials-secret) | | `knownHostsRef` | the Secret's own `known_hosts` | Optional ConfigMap or Secret sharing SSH host keys across providers. Host-key verification fails closed either way | | `commit.committer` | the operator's built-in identity | Name and email written as the commit committer. See [committer identity](#committer-identity) | @@ -82,7 +82,7 @@ Every field but `allowedGitTargets` is immutable: to change one, delete the `Git |---|---|---| | `gitProviderRef` | **required** | The `GitProvider` whose repository and credential write this branch: in this namespace, or `namespace` names another. Its `allowedGitBranches` must admit this namespace and `branch` | | `branch` | **required** | Branch to write. Only one `GitBranch` holds a repository branch; see [who writes a repository branch](#who-writes-a-repository-branch) | -| `allowedGitTargets` | **no `GitTarget`**, its own namespace included | Which namespaces' `GitTarget`s may write through this branch, each grant with a `from`. The only mutable field. See [the grants](#the-grants) | +| `allowedGitTargets` | **no `GitTarget`**, its own namespace included | Which namespaces' `GitTarget`s may write through this branch, each grant with a `consumerNamespaces` block. The only mutable field. See [the grants](#the-grants) | | `parentBranch` | the remote's default branch | Branch a `branch` the remote does not have yet is created from. Only read, so it need not be allowed. See [starting the write branch from another branch](#starting-the-write-branch-from-another-branch-specparentbranch) | ### `GitReceiver` (namespaced): the endpoint a Git host notifies on a push @@ -100,7 +100,7 @@ Set exactly one of `secretRef` and `inCluster`. Both are immutable: delete and r |---|---|---| | `secretRef` | none; set `inCluster` instead | Secret in this provider's namespace holding a kubeconfig for the source cluster. `key` defaults to `value`, then `value.yaml`. See [`KubeProvider`](#kubeprovider) | | `inCluster` | none; set `secretRef` instead | `{}` uses the operator's own identity. Honored only for a provider listed in `--in-cluster-kube-providers`. See [the operator's own identity](#the-operators-own-identity-specincluster) | -| `allowedGitTargets` | **no `GitTarget`**, its own namespace included | Which namespaces' `GitTarget`s may mirror through this provider, and what: each grant has a `from`, and `sourceNamespaces`, `clusterResources` or both. See [the grants](#the-grants) | +| `allowedGitTargets` | **no `GitTarget`**, its own namespace included | Which namespaces' `GitTarget`s may mirror through this provider, and what: each grant has a `consumerNamespaces` block, and `sourceNamespaces`, `clusterResources` or both. See [the grants](#the-grants) | | `allowedApplierClaims` | **no applier claim**, its own namespace included | Which namespaces' applier claims may hold an applier through this provider, and the `applierNamespaces` that applier may live in. See [the grants](#the-grants) | | `client.qps` | `20` | Client rate limit for this source cluster. Refused beside `inCluster` | | `client.burst` | `30` | Client burst for this source cluster. Refused beside `inCluster` | diff --git a/hack/crdfields/fields.yaml b/hack/crdfields/fields.yaml index dde7c3f9..5774808d 100644 --- a/hack/crdfields/fields.yaml +++ b/hack/crdfields/fields.yaml @@ -34,8 +34,8 @@ kinds: default: "**no `GitBranch`**, its own namespace included" doc: >- Which namespaces' `GitBranch`es may claim which branches through this provider. Each grant - has a `from` (`Same`, `Names`, `Selector`, `All`) and the `branches` patterns it allows. See - [the grants](#the-grants) + has a `consumerNamespaces` block (`from`: `Same`, `Names`, `Selector`, `All`) and the + `branchPatterns` it allows. See [the grants](#the-grants) - path: secretRef default: anonymous access doc: >- @@ -72,8 +72,8 @@ kinds: - path: allowedGitTargets default: "**no `GitTarget`**, its own namespace included" doc: >- - Which namespaces' `GitTarget`s may write through this branch, each grant with a `from`. The - only mutable field. See [the grants](#the-grants) + Which namespaces' `GitTarget`s may write through this branch, each grant with a + `consumerNamespaces` block. The only mutable field. See [the grants](#the-grants) - path: parentBranch default: the remote's default branch doc: >- @@ -112,7 +112,8 @@ kinds: default: "**no `GitTarget`**, its own namespace included" doc: >- Which namespaces' `GitTarget`s may mirror through this provider, and what: each grant has a - `from`, and `sourceNamespaces`, `clusterResources` or both. See [the grants](#the-grants) + `consumerNamespaces` block, and `sourceNamespaces`, `clusterResources` or both. See + [the grants](#the-grants) - path: allowedApplierClaims default: "**no applier claim**, its own namespace included" doc: >- diff --git a/internal/applier/dispatcher_test.go b/internal/applier/dispatcher_test.go index ea16bbc5..75758728 100644 --- a/internal/applier/dispatcher_test.go +++ b/internal/applier/dispatcher_test.go @@ -165,11 +165,15 @@ func kubeProvider(name string, admits ...string) *v1alpha3.KubeProvider { ObjectMeta: metav1.ObjectMeta{Namespace: kubeProviderNS, Name: name}, Spec: v1alpha3.KubeProviderSpec{ InCluster: &v1alpha3.KubeProviderInCluster{}, - AllowedGitTargets: []v1alpha3.SourceGrant{{From: v1alpha3.GrantFromNames, Names: names, - SourceNamespaces: []v1alpha3.ScopeNamespace{v1alpha3.ConsumerNamespaceVariable}}}, + AllowedGitTargets: []v1alpha3.KubeProviderGitTargetGrant{{ + ConsumerNamespaces: v1alpha3.ConsumerNamespaces{From: v1alpha3.GrantFromNames, Names: names}, + SourceNamespaces: []v1alpha3.ScopeNamespace{v1alpha3.ConsumerNamespaceVariable}, + }}, // The test appliers live in flux-system and argocd, not in the applier claims' namespace. - AllowedApplierClaims: []v1alpha3.ApplierGrant{{From: v1alpha3.GrantFromNames, Names: names, - ApplierNamespaces: []v1alpha3.ScopeNamespace{"*"}}}, + AllowedApplierClaims: []v1alpha3.KubeProviderApplierClaimGrant{{ + ConsumerNamespaces: v1alpha3.ConsumerNamespaces{From: v1alpha3.GrantFromNames, Names: names}, + ApplierNamespaces: []v1alpha3.ScopeNamespace{"*"}, + }}, }, } } @@ -185,8 +189,11 @@ func controlPlane() []client.Object { &v1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Namespace: testGitBranch.Namespace, Name: testGitBranch.Name}, Spec: v1alpha3.GitBranchSpec{ - GitProviderRef: fluxmeta.NamespacedObjectReference{Name: testProvider.Name}, Branch: testBranch, - AllowedGitTargets: []v1alpha3.NamespaceGrant{{From: v1alpha3.GrantFromAll}}, + GitProviderRef: fluxmeta.NamespacedObjectReference{Name: testProvider.Name}, + Branch: testBranch, + AllowedGitTargets: []v1alpha3.GitBranchGitTargetGrant{ + {ConsumerNamespaces: v1alpha3.ConsumerNamespaces{From: v1alpha3.GrantFromAll}}, + }, }, }, kubeProvider(localCluster, targetNS), diff --git a/internal/applier/holder_test.go b/internal/applier/holder_test.go index 671e3c6e..a5ed24ab 100644 --- a/internal/applier/holder_test.go +++ b/internal/applier/holder_test.go @@ -62,8 +62,14 @@ func referenceFrom(t *testing.T, c client.Client, claim v1alpha3.ApplierClaim) { continue } ns := v1alpha3.GrantNamespace(claim.GetNamespace()) - provider.Spec.AllowedGitTargets[0].Names = append(provider.Spec.AllowedGitTargets[0].Names, ns) - provider.Spec.AllowedApplierClaims[0].Names = append(provider.Spec.AllowedApplierClaims[0].Names, ns) + provider.Spec.AllowedGitTargets[0].ConsumerNamespaces.Names = append( + provider.Spec.AllowedGitTargets[0].ConsumerNamespaces.Names, + ns, + ) + provider.Spec.AllowedApplierClaims[0].ConsumerNamespaces.Names = append( + provider.Spec.AllowedApplierClaims[0].ConsumerNamespaces.Names, + ns, + ) require.NoError(t, c.Update(ctx, provider)) } } diff --git a/internal/authz/gitbranch_admission_test.go b/internal/authz/gitbranch_admission_test.go index c511efda..b383e0f3 100644 --- a/internal/authz/gitbranch_admission_test.go +++ b/internal/authz/gitbranch_admission_test.go @@ -19,13 +19,17 @@ import ( configv1alpha3 "github.com/ConfigButler/gitops-reverser/api/v1alpha3" ) -// branchIn is the GitBranch namespace/main, with writers as its spec.allowedGitTargets. -func branchIn(namespace string, writers ...configv1alpha3.NamespaceGrant) *configv1alpha3.GitBranch { +// branchIn is the GitBranch namespace/main, with one spec.allowedGitTargets grant per writers entry. +func branchIn(namespace string, writers ...configv1alpha3.ConsumerNamespaces) *configv1alpha3.GitBranch { + grants := make([]configv1alpha3.GitBranchGitTargetGrant, 0, len(writers)) + for _, w := range writers { + grants = append(grants, configv1alpha3.GitBranchGitTargetGrant{ConsumerNamespaces: w}) + } return &configv1alpha3.GitBranch{ ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: "main"}, Spec: configv1alpha3.GitBranchSpec{ GitProviderRef: meta.NamespacedObjectReference{Name: "monorepo"}, Branch: "main", - AllowedGitTargets: writers, + AllowedGitTargets: grants, }, } } @@ -44,7 +48,7 @@ func targetWriting(namespace, name string) *configv1alpha3.GitTarget { func TestGitTargetAdmitted_GitBranchAllowedGitTargets(t *testing.T) { // The KubeProvider gate passes in every case, so only the GitBranch's decides. source := providerNamed("prod-eu-1", mirrorFrom(testNS)) - same := configv1alpha3.NamespaceGrant{From: configv1alpha3.GrantFromSame} + same := configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame} tests := []struct { name string objects []client.Object @@ -74,7 +78,7 @@ func TestGitTargetAdmitted_GitBranchAllowedGitTargets(t *testing.T) { { name: "another namespace, admitted by name", objects: []client.Object{source, namespaceLabeled(nil), branchIn("platform", same, - configv1alpha3.NamespaceGrant{From: configv1alpha3.GrantFromNames, + configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromNames, Names: []configv1alpha3.GrantNamespace{testNS}})}, target: targetWriting("platform", "main"), allowed: true, @@ -82,7 +86,7 @@ func TestGitTargetAdmitted_GitBranchAllowedGitTargets(t *testing.T) { { name: "another namespace, admitted by its labels", objects: []client.Object{source, namespaceLabeled(map[string]string{"tenant": "yes"}), - branchIn("platform", configv1alpha3.NamespaceGrant{From: configv1alpha3.GrantFromSelector, + branchIn("platform", configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tenant": "yes"}}})}, target: targetWriting("platform", "main"), allowed: true, @@ -90,7 +94,7 @@ func TestGitTargetAdmitted_GitBranchAllowedGitTargets(t *testing.T) { { name: "a grant naming other namespaces, listed in the message", objects: []client.Object{source, namespaceLabeled(nil), branchIn("platform", same, - configv1alpha3.NamespaceGrant{From: configv1alpha3.GrantFromNames, + configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromNames, Names: []configv1alpha3.GrantNamespace{"team-b", "team-c"}})}, target: targetWriting("platform", "main"), wantMessage: "GitBranch platform/main admits platform, team-b, team-c in spec.allowedGitTargets", @@ -119,7 +123,7 @@ func TestGitTargetAdmitted_GitBranchAllowedGitTargets(t *testing.T) { } // sharedRepo is the GitProvider platform/shared-repo with grants as its spec.allowedGitBranches. -func sharedRepo(grants ...configv1alpha3.GitBranchGrant) *configv1alpha3.GitProvider { +func sharedRepo(grants ...configv1alpha3.GitProviderGitBranchGrant) *configv1alpha3.GitProvider { return &configv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: platformNS, Name: "shared-repo"}, Spec: configv1alpha3.GitProviderSpec{ @@ -145,11 +149,17 @@ func claiming(namespace, branch string) *configv1alpha3.GitBranch { func TestGitBranchClaimAdmitted(t *testing.T) { tenant := map[string]string{"platform.example.com/tenant": "true"} provider := sharedRepo( - configv1alpha3.GitBranchGrant{From: configv1alpha3.GrantFromSame, - Branches: []configv1alpha3.BranchPattern{"main"}}, - configv1alpha3.GitBranchGrant{From: configv1alpha3.GrantFromSelector, - Selector: &metav1.LabelSelector{MatchLabels: tenant}, - Branches: []configv1alpha3.BranchPattern{"teams/{consumer.namespace}/*", "feature/*"}}, + configv1alpha3.GitProviderGitBranchGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame}, + BranchPatterns: []configv1alpha3.BranchPattern{"main"}, + }, + configv1alpha3.GitProviderGitBranchGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: tenant}, + }, + BranchPatterns: []configv1alpha3.BranchPattern{"teams/{consumer.namespace}/*", "feature/*"}, + }, ) namespaces := []client.Object{ &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testNS, Labels: tenant}}, diff --git a/internal/authz/kubeprovider_admission.go b/internal/authz/kubeprovider_admission.go index 5e79f30e..e00dba11 100644 --- a/internal/authz/kubeprovider_admission.go +++ b/internal/authz/kubeprovider_admission.go @@ -93,28 +93,28 @@ func GitTargetSourceScope( ctx context.Context, reader client.Reader, target *configv1alpha3.GitTarget, -) (Decision, configv1alpha3.SourceScope, error) { +) (Decision, configv1alpha3.KubeProviderGitTargetScope, error) { key := target.KubeProviderKey() decision, provider, err := kubeProviderUsable(ctx, reader, key, fmt.Sprintf( "referenced KubeProvider %s was not found; a GitTarget may mirror a source cluster only "+ "through an existing KubeProvider, which the operator never creates", key)) if err != nil || !decision.Allowed { - return decision, configv1alpha3.SourceScope{}, err + return decision, configv1alpha3.KubeProviderGitTargetScope{}, err } nsLabels, err := namespaceLabels(ctx, reader, target.Namespace) if err != nil { - return Decision{}, configv1alpha3.SourceScope{}, err + return Decision{}, configv1alpha3.KubeProviderGitTargetScope{}, err } scope, selErr := provider.AdmitsGitTarget(client.ObjectKeyFromObject(target), nsLabels) if !scope.Admitted { return notAdmitted("KubeProvider", key, provider.DescribeAllowedGitTargets(), "spec.allowedGitTargets", - target.Namespace, selErr), configv1alpha3.SourceScope{}, nil + target.Namespace, selErr), configv1alpha3.KubeProviderGitTargetScope{}, nil } if decision, err = AuditRouteHeld(ctx, reader, provider); err != nil || !decision.Allowed { - return decision, configv1alpha3.SourceScope{}, err + return decision, configv1alpha3.KubeProviderGitTargetScope{}, err } if decision, err = gitBranchAdmitted(ctx, reader, target, nsLabels); err != nil || !decision.Allowed { - return decision, configv1alpha3.SourceScope{}, err + return decision, configv1alpha3.KubeProviderGitTargetScope{}, err } return Decision{Allowed: true}, scope, nil } @@ -175,7 +175,7 @@ func GitBranchClaimAdmitted( Reason: ReasonBranchNotAllowed, Message: fmt.Sprintf( "namespace %q may claim %s through GitProvider %s (spec.allowedGitBranches); requested branch %q "+ - "is not allowed", branch.Namespace, quoteAll(admission.Branches), key, branch.Spec.Branch), + "is not allowed", branch.Namespace, quoteAll(admission.BranchPatterns), key, branch.Spec.Branch), }, nil } return Decision{Allowed: true}, nil diff --git a/internal/authz/kubeprovider_admission_test.go b/internal/authz/kubeprovider_admission_test.go index 3be59c34..7e6dca47 100644 --- a/internal/authz/kubeprovider_admission_test.go +++ b/internal/authz/kubeprovider_admission_test.go @@ -49,7 +49,7 @@ func targetIn(providerName string) *configv1alpha3.GitTarget { // providerNamed builds a KubeProvider in platformNS with grants as its spec.allowedGitTargets, so // the GitTarget in testNS is admitted only through a grant. -func providerNamed(name string, grants ...configv1alpha3.SourceGrant) *configv1alpha3.KubeProvider { +func providerNamed(name string, grants ...configv1alpha3.KubeProviderGitTargetGrant) *configv1alpha3.KubeProvider { return &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: platformNS}, Spec: configv1alpha3.KubeProviderSpec{AllowedGitTargets: grants}, @@ -66,9 +66,14 @@ func grantNames(namespaces ...string) []configv1alpha3.GrantNamespace { } // mirrorFrom is a grant that admits namespaces by name to mirror their own namespace. -func mirrorFrom(namespaces ...string) configv1alpha3.SourceGrant { - return configv1alpha3.SourceGrant{From: configv1alpha3.GrantFromNames, Names: grantNames(namespaces...), - SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable}} +func mirrorFrom(namespaces ...string) configv1alpha3.KubeProviderGitTargetGrant { + return configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromNames, + Names: grantNames(namespaces...), + }, + SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable}, + } } func namespaceLabeled(labels map[string]string) *corev1.Namespace { @@ -76,7 +81,7 @@ func namespaceLabeled(labels map[string]string) *corev1.Namespace { } func TestGitTargetAdmitted_Policy(t *testing.T) { - ownNamespace := func(grants ...configv1alpha3.SourceGrant) *configv1alpha3.KubeProvider { + ownNamespace := func(grants ...configv1alpha3.KubeProviderGitTargetGrant) *configv1alpha3.KubeProvider { return &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: testNS}, Spec: configv1alpha3.KubeProviderSpec{AllowedGitTargets: grants}, @@ -122,15 +127,19 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { }, { name: "from: Same admits the provider's own namespace", - objects: []client.Object{ownNamespace(configv1alpha3.SourceGrant{From: configv1alpha3.GrantFromSame, - SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}}), namespaceLabeled(nil)}, + objects: []client.Object{ownNamespace(configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}, + }), namespaceLabeled(nil)}, target: ownTarget, wantAllowed: true, }, { name: "from: Same admits nothing else", - objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.SourceGrant{ - From: configv1alpha3.GrantFromSame, SourceNamespaces: []configv1alpha3.ScopeNamespace{"*"}}), + objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"*"}, + }), namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), wantReason: ReasonNamespaceNotAuthorized, @@ -161,17 +170,25 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { }, { name: "a Selector grant admits a namespace by its labels", - objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.SourceGrant{ - From: configv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}, - SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}}), namespaceLabeled(tenant)}, + objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: tenant}, + }, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}, + }), namespaceLabeled(tenant)}, target: targetIn("prod-eu-1"), wantAllowed: true, }, { name: "a Selector grant does not admit a namespace without the labels", - objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.SourceGrant{ - From: configv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}, - SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}}), namespaceLabeled(nil)}, + objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: tenant}, + }, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}, + }), namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), wantReason: ReasonNamespaceNotAuthorized, wantMessage: "admits namespaces labelled tenant=true", @@ -186,19 +203,24 @@ func TestGitTargetAdmitted_Policy(t *testing.T) { { // A malformed selector must FAIL CLOSED, and say so. name: "an invalid selector denies, and the message says why", - objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.SourceGrant{ - From: configv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "tier", Operator: "NotAnOperator"}}, - }, SourceNamespaces: []configv1alpha3.ScopeNamespace{"*"}}), namespaceLabeled(nil)}, + objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{{Key: "tier", Operator: "NotAnOperator"}}, + }, + }, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"*"}, + }), namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), wantReason: ReasonNamespaceNotAuthorized, wantMessage: "a grant's selector is invalid", }, { name: "from: All admits every namespace", - objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.SourceGrant{ - From: configv1alpha3.GrantFromAll, - SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}, + objects: []client.Object{providerNamed("prod-eu-1", configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromAll}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}, }), namespaceLabeled(nil)}, target: targetIn("prod-eu-1"), @@ -304,9 +326,16 @@ func claimOn(cluster, applierNamespace string) configv1alpha3.ApplierClaim { } // appliersFrom is a grant that admits namespaces by name to hold appliers in scope. -func appliersFrom(scope []configv1alpha3.ScopeNamespace, namespaces ...string) configv1alpha3.ApplierGrant { - return configv1alpha3.ApplierGrant{ - From: configv1alpha3.GrantFromNames, Names: grantNames(namespaces...), ApplierNamespaces: scope, +func appliersFrom( + scope []configv1alpha3.ScopeNamespace, + namespaces ...string, +) configv1alpha3.KubeProviderApplierClaimGrant { + return configv1alpha3.KubeProviderApplierClaimGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromNames, + Names: grantNames(namespaces...), + }, + ApplierNamespaces: scope, } } @@ -314,7 +343,7 @@ func appliersFrom(scope []configv1alpha3.ScopeNamespace, namespaces ...string) c // scope that grant gives. An explicit list is the whole scope. func TestApplierClaimAdmitted(t *testing.T) { own := []configv1alpha3.ScopeNamespace{"{consumer.namespace}"} - provider := func(grants ...configv1alpha3.ApplierGrant) *configv1alpha3.KubeProvider { + provider := func(grants ...configv1alpha3.KubeProviderApplierClaimGrant) *configv1alpha3.KubeProvider { p := providerNamed("hub", mirrorFrom(testNS)) p.Spec.AllowedApplierClaims = grants return p @@ -371,10 +400,10 @@ func TestApplierClaimAdmitted(t *testing.T) { func TestApplierClaimAdmitted_OwnNamespaceNeedsAGrant(t *testing.T) { provider := &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: testNS}, - Spec: configv1alpha3.KubeProviderSpec{AllowedGitTargets: []configv1alpha3.SourceGrant{ + Spec: configv1alpha3.KubeProviderSpec{AllowedGitTargets: []configv1alpha3.KubeProviderGitTargetGrant{ { - From: configv1alpha3.GrantFromSame, - SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}, + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{"{consumer.namespace}"}, }, }}, } @@ -400,7 +429,7 @@ func TestApplierClaimAdmitted_OwnNamespaceNeedsAGrant(t *testing.T) { // applying are granted separately, also on one KubeProvider from one namespace. func TestApplierAdmitted(t *testing.T) { own := []configv1alpha3.ScopeNamespace{"{consumer.namespace}"} - withAppliers := func(p *configv1alpha3.KubeProvider, grants ...configv1alpha3.ApplierGrant) *configv1alpha3.KubeProvider { + withAppliers := func(p *configv1alpha3.KubeProvider, grants ...configv1alpha3.KubeProviderApplierClaimGrant) *configv1alpha3.KubeProvider { p.Spec.AllowedApplierClaims = grants return p } @@ -472,7 +501,7 @@ func TestAuditRouteHeld(t *testing.T) { p := &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, CreationTimestamp: created}, Spec: configv1alpha3.KubeProviderSpec{ - AllowedGitTargets: []configv1alpha3.SourceGrant{mirrorFrom(testNS)}, + AllowedGitTargets: []configv1alpha3.KubeProviderGitTargetGrant{mirrorFrom(testNS)}, }, } if route != "" { diff --git a/internal/authz/source_namespace.go b/internal/authz/source_namespace.go index 3ad3129d..0d97c950 100644 --- a/internal/authz/source_namespace.go +++ b/internal/authz/source_namespace.go @@ -131,7 +131,7 @@ type itemGate struct { rule *configv1alpha3.WatchRule target *configv1alpha3.GitTarget decision Decision - scope configv1alpha3.SourceScope + scope configv1alpha3.KubeProviderGitTargetScope } // decide resolves ONE rule item. diff --git a/internal/authz/source_namespace_test.go b/internal/authz/source_namespace_test.go index cc3e5cc6..c49210b1 100644 --- a/internal/authz/source_namespace_test.go +++ b/internal/authz/source_namespace_test.go @@ -72,8 +72,11 @@ func snRule(sourceNamespaces ...string) *configv1alpha3.WatchRule { // snKubeProvider admits snTenantNS by name with scope as its sourceNamespaces, so the "provider // admits the target's namespace" leg of the gate passes and each case isolates the scope. func snKubeProvider(scope ...configv1alpha3.ScopeNamespace) *configv1alpha3.KubeProvider { - grant := configv1alpha3.SourceGrant{ - From: configv1alpha3.GrantFromNames, Names: []configv1alpha3.GrantNamespace{snTenantNS}, + grant := configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromNames, + Names: []configv1alpha3.GrantNamespace{snTenantNS}, + }, SourceNamespaces: scope, } if len(scope) == 0 { @@ -81,7 +84,9 @@ func snKubeProvider(scope ...configv1alpha3.ScopeNamespace) *configv1alpha3.Kube } return &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: snProvider, Namespace: snProviderNS}, - Spec: configv1alpha3.KubeProviderSpec{AllowedGitTargets: []configv1alpha3.SourceGrant{grant}}, + Spec: configv1alpha3.KubeProviderSpec{ + AllowedGitTargets: []configv1alpha3.KubeProviderGitTargetGrant{grant}, + }, } } @@ -245,7 +250,7 @@ func TestResolveWatchRuleSourceScope_DeniedItemRefusesTheWholeRule(t *testing.T) // admitted by. func TestResolveWatchRuleSourceScope_UnadmittedGitTargetGetsNoScope(t *testing.T) { denying := snKubeProvider(own, all) - denying.Spec.AllowedGitTargets[0].Names = []configv1alpha3.GrantNamespace{"some-other-tenant"} + denying.Spec.AllowedGitTargets[0].ConsumerNamespaces.Names = []configv1alpha3.GrantNamespace{"some-other-tenant"} reader := snReader(t, snTarget(), denying) resolved, err := authz.ResolveWatchRuleSourceScope( diff --git a/internal/controller/access_grants_admission_test.go b/internal/controller/access_grants_admission_test.go index 3b911812..97cd1baf 100644 --- a/internal/controller/access_grants_admission_test.go +++ b/internal/controller/access_grants_admission_test.go @@ -5,6 +5,7 @@ package controller import ( "context" "fmt" + "maps" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -59,8 +60,19 @@ var _ = Describe("Access grants at admission", func() { GinkgoHelper() Expect(create(obj)).To(Succeed()) } + same := map[string]any{"from": "Same"} branches := func(patterns ...any) map[string]any { - return map[string]any{"from": "Same", "branches": patterns} + return map[string]any{"consumerNamespaces": same, "branchPatterns": patterns} + } + // consumers is a grant holding only its consumerNamespaces block, the whole of a GitBranch grant. + consumers := func(block map[string]any) map[string]any { + return map[string]any{"consumerNamespaces": block} + } + // withConsumers is scope plus a consumerNamespaces block. + withConsumers := func(block map[string]any, scope map[string]any) map[string]any { + grant := maps.Clone(scope) + grant["consumerNamespaces"] = block + return grant } It("accepts the branch patterns the grammar allows", func() { @@ -90,80 +102,114 @@ var _ = Describe("Access grants at admission", func() { }) It("refuses what no branch name can hold", func() { - refused(gitProvider(branches("feature/?")), "spec.allowedGitBranches[0].branches[0]") - refused(gitProvider(branches("feature/[ab]")), "spec.allowedGitBranches[0].branches[0]") + refused(gitProvider(branches("feature/?")), "spec.allowedGitBranches[0].branchPatterns[0]") + refused(gitProvider(branches("feature/[ab]")), "spec.allowedGitBranches[0].branchPatterns[0]") refused(gitProvider(branches("a..b")), "can match no branch name") }) It("refuses an empty scope or names list", func() { - refused(gitProvider(branches()), "spec.allowedGitBranches[0].branches") - refused(gitProvider(map[string]any{"from": "Same"}), "branches: Required value") - refused(gitBranch(map[string]any{"from": "Names", "names": []any{}}), "names") - refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", "sourceNamespaces": []any{}}), + refused(gitProvider(branches()), "spec.allowedGitBranches[0].branchPatterns") + refused(gitProvider(consumers(same)), "branchPatterns: Required value") + refused(gitBranch(consumers(map[string]any{"from": "Names", "names": []any{}})), "names") + refused(kubeProvider("allowedGitTargets", withConsumers(same, map[string]any{"sourceNamespaces": []any{}})), "sourceNamespaces") - refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", "clusterResources": []any{}}), + refused(kubeProvider("allowedGitTargets", withConsumers(same, map[string]any{"clusterResources": []any{}})), "clusterResources") - refused(kubeProvider("allowedApplierClaims", map[string]any{"from": "Same", "applierNamespaces": []any{}}), + refused(kubeProvider("allowedApplierClaims", withConsumers(same, map[string]any{"applierNamespaces": []any{}})), "applierNamespaces") - refused(kubeProvider("allowedApplierClaims", map[string]any{"from": "Same"}), - "applierNamespaces: Required value") + refused(kubeProvider("allowedApplierClaims", consumers(same)), "applierNamespaces: Required value") }) + // consumerNamespaces is required on every grant type and from is required inside it, with no default + // for either, so a grant that leaves the "who" out admits nobody instead of its holder's namespace. + // The block's own rules cannot make it required, so each list is checked here. + for _, list := range []struct { + name string + build func(grant map[string]any) *unstructured.Unstructured + scope map[string]any + }{ + {"GitProvider allowedGitBranches", + func(g map[string]any) *unstructured.Unstructured { return gitProvider(g) }, + map[string]any{"branchPatterns": []any{"main"}}}, + {"GitBranch allowedGitTargets", + func(g map[string]any) *unstructured.Unstructured { return gitBranch(g) }, + map[string]any{}}, + {"KubeProvider allowedGitTargets", + func(g map[string]any) *unstructured.Unstructured { return kubeProvider("allowedGitTargets", g) }, + map[string]any{"sourceNamespaces": []any{"{consumer.namespace}"}}}, + {"KubeProvider allowedApplierClaims", + func(g map[string]any) *unstructured.Unstructured { return kubeProvider("allowedApplierClaims", g) }, + map[string]any{"applierNamespaces": []any{"flux-system"}}}, + } { + It("refuses a "+list.name+" grant without a complete consumerNamespaces block", func() { + refused(list.build(maps.Clone(list.scope)), "consumerNamespaces: Required value") + nullBlock := maps.Clone(list.scope) + nullBlock["consumerNamespaces"] = nil + refused(list.build(nullBlock), "consumerNamespaces: Required value") + refused(list.build(withConsumers(map[string]any{}, list.scope)), "from: Required value") + refused(list.build(withConsumers(map[string]any{"names": []any{"team-a"}}, list.scope)), + "from: Required value") + accepted(list.build(withConsumers(same, list.scope))) + }) + } + It("refuses a grant without from, or with names or a selector beside the wrong from", func() { - refused(gitBranch(map[string]any{}), "from: Required value") - refused(gitBranch(map[string]any{"from": "Everyone"}), "Unsupported value") - refused(gitBranch(map[string]any{"from": "Names"}), "from: Names needs names") - refused(gitBranch(map[string]any{"from": "Same", "names": []any{"team-b"}}), + refused(gitBranch(consumers(map[string]any{})), "from: Required value") + refused(gitBranch(consumers(map[string]any{"from": "Everyone"})), "Unsupported value") + refused(gitBranch(consumers(map[string]any{"from": "Names"})), "from: Names needs names") + refused(gitBranch(consumers(map[string]any{"from": "Same", "names": []any{"team-b"}})), "names goes only with from: Names") - refused(gitBranch(map[string]any{"from": "All", - "selector": map[string]any{"matchLabels": map[string]any{"a": "b"}}}), + refused(gitBranch(consumers(map[string]any{"from": "All", + "selector": map[string]any{"matchLabels": map[string]any{"a": "b"}}})), "selector goes only with from: Selector") }) - It("refuses a KubeProvider source grant that grants nothing", func() { - refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same"}), + It("refuses a KubeProvider GitTarget grant that grants nothing", func() { + refused(kubeProvider("allowedGitTargets", consumers(same)), "sets sourceNamespaces, clusterResources, or both") - accepted( - kubeProvider("allowedGitTargets", map[string]any{"from": "Same", - "sourceNamespaces": []any{"{consumer.namespace}"}}), - ) - accepted(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", - "clusterResources": []any{"clusterroles.rbac.authorization.k8s.io", "nodes", "*"}})) + accepted(kubeProvider("allowedGitTargets", + withConsumers(same, map[string]any{"sourceNamespaces": []any{"{consumer.namespace}"}}))) + accepted(kubeProvider("allowedGitTargets", withConsumers(same, + map[string]any{"clusterResources": []any{"clusterroles.rbac.authorization.k8s.io", "nodes", "*"}}))) }) It("refuses a variable in names, pointing to from: All and Same, and * pointing to from: All", func() { - refused(gitBranch(map[string]any{"from": "Names", "names": []any{"{consumer.namespace}"}}), "use from: All") - refused(gitBranch(map[string]any{"from": "Names", "names": []any{"{consumer.name}"}}), "or from: Same") - refused(gitBranch(map[string]any{"from": "Names", "names": []any{"*"}}), "use from: All") - refused(gitBranch(map[string]any{"from": "Names", "names": []any{"Team_A"}}), "namespace names") + names := func(name string) map[string]any { + return consumers(map[string]any{"from": "Names", "names": []any{name}}) + } + refused(gitBranch(names("{consumer.namespace}")), "use from: All") + refused(gitBranch(names("{consumer.name}")), "or from: Same") + refused(gitBranch(names("*")), "use from: All") + refused(gitBranch(names("Team_A")), "namespace names") }) It("refuses a Selector grant without a selector or with an empty one, and accepts a real one", func() { - refused(gitBranch(map[string]any{"from": "Selector"}), "from: Selector needs a selector") - refused(gitBranch(map[string]any{"from": "Selector", "selector": map[string]any{}}), + refused(gitBranch(consumers(map[string]any{"from": "Selector"})), "from: Selector needs a selector") + refused(gitBranch(consumers(map[string]any{"from": "Selector", "selector": map[string]any{}})), "selector: {} is refused; to admit every namespace, use from: All") - refused(gitBranch(map[string]any{"from": "Selector", - "selector": map[string]any{"matchLabels": map[string]any{}}}), "selector: {} is refused") - accepted(gitBranch(map[string]any{"from": "Selector", - "selector": map[string]any{"matchLabels": map[string]any{"tenant": "true"}}})) - accepted(gitBranch(map[string]any{"from": "Selector", "selector": map[string]any{ - "matchExpressions": []any{map[string]any{"key": "tenant", "operator": "Exists"}}}})) - accepted(gitBranch(map[string]any{"from": "All"}, map[string]any{"from": "Same"})) + refused(gitBranch(consumers(map[string]any{"from": "Selector", + "selector": map[string]any{"matchLabels": map[string]any{}}})), "selector: {} is refused") + accepted(gitBranch(consumers(map[string]any{"from": "Selector", + "selector": map[string]any{"matchLabels": map[string]any{"tenant": "true"}}}))) + accepted(gitBranch(consumers(map[string]any{"from": "Selector", "selector": map[string]any{ + "matchExpressions": []any{map[string]any{"key": "tenant", "operator": "Exists"}}}}))) + accepted(gitBranch(consumers(map[string]any{"from": "All"}), consumers(same))) }) It("refuses a scope entry that is neither a name, a consumer variable alone, nor *", func() { alone := "'{consumer.namespace}' alone, or '*' alone" - refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", - "sourceNamespaces": []any{"team-{consumer.namespace}"}}), alone) - refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", - "sourceNamespaces": []any{"{namespace}"}}), alone) - refused(kubeProvider("allowedApplierClaims", map[string]any{"from": "Same", - "applierNamespaces": []any{"flux-*"}}), alone) - refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", - "sourceNamespaces": []any{"{consumer.name}"}}), "the consumer picks its own name") - refused(kubeProvider("allowedApplierClaims", map[string]any{"from": "Same", - "applierNamespaces": []any{"{consumer.name}"}}), "the consumer picks its own name") - refused(kubeProvider("allowedGitTargets", map[string]any{"from": "Same", - "clusterResources": []any{"cluster*"}}), "clusterResources[0]") + sources := func(entry string) map[string]any { + return withConsumers(same, map[string]any{"sourceNamespaces": []any{entry}}) + } + appliers := func(entry string) map[string]any { + return withConsumers(same, map[string]any{"applierNamespaces": []any{entry}}) + } + refused(kubeProvider("allowedGitTargets", sources("team-{consumer.namespace}")), alone) + refused(kubeProvider("allowedGitTargets", sources("{namespace}")), alone) + refused(kubeProvider("allowedApplierClaims", appliers("flux-*")), alone) + refused(kubeProvider("allowedGitTargets", sources("{consumer.name}")), "the consumer picks its own name") + refused(kubeProvider("allowedApplierClaims", appliers("{consumer.name}")), "the consumer picks its own name") + refused(kubeProvider("allowedGitTargets", withConsumers(same, + map[string]any{"clusterResources": []any{"cluster*"}})), "clusterResources[0]") }) }) diff --git a/internal/controller/applierclaim_revocation_test.go b/internal/controller/applierclaim_revocation_test.go index 5df2ca80..feb70768 100644 --- a/internal/controller/applierclaim_revocation_test.go +++ b/internal/controller/applierclaim_revocation_test.go @@ -30,7 +30,7 @@ import ( var localProviderKey = k8stypes.NamespacedName{Namespace: claimProviderNamespace, Name: claimLocalProvider} // setApplierGrants replaces the local KubeProvider's allowedApplierClaims. -func (f *holdFixture) setApplierGrants(t *testing.T, grants []configbutleraiv1alpha3.ApplierGrant) { +func (f *holdFixture) setApplierGrants(t *testing.T, grants []configbutleraiv1alpha3.KubeProviderApplierClaimGrant) { t.Helper() provider := &configbutleraiv1alpha3.KubeProvider{} require.NoError(t, f.r.Get(context.Background(), localProviderKey, provider)) diff --git a/internal/controller/clusterwatchrule_admission_test.go b/internal/controller/clusterwatchrule_admission_test.go index 58f245d8..1bc5eccf 100644 --- a/internal/controller/clusterwatchrule_admission_test.go +++ b/internal/controller/clusterwatchrule_admission_test.go @@ -133,7 +133,7 @@ func cwaGitProvider() *configbutleraiv1alpha3.GitProvider { } } -func cwaKubeProvider(policy []configbutleraiv1alpha3.SourceGrant) *configbutleraiv1alpha3.KubeProvider { +func cwaKubeProvider(policy []configbutleraiv1alpha3.KubeProviderGitTargetGrant) *configbutleraiv1alpha3.KubeProvider { return &configbutleraiv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Name: cwaProviderName, Namespace: cwaProviderNS}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ @@ -157,11 +157,11 @@ func cwaClusterWatchRule() *configbutleraiv1alpha3.ClusterWatchRule { } // cwaAdmitting is the policy that admits cwaTargetNS; cwaDenying admits some other namespace. -func cwaAdmitting() []configbutleraiv1alpha3.SourceGrant { +func cwaAdmitting() []configbutleraiv1alpha3.KubeProviderGitTargetGrant { return mirrorGrants(cwaTargetNS) } -func cwaDenying() []configbutleraiv1alpha3.SourceGrant { +func cwaDenying() []configbutleraiv1alpha3.KubeProviderGitTargetGrant { return mirrorGrants("some-other-namespace") } @@ -368,11 +368,15 @@ func TestReconcile_AdmittedBySelectorOnNamespaceLabels(t *testing.T) { ctx := context.Background() f := newCWAFixture(t, []client.Object{ cwaClusterWatchRule(), cwaGitTarget(), cwaGitProvider(), - cwaKubeProvider([]configbutleraiv1alpha3.SourceGrant{{ - From: configbutleraiv1alpha3.GrantFromSelector, - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirror": "yes"}}, - ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}, - }}), + cwaKubeProvider([]configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirror": "yes"}}, + }, + ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}, + }, + }), &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{ Name: cwaTargetNS, Labels: map[string]string{"mirror": "yes"}, }}, diff --git a/internal/controller/gitbranch_access_test.go b/internal/controller/gitbranch_access_test.go index 6c489096..a921a6a8 100644 --- a/internal/controller/gitbranch_access_test.go +++ b/internal/controller/gitbranch_access_test.go @@ -24,10 +24,15 @@ import ( // branch. func sharedProvider(namespaces ...string) *configbutleraiv1alpha3.GitProvider { provider := branchProvider("platform", "shared-repo") - provider.Spec.AllowedGitBranches = []configbutleraiv1alpha3.GitBranchGrant{{ - From: configbutleraiv1alpha3.GrantFromNames, Names: grantNames(namespaces...), - Branches: []configbutleraiv1alpha3.BranchPattern{"*"}, - }} + provider.Spec.AllowedGitBranches = []configbutleraiv1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames(namespaces...), + }, + BranchPatterns: []configbutleraiv1alpha3.BranchPattern{"*"}, + }, + } return provider } @@ -209,10 +214,15 @@ func TestGitBranch_OneEditRevokesTwoCandidates(t *testing.T) { func TestGitBranch_ANamespaceLabelLossRevokesASelectorGrant(t *testing.T) { tenant := map[string]string{"tenant": "true"} provider := branchProvider("platform", "shared-repo") - provider.Spec.AllowedGitBranches = []configbutleraiv1alpha3.GitBranchGrant{{ - From: configbutleraiv1alpha3.GrantFromSelector, Selector: &metav1.LabelSelector{MatchLabels: tenant}, - Branches: []configbutleraiv1alpha3.BranchPattern{"teams/{consumer.namespace}/*", "main"}, - }} + provider.Spec.AllowedGitBranches = []configbutleraiv1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: tenant}, + }, + BranchPatterns: []configbutleraiv1alpha3.BranchPattern{"teams/{consumer.namespace}/*", "main"}, + }, + } namespace := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-a", Labels: tenant}} f := newGitBranchFixture(t, provider, namespace, tenantBranch("team-a", time.Hour)) got := f.reconcile(t, "team-a", "main") diff --git a/internal/controller/gitbranch_controller_test.go b/internal/controller/gitbranch_controller_test.go index 016318b4..488a99e2 100644 --- a/internal/controller/gitbranch_controller_test.go +++ b/internal/controller/gitbranch_controller_test.go @@ -43,8 +43,13 @@ func branchProvider( return &configbutleraiv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: namespace, Name: name, UID: k8stypes.UID("uid-" + name)}, Spec: configbutleraiv1alpha3.GitProviderSpec{URL: gitBranchTestURL, - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - {From: configbutleraiv1alpha3.GrantFromSame, Branches: allowed}, + AllowedGitBranches: []configbutleraiv1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSame, + }, + BranchPatterns: allowed, + }, }}, Status: configbutleraiv1alpha3.GitProviderStatus{Conditions: []metav1.Condition{{ Type: ConditionTypeReady, Status: metav1.ConditionTrue, Reason: ReasonSucceeded, diff --git a/internal/controller/gitprovider_immutability_test.go b/internal/controller/gitprovider_immutability_test.go index 86f60c31..a09d2d92 100644 --- a/internal/controller/gitprovider_immutability_test.go +++ b/internal/controller/gitprovider_immutability_test.go @@ -42,7 +42,10 @@ var _ = Describe("GitProvider URL Immutability", func() { Eventually(func(g Gomega) { current := &configbutleraiv1alpha3.GitProvider{} g.Expect(k8sClient.Get(ctx, key, current)).To(Succeed()) - current.Spec.AllowedGitBranches[0].Branches = []configbutleraiv1alpha3.BranchPattern{"main", "develop"} + current.Spec.AllowedGitBranches[0].BranchPatterns = []configbutleraiv1alpha3.BranchPattern{ + "main", + "develop", + } g.Expect(k8sClient.Update(ctx, current)).To(Succeed()) }, timeout, interval).Should(Succeed()) diff --git a/internal/controller/gittarget_gitbranch_test.go b/internal/controller/gittarget_gitbranch_test.go index cb11bd4c..f3dc8cb2 100644 --- a/internal/controller/gittarget_gitbranch_test.go +++ b/internal/controller/gittarget_gitbranch_test.go @@ -44,8 +44,13 @@ func newDestinationFixture(t *testing.T) *destinationFixture { require.NoError(t, configbutleraiv1alpha3.AddToScheme(scheme)) provider := branchProvider("platform", "acme") branch := branchObject("platform", "main", "acme", "main", time.Hour) - branch.Spec.AllowedGitTargets = []configbutleraiv1alpha3.NamespaceGrant{ - {From: configbutleraiv1alpha3.GrantFromNames, Names: grantNames("team-a")}, + branch.Spec.AllowedGitTargets = []configbutleraiv1alpha3.GitBranchGitTargetGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames("team-a"), + }, + }, } target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "apps"}, @@ -248,9 +253,12 @@ func TestReconcile_ACrossNamespaceGitBranchWritesWithItsProvidersCredential(t *t require.NoError(t, clientgoscheme.AddToScheme(scheme)) require.NoError(t, configbutleraiv1alpha3.AddToScheme(scheme)) platform := branchProvider("platform", "acme") - platform.Spec.AllowedGitBranches[0] = configbutleraiv1alpha3.GitBranchGrant{ - From: configbutleraiv1alpha3.GrantFromNames, Names: grantNames("team-a"), - Branches: []configbutleraiv1alpha3.BranchPattern{"teams/{consumer.namespace}/*"}, + platform.Spec.AllowedGitBranches[0] = configbutleraiv1alpha3.GitProviderGitBranchGrant{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames("team-a"), + }, + BranchPatterns: []configbutleraiv1alpha3.BranchPattern{"teams/{consumer.namespace}/*"}, } decoy := branchProvider("team-a", "acme") decoy.UID = "uid-decoy" diff --git a/internal/controller/gittarget_shared_branch_test.go b/internal/controller/gittarget_shared_branch_test.go index abc58b3a..152db0ea 100644 --- a/internal/controller/gittarget_shared_branch_test.go +++ b/internal/controller/gittarget_shared_branch_test.go @@ -48,8 +48,10 @@ func sharedProviderReconciler(t *testing.T, objects ...ctrlclient.Object) *GitTa require.NoError(t, configbutleraiv1alpha3.AddToScheme(scheme)) // The KubeProvider every GitTarget here mirrors through admits every namespace. local := admitting(claimLocalProvider) - local.Spec.AllowedGitTargets = []configbutleraiv1alpha3.SourceGrant{{From: configbutleraiv1alpha3.GrantFromAll, - SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.ConsumerNamespaceVariable}}} + local.Spec.AllowedGitTargets = []configbutleraiv1alpha3.KubeProviderGitTargetGrant{{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{From: configbutleraiv1alpha3.GrantFromAll}, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.ConsumerNamespaceVariable}, + }} objects = append(objects, local) for _, ns := range []string{"platform", "team-b"} { objects = append(objects, &configbutleraiv1alpha3.GitProvider{ @@ -103,8 +105,14 @@ func TestCheckForConflicts_AnUnadmittedTargetReservesNoFolder(t *testing.T) { var stored configbutleraiv1alpha3.GitBranch require.NoError(t, r.Get(context.Background(), ctrlclient.ObjectKeyFromObject(branch), &stored)) - stored.Spec.AllowedGitTargets = append(stored.Spec.AllowedGitTargets, configbutleraiv1alpha3.NamespaceGrant{ - From: configbutleraiv1alpha3.GrantFromNames, Names: grantNames("intruder")}) + stored.Spec.AllowedGitTargets = append( + stored.Spec.AllowedGitTargets, + configbutleraiv1alpha3.GitBranchGitTargetGrant{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, Names: grantNames("intruder"), + }, + }, + ) require.NoError(t, r.Update(context.Background(), &stored)) conflict, msg, reason, err := r.checkForConflicts(context.Background(), owner) diff --git a/internal/controller/gittarget_source_cluster_test.go b/internal/controller/gittarget_source_cluster_test.go index 715e53c1..37fa95b4 100644 --- a/internal/controller/gittarget_source_cluster_test.go +++ b/internal/controller/gittarget_source_cluster_test.go @@ -119,13 +119,13 @@ func scScheme(t *testing.T) *runtime.Scheme { func TestCheckSourceAuthorization(t *testing.T) { // The provider lives in "platform", so team-a reaches it only through spec.allowedGitTargets. - provider := func(grant configbutleraiv1alpha3.SourceGrant) *configbutleraiv1alpha3.KubeProvider { + provider := func(grant configbutleraiv1alpha3.KubeProviderGitTargetGrant) *configbutleraiv1alpha3.KubeProvider { p := remoteKubeProvider("kc", "") p.Namespace = "platform" grant.SourceNamespaces = []configbutleraiv1alpha3.ScopeNamespace{ configbutleraiv1alpha3.ConsumerNamespaceVariable, } - p.Spec.AllowedGitTargets = []configbutleraiv1alpha3.SourceGrant{grant} + p.Spec.AllowedGitTargets = []configbutleraiv1alpha3.KubeProviderGitTargetGrant{grant} return p } ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "team-a", Labels: map[string]string{"tier": "trusted"}}} @@ -163,9 +163,11 @@ func TestCheckSourceAuthorization(t *testing.T) { name: "provider allows the namespace by name", objects: []client.Object{ provider( - configbutleraiv1alpha3.SourceGrant{ - From: configbutleraiv1alpha3.GrantFromNames, - Names: grantNames("team-a"), + configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames("team-a"), + }, }, ), ns, @@ -176,9 +178,11 @@ func TestCheckSourceAuthorization(t *testing.T) { name: "provider does not allow the namespace -> refused", objects: []client.Object{ provider( - configbutleraiv1alpha3.SourceGrant{ - From: configbutleraiv1alpha3.GrantFromNames, - Names: grantNames("team-b"), + configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames("team-b"), + }, }, ), ns, @@ -188,9 +192,11 @@ func TestCheckSourceAuthorization(t *testing.T) { { name: "provider allows the namespace by label selector", objects: []client.Object{ - provider(configbutleraiv1alpha3.SourceGrant{ - From: configbutleraiv1alpha3.GrantFromSelector, - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "trusted"}}, + provider(configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "trusted"}}, + }, }), ns, }, providerRef: "prod-eu-1", wantAuthorized: true, @@ -200,11 +206,13 @@ func TestCheckSourceAuthorization(t *testing.T) { // unevaluatable policy as "allow" would hand a namespace access it was never granted. name: "invalid selector -> refused, not allowed", objects: []client.Object{ - provider(configbutleraiv1alpha3.SourceGrant{ - From: configbutleraiv1alpha3.GrantFromSelector, - Selector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{ - {Key: "tier", Operator: "BogusOperator", Values: []string{"trusted"}}, + provider(configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + {Key: "tier", Operator: "BogusOperator", Values: []string{"trusted"}}, + }, }, }, }), ns, @@ -217,9 +225,11 @@ func TestCheckSourceAuthorization(t *testing.T) { name: "namespace object absent -> evaluated with no labels, name allow still holds", objects: []client.Object{ provider( - configbutleraiv1alpha3.SourceGrant{ - From: configbutleraiv1alpha3.GrantFromNames, - Names: grantNames("team-a"), + configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames("team-a"), + }, }, ), }, @@ -228,9 +238,11 @@ func TestCheckSourceAuthorization(t *testing.T) { { name: "namespace object absent -> selector-only policy denies (no labels to match)", objects: []client.Object{ - provider(configbutleraiv1alpha3.SourceGrant{ - From: configbutleraiv1alpha3.GrantFromSelector, - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "trusted"}}, + provider(configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"tier": "trusted"}}, + }, }), }, providerRef: "prod-eu-1", wantAuthorized: false, wantReason: GitTargetReasonNamespaceNotAuthorized, @@ -257,8 +269,10 @@ func TestCheckSourceAuthorization(t *testing.T) { // is denied to every consumer, its own namespace included. func TestCheckSourceAuthorization_OwnNamespaceAndAuditRoute(t *testing.T) { own := inClusterKubeProvider("local") - own.Spec.AllowedGitTargets = []configbutleraiv1alpha3.SourceGrant{{From: configbutleraiv1alpha3.GrantFromSame, - SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.ConsumerNamespaceVariable}}} + own.Spec.AllowedGitTargets = []configbutleraiv1alpha3.KubeProviderGitTargetGrant{{ + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{From: configbutleraiv1alpha3.GrantFromSame}, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.ConsumerNamespaceVariable}, + }} target := &configbutleraiv1alpha3.GitTarget{ ObjectMeta: metav1.ObjectMeta{Name: "gt", Namespace: kpNamespace}, Spec: configbutleraiv1alpha3.GitTargetSpec{ diff --git a/internal/controller/grant_helpers_test.go b/internal/controller/grant_helpers_test.go index ba3a7a12..ca3ad186 100644 --- a/internal/controller/grant_helpers_test.go +++ b/internal/controller/grant_helpers_test.go @@ -17,30 +17,50 @@ func grantNames(namespaces ...string) []configbutleraiv1alpha3.GrantNamespace { // mirrorGrants admits the named namespaces to mirror their own namespace and every cluster-scoped // resource, the reach a provider admitting them had before grants were scoped. -func mirrorGrants(namespaces ...string) []configbutleraiv1alpha3.SourceGrant { - return []configbutleraiv1alpha3.SourceGrant{{ - From: configbutleraiv1alpha3.GrantFromNames, Names: grantNames(namespaces...), - SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.ConsumerNamespaceVariable}, - ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}, - }} +func mirrorGrants(namespaces ...string) []configbutleraiv1alpha3.KubeProviderGitTargetGrant { + return []configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames(namespaces...), + }, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{configbutleraiv1alpha3.ConsumerNamespaceVariable}, + ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}, + }, + } } // applierGrants admits the named namespaces to hold appliers in scope. func applierGrants( scope []configbutleraiv1alpha3.ScopeNamespace, namespaces ...string, -) []configbutleraiv1alpha3.ApplierGrant { - return []configbutleraiv1alpha3.ApplierGrant{{ - From: configbutleraiv1alpha3.GrantFromNames, Names: grantNames(namespaces...), ApplierNamespaces: scope, - }} +) []configbutleraiv1alpha3.KubeProviderApplierClaimGrant { + return []configbutleraiv1alpha3.KubeProviderApplierClaimGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: grantNames(namespaces...), + }, + ApplierNamespaces: scope, + }, + } } // branchesFromSame is the allowedGitBranches of a GitProvider that lets GitBranches in its own namespace // claim the branches patterns match. -func branchesFromSame(patterns ...configbutleraiv1alpha3.BranchPattern) []configbutleraiv1alpha3.GitBranchGrant { - return []configbutleraiv1alpha3.GitBranchGrant{{From: configbutleraiv1alpha3.GrantFromSame, Branches: patterns}} +func branchesFromSame( + patterns ...configbutleraiv1alpha3.BranchPattern, +) []configbutleraiv1alpha3.GitProviderGitBranchGrant { + return []configbutleraiv1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{From: configbutleraiv1alpha3.GrantFromSame}, + BranchPatterns: patterns, + }, + } } // writersFromSame is the allowedGitTargets of a GitBranch shared with its own namespace only. -func writersFromSame() []configbutleraiv1alpha3.NamespaceGrant { - return []configbutleraiv1alpha3.NamespaceGrant{{From: configbutleraiv1alpha3.GrantFromSame}} +func writersFromSame() []configbutleraiv1alpha3.GitBranchGitTargetGrant { + return []configbutleraiv1alpha3.GitBranchGitTargetGrant{ + {ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{From: configbutleraiv1alpha3.GrantFromSame}}, + } } diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index e71a611d..5e740d58 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -205,19 +205,25 @@ var _ = BeforeSuite(func() { ObjectMeta: metav1.ObjectMeta{Namespace: testKubeProviderNamespace, Name: testKubeProviderName}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, - AllowedGitTargets: []configbutleraiv1alpha3.SourceGrant{ + AllowedGitTargets: []configbutleraiv1alpha3.KubeProviderGitTargetGrant{ { - From: configbutleraiv1alpha3.GrantFromAll, + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromAll, + }, SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{ configbutleraiv1alpha3.ConsumerNamespaceVariable, }, ClusterResources: []configbutleraiv1alpha3.ClusterResourceIdentity{"*"}, }, }, - AllowedApplierClaims: []configbutleraiv1alpha3.ApplierGrant{{ - From: configbutleraiv1alpha3.GrantFromAll, - ApplierNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"*"}, - }}, + AllowedApplierClaims: []configbutleraiv1alpha3.KubeProviderApplierClaimGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromAll, + }, + ApplierNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"*"}, + }, + }, }, })).To(Succeed()) diff --git a/internal/controller/wiring/access_grants_wiring_test.go b/internal/controller/wiring/access_grants_wiring_test.go index d134abc4..d37bcd07 100644 --- a/internal/controller/wiring/access_grants_wiring_test.go +++ b/internal/controller/wiring/access_grants_wiring_test.go @@ -122,13 +122,21 @@ func TestWatchRule_ANarrowedScopeRefusesOnALabelChange(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "source", Namespace: namespace}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, - AllowedGitTargets: []configbutleraiv1alpha3.SourceGrant{ - {From: configbutleraiv1alpha3.GrantFromSelector, - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirror": "yes"}}, - SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"{consumer.namespace}"}}, - {From: configbutleraiv1alpha3.GrantFromSelector, - Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"audit": "yes"}}, - SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"*"}}, + AllowedGitTargets: []configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"mirror": "yes"}}, + }, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"{consumer.namespace}"}, + }, + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"audit": "yes"}}, + }, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"*"}, + }, }, }, })) @@ -211,11 +219,15 @@ func TestGitBranch_ALabelRemovedRevokesTheClaimAndItsGitTargetsFollow(t *testing // Nothing listens here, so the push-session proof fails at once: the reasons tell an // admitted GitBranch (PushSessionFailed) from a revoked one (NamespaceNotAuthorized). URL: "https://127.0.0.1:1/platform/config.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{{ - From: configbutleraiv1alpha3.GrantFromSelector, - Selector: &metav1.LabelSelector{MatchLabels: tenant}, - Branches: []configbutleraiv1alpha3.BranchPattern{"teams/{consumer.namespace}/*"}, - }}, + AllowedGitBranches: []configbutleraiv1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSelector, + Selector: &metav1.LabelSelector{MatchLabels: tenant}, + }, + BranchPatterns: []configbutleraiv1alpha3.BranchPattern{"teams/{consumer.namespace}/*"}, + }, + }, }, })) branch := &configbutleraiv1alpha3.GitBranch{ @@ -223,10 +235,12 @@ func TestGitBranch_ALabelRemovedRevokesTheClaimAndItsGitTargetsFollow(t *testing Spec: configbutleraiv1alpha3.GitBranchSpec{ GitProviderRef: meta.NamespacedObjectReference{Name: "shared-repo", Namespace: "wiring-platform"}, Branch: "teams/wiring-tenant/live", - AllowedGitTargets: []configbutleraiv1alpha3.NamespaceGrant{ + AllowedGitTargets: []configbutleraiv1alpha3.GitBranchGitTargetGrant{ { - From: configbutleraiv1alpha3.GrantFromNames, - Names: []configbutleraiv1alpha3.GrantNamespace{"wiring-writer"}, + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromNames, + Names: []configbutleraiv1alpha3.GrantNamespace{"wiring-writer"}, + }, }, }, }, @@ -236,8 +250,14 @@ func TestGitBranch_ALabelRemovedRevokesTheClaimAndItsGitTargetsFollow(t *testing ObjectMeta: metav1.ObjectMeta{Name: "source", Namespace: "wiring-writer"}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, - AllowedGitTargets: []configbutleraiv1alpha3.SourceGrant{{From: configbutleraiv1alpha3.GrantFromSame, - SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"{consumer.namespace}"}}}, + AllowedGitTargets: []configbutleraiv1alpha3.KubeProviderGitTargetGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSame, + }, + SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{"{consumer.namespace}"}, + }, + }, }, })) target := &configbutleraiv1alpha3.GitTarget{ @@ -270,7 +290,14 @@ func TestGitBranch_ALabelRemovedRevokesTheClaimAndItsGitTargetsFollow(t *testing refused := func(c *metav1.Condition) bool { return c.Reason == controller.GitTargetReasonNamespaceNotAuthorized } admitted := func(c *metav1.Condition) bool { return !refused(c) } writers := branch.Spec.AllowedGitTargets - setWriters(ctx, t, branchKey, []configbutleraiv1alpha3.NamespaceGrant{{From: configbutleraiv1alpha3.GrantFromSame}}) + setWriters( + ctx, + t, + branchKey, + []configbutleraiv1alpha3.GitBranchGitTargetGrant{ + {ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{From: configbutleraiv1alpha3.GrantFromSame}}, + }, + ) awaitTarget(ctx, t, targetKey, controller.GitTargetConditionValidated, refused) setWriters(ctx, t, branchKey, writers) awaitTarget(ctx, t, targetKey, controller.GitTargetConditionValidated, admitted) @@ -289,7 +316,10 @@ func awaitTarget( // setWriters replaces a GitBranch's allowedGitTargets. func setWriters( - ctx context.Context, t *testing.T, key types.NamespacedName, grants []configbutleraiv1alpha3.NamespaceGrant, + ctx context.Context, + t *testing.T, + key types.NamespacedName, + grants []configbutleraiv1alpha3.GitBranchGitTargetGrant, ) { t.Helper() require.NoError(t, retry.RetryOnConflict(retry.DefaultRetry, func() error { diff --git a/internal/controller/wiring/readiness_wiring_test.go b/internal/controller/wiring/readiness_wiring_test.go index 07f00761..1769fa8c 100644 --- a/internal/controller/wiring/readiness_wiring_test.go +++ b/internal/controller/wiring/readiness_wiring_test.go @@ -127,8 +127,13 @@ func seedTarget(ctx context.Context, t *testing.T, name string) string { ObjectMeta: metav1.ObjectMeta{Name: "provider", Namespace: namespace}, Spec: configbutleraiv1alpha3.GitProviderSpec{ URL: "https://github.com/test/repo.git", - AllowedGitBranches: []configbutleraiv1alpha3.GitBranchGrant{ - {From: configbutleraiv1alpha3.GrantFromSame, Branches: []configbutleraiv1alpha3.BranchPattern{"*"}}, + AllowedGitBranches: []configbutleraiv1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSame, + }, + BranchPatterns: []configbutleraiv1alpha3.BranchPattern{"*"}, + }, }, SecretRef: &meta.LocalObjectReference{Name: "git-credentials"}, }, @@ -137,9 +142,11 @@ func seedTarget(ctx context.Context, t *testing.T, name string) string { ObjectMeta: metav1.ObjectMeta{Name: "source", Namespace: namespace}, Spec: configbutleraiv1alpha3.KubeProviderSpec{ InCluster: &configbutleraiv1alpha3.KubeProviderInCluster{}, - AllowedGitTargets: []configbutleraiv1alpha3.SourceGrant{ + AllowedGitTargets: []configbutleraiv1alpha3.KubeProviderGitTargetGrant{ { - From: configbutleraiv1alpha3.GrantFromSame, + ConsumerNamespaces: configbutleraiv1alpha3.ConsumerNamespaces{ + From: configbutleraiv1alpha3.GrantFromSame, + }, SourceNamespaces: []configbutleraiv1alpha3.ScopeNamespace{ configbutleraiv1alpha3.ConsumerNamespaceVariable, }, diff --git a/internal/rulestore/store.go b/internal/rulestore/store.go index e820b399..9941b918 100644 --- a/internal/rulestore/store.go +++ b/internal/rulestore/store.go @@ -153,7 +153,8 @@ type CompiledClusterRule struct { // AllowsType reports whether the grant the rule compiled under covers resource in group. func (c CompiledClusterRule) AllowsType(group, resource string) bool { - return configv1alpha3.SourceScope{ClusterResources: c.ClusterResources}.AllowsClusterResource(group, resource) + scope := configv1alpha3.KubeProviderGitTargetScope{ClusterResources: c.ClusterResources} + return scope.AllowsClusterResource(group, resource) } // CompiledClusterResourceRule represents a single cluster resource rule. diff --git a/internal/watch/bootstrap_admission_test.go b/internal/watch/bootstrap_admission_test.go index f2d9b703..55805952 100644 --- a/internal/watch/bootstrap_admission_test.go +++ b/internal/watch/bootstrap_admission_test.go @@ -53,19 +53,19 @@ func bootGitProvider() *configv1alpha3.GitProvider { // mirrorGrants admits the named namespaces to mirror their own namespace and every cluster-scoped // resource, the reach a provider admitting them had before grants were scoped. -func mirrorGrants(namespaces ...string) []configv1alpha3.SourceGrant { +func mirrorGrants(namespaces ...string) []configv1alpha3.KubeProviderGitTargetGrant { names := make([]configv1alpha3.GrantNamespace, 0, len(namespaces)) for _, ns := range namespaces { names = append(names, configv1alpha3.GrantNamespace(ns)) } - return []configv1alpha3.SourceGrant{{ - From: configv1alpha3.GrantFromNames, Names: names, - SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable}, - ClusterResources: []configv1alpha3.ClusterResourceIdentity{"*"}, + return []configv1alpha3.KubeProviderGitTargetGrant{{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromNames, Names: names}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable}, + ClusterResources: []configv1alpha3.ClusterResourceIdentity{"*"}, }} } -func bootKubeProvider(policy []configv1alpha3.SourceGrant) *configv1alpha3.KubeProvider { +func bootKubeProvider(policy []configv1alpha3.KubeProviderGitTargetGrant) *configv1alpha3.KubeProvider { return &configv1alpha3.KubeProvider{ ObjectMeta: metav1.ObjectMeta{Namespace: bootProviderNS, Name: bootProviderName}, Spec: configv1alpha3.KubeProviderSpec{ diff --git a/internal/watch/cluster_resources_test.go b/internal/watch/cluster_resources_test.go index 52fc22ab..cf2189cd 100644 --- a/internal/watch/cluster_resources_test.go +++ b/internal/watch/cluster_resources_test.go @@ -20,14 +20,17 @@ import ( // granting is the boot KubeProvider admitting the boot GitTarget's namespace with clusterResources // identities; none at all gives it its own namespace and no cluster-scoped resource. func granting(identities ...configv1alpha3.ClusterResourceIdentity) *configv1alpha3.KubeProvider { - grant := configv1alpha3.SourceGrant{ - From: configv1alpha3.GrantFromNames, Names: []configv1alpha3.GrantNamespace{bootTargetNS}, + grant := configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromNames, + Names: []configv1alpha3.GrantNamespace{bootTargetNS}, + }, ClusterResources: identities, } if len(identities) == 0 { grant.SourceNamespaces = []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable} } - return bootKubeProvider([]configv1alpha3.SourceGrant{grant}) + return bootKubeProvider([]configv1alpha3.KubeProviderGitTargetGrant{grant}) } // clusterRuleFor is a ClusterWatchRule on the boot GitTarget with items. diff --git a/internal/watch/manager_startup_test.go b/internal/watch/manager_startup_test.go index 27983a37..76ba2196 100644 --- a/internal/watch/manager_startup_test.go +++ b/internal/watch/manager_startup_test.go @@ -51,16 +51,21 @@ func TestManagerStart_MustSeedRuleStoreFromExistingWatchRules(t *testing.T) { ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: "tilt-playground"}, Spec: configv1alpha3.KubeProviderSpec{ InCluster: &configv1alpha3.KubeProviderInCluster{}, - AllowedGitTargets: []configv1alpha3.SourceGrant{{From: configv1alpha3.GrantFromSame, - SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable}}}, + AllowedGitTargets: []configv1alpha3.KubeProviderGitTargetGrant{{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame}, + SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable}, + }}, }, } existingGitProvider := &configv1alpha3.GitProvider{ ObjectMeta: metav1.ObjectMeta{Name: "playground-provider", Namespace: "tilt-playground"}, Spec: configv1alpha3.GitProviderSpec{ URL: "https://example.invalid/playground.git", - AllowedGitBranches: []configv1alpha3.GitBranchGrant{ - {From: configv1alpha3.GrantFromSame, Branches: []configv1alpha3.BranchPattern{"main"}}, + AllowedGitBranches: []configv1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame}, + BranchPatterns: []configv1alpha3.BranchPattern{"main"}, + }, }, }, } @@ -129,8 +134,11 @@ func TestManagerStart_MustSeedRuleStoreFromExistingClusterWatchRules(t *testing. ObjectMeta: metav1.ObjectMeta{Name: "ops-provider", Namespace: "ops"}, Spec: configv1alpha3.GitProviderSpec{ URL: "https://example.invalid/ops.git", - AllowedGitBranches: []configv1alpha3.GitBranchGrant{ - {From: configv1alpha3.GrantFromSame, Branches: []configv1alpha3.BranchPattern{"main"}}, + AllowedGitBranches: []configv1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame}, + BranchPatterns: []configv1alpha3.BranchPattern{"main"}, + }, }, }, } @@ -144,8 +152,10 @@ func TestManagerStart_MustSeedRuleStoreFromExistingClusterWatchRules(t *testing. ObjectMeta: metav1.ObjectMeta{Name: "local", Namespace: "ops"}, Spec: configv1alpha3.KubeProviderSpec{ InCluster: &configv1alpha3.KubeProviderInCluster{}, - AllowedGitTargets: []configv1alpha3.SourceGrant{{From: configv1alpha3.GrantFromSame, - ClusterResources: []configv1alpha3.ClusterResourceIdentity{"*"}}}, + AllowedGitTargets: []configv1alpha3.KubeProviderGitTargetGrant{{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{From: configv1alpha3.GrantFromSame}, + ClusterResources: []configv1alpha3.ClusterResourceIdentity{"*"}, + }}, }, } diff --git a/internal/watch/source_namespace_test.go b/internal/watch/source_namespace_test.go index df7a646d..c57cf4ef 100644 --- a/internal/watch/source_namespace_test.go +++ b/internal/watch/source_namespace_test.go @@ -50,8 +50,11 @@ func snbGitProvider() *configv1alpha3.GitProvider { // snbKubeProvider admits snbTenantNS to watch its own namespace, and every namespace when wide. func snbKubeProvider(wide bool) *configv1alpha3.KubeProvider { - grant := configv1alpha3.SourceGrant{ - From: configv1alpha3.GrantFromNames, Names: []configv1alpha3.GrantNamespace{snbTenantNS}, + grant := configv1alpha3.KubeProviderGitTargetGrant{ + ConsumerNamespaces: configv1alpha3.ConsumerNamespaces{ + From: configv1alpha3.GrantFromNames, + Names: []configv1alpha3.GrantNamespace{snbTenantNS}, + }, SourceNamespaces: []configv1alpha3.ScopeNamespace{configv1alpha3.ConsumerNamespaceVariable}, } if wide { @@ -61,7 +64,7 @@ func snbKubeProvider(wide bool) *configv1alpha3.KubeProvider { ObjectMeta: metav1.ObjectMeta{Namespace: snbProviderNS, Name: snbProvider}, Spec: configv1alpha3.KubeProviderSpec{ InCluster: &configv1alpha3.KubeProviderInCluster{}, - AllowedGitTargets: []configv1alpha3.SourceGrant{grant}, + AllowedGitTargets: []configv1alpha3.KubeProviderGitTargetGrant{grant}, }, } } diff --git a/internal/watch/watchrule_compile.go b/internal/watch/watchrule_compile.go index c3e3af57..e4131e66 100644 --- a/internal/watch/watchrule_compile.go +++ b/internal/watch/watchrule_compile.go @@ -225,7 +225,9 @@ type ClusterResourceSelection func(item configv1alpha3.ClusterResourceRule) []sc // checked: by the planner's own matching, versions and ambiguity rule included. Under ["*"] nothing // is refused. func clusterResourcesRefusal( - rule *configv1alpha3.ClusterWatchRule, scope configv1alpha3.SourceScope, selected ClusterResourceSelection, + rule *configv1alpha3.ClusterWatchRule, + scope configv1alpha3.KubeProviderGitTargetScope, + selected ClusterResourceSelection, ) []string { if scope.AllowsAllClusterResources() { return nil diff --git a/internal/webhook/receiver_test.go b/internal/webhook/receiver_test.go index bc7b2d3c..bea30a7e 100644 --- a/internal/webhook/receiver_test.go +++ b/internal/webhook/receiver_test.go @@ -96,8 +96,11 @@ func testProvider(namespace, name, url string) *v1alpha3.GitProvider { ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace}, Spec: v1alpha3.GitProviderSpec{ URL: url, - AllowedGitBranches: []v1alpha3.GitBranchGrant{ - {From: v1alpha3.GrantFromSame, Branches: []v1alpha3.BranchPattern{"*"}}, + AllowedGitBranches: []v1alpha3.GitProviderGitBranchGrant{ + { + ConsumerNamespaces: v1alpha3.ConsumerNamespaces{From: v1alpha3.GrantFromSame}, + BranchPatterns: []v1alpha3.BranchPattern{"*"}, + }, }, }, } diff --git a/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml b/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml index c6c9f6c3..e0ab732c 100644 --- a/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml +++ b/test/e2e/setup/demo-only/podinfos-intent/2-reverse-gitops.yaml @@ -5,8 +5,8 @@ metadata: spec: url: http://gitea-http.gitea-e2e.svc.cluster.local:13000/testorg/demo.git allowedGitBranches: - - from: Same - branches: [main, nl-stuff] + - consumerNamespaces: {from: Same} + branchPatterns: [main, nl-stuff] secretRef: name: git-creds-demo --- @@ -19,7 +19,7 @@ spec: name: demo branch: nl-stuff allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml b/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml index 20f6690a..37c5e600 100644 --- a/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml +++ b/test/e2e/setup/demo-only/voter-gitops/test/coffeeconfig-reverse-gitops.yaml @@ -5,8 +5,8 @@ metadata: spec: url: http://gitea-http.gitea-e2e.svc.cluster.local:13000/testorg/demo.git allowedGitBranches: - - from: Same - branches: [main, demo-test] + - consumerNamespaces: {from: Same} + branchPatterns: [main, demo-test] secretRef: name: git-creds-demo --- @@ -19,7 +19,7 @@ spec: name: demo-coffeeconfig branch: demo-test allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml index d0d76774..d13f74b4 100644 --- a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/gittarget.yaml @@ -8,7 +8,7 @@ spec: name: mirror-repository branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/kubeprovider.yaml b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/kubeprovider.yaml index c8dee31c..72b4ef41 100644 --- a/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/kubeprovider.yaml +++ b/test/fixtures/layout-corpus/shapes/1-flat-serialized/config/kubeprovider.yaml @@ -13,5 +13,5 @@ spec: secretRef: name: prod-kubeconfig allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} sourceNamespaces: ["*"] diff --git a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml index 3f9c19c2..ab04c08f 100644 --- a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml +++ b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget-second-namespace.yaml @@ -8,7 +8,7 @@ spec: name: artifacts-repository branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- # The fence around "one namespace", as a fixture. Same folder and same flag as # gittarget.yaml; what differs is that TWO WatchRules reach it, from two source namespaces. diff --git a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml index 68d69e95..7a1e1cd1 100644 --- a/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/2-flat-namespace-free/config/gittarget.yaml @@ -8,7 +8,7 @@ spec: name: artifacts-repository branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml index d9b0168d..8fc7adaa 100644 --- a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/gittarget.yaml @@ -8,7 +8,7 @@ spec: name: mirror-repository branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/kubeprovider.yaml b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/kubeprovider.yaml index ff81adf8..74fae730 100644 --- a/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/kubeprovider.yaml +++ b/test/fixtures/layout-corpus/shapes/3-tree-serialized/config/kubeprovider.yaml @@ -13,5 +13,5 @@ spec: secretRef: name: home-kubeconfig allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} sourceNamespaces: ["*"] diff --git a/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml index 8f9ac9a6..f26ac2c3 100644 --- a/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/4-tree-namespace-free/config/gittarget.yaml @@ -8,7 +8,7 @@ spec: name: artifacts-repository branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml index 661e9607..4d506746 100644 --- a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml +++ b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget-empty-folder.yaml @@ -8,7 +8,7 @@ spec: name: app-repository branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- # The same folder, when it does not exist yet. This is the ONLY difference # between adopting a kustomize folder and creating one. diff --git a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml index 4c29a550..106579d1 100644 --- a/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/shapes/5-kustomize-single-folder/config/gittarget.yaml @@ -8,7 +8,7 @@ spec: name: app-repository branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml index 3f929ad0..d0eed3cc 100644 --- a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml +++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-app-root.yaml @@ -8,7 +8,7 @@ spec: name: app-repository branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- # The MISTAKE this shape exists to name, kept as a fixture because a set where every write # succeeds is advertising rather than specification. Same repository as gittarget-prod.yaml, diff --git a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml index 6be4b672..bebea87a 100644 --- a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-prod.yaml @@ -8,7 +8,7 @@ spec: name: app-repository branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml index 7f3e4ec6..ef98d210 100644 --- a/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml +++ b/test/fixtures/layout-corpus/shapes/6-kustomize-base-and-overlays/config/gittarget-test.yaml @@ -8,7 +8,7 @@ spec: name: app-repository branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- # The second and third targets are the same object with the environment swapped. # Nothing is shared between them except the byType map, if one were declared. diff --git a/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml index 949472a7..be0f71d4 100644 --- a/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/7-kustomize-layered/config/gittarget-prod.yaml @@ -8,7 +8,7 @@ spec: name: app-repository branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml index 4705c52d..9ae8dfe4 100644 --- a/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml +++ b/test/fixtures/layout-corpus/shapes/8-base-owned-field-edit/config/gittarget-prod.yaml @@ -8,7 +8,7 @@ spec: name: app-repository branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml index f1508243..0b4b0eff 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-argocd/config/gittarget.yaml @@ -8,7 +8,7 @@ spec: name: homelab-repository branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml index 8cc9b085..d8e0b485 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget-media.yaml @@ -8,7 +8,7 @@ spec: name: homelab-repository branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- # The second target. HelmReleases are a different layer from the sources they pull # from, so they get their own folder and their own GitTarget rather than sharing one diff --git a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml index af3398ca..94e34f5f 100644 --- a/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml +++ b/test/fixtures/layout-corpus/specific-examples/homelab-flux/config/gittarget.yaml @@ -8,7 +8,7 @@ spec: name: homelab-repository branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget diff --git a/test/fixtures/layout-corpus/specific-examples/prerequisites/config/gitprovider.yaml b/test/fixtures/layout-corpus/specific-examples/prerequisites/config/gitprovider.yaml index b5aac283..7d32a3e4 100644 --- a/test/fixtures/layout-corpus/specific-examples/prerequisites/config/gitprovider.yaml +++ b/test/fixtures/layout-corpus/specific-examples/prerequisites/config/gitprovider.yaml @@ -10,8 +10,8 @@ spec: knownHostsRef: name: example-com-known-hosts allowedGitBranches: - - from: Same - branches: [main] + - consumerNamespaces: {from: Same} + branchPatterns: [main] commit: committer: name: GitOps Reverser diff --git a/test/playground/config/gitprovider.yaml b/test/playground/config/gitprovider.yaml index 5f536186..ee7cbe1c 100644 --- a/test/playground/config/gitprovider.yaml +++ b/test/playground/config/gitprovider.yaml @@ -6,7 +6,7 @@ metadata: spec: url: http://gitea-http.gitea-e2e.svc.cluster.local:13000/testorg/playground.git allowedGitBranches: - - from: Same - branches: [main] + - consumerNamespaces: {from: Same} + branchPatterns: [main] secretRef: name: git-creds-playground diff --git a/test/playground/config/gittarget.yaml b/test/playground/config/gittarget.yaml index 864484b7..8460d22a 100644 --- a/test/playground/config/gittarget.yaml +++ b/test/playground/config/gittarget.yaml @@ -8,7 +8,7 @@ spec: name: playground-provider branch: main allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget From 7f412064ced968a2a43f3aef9d12b96952cfb25a Mon Sep 17 00:00:00 2001 From: Simon Koudijs Date: Sat, 24 Oct 2026 06:21:12 +0000 Subject: [PATCH 34/34] docs: write every grant in the consumerNamespaces shape, and say what a scope permits The e2e manifests, templates, Helm values and kubectl patches, and every grant in the docs, now use consumerNamespaces and branchPatterns. configuration.md: - the grants section introduces the two halves of a grant (the consumerNamespaces block, and the scope beside it), says that nothing defaults, unlike Gateway API, and lists consumerNamespaces in the field table; - an object's scope is the union across the grants admitting it, and a scope is a permission, not a selection: a request outside the union is refused whole, never narrowed; - the sourceNamespaces "*" row now says "*" is required, in at least one admitting grant, for rules[].sourceNamespace: "*", which is one cluster-wide watch (it said "also allows"); - {consumer.namespace} in a namespace scope names the same-named namespace in the provider's cluster and selects nothing on its own; - the holder-vs-consumer table stays, with a sentence that consumerNamespaces: {from: Same} is the holder's namespace, not the consumer's. definitions.md: the Grant entry names consumerNamespaces and lists branchPatterns among the scope fields; the source-namespace direction is bounded by the union of the admitting grants. UPGRADING.md's grants entry belongs to this same unreleased change, so its examples move to the shape the release ships with. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/UPGRADING.md | 27 +++-- docs/azure-devops-getting-started.md | 4 +- docs/configuration.md | 106 ++++++++++++------ docs/definitions.md | 11 +- docs/demo/commit-window.md | 2 +- test/e2e/applier_claim.go | 8 +- test/e2e/audit_route_attribution_e2e_test.go | 5 +- test/e2e/helm-values-suite-grants.yaml | 4 +- test/e2e/helpers.go | 4 +- test/e2e/new_branch_parent_e2e_test.go | 4 +- test/e2e/provider_access_e2e_test.go | 15 +-- test/e2e/shared_gitbranch_e2e_test.go | 4 +- test/e2e/source_cluster_e2e_test.go | 6 +- test/e2e/templates/gitprovider-signing.tmpl | 4 +- test/e2e/templates/gitprovider.tmpl | 4 +- test/e2e/templates/gittarget.tmpl | 2 +- .../templates/manager/gittarget-prune.tmpl | 2 +- 17 files changed, 126 insertions(+), 86 deletions(-) diff --git a/docs/UPGRADING.md b/docs/UPGRADING.md index e827c9eb..53d7a0ed 100644 --- a/docs/UPGRADING.md +++ b/docs/UPGRADING.md @@ -15,13 +15,14 @@ omitted or empty list admits nobody. | Grant list | Admits | Replaces | |---|---|---| -| `GitProvider.spec.allowedGitBranches` | `GitBranch`es claiming its `branches` patterns | `spec.allowedBranches`, and `spec.accessFrom` | +| `GitProvider.spec.allowedGitBranches` | `GitBranch`es claiming its `branchPatterns` | `spec.allowedBranches`, and `spec.accessFrom` | | `GitBranch.spec.allowedGitTargets` | `GitTarget`s writing through it | the `GitProvider`'s `spec.accessFrom` | | `KubeProvider.spec.allowedGitTargets` | `GitTarget`s mirroring its `sourceNamespaces` and `clusterResources` | `spec.accessFrom`, `spec.allowCrossNamespace.sources` | | `KubeProvider.spec.allowedApplierClaims` | applier claims holding an applier in its `applierNamespaces` | `spec.accessFrom`, `spec.allowCrossNamespace.appliers` | -Every grant has a required `from`: `Same` (the namespace of the object holding the grant), `Names` -with `names`, `Selector` with a non-empty `selector`, or `All`. `spec.accessFrom`, +Every grant has a required `consumerNamespaces` block with a required `from`: `Same` (the namespace +of the object holding the grant), `Names` with `names`, `Selector` with a non-empty `selector`, or +`All`. Beside the block sits the scope the grant gives. `spec.accessFrom`, `spec.allowedBranches`, `spec.allowCrossNamespace` and their chart values are gone, and the API server prunes them. The equivalents of the old defaults: @@ -29,21 +30,21 @@ server prunes them. The equivalents of the old defaults: kind: GitProvider spec: allowedGitBranches: # was allowedBranches: [main], for this namespace's GitBranches - - from: Same - branches: [main] + - consumerNamespaces: {from: Same} + branchPatterns: [main] --- kind: GitBranch spec: allowedGitTargets: # this namespace's GitTargets, as before; add from: Names to share - - from: Same + - consumerNamespaces: {from: Same} --- kind: KubeProvider spec: allowedGitTargets: # was its own namespace, plus accessFrom, without allowCrossNamespace - - from: Same + - consumerNamespaces: {from: Same} sourceNamespaces: ["{consumer.namespace}"] allowedApplierClaims: - - from: Same + - consumerNamespaces: {from: Same} applierNamespaces: ["{consumer.namespace}"] ``` @@ -66,7 +67,8 @@ spec: one type outside them refuses the whole rule (`ClusterResourceNotAllowed`). No default grants any, the chart's included, so a `ClusterWatchRule` through `local` stops mirroring until a grant does. - **A `WatchRule`'s own namespace needs a grant too:** `sourceNamespaces: ["{consumer.namespace}"]`. An - explicit list is the whole scope, and `sourceNamespace: "*"` needs `"*"`. + explicit list is the whole scope, the union across the admitting grants, and + `sourceNamespace: "*"` needs `"*"` in at least one of them: it is refused, never narrowed. - **An applier claim refused by `allowedApplierClaims` hands its applier back**, as one no eligible `GitTarget` references does. A `GitBranch` no grant admits any more gives up its branch after its branch worker pushed or refused what it held, and keeps its finalizer. @@ -197,8 +199,9 @@ spec: secretRef: name: prod-eu-1-kubeconfig # a Secret in this namespace; key defaults to value, then value.yaml allowedGitTargets: # see "Grants say who may use a provider or a branch" - - from: Names - names: [team-a] + - consumerNamespaces: + from: Names + names: [team-a] sourceNamespaces: ["{consumer.namespace}"] --- kind: GitTarget @@ -279,7 +282,7 @@ spec: branch: main # parentBranch: develop # was GitTarget.spec.parentBranch allowedGitTargets: # who may write through it; see the entry on grants - - from: Same + - consumerNamespaces: {from: Same} --- kind: GitTarget metadata: {name: apps, namespace: platform} diff --git a/docs/azure-devops-getting-started.md b/docs/azure-devops-getting-started.md index a44e435a..7a145ac1 100644 --- a/docs/azure-devops-getting-started.md +++ b/docs/azure-devops-getting-started.md @@ -45,8 +45,8 @@ spec: secretRef: name: ado-creds allowedGitBranches: - - from: Same - branches: [] + - consumerNamespaces: {from: Same} + branchPatterns: [] --- apiVersion: configbutler.ai/v1alpha3 kind: GitBranch diff --git a/docs/configuration.md b/docs/configuration.md index 5c34f14a..4e789986 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -217,9 +217,10 @@ spec: url: https://git.example.com/platform/config.git secretRef: {name: repository-auth} allowedGitBranches: - - from: Selector - selector: {matchLabels: {platform.example.com/tenant: "true"}} - branches: ["teams/{consumer.namespace}/*"] + - consumerNamespaces: + from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} + branchPatterns: ["teams/{consumer.namespace}/*"] --- # platform: the cluster connection, and who may mirror which namespaces apiVersion: configbutler.ai/v1alpha3 @@ -228,8 +229,9 @@ metadata: {name: production, namespace: platform} spec: secretRef: {name: production-auth} allowedGitTargets: - - from: Selector - selector: {matchLabels: {platform.example.com/tenant: "true"}} + - consumerNamespaces: + from: Selector + selector: {matchLabels: {platform.example.com/tenant: "true"}} sourceNamespaces: ["{consumer.namespace}"] --- # team-a: the branch it claims, and who may write it @@ -240,7 +242,7 @@ spec: gitProviderRef: {name: shared-repo, namespace: platform} branch: teams/team-a/live allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- # team-a: what to mirror, and where; a WatchRule in team-a selects what apiVersion: configbutler.ai/v1alpha3 @@ -257,7 +259,7 @@ spec: | `team-a`'s `GitBranch` claims `teams/team-a/live` | Allowed | The provider's grant selects `team-a`, and `teams/{consumer.namespace}/*` expands to `teams/team-a/*` | | `team-a`'s `GitBranch` claims `main` or `teams/team-b/live` | Refused, `BranchNotAllowed` | No grant gives `team-a` either branch | | `team-a`'s `GitTarget` writes through `team-a/live` | Allowed | `from: Same` on the `GitBranch` | -| `team-b`'s `GitTarget` writes through `team-a/live` | Refused, until team A adds `from: Names, names: [team-b]` | The branch's owner decides who shares it | +| `team-b`'s `GitTarget` writes through `team-a/live` | Refused, until team A adds a grant with `consumerNamespaces: {from: Names, names: [team-b]}` | The branch's owner decides who shares it | | A `WatchRule` in `team-a` watches `team-a` | Allowed | `sourceNamespaces: ["{consumer.namespace}"]` | | A `WatchRule` in `team-a` watches `kube-system` | Refused, `SourceNamespaceNotAllowed` | The grant's scope is `team-a` only | | A `GitTarget` in `platform` mirrors through `production` | Refused, `NamespaceNotAuthorized` | No grant admits `platform`; its own namespace is not automatic | @@ -269,6 +271,10 @@ Two expressions name a namespace, and they name different ones: | `from: Same` | The object **containing** the grant: for this `GitBranch`, `team-a`; for the providers, `platform` | | `{consumer.namespace}` in a scope | The **consumer**, the object asking for access: for `team-a`'s `GitBranch`, `team-a` | +`consumerNamespaces: {from: Same}` does not mean "the consumer's own namespace". The block says +which namespaces' consumers the grant admits, and `Same` picks the namespace of the object holding +the grant, as Gateway API's `from: Same` means "the same namespace as the Gateway". + **Platform administrators manage provider grants and authorization labels. Branch owners manage writer grants on their branches.** That split is why team A can share its branch without asking the platform. @@ -280,7 +286,7 @@ object that lends something, and is named `allowed` plus the kind that refers to | Grant list | Admits | Scope | |---|---|---| -| `GitProvider.spec.allowedGitBranches` | `GitBranch`es claiming a branch with its credential | `branches` (required): branch patterns | +| `GitProvider.spec.allowedGitBranches` | `GitBranch`es claiming a branch with its credential | `branchPatterns` (required) | | `GitBranch.spec.allowedGitTargets` | `GitTarget`s writing a folder into the branch | none | | `KubeProvider.spec.allowedGitTargets` | `GitTarget`s mirroring with its credential | `sourceNamespaces`, `clusterResources`, or both | | `KubeProvider.spec.allowedApplierClaims` | `FluxClaim`s and `ArgoCDClaim`s holding an applier with its credential | `applierNamespaces` (required) | @@ -290,13 +296,32 @@ object that lends something, and is named `allowed` plus the kind that refers to creating the objects that use it can then carry different RBAC permissions. - **Grants combine by union**, and order never matters. - **An explicit scope is the whole scope**, never "these plus your own": - `applierNamespaces: [flux-system]` means `flux-system` only. + `applierNamespaces: [flux-system]` means `flux-system` only. An object's scope is the union of + the scopes of every grant that admits it. +- **A scope is a permission, not a selection.** `sourceNamespaces` bounds the namespaces a + `WatchRule` may name; listing one watches nothing until a rule names it. A request outside the + union is refused as a whole, never narrowed to fit: a `rules[].sourceNamespace: "*"` item under a + union without `"*"` is refused, not trimmed to the namespaces listed. - **Every grant list is `+listType=atomic`**, so one owner applies it whole. - **References are checked when the referrer reconciles, not at admission.** A refused object is accepted by the API server, reports its reason in status, and recovers on its own once a grant admits it. -Every grant has a required `from`, borrowed from Gateway API's `allowedRoutes.namespaces.from`: +Every grant has two halves: a `consumerNamespaces` block saying which control-cluster namespaces' +consumers it admits, and beside it the scope it gives them. The block is shaped like Gateway API's +`allowedRoutes.namespaces`, with the same `from`: + +```yaml +allowedGitTargets: # on a KubeProvider + - consumerNamespaces: # who: control-cluster namespaces + from: Names + names: [team-a] + sourceNamespaces: ["{consumer.namespace}"] # what: namespaces in this provider's cluster +``` + +Unlike Gateway API, nothing defaults: `consumerNamespaces` is required on every grant, and so is +its `from`. A grant without the block, with `consumerNamespaces: null`, or with +`consumerNamespaces: {}` is refused. | `from` | Admits | Requires | |---|---|---| @@ -314,11 +339,12 @@ follow. Admitting everyone takes `from: All`, a word a reviewer sees: `from: Sel | Field | Omitted | `[]` | A value | `"*"` | |---|---|---|---|---| | A grant list | No access | No access | Grants, combined by union | (not a value) | -| `from` | Refused: required | (not a list) | `Same`, `Names`, `Selector` or `All` | Refused: use `All` | -| `selector` (with `Selector`) | Refused | (not a list) | A label selector on control-cluster Namespaces | `selector: {}` is refused: use `from: All` | -| `names` (with `Names`) | Refused | Refused | Exact control-cluster namespace names; a consumer variable is refused, because `{consumer.namespace}` would admit every consumer (use `from: All`, or `from: Same` for the grant's own namespace) | Refused: use `from: All` | -| `allowedGitBranches[].branches` | Refused: required | Refused | Branch patterns | Every branch | -| `allowedGitTargets[].sourceNamespaces` | No namespace | Refused | Exactly these source-cluster namespaces | Every namespace; also allows `rules[].sourceNamespace: "*"` | +| `consumerNamespaces` | Refused: required | (not a list) | A block with `from` | (not a value) | +| `consumerNamespaces.from` | Refused: required | (not a list) | `Same`, `Names`, `Selector` or `All` | Refused: use `All` | +| `consumerNamespaces.selector` (with `Selector`) | Refused | (not a list) | A label selector on control-cluster Namespaces | `selector: {}` is refused: use `from: All` | +| `consumerNamespaces.names` (with `Names`) | Refused | Refused | Exact control-cluster namespace names; a consumer variable is refused, because `{consumer.namespace}` would admit every consumer (use `from: All`, or `from: Same` for the grant's own namespace) | Refused: use `from: All` | +| `allowedGitBranches[].branchPatterns` | Refused: required | Refused | Branch patterns | Every branch | +| `allowedGitTargets[].sourceNamespaces` | No namespace | Refused | Exactly these source-cluster namespaces, each still named by a `WatchRule` | Every namespace; **required**, in at least one admitting grant, for `rules[].sourceNamespace: "*"`, which is one cluster-wide watch | | `allowedGitTargets[].clusterResources` | No cluster-scoped resource | Refused | Exactly these, by identity | Every cluster-scoped resource, including CRDs installed later | | `allowedApplierClaims[].applierNamespaces` | Refused: required | Refused | Exactly these namespaces | Every namespace | @@ -350,9 +376,11 @@ namespace segment does the bounding. **In `sourceNamespaces` and `applierNamespaces`** an entry is an exact name, `"{consumer.namespace}"` alone, or `"*"` alone. The variable names a namespace **of the same name in -the source cluster**: `sourceNamespaces: ["{consumer.namespace}"]` lets each `GitTarget` mirror the +the provider's cluster**: `sourceNamespaces: ["{consumer.namespace}"]` lets each `GitTarget` mirror the source namespace that shares its own namespace's name. For a `KubeProvider` that reaches another -cluster, that is a namespace over there that happens to share the name. +cluster, that is a namespace over there that happens to share the name. Like every scope entry it +selects nothing on its own: it permits a `WatchRule` to name that one namespace (an omitted +`sourceNamespace` names it too, being the rule's own namespace), or an applier to live there. **In a branch pattern** both variables may appear, each as a whole path segment, and the pattern is matched against the whole branch name: @@ -575,8 +603,8 @@ spec: secretRef: name: git-creds allowedGitBranches: - - from: Same - branches: [main] + - consumerNamespaces: {from: Same} + branchPatterns: [main] ``` ### `GitProvider.spec.secretRef`: the credentials Secret @@ -716,8 +744,8 @@ metadata: spec: url: git@github.com:example-org/example-repo.git allowedGitBranches: - - from: Same - branches: [main] + - consumerNamespaces: {from: Same} + branchPatterns: [main] secretRef: name: git-creds commit: @@ -888,17 +916,20 @@ spec: secretRef: name: prod-eu-1-kubeconfig allowedGitTargets: - - from: Names - names: [team-a] + - consumerNamespaces: + from: Names + names: [team-a] sourceNamespaces: ["{consumer.namespace}"] - - from: Selector - selector: - matchLabels: - gitops.configbutler.ai/source-access: "true" + - consumerNamespaces: + from: Selector + selector: + matchLabels: + gitops.configbutler.ai/source-access: "true" sourceNamespaces: ["{consumer.namespace}"] allowedApplierClaims: - - from: Names - names: [team-a] + - consumerNamespaces: + from: Names + names: [team-a] applierNamespaces: [flux-system] ``` @@ -916,12 +947,12 @@ before it uses it, without dialing the cluster, and reports the verdict as `Vali A `KubeProvider` is used only through its [grants](#the-grants), its own namespace included: -- **`spec.allowedGitTargets`** admits `GitTarget`s to mirror through it. Each grant's - `sourceNamespaces` bound what the `WatchRule`s feeding an admitted target may watch, and its - `clusterResources` what a `ClusterWatchRule` may mirror through it. +- **`spec.allowedGitTargets`** admits `GitTarget`s to mirror through it. The union of the admitting + grants' `sourceNamespaces` bounds which namespaces the `WatchRule`s feeding a target may name, and + the union of their `clusterResources` what a `ClusterWatchRule` may mirror through it. - **`spec.allowedApplierClaims`** admits `FluxClaim`s and `ArgoCDClaim`s to hold an applier through - it, and its `applierNamespaces` bound where that applier may live. A namespace admitted to mirror is - not admitted to apply. + it, and the union of the admitting grants' `applierNamespaces` bounds where that applier may + live. A namespace admitted to mirror is not admitted to apply. Grants are evaluated against namespaces in the **control cluster**, where the referring objects live. In this example, a `GitTarget` in `team-a`, or in a control-cluster namespace with the shown @@ -1108,7 +1139,7 @@ spec: branch: main # a grant on the provider must allow it parentBranch: develop # optional; only used to create a missing branch allowedGitTargets: # who may write through it, this namespace included - - from: Same + - consumerNamespaces: {from: Same} ``` - `spec.gitProviderRef` names the `GitProvider` whose credential writes the branch, in this @@ -2659,9 +2690,10 @@ Every item, the rule's own namespace included, is authorized by the grants of th `KubeProvider`: 1. a grant in its `spec.allowedGitTargets` admits the `GitTarget`'s namespace; and -2. those grants' `sourceNamespaces` hold the namespace the item asks for: its name, +2. the union of those grants' `sourceNamespaces` holds the namespace the item asks for: its name, `"{consumer.namespace}"` for the `GitTarget`'s own (which is the `WatchRule`'s), or `"*"`. An item - asking for `"*"` needs `"*"` there. + asking for `"*"` needs `"*"` in at least one of them; under a union without it the item is + refused, never narrowed to the namespaces listed. There is no third condition on the `GitTarget`. What a target may read from its source cluster is bounded by that cluster's RBAC for the provider's credential: a request the grants permit still diff --git a/docs/definitions.md b/docs/definitions.md index f84f4a00..f1300067 100644 --- a/docs/definitions.md +++ b/docs/definitions.md @@ -27,7 +27,7 @@ rather than per field: | readiness shape (`Ready`, `Reconciling`, `Stalled`, `observedGeneration`) | Flux and kstatus | the tools that read a cluster's health read this shape | | flags and their chart values | Flux | [`config-flag-conventions.md`](config-flag-conventions.md) is built on it | | a field we mirror verbatim | Flux | `meta.KubeConfigReference`, `suspend`, `sops` | -| the source of a grant (`from: Same`, `Names`, `Selector`, `All`) | Gateway API | `allowedRoutes.namespaces.from`; `Names` is ours, a plain list where Gateway API matches a label | +| who a grant admits (`consumerNamespaces`, with `from: Same`, `Names`, `Selector`, `All`) | Gateway API | `allowedRoutes.namespaces` and its `from`; `Names` is ours, a plain list where Gateway API matches a label, and nothing defaults where Gateway API defaults to `Same` | | enum value shape | Kubernetes `core/v1` | Flux is inconsistent here, and `core/v1` is not. See rule 7 | | field, block and timestamp shape | Kubernetes API conventions, then Flux | rules 2 through 4 | @@ -66,8 +66,8 @@ The two directions, which share the word "source" and must not share anything el object that lends something (a `GitProvider`, a `GitBranch`, a `KubeProvider`). It is granted on the referenced object, in a **grant** list, and nothing is automatic, the own namespace included. - **Source namespace** is the source-cluster direction: which source-cluster namespace a rule reads - objects from. `WatchRule.spec.rules[].sourceNamespace`, bounded by the `sourceNamespaces` of the - `KubeProvider` grant that admits the `GitTarget`. + objects from. `WatchRule.spec.rules[].sourceNamespace`, bounded by the union of the + `sourceNamespaces` of the `KubeProvider` grants that admit the `GitTarget`. A sentence about namespace policy names one of those two. Never "namespace access" unqualified. @@ -150,8 +150,9 @@ reports `HeldByAnotherClaim`. **Grant.** One entry in a list named `allowed` plus the kind that refers to it (`GitProvider.spec.allowedGitBranches`, `GitBranch.spec.allowedGitTargets`, -`KubeProvider.spec.allowedGitTargets` and `spec.allowedApplierClaims`). It says where it admits from -(`from`) and, where the object lends reach, the scope it gives (`branches`, `sourceNamespaces`, +`KubeProvider.spec.allowedGitTargets` and `spec.allowedApplierClaims`). It says which +control-cluster namespaces it admits consumers from (`consumerNamespaces`, with its `from`) and, +where the object lends reach, the scope it gives (`branchPatterns`, `sourceNamespaces`, `clusterResources`, `applierNamespaces`). Grants combine by union; an explicit scope is the whole scope. Not "permission" or "policy". diff --git a/docs/demo/commit-window.md b/docs/demo/commit-window.md index c7c58c31..9a0ac35c 100644 --- a/docs/demo/commit-window.md +++ b/docs/demo/commit-window.md @@ -8,7 +8,7 @@ resource types, so saving never commits the save button. **Run the [quickstart](../quickstart.md) first.** It leaves behind everything this example builds on: the `gitops-reverser-quickstart-demo` namespace, the `git-creds` Secret, a `GitProvider` named `example-provider` that lets its own namespace claim any branch (`allowedGitBranches` with -`branches: ["*"]`), and the chart's `local` `KubeProvider` in +`branchPatterns: ["*"]`), and the chart's `local` `KubeProvider` in `gitops-reverser`. If you configured those yourself instead, substitute your own names below. The example writes to its own branch, `commit-window-demo`, which no reconciler deploys. The diff --git a/test/e2e/applier_claim.go b/test/e2e/applier_claim.go index 1374b2a8..42c22ebb 100644 --- a/test/e2e/applier_claim.go +++ b/test/e2e/applier_claim.go @@ -63,7 +63,8 @@ spec: func grantAppliersInAnyNamespace() { GinkgoHelper() _, err := kubectlRunInNamespace(resolveE2ENamespace(), "patch", "kubeprovider", e2eKubeProviderName, - "--type=merge", "-p", `{"spec":{"allowedApplierClaims":[{"from":"All","applierNamespaces":["*"]}]}}`) + "--type=merge", "-p", + `{"spec":{"allowedApplierClaims":[{"consumerNamespaces":{"from":"All"},"applierNamespaces":["*"]}]}}`) Expect(err).NotTo(HaveOccurred(), "failed to let the install KubeProvider's applier claims reach other namespaces") } @@ -74,8 +75,9 @@ func grantAppliersInAnyNamespace() { // manifests need them patched in. On a config-dir install the patch restates what the manifest // already carries. const suiteKubeProviderGrants = `{"spec":{` + - `"allowedGitTargets":[{"from":"All","sourceNamespaces":["{consumer.namespace}"],"clusterResources":["*"]}],` + - `"allowedApplierClaims":[{"from":"All","applierNamespaces":["{consumer.namespace}"]}]}}` + `"allowedGitTargets":[{"consumerNamespaces":{"from":"All"},` + + `"sourceNamespaces":["{consumer.namespace}"],"clusterResources":["*"]}],` + + `"allowedApplierClaims":[{"consumerNamespaces":{"from":"All"},"applierNamespaces":["{consumer.namespace}"]}]}}` // grantSuiteAccess gives the install's KubeProvider the grants the suite needs. A Helm install already // carries them as values, and a patch there would take the fields from Helm, whose next server-side diff --git a/test/e2e/audit_route_attribution_e2e_test.go b/test/e2e/audit_route_attribution_e2e_test.go index afb23f94..2cd252c7 100644 --- a/test/e2e/audit_route_attribution_e2e_test.go +++ b/test/e2e/audit_route_attribution_e2e_test.go @@ -258,9 +258,10 @@ func applyKubeProviderWithAuditRoute( if wide { scope = `["{consumer.namespace}", "*"]` } - extra := fmt.Sprintf("\n allowedGitTargets:\n - from: Same\n sourceNamespaces: %s", scope) + extra := fmt.Sprintf( + "\n allowedGitTargets:\n - consumerNamespaces: {from: Same}\n sourceNamespaces: %s", scope) if len(admits) > 0 { - extra += fmt.Sprintf("\n - from: Names\n names: [%s]\n sourceNamespaces: %s", + extra += fmt.Sprintf("\n - consumerNamespaces: {from: Names, names: [%s]}\n sourceNamespaces: %s", strings.Join(admits, ", "), scope) } if auditRoute != "" { diff --git a/test/e2e/helm-values-suite-grants.yaml b/test/e2e/helm-values-suite-grants.yaml index 14b51093..a542e767 100644 --- a/test/e2e/helm-values-suite-grants.yaml +++ b/test/e2e/helm-values-suite-grants.yaml @@ -5,9 +5,9 @@ # same grants in the suite's setup (grantSuiteAccess); config/kubeprovider-local.yaml carries them. kubeProvider: allowedGitTargets: - - from: All + - consumerNamespaces: {from: All} sourceNamespaces: ["{consumer.namespace}"] clusterResources: ["*"] allowedApplierClaims: - - from: All + - consumerNamespaces: {from: All} applierNamespaces: ["{consumer.namespace}"] diff --git a/test/e2e/helpers.go b/test/e2e/helpers.go index 1ac1bb8a..dcd63a2b 100644 --- a/test/e2e/helpers.go +++ b/test/e2e/helpers.go @@ -243,9 +243,9 @@ spec: // gitBranchWritersYAML is the entries of a GitBranch's allowedGitTargets, each on its own line and // indented under that key: its own namespace, and the namespaces in sharedWith. func gitBranchWritersYAML(sharedWith ...string) string { - writers := "\n - from: Same" + writers := "\n - consumerNamespaces: {from: Same}" if len(sharedWith) > 0 { - writers += fmt.Sprintf("\n - from: Names\n names: [%s]", strings.Join(sharedWith, ", ")) + writers += fmt.Sprintf("\n - consumerNamespaces: {from: Names, names: [%s]}", strings.Join(sharedWith, ", ")) } return writers } diff --git a/test/e2e/new_branch_parent_e2e_test.go b/test/e2e/new_branch_parent_e2e_test.go index 5268fb82..307831ea 100644 --- a/test/e2e/new_branch_parent_e2e_test.go +++ b/test/e2e/new_branch_parent_e2e_test.go @@ -79,8 +79,8 @@ metadata: spec: url: %s allowedGitBranches: - - from: Same - branches: ["main", %q] + - consumerNamespaces: {from: Same} + branchPatterns: ["main", %q] secretRef: name: %s `, providerName, namespace, repo.RepoURLHTTP, writeBranch, repo.GitSecretHTTP) diff --git a/test/e2e/provider_access_e2e_test.go b/test/e2e/provider_access_e2e_test.go index 11ab5649..fec19109 100644 --- a/test/e2e/provider_access_e2e_test.go +++ b/test/e2e/provider_access_e2e_test.go @@ -57,13 +57,14 @@ spec: secretRef: name: %s allowedGitBranches: - - from: Same - branches: [main] - - from: Selector - selector: - matchLabels: - %s: "true" - branches: ["teams/{consumer.namespace}/*"] + - consumerNamespaces: {from: Same} + branchPatterns: [main] + - consumerNamespaces: + from: Selector + selector: + matchLabels: + %s: "true" + branchPatterns: ["teams/{consumer.namespace}/*"] `, providerName, platformNs, repo.RepoURLHTTP, repo.GitSecretHTTP, tenantLabel), "apply", "-f", "-") Expect(err).NotTo(HaveOccurred()) verifyResourceStatus("gitprovider", providerName, platformNs, diff --git a/test/e2e/shared_gitbranch_e2e_test.go b/test/e2e/shared_gitbranch_e2e_test.go index ad69b754..7c7c429b 100644 --- a/test/e2e/shared_gitbranch_e2e_test.go +++ b/test/e2e/shared_gitbranch_e2e_test.go @@ -167,8 +167,8 @@ metadata: spec: url: %s allowedGitBranches: - - from: Same - branches: ["main", "release"] + - consumerNamespaces: {from: Same} + branchPatterns: ["main", "release"] secretRef: name: %s `, name, namespace, url, secret), "apply", "-f", "-") diff --git a/test/e2e/source_cluster_e2e_test.go b/test/e2e/source_cluster_e2e_test.go index e7eb8e93..a1f6c524 100644 --- a/test/e2e/source_cluster_e2e_test.go +++ b/test/e2e/source_cluster_e2e_test.go @@ -172,10 +172,10 @@ func applyKubeProvider(namespace, name, secretName, key string, admits ...string if key != "" { keyLine = "\n key: " + key } - grants := "\n allowedGitTargets:\n - from: Same\n sourceNamespaces: [\"{consumer.namespace}\"]" + grants := "\n allowedGitTargets:\n - consumerNamespaces: {from: Same}\n sourceNamespaces: [\"{consumer.namespace}\"]" if len(admits) > 0 { grants += fmt.Sprintf( - "\n - from: Names\n names: [%s]\n sourceNamespaces: [\"{consumer.namespace}\"]", + "\n - consumerNamespaces: {from: Names, names: [%s]}\n sourceNamespaces: [\"{consumer.namespace}\"]", strings.Join(admits, ", "), ) } @@ -439,7 +439,7 @@ metadata: {name: %s, namespace: %s} spec: inCluster: {} allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} sourceNamespaces: ["{consumer.namespace}"] `, name, testNs), "apply", "-f", "-") Expect(err).NotTo(HaveOccurred()) diff --git a/test/e2e/templates/gitprovider-signing.tmpl b/test/e2e/templates/gitprovider-signing.tmpl index a7cd732c..a4a3c7f1 100644 --- a/test/e2e/templates/gitprovider-signing.tmpl +++ b/test/e2e/templates/gitprovider-signing.tmpl @@ -6,8 +6,8 @@ metadata: spec: url: {{.RepoURL}} allowedGitBranches: - - from: Same - branches: ["{{.Branch}}"] + - consumerNamespaces: {from: Same} + branchPatterns: ["{{.Branch}}"] secretRef: name: {{.SecretName}} commit: diff --git a/test/e2e/templates/gitprovider.tmpl b/test/e2e/templates/gitprovider.tmpl index 6da30c28..fd395168 100644 --- a/test/e2e/templates/gitprovider.tmpl +++ b/test/e2e/templates/gitprovider.tmpl @@ -6,7 +6,7 @@ metadata: spec: url: {{.RepoURL}} allowedGitBranches: - - from: Same - branches: ["{{.Branch}}"] + - consumerNamespaces: {from: Same} + branchPatterns: ["{{.Branch}}"] secretRef: name: {{.SecretName}} diff --git a/test/e2e/templates/gittarget.tmpl b/test/e2e/templates/gittarget.tmpl index bc722131..b96a02df 100644 --- a/test/e2e/templates/gittarget.tmpl +++ b/test/e2e/templates/gittarget.tmpl @@ -10,7 +10,7 @@ spec: name: {{ .ProviderName }} branch: {{ .Branch }} allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} {{- if .ParentBranch }} parentBranch: {{ .ParentBranch }} {{- end }} diff --git a/test/e2e/templates/manager/gittarget-prune.tmpl b/test/e2e/templates/manager/gittarget-prune.tmpl index 265d11ff..6313ecd7 100644 --- a/test/e2e/templates/manager/gittarget-prune.tmpl +++ b/test/e2e/templates/manager/gittarget-prune.tmpl @@ -10,7 +10,7 @@ spec: name: {{ .ProviderName }} branch: {{ .Branch }} allowedGitTargets: - - from: Same + - consumerNamespaces: {from: Same} --- apiVersion: configbutler.ai/v1alpha3 kind: GitTarget