-
Notifications
You must be signed in to change notification settings - Fork 4
fix(github-config): constrain team management #2718
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
devantler
merged 13 commits into
main
from
codex/propose-fix-for-github-team-management-vulnerability
Aug 18, 2026
Merged
Changes from 12 commits
Commits
Show all changes
13 commits
Select commit
Hold shift + click to select a range
c8a6040
fix(github-config): constrain team management
devantler 6b5eee9
Merge branch 'main' into codex/propose-fix-for-github-team-management…
devantler 73032ca
Merge branch 'main' into codex/propose-fix-for-github-team-management…
devantler 2b57d41
fix(cluster-policies): make the GitHub team guard match, and bind tea…
devantler e1bd002
fix(cluster-policies): scope the external-name rule with match operat…
devantler 5186097
fix(cluster-policies): close the initProvider bypass; drop the extern…
devantler 426766d
fix(cluster-policies): allow-list team repository permissions
devantler 011e8b4
Merge remote-tracking branch 'origin/main' into codex/propose-fix-for…
devantler 07a5343
fix(cluster-policies): require teamIdRef resolve Always instead of de…
devantler fcd8058
fix(cluster-policies): allow-list the teams that exist, not a team th…
devantler e70024a
fix(cluster-policies): bind the repository privilege ceiling to the team
devantler f8311b5
fix(cluster-policies): resolve the ceiling rule's team through initPr…
devantler c8c206b
docs(tests): describe the per-team ceiling accurately in fixture comm…
devantler File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
256 changes: 256 additions & 0 deletions
256
...bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,256 @@ | ||
| # Constrains the high-impact GitHub team managed resources delegated to the | ||
| # github-config tenant. The tenant's Flux Kustomization applies a separately | ||
| # published OCI artifact as the github-config ServiceAccount, and those | ||
| # Crossplane resources reconcile with an org-admin GitHub App. Namespace RBAC can | ||
| # limit the API group, but it cannot restrict resource spec fields such as the | ||
| # team, member, repository, role, or permission. This admission guard keeps that | ||
| # delegation scoped to the intended CODEOWNERS teams and blocks direct numeric | ||
| # team IDs/selectors that would bypass the allow-list enforced through | ||
| # Crossplane references. | ||
| apiVersion: kyverno.io/v1 | ||
| kind: ClusterPolicy | ||
| metadata: | ||
| name: restrict-github-team-management | ||
| annotations: | ||
| policies.kyverno.io/title: Restrict GitHub Team Management | ||
| policies.kyverno.io/category: Security, GitHub | ||
| policies.kyverno.io/severity: high | ||
| policies.kyverno.io/subject: Team, TeamMembership, TeamRepository | ||
| policies.kyverno.io/minversion: 1.6.0 | ||
| policies.kyverno.io/description: >- | ||
| Restricts provider-upjet-github team resources in the github-config | ||
| namespace to the github-config CODEOWNERS teams, requires TeamMembership and | ||
| TeamRepository resources to reference those Team objects by name, and | ||
| blocks repository admin grants. This prevents a compromised or unintended | ||
| github-config artifact from adding arbitrary users to arbitrary GitHub | ||
| teams or granting privileged repository access. | ||
| spec: | ||
| validationFailureAction: Enforce | ||
| background: true | ||
| rules: | ||
| - name: teams-allow-listed | ||
| match: | ||
| any: | ||
| - resources: | ||
| kinds: | ||
| - team.github.m.upbound.io/*/Team | ||
| namespaces: | ||
| - github-config | ||
| validate: | ||
| message: GitHub Team resources managed by github-config must be one of admins or maintainers. | ||
| deny: | ||
| conditions: | ||
| all: | ||
| - key: "{{ request.object.metadata.name }}" | ||
|
devantler marked this conversation as resolved.
devantler marked this conversation as resolved.
|
||
| operator: AnyNotIn | ||
| value: | ||
| - admins | ||
| - maintainers | ||
| # The allow-list above constrains the Kubernetes object name, but the | ||
| # provider takes the real GitHub team from spec.forProvider.name (and | ||
| # spec.initProvider.name). Without this rule a Team object named "admins" | ||
| # may carry forProvider.name: some-other-team, so the reference rules below | ||
| # accept teamIdRef.name: admins while the org-admin provider reconciles a | ||
| # different remote team. Binding the provider identity to the object name is | ||
| # what makes the object name a faithful proxy for the remote team, which is | ||
| # the assumption the two reference rules rest on. Both fields are authored | ||
| # by the tenant and never rewritten by the provider, so pinning them carries | ||
| # no reconciliation risk. | ||
| - name: teams-bind-provider-identity-to-object-name | ||
| match: | ||
| any: | ||
| - resources: | ||
| kinds: | ||
| - team.github.m.upbound.io/*/Team | ||
| namespaces: | ||
| - github-config | ||
| validate: | ||
| message: GitHub Team spec.forProvider.name and spec.initProvider.name must be omitted or equal to metadata.name, so the allow-listed object name is the team actually reconciled. | ||
| deny: | ||
| conditions: | ||
| any: | ||
| - key: "{{ to_lower(request.object.spec.forProvider.name || '') }}" | ||
| operator: AnyNotIn | ||
| value: | ||
| - "" | ||
| - "{{ to_lower(request.object.metadata.name) }}" | ||
| - key: "{{ to_lower(request.object.spec.initProvider.name || '') }}" | ||
| operator: AnyNotIn | ||
| value: | ||
| - "" | ||
| - "{{ to_lower(request.object.metadata.name) }}" | ||
| # crossplane.io/external-name is the other way the local name can be | ||
| # detached from the remote team, and it is NOT constrained here. A rule | ||
| # accepting only an empty annotation or metadata.name looks like the | ||
| # obvious counterpart to the binding above, but it breaks legitimate | ||
| # re-adoption: after a rebuild, or whenever a Team CR is recreated, the | ||
| # existing GitHub team must be adopted by the numeric team ID the provider | ||
| # itself writes into this annotation — and refusing that makes Crossplane | ||
| # try to create a team that already exists, so github-config cannot recover | ||
| # its own teams. Constraining it safely needs a way to tell our teams' | ||
| # numeric identities from a foreign one, plus the provider ServiceAccount | ||
| # excluded so its post-create write still succeeds. Tracked in #3144 rather | ||
| # than half-closed here. | ||
| - name: teammemberships-reference-allow-listed-teams | ||
| match: | ||
| any: | ||
| - resources: | ||
| kinds: | ||
| - team.github.m.upbound.io/*/TeamMembership | ||
| namespaces: | ||
| - github-config | ||
| validate: | ||
| message: GitHub TeamMembership resources must set spec.forProvider.teamIdRef.name to admins or maintainers with policy.resolve Always, so the reference re-resolves on every reconcile; selectors are not allowed, and initProvider must not carry a teamId, selector or non-allow-listed reference. | ||
| deny: | ||
| conditions: | ||
| any: | ||
| # #3195: requiring the reference to re-resolve on every reconcile is what | ||
| # makes a directly-authored numeric ID inert, so no forProvider.teamId deny | ||
| # is needed -- and none can work. The provider's own reference resolver | ||
| # writes the resolved ID into that field, so a deny on it fires on the | ||
| # controller's write and deadlocks the resource. Measured live: all 39 | ||
| # TeamMembership/TeamRepository resources carry a populated teamId. | ||
| - key: "{{ request.object.spec.forProvider.teamIdRef.policy.resolve || '' }}" | ||
| operator: NotEquals | ||
| value: Always | ||
| - key: "{{ request.object.spec.forProvider.teamIdSelector || `{}` }}" | ||
| operator: NotEquals | ||
| value: {} | ||
| - key: "{{ request.object.spec.forProvider.teamIdRef.name || '' }}" | ||
| operator: AnyNotIn | ||
| value: | ||
| - admins | ||
| - maintainers | ||
| # upjet merges initProvider into any unset forProvider field, so | ||
| # every constraint above has to hold there too — otherwise an | ||
| # approved forProvider reference fronts a foreign team supplied | ||
| # under initProvider. | ||
| - key: "{{ request.object.spec.initProvider.teamId || '' }}" | ||
| operator: NotEquals | ||
| value: "" | ||
| - key: "{{ request.object.spec.initProvider.teamIdSelector || `{}` }}" | ||
| operator: NotEquals | ||
| value: {} | ||
| - key: "{{ request.object.spec.initProvider.teamIdRef.name || '' }}" | ||
| operator: AnyNotIn | ||
| value: | ||
| - "" | ||
| - admins | ||
| - maintainers | ||
| - name: teamrepositories-reference-allow-listed-teams | ||
| match: | ||
| any: | ||
| - resources: | ||
| kinds: | ||
| - team.github.m.upbound.io/*/TeamRepository | ||
| namespaces: | ||
| - github-config | ||
| validate: | ||
| message: GitHub TeamRepository resources must reference admins or maintainers by spec.forProvider.teamIdRef.name with policy.resolve Always, and may grant only pull, triage or push permission, in forProvider and initProvider. | ||
| deny: | ||
| conditions: | ||
| any: | ||
| # #3195: requiring the reference to re-resolve on every reconcile is what | ||
| # makes a directly-authored numeric ID inert, so no forProvider.teamId deny | ||
| # is needed -- and none can work. The provider's own reference resolver | ||
| # writes the resolved ID into that field, so a deny on it fires on the | ||
| # controller's write and deadlocks the resource. Measured live: all 39 | ||
| # TeamMembership/TeamRepository resources carry a populated teamId. | ||
| - key: "{{ request.object.spec.forProvider.teamIdRef.policy.resolve || '' }}" | ||
| operator: NotEquals | ||
| value: Always | ||
| - key: "{{ request.object.spec.forProvider.teamIdSelector || `{}` }}" | ||
| operator: NotEquals | ||
| value: {} | ||
| - key: "{{ request.object.spec.forProvider.teamIdRef.name || '' }}" | ||
| operator: AnyNotIn | ||
| value: | ||
| - admins | ||
| - maintainers | ||
|
devantler marked this conversation as resolved.
|
||
| # An allow-list, not a deny-list on `admin`: GitHub also offers | ||
| # `maintain` and organization-defined custom repository roles, so | ||
| # denying only `admin` fails open the moment the tenant picks | ||
| # another privileged role. An empty value is the provider default | ||
| # (`pull`). | ||
| - key: "{{ request.object.spec.forProvider.permission || '' }}" | ||
| operator: AnyNotIn | ||
| value: | ||
| - "" | ||
| - pull | ||
| - triage | ||
| - push | ||
| - maintain | ||
| - admin | ||
| # Same initProvider merge as above: an approved forProvider | ||
| # reference must not be able to front a foreign team or an admin | ||
| # grant supplied under initProvider. | ||
| - key: "{{ request.object.spec.initProvider.teamId || '' }}" | ||
| operator: NotEquals | ||
| value: "" | ||
| - key: "{{ request.object.spec.initProvider.teamIdSelector || `{}` }}" | ||
| operator: NotEquals | ||
| value: {} | ||
| - key: "{{ request.object.spec.initProvider.teamIdRef.name || '' }}" | ||
| operator: AnyNotIn | ||
| value: | ||
| - "" | ||
| - admins | ||
| - maintainers | ||
| - key: "{{ request.object.spec.initProvider.permission || '' }}" | ||
| operator: AnyNotIn | ||
| value: | ||
| - "" | ||
| - pull | ||
| - triage | ||
| - push | ||
| - maintain | ||
| - admin | ||
| # #3208: the privilege ceiling is per-team, not flat. A single cap cannot express the | ||
| # intent — capping everything at `push` denies all 37 live grants, while widening it to | ||
| # `admin` lets the maintainers team be granted `admin` and makes the rule vacuous. | ||
| # Measured live, the correlation is exact and has no exceptions: `admins` holds `admin` | ||
| # on 22 repositories and `maintainers` holds `maintain` on 15, with zero | ||
| # `maintainers`+`admin` and zero `admins`+non-`admin`. Binding the ceiling to the | ||
| # referenced team admits all 37 live grants while still denying the escalation a | ||
| # compromised artifact would actually want: `admin` through the lower-privileged team. | ||
| # The reference rule above already guarantees the team is one of the two allow-listed | ||
| # names, so this precondition is exhaustive for anything that rule admits. The flat | ||
| # list above remains the known-roles guard against org-defined custom roles. | ||
| - name: teamrepositories-maintainers-capped-below-admin | ||
| match: | ||
| any: | ||
| - resources: | ||
| kinds: | ||
| - team.github.m.upbound.io/*/TeamRepository | ||
| namespaces: | ||
| - github-config | ||
| preconditions: | ||
| all: | ||
| # upjet merges initProvider into any UNSET forProvider field, so the team | ||
| # actually reconciled is forProvider's when set and initProvider's otherwise. | ||
| # Keying this precondition on forProvider alone let an initProvider-only | ||
| # maintainers reference skip the rule entirely and take admin. | ||
| - key: "{{ request.object.spec.forProvider.teamIdRef.name || request.object.spec.initProvider.teamIdRef.name || '' }}" | ||
| operator: Equals | ||
| value: maintainers | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| validate: | ||
| message: GitHub TeamRepository resources referencing the maintainers team may grant at most maintain; admin is reserved to the admins team. | ||
| deny: | ||
| conditions: | ||
| any: | ||
| - key: "{{ request.object.spec.forProvider.permission || '' }}" | ||
| operator: AnyNotIn | ||
| value: | ||
| - "" | ||
| - pull | ||
| - triage | ||
| - push | ||
| - maintain | ||
| - key: "{{ request.object.spec.initProvider.permission || '' }}" | ||
| operator: AnyNotIn | ||
| value: | ||
| - "" | ||
| - pull | ||
| - triage | ||
| - push | ||
| - maintain | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
25 changes: 25 additions & 0 deletions
25
tests/restrict-github-team-management/forprovider-mismatch/kyverno-test.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| --- | ||
| apiVersion: cli.kyverno.io/v1alpha1 | ||
| kind: Test | ||
| metadata: | ||
| name: restrict-github-team-management-forprovider-mismatch | ||
| policies: | ||
| - >- | ||
| ../../../k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml | ||
| resources: | ||
| - resources.yaml | ||
| results: | ||
| # The object name is allow-listed, so that rule cannot catch this. | ||
| - policy: restrict-github-team-management | ||
| rule: teams-allow-listed | ||
| resources: | ||
| - github-config/admins | ||
| kind: Team | ||
| result: pass | ||
| # Only the identity binding catches it. | ||
| - policy: restrict-github-team-management | ||
| rule: teams-bind-provider-identity-to-object-name | ||
| resources: | ||
| - github-config/admins | ||
| kind: Team | ||
| result: fail |
15 changes: 15 additions & 0 deletions
15
tests/restrict-github-team-management/forprovider-mismatch/resources.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| --- | ||
| # THE ESCALATION THIS RULE EXISTS TO STOP. metadata.name passes the allow-list, | ||
| # so a TeamMembership with teamIdRef.name: admins resolves to this object — | ||
| # while the org-admin provider reconciles the unrelated team named here. | ||
| # Its own fixture directory because it must reuse the allow-listed object name | ||
| # that the paved-road fixture already occupies. | ||
| apiVersion: team.github.m.upbound.io/v1beta1 | ||
| kind: Team | ||
| metadata: | ||
| name: admins | ||
| namespace: github-config | ||
| spec: | ||
| forProvider: | ||
| name: some-other-team | ||
| privacy: closed |
23 changes: 23 additions & 0 deletions
23
tests/restrict-github-team-management/initprovider-mismatch/kyverno-test.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| --- | ||
| apiVersion: cli.kyverno.io/v1alpha1 | ||
| kind: Test | ||
| metadata: | ||
| name: restrict-github-team-management-initprovider-mismatch | ||
| policies: | ||
| - >- | ||
| ../../../k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml | ||
| resources: | ||
| - resources.yaml | ||
| results: | ||
| - policy: restrict-github-team-management | ||
| rule: teams-allow-listed | ||
| resources: | ||
| - github-config/admins | ||
| kind: Team | ||
| result: pass | ||
| - policy: restrict-github-team-management | ||
| rule: teams-bind-provider-identity-to-object-name | ||
| resources: | ||
| - github-config/admins | ||
| kind: Team | ||
| result: fail |
11 changes: 11 additions & 0 deletions
11
tests/restrict-github-team-management/initprovider-mismatch/resources.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| --- | ||
| # The same detachment expressed through initProvider, which upjet merges into | ||
| # forProvider when the field is unset there. | ||
| apiVersion: team.github.m.upbound.io/v1beta1 | ||
| kind: Team | ||
| metadata: | ||
| name: admins | ||
| namespace: github-config | ||
| spec: | ||
| initProvider: | ||
| name: some-other-team |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.