Skip to content

Admission policies: Kyverno / Gatekeeper compatibility

Audience: Platform engineer, Security Goal: know — before you install — whether your cluster's admission policies will let github-actions-gateway (GAG) pods schedule, and apply complementary sample policies that lock GAG's own hardened posture in place.

Many clusters run a policy engine — Kyverno or OPA Gatekeeper — that rejects pods at admission when they violate a cluster rule: require runAsNonRoot, drop all Linux capabilities, block the :latest image tag, allow only certain registries, require resource limits, require a seccomp profile. If one of those rules rejects a GAG worker, egress-proxy, Actions Gateway Controller (AGC), or Gateway Manager Controller (GMC) pod, the pod never schedules — a confusing, silent failure that surfaces only as "no runners ever come online" or a Deployment stuck at zero ready replicas.

This page does two things:

  1. A compatibility matrix — for each common policy class, whether each GAG pod already complies (and why), or what you must allow. Every row is grounded in the real pod specs GAG produces.
  2. Sample policies you can apply to enforce GAG's own security posture, plus exception snippets for where a strict cluster policy would otherwise block GAG.

GAG uses in-tree Pod Security Admission (PSA) as its floor, not as a replacement for your policy engine — the GMC stamps a pod-security.kubernetes.io/enforce level on every tenant namespace (§5.3 of the security design). A Kyverno/Gatekeeper layer on top is fully supported and encouraged; this page is how to make the two layers agree.

GAG pod classes

The matrix distinguishes seven pod classes because their security posture differs by design:

Pod class Created by Namespace Lifecycle
Worker — baseline (default) AGC tenant Ephemeral, per-job, run-to-completion
Worker — restricted AGC tenant As above, hardened tenant opt-in
Worker — privileged AGC tenant As above, escape-hatch tenant opt-in (Docker-in-Docker (DinD), host caps)
Egress proxy GMC tenant Long-running per-tenant Deployment
AGC (v1alpha1) GMC tenant Long-running per-tenant control plane
AGC (v2alpha1) GMC tenant As above; adds resource defaults
GMC manager Helm chart install ns Long-running cluster control plane

The worker profile is the tenant's ActionsGateway.spec.securityProfile (baseline default, restricted, or privileged); the GMC mirrors it onto the worker pods and the namespace PSA level. The proxy, AGC, and GMC pods are uniformly hardened and not tenant-tunable.

Compatibility matrix

Legend: ✅ already complies · ⚠️ complies by default but a tenant override or profile can change it · ❌ does not comply — you must allow it (exempt the pod, or pick a stricter worker profile).

Policy class Worker baseline Worker restricted Worker privileged Egress proxy AGC v1 AGC v2 GMC
runAsNonRoot: true
seccompProfile: RuntimeDefault
Drop ALL capabilities
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
No privileged containers
No host namespaces (hostPID/Network/IPC)
Block :latest / require digest pin ⚠️ ⚠️ ⚠️
Registry allowlist ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️
Require CPU/memory requests + limits

Row notes (the why, grounded in the pod specs)

runAsNonRoot: true — On baseline/restricted the AGC gap-fills pod-level runAsNonRoot: true and a numeric runAsUser: 1001 (provisioner.go applySecurityDefaults). The numeric UID matters: kubelet cannot verify runAsNonRoot against the runner image's non-numeric USER runner, so without it the pod is rejected with CreateContainerConfigError (Q115). The privileged profile stamps no securityContext defaults — that is its whole purpose (DinD, root-requiring builds) — so a runAsNonRoot policy will reject it; that is correct, and those tenants need an explicit exception. Proxy, AGC, and GMC containers set runAsNonRoot: true directly.

seccompProfile: RuntimeDefault — Set on every baseline/restricted worker (pod-level) and on every proxy/AGC/GMC container. privileged workers get no default.

Drop ALL capabilities — Here is the most common surprise. The baseline worker profile deliberately does not drop capabilities or set allowPrivilegeEscalation: false, because baseline PSA permits in-job privilege escalation (sudo) and a large fraction of real CI jobs rely on it. A cluster policy that requires drop: [ALL] on all pods will therefore reject default workers. Your options, in order of preference:

  • Tell tenants to set securityProfile: restricted — that profile gap-fills allowPrivilegeEscalation: false + drop: [ALL] on every worker container, satisfying the policy with no exception needed.
  • Or scope the cluster policy to exclude tenant namespaces (see the exception samples) so baseline keeps working.

