From 090ce5ae2c06c9d27854abdd1c3a0c99fd4d3772 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 5 Oct 2026 11:20:21 +0200 Subject: [PATCH 1/9] fix(github-config): refuse a managed team that names a parent team or directory group A nested team inherits its parent's repository access on GitHub, outside the grant rules that cap what the github-config release may hand out, and a directory link moves membership outside the member allow-list. The Team rules checked neither. Refuse all four fields the provider exposes, in forProvider and initProvider, with one literal JMESPath comparison. Fixes #4511 Co-Authored-By: Claude Opus 5.5 --- .../restrict-github-team-management.yaml | 52 ++++++- tests/policy-failure-actions.json | 1 + .../kyverno-test.yaml | 11 ++ .../kyverno-test.yaml | 33 +++++ .../nested-or-directory-linked/resources.yaml | 129 ++++++++++++++++++ 5 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 tests/restrict-github-team-management/nested-or-directory-linked/kyverno-test.yaml create mode 100644 tests/restrict-github-team-management/nested-or-directory-linked/resources.yaml diff --git a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml index c35becf3cb..5cfae421ba 100644 --- a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml @@ -21,7 +21,8 @@ metadata: 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 + blocks repository admin grants. A Team may not be nested under a parent + team or linked to a directory group. This prevents a compromised or unintended github-config artifact from adding arbitrary users to arbitrary GitHub teams or granting privileged repository access. spec: @@ -92,6 +93,55 @@ spec: # 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. + # + # #4511: a nested team inherits every repository grant of its parent, and + # that inheritance happens on GitHub — it never passes through the + # TeamRepository rules below, which are what cap this delegation's grants. A + # Team that named a parent would hand its members the parent's access with no + # grant for those rules to judge. The directory link is refused for the same + # reason from the other side: it lets a group outside this cluster decide who + # the members are, past the member allow-list. + # + # All four fields the provider exposes are covered, in both blocks. + # parentTeamReadId and parentTeamReadSlug are documented as read-back values, + # but the schema accepts them as input, so they are refused rather than + # trusted to stay inert. An empty string is what the provider reports for + # "none" and is accepted. + # + # Measured live: neither team sets any of them, and the provider reports all + # four empty for both. The provider is NOT exempt. If a team were nested on + # GitHub by hand, the provider would try to record the parent on the admins + # object, be refused, and report the object out of sync — which is the + # signal wanted for a nesting nobody reviewed. + - name: teams-not-nested-or-directory-linked + match: + any: + - resources: + kinds: + - team.github.m.upbound.io/*/Team + namespaces: + - github-config + validate: + failureAction: Enforce + # Kyverno admits an update by default when the object already failed the + # rule before it. No object fails it today, and one that somehow did + # must not become freely editable for that reason. + allowExistingViolations: false + message: >- + A github-config Team must not set parentTeamId, parentTeamReadId, + parentTeamReadSlug or ldapDn, in forProvider or initProvider. Nesting a + team or linking it to a directory group is a reviewed change to + restrict-github-team-management.yaml. + deny: + conditions: + all: + # ONE comparison evaluated by JMESPath rather than eight Kyverno + # operators: AnyNotIn and Equals read * and ? in an operand as + # wildcards, and these fields are written by the party this rule + # constrains. JMESPath == compares strings literally. + - key: "{{ (request.object.spec.forProvider.parentTeamId || '') == '' && (request.object.spec.forProvider.parentTeamReadId || '') == '' && (request.object.spec.forProvider.parentTeamReadSlug || '') == '' && (request.object.spec.forProvider.ldapDn || '') == '' && (request.object.spec.initProvider.parentTeamId || '') == '' && (request.object.spec.initProvider.parentTeamReadId || '') == '' && (request.object.spec.initProvider.parentTeamReadSlug || '') == '' && (request.object.spec.initProvider.ldapDn || '') == '' }}" + operator: Equals + value: false - name: teammemberships-reference-allow-listed-teams match: any: diff --git a/tests/policy-failure-actions.json b/tests/policy-failure-actions.json index 8abe84f4f7..f8a28dca25 100644 --- a/tests/policy-failure-actions.json +++ b/tests/policy-failure-actions.json @@ -6,6 +6,7 @@ "disallow-latest-tag/validate-image-tag": "Audit", "restrict-github-team-management/teams-allow-listed": "Enforce", "restrict-github-team-management/teams-bind-provider-identity-to-object-name": "Enforce", + "restrict-github-team-management/teams-not-nested-or-directory-linked": "Enforce", "restrict-github-team-management/teammemberships-reference-allow-listed-teams": "Enforce", "restrict-github-team-management/teammemberships-allow-listed-members": "Enforce", "restrict-github-team-management/teammemberships-known-roles": "Enforce", diff --git a/tests/restrict-github-team-management/kyverno-test.yaml b/tests/restrict-github-team-management/kyverno-test.yaml index 04bd3b4732..baabb5c27f 100644 --- a/tests/restrict-github-team-management/kyverno-test.yaml +++ b/tests/restrict-github-team-management/kyverno-test.yaml @@ -35,6 +35,17 @@ results: - github-config/attacker-team kind: Team result: pass + # None of the paved-road teams names a parent team or a directory group. + # attacker-team is here for the same reason as above: the nesting rule judges + # only those fields, whatever the allow-list says about the object. + - policy: restrict-github-team-management + rule: teams-not-nested-or-directory-linked + resources: + - github-config/admins + - github-config/maintainers + - github-config/attacker-team + kind: Team + result: pass # References must go through the allow-listed Team objects, never a raw ID. - policy: restrict-github-team-management rule: teammemberships-reference-allow-listed-teams diff --git a/tests/restrict-github-team-management/nested-or-directory-linked/kyverno-test.yaml b/tests/restrict-github-team-management/nested-or-directory-linked/kyverno-test.yaml new file mode 100644 index 0000000000..a20aa07301 --- /dev/null +++ b/tests/restrict-github-team-management/nested-or-directory-linked/kyverno-test.yaml @@ -0,0 +1,33 @@ +--- +apiVersion: cli.kyverno.io/v1alpha1 +kind: Test +metadata: + name: restrict-github-team-management-nested-or-directory-linked +policies: + - >- + ../../../k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml +resources: + - resources.yaml +results: + - policy: restrict-github-team-management + rule: teams-not-nested-or-directory-linked + resources: + - github-config/forprovider-parentteamid + - github-config/forprovider-parentteamreadid + - github-config/forprovider-parentteamreadslug + - github-config/forprovider-ldapdn + - github-config/initprovider-parentteamid + - github-config/initprovider-parentteamreadid + - github-config/initprovider-parentteamreadslug + - github-config/initprovider-ldapdn + - github-config/wildcard-star + - github-config/wildcard-question + - github-config/admins + kind: Team + result: fail + - policy: restrict-github-team-management + rule: teams-not-nested-or-directory-linked + resources: + - github-config/maintainers + kind: Team + result: pass diff --git a/tests/restrict-github-team-management/nested-or-directory-linked/resources.yaml b/tests/restrict-github-team-management/nested-or-directory-linked/resources.yaml new file mode 100644 index 0000000000..90deb6325a --- /dev/null +++ b/tests/restrict-github-team-management/nested-or-directory-linked/resources.yaml @@ -0,0 +1,129 @@ +--- +# A parent team, a read-back parent field, or a directory link — each one alone, +# in each block the provider reads. Every one of these must be refused. The +# object names are not on the team allow-list on purpose: this fixture asserts +# only the nesting rule, which has to refuse them whatever the name. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: forprovider-parentteamid + namespace: github-config +spec: + forProvider: + parentTeamId: "1234567" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: forprovider-parentteamreadid + namespace: github-config +spec: + forProvider: + parentTeamReadId: "1234567" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: forprovider-parentteamreadslug + namespace: github-config +spec: + forProvider: + parentTeamReadSlug: other-team +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: forprovider-ldapdn + namespace: github-config +spec: + forProvider: + ldapDn: cn=owners,ou=groups,dc=example,dc=com +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: initprovider-parentteamid + namespace: github-config +spec: + initProvider: + parentTeamId: "1234567" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: initprovider-parentteamreadid + namespace: github-config +spec: + initProvider: + parentTeamReadId: "1234567" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: initprovider-parentteamreadslug + namespace: github-config +spec: + initProvider: + parentTeamReadSlug: other-team +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: initprovider-ldapdn + namespace: github-config +spec: + initProvider: + ldapDn: cn=owners,ou=groups,dc=example,dc=com +--- +# Kyverno's own operators read * and ? as wildcards, so a parent written as a +# wildcard is the value most likely to slip past a comparison. It is text like +# any other and must be refused. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: wildcard-star + namespace: github-config +spec: + forProvider: + parentTeamId: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: wildcard-question + namespace: github-config +spec: + initProvider: + parentTeamReadSlug: "?" +--- +# An approved forProvider block must not front a parent supplied through +# initProvider, which upjet merges into any field forProvider leaves unset. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: admins + namespace: github-config +spec: + forProvider: + name: Admins + privacy: secret + initProvider: + parentTeamId: "1234567" +--- +# The provider reports "no parent" and "no directory link" as empty strings. +# Writing them out changes nothing on GitHub and must stay admitted. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: maintainers + namespace: github-config +spec: + forProvider: + name: maintainers + parentTeamId: "" + parentTeamReadId: "" + parentTeamReadSlug: "" + ldapDn: "" + initProvider: + parentTeamId: "" + ldapDn: "" From 29976126292f2cbc5ae89cafa1dce5924c0d7399 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 5 Oct 2026 11:24:59 +0200 Subject: [PATCH 2/9] fix(github-config): refuse non-text parent and directory values in the rule itself Review finding: `x || ''` also reads false, [] and {} as empty, so only the provider's schema stood between those values and admission. not_null() replaces a missing value only. Adds fixtures for a boolean, an empty list, a blank string and a Team with no spec, and words the message to match what is accepted. Co-Authored-By: Claude Opus 5.5 --- .../restrict-github-team-management.yaml | 18 ++++++--- .../kyverno-test.yaml | 4 ++ .../nested-or-directory-linked/resources.yaml | 38 +++++++++++++++++++ 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml index 5cfae421ba..3ce08e99de 100644 --- a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml @@ -125,12 +125,15 @@ spec: failureAction: Enforce # Kyverno admits an update by default when the object already failed the # rule before it. No object fails it today, and one that somehow did - # must not become freely editable for that reason. + # must not become freely editable for that reason. The cost: such an + # object could not be changed at all, its finalizer included, until + # the field is cleared. allowExistingViolations: false message: >- - A github-config Team must not set parentTeamId, parentTeamReadId, - parentTeamReadSlug or ldapDn, in forProvider or initProvider. Nesting a - team or linking it to a directory group is a reviewed change to + A github-config Team must leave parentTeamId, parentTeamReadId, + parentTeamReadSlug and ldapDn unset or empty, in forProvider and + initProvider. Nesting a team or linking it to a directory group is a + reviewed change to restrict-github-team-management.yaml. deny: conditions: @@ -138,8 +141,11 @@ spec: # ONE comparison evaluated by JMESPath rather than eight Kyverno # operators: AnyNotIn and Equals read * and ? in an operand as # wildcards, and these fields are written by the party this rule - # constrains. JMESPath == compares strings literally. - - key: "{{ (request.object.spec.forProvider.parentTeamId || '') == '' && (request.object.spec.forProvider.parentTeamReadId || '') == '' && (request.object.spec.forProvider.parentTeamReadSlug || '') == '' && (request.object.spec.forProvider.ldapDn || '') == '' && (request.object.spec.initProvider.parentTeamId || '') == '' && (request.object.spec.initProvider.parentTeamReadId || '') == '' && (request.object.spec.initProvider.parentTeamReadSlug || '') == '' && (request.object.spec.initProvider.ldapDn || '') == '' }}" + # constrains. JMESPath == compares strings literally. not_null() + # replaces only a missing value with the empty string, so false, + # an empty list or an empty object is refused here and not left + # to the provider's schema to reject. + - key: "{{ not_null(request.object.spec.forProvider.parentTeamId, '') == '' && not_null(request.object.spec.forProvider.parentTeamReadId, '') == '' && not_null(request.object.spec.forProvider.parentTeamReadSlug, '') == '' && not_null(request.object.spec.forProvider.ldapDn, '') == '' && not_null(request.object.spec.initProvider.parentTeamId, '') == '' && not_null(request.object.spec.initProvider.parentTeamReadId, '') == '' && not_null(request.object.spec.initProvider.parentTeamReadSlug, '') == '' && not_null(request.object.spec.initProvider.ldapDn, '') == '' }}" operator: Equals value: false - name: teammemberships-reference-allow-listed-teams diff --git a/tests/restrict-github-team-management/nested-or-directory-linked/kyverno-test.yaml b/tests/restrict-github-team-management/nested-or-directory-linked/kyverno-test.yaml index a20aa07301..1321272df6 100644 --- a/tests/restrict-github-team-management/nested-or-directory-linked/kyverno-test.yaml +++ b/tests/restrict-github-team-management/nested-or-directory-linked/kyverno-test.yaml @@ -23,11 +23,15 @@ results: - github-config/wildcard-star - github-config/wildcard-question - github-config/admins + - github-config/typed-false + - github-config/typed-empty-list + - github-config/whitespace kind: Team result: fail - policy: restrict-github-team-management rule: teams-not-nested-or-directory-linked resources: - github-config/maintainers + - github-config/no-spec kind: Team result: pass diff --git a/tests/restrict-github-team-management/nested-or-directory-linked/resources.yaml b/tests/restrict-github-team-management/nested-or-directory-linked/resources.yaml index 90deb6325a..93e1296cb1 100644 --- a/tests/restrict-github-team-management/nested-or-directory-linked/resources.yaml +++ b/tests/restrict-github-team-management/nested-or-directory-linked/resources.yaml @@ -127,3 +127,41 @@ spec: initProvider: parentTeamId: "" ldapDn: "" +--- +# Not text at all. The provider's schema declares these fields as strings and +# would reject each of these first, but the rule must not depend on that: a +# value that is merely "empty-looking" is still a value somebody set. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: typed-false + namespace: github-config +spec: + forProvider: + parentTeamId: false +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: typed-empty-list + namespace: github-config +spec: + initProvider: + ldapDn: [] +--- +# Blank to a reader, not empty to GitHub. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: whitespace + namespace: github-config +spec: + forProvider: + parentTeamReadSlug: " " +--- +# Nothing under spec at all: every field is missing, none is set. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: no-spec + namespace: github-config From 77707f8145fc1db2b4a64d762d7aa370e4cb277e Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 5 Oct 2026 13:20:55 +0200 Subject: [PATCH 3/9] fix(github-config): compare tenant-written text literally in the team rules Kyverno's list operators read * and ? in either operand as wildcards, so a value of * matched every entry of an allow-list. Measured on v1.19.1, 25 such values were admitted by restrict-github-team-management: a team display name, a referenced team, a member login, a role and a repository permission. Every condition is now one JMESPath boolean compared with `Equals false`, so each comparison is exact. A membership's or grant's team reference may also only name the github-config namespace, because the provider resolves the reference in the namespace it names (#4525). Fixes #4517 Fixes #4525 Co-Authored-By: Claude Opus 5.5 --- .../restrict-github-team-management.yaml | 218 +++--- .../literal-comparison/kyverno-test.yaml | 115 +++ .../literal-comparison/resources.yaml | 667 ++++++++++++++++++ 3 files changed, 882 insertions(+), 118 deletions(-) create mode 100644 tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml create mode 100644 tests/restrict-github-team-management/literal-comparison/resources.yaml diff --git a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml index 3ce08e99de..3a014a17b6 100644 --- a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml @@ -27,6 +27,15 @@ metadata: teams or granting privileged repository access. spec: background: true + # #4517: every value these rules judge is written by the party they + # constrain, so every comparison is LITERAL. Kyverno's list operators (AnyIn, + # AnyNotIn, ...) read * and ? in either operand as wildcards, which lets a + # value of * match any entry of an allow-list: measured on v1.19.1, 25 such + # values were admitted by the rules below. Each condition is therefore one + # JMESPath boolean compared with `Equals false` — JMESPath == and contains() + # compare text exactly. Write a new condition the same way, and never with a + # list operator. not_null() replaces only a missing value with the empty + # string, and to_string() keeps a non-text value from being read as empty. rules: - name: teams-allow-listed match: @@ -42,11 +51,9 @@ spec: deny: conditions: all: - - key: "{{ request.object.metadata.name }}" - operator: AnyNotIn - value: - - admins - - maintainers + - key: "{{ contains(['admins', 'maintainers'], request.object.metadata.name) }}" + operator: Equals + value: false # 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" @@ -71,16 +78,12 @@ spec: 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) }}" + - key: "{{ not_null(request.object.spec.forProvider.name, '') == '' || to_lower(to_string(request.object.spec.forProvider.name)) == to_lower(request.object.metadata.name) }}" + operator: Equals + value: false + - key: "{{ not_null(request.object.spec.initProvider.name, '') == '' || to_lower(to_string(request.object.spec.initProvider.name)) == to_lower(request.object.metadata.name) }}" + operator: Equals + value: false # 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 @@ -168,33 +171,38 @@ spec: # 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 + - key: "{{ not_null(request.object.spec.forProvider.teamIdRef.policy.resolve, '') == 'Always' }}" + operator: Equals + value: false + - key: "{{ not_null(request.object.spec.forProvider.teamIdSelector, `{}`) == `{}` }}" + operator: Equals + value: false + - key: "{{ contains(['admins', 'maintainers'], not_null(request.object.spec.forProvider.teamIdRef.name, '')) }}" + operator: Equals + value: false + # #4525: the provider reads the referenced Team from the namespace the + # reference names and falls back to this object's own only when that is + # empty, so a foreign namespace would resolve to a Team object the team + # rules above never judged. + - key: "{{ contains(['', 'github-config'], not_null(request.object.spec.forProvider.teamIdRef.namespace, '')) }}" + operator: Equals + value: false # 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 + - key: "{{ not_null(request.object.spec.initProvider.teamId, '') == '' }}" + operator: Equals + value: false + - key: "{{ not_null(request.object.spec.initProvider.teamIdSelector, `{}`) == `{}` }}" + operator: Equals + value: false + - key: "{{ contains(['', 'admins', 'maintainers'], not_null(request.object.spec.initProvider.teamIdRef.name, '')) }}" + operator: Equals + value: false + - key: "{{ contains(['', 'github-config'], not_null(request.object.spec.initProvider.teamIdRef.namespace, '')) }}" + operator: Equals + value: false # #3146: the reference rule above constrains WHICH team is written to and # nothing constrains WHO is written into it. A TeamMembership naming `admins` # with policy.resolve Always, no teamId and no selector satisfies every @@ -235,19 +243,16 @@ spec: # GitHub logins are case-insensitive, so normalise before matching: # without it an allow-listed account spelled with different case is # refused, which is a false denial rather than a bypass but a real one. - - key: "{{ to_lower(request.object.spec.forProvider.username || request.object.spec.initProvider.username || '') }}" - operator: AnyNotIn - value: - - devantler + - key: "{{ contains(['devantler'], to_lower(to_string(request.object.spec.forProvider.username || request.object.spec.initProvider.username || ''))) }}" + operator: Equals + value: false # The initProvider value is constrained on its own as well, so an # approved forProvider username cannot front a foreign account if # the merge semantics ever widen. Same belt-and-braces the # reference conditions above apply to initProvider.teamIdRef. - - key: "{{ to_lower(request.object.spec.initProvider.username || '') }}" - operator: AnyNotIn - value: - - "" - - devantler + - key: "{{ contains(['', 'devantler'], to_lower(to_string(not_null(request.object.spec.initProvider.username, '')))) }}" + operator: Equals + value: false # An allow-list, not a deny-list on `maintainer`: the team role is an # enumeration the provider passes straight through to GitHub, so a value # outside the two documented roles must be refused rather than assumed @@ -284,18 +289,12 @@ spec: # initProvider condition below is what constrains the value the # merge would supply. Folding the fallback in would add a conjunct # no fixture can isolate. - - key: "{{ to_lower(request.object.spec.forProvider.role || '') }}" - operator: AnyNotIn - value: - - "" - - member - - maintainer - - key: "{{ to_lower(request.object.spec.initProvider.role || '') }}" - operator: AnyNotIn - value: - - "" - - member - - maintainer + - key: "{{ contains(['', 'member', 'maintainer'], to_lower(to_string(not_null(request.object.spec.forProvider.role, '')))) }}" + operator: Equals + value: false + - key: "{{ contains(['', 'member', 'maintainer'], to_lower(to_string(not_null(request.object.spec.initProvider.role, '')))) }}" + operator: Equals + value: false - name: teamrepositories-reference-allow-listed-teams match: any: @@ -316,55 +315,48 @@ spec: # 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 + - key: "{{ not_null(request.object.spec.forProvider.teamIdRef.policy.resolve, '') == 'Always' }}" + operator: Equals + value: false + - key: "{{ not_null(request.object.spec.forProvider.teamIdSelector, `{}`) == `{}` }}" + operator: Equals + value: false + - key: "{{ contains(['admins', 'maintainers'], not_null(request.object.spec.forProvider.teamIdRef.name, '')) }}" + operator: Equals + value: false + # #4525: the provider reads the referenced Team from the namespace the + # reference names and falls back to this object's own only when that is + # empty, so a foreign namespace would resolve to a Team object the team + # rules above never judged. + - key: "{{ contains(['', 'github-config'], not_null(request.object.spec.forProvider.teamIdRef.namespace, '')) }}" + operator: Equals + value: false # 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 + - key: "{{ contains(['', 'pull', 'triage', 'push', 'maintain', 'admin'], not_null(request.object.spec.forProvider.permission, '')) }}" + operator: Equals + value: false # 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 + - key: "{{ not_null(request.object.spec.initProvider.teamId, '') == '' }}" + operator: Equals + value: false + - key: "{{ not_null(request.object.spec.initProvider.teamIdSelector, `{}`) == `{}` }}" + operator: Equals + value: false + - key: "{{ contains(['', 'admins', 'maintainers'], not_null(request.object.spec.initProvider.teamIdRef.name, '')) }}" + operator: Equals + value: false + - key: "{{ contains(['', 'github-config'], not_null(request.object.spec.initProvider.teamIdRef.namespace, '')) }}" + operator: Equals + value: false + - key: "{{ contains(['', 'pull', 'triage', 'push', 'maintain', 'admin'], not_null(request.object.spec.initProvider.permission, '')) }}" + operator: Equals + value: false # #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. @@ -390,28 +382,18 @@ spec: # 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 || '' }}" + - key: "{{ (request.object.spec.forProvider.teamIdRef.name || request.object.spec.initProvider.teamIdRef.name || '') == 'maintainers' }}" operator: Equals - value: maintainers + value: true validate: failureAction: Enforce 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 + - key: "{{ contains(['', 'pull', 'triage', 'push', 'maintain'], not_null(request.object.spec.forProvider.permission, '')) }}" + operator: Equals + value: false + - key: "{{ contains(['', 'pull', 'triage', 'push', 'maintain'], not_null(request.object.spec.initProvider.permission, '')) }}" + operator: Equals + value: false diff --git a/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml b/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml new file mode 100644 index 0000000000..9db0b03e49 --- /dev/null +++ b/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml @@ -0,0 +1,115 @@ +--- +# #4517, #4525: every value these rules compare is written by the party they +# constrain, so each comparison has to be literal. Kyverno's list and equality +# operators read * and ? in either operand as wildcards; a value of * then +# matches every entry of an allow-list. Each resource here is on the paved road +# except for the one field its name says, so a row can only fail on the +# condition that judges that field. +apiVersion: cli.kyverno.io/v1alpha1 +kind: Test +metadata: + name: restrict-github-team-management-literal-comparison +policies: + - >- + ../../../k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml +resources: + - resources.yaml +results: + - policy: restrict-github-team-management + rule: teams-bind-provider-identity-to-object-name + resources: + - github-config/display-name-star + - github-config/display-name-question + - github-config/init-display-name-star + - github-config/init-display-name-question + kind: Team + result: fail + - policy: restrict-github-team-management + rule: teammemberships-reference-allow-listed-teams + resources: + - github-config/ref-name-star + - github-config/ref-name-question + - github-config/resolve-star + - github-config/resolve-question + - github-config/init-team-id-star + - github-config/init-team-id-question + - github-config/init-ref-name-star + - github-config/init-ref-name-question + # #4525: the provider resolves a reference in the namespace the reference + # names, so a Team object outside github-config is one these rules never + # judged. + - github-config/ref-namespace-foreign + - github-config/ref-namespace-star + - github-config/ref-namespace-question + - github-config/init-ref-namespace-foreign + # A selector resolves to whichever Team carries the labels, so it is + # refused even beside an allow-listed reference. + - github-config/selector-beside-reference + - github-config/init-selector-beside-reference + kind: TeamMembership + result: fail + # Naming github-config itself is the same reference as leaving it out. + - policy: restrict-github-team-management + rule: teammemberships-reference-allow-listed-teams + resources: + - github-config/ref-namespace-own + kind: TeamMembership + result: pass + - policy: restrict-github-team-management + rule: teammemberships-allow-listed-members + resources: + - github-config/username-star + - github-config/username-question + - github-config/init-username-star + - github-config/init-only-username-star + kind: TeamMembership + result: fail + - policy: restrict-github-team-management + rule: teammemberships-known-roles + resources: + - github-config/role-star + - github-config/role-question + - github-config/init-role-star + kind: TeamMembership + result: fail + - policy: restrict-github-team-management + rule: teamrepositories-reference-allow-listed-teams + resources: + - github-config/repo-ref-name-star + - github-config/repo-ref-name-question + - github-config/repo-resolve-star + - github-config/repo-init-team-id-star + - github-config/repo-init-ref-name-star + - github-config/repo-ref-namespace-foreign + - github-config/repo-ref-namespace-star + - github-config/repo-init-ref-namespace-foreign + - github-config/permission-star + - github-config/permission-question + - github-config/init-permission-star + - github-config/capped-wildcard-team-admin + - github-config/repo-selector-beside-reference + - github-config/repo-init-selector-beside-reference + kind: TeamRepository + result: fail + - policy: restrict-github-team-management + rule: teamrepositories-reference-allow-listed-teams + resources: + - github-config/repo-ref-namespace-own + kind: TeamRepository + result: pass + - policy: restrict-github-team-management + rule: teamrepositories-maintainers-capped-below-admin + resources: + - github-config/capped-permission-star + - github-config/capped-permission-question + - github-config/capped-init-permission-star + kind: TeamRepository + result: fail + # A reference that is not literally `maintainers` is not the maintainers + # team: the ceiling leaves it alone and the reference rule above refuses it. + - policy: restrict-github-team-management + rule: teamrepositories-maintainers-capped-below-admin + resources: + - github-config/capped-wildcard-team-admin + kind: TeamRepository + result: skip diff --git a/tests/restrict-github-team-management/literal-comparison/resources.yaml b/tests/restrict-github-team-management/literal-comparison/resources.yaml new file mode 100644 index 0000000000..de28cb5b8d --- /dev/null +++ b/tests/restrict-github-team-management/literal-comparison/resources.yaml @@ -0,0 +1,667 @@ +# Every object is on the paved road (allow-listed reference that re-resolves, +# allow-listed member, known role, permitted grant) except for the single field +# its name says. The * and ? values are the ones a list operator would read as +# wildcards; the namespace rows are #4525. +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: display-name-star + namespace: github-config +spec: + forProvider: + privacy: closed + name: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: display-name-question + namespace: github-config +spec: + forProvider: + privacy: closed + name: "display-name-questio?" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: init-display-name-star + namespace: github-config +spec: + forProvider: + privacy: closed + initProvider: + name: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: init-display-name-question + namespace: github-config +spec: + forProvider: + privacy: closed + initProvider: + name: "init-display-name-questio?" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: ref-name-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "*" + policy: + resolve: "Always" + username: devantler + role: member +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: ref-name-question + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admin?" + policy: + resolve: "Always" + username: devantler + role: member +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: resolve-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "*" + username: devantler + role: member +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: resolve-question + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Alway?" + username: devantler + role: member +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: init-team-id-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + username: devantler + role: member + initProvider: + teamId: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: init-team-id-question + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + username: devantler + role: member + initProvider: + teamId: "?" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: init-ref-name-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + username: devantler + role: member + initProvider: + teamIdRef: + name: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: init-ref-name-question + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + username: devantler + role: member + initProvider: + teamIdRef: + name: "admin?" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: ref-namespace-foreign + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + namespace: "other-namespace" + username: devantler + role: member +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: ref-namespace-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + namespace: "*" + username: devantler + role: member +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: ref-namespace-question + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + namespace: "github-confi?" + username: devantler + role: member +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: init-ref-namespace-foreign + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + username: devantler + role: member + initProvider: + teamIdRef: + name: admins + namespace: other-namespace +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: ref-namespace-own + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + namespace: "github-config" + username: devantler + role: member + initProvider: + teamIdRef: + name: admins + namespace: github-config +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: username-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + username: "*" + role: member +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: username-question + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + username: "d?vantler" + role: member +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: init-username-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + username: devantler + role: member + initProvider: + username: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: init-only-username-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + role: member + initProvider: + username: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: role-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + username: devantler + role: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: role-question + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + username: devantler + role: "membe?" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: init-role-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + username: devantler + role: member + initProvider: + role: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: selector-beside-reference + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + username: devantler + role: member + teamIdSelector: + matchLabels: + team: admins +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: init-selector-beside-reference + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + username: devantler + role: member + initProvider: + teamIdSelector: + matchLabels: + team: admins +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: repo-ref-name-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "*" + policy: + resolve: "Always" + repository: platform + permission: push +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: repo-ref-name-question + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "maintainer?" + policy: + resolve: "Always" + repository: platform + permission: push +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: repo-resolve-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "*" + repository: platform + permission: push +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: repo-init-team-id-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + repository: platform + permission: push + initProvider: + teamId: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: repo-init-ref-name-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + repository: platform + permission: push + initProvider: + teamIdRef: + name: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: repo-ref-namespace-foreign + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + namespace: "other-namespace" + repository: platform + permission: push +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: repo-ref-namespace-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + namespace: "*" + repository: platform + permission: push +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: repo-init-ref-namespace-foreign + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + repository: platform + permission: push + initProvider: + teamIdRef: + name: admins + namespace: other-namespace +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: repo-ref-namespace-own + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + namespace: "github-config" + repository: platform + permission: push + initProvider: + teamIdRef: + name: admins + namespace: github-config +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: permission-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + repository: platform + permission: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: permission-question + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + repository: platform + permission: "pus?" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: init-permission-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + repository: platform + permission: push + initProvider: + permission: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: capped-permission-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "maintainers" + policy: + resolve: "Always" + repository: platform + permission: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: capped-permission-question + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "maintainers" + policy: + resolve: "Always" + repository: platform + permission: "maintai?" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: capped-init-permission-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "maintainers" + policy: + resolve: "Always" + repository: platform + permission: push + initProvider: + permission: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: capped-wildcard-team-admin + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "maintainer?" + policy: + resolve: "Always" + repository: platform + permission: admin +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: repo-selector-beside-reference + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + repository: platform + permission: push + teamIdSelector: + matchLabels: + team: admins +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: repo-init-selector-beside-reference + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + repository: platform + permission: push + initProvider: + teamIdSelector: + matchLabels: + team: admins From a901a6c9b65f67a57eeb55e19aa31e2d18ebe776 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 5 Oct 2026 13:31:18 +0200 Subject: [PATCH 4/9] fix(github-config): say the namespace limit in the deny messages and cover an empty one Co-Authored-By: Claude Opus 5.5 --- .../restrict-github-team-management.yaml | 7 ++++--- .../literal-comparison/kyverno-test.yaml | 13 ++++++++----- .../literal-comparison/resources.yaml | 19 +++++++++++++++++++ 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml index 3a014a17b6..2825d96278 100644 --- a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml @@ -20,7 +20,8 @@ metadata: 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 + TeamRepository resources to reference those Team objects by name inside + the github-config namespace, and blocks repository admin grants. A Team may not be nested under a parent team or linked to a directory group. This prevents a compromised or unintended github-config artifact from adding arbitrary users to arbitrary GitHub @@ -161,7 +162,7 @@ spec: - github-config validate: failureAction: Enforce - 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. + 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, a reference may name no namespace other than github-config, and initProvider must not carry a teamId, selector or non-allow-listed reference. deny: conditions: any: @@ -305,7 +306,7 @@ spec: - github-config validate: failureAction: Enforce - 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. + message: GitHub TeamRepository resources must reference admins or maintainers by spec.forProvider.teamIdRef.name with policy.resolve Always, may name no namespace other than github-config in that reference, and may grant only pull, triage or push permission, in forProvider and initProvider. deny: conditions: any: diff --git a/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml b/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml index 9db0b03e49..90b2230858 100644 --- a/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml +++ b/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml @@ -1,10 +1,12 @@ --- # #4517, #4525: every value these rules compare is written by the party they -# constrain, so each comparison has to be literal. Kyverno's list and equality -# operators read * and ? in either operand as wildcards; a value of * then -# matches every entry of an allow-list. Each resource here is on the paved road -# except for the one field its name says, so a row can only fail on the -# condition that judges that field. +# constrain, so each comparison has to be literal. Kyverno's list operators +# read * and ? in either operand as wildcards; a value of * then matches every +# entry of an allow-list. Its equality operators read only the policy's own +# side that way, so the resolve and teamId rows were refused before as well and +# are here to keep it so. Each resource is on the paved road except for the one +# field its name says, so a row fails on what judges that field: one condition, +# or for init-only-username-star the two that both read the merged username. apiVersion: cli.kyverno.io/v1alpha1 kind: Test metadata: @@ -53,6 +55,7 @@ results: rule: teammemberships-reference-allow-listed-teams resources: - github-config/ref-namespace-own + - github-config/ref-namespace-empty kind: TeamMembership result: pass - policy: restrict-github-team-management diff --git a/tests/restrict-github-team-management/literal-comparison/resources.yaml b/tests/restrict-github-team-management/literal-comparison/resources.yaml index de28cb5b8d..9525c0a2ee 100644 --- a/tests/restrict-github-team-management/literal-comparison/resources.yaml +++ b/tests/restrict-github-team-management/literal-comparison/resources.yaml @@ -665,3 +665,22 @@ spec: teamIdSelector: matchLabels: team: admins +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: ref-namespace-empty + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + namespace: "" + username: devantler + role: member + initProvider: + teamIdRef: + name: admins + namespace: "" From 55a2f03a9c5e1a87becd7eab3a155a2ff1d39b17 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 5 Oct 2026 13:42:18 +0200 Subject: [PATCH 5/9] fix(github-config): name the permissions the grant rule really admits Co-Authored-By: Claude Opus 5.5 --- .../restrict-github-team-management.yaml | 2 +- .../literal-comparison/kyverno-test.yaml | 1 + .../literal-comparison/resources.yaml | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml index 2825d96278..f88b677b21 100644 --- a/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml @@ -306,7 +306,7 @@ spec: - github-config validate: failureAction: Enforce - message: GitHub TeamRepository resources must reference admins or maintainers by spec.forProvider.teamIdRef.name with policy.resolve Always, may name no namespace other than github-config in that reference, and may grant only pull, triage or push permission, in forProvider and initProvider. + message: GitHub TeamRepository resources must reference admins or maintainers by spec.forProvider.teamIdRef.name with policy.resolve Always, may name no namespace other than github-config in that reference, and may grant only the pull, triage, push, maintain or admin permission, in forProvider and initProvider; the maintainers team is capped below admin by a separate rule. deny: conditions: any: diff --git a/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml b/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml index 90b2230858..4c355ceaac 100644 --- a/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml +++ b/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml @@ -98,6 +98,7 @@ results: rule: teamrepositories-reference-allow-listed-teams resources: - github-config/repo-ref-namespace-own + - github-config/repo-ref-namespace-empty kind: TeamRepository result: pass - policy: restrict-github-team-management diff --git a/tests/restrict-github-team-management/literal-comparison/resources.yaml b/tests/restrict-github-team-management/literal-comparison/resources.yaml index 9525c0a2ee..108970ee08 100644 --- a/tests/restrict-github-team-management/literal-comparison/resources.yaml +++ b/tests/restrict-github-team-management/literal-comparison/resources.yaml @@ -684,3 +684,22 @@ spec: teamIdRef: name: admins namespace: "" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: repo-ref-namespace-empty + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + namespace: "" + repository: platform + permission: push + initProvider: + teamIdRef: + name: admins + namespace: "" From c27f534443fc37e59a899849505c220156bff6bb Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 5 Oct 2026 16:22:00 +0200 Subject: [PATCH 6/9] test(github-config): cover initProvider role question wildcard --- .../literal-comparison/kyverno-test.yaml | 1 + .../literal-comparison/resources.yaml | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml b/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml index 4c355ceaac..94d8685250 100644 --- a/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml +++ b/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml @@ -73,6 +73,7 @@ results: - github-config/role-star - github-config/role-question - github-config/init-role-star + - github-config/init-role-question kind: TeamMembership result: fail - policy: restrict-github-team-management diff --git a/tests/restrict-github-team-management/literal-comparison/resources.yaml b/tests/restrict-github-team-management/literal-comparison/resources.yaml index 108970ee08..6cd38ac0a4 100644 --- a/tests/restrict-github-team-management/literal-comparison/resources.yaml +++ b/tests/restrict-github-team-management/literal-comparison/resources.yaml @@ -354,6 +354,22 @@ spec: --- apiVersion: team.github.m.upbound.io/v1beta1 kind: TeamMembership +metadata: + name: init-role-question + namespace: github-config +spec: + forProvider: + teamIdRef: + name: "admins" + policy: + resolve: "Always" + username: devantler + role: member + initProvider: + role: "membe?" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership metadata: name: selector-beside-reference namespace: github-config From 7f61c072ded4020eeb043097faa2445ad0a02923 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Mon, 5 Oct 2026 21:35:32 +0200 Subject: [PATCH 7/9] test(security): cover wildcard initProvider reference namespaces --- .../literal-comparison/kyverno-test.yaml | 4 ++ .../literal-comparison/resources.yaml | 72 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml b/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml index 94d8685250..df34bea2a4 100644 --- a/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml +++ b/tests/restrict-github-team-management/literal-comparison/kyverno-test.yaml @@ -44,6 +44,8 @@ results: - github-config/ref-namespace-star - github-config/ref-namespace-question - github-config/init-ref-namespace-foreign + - github-config/init-ref-namespace-star + - github-config/init-ref-namespace-question # A selector resolves to whichever Team carries the labels, so it is # refused even beside an allow-listed reference. - github-config/selector-beside-reference @@ -87,6 +89,8 @@ results: - github-config/repo-ref-namespace-foreign - github-config/repo-ref-namespace-star - github-config/repo-init-ref-namespace-foreign + - github-config/repo-init-ref-namespace-star + - github-config/repo-init-ref-namespace-question - github-config/permission-star - github-config/permission-question - github-config/init-permission-star diff --git a/tests/restrict-github-team-management/literal-comparison/resources.yaml b/tests/restrict-github-team-management/literal-comparison/resources.yaml index 6cd38ac0a4..252e9a0119 100644 --- a/tests/restrict-github-team-management/literal-comparison/resources.yaml +++ b/tests/restrict-github-team-management/literal-comparison/resources.yaml @@ -232,6 +232,42 @@ spec: --- apiVersion: team.github.m.upbound.io/v1beta1 kind: TeamMembership +metadata: + name: init-ref-namespace-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: admins + policy: + resolve: "Always" + username: devantler + role: member + initProvider: + teamIdRef: + name: admins + namespace: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: init-ref-namespace-question + namespace: github-config +spec: + forProvider: + teamIdRef: + name: admins + policy: + resolve: "Always" + username: devantler + role: member + initProvider: + teamIdRef: + name: admins + namespace: "github-confi?" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership metadata: name: ref-namespace-own namespace: github-config @@ -528,6 +564,42 @@ spec: --- apiVersion: team.github.m.upbound.io/v1beta1 kind: TeamRepository +metadata: + name: repo-init-ref-namespace-star + namespace: github-config +spec: + forProvider: + teamIdRef: + name: admins + policy: + resolve: "Always" + repository: platform + permission: push + initProvider: + teamIdRef: + name: admins + namespace: "*" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: repo-init-ref-namespace-question + namespace: github-config +spec: + forProvider: + teamIdRef: + name: admins + policy: + resolve: "Always" + repository: platform + permission: push + initProvider: + teamIdRef: + name: admins + namespace: "github-confi?" +--- +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository metadata: name: repo-ref-namespace-own namespace: github-config From fead020972a0e12064e073a33be3af1c79546dcc Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 6 Oct 2026 00:17:54 +0200 Subject: [PATCH 8/9] ci: re-run code scanning after the cancelled analysis The GitHub Actions incident on 5 October cancelled this head's code scanning runs, and those runs cannot be retried. No file changes. Co-Authored-By: Claude Opus 5.5 From 99c78179b8ef3c4fcd3264e2bdaea30262c94935 Mon Sep 17 00:00:00 2001 From: Nikolai Emil Damm Date: Tue, 6 Oct 2026 01:08:54 +0200 Subject: [PATCH 9/9] test(policies): read the fixture report from a here-string, not a pipe The self-test searched the validator's report with printf piped into grep -q. grep -q exits at the first match, so a report longer than the pipe buffer makes printf fail with a broken pipe, and under pipefail the test then reports the message as missing although it is there. The team fixtures this branch adds made the shorthand-kinds report long enough to hit that. Co-Authored-By: Claude Opus 5.5 --- scripts/tests/test-validate-kyverno-fixture-evaluation.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/tests/test-validate-kyverno-fixture-evaluation.sh b/scripts/tests/test-validate-kyverno-fixture-evaluation.sh index 3157a4160b..482c7617d4 100755 --- a/scripts/tests/test-validate-kyverno-fixture-evaluation.sh +++ b/scripts/tests/test-validate-kyverno-fixture-evaluation.sh @@ -43,7 +43,9 @@ expect_fail() { fail "$1: expected exit $3, got ${rc}:"$'\n'"${output}" return fi - if ! printf '%s\n' "${output}" | grep -qF -- "$4"; then + # A here-string, not a pipe: grep -q stops at the first match, and a long + # report still being written into a pipe then fails the whole test. + if ! grep -qF -- "$4" <<<"${output}"; then fail "$1: exit $3 but missing \"$4\":"$'\n'"${output}" return fi