diff --git a/go/api/gen/kagent/api/v1/system.pb.go b/go/api/gen/kagent/api/v1/system.pb.go index 1dd3795de6..7fd5f28bd8 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/go/core/internal/service/system/service.go b/go/core/internal/service/system/service.go index d590f1cbde..6dd7278f5d 100644 --- a/go/core/internal/service/system/service.go +++ b/go/core/internal/service/system/service.go @@ -51,7 +51,7 @@ func NewService( ) *Service { return &Service{ kubeClient: kubeClient, - observedNamespaces: slices.Clone(observedNamespaces), + observedNamespaces: slices.Compact(slices.Sorted(slices.Values(observedNamespaces))), authorizer: authorizer, ateClient: ateClient, } @@ -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,22 +163,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, atespace string) ([]*ateapipb.ActorTemplate, error) { - templatesFromAPI, err := s.ateClient.ListActorTemplates(ctx, atespace) - if err != nil { - return nil, err - } - templates := make([]*ateapipb.ActorTemplate, 0, len(templatesFromAPI)) - for _, template := range templatesFromAPI { - if template == nil { - continue +func (s *Service) substrateActorTemplates(ctx context.Context, scope substrateScope) ([]*ateapipb.ActorTemplate, error) { + 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(), + WorkerSelector: template.GetWorkerSelector(), + }) } - // Only expose inventory fields: containers can contain resolved credentials. - templates = append(templates, &ateapipb.ActorTemplate{ - Metadata: template.GetMetadata(), - Status: template.GetStatus(), - 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 2b033acb72..19937336ff 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" @@ -46,11 +47,14 @@ 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 + actorReads int + workerReads int + 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 +66,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 } @@ -527,7 +532,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 @@ -536,7 +541,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) @@ -604,3 +608,177 @@ func TestSubstrateEmptyPagesPreserveUpstreamTokens(t *testing.T) { assert.Empty(t, workers.ATEAPIError) assert.Equal(t, "upstream:next/cursor=", workers.NextPageToken) } + +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, "", ""), + substrateActor("session-c", "tenant-c", ateapipb.ActorState_ACTOR_STATE_RUNNING, "tenant-c", "pod-c"), + substrateActor("session-b2", "tenant-b", 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"}}, + {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"}, + }, + } + 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(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 { + var names []string + request := &apiv1.ListSubstrateActorsRequest{Atespace: atespace, Page: &apiv1.PageRequest{Limit: 1}} + for { + 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 == "" { + 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 + } + } + 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 { + 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) + 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) + }) + + 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, []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) + 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", "") + 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) { + summary, err := clusterWide.GetSubstrateSummary(ctx, "", "") + 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")) + }) + + 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) + 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) + }) +} diff --git a/go/core/internal/service/system/substrate.go b/go/core/internal/service/system/substrate.go index 19e3b87061..602192a1bd 100644 --- a/go/core/internal/service/system/substrate.go +++ b/go/core/internal/service/system/substrate.go @@ -76,7 +76,11 @@ 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) + workerScope, err := s.authorizeSubstrateScope(ctx, requestedNamespace) + if err != nil { + return SubstrateSummary{}, err + } + actorScope, err := s.resolveSubstrateScope(atespace) if err != nil { return SubstrateSummary{}, err } @@ -88,7 +92,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 +103,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 +126,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 worker == nil || !workerScope.contains(worker.GetWorkerNamespace()) { return } result.WorkerCount++ @@ -145,19 +147,28 @@ 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 } + atespace := "" + if len(scope.namespaces) == 1 { + atespace = scope.namespaces[0] + } 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, 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 +176,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 +187,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 worker != nil && scope.contains(worker.GetWorkerNamespace()) { result.Workers = append(result.Workers, worker) } } @@ -186,12 +196,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 { - read := func(ctx context.Context, pageSize int32, pageToken string) ([]*ateapipb.Actor, string, error) { - return s.ateClient.ListActorsPage(ctx, atespace, pageSize, pageToken) +func (s *Service) walkActors(ctx context.Context, scope substrateScope, visit func(*ateapipb.Actor)) error { + 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 walkSubstrate(ctx, read, visit) + return nil } // walkWorkers calls visit for every worker ate-api holds, one page at a time. @@ -225,36 +239,40 @@ 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) { +type substrateScope struct { + all bool + namespaces []string +} + +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{}{} - } +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(fmt.Sprintf("Namespace %q is not watched by this controller", requested), nil) } - return allowAll, allowed } -// 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 +func (scope substrateScope) contains(namespace string) bool { + return scope.all || slices.Contains(scope.namespaces, namespace) +} + +func (scope substrateScope) listNamespaces() []string { + if scope.all { + return []string{""} } - _, ok := allowed[namespace] - return ok + return scope.namespaces } func substratePageSize(requested int32) int32 { 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; */