restricted, proxy, AGC, and GMC pods already drop all capabilities.

allowPrivilegeEscalation: false — Same story as capabilities: only restricted (and the proxy/AGC/GMC pods) set it. baseline leaves it unset on purpose.

readOnlyRootFilesystem: trueNo worker profile sets this, including restricted. The runner writes to its working tree (_work), tool cache, and temp dirs on the root filesystem; a read-only root would break virtually every job. If your cluster requires readOnlyRootFilesystem, you must exempt worker pods. The proxy, AGC, and GMC containers all run with readOnlyRootFilesystem: true.

No privileged containers / no host namespaces — The AGC unconditionally forces hostPID/hostNetwork/hostIPC: false and automountServiceAccountToken: false on every worker pod (all three profiles), overwriting any tenant PodTemplate value. baseline/restricted also forbid privileged containers via PSA. Only the privileged profile permits a privileged container — by design, and gated behind an admission webhook that requires an explicit per-tenant opt-in (§5.3).

Block :latest / require digest pin — All GAG-managed images are pinned by @sha256: digest: the GMC, AGC, and proxy images are digest-pinned and the Helm chart refuses to render without a digest (validateImageDigest); the default worker image is also digest-pinned (names.go). The ⚠️ is only because a tenant may override spec.workerImage (or a PodTemplate container image) with a floating tag — a tenant choice, not a GAG default. A "require digest" policy scoped to tenant namespaces is a good way to force tenants to pin too.

