Skip to content

fix(substrate): keep Substrate page tokens inside the watched namespaces - #3262

Open
danielle-mustillo wants to merge 12 commits into
kagent-dev:mainfrom
danielle-mustillo:danielle-mustillo/substrate-scope-tokens
Open

danielle-mustillo wants to merge 12 commits into
kagent-dev:mainfrom
danielle-mustillo:danielle-mustillo/substrate-scope-tokens

Conversation

@danielle-mustillo

@danielle-mustillo danielle-mustillo commented Oct 19, 2026 •

Copy link
Copy Markdown
Contributor

Stacked on #3261. Until #3261 merges, this diff also shows its four commits. Review the last eight commits.

Why

After #3261, a controller that watches some namespaces still passes ate-api's page tokens to the caller. ate-api tokens are readable base64 JSON that hold the last row's key, so a token can name another tenant's actor ([atespace, name]) or worker. With two or more watched namespaces, actor pages can also be empty and still have a next-page token.

What changed

  • Actors: a controller that watches some namespaces reads each watched atespace with its own scoped list, and returns a kagent token, base64url("atespace/inner"). A bad or foreign token gets InvalidArgument.
  • Workers: such a controller reads all workers, keeps the ones in scope, and pages them by worker name. The token is base64url("namespace/name") of the last worker, and it never goes to ate-api. A bad or foreign token gets InvalidArgument. ate-api worker errors return only the gRPC code.
  • Proto docs and the UI say "All watched atespaces".

Scope

  • Covers ListSubstrateActors, ListSubstrateWorkers and the worker count in GetSubstrateSummary. A cluster-wide install passes ate-api's pages and tokens through, as before.
  • Not included: duplicate row checks after scoped ate-api lists.

Tradeoffs

  • The worker list reads every tenant's workers on each request, because ate-api cannot filter workers by namespace. The alternative was to keep ate-api's token, which leaks names. Replace this with an ate-api namespace filter when one exists.

Blast Radius

Only installs that watch some namespaces change. An actor or worker page token from an older controller gets InvalidArgument. Reload the UI page, or send the request without the token.

Verification

  • Kind cluster with two tenants on one shared Substrate. As tenant-a, ListSubstrateWorkers {page:{limit:1}}: before, page 1 was empty, and its token decoded to a tenant-b worker UID. After, each page holds one tenant-a worker, and the token is a tenant-a worker name. A foreign actor token gets InvalidArgument.
  • Unit tests send 600 random and hand-made forged tokens to both lists. No read leaves the watched namespaces, and no token or row names another tenant.
  • Each test commit fails on its parent and passes after its fix. go test ./core/internal/service/... passes, and make lint reports 0 issues.

…entory

Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
A kagent that watches only its own namespace listed every tenant's actors
and ActorTemplates from a shared Substrate, and served a foreign atespace
or namespace when a caller named one.

Resolve one scope from the watched namespaces for every Substrate read.
An empty request means the watched namespaces, and a namespace or atespace
outside them is refused with PermissionDenied. A cluster-wide install
still sees everything.

Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
…refusal wording

A controller watching tenant-a and tenant-c must never page tenant-b actors
to the caller, must ask ate-api only for its own atespaces when it builds
the summary, and must hide a worker that reports no namespace. The refusal
message must say the namespace is not watched, since the gRPC layer sends
only the message.

Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
…workers

The summary for a controller watching several namespaces asked ate-api for
every atespace and filtered the rows itself, so ate-api served every
tenant's actors and templates to it. It now walks actors and drains
templates once per atespace in scope. ListSubstrateActors still reads one
upstream page across all atespaces and filters it, because one upstream
token cannot span several requests.

A worker that reports no namespace now belongs only to a cluster-wide
scope, and the refusal message names the namespace that is not watched,
since the gRPC layer sends only the message.

Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
A controller watching tenant-a and tenant-c must page through its own
actors in watched-atespace order, scope every ate-api read to one watched
atespace, never hand out an empty page with a token, and never return a
token that names a tenant-b actor. A token it did not issue is
InvalidArgument, without echoing the token and without an ate-api call.

The cluster-wide pass-through test now runs cluster-wide, and the
assertion that a filtered-out first page still leads on is dropped: that
page is the leak.

Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
With two or more watched namespaces, ListSubstrateActors asked ate-api for
every atespace and passed its page token through. ate-api tokens are
readable base64 JSON holding the last row's atespace and name, so a token
could name another tenant's actor, and a page filtered to nothing still
carried a token.

A controller that watches some namespaces now reads each watched atespace
with its own scoped list, in sorted order, and returns its own token: the
atespace to read and ate-api's token inside it. A token it did not issue,
or one longer than 1 KiB, is InvalidArgument and never reaches ate-api.
NewService sorts and dedupes the watched list, since tokens resume in its
order. A cluster-wide controller still passes ate-api's page and token
through.

Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
… workers

A controller watching tenant-a and tenant-c must page its own workers once
each, in worker name order whatever order ate-api sends, never hand out an
empty page with a token, and never send its own token to ate-api. An
ate-api worker list error must reach the caller as its gRPC code only,
since the text can name another tenant's worker.

The tenant isolation fixtures now name their workers, as ate-api does.

Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
A controller that watches some namespaces passed ate-api's worker page
token through. The token holds the last worker's name, so it could name
another tenant's worker, and a page filtered to nothing still carried a
token.

Such a controller now drains ate-api's workers, keeps the ones in scope,
and pages them in worker name order. Its token is the last worker's name:
a cursor only, never sent to ate-api. ate-api worker list errors reach the
caller as the gRPC code only, for the list and the summary, since the text
can name another tenant's worker; the full error is logged. A
cluster-wide controller still passes ate-api's page, token and error
through.

Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
…spaces

The proto comments, the generated Go and TypeScript, the UI page type and
the Playwright deferral notes said an empty atespace lists all atespaces
and that every page passes Substrate's token through. A controller that
watches some namespaces lists only its watched atespaces, issues its own
tokens, and never returns an empty page with a token. The atespace filter
placeholder now reads "All watched atespaces".

Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
@github-actions github-actions Bot added the bug Something isn't working label Oct 19, 2026
Drop comments that restate the code. The proto now says that an actor
page can be empty when another page exists, and gives the worker order
for each mode.

Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Oct 19, 2026
… tokens

The fake ate-api now hands out base64 JSON tokens naming the page's last row, as
ate-api does, so assertOwnToken can see a token that would name another tenant's
row. New tests: scoped worker page tokens must be refused unless kagent issued
them, a scoped summary hides ate-api worker error text, forged tokens never reach
or return an unwatched namespace, and the longest names fit the page token.

Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
A controller that watches some namespaces used any worker page_token raw as the
name to resume after, so an ate-api token or junk silently returned a wrong or
short page. Worker tokens now share the actor tokens' shape (base64url of a
watched namespace, a slash, then the cursor) and the same 1 KiB cap, and
anything else is InvalidArgument. The length check now runs before decoding.
Cluster-wide installs still pass ate-api's tokens through.

Signed-off-by: Danielle Mustillo <danielle.mustillo@solo.io>
@github-actions github-actions Bot added bug Something isn't working and removed bug Something isn't working labels Oct 19, 2026
@danielle-mustillo
danielle-mustillo marked this pull request as ready for review October 19, 2026 06:53
@danielle-mustillo
danielle-mustillo requested a review from a team as a code owner October 19, 2026 06:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant