Skip to content

Satisfy the Pod Security Standards "restricted" profile by default - #875

Open
janhoy wants to merge 8 commits into
apache:mainfrom
janhoy:restricted-pss-defaults
Open

janhoy wants to merge 8 commits into
apache:mainfrom
janhoy:restricted-pss-defaults

Conversation

@janhoy

@janhoy janhoy commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #873, fixes #582, fixes #466

The pods the operator generates now satisfy the Kubernetes Pod Security Standards "restricted" profile by default, so they run in a namespace that enforces it without any extra configuration. There is no opt-in flag and no platform detection: a platform-conditional default would make the hardened path the least tested one.

Defaults

  • seccompProfile: RuntimeDefault on the pod.
  • runAsNonRoot: true, allowPrivilegeEscalation: false and capabilities.drop: [ALL] on every container and init container the operator generates. This now includes the TLS init containers, which defaultInitContainerSecurityContext never reached before.
  • runAsUser: 8983 on the busybox-based init containers. That image has no non-root user of its own, and the kubelet will not honour runAsNonRoot without one.
  • The operator's own chart container drops all capabilities and sets a seccomp profile.

Overrides

podSecurityContext, containerSecurityContext and defaultInitContainerSecurityContext are merged over the defaults field by field, rather than replacing them. Setting one field overrides one default and leaves the rest in place, so a custom image that must run as root only needs runAsNonRoot: false. allowPrivilegeEscalation: false is skipped for a container asked to run privileged or to add CAP_SYS_ADMIN, since Kubernetes rejects that combination, and a container asked to run as root gets neither runAsNonRoot nor capabilities.drop. Containers the user adds through podOptions are left untouched.

Behaviour changes

  • volume-prep runs as the Solr user now, so it can no longer chown a backup repository directory. It checks that the directory is writable and fails the pod with a message naming it. defaultInitContainerSecurityContext.runAsUser: 0 restores the old behaviour.
  • The exporter pod backfills fsGroup when given a podSecurityContext that omits it, as Solr pods always did.
  • The pod spec changes, so all pods restart when the operator is upgraded.

OpenShift

The default restricted-v2 SCC accepts everything above except hardcoded IDs: it assigns runAsUser and fsGroup from a per-namespace range and rejects any other value, and the field-by-field merge gives no way to leave a numeric default out. The new podOptions.omitDefaultIDs leaves fsGroup and the busybox runAsUser unset so the platform assigns them, while the rest of the defaults still apply and explicit IDs are still used. The solr chart exposes it. This fixes #466 without the platform detection proposed in #706.

Not covered

ZooKeeper pods. A provided ensemble's pods come from the ZooKeeper operator, whose zookeeperPodPolicy exposes only a pod-level securityContext, and the zookeeper-operator chart dependency is configured by its own chart. readOnlyRootFilesystem is outside the profile and not defaulted here; #884 tracks setting it on the busybox init containers and as an opt-in for the Solr pods.

Testing

A unit test evaluates the generated pod templates against the upstream profile checks, using k8s.io/pod-security-admission as a test-only dependency pinned to the k8s.io/api version, so a new operator container that is not hardened fails the build. Verified in a kind cluster against Kyverno's full restricted policy set and native Pod Security Admission with enforce=restricted, covering plain, backup-volume, TLS and provided-ZooKeeper SolrClouds, the exporter and the operator itself. omitDefaultIDs has unit tests only, it has not been run on an OpenShift cluster.

Builds on the defaultInitContainerSecurityContext field added by @alexalex89 in #796.

🤖 Written with AI assistance 🤖

This comment was marked as resolved.

@janhoy
janhoy marked this pull request as draft October 29, 2026 02:03
@janhoy
janhoy requested a balanced review from Copilot October 29, 2026 07:39

This comment was marked as resolved.

@janhoy

janhoy commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

@HoustonPutman This is something I have been wanting to have for a long time. Things should just be secure by default. This is AI generated, but Claude has done lots of verification steps, and I've done three rounds of AI self review. I have not yet got around to reading all the code, thus the draft state.

Do you agree with the approach - that the restricted profile is the default, and if user override certain values in a pod/container, they are overlaid the security defaults instead of overwriting. I suppose that in order to nullify a security setting, you'd have to ovveride with false instead of true or an empty list or dict {}/[].

I'll add a comment on each of the linked issues and ask them to have a look.

Set a seccompProfile on the Solr and Prometheus exporter pods, and
runAsNonRoot, allowPrivilegeEscalation=false and capabilities.drop=[ALL]
on every container and initContainer the operator generates. The
busybox-based initContainers also get runAsUser, since that image has no
non-root user of its own.

