From 0faac5d435bfe5a2b8586410cbe4328bb07de980 Mon Sep 17 00:00:00 2001 From: Danielle Mustillo Date: Sun, 18 Oct 2026 20:16:29 -0400 Subject: [PATCH 1/8] test(substrate): reproduce one tenant reading another's Substrate inventory Signed-off-by: Danielle Mustillo --- .../internal/service/system/service_test.go | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/go/core/internal/service/system/service_test.go b/go/core/internal/service/system/service_test.go index c6e19b1dd1..5af4cefc89 100644 --- a/go/core/internal/service/system/service_test.go +++ b/go/core/internal/service/system/service_test.go @@ -606,3 +606,77 @@ func TestSubstrateEmptyPagesPreserveUpstreamTokens(t *testing.T) { assert.Empty(t, workers.ATEAPIError) assert.Equal(t, "upstream:next/cursor=", workers.NextPageToken) } + +// Two kagent installs, each watching only its own namespace, share one ate-api. Each +// must see only its own atespace, and refuse a request naming the other. +func TestSubstrateTenantIsolation(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, atev1alpha1.AddToScheme(scheme)) + ctx := pkgAuth.AuthSessionTo(t.Context(), &authimpl.SimpleSession{P: pkgAuth.Principal{User: pkgAuth.User{ID: "user"}}}) + ateClient := &fakeATEClient{ + pageSize: 1, + actors: []*ateapipb.Actor{ + substrateActor("session-b", "tenant-b", ateapipb.ActorState_ACTOR_STATE_RUNNING, "tenant-b", "pod-b"), + substrateActor("session-a", "tenant-a", ateapipb.ActorState_ACTOR_STATE_SUSPENDED, "", ""), + }, + templates: []*ateapipb.ActorTemplate{ + {Metadata: &ateapipb.ResourceMetadata{Atespace: "tenant-a", Name: "agent-a"}}, + {Metadata: &ateapipb.ResourceMetadata{Atespace: "tenant-b", Name: "agent-b"}}, + }, + workers: []*ateapipb.Worker{ + {WorkerNamespace: "tenant-a", WorkerPod: "pod-a"}, + {WorkerNamespace: "tenant-b", WorkerPod: "pod-b"}, + }, + } + tenantA := system.NewService(fake.NewClientBuilder().WithScheme(scheme).Build(), []string{"tenant-a"}, &pkgAuth.NoopAuthorizer{}, ateClient) + + t.Run("lists only its own actors", func(t *testing.T) { + var names []string + request := &apiv1.ListSubstrateActorsRequest{Page: &apiv1.PageRequest{Limit: 1}} + for { + page, err := tenantA.ListSubstrateActors(ctx, request) + require.NoError(t, err) + for _, actor := range page.Actors { + names = append(names, actor.GetMetadata().GetAtespace()+"/"+actor.GetMetadata().GetName()) + } + if page.NextPageToken == "" { + break + } + request.Page.PageToken = page.NextPageToken + } + assert.Equal(t, []string{"tenant-a/session-a"}, names) + }) + + t.Run("summarises only its own atespace", func(t *testing.T) { + summary, err := tenantA.GetSubstrateSummary(ctx, "", "") + require.NoError(t, err) + require.Empty(t, summary.ATEAPIError) + require.Len(t, summary.ActorTemplates, 1) + assert.Equal(t, "agent-a", summary.ActorTemplates[0].GetMetadata().GetName()) + assert.Equal(t, int64(1), summary.ActorCount) + assert.Equal(t, int64(0), summary.RunningActorCount) + assert.Equal(t, int64(1), summary.WorkerCount) + }) + + t.Run("refuses another tenant's atespace or namespace", func(t *testing.T) { + _, err := tenantA.ListSubstrateActors(ctx, &apiv1.ListSubstrateActorsRequest{Atespace: "tenant-b"}) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied), err) + _, err = tenantA.GetSubstrateSummary(ctx, "", "tenant-b") + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied), err) + _, err = tenantA.GetSubstrateSummary(ctx, "tenant-b", "") + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied), err) + _, err = tenantA.ListSubstrateWorkers(ctx, &apiv1.ListSubstrateWorkersRequest{Namespace: "tenant-b"}) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied), err) + }) + + t.Run("a cluster-wide install still sees every tenant", func(t *testing.T) { + clusterWide := system.NewService(fake.NewClientBuilder().WithScheme(scheme).Build(), nil, &pkgAuth.NoopAuthorizer{}, ateClient) + summary, err := clusterWide.GetSubstrateSummary(ctx, "", "") + require.NoError(t, err) + assert.Equal(t, int64(2), summary.ActorCount) + assert.Len(t, summary.ActorTemplates, 2) + page, err := clusterWide.ListSubstrateActors(ctx, &apiv1.ListSubstrateActorsRequest{Atespace: "tenant-b"}) + require.NoError(t, err) + require.Len(t, page.Actors, 1) + }) +} From 4ce42c30a72b6a2aab7d46f3e31facf6942244c6 Mon Sep 17 00:00:00 2001 From: Danielle Mustillo Date: Sun, 18 Oct 2026 20:21:09 -0400 Subject: [PATCH 2/8] fix(substrate): scope actors and templates to the watched namespaces 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 --- go/core/internal/service/system/service.go | 16 +-- .../internal/service/system/service_test.go | 3 +- go/core/internal/service/system/substrate.go | 121 ++++++++++++------ 3 files changed, 85 insertions(+), 55 deletions(-) diff --git a/go/core/internal/service/system/service.go b/go/core/internal/service/system/service.go index f3dd63e716..a4aaf3aca9 100644 --- a/go/core/internal/service/system/service.go +++ b/go/core/internal/service/system/service.go @@ -148,16 +148,6 @@ func namespacesFromNames(names []string) []Namespace { return result } -func (s *Service) substrateNamespaces(requested string) []string { - if requested != "" { - return []string{requested} - } - if len(s.observedNamespaces) > 0 { - return slices.Clone(s.observedNamespaces) - } - return []string{""} -} - func (s *Service) listWorkerPools(ctx context.Context, namespace string) ([]atev1alpha1.WorkerPool, error) { var options []client.ListOption if namespace != "" { @@ -173,14 +163,14 @@ func (s *Service) listWorkerPools(ctx context.Context, namespace string) ([]atev } // substrateActorTemplates drains upstream pagination for the configuration-sized list. -func (s *Service) substrateActorTemplates(ctx context.Context, atespace string) ([]*ateapipb.ActorTemplate, error) { - templatesFromAPI, err := s.ateClient.ListActorTemplates(ctx, atespace) +func (s *Service) substrateActorTemplates(ctx context.Context, scope substrateScope) ([]*ateapipb.ActorTemplate, error) { + templatesFromAPI, err := s.ateClient.ListActorTemplates(ctx, scope.atespace()) if err != nil { return nil, err } templates := make([]*ateapipb.ActorTemplate, 0, len(templatesFromAPI)) for _, template := range templatesFromAPI { - if template == nil { + if template == nil || !scope.contains(template.GetMetadata().GetAtespace()) { continue } // Only expose inventory fields: containers can contain resolved credentials. diff --git a/go/core/internal/service/system/service_test.go b/go/core/internal/service/system/service_test.go index 5af4cefc89..d4ac94ecc6 100644 --- a/go/core/internal/service/system/service_test.go +++ b/go/core/internal/service/system/service_test.go @@ -529,7 +529,7 @@ func TestSubstrateScopesAreIndependent(t *testing.T) { }, workers: []*ateapipb.Worker{{WorkerNamespace: "workers", WorkerPod: "pod"}}, } - service := system.NewService(kubeClient, []string{"workers"}, &pkgAuth.NoopAuthorizer{}, ateClient) + service := system.NewService(kubeClient, []string{"workers", "team-a", "shared"}, &pkgAuth.NoopAuthorizer{}, ateClient) for _, tc := range []struct { atespace string count int64 @@ -538,7 +538,6 @@ func TestSubstrateScopesAreIndependent(t *testing.T) { {atespace: "", count: 2, running: 1}, {atespace: "team-a", count: 1, running: 1}, {atespace: "shared", count: 1, running: 0}, - {atespace: "missing", count: 0, running: 0}, } { t.Run(tc.atespace, func(t *testing.T) { summary, err := service.GetSubstrateSummary(ctx, "", tc.atespace) diff --git a/go/core/internal/service/system/substrate.go b/go/core/internal/service/system/substrate.go index 33349bbff2..a5c225b9e3 100644 --- a/go/core/internal/service/system/substrate.go +++ b/go/core/internal/service/system/substrate.go @@ -76,7 +76,14 @@ type SubstrateActorStatusCount struct { // ate-api reports no totals, so every count costs a walk of its pages. The walk holds // one page at a time and keeps only tallies. func (s *Service) GetSubstrateSummary(ctx context.Context, requestedNamespace, atespace string) (SubstrateSummary, error) { - namespaces, err := s.substrateScope(ctx, requestedNamespace) + if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: "Substrate"}); err != nil { + return SubstrateSummary{}, err + } + workerScope, err := s.resolveSubstrateScope(requestedNamespace) + if err != nil { + return SubstrateSummary{}, err + } + actorScope, err := s.resolveSubstrateScope(atespace) if err != nil { return SubstrateSummary{}, err } @@ -88,7 +95,7 @@ func (s *Service) GetSubstrateSummary(ctx context.Context, requestedNamespace, a ComputedAt: time.Now().UTC(), } - for _, namespace := range namespaces { + for _, namespace := range workerScope.listNamespaces() { workerPools, err := s.listWorkerPools(ctx, namespace) if err != nil { return SubstrateSummary{}, serviceerrors.NewInternal("Failed to list substrate resources from Kubernetes", err) @@ -99,18 +106,16 @@ func (s *Service) GetSubstrateSummary(ctx context.Context, requestedNamespace, a return strings.Compare(left.Namespace+"/"+left.Name, right.Namespace+"/"+right.Name) }) - allowAll, allowed := substrateScopeFilter(namespaces) - // Three independent reads: none gates the others, so one failure leaves the rest // counted rather than zeroing the whole summary. - if templates, err := s.substrateActorTemplates(ctx, atespace); err != nil { + if templates, err := s.substrateActorTemplates(ctx, actorScope); err != nil { result.recordATEError(ctx, err) } else { result.ActorTemplates = templates } statusCounts := map[ateapipb.ActorState]int64{} - if err := s.walkActors(ctx, atespace, func(actor *ateapipb.Actor) { + if err := s.walkActors(ctx, actorScope, func(actor *ateapipb.Actor) { if actor == nil { return } @@ -124,7 +129,7 @@ func (s *Service) GetSubstrateSummary(ctx context.Context, requestedNamespace, a } if err := s.walkWorkers(ctx, func(worker *ateapipb.Worker) { - if worker == nil || !allowedWorkerNamespace(worker.GetWorkerNamespace(), allowAll, allowed) { + if !workerScope.containsWorker(worker) { return } result.WorkerCount++ @@ -145,19 +150,24 @@ func (s *Service) GetSubstrateSummary(ctx context.Context, requestedNamespace, a return result, nil } -// ListSubstrateActors returns one upstream page without changing its order or token. +// ListSubstrateActors filters one upstream page by atespace and preserves its order and token. func (s *Service) ListSubstrateActors(ctx context.Context, input *apiv1.ListSubstrateActorsRequest) (SubstrateActorPage, error) { - if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: "Substrate"}); err != nil { + scope, err := s.authorizeSubstrateScope(ctx, input.GetAtespace()) + if err != nil { return SubstrateActorPage{}, err } result := SubstrateActorPage{ComputedAt: time.Now().UTC()} - actors, next, err := s.ateClient.ListActorsPage(ctx, input.GetAtespace(), substratePageSize(input.GetPage().GetLimit()), input.GetPage().GetPageToken()) + actors, next, err := s.ateClient.ListActorsPage(ctx, scope.atespace(), substratePageSize(input.GetPage().GetLimit()), input.GetPage().GetPageToken()) if err != nil { result.ATEAPIError = err.Error() logging.FromContext(ctx).ErrorContext(ctx, "failed to list ate-api actors", "error", err) return result, nil } - result.Actors = actors + for _, actor := range actors { + if actor != nil && scope.contains(actor.GetMetadata().GetAtespace()) { + result.Actors = append(result.Actors, actor) + } + } result.NextPageToken = next return result, nil } @@ -165,7 +175,7 @@ func (s *Service) ListSubstrateActors(ctx context.Context, input *apiv1.ListSubs // ListSubstrateWorkers filters one upstream page by namespace and preserves its token. // A page with no workers in scope may still have a next page. func (s *Service) ListSubstrateWorkers(ctx context.Context, input *apiv1.ListSubstrateWorkersRequest) (SubstrateWorkerPage, error) { - namespaces, err := s.substrateScope(ctx, input.GetNamespace()) + scope, err := s.authorizeSubstrateScope(ctx, input.GetNamespace()) if err != nil { return SubstrateWorkerPage{}, err } @@ -176,9 +186,8 @@ func (s *Service) ListSubstrateWorkers(ctx context.Context, input *apiv1.ListSub logging.FromContext(ctx).ErrorContext(ctx, "failed to list ate-api workers", "error", err) return result, nil } - allowAll, allowed := substrateScopeFilter(namespaces) for _, worker := range workers { - if worker != nil && allowedWorkerNamespace(worker.GetWorkerNamespace(), allowAll, allowed) { + if scope.containsWorker(worker) { result.Workers = append(result.Workers, worker) } } @@ -186,12 +195,16 @@ func (s *Service) ListSubstrateWorkers(ctx context.Context, input *apiv1.ListSub return result, nil } -// walkActors visits every actor in the requested atespace, one page at a time. -func (s *Service) walkActors(ctx context.Context, atespace string, visit func(*ateapipb.Actor)) error { +// walkActors visits every actor in scope, one page at a time. +func (s *Service) walkActors(ctx context.Context, scope substrateScope, visit func(*ateapipb.Actor)) error { read := func(ctx context.Context, pageSize int32, pageToken string) ([]*ateapipb.Actor, string, error) { - return s.ateClient.ListActorsPage(ctx, atespace, pageSize, pageToken) + return s.ateClient.ListActorsPage(ctx, scope.atespace(), pageSize, pageToken) } - return walkSubstrate(ctx, read, visit) + return walkSubstrate(ctx, read, func(actor *ateapipb.Actor) { + if actor != nil && scope.contains(actor.GetMetadata().GetAtespace()) { + visit(actor) + } + }) } // walkWorkers calls visit for every worker ate-api holds, one page at a time. @@ -225,36 +238,64 @@ func walkSubstrate[Row any]( return fmt.Errorf("ate-api did not finish paging after %d pages", maxATEPagesPerWalk) } -// substrateScope authorizes the caller and resolves the namespaces a read covers. -// ATE-only actor reads authorize independently of Kubernetes scope. -func (s *Service) substrateScope(ctx context.Context, requestedNamespace string) ([]string, error) { +// substrateScope is the set of namespaces a Substrate read covers. An atespace is named +// after the namespace of the agent it serves, so the same set bounds actors and templates. +type substrateScope struct { + all bool + namespaces []string +} + +// authorizeSubstrateScope authorizes the caller and resolves the scope of a read. +func (s *Service) authorizeSubstrateScope(ctx context.Context, requested string) (substrateScope, error) { if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: "Substrate"}); err != nil { - return nil, err + return substrateScope{}, err } - return s.substrateNamespaces(requestedNamespace), nil + return s.resolveSubstrateScope(requested) } -// substrateScopeFilter turns resolved namespaces into the pair every row filter here -// takes: whether every namespace is in scope, and the set that is when it is not. -func substrateScopeFilter(namespaces []string) (bool, map[string]struct{}) { - allowAll := len(namespaces) == 1 && namespaces[0] == "" - allowed := make(map[string]struct{}, len(namespaces)) - for _, namespace := range namespaces { - if namespace != "" { - allowed[namespace] = struct{}{} - } +// resolveSubstrateScope narrows the watched namespaces to the requested one. A +// controller that watches only some namespaces refuses any other. +func (s *Service) resolveSubstrateScope(requested string) (substrateScope, error) { + switch { + case requested == "" && len(s.observedNamespaces) == 0: + return substrateScope{all: true}, nil + case requested == "": + return substrateScope{namespaces: slices.Clone(s.observedNamespaces)}, nil + case len(s.observedNamespaces) == 0 || slices.Contains(s.observedNamespaces, requested): + return substrateScope{namespaces: []string{requested}}, nil + default: + return substrateScope{}, serviceerrors.NewPermissionDenied("Not authorized", fmt.Errorf("namespace %q is not watched by this controller", requested)) + } +} + +func (scope substrateScope) contains(namespace string) bool { + return scope.all || slices.Contains(scope.namespaces, namespace) +} + +// containsWorker also admits a worker that reports no namespace. +func (scope substrateScope) containsWorker(worker *ateapipb.Worker) bool { + if worker == nil { + return false + } + namespace := strings.TrimSpace(worker.GetWorkerNamespace()) + return namespace == "" || scope.contains(namespace) +} + +// atespace is the atespace to ask ate-api for: the only one in scope, or every +// atespace ("") for the caller to filter. +func (scope substrateScope) atespace() string { + if len(scope.namespaces) == 1 { + return scope.namespaces[0] } - return allowAll, allowed + return "" } -// allowedWorkerNamespace filters workers by their Kubernetes namespace. -func allowedWorkerNamespace(namespace string, allowAll bool, allowed map[string]struct{}) bool { - namespace = strings.TrimSpace(namespace) - if allowAll || namespace == "" { - return true +// listNamespaces is the namespaces to list Kubernetes objects in, "" meaning all. +func (scope substrateScope) listNamespaces() []string { + if scope.all { + return []string{""} } - _, ok := allowed[namespace] - return ok + return scope.namespaces } func substratePageSize(requested int32) int32 { From 93f555f9c611403f5605fddaa0cff7ea3d3566a9 Mon Sep 17 00:00:00 2001 From: Danielle Mustillo Date: Sun, 18 Oct 2026 21:00:34 -0400 Subject: [PATCH 3/8] test(substrate): cover multi-namespace scope, unlabelled workers and 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 --- .../internal/service/system/service_test.go | 112 +++++++++++++++--- 1 file changed, 97 insertions(+), 15 deletions(-) diff --git a/go/core/internal/service/system/service_test.go b/go/core/internal/service/system/service_test.go index d4ac94ecc6..09a90c5844 100644 --- a/go/core/internal/service/system/service_test.go +++ b/go/core/internal/service/system/service_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "strconv" "strings" "testing" @@ -48,9 +49,13 @@ type fakeATEClient struct { // was followed rather than that the rows came back. actorReads int workerReads int + // The atespaces ate-api was asked for, since asking for "" reads every tenant's rows. + actorAtespaces []string + templateAtespaces []string } func (client *fakeATEClient) ListActorTemplates(_ context.Context, atespace string) ([]*ateapipb.ActorTemplate, error) { + client.templateAtespaces = append(client.templateAtespaces, atespace) templates := []*ateapipb.ActorTemplate{} for _, template := range client.templates { if atespace == "" || template.GetMetadata().GetAtespace() == atespace { @@ -62,6 +67,7 @@ func (client *fakeATEClient) ListActorTemplates(_ context.Context, atespace stri func (client *fakeATEClient) ListActorsPage(_ context.Context, atespace string, pageSize int32, pageToken string) ([]*ateapipb.Actor, string, error) { client.actorReads++ + client.actorAtespaces = append(client.actorAtespaces, atespace) if err := client.readError(client.actorReads); err != nil { return nil, "", err } @@ -606,8 +612,8 @@ func TestSubstrateEmptyPagesPreserveUpstreamTokens(t *testing.T) { assert.Equal(t, "upstream:next/cursor=", workers.NextPageToken) } -// Two kagent installs, each watching only its own namespace, share one ate-api. Each -// must see only its own atespace, and refuse a request naming the other. +// Kagent installs that each watch only some namespaces share one ate-api. Each must see +// only the atespaces it watches, and refuse a request naming another. func TestSubstrateTenantIsolation(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, atev1alpha1.AddToScheme(scheme)) @@ -617,49 +623,116 @@ func TestSubstrateTenantIsolation(t *testing.T) { actors: []*ateapipb.Actor{ substrateActor("session-b", "tenant-b", ateapipb.ActorState_ACTOR_STATE_RUNNING, "tenant-b", "pod-b"), substrateActor("session-a", "tenant-a", ateapipb.ActorState_ACTOR_STATE_SUSPENDED, "", ""), + substrateActor("session-c", "tenant-c", ateapipb.ActorState_ACTOR_STATE_RUNNING, "tenant-c", "pod-c"), + substrateActor("session-b2", "tenant-b", ateapipb.ActorState_ACTOR_STATE_PAUSED, "", ""), }, templates: []*ateapipb.ActorTemplate{ {Metadata: &ateapipb.ResourceMetadata{Atespace: "tenant-a", Name: "agent-a"}}, {Metadata: &ateapipb.ResourceMetadata{Atespace: "tenant-b", Name: "agent-b"}}, + {Metadata: &ateapipb.ResourceMetadata{Atespace: "tenant-c", Name: "agent-c"}}, }, workers: []*ateapipb.Worker{ {WorkerNamespace: "tenant-a", WorkerPod: "pod-a"}, {WorkerNamespace: "tenant-b", WorkerPod: "pod-b"}, + {WorkerNamespace: "tenant-c", WorkerPod: "pod-c"}, + {WorkerPod: "pod-unlabelled"}, }, } - tenantA := system.NewService(fake.NewClientBuilder().WithScheme(scheme).Build(), []string{"tenant-a"}, &pkgAuth.NoopAuthorizer{}, ateClient) + newService := func(observedNamespaces []string) *system.Service { + return system.NewService(fake.NewClientBuilder().WithScheme(scheme).Build(), observedNamespaces, &pkgAuth.NoopAuthorizer{}, ateClient) + } + tenantA := newService([]string{"tenant-a"}) + tenantsAC := newService([]string{"tenant-a", "tenant-c"}) + clusterWide := newService(nil) - t.Run("lists only its own actors", func(t *testing.T) { + listAllActors := func(t *testing.T, service *system.Service, atespace string) []string { var names []string - request := &apiv1.ListSubstrateActorsRequest{Page: &apiv1.PageRequest{Limit: 1}} + request := &apiv1.ListSubstrateActorsRequest{Atespace: atespace, Page: &apiv1.PageRequest{Limit: 1}} for { - page, err := tenantA.ListSubstrateActors(ctx, request) + page, err := service.ListSubstrateActors(ctx, request) require.NoError(t, err) for _, actor := range page.Actors { names = append(names, actor.GetMetadata().GetAtespace()+"/"+actor.GetMetadata().GetName()) } if page.NextPageToken == "" { - break + return names + } + request.Page.PageToken = page.NextPageToken + } + } + workerPods := func(t *testing.T, service *system.Service) []string { + var pods []string + request := &apiv1.ListSubstrateWorkersRequest{Page: &apiv1.PageRequest{}} + for { + page, err := service.ListSubstrateWorkers(ctx, request) + require.NoError(t, err) + for _, worker := range page.Workers { + pods = append(pods, worker.GetWorkerPod()) + } + if page.NextPageToken == "" { + return pods } request.Page.PageToken = page.NextPageToken } - assert.Equal(t, []string{"tenant-a/session-a"}, names) + } + templateNames := func(summary system.SubstrateSummary) []string { + var names []string + for _, template := range summary.ActorTemplates { + names = append(names, template.GetMetadata().GetName()) + } + return names + } + + t.Run("lists only its own actors", func(t *testing.T) { + assert.Equal(t, []string{"tenant-a/session-a"}, listAllActors(t, tenantA, "")) }) t.Run("summarises only its own atespace", func(t *testing.T) { summary, err := tenantA.GetSubstrateSummary(ctx, "", "") require.NoError(t, err) require.Empty(t, summary.ATEAPIError) - require.Len(t, summary.ActorTemplates, 1) - assert.Equal(t, "agent-a", summary.ActorTemplates[0].GetMetadata().GetName()) + assert.Equal(t, []string{"agent-a"}, templateNames(summary)) assert.Equal(t, int64(1), summary.ActorCount) assert.Equal(t, int64(0), summary.RunningActorCount) assert.Equal(t, int64(1), summary.WorkerCount) }) + t.Run("hides a worker that reports no namespace", func(t *testing.T) { + assert.Equal(t, []string{"pod-a"}, workerPods(t, tenantA)) + assert.Equal(t, []string{"pod-a", "pod-c"}, workerPods(t, tenantsAC)) + }) + + t.Run("a controller watching two namespaces lists only those atespaces", func(t *testing.T) { + assert.Equal(t, []string{"tenant-a/session-a", "tenant-c/session-c"}, listAllActors(t, tenantsAC, "")) + + first, err := tenantsAC.ListSubstrateActors(ctx, &apiv1.ListSubstrateActorsRequest{Page: &apiv1.PageRequest{Limit: 1}}) + require.NoError(t, err) + assert.Empty(t, first.Actors, "the first upstream page holds only a tenant-b actor") + assert.NotEmpty(t, first.NextPageToken, "a page filtered to nothing still leads to the rest") + }) + + t.Run("a controller watching two namespaces asks ate-api for only those atespaces", func(t *testing.T) { + ateClient.actorAtespaces, ateClient.templateAtespaces = nil, nil + summary, err := tenantsAC.GetSubstrateSummary(ctx, "", "") + require.NoError(t, err) + require.Empty(t, summary.ATEAPIError) + assert.Equal(t, int64(2), summary.ActorCount) + assert.Equal(t, int64(1), summary.RunningActorCount) + assert.ElementsMatch(t, []system.SubstrateActorStatusCount{ + {State: ateapipb.ActorState_ACTOR_STATE_RUNNING, Count: 1}, + {State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED, Count: 1}, + }, summary.ActorStatusCounts) + assert.Equal(t, []string{"agent-a", "agent-c"}, templateNames(summary)) + assert.Equal(t, int64(2), summary.WorkerCount) + + assert.Equal(t, []string{"tenant-a", "tenant-c"}, slices.Compact(slices.Sorted(slices.Values(ateClient.actorAtespaces)))) + assert.ElementsMatch(t, []string{"tenant-a", "tenant-c"}, ateClient.templateAtespaces) + }) + t.Run("refuses another tenant's atespace or namespace", func(t *testing.T) { _, err := tenantA.ListSubstrateActors(ctx, &apiv1.ListSubstrateActorsRequest{Atespace: "tenant-b"}) assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied), err) + assert.Contains(t, serviceerrors.MessageOf(err), "not watched by this controller", "the caller sees only the message, not the cause") _, err = tenantA.GetSubstrateSummary(ctx, "", "tenant-b") assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied), err) _, err = tenantA.GetSubstrateSummary(ctx, "tenant-b", "") @@ -669,13 +742,22 @@ func TestSubstrateTenantIsolation(t *testing.T) { }) t.Run("a cluster-wide install still sees every tenant", func(t *testing.T) { - clusterWide := system.NewService(fake.NewClientBuilder().WithScheme(scheme).Build(), nil, &pkgAuth.NoopAuthorizer{}, ateClient) summary, err := clusterWide.GetSubstrateSummary(ctx, "", "") require.NoError(t, err) - assert.Equal(t, int64(2), summary.ActorCount) - assert.Len(t, summary.ActorTemplates, 2) - page, err := clusterWide.ListSubstrateActors(ctx, &apiv1.ListSubstrateActorsRequest{Atespace: "tenant-b"}) + assert.Equal(t, int64(4), summary.ActorCount) + assert.Len(t, summary.ActorTemplates, 3) + assert.Equal(t, int64(4), summary.WorkerCount) + assert.Equal(t, []string{"pod-a", "pod-b", "pod-c", "pod-unlabelled"}, workerPods(t, clusterWide)) + assert.Equal(t, []string{"tenant-b/session-b", "tenant-b/session-b2"}, listAllActors(t, clusterWide, "tenant-b")) + }) + + t.Run("a cluster-wide install counts nothing in an atespace with no rows", func(t *testing.T) { + summary, err := clusterWide.GetSubstrateSummary(ctx, "", "missing") require.NoError(t, err) - require.Len(t, page.Actors, 1) + assert.Empty(t, summary.ATEAPIError) + assert.Zero(t, summary.ActorCount) + assert.Zero(t, summary.RunningActorCount) + assert.Empty(t, summary.ActorStatusCounts) + assert.Empty(t, summary.ActorTemplates) }) } From 0f23fc5a31f27b989034a5c872f7ec37cd560276 Mon Sep 17 00:00:00 2001 From: Danielle Mustillo Date: Sun, 18 Oct 2026 21:02:39 -0400 Subject: [PATCH 4/8] fix(substrate): ask ate-api per watched atespace and hide unlabelled 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 --- go/core/internal/service/system/service.go | 32 ++++++----- .../internal/service/system/service_test.go | 7 +-- go/core/internal/service/system/substrate.go | 57 ++++++------------- 3 files changed, 36 insertions(+), 60 deletions(-) diff --git a/go/core/internal/service/system/service.go b/go/core/internal/service/system/service.go index a4aaf3aca9..9f35284df5 100644 --- a/go/core/internal/service/system/service.go +++ b/go/core/internal/service/system/service.go @@ -164,22 +164,24 @@ func (s *Service) listWorkerPools(ctx context.Context, namespace string) ([]atev // substrateActorTemplates drains upstream pagination for the configuration-sized list. func (s *Service) substrateActorTemplates(ctx context.Context, scope substrateScope) ([]*ateapipb.ActorTemplate, error) { - templatesFromAPI, err := s.ateClient.ListActorTemplates(ctx, scope.atespace()) - if err != nil { - return nil, err - } - templates := make([]*ateapipb.ActorTemplate, 0, len(templatesFromAPI)) - for _, template := range templatesFromAPI { - if template == nil || !scope.contains(template.GetMetadata().GetAtespace()) { - continue + templates := []*ateapipb.ActorTemplate{} + for _, atespace := range scope.listNamespaces() { + templatesFromAPI, err := s.ateClient.ListActorTemplates(ctx, atespace) + if err != nil { + return nil, err + } + for _, template := range templatesFromAPI { + if template == nil { + continue + } + // Only expose inventory fields: containers can contain resolved credentials. + templates = append(templates, &ateapipb.ActorTemplate{ + Metadata: template.GetMetadata(), + Status: template.GetStatus(), + SandboxConfig: template.GetSandboxConfig(), + WorkerSelector: template.GetWorkerSelector(), + }) } - // Only expose inventory fields: containers can contain resolved credentials. - templates = append(templates, &ateapipb.ActorTemplate{ - Metadata: template.GetMetadata(), - Status: template.GetStatus(), - SandboxConfig: template.GetSandboxConfig(), - WorkerSelector: template.GetWorkerSelector(), - }) } slices.SortStableFunc(templates, func(left, right *ateapipb.ActorTemplate) int { diff --git a/go/core/internal/service/system/service_test.go b/go/core/internal/service/system/service_test.go index 09a90c5844..2dcdd17de2 100644 --- a/go/core/internal/service/system/service_test.go +++ b/go/core/internal/service/system/service_test.go @@ -47,9 +47,8 @@ type fakeATEClient struct { pageSize int // How many page reads each list has taken, so a test can assert that the token // was followed rather than that the rows came back. - actorReads int - workerReads int - // The atespaces ate-api was asked for, since asking for "" reads every tenant's rows. + actorReads int + workerReads int actorAtespaces []string templateAtespaces []string } @@ -612,8 +611,6 @@ func TestSubstrateEmptyPagesPreserveUpstreamTokens(t *testing.T) { assert.Equal(t, "upstream:next/cursor=", workers.NextPageToken) } -// Kagent installs that each watch only some namespaces share one ate-api. Each must see -// only the atespaces it watches, and refuse a request naming another. func TestSubstrateTenantIsolation(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, atev1alpha1.AddToScheme(scheme)) diff --git a/go/core/internal/service/system/substrate.go b/go/core/internal/service/system/substrate.go index a5c225b9e3..6c15366762 100644 --- a/go/core/internal/service/system/substrate.go +++ b/go/core/internal/service/system/substrate.go @@ -76,10 +76,7 @@ type SubstrateActorStatusCount struct { // ate-api reports no totals, so every count costs a walk of its pages. The walk holds // one page at a time and keeps only tallies. func (s *Service) GetSubstrateSummary(ctx context.Context, requestedNamespace, atespace string) (SubstrateSummary, error) { - if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: "Substrate"}); err != nil { - return SubstrateSummary{}, err - } - workerScope, err := s.resolveSubstrateScope(requestedNamespace) + workerScope, err := s.authorizeSubstrateScope(ctx, requestedNamespace) if err != nil { return SubstrateSummary{}, err } @@ -129,7 +126,7 @@ func (s *Service) GetSubstrateSummary(ctx context.Context, requestedNamespace, a } if err := s.walkWorkers(ctx, func(worker *ateapipb.Worker) { - if !workerScope.containsWorker(worker) { + if worker == nil || !workerScope.contains(worker.GetWorkerNamespace()) { return } result.WorkerCount++ @@ -156,8 +153,12 @@ func (s *Service) ListSubstrateActors(ctx context.Context, input *apiv1.ListSubs if err != nil { return SubstrateActorPage{}, err } + atespace := "" + if len(scope.namespaces) == 1 { + atespace = scope.namespaces[0] + } result := SubstrateActorPage{ComputedAt: time.Now().UTC()} - actors, next, err := s.ateClient.ListActorsPage(ctx, scope.atespace(), substratePageSize(input.GetPage().GetLimit()), input.GetPage().GetPageToken()) + actors, next, err := s.ateClient.ListActorsPage(ctx, atespace, substratePageSize(input.GetPage().GetLimit()), input.GetPage().GetPageToken()) if err != nil { result.ATEAPIError = err.Error() logging.FromContext(ctx).ErrorContext(ctx, "failed to list ate-api actors", "error", err) @@ -187,7 +188,7 @@ func (s *Service) ListSubstrateWorkers(ctx context.Context, input *apiv1.ListSub return result, nil } for _, worker := range workers { - if scope.containsWorker(worker) { + if worker != nil && scope.contains(worker.GetWorkerNamespace()) { result.Workers = append(result.Workers, worker) } } @@ -195,16 +196,16 @@ func (s *Service) ListSubstrateWorkers(ctx context.Context, input *apiv1.ListSub return result, nil } -// walkActors visits every actor in scope, one page at a time. func (s *Service) walkActors(ctx context.Context, scope substrateScope, visit func(*ateapipb.Actor)) error { - read := func(ctx context.Context, pageSize int32, pageToken string) ([]*ateapipb.Actor, string, error) { - return s.ateClient.ListActorsPage(ctx, scope.atespace(), pageSize, pageToken) - } - return walkSubstrate(ctx, read, func(actor *ateapipb.Actor) { - if actor != nil && scope.contains(actor.GetMetadata().GetAtespace()) { - visit(actor) + for _, atespace := range scope.listNamespaces() { + read := func(ctx context.Context, pageSize int32, pageToken string) ([]*ateapipb.Actor, string, error) { + return s.ateClient.ListActorsPage(ctx, atespace, pageSize, pageToken) } - }) + if err := walkSubstrate(ctx, read, visit); err != nil { + return err + } + } + return nil } // walkWorkers calls visit for every worker ate-api holds, one page at a time. @@ -238,14 +239,11 @@ func walkSubstrate[Row any]( return fmt.Errorf("ate-api did not finish paging after %d pages", maxATEPagesPerWalk) } -// substrateScope is the set of namespaces a Substrate read covers. An atespace is named -// after the namespace of the agent it serves, so the same set bounds actors and templates. type substrateScope struct { all bool namespaces []string } -// authorizeSubstrateScope authorizes the caller and resolves the scope of a read. func (s *Service) authorizeSubstrateScope(ctx context.Context, requested string) (substrateScope, error) { if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: "Substrate"}); err != nil { return substrateScope{}, err @@ -253,8 +251,6 @@ func (s *Service) authorizeSubstrateScope(ctx context.Context, requested string) return s.resolveSubstrateScope(requested) } -// resolveSubstrateScope narrows the watched namespaces to the requested one. A -// controller that watches only some namespaces refuses any other. func (s *Service) resolveSubstrateScope(requested string) (substrateScope, error) { switch { case requested == "" && len(s.observedNamespaces) == 0: @@ -264,7 +260,7 @@ func (s *Service) resolveSubstrateScope(requested string) (substrateScope, error case len(s.observedNamespaces) == 0 || slices.Contains(s.observedNamespaces, requested): return substrateScope{namespaces: []string{requested}}, nil default: - return substrateScope{}, serviceerrors.NewPermissionDenied("Not authorized", fmt.Errorf("namespace %q is not watched by this controller", requested)) + return substrateScope{}, serviceerrors.NewPermissionDenied(fmt.Sprintf("Namespace %q is not watched by this controller", requested), nil) } } @@ -272,25 +268,6 @@ func (scope substrateScope) contains(namespace string) bool { return scope.all || slices.Contains(scope.namespaces, namespace) } -// containsWorker also admits a worker that reports no namespace. -func (scope substrateScope) containsWorker(worker *ateapipb.Worker) bool { - if worker == nil { - return false - } - namespace := strings.TrimSpace(worker.GetWorkerNamespace()) - return namespace == "" || scope.contains(namespace) -} - -// atespace is the atespace to ask ate-api for: the only one in scope, or every -// atespace ("") for the caller to filter. -func (scope substrateScope) atespace() string { - if len(scope.namespaces) == 1 { - return scope.namespaces[0] - } - return "" -} - -// listNamespaces is the namespaces to list Kubernetes objects in, "" meaning all. func (scope substrateScope) listNamespaces() []string { if scope.all { return []string{""} From e70146e8e289314501059ccad9665401fbd0fb38 Mon Sep 17 00:00:00 2001 From: Danielle Mustillo Date: Mon, 19 Oct 2026 10:51:23 -0400 Subject: [PATCH 5/8] test(substrate): cover worker pools and a namespace watched twice The isolation test built its fake cluster with no WorkerPools, so it never checked that the summary lists only pools in the watched namespaces. It also never listed a namespace twice in watchNamespaces, which counted tenant-a's actors, templates and pools twice. Signed-off-by: Danielle Mustillo --- .../internal/service/system/service_test.go | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/go/core/internal/service/system/service_test.go b/go/core/internal/service/system/service_test.go index 2dcdd17de2..4e3016c319 100644 --- a/go/core/internal/service/system/service_test.go +++ b/go/core/internal/service/system/service_test.go @@ -635,11 +635,17 @@ func TestSubstrateTenantIsolation(t *testing.T) { {WorkerPod: "pod-unlabelled"}, }, } + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + &atev1alpha1.WorkerPool{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-a", Name: "pool-a"}}, + &atev1alpha1.WorkerPool{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-b", Name: "pool-b"}}, + &atev1alpha1.WorkerPool{ObjectMeta: metav1.ObjectMeta{Namespace: "tenant-c", Name: "pool-c"}}, + ).Build() newService := func(observedNamespaces []string) *system.Service { - return system.NewService(fake.NewClientBuilder().WithScheme(scheme).Build(), observedNamespaces, &pkgAuth.NoopAuthorizer{}, ateClient) + return system.NewService(kubeClient, observedNamespaces, &pkgAuth.NoopAuthorizer{}, ateClient) } tenantA := newService([]string{"tenant-a"}) tenantsAC := newService([]string{"tenant-a", "tenant-c"}) + tenantsACDuplicated := newService([]string{"tenant-a", "tenant-c", "tenant-a"}) clusterWide := newService(nil) listAllActors := func(t *testing.T, service *system.Service, atespace string) []string { @@ -672,6 +678,13 @@ func TestSubstrateTenantIsolation(t *testing.T) { request.Page.PageToken = page.NextPageToken } } + poolNames := func(summary system.SubstrateSummary) []string { + var names []string + for _, pool := range summary.WorkerPools { + names = append(names, pool.Namespace+"/"+pool.Name) + } + return names + } templateNames := func(summary system.SubstrateSummary) []string { var names []string for _, template := range summary.ActorTemplates { @@ -689,6 +702,7 @@ func TestSubstrateTenantIsolation(t *testing.T) { require.NoError(t, err) require.Empty(t, summary.ATEAPIError) assert.Equal(t, []string{"agent-a"}, templateNames(summary)) + assert.Equal(t, []string{"tenant-a/pool-a"}, poolNames(summary)) assert.Equal(t, int64(1), summary.ActorCount) assert.Equal(t, int64(0), summary.RunningActorCount) assert.Equal(t, int64(1), summary.WorkerCount) @@ -720,12 +734,23 @@ func TestSubstrateTenantIsolation(t *testing.T) { {State: ateapipb.ActorState_ACTOR_STATE_SUSPENDED, Count: 1}, }, summary.ActorStatusCounts) assert.Equal(t, []string{"agent-a", "agent-c"}, templateNames(summary)) + assert.Equal(t, []string{"tenant-a/pool-a", "tenant-c/pool-c"}, poolNames(summary)) assert.Equal(t, int64(2), summary.WorkerCount) assert.Equal(t, []string{"tenant-a", "tenant-c"}, slices.Compact(slices.Sorted(slices.Values(ateClient.actorAtespaces)))) assert.ElementsMatch(t, []string{"tenant-a", "tenant-c"}, ateClient.templateAtespaces) }) + t.Run("a namespace listed twice in the config is counted once", func(t *testing.T) { + summary, err := tenantsACDuplicated.GetSubstrateSummary(ctx, "", "") + require.NoError(t, err) + require.Empty(t, summary.ATEAPIError) + assert.Equal(t, int64(2), summary.ActorCount) + assert.Equal(t, []string{"agent-a", "agent-c"}, templateNames(summary)) + assert.Equal(t, []string{"tenant-a/pool-a", "tenant-c/pool-c"}, poolNames(summary)) + assert.Equal(t, int64(2), summary.WorkerCount) + }) + t.Run("refuses another tenant's atespace or namespace", func(t *testing.T) { _, err := tenantA.ListSubstrateActors(ctx, &apiv1.ListSubstrateActorsRequest{Atespace: "tenant-b"}) assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied), err) @@ -743,6 +768,7 @@ func TestSubstrateTenantIsolation(t *testing.T) { require.NoError(t, err) assert.Equal(t, int64(4), summary.ActorCount) assert.Len(t, summary.ActorTemplates, 3) + assert.Equal(t, []string{"tenant-a/pool-a", "tenant-b/pool-b", "tenant-c/pool-c"}, poolNames(summary)) assert.Equal(t, int64(4), summary.WorkerCount) assert.Equal(t, []string{"pod-a", "pod-b", "pod-c", "pod-unlabelled"}, workerPods(t, clusterWide)) assert.Equal(t, []string{"tenant-b/session-b", "tenant-b/session-b2"}, listAllActors(t, clusterWide, "tenant-b")) From 9b19e5683a9f5bd977712ef8a838090db4f67586 Mon Sep 17 00:00:00 2001 From: Danielle Mustillo Date: Mon, 19 Oct 2026 10:51:23 -0400 Subject: [PATCH 6/8] fix(substrate): count a namespace watched twice once watchNamespaces allows duplicates, and every Substrate read asks ate-api and Kubernetes once per watched namespace, so a duplicate entry doubled the summary's actors, templates and worker pools. Drop duplicates once when the service is built, keeping the configured order. Signed-off-by: Danielle Mustillo --- go/core/internal/service/system/service.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/go/core/internal/service/system/service.go b/go/core/internal/service/system/service.go index 9f35284df5..bf382f70d9 100644 --- a/go/core/internal/service/system/service.go +++ b/go/core/internal/service/system/service.go @@ -49,9 +49,15 @@ func NewService( authorizer auth.Authorizer, ateClient ATEClient, ) *Service { + var observed []string + for _, namespace := range observedNamespaces { + if !slices.Contains(observed, namespace) { + observed = append(observed, namespace) + } + } return &Service{ kubeClient: kubeClient, - observedNamespaces: slices.Clone(observedNamespaces), + observedNamespaces: observed, authorizer: authorizer, ateClient: ateClient, } From 4774891b8f3ee39eb1ad6482fdce6041dca06297 Mon Sep 17 00:00:00 2001 From: Danielle Mustillo Date: Mon, 19 Oct 2026 10:51:23 -0400 Subject: [PATCH 7/8] docs(api): say an empty atespace means the watched namespaces GetSubstrateSummaryRequest.atespace and ListSubstrateActorsRequest.atespace still said an empty value lists all atespaces. It now means the watched namespaces unless the controller is cluster-wide, and an unwatched atespace returns PermissionDenied. Signed-off-by: Danielle Mustillo --- go/api/gen/kagent/api/v1/system.pb.go | 6 ++++-- proto/kagent/api/v1/system.proto | 6 ++++-- ui/src/generated/kagent/api/v1/system_pb.ts | 6 ++++-- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/go/api/gen/kagent/api/v1/system.pb.go b/go/api/gen/kagent/api/v1/system.pb.go index 5ae0980ae8..39bcdd5c28 100644 --- a/go/api/gen/kagent/api/v1/system.pb.go +++ b/go/api/gen/kagent/api/v1/system.pb.go @@ -396,7 +396,8 @@ type GetSubstrateSummaryRequest struct { state protoimpl.MessageState `protogen:"open.v1"` // Kubernetes namespace for workers and pools. Empty uses watched namespaces. Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` - // ATE atespace for actors and templates. Empty lists all atespaces. + // ATE atespace for actors and templates. Empty uses the watched namespaces, or every + // atespace on a cluster-wide controller. An unwatched atespace returns PermissionDenied. Atespace string `protobuf:"bytes,2,opt,name=atespace,proto3" json:"atespace,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -617,7 +618,8 @@ func (x *GetSubstrateSummaryResponse) GetActorTemplates() []*ateapipb.ActorTempl // One upstream page of actors, in Substrate order. type ListSubstrateActorsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - // ATE atespace of the actor itself. Empty lists all atespaces. + // ATE atespace of the actor itself. Empty uses the watched namespaces, or every + // atespace on a cluster-wide controller. An unwatched atespace returns PermissionDenied. Atespace string `protobuf:"bytes,6,opt,name=atespace,proto3" json:"atespace,omitempty"` // `limit` is capped at 100 in common.proto and zero means the server's default; // above the cap is refused rather than clamped. diff --git a/proto/kagent/api/v1/system.proto b/proto/kagent/api/v1/system.proto index b8e7cbecdf..863ffff3bf 100644 --- a/proto/kagent/api/v1/system.proto +++ b/proto/kagent/api/v1/system.proto @@ -62,7 +62,8 @@ message GetSubstrateSummaryRequest { max_len: 63 pattern: "^([a-z0-9]([-a-z0-9]*[a-z0-9])?)?$" }]; - // ATE atespace for actors and templates. Empty lists all atespaces. + // ATE atespace for actors and templates. Empty uses the watched namespaces, or every + // atespace on a cluster-wide controller. An unwatched atespace returns PermissionDenied. string atespace = 2 [(buf.validate.field).string = { max_len: 63 pattern: "^([a-z0-9]([-a-z0-9]*[a-z0-9])?)?$" @@ -96,7 +97,8 @@ message GetSubstrateSummaryResponse { // One upstream page of actors, in Substrate order. message ListSubstrateActorsRequest { - // ATE atespace of the actor itself. Empty lists all atespaces. + // ATE atespace of the actor itself. Empty uses the watched namespaces, or every + // atespace on a cluster-wide controller. An unwatched atespace returns PermissionDenied. string atespace = 6 [(buf.validate.field).string = { max_len: 63 pattern: "^([a-z0-9]([-a-z0-9]*[a-z0-9])?)?$" diff --git a/ui/src/generated/kagent/api/v1/system_pb.ts b/ui/src/generated/kagent/api/v1/system_pb.ts index 504a6cdba8..24b8b86279 100644 --- a/ui/src/generated/kagent/api/v1/system_pb.ts +++ b/ui/src/generated/kagent/api/v1/system_pb.ts @@ -184,7 +184,8 @@ export type GetSubstrateSummaryRequest = Message<"kagent.api.v1.GetSubstrateSumm namespace: string; /** - * ATE atespace for actors and templates. Empty lists all atespaces. + * ATE atespace for actors and templates. Empty uses the watched namespaces, or every + * atespace on a cluster-wide controller. An unwatched atespace returns PermissionDenied. * * @generated from field: string atespace = 2; */ @@ -297,7 +298,8 @@ export const GetSubstrateSummaryResponseSchema: GenMessage & { /** - * ATE atespace of the actor itself. Empty lists all atespaces. + * ATE atespace of the actor itself. Empty uses the watched namespaces, or every + * atespace on a cluster-wide controller. An unwatched atespace returns PermissionDenied. * * @generated from field: string atespace = 6; */ From 2e755e5252fecb99d479c310c5892b276638d501 Mon Sep 17 00:00:00 2001 From: Danielle Mustillo Date: Mon, 19 Oct 2026 12:21:03 -0400 Subject: [PATCH 8/8] refactor(substrate): dedupe watched namespaces with slices.Compact Sorting before Compact removes duplicates that are not adjacent. ListNamespaces already sorts its output, so the order change is not visible to callers. Verified with go test ./core/internal/service/system, including the case that watches [tenant-a, tenant-c, tenant-a]. Signed-off-by: Danielle Mustillo --- go/core/internal/service/system/service.go | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/go/core/internal/service/system/service.go b/go/core/internal/service/system/service.go index bf382f70d9..fd316d4a51 100644 --- a/go/core/internal/service/system/service.go +++ b/go/core/internal/service/system/service.go @@ -49,15 +49,9 @@ func NewService( authorizer auth.Authorizer, ateClient ATEClient, ) *Service { - var observed []string - for _, namespace := range observedNamespaces { - if !slices.Contains(observed, namespace) { - observed = append(observed, namespace) - } - } return &Service{ kubeClient: kubeClient, - observedNamespaces: observed, + observedNamespaces: slices.Compact(slices.Sorted(slices.Values(observedNamespaces))), authorizer: authorizer, ateClient: ateClient, }