Constrain Artifact Promotion with Kyverno
TOC
Feature OverviewUse CasesWhat Counts as a PromotionWhere This Fits in a Promotion PipelinePrerequisitesPlaceholders to ReplaceThe RulesRule 1: a run holding the production credential must be a templateTemplates from a catalogRule 2: noTaskRun outside a templateCatalog-served templates: the parent test for rules 2 and 3Rule 3: no Pod outside a templateRule 4: only maintainers change the templatesRule 5: approvers come from the namespace's allowed listRule 6: sources must come from an approved registryRule 7: destinations must land in an approved registryRule 8: mappings must be present and well formedRule 9: the destination repository must match the sourceRule 10: the templates expose only the reviewed inputsRule 11: sources carry a release tagComposing the RulesBundling rules into fewer objectsTightening a namespace with PolicyApplying and VerifyingQuick check: one denial, one admissionCheck it against a real promotion runRolling out, and rolling backWho May ApproveLimitsTroubleshootingLearn MoreFeature Overview
Promote Artifacts with Skopeo Copy shows how to copy an artifact from one registry to another behind a manual approval step, with the production credential kept in a Connector (set up there) rather than a Secret. That page guarantees that an approval happens for every run of the template. This guide adds a set of Kyverno policies that make the template the only way to hold the production credential — so the approval step cannot be skipped — and whose second job is to take work off the approver. Everything a careful approver would otherwise verify by hand — is this one of the reviewed promotion pipelines, does the copy pull from an approved source and land in an approved target under the same name, is the approver list the agreed one — is checked automatically at admission, so the approval itself can stay what it should be: a sign-off on intent, not a line-by-line inspection.
The second half of the value is when the check happens: a request that breaks the
promotion convention fails before anything starts, instead of waiting for a human to
catch it. One example makes that concrete. Different promotions
need different approvers, so a template may take its approvers as a PipelineRun
parameter — but on its own that lets whoever starts the run choose who approves it, and the
approval machinery validates the names only late, after the run exists and sits at its
approval step. That is why the promotion page pins the approvers inside the Pipeline.
Rule 5 is what makes the run-time form safe: a run that passes unlisted
approvers is never created, and a template edit that names approvers outside the agreed
list — as a literal or as the parameter's default — is refused the moment someone tries it.
The recognition does not come from the tool: skopeo-copy is a general-purpose Task that
mirrors base images and moves artifacts between test registries, and none of that is a promotion.
It comes from a convention the promotion page already establishes and this guide makes explicit:
the production registry is reachable only through one Connector, and a run that mounts that
Connector must be one of the promotion pipeline templates. Everything else that copies images
is left alone. What Counts as a Promotion spells the convention out.
Three Alauda DevOps Connectors terms carry the rest of the page. A Connector is a namespaced
object that holds the credential for one external system — here, the production registry — and
lends it to workloads without exposing it. A run borrows it through the connectors-csi
driver: a workspace (or volume) whose csi.driver is connectors-csi and whose attributes
name the Connector. An AccessPolicy is an optional object that can make borrowing a
Connector conditional on an approval; it is not required by this guide, and
Who May Approve says when it is worth its cluster-wide cost.
The guardrails are eleven independent policy objects — one per guardrail — meant to be
composed. Nine carry a single rule; two objects carry a pair that drops or tightens as one
unit — rule 2's (the TaskRun rule and the owner-reference rule its Pod-level companion
depends on) and rule 5's (the run side and the template side of one approver list). Compose freely:
apply all of them, or only the ones that express constraints your organisation actually has.
Each is explained and listed on its own in The Rules, and
Composing the Rules shows how to operate a chosen subset as one unit.
"Core" is what makes the convention hold — with rules 1, 2 and 3 absent, nothing ties the credential to the reviewed pipelines and every other rule can be bypassed by not being a promotion at all. Rules 1 and 2 judge Tekton's runs; rule 3 is the floor under them, judging the Pod every request eventually becomes. The optional rules each assume the core and add one constraint; a team that does not restrict destination names simply leaves rule 7 out, and a team whose templates come from a governed catalog leaves rule 4 out.
Kyverno judges what the request contains, not what the Task eventually does with it. The Limits section is not optional reading: a policy trusted beyond its actual reach is worse than no policy.
Use Cases
- Lighten manual review: approvers sign off on intent instead of re-checking pipeline identity, sources, targets and paths by hand.
- Refuse a run that names an approver outside the agreed list at admission, before the run exists — earlier than the approval machinery's own check.
- Ensure only the reviewed promotion pipelines can hold the production registry credential —
no hand-written runs,
TaskRuns or Pods, and no other workload. - Pin promotion sources and destinations to approved registries, and keep an artifact's repository path unchanged on its way to production.
- Adopt only the constraints your organisation actually has, and tighten further per namespace (Tightening a Namespace).
What Counts as a Promotion
A policy can only constrain what it can recognise, and a promotion has no intrinsic marker: the
same skopeo-copy Task, with the same parameters, is a promotion when it writes to production
and a routine mirror when it writes anywhere else. So the recognition has to come from a
convention — something a promotion always has and nothing else can obtain. This guide uses
the production credential itself:
- The production registry credential exists only as a Connector. Create it as an OCI or
Harbor
Connector—<production-connector>below — and do not keep akubernetes.io/dockerconfigjsonSecret for the production registry in any pipeline namespace. A run obtains access by mounting the Connector through theconnectors-csidriver, which makes the mount the one attribute every promotion carries and nothing else can have — and whatever declares that mount, aPipelineRun, aTaskRunor a bare Pod, is what the policies examine. The Pod never sees the registry password: the mount provides a.envfile whoseHTTPS_PROXYpoints at the Connectors proxy with the run's short-lived token,skopeo-copyloads it, and the proxy injects the real credential on the way to the registry. Once that convention holds — no other copy of the credential exists — there is no way to push to production without the mount; Limits says what happens when it does not hold. - The approval lives in the template. The promotion
Pipelineruns itsApprovalTaskbefore the copy and names the approvers inside the template; the manual approval gate accepts a response only from those identities. Together with the first convention that is the whole gate: a run can only reach production by mounting the Connector, and — once the rules below hold — only a run of a template can mount it, so nothing reaches production without the template's approval step. Who may approve is therefore decided by the template's list, which rules 4 and 5 protect — see Who May Approve, which also describes an optionalAccessPolicylayer for teams that want the Connector itself to withhold the credential until the approval is recorded. - Promotion pipelines are fixed templates. A template is a reviewed
Pipeline— the one from the promotion page,promote-artifacts-with-approval, is the model — that declares adst-registry-configworkspace and exposes onlyimageMappingsand the TLS switches as run-time inputs; eachPipelineRunbinds the Connector to that workspace. There may be several templates — one per artifact kind, say — and they may live in the promotion namespace or come from a governed catalog; the rules below take a list of template names, and rules 1 to 3 have a variant for catalog-resolved references.
Kyverno's job is then narrow and precise: a request that mounts <production-connector> must
be one of those templates, the templates must not be tampered with, and the inputs the template
is given must describe a legitimate promotion. A run that does not mount the Connector is not
examined at all — under the convention it cannot reach production, so there is nothing to
constrain.
The promotion page binds Secrets to both workspaces. To adopt the convention, replace the
destination binding in the PipelineRun with the Connector — that is the only required
change; the source side may keep its Secret or use a Connector of its own. The
Connector may live in the promotion namespace or be shared from another one — a project- or
cluster-level Connector is referenced as namespace/name just the same; which Pods may mount
it is exactly what rules 1, 2 and 3 decide. This is
the spec.workspaces portion of the PipelineRun — the quick check carries a
complete run in exactly this shape, ready to copy:
Replace in this block: <connector-namespace>, <connector-name>.
Two things follow from routing the copy through the Connector. Admission-time checks cannot show them — nothing starts a Pod there; the checks in Check it against a real promotion run do.
- Destinations are still written with the real registry host. The proxy is transparent:
imageMappingsnames<approved-target-registry>/team/app:1.2.3exactly as it would with a Secret, and<approved-target-registry>is simply the host of the Connector'sspec.address.skopeo-copyreports the pushed digest as usual, and it equals the source digest. - The copy passes through the proxy on both sides, but only the destination connection is
re-signed. The
.envsetsHTTPS_PROXYfor every host exceptlocalhost, so the source pull goes through it as well — yet the proxy substitutes its own certificate only for aCONNECTtarget that matches the Connector's own address, host and port included, and tunnels every other host through untouched. So the destination connection sees a certificate issued by the Connectors CA, while the source connection still sees the source registry's own. The promotion page's template keeps verification on by exposing both CA workspaces as optional and letting each run bind what its hops need: a Connector-backed run binds the Task'sdst-ca-bundleto the mount, whoseca.crtfile is that CA, andsrc-ca-bundletakes the site's own CA when the source registry uses a private one. Rule 10'sallowedCopyWorkspaceslist already carries both names, so nothing has to be reviewed in to adopt it. SettingdstTLSVerifyto"false"is the fallback for a template that cannot bind the mount. The policies do not constrain the TLS switches; see Limits.
The rules recognise both ways of naming a Connector on a connectors-csi volume: the
single-Connector form above (connector.name, with connector.namespace defaulting to the
run's namespace) and the multi-Connector form (connectors: ns-a/name-a,ns-b/name-b, available
when the enable-multi-connector feature flag is on). They normalise the list the way the
driver does — whitespace around an entry is trimmed, empty entries are dropped, and a bare
name resolves in the run's namespace — so a padded or unqualified spelling of the production
Connector is still recognised. A Connector of the same name in another
namespace is a different Connector and is not matched.
Why not anchor on the Task? Because the Task is shared. A rule that said "every skopeo-copy
must obey promotion rules" would reject legitimate mirroring, and — worse — would be satisfied by
any copy that avoided the Task, since there is nothing else identifying it as a promotion. The
Connector has neither problem: it is required for the outcome, and it is not used for anything
else.
Where This Fits in a Promotion Pipeline
Promotion exists to guarantee one thing: what runs in production is the artifact that was tested, unchanged. Rebuilding per environment forfeits that guarantee — the same source can resolve different base layers or dependencies on a later build — which is why a promotion copies rather than rebuilds.
Enforcing that guarantee end to end takes three gates. This guide implements the admission
side of the middle one; the approval side of that gate is the template's ApprovalTask,
which the promotion page builds, and Who May Approve pins down who may answer
it. The other two gates are sketched here so
the boundary is clear; each is a design of its own and neither is required to adopt the policy
below.
Gate 1 — evidence at build time. A promotion decision is only as good as what is known about
the artifact. The catalog ships the pieces: syft produces an SBOM, trivy-scanner produces a
vulnerability verdict, and cosign signs images and attaches attestations. Tekton Chains, where it
is enabled and holds a signing key, records signed build provenance for every run without the
pipeline having to ask — a platform-level setting, configured once by whoever operates the
cluster, not something a promotion pipeline turns on. The output of a build then is not an image
but an image plus a set of statements about it that can be checked later by someone who does not
trust the pipeline.
Verification does not have to wait for the target cluster. The cheapest place to require
"only promote what passes" is a task in the promotion pipeline itself, before the copy: a
cosign verify step, or a scan verdict, that fails the run when the artifact does not qualify.
The approval step then approves something that has already been checked. Because the template
is protected by this policy, such a step cannot be removed by whoever starts the run.
Gate 3 — verification at the point of use. Gates 1 and 2 constrain the promotion path. Neither constrains a workload that obtained its image some other way. Closing that requires the consuming cluster to verify the artifact itself — Kyverno can require a valid signature or attestation before a Pod is admitted. This is what turns the promotion path from a convention into a technical constraint: an image that skipped it does not run. It depends on gate 1 actually signing, and on that cluster's Kyverno being able to reach the registry holding the signatures — verify that reachability before planning around it, because a policy that cannot read the registry fails closed and rejects everything.
Promote by digest, and bring the signatures along. A tag can be repointed after it is
approved; a digest cannot. The parity rule below compares repository paths, so tag-based mappings
remain usable, but passing repo@sha256:… on both sides of a mapping turns "the artifact is
unchanged" from an assumption into something the policy enforces. skopeo-copy already reports
each destination in repo@sha256:… form for that reason.
Signatures and attestations do not travel with the image on their own — they are separate
objects, stored in the same repository under tags derived from the image digest
(sha256-<digest>.sig, sha256-<digest>.att). Promoting them is therefore just more mappings in
the same imageMappings list, and the rules below admit them: the repository path is identical
on both sides, only the tag differs. Two details make this work in practice:
- Keep the default
multiArch: all. Copying the whole manifest list preserves the digest, so signatures made against it stay valid at the destination. Selecting a single architecture, or converting the manifest withdstImageFormat, produces a different digest — and every signature and attestation keyed to the old one stops matching. - The promotion run gets provenance of its own.
skopeo-copyreports its destinations in the form Tekton Chains consumes, so where Chains is configured with a signing key, the promotion itself is attested — a record of this copy, distinct from the build provenance carried over in the mappings above.
Prerequisites
- The promotion pipeline from Promote Artifacts with Skopeo Copy is in place, with the destination workspace bound to a Connector as shown in Keep the Production Credential in a Connector.
- Alauda DevOps Connectors is installed, and an OCI or Harbor
Connectorfor the production registry exists — in the promotion namespace, or shared at project or cluster level. No Connectors feature flag is required; the optionalAccessPolicylayer in Who May Approve is the only part that needs one. - Kyverno is installed and all of its controllers are ready. On ACP it is delivered by the Compliance Management plugin.
- You can create cluster-scoped
ClusterPolicyobjects — the rules are cluster-scoped because they must apply regardless of who creates the run, and because a Connector can be mounted from any namespace. kubectlcan reach the cluster that runs Tekton and Kyverno. Every command in this guide runs against that cluster.
Locate the Kyverno installation before checking it — the namespace is an install-time choice, not a fixed name:
Ready pods are a precondition, not proof that admission is being served — the dry-run checks further down are what actually prove it. Note the direction of failure these policies choose: with the fail-closed webhook they declare, an unavailable admission controller does not silently stop enforcing, it rejects every matching request, which stops promotions altogether.
Placeholders to Replace
Replace every occurrence before applying a policy. A stale placeholder does not fail loudly: it silently changes which requests are matched, or which registries are trusted.
Three notes on reading the table:
<connector-namespace>and<connector-name>in thePipelineRunexamples are the two halves of<production-connector>: withdevops-prod/prod-harbor, the Connector mounts asconnector.namespace: devops-prodandconnector.name: prod-harbor.- The table lists every string to substitute. An angle-bracket string that is not in the table
is notation local to the spot where it appears, and stays as it is:
<tekton-namespace>sits inside the ready-made value of<tekton-controller-identity>(the check command prints the whole identity, nothing to fill in by hand),<digest>describes the signature tag patternsha256-<digest>.sig, and the scripts' comments andjqexpressions use angle-bracket notation of their own. - The workspace names
dst-registry-configanddst-ca-bundleare literals, not placeholders, because the promotion templates declare them under those names. If your template names the destination workspace or its CA workspace differently, change the names inpromotion-runs-use-a-templateaccordingly.
The Rules
Each rule below is a complete, standalone policy object. The YAML blocks are not fragments
of one large policy: any one of them can be saved and applied on its own. All eleven carry the label
app.kubernetes.io/part-of: promotion-guardrails, which is how
Composing the Rules operates whatever subset you chose as a unit.
Every object in this guide is a cluster-scoped ClusterPolicy — the demo set is the
platform's baseline. Kyverno also has a namespaced Policy kind with the same rule syntax,
which is how a team tightens the baseline for its own namespace without cluster-wide rights;
Tightening a Namespace introduces it and links a worked reference.
Every rule that inspects runs carries the same recognition preamble in its context: the
connectors-csi scan described in What Counts as a Promotion, ending in the
mountsProductionConnector precondition. It is repeated verbatim in each object — the price of
policies that can be applied independently — so when you change how the Connector is recognised,
change it in every rule you deploy.
How to prove the deployed set behaves as described — without persisting anything — is a single procedure for the whole set, covered in Applying and Verifying.
Rule 1: a run holding the production credential must be a template
promotion-runs-use-a-template matches every PipelineRun creation on the cluster, but its
precondition keeps it silent unless a connectors-csi volume names <production-connector>.
The rule looks everywhere such a volume can be declared — the workspace bindings, the pod
templates (taskRunTemplate, taskRunSpecs), and the volumes of any inline taskSpec — so
mounting the Connector as a raw volume instead of a workspace is recognised just the same.
When one does, the run must be created in <pipeline-namespace>, reference one of the
templateNames — by local name in the base rule below, or through the pinned hub-resolver
form when the templates are served from a catalog (Templates from a catalog, after the
policy) — never an inline pipelineSpec, and carry the
Connector only as the dst-registry-config workspace binding: any other workspace, and any pod
template or inline Task volume naming it, is refused. The one other workspace name it accepts is
dst-ca-bundle, which a Connector-backed run binds to that very same mount so the destination CA
is the mount's own ca.crt — the same files, in the same Pod, of the same reviewed Task, so it
exposes nothing the credential binding does not. Anything else holding the credential is
refused before it starts, whatever it intended to do with it.
The workspace check matters because the template's source workspace is also a credential mount: binding the production Connector there would let the run read production, which a promotion never needs.
Two more details close the gaps a reviewer is likely to look for. The rule also refuses a
taskRunTemplate.podTemplate or taskRunSpecs[].podTemplate carrying hostAliases,
dnsConfig, dnsPolicy, hostNetwork or env. These fields are harmless on their own —
runs that do not mount the Connector keep full use of them — but combined with the mounted
credential they change what the run does with it: the first four change where the proxy and
registry hostnames resolve, so a mapping that names the approved target (and passes rules 6
and 7) can be made to log in — presenting the production credential — at whatever address the
pod template points the name to; env can rewrite PATH so that a binary planted in a
workspace runs in place of skopeo, with the credential mounted beside it. It checks those keys
rather than the presence of a pod template, because Tekton injects its configured default pod
template (securityContext on ACP) into every run before this policy sees it. And every Tekton kind is
matched as tekton.dev/v1/... only, although Tekton serves v1beta1 alongside v1: the webhook
Kyverno generates for a v1-only rule registers matchPolicy: Equivalent, so the API server
converts a v1beta1 request to v1 — the top-level podTemplate becomes
taskRunTemplate.podTemplate, taskRunSpecs[].taskPodTemplate becomes
taskRunSpecs[].podTemplate — before Kyverno sees it, and the rule reads the v1 field names
alone. Do not add v1beta1 to kinds "to be safe": that registers the version too, the
conversion stops, and every renamed field would then have to be read under both spellings by
hand. To prove the conversion holds on your cluster, submit a v1beta1 run that mounts the
Connector as a server dry run: rule 1 refuses it by name.
Complete policy YAML —
promotion-runs-use-a-template (copy-paste ready)
Replace in this block: <promotion-pipeline>, <production-connector>, <pipeline-namespace>.
Templates from a catalog
When your templates are served by the internal hub from a
governed catalog rather than created as Pipeline objects in the namespace, a run references
them through the hub resolver — which the base rule refuses, because a resolver reference is not
a local name. To also accept a
hub reference — pinned to the trusted catalog, the pipeline kind and a name from the same
templateNames list, the version parameter deliberately not compared, so whatever version the
catalog serves under that name is accepted (the catalog bullet under Limits weighs
that) — edit the collapsed policy above: in the rule's context list, replace the single
refIsApprovedTemplate entry with the seven entries below (the six ref* variables it needs
must come first, exactly as ordered). The trust then rests on the catalog's own governance instead
of rule 4. Two of the entries close the ways a hub reference can be pointed at another hub while
keeping the approved coordinates: the hub resolver accepts a request-level url parameter that
replaces the cluster-configured hub endpoint, and a type parameter that switches it to the
Tekton Hub endpoint set — with either, the same catalog and name resolve to whatever that other
hub serves. The variant refuses any url, and accepts type only when it is omitted or
artifact. Omitting type is safe only while the cluster's hub resolver defaults to artifact;
confirm that once, and have every template reference write type: artifact if it does not:
What the variant still trusts is the cluster-configured hub endpoint itself: whoever can change
hubresolver-config changes what the pinned names resolve to, so that platform configuration
is controlled like ClusterPolicy — see Limits.
Replace in this block: <catalog-name>.
Check the variant the same way as the base rule, with server dry runs: a run whose reference
carries a url parameter, or a type other than artifact, is refused by name, and the plain
reference to an approved template is admitted. The same holds over the parent chains of rules 2
and 3 once the parent test for rules 2 and 3 is installed.
Rule 2: no TaskRun outside a template
Tekton creates the TaskRuns of a PipelineRun itself, as <tekton-controller-identity>, and
labels each with the PipelineRun it belongs to. Anyone with permission to create TaskRuns
could instead write one by hand, mount the Connector, and skip the pipeline and its approval
step. no-taskrun-outside-a-template closes that: a TaskRun that mounts
<production-connector> — as a workspace, a pod template volume, or a volume of an inline
taskSpec — must come from the Tekton controller, in <pipeline-namespace>, and belong to a
PipelineRun that references one of the templates by name.
The last condition is checked against the parent run itself, reached through the TaskRun's
controlling ownerReference — not through a label. The rule takes the PipelineRun named
by the ownerReferences entry with controller: true, looks it up with a Kyverno API call, and
applies the same test rule 1 applies to a run: a by-name reference to an approved template, no
resolver, no inline pipelineSpec. The owner reference is the parent pointer that rule 2's
second rule reserves for the Tekton controller, which writes it from the run it is reconciling:
a hand-written TaskRun may carry one, but it is refused unless the controller wrote it. Every
tekton.dev/* label on a child, by contrast, is open to the run's author —
taskRunSpecs[].metadata.labels is copied onto the TaskRun first and wins over the
controller's own values, so a run can carry a tekton.dev/pipelineRun label naming another
run, and tekton.dev/pipeline is derived from the run's name when the pipeline is inline.
Verified: an inline run named after the template, referencing a Task that declares the
Connector volume in its own definition, sailed past a label check on every object. The lookup is
made only for TaskRuns that mount the Connector — Kyverno loads a context entry when it is
first used, after the precondition, which was confirmed by pointing the call at a path that does
not exist and watching ordinary requests stay admitted — so it adds nothing to runs outside the
convention. The admission controller needs permission to list PipelineRuns; on ACP it has it.
An owner reference is only as trustworthy as its writer, and a TaskRun written by hand can
carry one too. That does not matter for the rule above — a hand-written TaskRun fails the
creator check regardless — but it matters for rule 3, which reaches the parent
through the TaskRun of a Pod: a TaskRun that mounts nothing itself, references a Task
declaring the Connector volume, and forges an owner reference to a real promotion run would
hand the controller a Pod whose owner chain looks legitimate. The object therefore carries a
second rule, taskrun-owner-references-set-by-the-controller: in <pipeline-namespace>, a
TaskRun that gains a controlling PipelineRun owner reference — on creation, or added by an
update — must be created or updated by the Tekton controller. It has no Connector precondition,
because it is what makes every TaskRun's owner reference in the namespace mean what it says;
its cost is a comparison of two short lists on each TaskRun request. The comparison is over
the whole reference — apiVersion, kind, name and uid joined — not the uid alone, so
changing any field of a reference that is already there counts as adding one. And because rules
2 and 3 fetch the parent by name, they also require the run they find to carry the uid the
reference names: a reference renamed to another run points nowhere. Removing an owner
reference is left alone (the garbage collector does that when a run is orphaned), and so are
the controller's own label and annotation updates.
A TaskRun is not the last place a mount can be declared: a Pod written by hand, or produced
by any controller other than Tekton, never passes through this rule — and a Task that
declares the volume in its own definition surfaces the mount only on the Pod, because the
TaskRun that references it carries no volume. Rule 3 covers those; deploy the
two together. Templates served from a catalog need the parent test widened the same way as
rule 1 — the parent test for rules 2 and 3 below serves both.
Complete policy YAML —
no-taskrun-outside-a-template (copy-paste ready)
Replace in this block: <promotion-pipeline>, <production-connector>, <pipeline-namespace>, <tekton-controller-identity>.
Catalog-served templates: the parent test for rules 2 and 3
The parentIsApprovedTemplate entry in the policy above — and the same entry in rule 3 —
accepts only a namespace-local Pipeline referenced by name. With the catalog variant of rule 1, a legitimate
promotion run references its template through the hub resolver, and the parent test in rules 2
and 3 must accept the same form. In each of the two policies, replace the single
parentIsApprovedTemplate entry with the seven entries below (the six parentRef* variables
it needs must come first, exactly as ordered). The comparison pins the catalog, the pipeline
kind and the template name from the same templateNames list — not the version parameter,
which is left uncompared here exactly as in rule 1's variant (the catalog bullet under
Limits weighs that) — and refuses a request-level url or a type other than
artifact, as rule 1's variant does and for the same reason.
Replace in this block: <catalog-name>.
Rule 3: no Pod outside a template
Rules 1 and 2 judge Tekton's own objects. Whatever they admit — and whatever never reaches
them — ends as a Pod, and the Pod is where the driver hands the credential out:
connectors-csi mounts for any Pod whose volume names the Connector, however that Pod came to
exist. A Pod written by hand, a Deployment, a Job, a CronJob, or any other controller's
workload that mounts <production-connector> passes through neither rule — and neither does a
Tekton Task that declares the volume in its own definition, because the run and the TaskRun
that reference it carry no volume; only the Pod does. no-pod-outside-a-template closes all of
that at the one place every path converges: a Pod that mounts the Connector must have been
created by the Tekton controller, in <pipeline-namespace>, for a PipelineRun that
references one of the templates by name — the parent reached through the Pod's controlling
ownerReference to its TaskRun, and that TaskRun's controlling ownerReference to the
run: two API calls, and the same test rule 2 applies at the end. Labels are not consulted; the
ones the controller stamps on a Pod include whatever taskRunSpecs[].metadata.labels the run's
author chose. The second link holds because rule 2's taskrun-owner-references-set-by-the-controller
keeps a hand-written TaskRun from carrying a controlling PipelineRun reference in the first
place — from the moment the rule is installed, since it runs with background: false and judges
admission-time creations and updates but never rescans objects that already exist; a TaskRun
that predates the apply keeps whatever reference it was given, and draining those is the rollout
step's job (Rolling out) — and because it compares the whole reference, so an
existing one cannot be renamed onto another run while keeping its uid. Each hop also checks
that the object fetched by name carries the uid its reference names, so a renamed reference
points nowhere. That is one more reason to deploy rules 2 and 3 together.
This is the floor under the whole set. Rules 1 and 2 fail a bad request earlier, with a message
that names what the run got wrong; rule 3 is what makes "only Tekton, only from a template"
true for everything else that can declare a volume. Deploy it whenever rules 1 and 2 are
deployed. The parent test is a reference test: the parent's content — its mappings, its pod
template — was judged by rule 1, and by whichever of rules 5 to 11 are deployed, when the
parent was created, which is why runs that
predate the set must be drained before the set is trusted (Rolling out). A Pod declares volumes in one place, spec.volumes, so its recognition preamble is
shorter than the run rules'; the two Connector spellings are normalised the same way.
Two properties of a Pod rule are worth knowing before applying it. Kyverno applies it to the
workload kinds that produce Pods as well — Deployment, StatefulSet, DaemonSet, Job,
ReplicaSet, ReplicationController and CronJob — by generating a matching rule for each
(autogen-no-pod-outside-a-template, autogen-cronjob-no-pod-outside-a-template), so a
Deployment that mounts the Connector is refused when the Deployment is created, not later
when its controller tries to start a Pod; the refusal names the generated rule. And because the
rule matches every Pod creation on the cluster, the fail-closed webhook it declares stops
all Pod creation while Kyverno is unavailable, not only promotions — Limits
weighs that trade-off.
Complete policy YAML —
no-pod-outside-a-template (copy-paste ready)
Replace in this block: <promotion-pipeline>, <production-connector>, <pipeline-namespace>, <tekton-controller-identity>.
Catalog-served templates. Replace the parentIsApprovedTemplate entry above exactly as
the parent test for rules 2 and 3 under rule 2 describes; the
substitution is the same in both policies.
Rule 4: only maintainers change the templates
The first-choice answer to template integrity is not a policy. Templates served from a
governed catalog (the catalog variant of rule 1) are protected by the catalog's own release
process and access control, and this rule is simply not needed — leave it out. Reach for it
when the templates live in the namespace as Pipeline objects, and mind the trade-off: the
team that owns the templates edits them through the maintainer identities, so the maintainer
list below is one more thing to keep current.
The promotion page keeps approvers and the Task reference inside the Pipeline so that a run
cannot override them. That is only as strong as the Pipeline objects themselves.
template-changes-by-maintainers-only matches create, update and delete of the named templates
in <pipeline-namespace> and refuses any requester not listed as a <template-maintainer>.
It protects namespace-local templates; templates served from a catalog are governed by the
catalog instead, which is what makes this rule optional.
The rule sets allowExistingViolations: false. Its condition depends on the requester rather
than on the object, and without that setting Kyverno evaluates an update against the old object
too, finds the same "violation" there, and admits the update as pre-existing — which would leave
exactly the operation the rule exists for unguarded.
Complete policy YAML —
template-changes-by-maintainers-only (copy-paste ready)
Replace in this block: <pipeline-namespace>, <promotion-pipeline>, <template-maintainer>.
Rule 5: approvers come from the namespace's allowed list
The template names its approvers, and rule 4 keeps that from being edited — but that protection
is namespace-local and coarse: any maintainer edit is allowed, and a template that takes its
approvers as a run parameter would let whoever starts the run choose who approves it.
promotion-approvers pins the values: one policy object with two rules sharing one
allowedApprovers list. run-approvers-from-the-allowed-list refuses a promotion run whose
approvers parameter names anyone outside the list — whoever creates the run, whatever the
template does with the parameter. template-approvers-from-the-allowed-list refuses a
Pipeline — template or not, maintainer or not — whose ApprovalTask steps name anyone
outside it. It judges creations and edits, not what already exists: audit pre-existing
templates once at rollout (Rolling out).
The same list is checked later by the approval machinery itself (Who May Approve), but later is the point: this rule fails the request at admission, before a run exists that an unlisted approver could wave through, instead of after the pipeline has started and stopped at an approval nobody trusted was allowed to give.
Both rules read the parameter whether it is an array or a single string. A run that passes no
approvers parameter is untouched — the template's own reviewed list, or the parameter's
default, applies, and both are themselves held to allowedApprovers by the second rule. The
parameter name approvers is pinned; a template that names it differently needs the rule
adjusted.
Two consequences are worth spelling out. The recommended default remains a fixed, reviewed
approver surface in the template (Who May Approve); run-time approvers are the
variant for teams whose promotions genuinely need them, supported through exactly one
shape: a template may plumb the parameter named approvers into its ApprovalTask
(value: $(params.approvers[*]) for an array, value: $(params.approvers) for a string) —
the run side then judges the values each run supplies, and the template side judges the
parameter's default. A reference to any other parameter name is refused as an unlisted
literal, deliberately: the run side only reads the parameter named approvers, so a
differently named parameter would smuggle names past it. And the template side judges
Pipeline objects in <pipeline-namespace> only: a template served from a catalog never
passes through cluster admission, so its hard-coded approvers are part of the reviewed catalog
content — the same trust boundary as the catalog variant of rule 1 — and the approval
machinery's own late check still applies to whatever the run ends up waiting on.
Complete policy YAML —
promotion-approvers (copy-paste ready)
Replace in this block: <allowed-approver>, <production-connector>, <pipeline-namespace>.
Rule 6: sources must come from an approved registry
Every imageMappings source must start with docker:// plus an approved host. A promotion
moves an artifact out of the registry where it was built and tested; a source anywhere else has
no such history. The check is a foreach over the mappings: each source is
compared against a list of approved-registry wildcard patterns in a single condition —
trusting another registry is adding one entry to that list, not writing a new condition:
Complete policy YAML —
sources-from-approved-registries (copy-paste ready)
Replace in this block: <production-connector>, <approved-source-registry>.
Rule 7: destinations must land in an approved registry
Every imageMappings destination must start with docker:// plus an approved target host. The
Connector already limits where the credential works; this rule limits where the run is
allowed to aim, so that a mapping pointing elsewhere is refused up front rather than failing
inside the copy. Teams that do not restrict destination names simply do not apply it. The shape
mirrors rule 6 — one wildcard-pattern entry per approved host:
Complete policy YAML —
targets-in-approved-registries (copy-paste ready)
Replace in this block: <production-connector>, <approved-target-registry>.
Rule 8: mappings must be present and well formed
imageMappings must be a non-empty list in which every item is exactly SRC DST: two
space-delimited references, with no embedded newline, tab, or carriage return. A run with no mappings would hold the credential and copy nothing, which is never a
promotion; a malformed item is refused because the rules that judge content cannot judge what
they cannot parse. This is also the rule that owns the list requirement: rules 6, 7, 9 and 11
coerce a single-string value and judge its content, but only this rule refuses the non-list
form itself (which Tekton would also reject at run time against the template's array
parameter).
Complete policy YAML —
mapping-shape (copy-paste ready)
Replace in this block: <production-connector>.
Rule 9: the destination repository must match the source
target-path-matches-source compares the repository path — everything after the registry host,
before the tag or digest — on both sides of each mapping. team/app must land in team/app.
A port on the host is part of the host, not the path: registry.example.com:5000/team/app and
registry.example.com/team/app share the path team/app, and the port's colon never reaches
the tag comparison, because the host is dropped before the tag is looked at.
The tag or digest may differ, so app:1.2.3 may be promoted as app:stable, and the .sig and
.att companions of a digest-pinned image pass unchanged. A destination in a different
repository is refused: an approval covers an artifact, not a rename. The two paths are compared
inside JMESPath rather than by a Kyverno operator, because the operators read a purely numeric
string as a number — 0123 would otherwise equal 123, and the rename would be admitted.
Complete policy YAML —
target-path-matches-source (copy-paste ready)
Replace in this block: <production-connector>.
Stricter variant: forbid retagging as well. In the collapsed policy above, replace the
comment and the two entries named srcRepo and dstRepo in the foreach block's context
with the block below — the comment goes too, because it describes the base rule's parsing and
this variant keeps the tag:
With this variant, promoting app:1.2.3 as app:stable is refused, and so is the multi-tag
pattern the promotion page documents under Promote One Image to Multiple Destinations.
Rule 10: the templates expose only the reviewed inputs
The guardrails read imageMappings and approvers because those are the inputs the reviewed
templates expose — and skopeo-copy itself accepts more: srcImage and dstImages name
content to copy, the copy-mappings workspace feeds mappings in from a file, skopeoImage
replaces the executing image and extraCopyArgs its arguments. A run cannot reach any of
them: rule 1 pins runs to the templates, and Tekton silently drops parameters a template does
not declare. The one path left is a maintainer edit that widens the template itself, and
templates-expose-only-reviewed-inputs closes it: on the named templates, the copy step's
parameters and workspaces are whitelists, so an input added later — including one this guide
has never heard of — is refused until it is reviewed into the lists. The CA-bundle workspaces are
the worked example, already carried out: the promotion page's template exposes src-ca-bundle and
dst-ca-bundle as optional workspaces so a proxied copy can keep TLS verification on — a
Connector-backed run binds the destination one to the Connector mount for its ca.crt, and the
source one takes the site's own CA — and both names sit in allowedCopyWorkspaces below. The
rule also refuses an inline taskSpec anywhere in
the template. That is not a second concern but the same one: the whitelists find the copy step
through its taskRef, so a step rewritten inline would present no parameters and no workspaces to
compare and would pass every check while running whatever it liked. Promotion templates reference
every task they run — from the namespace or from a catalog — so refusing inlining costs the
reviewed shape nothing. The promotion page's srcImage/dstImages alternative is different —
whitelisting it here would not make
it safe, because rules 6–9 and 11 judge only imageMappings and would never see what it copies;
that form stays outside the guardrails, as its own section says. Other Pipelines in the
namespace are left alone; if one of them mounts the Connector, rule 1 already refuses it.
Templates served from a catalog are not Pipeline objects in the namespace, so this rule
cannot see them: their input surface is governed by the catalog's own release process, as
rule 4 leaves their edits to it.
Complete policy YAML —
templates-expose-only-reviewed-inputs (copy-paste ready)
Replace in this block: <promotion-pipeline>, <pipeline-namespace>.
Rule 11: sources carry a release tag
The parity rule keeps a promotion inside one repository; it says nothing about which build
of that repository may be promoted. app:latest, app:dev-20260902, a branch tag, or an
untagged reference (an implicit latest) all satisfy rules 6 to 9 and would be promoted with
the approval. This rule requires every imageMappings source to be a release artifact: pinned
by digest (@sha256: followed by 64 hex digits), or carrying a tag that matches one of the
approved patterns — one regular expression per entry, the semver.org grammar with an optional
v prefix by default. The second default entry admits
the sha256-<digest>.sig / .att / .sbom tags cosign stores beside a digest-pinned image,
so a signed image promotes together with its signature and attestation. Destinations are not
judged: retagging app:1.2.3 as app:stable on the way in stays allowed, as the parity rule
intends. Teams whose release tags follow another convention put their patterns in the list;
teams that do not gate on tags simply do not apply the rule. The tag is read from the last
path segment of the reference, so a registry port (host:5000/team/app:1.2.3) is not mistaken
for a tag.
Two patterns are ready to copy into <approved-source-tag-pattern>. The default is the
semver.org grammar, v prefix optional — it admits pre-release and build-metadata tags such
as 1.2.3-rc.1, for teams that promote release candidates through the same path:
The strict form admits only v?major.minor.patch — 1.2.3 and v1.2.3, never 1.2.3-rc.1,
1.2.3+build.5 or 01.2.3 — for teams whose production may carry final releases only:
Complete policy YAML —
sources-carry-release-tags (copy-paste ready)
Replace in this block: <production-connector>, <approved-source-tag-pattern>.
Composing the Rules
Adopting the guardrails is choosing a subset and operating it as one unit. The subset has a
floor: rules 1, 2 and 3 always, and rule 4 whenever the templates are Pipeline objects in
the namespace — without it, anyone who can update a Pipeline there can replace the reviewed
template under its approved name, and rules 1, 2 and 3 will keep trusting that name. Save each chosen
rule's object — or all of them in one file, separated by ---, or a few bundles built as in
Bundling rules into fewer objects — replace the placeholders, keep
the file in version control, and apply it. The shared label is the handle for everything that
follows:
Three consequences of the one-object-per-guardrail shape are worth spelling out:
- Dropping a guardrail is deleting an object, not editing YAML inside a shared policy — and adding one later does not touch the ones already running.
- Denials name the rule. A defective request that violates several deployed rules is refused once, with every violated rule listed under its own policy name in the message.
- Reports attribute per rule. Each policy produces its own
PolicyReportresults, so "which constraint would this run have violated" is readable per rule during anAuditobservation window.
Bundling rules into fewer objects
One object per guardrail is the shape this guide ships, because it makes each rule a unit
you can apply, audit and delete on its own. Nothing requires it: a ClusterPolicy holds any
number of entries under spec.rules, each with its own match, context, preconditions
and validate, and the objects of rules 2 and 5 already carry two each. Three techniques reduce the count or
the repetition, in increasing order of what they change. The first and the third change only
the deploying objects, not what is refused: verified, the same requests are refused by the same
rule names with the objects replaced. The second was verified with a server dry run that shows
the alias expanded.
Concatenate the rules lists. Rule names are unique across the set, so bundling needs no
renaming. Group by lifecycle rather than by count: the rules you will switch on, audit and
roll back together belong together — the core trio (rules 1 to 3), the template rules
(4, 5 and 10) and the content rules (6 to 9 and 11). Build a bundle from the policy files
above with yq (Mike Farah's yq v4, yq --version) — one file per guardrail, rule 5's
contributing its two rules; the expression keeps every field of the
first file except rules, which becomes the concatenation, and metadata.name, which it
sets, so every other field of the inputs must agree — in this guide they do; check in
particular background, admission, webhookConfiguration, applyRules and any
validationFailureAction you may have added, since a difference there would be taken
silently from the first file:
Replace in this block: <bundle-name>, <first-rule-file>, <next-rule-file>.
Four things change when rules share an object, none of them in what the rules decide:
- An invalid rule takes the whole bundle out of service. Kyverno rejects some mistakes
at
kubectl apply; others get through and leave the objectREADY=False— and a policy that is not ready evaluates none of its rules, so every guardrail in that bundle is silently absent until it compiles. (failurePolicy: Failis about the webhook being unreachable or a rule erroring during evaluation; it does not cover a policy that never loaded.) CheckREADYafter every apply, as Composing already does, and try a new bundle on a test cluster — or as a namespacedPolicycopy in a scratch namespace — before it carries production. - Rollback granularity is the bundle. The emergency delete under Rolling out removes a whole bundle; the Audit switch there patches every rule of every object and needs no change.
- Reports and denials name the bundle.
PolicyReportresults carry the bundle inpolicyand the rule inrule, and a denial message is headed by the bundle name with the rule beneath it. The post-run check under Check it against a real promotion run filters by policy name — replace the single-rule names in itsjqlist with your bundle names; thekind/rule=resultlines it compares are unchanged. contextstays per rule. Kyverno shares nothing between rules, so the Connector recognition preamble is still repeated in every rule that recognises the Connector — nine of the thirteen rule entries; rules 4 and 10 and rule 5's template side judgePipelineobjects, and rule 2's owner-reference rule judges everyTaskRun, so those four carry none. Bundling reduces the object count, not the YAML. The two techniques below address that.
Swap in the safe order: apply the bundles, wait for READY=True, then delete the per-guardrail
objects they replace — and the same order going back. While both exist the Connector stays
guarded, but every request is judged twice: a denial lists both objects, PolicyReport
carries two rows per check, and the exact-count checks under
Check it against a real promotion run fail until the old objects are gone. A gap
between delete and apply is worse, because nothing guards the Connector in between. Both shapes carry the shared label, so
the label selector under Rolling out finds either.
Author the preamble once with a YAML anchor. Within one file, YAML anchors let a bundle declare the recognition preamble a single time and reference it from every rule — the server receives the expanded copies, so nothing changes at run time, but an edit to the preamble is made in one place:
kubectl resolves the alias before sending the object; a server dry run of such a bundle
shows the second rule with the full context list. Anchors do not cross documents, so this
only helps within a bundle, and a rule that needs one extra entry cannot append to an alias —
give it its own list.
Fold same-shaped rules into one rule. The content rules (6 to 9 and 11) share their
match, precondition and imageMappings extraction; they can become one rule with one
preamble, one foreach over the mappings and every check as a condition of the same deny.
What is given up is attribution: a rule yields one result and one message, so which check
refused is visible only in values the message echoes, PolicyReport shows one row for the
five, and a denial names promotion-content where it named one of the five (the same requests
are refused; only the name changes). The checks under
Check it against a real promotion run change the same way: in the post-run
script, list promotion-content in the jq policy filter and replace the five
PipelineRun/<content rule>=pass entries of expected with one
PipelineRun/promotion-content=pass (the script's comment then counts three PipelineRun
rules, not seven), and the renamed-copy check must look for the promotion-content: heading
instead of target-path-matches-source:. Choose this when one
switch for "content" is what you want; keep separate rules when you need to see which one
fired.
Complete policy YAML —
promotion-content, the five content rules as one (copy-paste ready)
Replace in this block: <production-connector>, <approved-source-registry>, <approved-target-registry>, <approved-source-tag-pattern>.
Beyond these, Kyverno's CEL-based ValidatingPolicy (policies.kyverno.io/v1alpha1) is the
shape that offers both: variables computed once per request and shared by every entry in
validations, each with its own message. It is a rewrite of every rule into CEL rather than
a rearrangement, so this guide does not go there; see Limits.
Tightening a namespace with Policy
The cluster-wide set is the platform's baseline; teams differ, and a namespace is free to
be stricter than the baseline without asking the platform to change it. That is what
Kyverno's namespaced Policy kind is for: the rule syntax is identical to ClusterPolicy,
but the object lives in a namespace, evaluates only that namespace's requests, and is managed
by the namespace's owners — no cluster-wide permissions involved. Copy any run rule from this
guide into a Policy, give it tighter values (a shorter approver list, a single approved
source), and apply it in the namespace. Layering is safe by construction: every matching rule
of every policy must pass, so an added Policy only ever tightens — it cannot loosen the
baseline, and there is no precedence to reason about.
This guide stops here on purpose. The two-tier model — a platform baseline as ClusterPolicy
plus per-project tightening as Policy, including how to scope the baseline safely and why
it must not depend on namespace labels — is worked out with examples in
Pipeline Policy Constraints with Tekton and Kyverno.
Applying and Verifying
Verify in both directions: a gate that has only been tested against violations is half tested — the case that breaks a release is the compliant promotion that gets rejected, and the case that breaks everyone else is the ordinary copy that gets caught.
Quick check: one denial, one admission
Both checks use server-side dry run — admission is evaluated for real, nothing is created.
First the direction that must fail: the same run your promotion pipeline produces, except that
it names a Pipeline that is not a promotion template. Replace the placeholders (the
table above) and run it as one block:
Replace in this block: <pipeline-namespace>, <approved-source-registry>, <approved-target-registry>, <connector-namespace>, <connector-name>.
Then the direction that must succeed — the identical request, with pipelineRef.name changed
to your promotion template (<promotion-pipeline>). With the catalog variants deployed,
reference the template the way your runs do instead — resolver: hub with the catalog,
kind and name params in place of pipelineRef.name. Either way the template need not exist
as a local Pipeline for this check: admission judges the reference, and Tekton resolves it
only when the run actually starts.
Replace in this block: <pipeline-namespace>, <promotion-pipeline>, <approved-source-registry>, <approved-target-registry>, <connector-namespace>, <connector-name>.
A refusal here means a placeholder does not match your environment — most often the template
name, the registry hosts, or the Connector coordinates; with rule 11 deployed under your own
pattern list, also the example's 1.2.3 tag, which must match a pattern. The refusal message names the rule
that objected; Troubleshooting maps each rule to its usual cause.
Check it against a real promotion run
Everything above is synthetic: it proves the rules work on requests this page wrote. It does not prove they match the requests your pipeline produces — and a mismatch there is silent, because a rule whose precondition never fires produces neither a denial nor a report entry. Close that gap before trusting the policies.
Start a real promotion with a template and the Connector and let it finish. Then run the
script below; every check prints PASS or FAIL and the script exits non-zero when any
failed. Check 2 needs skopeo and read access to the source registry from where you run it;
checks 1, 2 and 4 need jq. Check 3 asserts success=1, the Task's count of copied mappings:
adjust it when a run carries several. When you save it, copy the body of the block only — not
the surrounding triple-backtick fence lines, since a stray backtick run in a shell file opens a
command substitution that swallows the rest of the script.
Check 1 reads PolicyReport objects, which the reports controller writes after admission,
on its own schedule. Run the script immediately after the run finishes and check 1 can list
the PipelineRun rules but not yet the TaskRun or Pod ones, which reads as a failure that
is only a race — give the reports a moment and run it again before investigating.
Post-run verification script (four checks, copy-paste ready)
Replace in this block: <pipeline-namespace>, <promotion-pipeline>, <production-connector>, and
SRC_TLS_VERIFY when the source registry is self-signed.
Checks 2 and 3 are also where the two consequences listed under What Counts as a Promotion become reproducible: the digest equality is check 2, and the proxy routing is check 3.
Rolling out, and rolling back
To observe before enforcing, set validate.failureAction to Audit on the rules you are
introducing, run real promotions, and read the resulting PolicyReport objects:
Replace in this block: <pipeline-namespace>.
Reports are attached to the evaluated object: deleting the run deletes its report. The run rules
match CREATE only and the policies run with background: false, so a run that already existed
when a policy was installed is never evaluated and never appears in a report. It cannot be
judged later either, and nothing needs it to be: Tekton's own validation refuses every change
to a run's spec other than the status control field used to cancel it or release a pending
one — whether or not the run has started. A run created with spec.status: PipelineRunPending
is the case to be sure about, since it sits unstarted for as long as someone wants: verified,
its mappings could not be rewritten while pending (Once the PipelineRun has started, only status updates are allowed, from Tekton's webhook, even though the run had no start time), and
releasing it was accepted. So what a run was admitted
with is what it runs with. That matters for one kind of run: a promotion that predates the set
and is still waiting at its approval task. Once approved, it produces a TaskRun and a Pod that
rules 2 and 3 accept, because its parent does reference a template —
while its mappings and pod template were never checked by rules 1 and 5 to 11. Drain those runs
when the set is installed: list what is still in progress, and cancel and recreate anything you
would not have admitted.
Replace in this block: <pipeline-namespace>, <run>.
Templates are different. A Pipeline is mutable and long-lived, and one that predates the
policies is a real blind spot: rule 5's template side never rescans it, rule 1 admits runs that
reference it by name, and allowExistingViolations: false only catches it on its next edit —
until someone edits it, an unlisted approver baked into an old template keeps receiving
approval requests. Audit the namespace once when the set is installed:
Replace in this block: <pipeline-namespace>.
An empty report is ambiguous: it means "nothing violated" and "nothing matched". Check 1 of
the real-run section tells the two apart — it counts the rules that reported on a specific run —
so run it before deciding the observation window was clean, then switch the rules back to
Enforce (the same loop, with the value flipped) and re-read the per-rule action listed under
Composing the Rules.
Because the set is fail-closed, plan the way out before you need it. Deleting the policies is the fastest unblock and takes effect immediately — and the label makes it one command:
Neither step revives a run that was already rejected: admission happened once, at creation, so the run has to be created again after the change. Re-applying the version-controlled file restores the set as it was.
Who May Approve
Kyverno does not perform approvals. Who may approve a promotion is decided in two places, and the recommended shape needs only the first:
- The template names the approvers — and the approval gate enforces them. The promotion
page pins
approversandnumberOfApprovalsRequiredinside thePipeline. The manual approval gate's own admission webhook accepts a response to theApprovalTaskonly from an identity on that list — a named user acting as themselves, or a member of a named group — and only for that identity's own entry; anyone else's update is refused. So the list in the template is the approval policy, and the guardrails exist to keep it intact: rule 4 keeps the template from being edited by non-maintainers, rule 5 keeps even a maintainer's edit insideallowedApprovers, and rules 1, 2 and 3 make a run of the template the only thing that can hold the production credential — a run that skipped the approval step cannot reach production because nothing lets it mount the Connector. A template may instead takeapproversas a run parameter — a variant for teams whose promotions genuinely need per-run approvers — and rule 5 is what makes that variant safe: literal, default and run-supplied values alike are checked before the run exists. - Optionally, the Connector withholds the credential until that approval is recorded. An
AccessPolicyon the production Connector can make the driver release the mount only after the built-in checkconnectors-approvals-in-pipelinefinds theApprovalTaskcarrying the requesting Pod'stekton.dev/pipelineRunlabel approved — by at leastnumberOfApprovalsRequireddistinct identities from theapproversthe policy itself names, when those parameters are set; a group is written with thegroup:prefix, and every approving member counts individually. That moves enforcement to the moment the credential is handed out — but not on its own: the check identifies the run through the requesting Pod'stekton.dev/pipelineRunlabel, and a label is whatever the Pod's creator wrote. With rules 2 and 3 absent, any Pod in the namespace could carry an approved run's label and borrow the credential while that approval stands — a Pod written by hand, or one the controller created for a run whosetaskRunSpecs[].metadata.labelsnamed the approved run. The gate adds who may approve on the credential's side; rules 2 and 3 remain what ties the credential to the approved run's own Pods, and they read owner references, not labels — each hop matched byuidas well as by name, so a renamed reference resolves to no parent. It is not a second approval — the check reads the sameApprovalTask's recorded responses, and theAccessRequestit produces is created and resolved automatically. It costs two cluster-wide feature flags that change the behavior of every Connector on the cluster; the promotion page's Optional: release the credential only after approval weighs that and sets the layer up end to end.
Which shape to pick:
- Default — template approvers, protected by rules 4 and 5. No cluster-wide change, one list to maintain. Everything else on this page assumes this shape.
- Add the
AccessPolicylayer when the credential's owners are not the pipeline's owners — platform or security administrators own the production Connector, application teams own the pipelines — and the cluster already runs with the Connectors approval flags on, or is prepared to enable them for every Connector. Name the identities in the policy; the administrators then keep the final say regardless of what the template names. When one team owns both, use the policy without theapproversparameter: any approval of the run'sApprovalTaskreleases the credential, and the template's list stays the only one to keep.
With the layer on, the promotion page records the verified runtime behavior: a pending
approval keeps the copy Pod waiting at volume mount; an approval by someone outside the
policy's list fails the check with ApproverMismatch and the Pod gets an .error.json marker
instead of configuration, failing the step promptly; a rejection stops the pipeline at the
approval task. An AccessPolicy of that shape, naming the specific identities whose approval
releases the production credential:
Replace in this block: <connector-namespace>, <connector-name>, <allowed-approver>.
The two referenced ConfigMaps are installed by Alauda DevOps Connectors in its own system
namespace; both refs must point there. Approval-gated access requires the
enable-connectors-approval and enable-connector-proxy-permissions feature flags — the
promotion page shows how to enable them, and
covers keeping existing Connector consumers working across the flip; confirm they are on before relying on this layer:
With the flags off — the default — the AccessPolicy webhook refuses to create policies at
all, and the template's list, protected by rules 4 and 5, is the whole approval policy. With
them on, two contracts keep the layers consistent, and both were confirmed by running real
promotions against a real Connector:
- Every identity the
AccessPolicynames must also be on the template's approver surface — and the recommended default is to make the two lists identical. The check reads theApprovalTask's recorded responses, and those mirror the template's approver entries: a username in the policy is satisfied only by that user approving as themselves, and agroup:entry only by members of that same group approving through it. An approval through an entry the policy does not name — however legitimate on the pipeline side — leaves the check atApproverMismatchand the credential withheld. That is why a template surface wider than the policy is a trap, not a convenience: withnumberOfApprovalsRequired: "1", the first response completes theApprovalTask, so a well-meaning approval by someone on the template's list but outside the policy's fails the run outright (observed live: pipeline approved, checkApproverMismatch, copy step failed) — it does not wait for the right person. Keep the lists identical and the mismatch cannot happen by accident;ApproverMismatchthen only ever signals tampering. Widen the template beyond the policy only when the template collects several approvals per run (itsnumberOfApprovalsRequiredabove 1, so a policy-named identity can still respond after the others) — and even then, a run signed off exclusively by the extra identities fails. - Rule 5's
allowedApproversmust cover the template surface, policy identities included. The same identities then appear in three places with a fixed relationship: the template'sapprovers(who is asked) is a subset of rule 5'sallowedApprovers(who may be named at all), and theAccessPolicy'sapprovers(whose response releases the credential) is a subset of the template's. Rule 5 mechanically ties the first pair; theAccessPolicyis the Connector's own object, so tie the second by review. If the policy demands identities or counts the template cannot deliver, every run fails at the copy step withApproverMismatch— visible on theAccessRequest, not a hang.
One more shape came out of live testing: a pipeline with no ApprovalTask at all,
leaning on the Connector alone. It does not work with the built-in kit — the check matches
ApprovalTask objects by the run's label, and hand-created ones are never reconciled by the
approval controller, so the request stays Pending until the run times out. The
AccessPolicy check mechanism is generic (a custom check template can match other objects),
but with the built-in templates the ApprovalTask in the pipeline remains the supported
interaction point — keep it.
Limits
The policies judge what the request declares, at the moment it is created. Everything below is outside their reach and must be handled elsewhere:
-
The convention is the anchor. Every guarantee above rests on the production credential existing only as the Connector. A
dockerconfigjsonSecret for the production registry, in any namespace, is a promotion path the policies never see. Search for such Secrets before relying on the guardrails, and bound who may create them. -
The templates' contract is the policies' contract. Rules 6 to 9 and 11 read
imageMappings, and rule 5 readsapprovers, because those are the inputs the templates expose. Rule 10 refuses the edit that would widen the copy step's surface —srcImage,dstImages, thecopy-mappingsworkspace,skopeoImage,extraCopyArgsor anything newer; widening it for real needs the new input reviewed into rule 10's lists and a matching rule. A template that changes the Task reference still needs the change reviewed: rule 4 makes sure such changes go through maintainers, not that they are safe. -
The catalog variant trusts the catalog — and the hub endpoint. With the variant of rule 1, template content is whatever the trusted catalog serves under the pinned names — including newer versions, since the version parameter is not compared. Where that is too loose, extend the variant's comparison with the
versionresolver parameter, and treat catalog publishing rights as what rule 4 is for namespace-local templates. The variant refuses the two resolver parameters that would fetch the pinned names from another hub (url, and atypeother thanartifact), which leaves the cluster-configured endpoint inhubresolver-configas the trust root: an identity that can change it needs no bypass, the approved coordinates simply resolve elsewhere. Control write access to that configuration as you controlClusterPolicy. -
The destination artifact does not exist yet. Admission can inspect references, never the content the copy is about to produce. Properties of the promoted artifact can only be checked after it lands, by the consuming side.
-
Validating the source artifact's own attributes is not included. Rules that read image labels or other content need Kyverno itself to reach the registry (
context.imageRegistry), and they fail closed: when the registry cannot be read, the rule errors and every matching promotion is rejected. The failure is not hypothetical. In one environment every such rule was refused withinvalid realm in www-authenticate: realm host "192.0.2.10" is a private or link-local addresseven though the registry was reachable and readable — Kyverno's registry client rejected the token realm because its host was a private IP literal, a guard that later versions relax when the realm host equals the registry host. So whether these rules work is a property of your Kyverno build, not only of your registry: before designing anything around image-attribute rules, apply one trial rule and watch it succeed. Treat such rules as a separate project with their own connectivity and credential design, not as a line to add to this set. -
The parity rule compares repositories, not tags. By design, promoting
app:1.2.3toapp:stableis allowed; what is forbidden is landing in a different repository. Teams that also forbid retagging must apply the stricter variant. Which builds may be promoted at all is rule 11's question — without it,app:latestpromotes as readily asapp:1.2.3. -
TLS verification stays a run-time input.
srcTLSVerifyanddstTLSVerifyare exposed by the template and not constrained here: they change how the copy trusts an endpoint, not which endpoint it writes to. With the Connector in the path, the destination connection presents the Connectors CA while the source one is tunnelled through and presents its own, and the promotion page's template answers that by exposing the Task'ssrc-ca-bundle/dst-ca-bundleworkspaces for a run to bind — the destination one to the mount holding thatca.crt, the source one to the site's own CA where its source registry needs it. Rule 10 lists both names, so adopting it needs no review. A run may still set either switch to"false"; if your policy forbids weakening them, add a rule on those two parameters. -
The controller identity is trusted. Rules 2 and 3 accept a
TaskRunor a Pod because the Tekton controller created it for a run of a template, and they find that run through the owner references the controller writes — by name and byuid, so an object found under the referenced name that is not the referenced object is no parent. Whoever can act as that service account — or can createTaskRuns or Pods through some other controller running with it — is inside the trust boundary. Labels are not:taskRunSpecs[].metadata.labelslets a run's author choose thetekton.dev/*labels of its children, which is why no rule here reads one. -
A running promotion Pod can be read by whoever may reach it. The policies judge creation. Anyone allowed to
execinto, or attach an ephemeral container to, Pods in<pipeline-namespace>can read the mounted token and proxy settings while the copy step runs — an RBAC boundary of that namespace, not something admission can close. -
Outside these policies, the mount is governed by RBAC alone. With the Connectors approval flags off — the default — the driver mounts the Connector for any Pod whose service account may
getit, and the product grants that to every service account in the Connector's scope: its own namespace for an ordinary Connector, every namespace of the project for a project-level one, the whole cluster for a cluster-level one inkube-public. Rule 3 is what turns that into "only Tekton, only from a template"; without it, moving the credential into a Connector removes the raw password from the namespace but not the ability to push with it. -
Step overrides cannot change what runs.
taskRunSpecs[].stepSpecs(thev1beta1stepOverrides, after conversion) carry only compute resources, so a run cannot swap the copy step's image or command through them; they are left unconstrained. -
The pod-template check is a denylist. Rule 1 refuses the five keys known to redirect traffic or execution. A future Tekton or Kubernetes field with the same effect is not covered until it is added to the list.
-
One Connector, one namespace. A
Connectoris a namespaced API object, and the product's Connector levels are namespace conventions on top of it: a cluster-level Connector lives in the designated shared namespace (kube-publicin the Connectors documentation) and is readable — and, without rule 3, mountable — cluster-wide, a project-level one in the project's namespace, an ordinary one in its own. Every level is referenced the same way — an explicitnamespace/name; a bare name always resolves to the run's own namespace, never to the shared one — so the pinned reference works for any level: a production Connector kept at cluster level is pinned askube-public/<name>. A same-named Connector in another namespace is a different object that deliberately does not count. The precondition compares against the single<production-connector>; a second production Connector is not evaluated at all until everymountsProductionConnectorvariable is widened to a list of references. The replacement below is written in terms ofsingleRefsandpluralRefs, the two variables every rule's recognition preamble defines, so the same entry can be pasted over themountsProductionConnectorentry of each rule you deploy — a form that names onlyconnectorRefscompiles, but fails to resolve in the rules that do not define it, and a rule whose precondition errors rejects every matching request:A run in a namespace outside the
<pipeline-namespace>list that mounts the Connector is rejected. Promotions from several namespaces share one set — list every namespace in each rule — as long as they share the template names and the approver and maintainer lists; a namespace that needs its own lists takes a namespacedPolicyinstead (Tightening a namespace withPolicy). A new version of the Task is a template change: re-read its parameter and result contract before adopting it. -
Kyverno's resource filters are outside the policies' reach. By default Kyverno does not evaluate requests in
kube-system,kube-public,kube-node-leaseand its own namespace at all. A Pod,TaskRunorPipelineRuncreated there is never seen by any rule on this page, whatever it mounts — creating workloads in those namespaces is an administrator's privilege, and it stays one. -
Auditdoes not block anything. It only records. A rule left inAuditafter the observation window is a rule that has been quietly turned off. -
"The submitter may not approve their own promotion" is not expressed here. Separation of duty requires correlating the run's originator with the approver, which neither the
AccessPolicycheck nor rule 5 does — rule 5 bounds the set of names, not who responds. -
Shared context across rules is not a
ClusterPolicyfeature. Each rule carries its owncontext, so the Connector recognition preamble is repeated in each of the nine rule entries that recognise the Connector; the ways to reduce that are under Bundling rules into fewer objects. Kyverno's CEL-basedValidatingPolicycomputesvariablesonce and shares them across attributedvalidations, and itsimage()library parses registry, repository, tag and digest — the natural next home for these rules, at the cost of rewriting them in CEL. -
Kyverno availability is part of the contract. Every policy declares
webhookConfiguration.failurePolicy: Fail, so while Kyverno is unavailable, matching requests are rejected rather than admitted unchecked. Because the run rules match everyPipelineRunandTaskRuncreation on the cluster — cheaply, the precondition is a string comparison — an outage stops all Tekton runs, not only promotions; and because rule 3 matches every Pod creation, with it deployed an outage stops every Pod — and, through the generated controller rules, everyDeployment,Joband the like — on the whole cluster. Size the Kyverno deployment accordingly. Narrowing rule 3'smatchto the namespaces that can reach the Connector is a legitimate trade-off; it leaves a mount from an unlisted namespace to the driver's own RBAC check, described two points up. -
Tekton's validation webhook is part of it too. The run rules match
CREATEonly because Tekton's own validating webhook refuses every change to a run'sspecother thanspec.status(Rolling out), so what a run was admitted with is what it runs with. That premise rests in turn on that webhook beingfailurePolicy: Fail: while it is unavailable,PipelineRunupdates are rejected by the API server, not admitted unchecked. Weakening or removing its webhook configuration is a cluster administrator's act, outside this page's model.
Troubleshooting
- A compliant promotion is rejected. Read the denial message: it names the policy and rule
and prints the offending values. The most common causes are a placeholder that was not
replaced everywhere, a Connector reference that differs from
<production-connector>in namespace, and template lists that drifted apart between rules 1, 2, 3 and 4. - An ordinary copy is rejected. It mounts the production Connector — check its workspaces. A copy that does not need production must not hold the production credential; give it a Secret or a different Connector.
- A promotion is refused by
sources-carry-release-tags. A source tag matches none of the approved patterns, or the reference carries no tag at all (an implicitlatest). Promote a release-tagged build or the image's digest; to admit another convention, add its pattern to the list. A digest-pinned image's.sig/.attcompanions are admitted by the default list. With the foldedpromotion-contentrule from Bundling, this and the other four content refusals all appear under that heading; the values echoed in its message tell them apart. - A run naming a legitimate approver is rejected. The identity is missing from
allowedApproversinpromotion-approvers, or is spelled differently there than theApprovalTaskexpects (group:prefix included). The denial message prints both lists. - With the optional
AccessPolicylayer, theApprovalTaskisapprovedbut the promotion Pod never gets the credential. The Connector releases the credential through anAccessRequest; check it and the three things it depends on:kubectl get accessrequest -n <connector-namespace>(the request lives in the Connector's namespace; its status names the check that is not passing), theAccessPolicy'sapproversandnumberOfApprovalsRequiredagainststatus.approversResponseon theApprovalTask(the Connector may demand more, or different, approvers than the template collected), and the feature flags listed under Who May Approve. - The copy fails with
x509: certificate signed by unknown authorityalthoughsrcTLSVerifyordstTLSVerifyis"false". The template does not declare that parameter, so Tekton drops it from the run and the Task falls back to its default of"true". Use the template from the promotion page, which declares both. - A Pod of a real promotion run is refused by
no-pod-outside-a-template, or itsTaskRunbyno-taskrun-outside-a-template. The message prints the creator, the owner chain (theTaskRunfor a Pod, then thePipelineRun) and thepipelineRefthe parent run was found to carry. An emptyparentRefmeans the owner references led to no run in that namespace — the object was not created by the controller for a run, a Pod'sTaskRunis not owned by a run, or an object does exist under the referenced name but carries a differentuid, which the rules read as no parent at all; a filled one that was refused names a template outside the rule'stemplateNames, a resolver (with catalog-served templates, adopt both variants: rule 1's for the run and the parent test for rules 2 and 3), a hub reference carrying aurlor a non-artifacttypeparameter, or an inlinepipelineSpec. KeeptemplateNamesidentical across rules 1, 2 and 3. - A hand-written
TaskRunis refused bytaskrun-owner-references-set-by-the-controller. It carries anownerReferencesentry withcontroller: truenaming aPipelineRun, which only the Tekton controller may write — rule 3 trusts it. The rule compares the whole reference, so editing one field of a reference that is already there is refused the same way. Drop the reference: aTaskRuncreated directly is not part of any run. - A
Deployment,Jobor other workload is refused byautogen-no-pod-outside-a-template. It mounts the production Connector, which rule 3 reserves for Pods the Tekton controller creates for a promotion template — by design; such a workload must not hold the production credential. - A violating run is admitted. First check the rule's policy is actually deployed — with a
subset, the missing rules' cases admit by design. Then check the precondition fired: the run
must name the Connector on a
connectors-csiworkspace, in one of the two forms shown in What Counts as a Promotion. A precondition that never matches produces no denial and no report entry — silence here means "not evaluated", not "compliant". - A non-maintainer can update the template, but not create or delete it. The rule is missing
allowExistingViolations: false, and Kyverno is treating the update as a pre-existing violation. The admission controller log showsskipping the rule evaluation as pre-existing violations are allowedfor the request. - A policy shows READY=False. Its rule failed to compile.
kubectl describe clusterpolicyon the failing policy reports the offending expression. - A denial message renders as an empty string. The message references a context variable that did not resolve. Check the Kyverno admission controller log for the underlying error; a failed context loader is reported there, not in the denial.
- No
PolicyReportat all. Reports are produced by the reports controller; confirm it is running, and remember thatbackground: falsepolicies only report on admission requests. - The promotion pipeline itself cannot be updated. Either the requester is not listed as a
<template-maintainer>— add the identitykubectl auth whoamireports, or perform the change with a listed one — or the edit names an approver outsideallowedApprovers, which rule 5 refuses regardless of who asks.
Learn More
- Promote Artifacts with Skopeo Copy — the promotion pipeline these policies protect, including the approval step.
- Specifying remote tasks using hub resolvers — the Task reference form the templates use, and the resolver the catalog variant of rule 1 pins.
- Pipeline Policy Constraints with Tekton and Kyverno
— the background article on how Kyverno constrains pipelines: where admission fits among the
available enforcement points, and how to choose between
AuditandEnforcewhen rolling a policy out. - Alauda DevOps Connectors: Connectors CSI Driver — how a run mounts a Connector, and the volume attributes the policies read.
- Alauda DevOps Connectors: AccessPolicy — the optional resource that can gate the production credential behind approval.
- Kyverno policy types — the reference for the
validateconstructs used here (deny.conditions,foreach,preconditions), and for thePolicy/ClusterPolicydistinction.