podSecurityContext, containerSecurityContext and
defaultInitContainerSecurityContext are now merged over these defaults
field by field, so one field opts out of one default. This also extends
the initContainer securityContext to the TLS initContainers, which it
did not reach before.

The volume-prep initContainer can no longer chown a backup repository
directory, so it checks that the directory is writable and fails with a
message naming it. Setting defaultInitContainerSecurityContext.runAsUser
to 0 restores the previous behaviour.

A unit test evaluates the generated pods against the upstream profile
checks, so a new operator container that is not hardened fails the
build.

Closes apache#873
Closes apache#582
Skip the security context defaults for containers the user supplied
through podOptions, matching them by name rather than relying on the
operatorContainers map, which a user-supplied container can collide
with.

Leave allowPrivilegeEscalation unset when the user asked for privileged
mode or an added CAP_SYS_ADMIN, since Kubernetes rejects either of those
alongside allowPrivilegeEscalation: false.

Document that a root-based custom Solr image needs the init container
override too, as setup-zk and gen-pkcs12-keystore run the same image.
Send the backup repository rejection to stderr and set
FallbackToLogsOnError on the volume-prep container, so the reason shows
up in the pod status rather than only in that container's log.

Document that capabilities is overridden as a whole rather than per
sub-field, with a test pinning that, since an empty block is the only
way to keep a container's default capabilities.

Note that spec.dataStorage.ephemeral.hostPath breaks the "restricted"
profile too, not just the volumes a user adds through podOptions.
The init container override reaching the TLS init containers was the
point of moving the hardening to the end of pod assembly, and nothing
tested it: the three containers added after assembly had no coverage of
a user-supplied securityContext at all.

Also cover the exporter container, which only had pod-level assertions;
the exporter's TLS-from-secret path, which generates a keystore
conversion init container that no fixture reached; a pod that stays
compliant when the user sets an unrelated field, which is the common
case for the merging behaviour; and the volume prep container with
several repositories, where the Solr user must only be added once.
Two of the operator's security context defaults are numeric IDs: fsGroup
8983 on the pod (8080 for the exporter), and runAsUser 8983 on the init
containers whose busybox image has no non-root user of its own. OpenShift's
restricted SCCs only accept IDs from a per-namespace range and fill them in
themselves when unset, and the field-by-field merge gives the user no way
to leave a default out. Everything else the operator sets is accepted.

The new podOptions.omitDefaultIDs leaves both IDs out, for the SolrCloud
and the SolrPrometheusExporter, and the solr chart exposes it. IDs given
explicitly are still used, and the generated pods still satisfy the
"restricted" profile. The volume-prep check now reports the user and
groups it ran as, instead of assuming the default.

Fixes apache#466 without platform detection.
Code comments explained user-facing behaviour and the docs repeated
themselves. Say what each piece needs for its audience and no more. The
readOnlyRootFilesystem note now points at apache#884, which tracks setting it.
@janhoy
janhoy force-pushed the restricted-pss-defaults branch from 8ee139a to 5a61978 Compare November 9, 2026 10:17
Both test harnesses give a SolrCloud a volume backupRepository backed by a
hostPath directory created by root. The kubelet applies no fsGroup to
hostPath volumes, so uid 8983 cannot write there, and the volume-prep
initContainer now fails the pod instead of chowning it as root. The Solr
pods never became ready: the smoke test timed out after 30 minutes, and the
e2e "Local Directory - Single" backup spec after 4.

Prepare the directory in both harnesses, which is what the docs tell users
to do for a volume that comes with its own ownership. The smoke test owns
its directory inside the Kind node, so it can chown it. The e2e Kind config
shares one host directory with every node, so that one is made writable
before Kind creates it as root.
@janhoy
janhoy force-pushed the restricted-pss-defaults branch from 5a61978 to 70d6f18 Compare November 9, 2026 13:44
@HoustonPutman

Copy link
Copy Markdown
Contributor

I do agree with the general approach I think, but this is almost certainly a v1 (not a v0.10) feature, so it can wait for a full review.

@janhoy janhoy added this to the main (v1.0.0) milestone Nov 9, 2026
The volume-prep initContainer runs as the Solr user and only checks that
each backup repository directory is writable. Each repository is mounted
at a subPath, which the kubelet creates owned by root with the mode of
the directory above it, so chowning that directory leaves the repository
unwritable, volume-prep rejects it, and the Solr pods never start. The
smoke test hung on this until the job timed out.

Give the smoke test's directory a permissive mode instead, as the e2e
harness already does, and say in the initContainer's error message and in
the docs that the owner is not inherited, only the mode.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants