Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
464bcd9
fix(github-config): pin a team's GitHub identity for its whole lifecycle
devantler Oct 5, 2026
8e1d8c4
fix(github-config): compare the team identity literally
devantler Oct 5, 2026
3db86e1
docs(github-config): correct the operator note and pin the no-expansi…
devantler Oct 5, 2026
090ce5a
fix(github-config): refuse a managed team that names a parent team or…
devantler Oct 5, 2026
2997612
fix(github-config): refuse non-text parent and directory values in th…
devantler Oct 5, 2026
4240280
fix(github-config): bind a membership's or grant's stored identity to…
devantler Oct 5, 2026
cb3c901
fix(github-config): refuse a stored identity next to an initProvider …
devantler Oct 5, 2026
c0f4d02
docs(github-config): say the initProvider value is not compared
devantler Oct 5, 2026
a115349
Merge remote-tracking branch 'origin/claude/team-nesting-guard-4511' …
devantler Oct 5, 2026
77707f8
fix(github-config): compare tenant-written text literally in the team…
devantler Oct 5, 2026
a901a6c
fix(github-config): say the namespace limit in the deny messages and …
devantler Oct 5, 2026
55a2f03
fix(github-config): name the permissions the grant rule really admits
devantler Oct 5, 2026
c27f534
test(github-config): cover initProvider role question wildcard
devantler Oct 5, 2026
665c78f
fix(github-config): compose team nesting guard with identity enforcement
devantler Oct 5, 2026
154f5d7
fix(github-config): integrate literal team checks with current enforc…
devantler Oct 5, 2026
ada9540
chore: integrate reviewed team security foundations
devantler Oct 5, 2026
a88c4f6
ci: re-run code scanning after the cancelled analysis
devantler Oct 5, 2026
53ebbbf
Merge remote-tracking branch 'origin/main' into claude/team-grant-ide…
devantler Oct 6, 2026
413b8b2
fix(github-config): refuse removing a stored identity while retargeti…
devantler Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,17 @@ metadata:
policies.kyverno.io/title: Restrict GitHub Team External Identity
policies.kyverno.io/category: Security, GitHub
policies.kyverno.io/severity: high
policies.kyverno.io/subject: Team
policies.kyverno.io/subject: Team, TeamMembership, TeamRepository
policies.kyverno.io/minversion: 1.13.0
policies.kyverno.io/description: >-
Refuses a github-config Team whose crossplane.io/external-name is set or
changed to anything other than the approved numeric identity of that team,
so an allow-listed Team object cannot be pointed at a different GitHub
team after it was admitted. The provider's own record of a team it
created is exempt.
team after it was admitted, and a TeamMembership or TeamRepository whose
crossplane.io/external-name disagrees with the team and the user or
repository the object declares, so a compliant-looking membership or
grant cannot stand for another one. The provider's own records are
exempt.
spec:
background: false
rules:
Expand Down Expand Up @@ -117,3 +120,172 @@ spec:
- key: "{{ externalName == '' || externalName == previousExternalName || contains(['admins/18573307', 'maintainers/17046606'], join('/', [request.object.metadata.name, externalName])) }}"
operator: Equals
value: false
# A membership or repository grant has a stored identity of its own:
# "<team id>:<username>" or "<team id>:<repository>" (#4518). Read from the
# released sources of the provider version prod runs (provider-upjet-github
# v0.20.0, terraform-provider-github v6.13.0, upjet v2.2.0):
#
# - a repository grant is read, updated and removed by that stored identity
# alone, so a grant object that satisfies every rule in
# restrict-github-team-management could change or remove the access of a
# different team than the one it references;
# - a membership refuses an update when its stored identity names another
# team or user, but its removal acts on what was read through that
# identity.
#
# So the stored identity has to agree with what the object itself declares.
# From anyone but the provider it may be absent, exactly what the object
# already carries while it keeps referencing the same team and the same
# repository or user, or the approved identity of the referenced team
# followed by the declared repository or user. The two team identities are
# the ones the Team rule approves, repeated here for the reason given at the
# top of this file: a change of one is a change of all three rules.
#
# "Unchanged" covers the team reference and the repository or user as well
# as the identity, so an object cannot keep an identity while being
# re-declared to stand for something else. The provider records identities
# in exactly the approved shape (measured on prod 2026-10-05: all 49
# memberships and grants), so the artifact's routine re-apply passes either
# way.
- name: teamrepositories-external-name-matches-declared-grant
match:
any:
- resources:
kinds:
- team.github.m.upbound.io/*/TeamRepository
operations:
- CREATE
- UPDATE
namespaces:
- github-config
exclude:
any:
- subjects:
- kind: ServiceAccount
name: provider-upjet-github-*
namespace: crossplane-system
context:
- name: externalName
variable:
jmesPath: request.object.metadata.annotations."crossplane.io/external-name"
default: ""
- name: teamRef
variable:
jmesPath: request.object.spec.forProvider.teamIdRef.name
default: ""
- name: subject
variable:
jmesPath: request.object.spec.forProvider.repository
default: ""
- name: initSubject
variable:
jmesPath: request.object.spec.initProvider.repository
default: ""
- name: previousExternalName
variable:
jmesPath: request.oldObject.metadata.annotations."crossplane.io/external-name"
default: ""
- name: previousTeamRef
variable:
jmesPath: request.oldObject.spec.forProvider.teamIdRef.name
default: ""
- name: previousSubject
variable:
jmesPath: request.oldObject.spec.forProvider.repository
default: ""
validate:
failureAction: Enforce
# As on the Team rule: an object that already fails must not be waved
# through on update.
allowExistingViolations: false
message: >-
The crossplane.io/external-name of a github-config TeamRepository may
only be left unset, removed or left unchanged while its team reference
and repository stay as they were, or set to the approved numeric identity of the referenced
team followed by a colon and spec.forProvider.repository. A stored
identity cannot be combined with spec.initProvider.repository. To point an
object at another team or repository, delete it and create it again.
deny:
conditions:
all:
# One literal JMESPath expression, for the reason given on the
# Team rule. The team reference is compared on its own and the
# team identity is a literal prefix, so no value can borrow a
# separator to look like another pair. An empty repository is never an
# approved identity, and one stated under initProvider is not
# compared here, so it may not accompany a stored identity at all
# (no object on prod states one: measured 2026-10-05).
- key: "{{ (externalName == '' && (previousExternalName == '' || (teamRef == previousTeamRef && subject == previousSubject))) || (initSubject == '' && ((externalName == previousExternalName && teamRef == previousTeamRef && subject == previousSubject) || (subject != '' && ((teamRef == 'admins' && externalName == join('', ['18573307:', subject])) || (teamRef == 'maintainers' && externalName == join('', ['17046606:', subject])))))) }}"
operator: Equals
value: false
- name: teammemberships-external-name-matches-declared-member
match:
any:
- resources:
kinds:
- team.github.m.upbound.io/*/TeamMembership
operations:
- CREATE
- UPDATE
namespaces:
- github-config
exclude:
any:
- subjects:
- kind: ServiceAccount
name: provider-upjet-github-*
namespace: crossplane-system
context:
- name: externalName
variable:
jmesPath: request.object.metadata.annotations."crossplane.io/external-name"
default: ""
- name: teamRef
variable:
jmesPath: request.object.spec.forProvider.teamIdRef.name
default: ""
- name: subject
variable:
jmesPath: request.object.spec.forProvider.username
default: ""
- name: initSubject
variable:
jmesPath: request.object.spec.initProvider.username
default: ""
- name: previousExternalName
variable:
jmesPath: request.oldObject.metadata.annotations."crossplane.io/external-name"
default: ""
- name: previousTeamRef
variable:
jmesPath: request.oldObject.spec.forProvider.teamIdRef.name
default: ""
- name: previousSubject
variable:
jmesPath: request.oldObject.spec.forProvider.username
default: ""
validate:
failureAction: Enforce
# As on the Team rule: an object that already fails must not be waved
# through on update.
allowExistingViolations: false
message: >-
The crossplane.io/external-name of a github-config TeamMembership may
only be left unset, removed or left unchanged while its team reference
and username stay as they were, or set to the approved numeric identity of the referenced
team followed by a colon and spec.forProvider.username. A stored
identity cannot be combined with spec.initProvider.username. To point an
object at another team or username, delete it and create it again.
deny:
conditions:
all:
# One literal JMESPath expression, for the reason given on the
# Team rule. The team reference is compared on its own and the
# team identity is a literal prefix, so no value can borrow a
# separator to look like another pair. An empty username is never an
# approved identity, and one stated under initProvider is not
# compared here, so it may not accompany a stored identity at all
# (no object on prod states one: measured 2026-10-05).
- key: "{{ (externalName == '' && (previousExternalName == '' || (teamRef == previousTeamRef && subject == previousSubject))) || (initSubject == '' && ((externalName == previousExternalName && teamRef == previousTeamRef && subject == previousSubject) || (subject != '' && ((teamRef == 'admins' && externalName == join('', ['18573307:', subject])) || (teamRef == 'maintainers' && externalName == join('', ['17046606:', subject])))))) }}"
operator: Equals
value: false
2 changes: 2 additions & 0 deletions tests/policy-failure-actions.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
"audit-privileged-rbac/privileged-rules": "Enforce",
"disallow-latest-tag/require-image-tag": "Audit",
"disallow-latest-tag/validate-image-tag": "Audit",
"restrict-github-team-external-identity/teammemberships-external-name-matches-declared-member": "Enforce",
"restrict-github-team-external-identity/teamrepositories-external-name-matches-declared-grant": "Enforce",
"restrict-github-team-external-identity/teams-external-name-is-an-approved-identity": "Enforce",
"restrict-github-team-management/teams-allow-listed": "Enforce",
"restrict-github-team-management/teams-bind-provider-identity-to-object-name": "Enforce",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
apiVersion: cli.kyverno.io/v1alpha1
kind: Test
metadata:
name: restrict-github-team-external-identity-grants-create
policies:
- >-
../../../k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-external-identity.yaml
resources:
- resources.yaml
variables: values.yaml
userinfo: user-info.yaml
results:
- policy: restrict-github-team-external-identity
rule: teamrepositories-external-name-matches-declared-grant
resources:
- github-config/admins-approved
- github-config/maintainers-approved
- github-config/unset
kind: TeamRepository
result: pass
- policy: restrict-github-team-external-identity
rule: teamrepositories-external-name-matches-declared-grant
resources:
- github-config/swapped-team
- github-config/swapped-team-reverse
- github-config/other-subject
- github-config/foreign-team
- github-config/slug-identity
- github-config/wildcard
- github-config/wildcard-subject
- github-config/longer-subject
- github-config/undeclared-subject
- github-config/init-provider-subject
- github-config/init-provider-second-subject
kind: TeamRepository
result: fail
Loading
Loading