Registry allowlist — ⚠️ for every class because you must add GAG's registries to your allowlist:

  • Control-plane images (GMC, AGC, egress proxy): ghcr.io/actions-gateway/*.
  • Default worker image: ghcr.io/actions/actions-runner.
  • Any registry your tenants set via spec.workerImage / PodTemplate.

Require CPU/memory requests + limits — Workers are gap-filled with 500m CPU / 1Gi memory as both requests and limits when a container sets neither. The proxy sets requests+limits. AGC v1alpha1 stamps no resources — a "require limits" policy will reject AGC v1 pods; either move the tenant to a v2alpha1 ActionsGateway (which gap-fills 500m/2Gi requests, 2/4Gi limits) or exempt AGC pods. The GMC's resources come from the chart's resources value, which ships with a sane default (10m/64Mi request, 500m/128Mi limit).

Sample policies

Ready-to-apply samples live in examples/policies/. Two complementary kinds, for both engines:

File Engine Purpose
kyverno/enforce-gag-worker-hardening.yaml Kyverno Enforce GAG's posture: in tenant namespaces require runAsNonRoot, seccomp RuntimeDefault, no host namespaces, no privileged container, and digest-pinned images.
kyverno/policyexception-gag.yaml Kyverno Allow GAG where a strict cluster policy would block it (read-only-rootfs, drop-ALL-caps) — scoped to tenant namespaces and the GMC install namespace.
gatekeeper/enforce-gag-worker-hardening.yaml Gatekeeper Same enforce intent as the Kyverno policy, as a ConstraintTemplate + Constraint.
gatekeeper/exclude-gag-namespaces.yaml Gatekeeper Exclude GAG namespaces from strict cluster Constraints, two ways: excludedNamespaces and a namespace-marker labelSelector.
kyverno/restrict-gag-placement.yaml Kyverno Govern placement: pin each tenant namespace's pods to that tenant's node pool, and forbid keyless Exists tolerations. See Governing where GAG pods schedule below.
gatekeeper/restrict-gag-placement.yaml Gatekeeper Same placement intent, as an Assign mutation plus a ConstraintTemplate + Constraint.

All samples key off two stable signals so they keep working as tenants come and go:

  • The tenant-namespace marker actions-gateway.github.com/tenant: "true", which the GMC requires on every namespace it provisions into (worker, proxy, and AGC pods all live in marked namespaces).
  • The GMC install namespace, where only the GMC manager pod runs.

Apply the enforce policies

# Kyverno (cluster-scoped ClusterPolicy)
kubectl apply -f docs/operations/examples/policies/kyverno/enforce-gag-worker-hardening.yaml

# Gatekeeper (ConstraintTemplate must exist before its Constraint)
kubectl apply -f docs/operations/examples/policies/gatekeeper/enforce-gag-worker-hardening.yaml

Start with validationFailureAction: Audit (Kyverno) or enforcementAction: dryrun (Gatekeeper) — both samples ship in that mode — and promote to enforce once you confirm no false positives against your real workloads.

Apply the exceptions

If you already run a strict cluster baseline (e.g. the Kyverno require-ro-rootfs / disallow-capabilities-strict policies, or a Gatekeeper PodSecurityPolicy (PSP)-style constraint), apply the matching exception so GAG pods are not caught:

# Kyverno PolicyException — requires Kyverno's PolicyExceptions feature enabled.
kubectl apply -f docs/operations/examples/policies/kyverno/policyexception-gag.yaml

# Gatekeeper — exclude GAG namespaces from your strict Constraints.
kubectl apply -f docs/operations/examples/policies/gatekeeper/exclude-gag-namespaces.yaml

Read the comments in each file: the exceptions are deliberately narrow (only the properties GAG legitimately cannot satisfy, only in GAG namespaces) so they do not widen your cluster posture more than necessary.

Governing where GAG pods schedule

GAG passes pod placement through to the CR author, in three places:

Field Governs Since
RunnerTemplate.spec.podTemplate (a full PodTemplateSpec) Worker pods v2, always
EgressProxy.spec.scheduling Egress-proxy pool pods v2
ActionsGateway.spec.scheduling AGC control-plane pod v2

Each carries the standard nodeSelector, tolerations, and affinity. This is deliberate and not admin-gated. Distinct per-tenant egress IPs exist so tenants do not share a rate limit or a block radius; a tenant electing where its own traffic leaves the cluster is the intended use, and worker placement has always been able to do it.

It follows that per-tenant egress-IP attribution is a property of your cluster configuration, not something GAG enforces on its own. If a downstream firewall allowlist or an audit trail attributes traffic by source IP, and CR authors in tenant namespaces are not fully trusted, constrain placement here — at the pod admission layer, where it also covers any pod a tenant can create directly.

Pin by mutation, not by an allowlist

The instinct is to validate an allowlist of permitted nodeSelector values. That is not sound. affinity.nodeAffinity is a small query language supporting NotIn and DoesNotExist, so "any pool except my own" is expressible, and no key=value allowlist rejects it in general.

Stamping the nodeSelector by mutation is sound, because Kubernetes ANDs nodeSelector with nodeAffinity — affinity can only narrow the candidate nodes, never widen them. Once admission pins the pod to a pool, no affinity expression moves it off. The worst a hostile author achieves is an unschedulable pod (Pending), never another tenant's egress IP.

Label each tenant namespace with the pool it owns, then apply the policy:

kubectl label namespace tenant-a actions-gateway.com/node-pool=pool-tenant-a

# Kyverno — reads the label, pins every pod in that namespace
kubectl apply -f docs/operations/examples/policies/kyverno/restrict-gag-placement.yaml

# Gatekeeper — one Assign per tenant namespace, plus the toleration Constraint
kubectl apply -f docs/operations/examples/policies/gatekeeper/restrict-gag-placement.yaml

Namespaces without the label are left untouched, so the Kyverno policy is safe to apply before you have labelled anything.

The taint bypass — and why validation alone is not enough

A toleration with operator: Exists and no key tolerates every taint in the cluster, including the taints that reserve a node pool for one tenant. No GAG-emitted pod needs one, so both samples deny it.

But do not mistake that rule for a control. It blocks only the blanket case. A pod that names the target pool's taint explicitly —

tolerations:
  - {key: dedicated, operator: Equal, value: pool-tenant-b, effect: NoSchedule}

— walks straight past it, and taint keys are conventional and readable (kubectl get nodes -o jsonpath='{.items[*].spec.taints}'). Denying keyless Exists raises the floor; it does not close the door.

The mutating pin is the only sound control of the two. It holds even against a pod that sets nodeName directly to bypass the scheduler, because kubelet's PodMatchNodeSelector admission re-checks nodeSelector and required nodeAffinity against the node it actually landed on. If you take one thing from this section, apply the mutation; treat the toleration rule as a cheap extra, not a substitute.

Not a substitute for tenant RBAC. These policies act on pods, so they cover worker, proxy, AGC, and any pod a tenant creates directly. That breadth is the point: GAG's NetworkPolicies select GAG-labelled pods, so an unlabelled pod a tenant creates in its own namespace is selected by no policy and egresses unrestricted. If tenants hold pods: create in their namespaces, placement is the least of what a policy engine needs to constrain. See security design §5.2.

See also