Skip to content

Restrict daemon reading the pull secret from a merged secret - #278

Open
alicefr wants to merge 8 commits into
bootc-dev:mainfrom
alicefr:restrict-secrets-v2
Open

alicefr wants to merge 8 commits into
bootc-dev:mainfrom
alicefr:restrict-secrets-v2

Conversation

@alicefr

@alicefr alicefr commented Oct 30, 2026

Copy link
Copy Markdown
Collaborator

The controller now keeps one Secret, bootc-pull-secrets, with one key ( <namespace>_<name>) per pull secret a pool references, and mounts it into the daemon.

The daemon copies that mounted entry to /run/ostree/auth.json before staging and deletes the file when there is none, instead of reading Secrets through the API.

This removes the daemon's cluster-wide secrets permission, so nodes can only read the mounted Secret.

Based on #273
Partially Fixes: ##272

@alicefr
alicefr force-pushed the restrict-secrets-v2 branch from 7c63f4b to a918936 Compare November 5, 2026 07:49
@alicefr alicefr changed the title DNM: Restrict daemon reading the pull secret from a merged secret Restrict daemon reading the pull secret from a merged secret Nov 5, 2026
@alicefr
alicefr force-pushed the restrict-secrets-v2 branch from a918936 to d18b058 Compare November 5, 2026 10:52
@alicefr
alicefr requested review from HarshwardhanPatil07 and cheesesashimi and removed request for HarshwardhanPatil07 November 5, 2026 13:16

@HarshwardhanPatil07 HarshwardhanPatil07 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will let @cheesesashimi have the final look here, need to head at other place, thank you!

@@ -0,0 +1,179 @@
// SPDX-License-Identifier: Apache-2.0

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Is it possible to place the aggregator in its own module? I'm mostly thinking about separation of concerns. For example, we could relocate it under internal/pullsecret. Or, since it's effectively a new controller, we could do something like this to keep things more logically grouped:

internal/controller/bootcnodepool/<all current bootcnode controller code>
internal/controller/pullsecret/<all pull secret aggregator code>

