6.1 GitOps + admission
Overview
You promote the signed app digest into stage via GitOps, then turn on admission so unsigned images cannot run. You edit one file on the stage Gitea remote. That file is admission/trust-policy.yaml (kind: TrustPolicy). A CronJob renders namespaced ImagePolicy on lw-poc-staging and lw-poc-prod. Do not apply ClusterImagePolicy. Do not use RHACS Trusted image signers for this gate.
Native ImagePolicy signedEmail must be an email. Track 5.1 identity is a Kubernetes service-account URI, so the CronJob renders policyType: PublicKey from ConfigMap lab-cosign-pubkey (the 5.3 cosign.pub you published). Keep the 5.1 service-account URI in TrustPolicy. That is still the Fulcio identity. oc tag does not copy .sig tags. Use cosign copy (and a dest-repo cosign sign --key) after the tag.
Why it matters
Only the signed digest may run. Lightwell pins inside the image do not admit an unsigned tag. Unsigned deny is a pull failure (ImagePullBackOff), not oc apply stderr.
You fill TrustPolicy (enforce: true, live digest, pipeline service-account subject) and run cosign copy after oc tag because signatures do not travel with oc tag by themselves. Prod Argo stays on the prod remote.
Lightwell Network and Clearinghouse Premier are already in the image as GAVs. Admission does not inspect Maven. A Premier member version does not skip unsigned deny.
What does it solve
-
Unsigned images that still deploy
-
Signatures that are not copied after
oc tag -
Using
ClusterImagePolicyinstead of namespacedImagePolicy -
Mixing stage and prod remotes
Stage vs prod remotes
Discover URLs from userinfo. The Gitea password is the value of ConfigMap key student_password (not the key name). Do not put that value in Git or a screenshot.
oc -n gitea get configmap demo-userinfo-gitea \
-o jsonpath='stage={.data.student_gitops_repo_url}{"\n"}prod={.data.student_prod_gitops_repo_url}{"\n"}promote_ns={.data.student_promote_namespace}{"\n"}build_ns={.data.student_build_namespace}{"\n"}argo={.data.student_argocd_app}{"\n"}'
oc -n tssc-admission get configmap demo-userinfo-admission \
-o jsonpath='scored={.data.scored_kind}{"\n"}live={.data.live_kind}{"\n"}hint={.data.unsigned_deny_hint}{"\n"}'
Worked example (not the Check)
# Worked example ONLY — wrong name, enforce still false, fake identity.
# Do not copy admission-docs example-trust-policy.yaml as the scored file.
apiVersion: tssc.workshop/v1
kind: TrustPolicy
metadata:
name: example-not-scored
spec:
enforce: false
scopes:
- quay.io/example/not-the-lab-repo
identity:
issuer: https://example.invalid/oidc
subject: student@example.invalid
digest: sha256:0000000000000000000000000000000000000000000000000000000000000000
Your change: tag, stage GitOps, TrustPolicy
-
Tag the signed image into staging (not prod):
BUILD_NS="$(oc -n gitea get configmap demo-userinfo-gitea -o jsonpath='{.data.student_build_namespace}')"
PROMOTE_NS="$(oc -n gitea get configmap demo-userinfo-gitea -o jsonpath='{.data.student_promote_namespace}')"
oc tag "${BUILD_NS}/spring-boot-lw-poc:latest" "${PROMOTE_NS}/spring-boot-lw-poc:latest"
oc -n "${PROMOTE_NS}" get istag spring-boot-lw-poc:latest \
-o jsonpath='{.image.metadata.name}{"\n"}'
DIGEST="$(oc -n "${PROMOTE_NS}" get istag spring-boot-lw-poc:latest \
-o jsonpath='{.image.metadata.name}')"
SRC="image-registry.openshift-image-registry.svc:5000/${BUILD_NS}/spring-boot-lw-poc@${DIGEST}"
DST="image-registry.openshift-image-registry.svc:5000/${PROMOTE_NS}/spring-boot-lw-poc@${DIGEST}"
# oc tag copies the image, not .sig tags. Copy, then sign the dest repo with the 5.3 key.
export HOME=/tmp/lab-home
mkdir -p "${HOME}"
cosign login -u unused -p "$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)" \
image-registry.openshift-image-registry.svc:5000
cosign copy --allow-http-registry --allow-insecure-registry "${SRC}" "${DST}"
COSIGN_PASSWORD="" cosign sign --key /home/lab-user/lab-trust/cosign.key \
--tlog-upload=false --allow-http-registry --allow-insecure-registry \
"${DST}"
Keep the sha256: prefix. That digest must match Track 5.1.
-
Clone the stage GitOps remote (
student_gitops_repo_url). Setimage.digestto that digest andreplicas: 1. Commit and push. Do not edit the prod remote. FollowPROMOTE.mdin that repo if you need the field names. A fresh clone does not inheritgit configfrom the app repo — set author locally (same identity as 3.2):
STAGE_GITOPS="$(oc -n gitea get configmap demo-userinfo-gitea -o jsonpath='{.data.student_gitops_repo_url}')"
: "${STUDENT_USER:=$(oc -n gitea get configmap demo-userinfo-gitea -o jsonpath='{.data.student_username}')}"
: "${STUDENT_PASS:=$(oc -n gitea get configmap demo-userinfo-gitea -o jsonpath='{.data.student_password}')}"
rm -rf /tmp/gitops-stage
hostpath="${STAGE_GITOPS#https://}"; hostpath="${hostpath#http://}"
git clone "https://${STUDENT_USER}:${STUDENT_PASS}@${hostpath}" /tmp/gitops-stage
cd /tmp/gitops-stage
git config user.name student
git config user.email student@workshop.local
-
After you push
image.digest+replicas: 1, hard-refresh Argo. Automated sync often stays on the previous commit (replicas: 0still looks Healthy). Wait until the Deployment hasreplicas: 1and a Ready pod:
ARGO="$(oc -n gitea get configmap demo-userinfo-gitea -o jsonpath='{.data.student_argocd_app}')"
PROMOTE_NS="$(oc -n gitea get configmap demo-userinfo-gitea -o jsonpath='{.data.student_promote_namespace}')"
oc -n openshift-gitops annotate applications.argoproj.io "${ARGO}" \
argocd.argoproj.io/refresh=hard --overwrite
oc -n openshift-gitops get applications.argoproj.io "${ARGO}" \
-o jsonpath='sync={.status.sync.status} health={.status.health.status} rev={.status.sync.revision}{"\n"}'
oc -n "${PROMOTE_NS}" get deploy spring-boot-lw-poc \
-o jsonpath='replicas={.spec.replicas} ready={.status.readyReplicas}{"\n"}'
-
Edit
admission/trust-policy.yamlon the same stage remote. Do not changeapiVersion/kind. Setenforce: true. Replace everyREPLACE_ME_*with live values: issuerhttps://kubernetes.default.svc(or userinfooidc_issuer_hint), subject the pipeline SA URI from 5.1 (not an invented email), digest the signedsha256:…, scopes the internal ImageStream repos staging and prod actually pull — discover withoc get is. Confirm ConfigMaptssc-admission/lab-cosign-pubkeystill has your 5.3 PEM. Do not paste Fulcio / Rekor PEMs (the CronJob injects those). The CronJob setssignedIdentity.matchPolicy: ExactRepositoryto the build ImageStream so dest pulls accept the signature identity fromlw-poc-build.
CronJob trust-policy-apply in tssc-admission polls about every two minutes. Pass when ImagePolicy exists in both lw-poc-staging and lw-poc-prod (name from userinfo policy_name).
Check: unsigned deny; stage Healthy
Native ImagePolicy is CRI-O policy.json: oc apply of an unsigned Deployment can succeed; the pod fails at pull. Kyverno fallback (if the claim uses it) denies at admission instead — still look at the pod / events.
The gate only covers spec.scopes. Tag an unsigned digest onto the same ImageStream and deploy that tag. Do not point GitOps image.digest at it (that would flap the Healthy Check).
BUILD_NS="$(oc -n gitea get configmap demo-userinfo-gitea -o jsonpath='{.data.student_build_namespace}')"
PROMOTE_NS="$(oc -n gitea get configmap demo-userinfo-gitea -o jsonpath='{.data.student_promote_namespace}')"
ARGO="$(oc -n gitea get configmap demo-userinfo-gitea -o jsonpath='{.data.student_argocd_app}')"
# Unsigned digest, in-scope name:
oc tag "${BUILD_NS}/openjdk-21-builder:latest" "${PROMOTE_NS}/spring-boot-lw-poc:unsigned"
oc -n "${PROMOTE_NS}" create deployment unsigned-deny-test \
--image="image-registry.openshift-image-registry.svc:5000/${PROMOTE_NS}/spring-boot-lw-poc:unsigned"
oc -n "${PROMOTE_NS}" rollout status deploy/unsigned-deny-test --timeout=30s || true
oc -n "${PROMOTE_NS}" get pods -l app=unsigned-deny-test
oc -n "${PROMOTE_NS}" get events --field-selector reason=Failed --sort-by=.lastTimestamp | tail -10
oc -n "${PROMOTE_NS}" delete deployment unsigned-deny-test
oc -n openshift-gitops get applications.argoproj.io "${ARGO}" \
-o custom-columns=NAME:.metadata.name,SYNC:.status.sync.status,HEALTH:.status.health.status
oc -n "${PROMOTE_NS}" get deploy spring-boot-lw-poc \
-o jsonpath='replicas={.spec.replicas} ready={.status.readyReplicas}{"\n"}'
oc -n "${PROMOTE_NS}" get imagepolicy
oc -n lw-poc-prod get imagepolicy
Pass when: TrustPolicy has no REPLACE_ME and enforce: true; ImagePolicy is live on staging and prod; the :unsigned pod is ImagePullBackOff (or signature error); lw-poc-staging stays Healthy on the signed digest. Do not retarget prod Argo at the stage remote.
The Job also grades ConfigMap report-15-gitops-admission key live_api. Set it to imagepolicy. Empty or REPLACE_ME fails even if cluster state is correct. Do not copy validate-docs.
oc -n lw-poc-validate edit configmap report-15-gitops-admission
The scored Check is Job validate-15-gitops-admission in lw-poc-validate. Direct nav URLs still work (honor system). Unlimited retries. There is no Solve.
oc -n lw-poc-validate delete job validate-15-gitops-admission --ignore-not-found
TPL='job-15.yaml'
oc -n lw-poc-validate get configmap validate-job-templates \
-o go-template="{{ index .data \"${TPL}\" }}" | oc create -f -
oc -n lw-poc-validate logs -f job/validate-15-gitops-admission
Expect CHECK PASSED: before you continue. CHECK FAILED: names the object to fix. Delete the Job and create it again as often as you need.
Continue to 6.2 when you are ready.
Official documentation
-
Trusted Artifact Signer (the signatures admission consumes)
Callouts (not Checks)
-
A production VM is the same signed digest on bootc / Satellite. No VM in this lab.
-
Canary and blue-green are other strategies for that digest. This lab uses rolling (default Deployment).
Key takeaways
-
Admission is namespaced
ImagePolicyfrom one TrustPolicy file you own. It is notClusterImagePolicy. -
Lightwell Network and Clearinghouse Premier GAVs do not bypass unsigned deny.
-
Service-account URI subjects render
PublicKeyfrom 5.3lab-cosign-pubkey.oc tagneedscosign copy. -
Promotion to prod is a git commit to a second remote (6.2).
cd ~/ocp5-rhacs-showroom
bash setup/lab-cleanup.sh --module tssc-15



