That deployment had run for three years on Tanzu Kubernetes Grid Integrated Edition (TKGI, formerly Enterprise PKS) without anybody thinking about it. Same image, same manifest, same replica count, moved across with the same tooling that moved everything else. On OpenShift it never got as far as starting a container. Nothing about networking, storage or the migration toolchain was involved. A validating admission plugin read one integer in the pod spec and refused.
• Your TKGI 1.18 clusters are not running Pod Security Policy. Kubernetes removed it at 1.25, so there is nothing to export and nothing to translate.
• On OpenShift, Pod Security Admission is telemetry and a Security Context Constraint (SCC) is enforcement. Copying namespace labels across buys you nothing.
• Fastest debugging move: oc get pod POD -o jsonpath='{.metadata.annotations.openshift.io/scc}’ tells you which SCC admitted a running pod.
• On a 118 workload estate, 61 passed restricted-v2 untouched and 57 did not. Of those 57, 31 needed only a manifest edit.
• Treat every SCC grant as a defect you owe somebody, not a fix you shipped.
Admission on TKGI, and What Actually Enforced It
Nobody in a TKGI shop thinks about admission, and there is a structural reason for that. PSP was the original mechanism, a cluster scoped object that validated pod specs against an allow list and reached workloads through RBAC. Kubernetes removed PSP outright in 1.25, which means a TKGI 1.18 estate is not running PSP at all, whatever your runbooks still say. Broadcom guidance required replacing PSP configurations with PSA before upgrading past TKGI 1.16, and TKGI 1.18 went further by dropping support for the SecurityContextDeny admission controller entirely.
PSA is what your clusters run now. It works on namespace labels: three levels, privileged, baseline and restricted, crossed with three modes, enforce, audit and warn. Documentation makes that sound tidy. Real estates are not. In every TKGI environment I have inventoried, most namespaces carry no PSA label at all, or carry privileged because somebody hit a wall during an upgrade and labelled their way out of it. An unlabelled namespace inherits the cluster default, and on TKGI that default is almost always permissive, because tightening it would have broken workloads that were already running.
So your effective posture is: anything goes. Containers run as uid 0 because a base image said USER root in 2019 and nobody revisited it. A log shipper mounts hostPath. A monitoring agent asks for SYS_TIME. None of that was a decision anybody made. It accumulated, quietly, for years, and it worked because nothing was checking.
Now count what it costs. Our reference estate is the one from Part 5 and Part 6: 47 namespaces, 118 Deployments, StatefulSets and DaemonSets between them. I ran every one of those specs through a server side dry run against a fresh OCP 4.20 cluster and recorded the first blocking reason for each. Sixty one passed untouched. Fifty seven did not, and the shape of that failure set is the whole story of this part.
SCC Evaluation Order and Default Assignment
An SCC does roughly what PSP did, and Red Hat kept it alive when upstream dropped PSP. Two things about how it is granted trip up people arriving from PSA. First, an SCC reaches a workload through RBAC and not through a namespace label, so you need a Role or ClusterRole permitting use of that SCC plus a binding that ties it to a service account. Second, restricted-v2 is already granted to every authenticated user by default, which means every workload in your cluster has at least one SCC available whether you configured anything or not.
Evaluation order is where the surprises live. When a pod create request arrives, admission collects every SCC the requesting service account may use, sorts them by restrictiveness, and tries them from least privileged upward. First SCC that validates the entire pod spec wins, and OpenShift stamps the winner onto the pod as an openshift.io/scc annotation. If nothing validates, you get the field level rejection at the top of this article. Reading that annotation on a running pod is the fastest debugging move available to you, and it is the one command I wish somebody had shown me in week one.
PSA still exists on OpenShift, and it is not doing what you would guess. OpenShift enforces the privileged level globally at the PSA layer and leaves restricted set only to warn and audit on new namespaces. A separate controller then synchronises those warn and audit labels upward to match the most privileged SCC any service account in that namespace can use, specifically so a legitimately privileged workload does not spray warnings across your logs. Net effect: on OpenShift, PSA reports and SCC decides. That single fact invalidates the most common migration plan I see written down.
Mapping PSA Labels and PSP Rules onto SCCs
Work in the opposite direction from the checklist. Start at what each workload asks for in its pod spec, and look up what restricted-v2 is willing to give it. That is a field level exercise rather than a policy translation exercise, and it produces something useful: a list of image and manifest changes owned by application teams, instead of a list of cluster objects owned by you.
Below is the artifact worth keeping from this part. Print it, put it beside the wave list from Part 6, and walk every rejected workload through it. Call it the admission field mapping table. It is the thing I reach for in every migration triage call, and it has not changed shape in three engagements.
| What the workload asks for | Where it was permitted on TKGI | OpenShift SCC field | Default under restricted-v2 | What happens if you ignore it |
|---|---|---|---|---|
| runAsUser 0 | Privileged or unlabelled namespace | runAsUser strategy MustRunAsRange | Unset, or inside the project uid range | Rejected at create, no container ever starts |
| allowPrivilegeEscalation true | Kubernetes default when unset | allowPrivilegeEscalation | false | Pod admitted, container exits with operation not permitted |
| Capabilities such as NET_ADMIN or SYS_TIME | PSP allowedCapabilities, later nothing at all | allowedCapabilities and requiredDropCapabilities | Drop ALL, allow only NET_BIND_SERVICE | Rejected at create |
| hostPath volume mount | Privileged or unlabelled namespace | allowHostDirVolumePlugin and volumes list | false, hostPath absent from allowed volumes | Rejected at create, needs a redesign not a grant |
| hostNetwork or hostPort | Privileged namespace, common for NCP era agents | allowHostNetwork and allowHostPorts | false | Rejected at create, needs hostnetwork-v2 |
| fsGroup for a shared volume | Any value, unchecked | fsGroup strategy MustRunAs | Project allocated supplemental group range | Pod runs green, first write to the PVC fails with permission denied |
| seccompProfile left unset | Unconfined in practice | seccompProfiles | Defaulted to runtime/default by the SCC | Usually fine, custom profiles need an explicit grant |
| No SELinux context set | Not enforced | seLinuxContext MustRunAs with an MCS label | Project allocated MCS label | Relabel failures on shared block storage at mount time |
| Namespace labelled PSA privileged | Namespace label | No equivalent, OpenShift enforces privileged globally | Synced from the highest SCC in use | Copying the label changes nothing, SCC still decides |
Two rows deserve extra attention because they behave differently from the rest. fsGroup and SELinux bite at runtime rather than at create time, which makes them worse to debug: your pod reaches Running, your readiness probe goes green, and the application dies on first write to a persistent volume with a bare permission denied. Stateful migration in Part 19 inherits that problem directly, so flag those workloads now rather than discovering them during a cutover window.
Five Rejection Patterns from a Real Estate
Read what restricted-v2 declares once, in full, rather than trusting anybody summarising it for you. Note that Users and Groups are both empty, because since OCP 4.11 access to restricted-v2 comes from a ClusterRole rather than from the fields on the SCC object itself. People new to OpenShift see those empty fields and conclude nobody has it. Everybody has it.
| Symptom | Error you will actually see | Root cause | Fix that holds |
|---|---|---|---|
| Deployment created, zero pods | provider restricted-v2: .spec.securityContext.runAsUser: Invalid value: 0 | Explicit runAsUser 0 in the manifest or chart values | Delete the field and let the project range default it |
| Pod reaches Running then CrashLoopBackOff | exec /usr/bin/nc: operation not permitted | Binary carries file capabilities or a setuid bit, blocked by allowPrivilegeEscalation false | Rebuild the image without the capability bits |
| DaemonSet admits nothing | provider restricted-v2: .spec.volumes[0]: Invalid value: hostPath: hostPath volumes are not allowed to be used | Node level agent reading host log paths | Replace with the cluster logging stack, or a tightly scoped custom SCC |
| Pod rejected on capabilities | capabilities.add: Invalid value: NET_ADMIN: capability may not be added | Sidecar doing packet level work that NSX-T used to do | Drop the sidecar, move the behaviour to a NetworkPolicy in Part 16 |
| Pod green, application writes fail | open /data/pgdata/PG_VERSION: permission denied | fsGroup outside the project supplemental group range, PVC owned by the wrong gid | Remove the hardcoded fsGroup, let the SCC allocate it |
Now look at where those ranges come from, because this is the production gotcha that catches teams twice.
Remediation Paths Compared by Cost and Risk
Six ways out of an admission rejection, and they are absolutely not equivalent. Effort and blast radius pull in opposite directions here, which is exactly why teams under schedule pressure pick the wrong one: the cheapest fix in minutes is also the one that costs most in audit findings six months later.
| Path | Effort | Blast radius | Audit posture | Verdict |
|---|---|---|---|---|
| Delete the offending manifest field | Minutes per workload | One workload | Clean, stays on restricted-v2 | First choice, always try this before anything else |
| Rebuild the image for an arbitrary uid | Hours to days, plus team scheduling | One image, often many workloads | Clean | Correct fix when the image is genuinely at fault, budget for it |
| Grant nonroot-v2 to the service account | Minutes | One service account | Acceptable, still never root | Reasonable when an image declares its own fixed non zero uid |
| Grant anyuid to the service account | Minutes | Root inside the container | Weak, flagged in every review | Temporary only, with a dated exception and a ticket |
| Author a scoped custom SCC | Half a day plus review | Everything you bind it to | Good if the grant is narrow and documented | Rare, for genuine platform agents such as storage or CNI |
| Grant privileged | Minutes | Whole node | Fails audit outright | Avoid for application workloads, no exceptions |
Here is what the top path looks like in practice, on the deployment that failed at the top of this article. Removing three lines and adding four was the entire fix.
Leave seccompProfile unset and the SCC defaults it to runtime/default for you, which keeps the manifest portable to clusters that predate your target. Setting it explicitly is fine on OCP 4.20 and harmless, but it is one more thing to keep correct in a chart, so I leave it out. When a grant genuinely is the answer, scope it to a named service account rather than the default one, and never to a group.
Field Note from a Wave 1 Admission Sweep
We put nine namespaces into Wave 1 and I budgeted two days for admission fixes. It took eleven.
First mistake was entirely mine. On day one, with four workloads rejected and a stakeholder demo at 16:00, I ran add-scc-to-user anyuid against the default service account in all four namespaces. Everything came up green inside forty minutes and the demo went well. That was the problem. A fix that works in forty minutes under pressure becomes the pattern everyone copies, and two other engineers copied it out of the migration channel within a fortnight.
Three weeks later our security team ran their first review of the new cluster and produced a list of every service account bound to anyuid. Nine of them. Every one had to be unwound, and unwinding is far more expensive than fixing properly at the start, because by then applications had been promoted, images had been retagged, and two teams had built pipeline steps around the working state. Undoing those nine cost roughly six engineer days against maybe four hours if I had done it correctly on day one.
Second thing I got wrong was assuming the image was the hard part. For six of those nine workloads it was not. Image already ran fine as an arbitrary uid. Only reason it failed was a runAsUser: 0 line sitting in a Helm values file that somebody had added in 2021 to work around a TKGI storage permission problem that had not existed for years. Deleting one line fixed it. I had assumed rebuild, so I never checked, and I burned two days scheduling calls with teams who did not need to be on them.
Actual split once we finished: 31 workloads needed a manifest edit only, 14 needed a genuine image rebuild, 8 landed on nonroot-v2, 3 needed a scoped custom SCC for a storage agent, and 1 stayed on anyuid with a dated exception and a ticket. Median time for a manifest only fix was 43 minutes including a test deploy. Median for an image rebuild was closer to two days, almost all of it waiting on the owning team rather than on the build. Three months on, that single anyuid exception is still in place. They always are. Write the expiry date down anyway.
Fix Images First, Grant SCCs Last
Recommendation, unambiguous: treat every SCC grant as a defect you owe somebody, not a fix you shipped. Order of preference is delete the field, then rebuild the image, then nonroot-v2, then a narrowly scoped custom SCC. anyuid is a dated exception with a ticket attached and never a resolution. privileged is not on the list for application workloads at all.
Second recommendation, which costs nothing and saves weeks: stop trying to reproduce your TKGI security posture on OpenShift. There is nothing to reproduce. PSP was deleted by an upgrade you already did, PSA labels carry no authority on the target, and the only artifact that survives the move is the pod spec. Every hour spent authoring SCC objects before you have run a dry run is an hour spent on the wrong problem.
Worth saying plainly, because it comes up in every kickoff: this gap is not an argument against OpenShift. Kubernetes upstream moved in exactly this direction and your TKGI clusters would have faced the same reckoning eventually. Migration just makes you pay it in one visible block instead of spreading it across three upgrades. Broader trade offs for the platform choice sit in the complete guide, and if a permissive posture is genuinely non negotiable for a subset of workloads, that is one of the few honest reasons to look at the TKGI to VKS path for those specific applications instead.
Do this on Monday. Take your Wave 1 namespaces, run every workload spec through oc apply with server side dry run against a scratch OpenShift project, and record the first blocking reason for each one. You want the histogram from the top of this article for your own estate, because that number, not your wave list, is your real Wave 1 schedule. Part 8 leaves admission behind and takes on the other half of the portability problem, NSX-T and NCP against OVN-Kubernetes.
References
- Managing security context constraints, OpenShift Container Platform, Red Hat Documentation
- Pod Admission and SCCs Version 2 in OpenShift, Red Hat Blog
- Tanzu Kubernetes Grid Integrated Edition Security, TKGI 1.18, Broadcom TechDocs
- Migrate from PodSecurityPolicy to the Built-In PodSecurity Admission Controller, Kubernetes


DrJha