if !ok {
return false
}
return secret.Type == corev1.SecretTypeDockerConfigJson ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: Here are a few edge-cases we may want to consider:

  1. Legacy pull secrets (e.g., corev1.DockerConfigKey / corev1.SecretTypeDockercfg. We should detect these and normalize them though I'll put my comments around normalization elsewhere.
  2. Opqaue secrets with a .dockerconfigjson or .dockercfg key.
  3. Transitions from Opaque -> {DockerConfigJson, DockerConfig} (and vice-versa). Although there is an immutable bit on secrets, we can't always assume that it is set to true, especially if another operator is what created the secret.

Something like the following should cover all of these edge-cases:

func isPullSecretCandidate(obj client.Object) bool {
	secret, ok := obj.(*corev1.Secret)
	if !ok {
		return false
	}

	_, hasJSON := secret.Data[corev1.DockerConfigJsonKey]
	_, hasLegacy := secret.Data[corev1.DockerConfigKey]

	return hasJSON || hasLegacy ||
		secret.Type == corev1.SecretTypeDockerConfigJson ||
		secret.Type == corev1.SecretTypeDockercfg
}

func (r *PullSecretAggregator) secretPredicate() predicate.Predicate {
	isAggregate := func(obj client.Object) bool {
		return client.ObjectKeyFromObject(obj) == r.secretKey()
	}

	isMatch := func(obj client.Object) bool {
		return isAggregate(obj) || isPullSecretCandidate(obj)
	}

	return predicate.Funcs{
		CreateFunc: func(e event.CreateEvent) bool {
			return isMatch(e.Object)
		},
		UpdateFunc: func(e event.UpdateEvent) bool {
			return isMatch(e.ObjectOld) || isMatch(e.ObjectNew)
		},
		DeleteFunc: func(e event.DeleteEvent) bool {
			return isMatch(e.Object)
		},
	}
}

Additionally, we may want to consider even tighter scoping in the future where we can give a list of secret names to watch so that we don't have to reconcile whenever any secret in a given namespace changes, regardless of whether we're consuming it or not.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additionally, we may want to consider even tighter scoping in the future where we can give a list of secret names to watch so that we don't have to reconcile whenever any secret in a given namespace changes, regardless of whether we're consuming it or not.

Agree, but if you don't mind I would postpone this since right now we are passing the allowed namespace statically. If something changes the operator needs to be restarted. So, I would introduce this after #257 and #92. We want also to restrict the RBAC to that secrets and we want to adjust them dynamically

if err != nil {
return nil, fmt.Errorf("fetching pull secret %s/%s: %w", ref.Namespace, ref.Name, err)
}
if data, ok := secret.Data[corev1.DockerConfigJsonKey]; ok {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: We'll need to normalize legacy secrets (e.g., corev1.DockerConfigKey). This is a problem I solved for the MCO team, which I did like this: https://github.com/openshift/machine-config-operator/blob/main/pkg/secrets/secrets.go. We might not want something that sophisticated here, but its an idea.

Also, since we're also doing a similar operation in the BootcNodePool controller, we should consider making it a common function that both controllers can use.

// atomically (temp file + rename).
func (r *BootcNodeReconciler) writeAuthFile(data []byte) error {
// mountedPullSecret returns the mounted pull secret entry for ref, or nil if
// ref is nil or the entry does not exist.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought: Since we now have the pull secret mounted as a projected volume, we may want to consider using fsnotify to proactively update auth.json whenever the secrets change. It's also possible that the secrets could be changed while an update is in progress, so we may want to consider how to handle that too.

@alicefr alicefr Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have considered the case where the secret change while we are updating. I think, it can be reduced to 2 cases, the old credentials are still valid and the pull will be successful or it will fail because it require new credentials. In the last case, we will reconcile and retry until the credentials are updated and correct. I think it is a good mechanism for keeping the logic relatively simple

@alicefr alicefr Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we may want to consider using fsnotify to proactively update auth.json whenever the secrets change

we aren't using fsnotify because we simply reread the secret every time we need to pull and we don't cache it. So, there is no need to watch for fsnotify event. We could review this mechanism if necessary but if you don't mind I would tackle this in a separate PR since it is already en existing mechanism

The tag resolver looked up credentials with an exact match of the
registry host in the auths map. Entries written by kubectl create secret
docker-registry and docker login use other forms, most commonly
https://index.docker.io/v1/ for Docker Hub. Those missed, the resolver
silently fell back to anonymous access and tag resolution failed with
401, while the node's container runtime accepted the same secret.

Compare keys the way containers/image does for auth files: strip the
http(s):// scheme, drop the path when a scheme was present, and map
docker.io and registry-1.docker.io to index.docker.io. An exact match
still wins, and normalized keys are tried in sorted order so the result
is deterministic. Scoped keys without a scheme, such as quay.io/org, are
still not matched; the resolver looks up registries only.

Assisted-by: AI
Signed-off-by: Alice Frosi <afrosi@redhat.com>
The daemon will read pull secrets from a single Secret maintained by the
controller and mounted into the DaemonSet, with one key per source
secret. The new internal/pullsecret package holds the Secret name, the
daemon mount directory and the key function, "<namespace>_<name>", so
the controller and the daemon cannot drift.

A namespace and a Secret name together can exceed the 253-character
Secret key limit, and one invalid key would make the API server reject
the whole aggregated Secret. Key returns an error in that case, and
fetchPullSecretData reports it on the pool as SecretError.

Assisted-by: AI
Signed-off-by: Alice Frosi <afrosi@redhat.com>
The pool reconciler required a .dockerconfigjson key and passed its
bytes unchanged to the tag resolver and, via the aggregated Secret, to
bootc. Both expect the {"auths": ...} wrapper. A legacy .dockercfg
Secret, which holds the bare auths map and which OpenShift generates for
service accounts, was rejected as missing a key; had it been passed
through, it would have meant silently no credentials.

Add pullsecret.AuthData, which returns the credentials in
.dockerconfigjson format. It goes by the Secret's keys rather than its
type, so an Opaque Secret works too. .dockerconfigjson is returned as
is and wins when both keys are set. .dockercfg is wrapped in
{"auths": ...} without re-encoding its entries, so fields such as
identitytoken and the credential bytes are preserved. Data that is not
a JSON object, including an empty .dockerconfigjson, is reported with
the key name but never with its content. fetchPullSecretData uses it, so
the reconciler normalizes once and the daemon keeps copying bytes
verbatim.

Assisted-by: AI
Signed-off-by: Alice Frosi <afrosi@redhat.com>
The pool reconciler decided which Secret events to act on by type,
accepting only kubernetes.io/dockerconfigjson. That ignored events for
the other forms of pull secret that fetchPullSecretData accepts, such as
legacy .dockercfg and Opaque Secrets carrying a .dockerconfigjson key,
so rotating them was never noticed. Matching on type is also not
meaningful for a Secret that is not referenced by any pool, and the
mapper listed every pool on each Secret event to find out.

Index BootcNodePool by spec.pullSecretRef and map a Secret event to the
pools in that index. A referenced Secret now triggers a reconcile
whatever its type or content, so data that becomes valid or invalid is
always seen, and Secrets no pool references enqueue nothing. The Secret
type is immutable, so type changes need no handling.

The field index is registered once per manager, in main and in the
envtest suite, since a manager rejects duplicate registrations.

Assisted-by: AI
Signed-off-by: Alice Frosi <afrosi@redhat.com>
The daemon will stop reading pull secrets through the API and read them
from a Secret mounted into the DaemonSet instead. The new
PullSecretAggregator maintains that Secret, bootc-pull-secrets in the
operator namespace, with one key per source secret referenced by a pool,
holding its credentials normalized to .dockerconfigjson format. It lives
in internal/pullsecret, next to the key function and the constants it
shares with the daemon, so the reconciler and the key format cannot
drift apart.

It is the Secret's only writer, so pool reconciles cannot conflict on
it. Pool, referenced source secret and bootc-pull-secrets events all map
to one request, found through the pool index, and each reconcile
recomputes the data from scratch, so stale keys disappear and a deleted
or edited Secret is repaired. References the pool reconciler reports as
SecretError are skipped. The aggregator also reconciles once at startup,
so the Secret exists whenever the controller runs; it is kept, with no
keys, when no pool references a pull secret.

The controller needs create on secrets in its namespace, and update and
patch on bootc-pull-secrets only. It now requires the operator namespace
to be known, since that is where the Secret lives.

Assisted-by: AI
Signed-off-by: Alice Frosi <afrosi@redhat.com>
The DaemonSet now mounts bootc-pull-secrets, maintained by the
controller, at /var/run/bootc-pull-secrets. The volume is optional, as
the daemon may start before the Secret exists, and uses no subPath, so
the kubelet keeps it up to date.

While an update is pending, before staging, syncPullSecret copies the
entry for the node's pullSecretRef to /run/ostree/auth.json instead of
fetching the Secret through the API. Without a reference or a readable
entry, it now removes the auth file instead of leaving stale credentials
behind, and bootc falls back to /etc or /usr/lib. A missing entry needs
no special handling: the kubelet may take a minute to sync a new or
rotated Secret, and a failed stage is retried with backoff and re-reads
the file.

The daemon tests now use temporary directories for the host root and
the mount, so they never touch the real /run/ostree.

Assisted-by: AI
Signed-off-by: Alice Frosi <afrosi@redhat.com>
The daemon reads pull secrets from the mounted bootc-pull-secrets
Secret, so it no longer needs to get Secrets through the API. Remove the
rule from its ClusterRole, which allowed every node to read any Secret
in the cluster, and the now unused APIReader.

The kubelet still reads the mounted Secret: the node authorizer allows
a node to read Secrets used by pods scheduled on it.

make helm-test now checks that no ClusterRole, the daemon's included,
grants access to secrets.

Assisted-by: AI
Signed-off-by: Alice Frosi <afrosi@redhat.com>
The controller now creates bootc-pull-secrets in the release namespace.
Helm does not know about it, and helm uninstall keeps the namespace
created by --create-namespace, so the Secret is left behind. Document
deleting the namespace to remove it.

Assisted-by: AI
Signed-off-by: Alice Frosi <afrosi@redhat.com>
@alicefr
alicefr force-pushed the restrict-secrets-v2 branch from d18b058 to 4ae038c Compare November 6, 2026 10:31
@alicefr
alicefr requested a review from cheesesashimi November 6, 2026 13:39

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

Development

Successfully merging this pull request may close these issues.

3 participants