Skip to content

feat(credentials): narrow a namespace grant by secret name or label - #1859

Merged
Eitan Yarmush (EItanya) merged 8 commits into
agent-substrate:mainfrom
jjamroga:jjamroga/credential-provider-secret-scoping
Oct 1, 2026
Merged

Eitan Yarmush (EItanya) merged 8 commits into
agent-substrate:mainfrom
jjamroga:jjamroga/credential-provider-secret-scoping

Conversation

@jjamroga

@jjamroga Jonathan Jamroga (jjamroga) commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

What

Adds an optional secretSelector field so a credential-provider namespace grant can be narrowed by label to specific Secrets instead of the whole namespace:

policies:
  - atespace: team-a
    allowedNamespaces: [team-a]
    secretSelector:            # new
      matchLabels:
        ate.dev/credential: "true"

No selector set → unchanged behavior (full-namespace grant).

Why

A namespace-only grant lets any actor read any Secret in that namespace, including Substrate's own CA/JWT/DB Secrets when they share a namespace with actors (e.g. kagent as a Substrate subchart). RBAC resourceNames can't scope this per-atespace, since the provider is a single ServiceAccount. The policy is the only place with that context.

Behavior

  • Once a namespace policy is enforced, a missing Secret and one whose labels don't match both return PermissionDenied — so a caller can't probe what exists in the namespace.
  • Malformed policies (bad label key/value, unknown YAML fields) fail to load rather than silently allowing nothing.

Tests

New unit tests cover label narrowing, the no-probing property, and malformed-policy rejection. Existing tests pass unchanged. go build ./... and lint/verify scripts pass.

@jjamroga
Jonathan Jamroga (jjamroga) force-pushed the jjamroga/credential-provider-secret-scoping branch from 8d08a1e to 9f326e9 Compare September 24, 2026 16:54
A grant is the whole namespace. The provider holds `get` on every Secret and
the policy checks only the namespace, so granting an atespace the namespace its
model credentials sit in also grants it everything else there. That is a thin
line wherever actors and the control plane share a namespace -- installing
Substrate as a subchart of its consumer puts them together -- because the same
grant then reaches actor-id-ca-pool, and an actor holding the CA that signs
actor identities can mint any actor.

Add allowedSecretNames and secretSelector to a policy, so a grant can name the
credentials it means and nothing else, or label them and let the label carry
it. Both unset keeps the namespace-wide grant every existing policy already
means, so no policy has to change.

The criteria of one policy are AND-ed and the policies are OR-ed, as they are
in a Kubernetes RBAC rule. A policy carrying a namespace, a name and a selector
admits only a Secret in that namespace, with that name, carrying those labels;
to admit either, write a second policy. Each policy therefore keeps its own
grant: merging them would AND the selectors of separate policies together, and
would let a narrow policy take away the namespace a broad one granted.

secretSelector mirrors the shape of ateapi.Selector, so a policy selects
Secrets the way an ActorTemplate selects workers, and matchLabels is exact
equality as it is there. It is nested rather than a bare matchLabels: the
common grant narrows by label alone, where the only neighbouring field names
namespaces, and a bare matchLabels there reads as though it selected the
namespaces. The shape is mirrored rather than imported -- this is a config
file, not the API.

Namespace and name are settled before anything is read, so a Secret no policy
names is refused without a request to Kubernetes and cannot be probed for
existence. A grant narrowed by label needs the Secret in hand, and there the
refusal has to hide existence too: a Secret that is absent and one whose labels
do not match are refused with the same code and the same message, so a caller
cannot walk a list of names and learn what the namespace holds. A grant that is
not narrowed by label has already been admitted to every name there, so
NotFound tells it nothing new and is kept.

A malformed name or label would narrow a grant to nothing and read as the
policy being ignored, so loading rejects it rather than failing open later.

Signed-off-by: Jonathan Jamroga <[email protected]>
@jjamroga
Jonathan Jamroga (jjamroga) force-pushed the jjamroga/credential-provider-secret-scoping branch from 9f326e9 to 50d1560 Compare September 24, 2026 17:03
@jjamroga
Jonathan Jamroga (jjamroga) marked this pull request as ready for review September 24, 2026 17:05
@bowei Bowei Du (bowei) added kind/feature An enhancement / feature request or implementation area/security Security related issue/pr area/identity labels Sep 24, 2026

@EItanya Eitan Yarmush (EItanya) left a comment

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.

I like the idea of adding label selectors, but I think this PR is a bit overcomplicated. Can we stick with mostly what was here before with added label selection?

Keep only label-based narrowing (SecretSelector) for scoping which
Secrets an atespace grant may resolve. Per review feedback, name-based
narrowing (AllowedSecretNames) added unnecessary complexity; label
selection alone covers the intended use case.

@EItanya Eitan Yarmush (EItanya) left a comment

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.

🤖 AI-generated review. I looked through the findings and agree with them.

Per review: replace the pre-fetch Decision enum with two plain methods
on NamespaceAuthorizer. authorize() now returns just (atespace, error)
before the Secret is fetched. AllowedUnconditionally reports whether
any grant for the atespace/namespace needs no label check, used only
to decide whether a missing Secret may be reported as NotFound.
AllowedSecret checks the fetched Secret against any matching grant,
including an unrestricted one. Existence is still hidden the same way
for grants narrowed by label.

Also switch namespace policy YAML parsing to yaml.UnmarshalStrict, so a
typo like matchLabel, or an unsupported field like matchExpressions,
fails to load instead of silently parsing as an empty selector that
grants every Secret in the namespace.
Drop AllowedUnconditionally. AllowedSecret(atespace, namespace, nil)
already answers the same question: a grant with no label selector
matches any labels, including none, so the pre-fetch NotFound check
reuses the one method instead of a separate one.

NamespaceAuthorizer is now exactly two decision methods matching the
two steps left to it: Allowed (is the namespace granted) and
AllowedSecret (does this Secret, or the absence of one, satisfy a
grant). The third step, checking the callers identity, already lived
in Server.authorize and is unchanged.
Once a namespace policy is enforced, report PermissionDenied for a
missing Secret unconditionally, instead of first asking the authorizer
whether this atespace holds an unrestricted grant. The distinction was
not worth its complexity: a caller gains nothing from a precise
NotFound when enforcement is on, and dropping it removes the only
caller of AllowedSecret with a nil labels argument.

NotFound is still returned verbatim when nsAuth is nil, which only
happens in tests: production always loads a policy because main
requires --namespace-policy-file. Clarified that in the doc comments.
@EItanya
Eitan Yarmush (EItanya) added this pull request to the merge queue Oct 1, 2026
Merged via the queue into agent-substrate:main with commit 0409305 Oct 1, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/identity area/security Security related issue/pr kind/feature An enhancement / feature request or implementation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants