ADR 0006: What the operator may read, and why it holds no cluster-wide Secret access
Date: 2026-09-11 Status: accepted
Context
The operator clones customer repositories, so it needs git credentials: a token, or an SSH key, sometimes a private CA bundle. Those live in Kubernetes Secrets in the namespace of the application that uses them.
The default way to write this is a kubebuilder:rbac marker for Secrets, which generates a
ClusterRole with get, list, watch on Secrets cluster-wide. That is what the operator had.
It is also the finding that ends an evaluation. A controller with cluster-wide Secret read
can read every Secret in the cluster: the core banking system's database password, the
payment gateway's signing key, every other team's credentials. Nothing in Nazeel wants those,
but the permission does not know that, and a security review does not grade on intent. Worse,
list and watch on Secrets means the controller's cache holds them in memory, so a single
read primitive anywhere in the process becomes a whole-cluster credential disclosure.
Decision
Two independent limits, so a mistake in either one is not sufficient.
1. Permission is granted per Secret, by name, by the API. When a repository is connected,
the API creates a namespaced Role naming exactly that Secret in resourceNames, and binds
the operator's service account to it. Disconnecting the repository revokes it. There is no
cluster-scoped rule for Secrets at all. The operator can read the Secrets a customer has
explicitly pointed it at, and nothing else, and the list is inspectable with kubectl get rolebindings -A.
2. The informer cache only watches labelled Secrets. The manager's cache is configured
to select nazeel.sa/git-credentials. Even where permission would allow a read, an unrelated
Secret is never fetched and never sits in the operator's memory.
The kubebuilder:rbac marker was removed from the source, not just from the generated YAML.
Editing config/rbac/role.yaml alone would have been undone by the next make manifests,
and the permission would have come back in a release with nobody noticing.
The operator also holds no Secret write permission outside its own namespace. This
came up when distributing the preview signing key to application namespaces: the easy
implementation is for the operator to copy the Secret, which needs create on Secrets
wherever environments live. That was implemented and then reverted. The API distributes the
key instead. Re-granting namespace-wide Secret write to avoid one API call would have
undone this decision for a convenience.
Consequences
- The answer to "what can this controller read?" is a list of named Secrets, which is the answer a bank's security team is asking for.
- Connecting a repository is a two-step operation (Secret, then Role and RoleBinding), and disconnecting must revoke. Both are in the API and covered by tests; a leaked RoleBinding after a disconnect would be a real finding, so it is asserted rather than assumed.
- A Secret created by hand, without the label, is invisible to the operator even with permission granted. The error message says so, because otherwise it looks like a permission problem and sends the administrator to the wrong place.
- The operator cannot help distribute anything to application namespaces. Anything that needs to land there goes through the API. That is more code, and it is the point.
- The generated
config/rbac/role.yamlmust stay free of a cluster-wide Secrets rule. If a future controller adds a Secrets marker, this decision is quietly reversed, so the RBAC test asserts the absence.