diff --git a/k8s/bases/apps/github-config/role.yaml b/k8s/bases/apps/github-config/role.yaml index 695d5f0f0..aa7af3c13 100644 --- a/k8s/bases/apps/github-config/role.yaml +++ b/k8s/bases/apps/github-config/role.yaml @@ -51,6 +51,11 @@ rules: - update - patch - delete + # Team resources are high-impact because they change real GitHub org + # authorization. RBAC alone cannot express which teams this SA may manage, so + # the restrict-github-team-management Kyverno admission policy constrains the + # allowed team identities, reference style, and repository permissions on top + # of these verbs. - apiGroups: - team.github.m.upbound.io resources: 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 new file mode 100644 index 000000000..88ae52384 --- /dev/null +++ b/k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml @@ -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 }}" + 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 + # 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 + 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 diff --git a/k8s/bases/infrastructure/cluster-policies/kustomization.yaml b/k8s/bases/infrastructure/cluster-policies/kustomization.yaml index 2885fbbbb..574f39ba1 100644 --- a/k8s/bases/infrastructure/cluster-policies/kustomization.yaml +++ b/k8s/bases/infrastructure/cluster-policies/kustomization.yaml @@ -12,6 +12,7 @@ resources: - best-practices/disable-default-sa-automount.yaml - best-practices/disallow-latest-tag.yaml - best-practices/propagate-reloader-to-flagger-primary.yaml + - best-practices/restrict-github-team-management.yaml - best-practices/restrict-homepage-service-groups.yaml - best-practices/restrict-tenant-issuer-refs.yaml - best-practices/restrict-tenant-route-hostnames.yaml diff --git a/tests/restrict-github-team-management/forprovider-mismatch/kyverno-test.yaml b/tests/restrict-github-team-management/forprovider-mismatch/kyverno-test.yaml new file mode 100644 index 000000000..0742c2f2c --- /dev/null +++ b/tests/restrict-github-team-management/forprovider-mismatch/kyverno-test.yaml @@ -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 diff --git a/tests/restrict-github-team-management/forprovider-mismatch/resources.yaml b/tests/restrict-github-team-management/forprovider-mismatch/resources.yaml new file mode 100644 index 000000000..71fbbe354 --- /dev/null +++ b/tests/restrict-github-team-management/forprovider-mismatch/resources.yaml @@ -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 diff --git a/tests/restrict-github-team-management/initprovider-mismatch/kyverno-test.yaml b/tests/restrict-github-team-management/initprovider-mismatch/kyverno-test.yaml new file mode 100644 index 000000000..7efc13a83 --- /dev/null +++ b/tests/restrict-github-team-management/initprovider-mismatch/kyverno-test.yaml @@ -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 diff --git a/tests/restrict-github-team-management/initprovider-mismatch/resources.yaml b/tests/restrict-github-team-management/initprovider-mismatch/resources.yaml new file mode 100644 index 000000000..dd21ae049 --- /dev/null +++ b/tests/restrict-github-team-management/initprovider-mismatch/resources.yaml @@ -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 diff --git a/tests/restrict-github-team-management/kyverno-test.yaml b/tests/restrict-github-team-management/kyverno-test.yaml new file mode 100644 index 000000000..9c98a5c32 --- /dev/null +++ b/tests/restrict-github-team-management/kyverno-test.yaml @@ -0,0 +1,88 @@ +--- +apiVersion: cli.kyverno.io/v1alpha1 +kind: Test +metadata: + name: restrict-github-team-management +policies: + - >- + ../../k8s/bases/infrastructure/cluster-policies/best-practices/restrict-github-team-management.yaml +resources: + - resources.yaml +results: + # Only the CODEOWNERS teams may be managed through this delegation. + - policy: restrict-github-team-management + rule: teams-allow-listed + resources: + - github-config/attacker-team + kind: Team + result: fail + - policy: restrict-github-team-management + rule: teams-allow-listed + resources: + - github-config/admins + - github-config/maintainers + kind: Team + result: pass + # A self-consistent provider identity passes, including when it is omitted and + # Crossplane derives the remote team from the object name. attacker-team is + # here on purpose: it fails the allow-list above yet must pass this rule, so a + # regression that collapsed the two rules into one would be caught. + - policy: restrict-github-team-management + rule: teams-bind-provider-identity-to-object-name + 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 + resources: + - github-config/direct-team-id + - github-config/unresolved-reference + - github-config/ifnotpresent-reference + - github-config/initprovider-foreign-team + - github-config/selector-reference + - github-config/non-allow-listed-reference + kind: TeamMembership + result: fail + - policy: restrict-github-team-management + rule: teammemberships-reference-allow-listed-teams + resources: + - github-config/admins-member + kind: TeamMembership + result: pass + # Repository grants: admins may hold admin; only maintainers are capped below it. + - policy: restrict-github-team-management + rule: teamrepositories-reference-allow-listed-teams + resources: + - github-config/unresolved-reference-repo + - github-config/maintainers-escalated-initprovider-only + kind: TeamRepository + result: fail + - policy: restrict-github-team-management + rule: teamrepositories-reference-allow-listed-teams + resources: + - github-config/admins-repo + - github-config/admins-repo-admin + - github-config/initprovider-admin-grant + - github-config/maintain-grant + - github-config/maintainers-repo + kind: TeamRepository + result: pass + # #3208: the per-team ceiling. admins may hold admin; maintainers may not. + - policy: restrict-github-team-management + rule: teamrepositories-maintainers-capped-below-admin + resources: + - github-config/maintainers-escalated + - github-config/maintainers-escalated-initprovider + - github-config/maintainers-escalated-initprovider-only + kind: TeamRepository + result: fail + - policy: restrict-github-team-management + rule: teamrepositories-maintainers-capped-below-admin + resources: + - github-config/maintainers-repo + kind: TeamRepository + result: pass diff --git a/tests/restrict-github-team-management/resources.yaml b/tests/restrict-github-team-management/resources.yaml new file mode 100644 index 000000000..a0d465215 --- /dev/null +++ b/tests/restrict-github-team-management/resources.yaml @@ -0,0 +1,299 @@ +--- +# The paved road: object name is allow-listed and the provider identity agrees +# with it, so the reference rules mean what they say. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: admins + namespace: github-config +spec: + forProvider: + name: Admins + privacy: closed +--- +# Allow-listed with the provider identity omitted entirely — Crossplane then +# derives the remote team from the object name, which is the shape the +# allow-list was written for. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: maintainers + namespace: github-config +spec: + forProvider: + privacy: closed +--- +# A team outside the allow-list. Its provider identity is self-consistent, so it +# must fail the allow-list rule and pass the identity-binding rule — the two +# rules are independent. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: Team +metadata: + name: attacker-team + namespace: github-config +spec: + forProvider: + name: attacker-team +--- +# A TeamMembership on the paved road: allow-listed reference that re-resolves on +# every reconcile, so no authored teamId can survive. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: admins-member + namespace: github-config +spec: + forProvider: + teamIdRef: + name: admins + policy: + resolve: Always + username: devantler + role: member +--- +# The bypass #3195 documents, and the reason this rule exists: an allow-listed +# reference left on the IfNotPresent default never overwrites an authored +# teamId, so the reference is decorative. Only the missing resolve policy is +# wrong here — the reference name is allow-listed and no teamId is set — so this +# fixture isolates the new condition. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: unresolved-reference + namespace: github-config +spec: + forProvider: + teamIdRef: + name: admins + username: attacker + role: maintainer +--- +# The same bypass stated explicitly rather than by omission. Kept separate from +# the fixture above because "field absent" and "field present with the wrong +# value" are different JMESPath paths through the `|| ''` default. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: ifnotpresent-reference + namespace: github-config +spec: + forProvider: + teamIdRef: + name: admins + policy: + resolve: IfNotPresent + username: attacker + role: maintainer +--- +# A direct numeric team ID with no reference at all. Nothing resolves it, so the +# allow-list never applies. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: direct-team-id + namespace: github-config +spec: + forProvider: + teamId: "1234567" + username: attacker + role: maintainer +--- +# A TeamRepository on the paved road. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: admins-repo + namespace: github-config +spec: + forProvider: + teamIdRef: + name: admins + policy: + resolve: Always + repository: platform + permission: push +--- +# The unresolved-reference bypass on the TeamRepository rule. Its permission is +# allowed and its reference is allow-listed, so only the resolve policy is wrong. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: unresolved-reference-repo + namespace: github-config +spec: + forProvider: + teamIdRef: + name: admins + repository: platform + permission: push +--- +# An admin grant held by the admins team. The ceiling caps maintainers only, so +# this is an allowed grant and the rule passes it. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: admins-repo-admin + namespace: github-config +spec: + forProvider: + teamIdRef: + name: admins + policy: + resolve: Always + repository: platform + permission: admin +--- +# initProvider bypass: the forProvider reference is fully compliant, but upjet +# merges initProvider into unset forProvider fields, so a foreign team supplied +# there is what actually reconciles. Only the initProvider teamId is wrong. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: initprovider-foreign-team + namespace: github-config +spec: + forProvider: + teamIdRef: + name: admins + policy: + resolve: Always + username: attacker + initProvider: + teamId: "7654321" +--- +# Same bypass for the admin grant the TeamRepository rule blocks. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: initprovider-admin-grant + namespace: github-config +spec: + forProvider: + teamIdRef: + name: admins + policy: + resolve: Always + repository: platform + permission: push + initProvider: + permission: admin +--- +# teamIdSelector: a label selector resolves to whatever Team matches, so it +# bypasses the by-name allow-list entirely. Exercises the JMESPath map-literal +# comparison against `{}`, which nothing else covers. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: selector-reference + namespace: github-config +spec: + forProvider: + teamIdSelector: + matchLabels: + role: privileged + username: attacker +--- +# A by-name reference that resolves correctly, but to a team outside the +# allow-list. Only the referenced name is wrong. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamMembership +metadata: + name: non-allow-listed-reference + namespace: github-config +spec: + forProvider: + teamIdRef: + name: attacker-team + policy: + resolve: Always + username: attacker +--- +# maintain is privileged but permitted here: the allow-list admits the team, and +# the ceiling caps only maintainers at admin, so this grant is allowed. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: maintain-grant + namespace: github-config +spec: + forProvider: + teamIdRef: + name: admins + policy: + resolve: Always + repository: platform + permission: maintain +--- +# #3208 THE ESCALATION THIS CEILING EXISTS TO STOP: an allow-listed reference to the +# LOWER-privileged team asking for admin. Everything else about it is compliant, so +# only the per-team ceiling can catch it. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: maintainers-escalated + namespace: github-config +spec: + forProvider: + teamIdRef: + name: maintainers + policy: + resolve: Always + repository: platform + permission: admin +--- +# The same escalation routed through initProvider, which upjet merges into unset +# forProvider fields. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: maintainers-escalated-initprovider + namespace: github-config +spec: + forProvider: + teamIdRef: + name: maintainers + policy: + resolve: Always + repository: platform + permission: maintain + initProvider: + permission: admin +--- +# The maintainers paved road: maintain through the maintainers team is the live shape +# on 15 repositories and must pass, or the ceiling is just a broken cap again. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: maintainers-repo + namespace: github-config +spec: + forProvider: + teamIdRef: + name: maintainers + policy: + resolve: Always + repository: platform + permission: maintain +--- +# CodeRabbit e70024a4: an initProvider-ONLY maintainers reference asking for admin. +# Two independent rules must reject it, and the fixture asserts both: +# * teamrepositories-reference-allow-listed-teams — forProvider.teamIdRef is absent, +# so policy.resolve is not Always. This is what already denied the case before the +# precondition was widened, i.e. it was never a live escalation. +# * teamrepositories-maintainers-capped-below-admin — the widened precondition now +# resolves the effective team through initProvider, so the ceiling rule is correct +# on its own rather than relying on the rule above. +apiVersion: team.github.m.upbound.io/v1beta1 +kind: TeamRepository +metadata: + name: maintainers-escalated-initprovider-only + namespace: github-config +spec: + forProvider: + repository: platform + initProvider: + teamIdRef: + name: maintainers + policy: + resolve: Always + permission: admin