From 8a3d2fc590cc92ac34fe83162142fc219931b2ce Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Thu, 3 Sep 2026 08:54:58 -0700 Subject: [PATCH 1/4] Add scoped authorization for configuration resources Signed-off-by: Jeremy Alvis --- .claude/skills/kagent-dev/SKILL.md | 4 + .../references/scoped-authorization.md | 83 +++++++ design/scoped-authorization.md | 207 ++++++++++++++++++ go/api/authorization/scope.go | 36 +++ go/core/cmd/controller-v2/main.go | 2 +- go/core/internal/grpcserver/agenttemplate.go | 13 +- .../grpcserver/agenttemplate_harness_test.go | 2 +- go/core/internal/grpcserver/server.go | 2 +- go/core/internal/httpserver/auth/authz.go | 6 +- go/core/internal/service/agent/service.go | 60 +++-- .../internal/service/agent/service_test.go | 138 ++++++++++++ go/core/internal/service/kubeauth/scope.go | 102 +++++++++ .../internal/service/kubeauth/scope_test.go | 107 +++++++++ go/core/internal/service/kubecrud/service.go | 116 ++++++++-- .../internal/service/kubecrud/service_test.go | 130 +++++++++++ go/core/internal/service/model/service.go | 77 ++++--- .../internal/service/model/service_test.go | 103 ++++++++- go/core/pkg/auth/auth.go | 37 +++- go/core/v2/a2agateway/gateway_test.go | 2 +- 19 files changed, 1152 insertions(+), 75 deletions(-) create mode 100644 .claude/skills/kagent-dev/references/scoped-authorization.md create mode 100644 design/scoped-authorization.md create mode 100644 go/api/authorization/scope.go create mode 100644 go/core/internal/service/agent/service_test.go create mode 100644 go/core/internal/service/kubeauth/scope.go create mode 100644 go/core/internal/service/kubeauth/scope_test.go create mode 100644 go/core/internal/service/kubecrud/service_test.go diff --git a/.claude/skills/kagent-dev/SKILL.md b/.claude/skills/kagent-dev/SKILL.md index 5eadc4bf8a..6825b03688 100644 --- a/.claude/skills/kagent-dev/SKILL.md +++ b/.claude/skills/kagent-dev/SKILL.md @@ -84,6 +84,10 @@ After SQL changes, run `sqlc generate` in `go/core/internal/database` and commit - Use PostgreSQL constraints for invariants that can be enforced atomically. - Keep migrations schema-agnostic and safe for multiple controller replicas. +## Authorization changes + +For resource or collection authorization work, read the [scoped authorization guide](references/scoped-authorization.md) before changing services or list queries. + ## Testing and CI - Focused unit and generation checks are required for implemented behavior. diff --git a/.claude/skills/kagent-dev/references/scoped-authorization.md b/.claude/skills/kagent-dev/references/scoped-authorization.md new file mode 100644 index 0000000000..02922adbf6 --- /dev/null +++ b/.claude/skills/kagent-dev/references/scoped-authorization.md @@ -0,0 +1,83 @@ +# Scoped authorization implementation guide + +The source contract is [design/scoped-authorization.md](../../../../design/scoped-authorization.md). This guide describes how to apply it to Kubernetes-backed configuration resources. + +## Mental model + +An authorizer decides which resources a principal may access. It returns an `AuthorizationScope`; it never returns SQL, Kubernetes selectors, policy objects, or backend field names. + +- `ALL` permits the complete collection and has no clauses. +- `NONE` permits no resources and has no clauses. +- `ANY_OF` is an OR of non-empty clauses. +- Each clause is an AND of non-empty predicates. +- `IN` matches one of its non-empty values. +- `MISSING` matches an absent attribute and has no values. + +For example, this scope: + +```text +(namespace IN [team-a] AND name IN [agent-a, agent-b]) +OR namespace IN [shared] +``` + +is represented as two `AnyOf` clauses. The first contains both predicates in `All`; the second contains one predicate. + +The shared scope types and attribute names live in [`go/api/authorization`](../../../../go/api/authorization/scope.go). The core authorization package aliases them for service use. + +## Trusted resource attributes + +Use [`kubeauth.Resource`](../../../../go/core/internal/service/kubeauth/scope.go) to construct authorization input from a Kubernetes object. It supplies `namespace` and `name` from object metadata and preserves the existing `namespace/name` value in `Resource.Name`. + +For a single-resource operation: + +- Read and delete: load the stored object, then authorize it. +- Create: validate and normalize the proposed object, then authorize it before checking or writing storage. +- Update: authorize the stored object and the validated proposed object before any write. + +Do not treat a request reference as stored resource data. Request references are suitable for loading an object, not for constructing its trusted attributes. + +## Collection flow + +Implement a protected collection request in this order: + +1. Validate caller-supplied filters. +2. Call the required `CollectionAuthorizer.Scope` with `VerbList` and the protected resource type. +3. Compile the scope with [`kubeauth.ScopeMatcher`](../../../../go/core/internal/service/kubeauth/scope.go). +4. List a safe, complete Kubernetes resource set. +5. Apply the matcher to object metadata. +6. Sort and build the response from authorized objects only. + +The matcher accepts only `namespace` and `name`. It rejects malformed scopes, unsupported attributes or operators, and empty `IN` values. Treat matcher and authorizer errors as authorization failures; never convert them to `ScopeAll`. + +Current list operations do not paginate, so an in-memory filter is complete. If pagination is added, authorization must happen before totals, sorting, and page slicing. + +Pass scopes as ordinary function arguments. Do not place policy decisions in request context. + +## Service integration + +- `AgentTemplate` uses `kubecrud.NewScopedService`. The gRPC server accepts the scoped service type, so it cannot be wired with the unscoped constructor accidentally. +- `AgentHarness` is filtered inside the mixed `agent.Service.List` result. `SandboxAgent` keeps its existing authorization behavior. +- `ModelConfig` requests require a `CollectionAuthorizer`; `Service.List` filters the returned Kubernetes list before transport conversion. +- `ListConfiguredProviders` reads the separate `ModelProviderConfig` resource and remains outside this scope. +- The unrelated `Harness` resource keeps `kubecrud.NewService` and the existing `Authorizer` behavior. + +The default OSS no-op authorizer implements `CollectionAuthorizer` and returns `ScopeAll`, preserving current OSS visibility. + +## Required tests + +Test generic scope behavior once in the Kubernetes matcher: + +- `ALL`, `NONE`, and `ANY_OF`; +- OR clauses and AND predicates; +- `IN` and `MISSING`; +- every invalid kind, attribute, operator, clause shape, and value shape. + +Each protected service then needs focused tests proving: + +- it requests `VerbList` for the correct resource type; +- denied objects are absent before sorting or response construction; +- single-resource checks receive trusted `namespace` and `name` attributes; +- updates check both stored and proposed objects; +- malformed scopes fail closed. + +For mixed collections, also prove that the scope removes only the protected resource kind. diff --git a/design/scoped-authorization.md b/design/scoped-authorization.md new file mode 100644 index 0000000000..4db575784f --- /dev/null +++ b/design/scoped-authorization.md @@ -0,0 +1,207 @@ +# Scoped Authorization for Kagent Configuration Resources + +Status: Draft + +## Summary + +Kagent uses `Authorizer.Check` for resource authorization. + +This design adds scoped authorization for `AgentTemplate`, `AgentHarness`, and `ModelConfig` resources. + +Kagent supplies trusted resource attributes and enforces authorization before it builds a response. + +An `Authorizer` implementation defines roles, policies, identity rules, and catalog keys. + +## Initial scope + +| Resource type | Operations | Attributes | +| --- | --- | --- | +| `AgentTemplate` | list, get, create, update, delete | `namespace`, `name` | +| `AgentHarness` | list, get, create, delete | `namespace`, `name` | +| `ModelConfig` | list, get, create, update, delete | `namespace`, `name` | + +`ListAgents` returns `SandboxAgent` and `AgentHarness` entries. Catalog authorization applies only to `AgentHarness` entries. + +`ListConfiguredProviders` derives entries from the separate `ModelProviderConfig` resource. This design does not change its authorization. + +Provider discovery does not return `ModelConfig` resources. This design does not change its authorization. + +## Goals + +- Support partial access to the three protected resource collections. +- Apply authorization before sorting and response construction. +- Use trusted stored or validated resource attributes. +- Keep authorization rules outside storage code. +- Keep the extension contract independent from one policy system. + +## Non-goals + +- This design does not define roles, policies, claims, or catalog keys. +- This design does not protect `SandboxAgent`, `Harness`, `AgentInstance`, or `ModelProviderConfig` resources. +- This design does not protect tool servers or prompt templates. +- This design does not authorize `AgentHarness` session actor operations. +- This design does not add SQL or backend expressions to the authorization API. + +## Authorization API + +Kagent will keep the current `Authorizer.Check` interface. + +The `Resource` type will carry trusted attributes: + +```go +type Resource struct { + Type string + Name string + Attributes map[string][]string +} +``` + +Services will construct attributes from stored or validated resource data. + +Kagent will use `CollectionAuthorizer` for protected collection operations: + +```go +type CollectionAuthorizer interface { + Authorizer + Scope( + ctx context.Context, + principal Principal, + verb Verb, + resourceType string, + ) (AuthorizationScope, error) +} +``` + +An `AuthorizationScope` describes the required collection restriction: + +```go +type ScopeKind string + +const ( + ScopeAll ScopeKind = "ALL" + ScopeNone ScopeKind = "NONE" + ScopeAnyOf ScopeKind = "ANY_OF" +) + +type ScopeOperator string + +const ( + ScopeIn ScopeOperator = "IN" + ScopeMissing ScopeOperator = "MISSING" +) + +type AuthorizationScope struct { + Kind ScopeKind + AnyOf []ScopeClause +} + +type ScopeClause struct { + All []ScopePredicate +} + +type ScopePredicate struct { + Attribute string + Operator ScopeOperator + Values []string +} +``` + +`ScopeAll` permits the complete collection. `ScopeNone` permits no items. + +`ScopeAnyOf` joins clauses with OR. Each clause joins predicates with AND. + +`ScopeIn` matches a listed value. `ScopeMissing` matches an absent attribute. + +The scope contains no SQL, Kubernetes field paths, policy types, or backend expressions. + +## Single-resource enforcement + +For a read, the service will load the stored resource before authorization. + +For a create, the service will authorize the validated proposed resource. + +For an update, the service will authorize the stored and proposed resources. + +For a delete, the service will load and authorize the stored resource. + +The service will use `namespace` and `name` from the Kubernetes object metadata. + +The service must not trust a request reference as stored resource data. + +## Collection enforcement + +For each protected collection request: + +1. Validate the caller query. +2. Request the `AuthorizationScope` with `VerbList`. +3. Query a safe Kubernetes resource set. +4. Apply the scope to trusted object metadata. +5. Sort the authorized items. +6. Build the response from the authorized items. + +`ScopeNone` returns an empty protected collection. + +An authorization error fails the request. Kagent must not convert an error to `ScopeAll`. + +The matcher will accept only `namespace` and `name` for these resources. + +The matcher will reject an unsupported scope kind, attribute, operator, or empty value. + +The services will pass the scope through an explicit function argument. They will not store it in request context. + +The current Kubernetes lists do not use server pagination. A complete in-memory filter is correct for the initial release. + +If a list adds pagination, it must apply the scope before totals, sorting, and pagination. + +## Mixed Agent list + +`ListAgents` will request one scope for the `AgentHarness` resource type. + +The service will apply this scope to each `AgentHarness` entry. + +The service will keep `SandboxAgent` entries under their current authorization behavior. + +`ScopeNone` will remove all `AgentHarness` entries. It will not remove `SandboxAgent` entries. + +## Default OSS behavior + +The default no-op authorizer will return `ScopeAll`. + +This default keeps existing OSS behavior when no scoped authorizer is installed. + +Services outside the initial scope will continue to accept their current authorizer type. + +Kagent will not define policy resources, subjects, grants, or access levels. + +## Validation + +Tests must cover `ScopeAll`, `ScopeNone`, and `ScopeAnyOf`. + +Tests must verify OR clauses, AND predicates, `ScopeIn`, and `ScopeMissing`. + +Tests must verify trusted attributes for each single-resource operation. + +Tests must prove that list filtering occurs before sorting and response construction. + +Tests must prove that `ListAgents` filters only `AgentHarness` entries. + +Tests must verify fail-closed behavior for invalid scopes. + +## Implementation checklist + +- [x] Add `name` to the shared authorization attributes. +- [x] Add one Kubernetes scope matcher for `namespace` and `name`. +- [x] Require `CollectionAuthorizer` for `AgentTemplate` collection operations. +- [x] Require `CollectionAuthorizer` for `AgentHarness` collection operations. +- [x] Require `CollectionAuthorizer` for `ModelConfig` collection operations. +- [x] Populate trusted attributes for reads and writes. +- [x] Filter each protected list before response construction. +- [x] Add focused service and matcher tests. + +## Alternatives + +Per-item checks after pagination produce incomplete pages and incorrect totals. + +Separate allowed-name and allowed-namespace lists can lose required AND relationships. + +Raw query fragments couple an `Authorizer` to storage and create an unsafe trust boundary. diff --git a/go/api/authorization/scope.go b/go/api/authorization/scope.go new file mode 100644 index 0000000000..190a987742 --- /dev/null +++ b/go/api/authorization/scope.go @@ -0,0 +1,36 @@ +package authorization + +const ( + AttributeNamespace = "namespace" + AttributeName = "name" +) + +type ScopeKind string + +const ( + ScopeAll ScopeKind = "ALL" + ScopeNone ScopeKind = "NONE" + ScopeAnyOf ScopeKind = "ANY_OF" +) + +type ScopeOperator string + +const ( + ScopeIn ScopeOperator = "IN" + ScopeMissing ScopeOperator = "MISSING" +) + +type AuthorizationScope struct { + Kind ScopeKind + AnyOf []ScopeClause +} + +type ScopeClause struct { + All []ScopePredicate +} + +type ScopePredicate struct { + Attribute string + Operator ScopeOperator + Values []string +} diff --git a/go/core/cmd/controller-v2/main.go b/go/core/cmd/controller-v2/main.go index 2c78c2c561..da45226cc6 100644 --- a/go/core/cmd/controller-v2/main.go +++ b/go/core/cmd/controller-v2/main.go @@ -214,7 +214,7 @@ func main() { AgentInstanceService: instances, // Both halves of the pair CreateAgentInstance names. Without these two // the only way to author a Harness or an AgentTemplate is kubectl. - AgentTemplateService: kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.AgentTemplate{}, &kagentv1alpha3.AgentTemplateList{}, "AgentTemplate"), + AgentTemplateService: kubecrud.NewScopedService(manager.GetClient(), authorizer, &kagentv1alpha3.AgentTemplate{}, &kagentv1alpha3.AgentTemplateList{}, "AgentTemplate"), HarnessService: kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.Harness{}, &kagentv1alpha3.HarnessList{}, "Harness"), CheckpointService: checkpoints, A2AHandler: gateway, diff --git a/go/core/internal/grpcserver/agenttemplate.go b/go/core/internal/grpcserver/agenttemplate.go index ce7cb8e690..6c9300b182 100644 --- a/go/core/internal/grpcserver/agenttemplate.go +++ b/go/core/internal/grpcserver/agenttemplate.go @@ -15,11 +15,11 @@ const agentTemplateKind = "AgentTemplate" type agentTemplateServer struct { apiv1alpha1.UnimplementedAgentTemplateServiceServer - service *kubecrud.Service[*v1alpha3.AgentTemplate, *v1alpha3.AgentTemplateList] + service *kubecrud.ScopedService[*v1alpha3.AgentTemplate, *v1alpha3.AgentTemplateList] maxMessageBytes int } -func newAgentTemplateServer(service *kubecrud.Service[*v1alpha3.AgentTemplate, *v1alpha3.AgentTemplateList], maxMessageBytes int) *agentTemplateServer { +func newAgentTemplateServer(service *kubecrud.ScopedService[*v1alpha3.AgentTemplate, *v1alpha3.AgentTemplateList], maxMessageBytes int) *agentTemplateServer { return &agentTemplateServer{service: service, maxMessageBytes: maxMessageBytes} } @@ -81,12 +81,9 @@ func (s *agentTemplateServer) UpdateAgentTemplate(ctx context.Context, request * if err := s.decodeResource(request.GetRef(), request.GetResource(), incoming); err != nil { return nil, err } - existing, err := s.service.GetForUpdate(ctx, ref) - if err != nil { - return nil, err - } - existing.Spec = *incoming.Spec.DeepCopy() - result, err := s.service.SaveUpdate(ctx, existing) + result, err := s.service.Update(ctx, ref, func(existing *v1alpha3.AgentTemplate) { + existing.Spec = *incoming.Spec.DeepCopy() + }) if err != nil { return nil, err } diff --git a/go/core/internal/grpcserver/agenttemplate_harness_test.go b/go/core/internal/grpcserver/agenttemplate_harness_test.go index 8c9c13f3f4..c45157e387 100644 --- a/go/core/internal/grpcserver/agenttemplate_harness_test.go +++ b/go/core/internal/grpcserver/agenttemplate_harness_test.go @@ -39,7 +39,7 @@ func newTemplateAndHarnessConnection(t *testing.T, objects ...ctrlclient.Object) Listener: listener, Registerer: prometheus.NewRegistry(), Authenticator: &authimpl.UnsecureAuthenticator{}, - AgentTemplateService: kubecrud.NewService(kubeClient, &authimpl.NoopAuthorizer{}, &v1alpha3.AgentTemplate{}, &v1alpha3.AgentTemplateList{}, "AgentTemplate"), + AgentTemplateService: kubecrud.NewScopedService(kubeClient, &authimpl.NoopAuthorizer{}, &v1alpha3.AgentTemplate{}, &v1alpha3.AgentTemplateList{}, "AgentTemplate"), HarnessService: kubecrud.NewService(kubeClient, &authimpl.NoopAuthorizer{}, &v1alpha3.Harness{}, &v1alpha3.HarnessList{}, "Harness"), }) if err != nil { diff --git a/go/core/internal/grpcserver/server.go b/go/core/internal/grpcserver/server.go index 97f4513757..b71140ff7c 100644 --- a/go/core/internal/grpcserver/server.go +++ b/go/core/internal/grpcserver/server.go @@ -52,7 +52,7 @@ type Config struct { ShareStore ShareStore Registerer prometheus.Registerer AgentService *agentservice.Service - AgentTemplateService *kubecrud.Service[*v1alpha3.AgentTemplate, *v1alpha3.AgentTemplateList] + AgentTemplateService *kubecrud.ScopedService[*v1alpha3.AgentTemplate, *v1alpha3.AgentTemplateList] HarnessService *kubecrud.Service[*v1alpha3.Harness, *v1alpha3.HarnessList] ModelService *modelservice.Service ToolService *toolservice.Service diff --git a/go/core/internal/httpserver/auth/authz.go b/go/core/internal/httpserver/auth/authz.go index fdb806c59e..04c0289cf0 100644 --- a/go/core/internal/httpserver/auth/authz.go +++ b/go/core/internal/httpserver/auth/authz.go @@ -12,4 +12,8 @@ func (a *NoopAuthorizer) Check(ctx context.Context, principal auth.Principal, ve return nil } -var _ auth.Authorizer = (*NoopAuthorizer)(nil) +func (a *NoopAuthorizer) Scope(ctx context.Context, principal auth.Principal, verb auth.Verb, resourceType string) (auth.AuthorizationScope, error) { + return auth.AuthorizationScope{Kind: auth.ScopeAll}, nil +} + +var _ auth.CollectionAuthorizer = (*NoopAuthorizer)(nil) diff --git a/go/core/internal/service/agent/service.go b/go/core/internal/service/agent/service.go index 3629f187d0..b7b26c8dc6 100644 --- a/go/core/internal/service/agent/service.go +++ b/go/core/internal/service/agent/service.go @@ -6,6 +6,7 @@ import ( "strings" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" "github.com/kagent-dev/kagent/go/core/internal/utils" "github.com/kagent-dev/kagent/go/core/pkg/auth" @@ -82,12 +83,12 @@ func WithValidator(validator Validator) ServiceOption { type Service struct { kubeClient client.Client - authorizer auth.Authorizer + authorizer auth.CollectionAuthorizer defaultNamespace string validator Validator } -func NewService(kubeClient client.Client, authorizer auth.Authorizer, defaultNamespace string, options ...ServiceOption) *Service { +func NewService(kubeClient client.Client, authorizer auth.CollectionAuthorizer, defaultNamespace string, options ...ServiceOption) *Service { service := &Service{ kubeClient: kubeClient, authorizer: authorizer, @@ -100,10 +101,6 @@ func NewService(kubeClient client.Client, authorizer auth.Authorizer, defaultNam } func (s *Service) List(ctx context.Context, request ListRequest) ([]View, error) { - if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: "Agent"}); err != nil { - return nil, err - } - options := make([]client.ListOption, 0, 1) if request.Namespace != "" { if strings.TrimSpace(request.Namespace) != request.Namespace { @@ -120,6 +117,17 @@ func (s *Service) List(ctx context.Context, request ListRequest) ([]View, error) } options = append(options, client.InNamespace(request.Namespace)) } + if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: "Agent"}); err != nil { + return nil, err + } + scope, err := s.scope(ctx, string(KindAgentHarness)) + if err != nil { + return nil, err + } + matches, err := kubeauth.ScopeMatcher(scope) + if err != nil { + return nil, serviceerrors.NewPermissionDenied("Not authorized", err) + } sandboxAgents := &v1alpha3.SandboxAgentList{} if err := s.kubeClient.List(ctx, sandboxAgents, options...); err != nil { @@ -137,7 +145,7 @@ func (s *Service) List(ctx context.Context, request ListRequest) ([]View, error) } for index := range harnesses.Items { harness := &harnesses.Items[index] - if !v1alpha3.IsKnownAgentHarnessBackend(harness.Spec.Backend) { + if !matches(harness) || !v1alpha3.IsKnownAgentHarnessBackend(harness.Spec.Backend) { continue } views = append(views, s.harnessView(ctx, harness)) @@ -154,13 +162,19 @@ func (s *Service) GetSandboxAgent(ctx context.Context, request GetRequest) (View } func (s *Service) GetAgentHarness(ctx context.Context, request GetRequest) (View, error) { + if request.Ref.Namespace == "" || request.Ref.Name == "" { + return View{}, serviceerrors.NewInvalidArgument("AgentHarness namespace and name are required", nil) + } harness := &v1alpha3.AgentHarness{} - if err := s.get(ctx, request.Ref, harness, "AgentHarness not found"); err != nil { + if err := s.loadForMutation(ctx, request.Ref, harness, "AgentHarness not found", "Failed to get AgentHarness"); err != nil { return View{}, err } if !v1alpha3.IsKnownAgentHarnessBackend(harness.Spec.Backend) { return View{}, serviceerrors.NewNotFound("AgentHarness not found", nil) } + if err := s.authorize(ctx, auth.VerbGet, kubeauth.Resource(string(KindAgentHarness), harness)); err != nil { + return View{}, err + } return s.harnessView(ctx, harness), nil } @@ -227,12 +241,12 @@ func (s *Service) CreateAgentHarness(ctx context.Context, request CreateAgentHar if harness.Kind == "" { harness.Kind = string(KindAgentHarness) } - if _, err := s.prepareCreate(ctx, harness, KindAgentHarness, auth.VerbCreate); err != nil { - return View{}, err - } if strings.TrimSpace(string(harness.Spec.Backend)) == "" { return View{}, serviceerrors.NewInvalidArgument("spec.backend is required", nil) } + if _, err := s.prepareCreate(ctx, harness, KindAgentHarness, auth.VerbCreate); err != nil { + return View{}, err + } if err := s.kubeClient.Create(ctx, harness); err != nil { if apierrors.IsAlreadyExists(err) { return View{}, serviceerrors.NewAlreadyExists("AgentHarness already exists", err) @@ -246,9 +260,6 @@ func (s *Service) DeleteAgentHarness(ctx context.Context, request DeleteRequest) if request.Ref.Namespace == "" || request.Ref.Name == "" { return serviceerrors.NewInvalidArgument("AgentHarness namespace and name are required", nil) } - if err := s.authorize(ctx, auth.VerbDelete, auth.Resource{Type: "Agent", Name: request.Ref.String()}); err != nil { - return err - } harness := &v1alpha3.AgentHarness{} if err := s.loadForMutation(ctx, request.Ref, harness, "AgentHarness not found", "Failed to get AgentHarness"); err != nil { return err @@ -256,6 +267,9 @@ func (s *Service) DeleteAgentHarness(ctx context.Context, request DeleteRequest) if !v1alpha3.IsKnownAgentHarnessBackend(harness.Spec.Backend) { return serviceerrors.NewNotFound("AgentHarness not found", nil) } + if err := s.authorize(ctx, auth.VerbDelete, kubeauth.Resource(string(KindAgentHarness), harness)); err != nil { + return err + } if err := s.kubeClient.Delete(ctx, harness); err != nil { return serviceerrors.NewInternal("Failed to delete AgentHarness", err) } @@ -273,7 +287,11 @@ func (s *Service) prepareCreate(ctx context.Context, object client.Object, kind if ref.Name != object.GetName() || ref.Namespace != object.GetNamespace() { return types.NamespacedName{}, serviceerrors.NewInvalidArgument(fmt.Sprintf("Invalid %s metadata", kind), nil) } - if err := s.authorize(ctx, verb, auth.Resource{Type: "Agent", Name: ref.String()}); err != nil { + resource := auth.Resource{Type: "Agent", Name: ref.String()} + if kind == KindAgentHarness { + resource = kubeauth.Resource(string(kind), object) + } + if err := s.authorize(ctx, verb, resource); err != nil { return types.NamespacedName{}, err } return ref, nil @@ -454,3 +472,15 @@ func (s *Service) authorize(ctx context.Context, verb auth.Verb, resource auth.R } return nil } + +func (s *Service) scope(ctx context.Context, resourceType string) (auth.AuthorizationScope, error) { + session, ok := auth.AuthSessionFrom(ctx) + if !ok || session == nil { + return auth.AuthorizationScope{}, serviceerrors.NewUnauthenticated("Failed to get authenticated principal", fmt.Errorf("no session found")) + } + scope, err := s.authorizer.Scope(ctx, session.Principal(), auth.VerbList, resourceType) + if err != nil { + return auth.AuthorizationScope{}, serviceerrors.NewPermissionDenied("Not authorized", err) + } + return scope, nil +} diff --git a/go/core/internal/service/agent/service_test.go b/go/core/internal/service/agent/service_test.go new file mode 100644 index 0000000000..15fb22fafe --- /dev/null +++ b/go/core/internal/service/agent/service_test.go @@ -0,0 +1,138 @@ +package agent + +import ( + "context" + "testing" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/pkg/auth" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +type testSession struct{} + +func (testSession) Principal() auth.Principal { + return auth.Principal{User: auth.User{ID: "test-user"}} +} + +type authorizationCall struct { + verb auth.Verb + resource auth.Resource +} + +type recordingAuthorizer struct { + scope auth.AuthorizationScope + scopeVerb auth.Verb + scopeType string + checkCalls []authorizationCall +} + +func (a *recordingAuthorizer) Check(_ context.Context, _ auth.Principal, verb auth.Verb, resource auth.Resource) error { + a.checkCalls = append(a.checkCalls, authorizationCall{verb: verb, resource: resource}) + return nil +} + +func (a *recordingAuthorizer) Scope(_ context.Context, _ auth.Principal, verb auth.Verb, resourceType string) (auth.AuthorizationScope, error) { + a.scopeVerb = verb + a.scopeType = resourceType + return a.scope, nil +} + +func TestListFiltersOnlyAgentHarnesses(t *testing.T) { + scheme := runtime.NewScheme() + if err := v1alpha3.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + &v1alpha3.SandboxAgent{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "sandbox"}}, + &v1alpha3.AgentHarness{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "allowed"}, + Spec: v1alpha3.AgentHarnessSpec{Backend: v1alpha3.AgentHarnessBackendOpenClaw}, + }, + &v1alpha3.AgentHarness{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "denied"}, + Spec: v1alpha3.AgentHarnessSpec{Backend: v1alpha3.AgentHarnessBackendHermes}, + }, + ).Build() + authorizer := &recordingAuthorizer{scope: auth.AuthorizationScope{ + Kind: auth.ScopeAnyOf, + AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{ + Attribute: auth.AttributeName, + Operator: auth.ScopeIn, + Values: []string{"allowed"}, + }}}}, + }} + service := NewService(kubeClient, authorizer, "default") + ctx := auth.AuthSessionTo(t.Context(), testSession{}) + + views, err := service.List(ctx, ListRequest{Namespace: "team"}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(views) != 2 || views[0].Kind != KindSandboxAgent || views[1].Kind != KindAgentHarness || views[1].Ref.Name != "allowed" { + t.Fatalf("List() = %+v, want sandbox plus allowed AgentHarness", views) + } + if authorizer.scopeVerb != auth.VerbList || authorizer.scopeType != string(KindAgentHarness) { + t.Fatalf("Scope() = (%q, %q), want (list, AgentHarness)", authorizer.scopeVerb, authorizer.scopeType) + } + if len(authorizer.checkCalls) != 1 || authorizer.checkCalls[0].resource.Type != "Agent" { + t.Fatalf("SandboxAgent collection Check() calls = %+v", authorizer.checkCalls) + } + + authorizer.scope = auth.AuthorizationScope{Kind: auth.ScopeNone} + views, err = service.List(ctx, ListRequest{Namespace: "team"}) + if err != nil { + t.Fatalf("List(ScopeNone) error = %v", err) + } + if len(views) != 1 || views[0].Kind != KindSandboxAgent { + t.Fatalf("List(ScopeNone) = %+v, want only SandboxAgent", views) + } +} + +func TestAgentHarnessCRUDUsesTrustedAttributes(t *testing.T) { + scheme := runtime.NewScheme() + if err := v1alpha3.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + existing := &v1alpha3.AgentHarness{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "existing"}, + Spec: v1alpha3.AgentHarnessSpec{Backend: v1alpha3.AgentHarnessBackendOpenClaw}, + } + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existing).Build() + authorizer := &recordingAuthorizer{scope: auth.AuthorizationScope{Kind: auth.ScopeAll}} + service := NewService(kubeClient, authorizer, "default") + ctx := auth.AuthSessionTo(t.Context(), testSession{}) + + if _, err := service.GetAgentHarness(ctx, GetRequest{Ref: types.NamespacedName{Namespace: "team", Name: "existing"}}); err != nil { + t.Fatalf("GetAgentHarness() error = %v", err) + } + if _, err := service.CreateAgentHarness(ctx, CreateAgentHarnessRequest{AgentHarness: &v1alpha3.AgentHarness{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "created"}, + Spec: v1alpha3.AgentHarnessSpec{Backend: v1alpha3.AgentHarnessBackendHermes}, + }}); err != nil { + t.Fatalf("CreateAgentHarness() error = %v", err) + } + if err := service.DeleteAgentHarness(ctx, DeleteRequest{Ref: types.NamespacedName{Namespace: "team", Name: "existing"}}); err != nil { + t.Fatalf("DeleteAgentHarness() error = %v", err) + } + + wantVerbs := []auth.Verb{auth.VerbGet, auth.VerbCreate, auth.VerbDelete} + wantNames := []string{"existing", "created", "existing"} + if len(authorizer.checkCalls) != len(wantVerbs) { + t.Fatalf("Check() calls = %d, want %d", len(authorizer.checkCalls), len(wantVerbs)) + } + for index, call := range authorizer.checkCalls { + if call.verb != wantVerbs[index] || call.resource.Type != string(KindAgentHarness) { + t.Errorf("Check() call %d = %+v", index, call) + } + if got := call.resource.Attributes[auth.AttributeNamespace]; len(got) != 1 || got[0] != "team" { + t.Errorf("Check() call %d namespace = %v", index, got) + } + if got := call.resource.Attributes[auth.AttributeName]; len(got) != 1 || got[0] != wantNames[index] { + t.Errorf("Check() call %d name = %v, want %q", index, got, wantNames[index]) + } + } +} diff --git a/go/core/internal/service/kubeauth/scope.go b/go/core/internal/service/kubeauth/scope.go new file mode 100644 index 0000000000..036dfd4433 --- /dev/null +++ b/go/core/internal/service/kubeauth/scope.go @@ -0,0 +1,102 @@ +package kubeauth + +import ( + "fmt" + "slices" + + "github.com/kagent-dev/kagent/go/core/pkg/auth" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" +) + +// Resource builds authorization input from trusted Kubernetes metadata. +func Resource(resourceType string, object metav1.Object) auth.Resource { + attributes := make(map[string][]string, 2) + if object.GetNamespace() != "" { + attributes[auth.AttributeNamespace] = []string{object.GetNamespace()} + } + if object.GetName() != "" { + attributes[auth.AttributeName] = []string{object.GetName()} + } + return auth.Resource{ + Type: resourceType, + Name: types.NamespacedName{ + Namespace: object.GetNamespace(), + Name: object.GetName(), + }.String(), + Attributes: attributes, + } +} + +// ScopeMatcher validates a collection scope and returns its object predicate. +func ScopeMatcher(scope auth.AuthorizationScope) (func(metav1.Object) bool, error) { + switch scope.Kind { + case auth.ScopeAll: + if len(scope.AnyOf) != 0 { + return nil, fmt.Errorf("%s scope must not contain clauses", scope.Kind) + } + return func(metav1.Object) bool { return true }, nil + case auth.ScopeNone: + if len(scope.AnyOf) != 0 { + return nil, fmt.Errorf("%s scope must not contain clauses", scope.Kind) + } + return func(metav1.Object) bool { return false }, nil + case auth.ScopeAnyOf: + if len(scope.AnyOf) == 0 { + return nil, fmt.Errorf("%s scope requires at least one clause", scope.Kind) + } + default: + return nil, fmt.Errorf("unsupported scope kind %q", scope.Kind) + } + + for clauseIndex, clause := range scope.AnyOf { + if len(clause.All) == 0 { + return nil, fmt.Errorf("scope clause %d requires at least one predicate", clauseIndex) + } + for predicateIndex, predicate := range clause.All { + if predicate.Attribute != auth.AttributeNamespace && predicate.Attribute != auth.AttributeName { + return nil, fmt.Errorf("unsupported scope attribute %q", predicate.Attribute) + } + switch predicate.Operator { + case auth.ScopeIn: + if len(predicate.Values) == 0 { + return nil, fmt.Errorf("scope predicate %d.%d requires at least one value", clauseIndex, predicateIndex) + } + if slices.Contains(predicate.Values, "") { + return nil, fmt.Errorf("scope predicate %d.%d contains an empty value", clauseIndex, predicateIndex) + } + case auth.ScopeMissing: + if len(predicate.Values) != 0 { + return nil, fmt.Errorf("scope predicate %d.%d must not contain values", clauseIndex, predicateIndex) + } + default: + return nil, fmt.Errorf("unsupported scope operator %q", predicate.Operator) + } + } + } + + return func(object metav1.Object) bool { + for _, clause := range scope.AnyOf { + matches := true + for _, predicate := range clause.All { + value := object.GetNamespace() + if predicate.Attribute == auth.AttributeName { + value = object.GetName() + } + switch predicate.Operator { + case auth.ScopeIn: + matches = value != "" && slices.Contains(predicate.Values, value) + case auth.ScopeMissing: + matches = value == "" + } + if !matches { + break + } + } + if matches { + return true + } + } + return false + }, nil +} diff --git a/go/core/internal/service/kubeauth/scope_test.go b/go/core/internal/service/kubeauth/scope_test.go new file mode 100644 index 0000000000..0cad484523 --- /dev/null +++ b/go/core/internal/service/kubeauth/scope_test.go @@ -0,0 +1,107 @@ +package kubeauth + +import ( + "testing" + + "github.com/kagent-dev/kagent/go/core/pkg/auth" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestScopeMatcher(t *testing.T) { + object := &metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "agent-a"}} + tests := []struct { + name string + scope auth.AuthorizationScope + want bool + }{ + {name: "all", scope: auth.AuthorizationScope{Kind: auth.ScopeAll}, want: true}, + {name: "none", scope: auth.AuthorizationScope{Kind: auth.ScopeNone}}, + { + name: "or clauses", + scope: auth.AuthorizationScope{Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{ + {All: []auth.ScopePredicate{{Attribute: auth.AttributeName, Operator: auth.ScopeIn, Values: []string{"other"}}}}, + {All: []auth.ScopePredicate{{Attribute: auth.AttributeNamespace, Operator: auth.ScopeIn, Values: []string{"team-a"}}}}, + }}, + want: true, + }, + { + name: "and predicates", + scope: auth.AuthorizationScope{Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{ + {Attribute: auth.AttributeNamespace, Operator: auth.ScopeIn, Values: []string{"team-a"}}, + {Attribute: auth.AttributeName, Operator: auth.ScopeIn, Values: []string{"agent-a"}}, + }}}}, + want: true, + }, + { + name: "and mismatch", + scope: auth.AuthorizationScope{Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{ + {Attribute: auth.AttributeNamespace, Operator: auth.ScopeIn, Values: []string{"team-a"}}, + {Attribute: auth.AttributeName, Operator: auth.ScopeIn, Values: []string{"other"}}, + }}}}, + }, + { + name: "missing", + scope: auth.AuthorizationScope{Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{ + {Attribute: auth.AttributeNamespace, Operator: auth.ScopeMissing}, + }}}}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + matches, err := ScopeMatcher(test.scope) + if err != nil { + t.Fatalf("ScopeMatcher() error = %v", err) + } + if got := matches(object); got != test.want { + t.Fatalf("matches() = %v, want %v", got, test.want) + } + }) + } + + missingNamespace, err := ScopeMatcher(auth.AuthorizationScope{Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{ + Attribute: auth.AttributeNamespace, + Operator: auth.ScopeMissing, + }}}}}) + if err != nil { + t.Fatalf("ScopeMatcher() error = %v", err) + } + if !missingNamespace(&metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Name: "cluster-scoped"}}) { + t.Fatal("missing namespace did not match") + } +} + +func TestScopeMatcherRejectsInvalidScopes(t *testing.T) { + tests := []auth.AuthorizationScope{ + {}, + {Kind: auth.ScopeAll, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: auth.AttributeName, Operator: auth.ScopeMissing}}}}}, + {Kind: auth.ScopeNone, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: auth.AttributeName, Operator: auth.ScopeMissing}}}}}, + {Kind: auth.ScopeAnyOf}, + {Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{}}}, + {Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: "label", Operator: auth.ScopeIn, Values: []string{"x"}}}}}}, + {Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: auth.AttributeName, Operator: "EQUALS", Values: []string{"x"}}}}}}, + {Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: auth.AttributeName, Operator: auth.ScopeIn}}}}}, + {Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: auth.AttributeName, Operator: auth.ScopeIn, Values: []string{""}}}}}}, + {Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: auth.AttributeName, Operator: auth.ScopeMissing, Values: []string{"x"}}}}}}, + } + + for index, scope := range tests { + if _, err := ScopeMatcher(scope); err == nil { + t.Errorf("ScopeMatcher(invalid scope %d) error = nil", index) + } + } +} + +func TestResourceUsesObjectMetadata(t *testing.T) { + object := &metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "agent-a"}} + resource := Resource("AgentHarness", object) + if resource.Type != "AgentHarness" || resource.Name != "team-a/agent-a" { + t.Fatalf("Resource() = %+v", resource) + } + if got := resource.Attributes[auth.AttributeNamespace]; len(got) != 1 || got[0] != "team-a" { + t.Fatalf("namespace attribute = %v", got) + } + if got := resource.Attributes[auth.AttributeName]; len(got) != 1 || got[0] != "agent-a" { + t.Fatalf("name attribute = %v", got) + } +} diff --git a/go/core/internal/service/kubecrud/service.go b/go/core/internal/service/kubecrud/service.go index c8bdf18793..aa1963a756 100644 --- a/go/core/internal/service/kubecrud/service.go +++ b/go/core/internal/service/kubecrud/service.go @@ -6,10 +6,12 @@ import ( "fmt" "slices" + "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" "github.com/kagent-dev/kagent/go/core/pkg/auth" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" utilvalidation "k8s.io/apimachinery/pkg/util/validation" @@ -26,9 +28,15 @@ type Service[T Object, L client.ObjectList] struct { object T list L authorizer auth.Authorizer + collection auth.CollectionAuthorizer resource string } +// ScopedService is a Kubernetes CRUD service whose collection operations require a scope. +type ScopedService[T Object, L client.ObjectList] struct { + *Service[T, L] +} + func NewService[T Object, L client.ObjectList]( client client.Client, authorizer auth.Authorizer, @@ -41,20 +49,48 @@ func NewService[T Object, L client.ObjectList]( } } +// NewScopedService constructs a service that applies collection scopes before returning objects. +func NewScopedService[T Object, L client.ObjectList]( + client client.Client, + authorizer auth.CollectionAuthorizer, + object T, + list L, + resource string, +) *ScopedService[T, L] { + service := NewService(client, authorizer, object, list, resource) + service.collection = authorizer + return &ScopedService[T, L]{Service: service} +} + func (s *Service[T, L]) List(ctx context.Context, namespace string) ([]T, error) { - if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: s.resource}); err != nil { - return nil, err - } if namespace == "" { return nil, serviceerrors.NewInvalidArgument("namespace is required", nil) } + var matches func(metav1.Object) bool + if s.collection == nil { + if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: s.resource}); err != nil { + return nil, err + } + } else { + scope, err := s.scope(ctx) + if err != nil { + return nil, err + } + matches, err = kubeauth.ScopeMatcher(scope) + if err != nil { + return nil, serviceerrors.NewPermissionDenied("Not authorized", err) + } + } list := s.list.DeepCopyObject().(L) if err := s.client.List(ctx, list, client.InNamespace(namespace)); err != nil { return nil, serviceerrors.NewInternal("Failed to list "+s.resource+"s", err) } items := make([]T, 0) if err := meta.EachListItem(list, func(item runtime.Object) error { - items = append(items, item.(T)) + object := item.(T) + if matches == nil || matches(object) { + items = append(items, object) + } return nil }); err != nil { return nil, serviceerrors.NewInternal("Failed to read "+s.resource+" list", err) @@ -68,10 +104,20 @@ func (s *Service[T, L]) Get(ctx context.Context, ref types.NamespacedName) (T, e if err := s.validateRef(ref); err != nil { return zero, err } - if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: s.resource, Name: ref.String()}); err != nil { + if s.collection == nil { + if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: s.resource, Name: ref.String()}); err != nil { + return zero, err + } + return s.get(ctx, ref) + } + object, err := s.get(ctx, ref) + if err != nil { + return zero, err + } + if err := s.authorize(ctx, auth.VerbGet, kubeauth.Resource(s.resource, object)); err != nil { return zero, err } - return s.get(ctx, ref) + return object, nil } // Create persists an object already prepared by the resource-specific service. @@ -84,7 +130,11 @@ func (s *Service[T, L]) Create(ctx context.Context, object T) (T, error) { if err := s.validateNewRef(ref); err != nil { return zero, err } - if err := s.authorize(ctx, auth.VerbCreate, auth.Resource{Type: s.resource, Name: ref.String()}); err != nil { + resource := auth.Resource{Type: s.resource, Name: ref.String()} + if s.collection != nil { + resource = kubeauth.Resource(s.resource, object) + } + if err := s.authorize(ctx, auth.VerbCreate, resource); err != nil { return zero, err } if err := s.client.Create(ctx, object); err != nil { @@ -100,21 +150,32 @@ func (s *Service[T, L]) Create(ctx context.Context, object T) (T, error) { return object, nil } -// GetForUpdate authorizes an update and loads the live object that owns metadata and status. -func (s *Service[T, L]) GetForUpdate(ctx context.Context, ref types.NamespacedName) (T, error) { +// Update loads the stored object, applies the resource-specific change, and persists it. +func (s *Service[T, L]) Update(ctx context.Context, ref types.NamespacedName, apply func(T)) (T, error) { var zero T if err := s.validateRef(ref); err != nil { return zero, err } - if err := s.authorize(ctx, auth.VerbUpdate, auth.Resource{Type: s.resource, Name: ref.String()}); err != nil { + if s.collection == nil { + if err := s.authorize(ctx, auth.VerbUpdate, auth.Resource{Type: s.resource, Name: ref.String()}); err != nil { + return zero, err + } + } + object, err := s.get(ctx, ref) + if err != nil { return zero, err } - return s.get(ctx, ref) -} - -// SaveUpdate persists an object returned by GetForUpdate after its spec is changed. -func (s *Service[T, L]) SaveUpdate(ctx context.Context, object T) (T, error) { - var zero T + if s.collection != nil { + if err := s.authorize(ctx, auth.VerbUpdate, kubeauth.Resource(s.resource, object)); err != nil { + return zero, err + } + } + apply(object) + if s.collection != nil { + if err := s.authorize(ctx, auth.VerbUpdate, kubeauth.Resource(s.resource, object)); err != nil { + return zero, err + } + } if err := s.client.Update(ctx, object); err != nil { if apierrors.IsInvalid(err) { return zero, serviceerrors.NewInvalidArgument("Invalid "+s.resource, err) @@ -128,19 +189,38 @@ func (s *Service[T, L]) Delete(ctx context.Context, ref types.NamespacedName) er if err := s.validateRef(ref); err != nil { return err } - if err := s.authorize(ctx, auth.VerbDelete, auth.Resource{Type: s.resource, Name: ref.String()}); err != nil { - return err + if s.collection == nil { + if err := s.authorize(ctx, auth.VerbDelete, auth.Resource{Type: s.resource, Name: ref.String()}); err != nil { + return err + } } object, err := s.get(ctx, ref) if err != nil { return err } + if s.collection != nil { + if err := s.authorize(ctx, auth.VerbDelete, kubeauth.Resource(s.resource, object)); err != nil { + return err + } + } if err := s.client.Delete(ctx, object); err != nil { return serviceerrors.NewInternal("Failed to delete "+s.resource, err) } return nil } +func (s *Service[T, L]) scope(ctx context.Context) (auth.AuthorizationScope, error) { + session, ok := auth.AuthSessionFrom(ctx) + if !ok || session == nil { + return auth.AuthorizationScope{}, serviceerrors.NewUnauthenticated("Failed to get authenticated principal", fmt.Errorf("no session found")) + } + scope, err := s.collection.Scope(ctx, session.Principal(), auth.VerbList, s.resource) + if err != nil { + return auth.AuthorizationScope{}, serviceerrors.NewPermissionDenied("Not authorized", err) + } + return scope, nil +} + func (s *Service[T, L]) get(ctx context.Context, ref types.NamespacedName) (T, error) { var zero T object := s.object.DeepCopyObject().(T) diff --git a/go/core/internal/service/kubecrud/service_test.go b/go/core/internal/service/kubecrud/service_test.go new file mode 100644 index 0000000000..21ec163e65 --- /dev/null +++ b/go/core/internal/service/kubecrud/service_test.go @@ -0,0 +1,130 @@ +package kubecrud + +import ( + "context" + "testing" + + "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" + "github.com/kagent-dev/kagent/go/core/pkg/auth" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" +) + +type testSession struct{} + +func (testSession) Principal() auth.Principal { + return auth.Principal{User: auth.User{ID: "test-user"}} +} + +type authorizationCall struct { + verb auth.Verb + resource auth.Resource +} + +type recordingAuthorizer struct { + scope auth.AuthorizationScope + scopeVerb auth.Verb + scopeType string + checkCalls []authorizationCall +} + +func (a *recordingAuthorizer) Check(_ context.Context, _ auth.Principal, verb auth.Verb, resource auth.Resource) error { + a.checkCalls = append(a.checkCalls, authorizationCall{verb: verb, resource: resource}) + return nil +} + +func (a *recordingAuthorizer) Scope(_ context.Context, _ auth.Principal, verb auth.Verb, resourceType string) (auth.AuthorizationScope, error) { + a.scopeVerb = verb + a.scopeType = resourceType + return a.scope, nil +} + +func TestScopedServiceFiltersBeforeSortingAndUsesTrustedAttributes(t *testing.T) { + scheme := runtime.NewScheme() + if err := v1alpha3.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + authorizer := &recordingAuthorizer{scope: auth.AuthorizationScope{ + Kind: auth.ScopeAnyOf, + AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{ + Attribute: auth.AttributeName, + Operator: auth.ScopeIn, + Values: []string{"a", "b"}, + }}}}, + }} + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "b"}}, + &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "denied"}}, + &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "a"}}, + &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "mutable"}}, + ).Build() + service := NewScopedService(kubeClient, authorizer, &v1alpha3.AgentTemplate{}, &v1alpha3.AgentTemplateList{}, "AgentTemplate") + ctx := auth.AuthSessionTo(t.Context(), testSession{}) + + listed, err := service.List(ctx, "team") + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(listed) != 2 || listed[0].Name != "a" || listed[1].Name != "b" { + t.Fatalf("List() names = %v, want [a b]", []string{listed[0].Name, listed[1].Name}) + } + if authorizer.scopeVerb != auth.VerbList || authorizer.scopeType != "AgentTemplate" { + t.Fatalf("Scope() = (%q, %q), want (list, AgentTemplate)", authorizer.scopeVerb, authorizer.scopeType) + } + + if _, err := service.Get(ctx, types.NamespacedName{Namespace: "team", Name: "a"}); err != nil { + t.Fatalf("Get() error = %v", err) + } + if _, err := service.Create(ctx, &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "created"}}); err != nil { + t.Fatalf("Create() error = %v", err) + } + if _, err := service.Update(ctx, types.NamespacedName{Namespace: "team", Name: "mutable"}, func(mutable *v1alpha3.AgentTemplate) { + mutable.Spec.Description = "updated" + }); err != nil { + t.Fatalf("Update() error = %v", err) + } + if err := service.Delete(ctx, types.NamespacedName{Namespace: "team", Name: "b"}); err != nil { + t.Fatalf("Delete() error = %v", err) + } + + wantVerbs := []auth.Verb{auth.VerbGet, auth.VerbCreate, auth.VerbUpdate, auth.VerbUpdate, auth.VerbDelete} + wantNames := []string{"a", "created", "mutable", "mutable", "b"} + if len(authorizer.checkCalls) != len(wantVerbs) { + t.Fatalf("Check() calls = %d, want %d", len(authorizer.checkCalls), len(wantVerbs)) + } + for index, call := range authorizer.checkCalls { + if call.verb != wantVerbs[index] { + t.Errorf("Check() call %d verb = %q, want %q", index, call.verb, wantVerbs[index]) + } + if call.resource.Type != "AgentTemplate" || len(call.resource.Attributes[auth.AttributeNamespace]) != 1 || call.resource.Attributes[auth.AttributeNamespace][0] != "team" { + t.Errorf("Check() call %d resource = %+v", index, call.resource) + } + if got := call.resource.Attributes[auth.AttributeName]; len(got) != 1 || got[0] != wantNames[index] { + t.Errorf("Check() call %d name = %v, want %q", index, got, wantNames[index]) + } + } +} + +func TestScopedServiceRejectsInvalidScope(t *testing.T) { + scheme := runtime.NewScheme() + if err := v1alpha3.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + authorizer := &recordingAuthorizer{scope: auth.AuthorizationScope{Kind: auth.ScopeAnyOf}} + service := NewScopedService( + fake.NewClientBuilder().WithScheme(scheme).Build(), + authorizer, + &v1alpha3.AgentTemplate{}, + &v1alpha3.AgentTemplateList{}, + "AgentTemplate", + ) + ctx := auth.AuthSessionTo(t.Context(), testSession{}) + + _, err := service.List(ctx, "team") + if err == nil || !serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied) { + t.Fatalf("List() error = %v, want permission denied", err) + } +} diff --git a/go/core/internal/service/model/service.go b/go/core/internal/service/model/service.go index e4e4ad42e7..8eafa89fbb 100644 --- a/go/core/internal/service/model/service.go +++ b/go/core/internal/service/model/service.go @@ -12,6 +12,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "github.com/kagent-dev/kagent/go/api/v1alpha3" + "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" "github.com/kagent-dev/kagent/go/core/internal/service/secretmaterial" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" common "github.com/kagent-dev/kagent/go/core/internal/utils" @@ -22,7 +23,7 @@ var modelConfigGVK = v1alpha3.GroupVersion.WithKind("ModelConfig") type Service struct { kubeClient client.Client - authorizer auth.Authorizer + authorizer auth.CollectionAuthorizer defaultNamespace string providerModelRefresher ProviderModelRefresher } @@ -51,7 +52,7 @@ type DeleteRequest struct { Ref types.NamespacedName } -func NewService(kubeClient client.Client, authorizer auth.Authorizer, defaultNamespace string, options ...ServiceOption) *Service { +func NewService(kubeClient client.Client, authorizer auth.CollectionAuthorizer, defaultNamespace string, options ...ServiceOption) *Service { service := &Service{ kubeClient: kubeClient, authorizer: authorizer, @@ -64,22 +65,30 @@ func NewService(kubeClient client.Client, authorizer auth.Authorizer, defaultNam } func (s *Service) List(ctx context.Context, _ ListRequest) (*v1alpha3.ModelConfigList, error) { - if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: "ModelConfig"}); err != nil { + scope, err := s.scope(ctx, "ModelConfig") + if err != nil { return nil, err } + matches, err := kubeauth.ScopeMatcher(scope) + if err != nil { + return nil, serviceerrors.NewPermissionDenied("Not authorized", err) + } modelConfigs := &v1alpha3.ModelConfigList{} if err := s.kubeClient.List(ctx, modelConfigs); err != nil { return nil, serviceerrors.NewInternal("Failed to list ModelConfigs from Kubernetes", err) } + authorized := make([]v1alpha3.ModelConfig, 0, len(modelConfigs.Items)) + for index := range modelConfigs.Items { + if matches(&modelConfigs.Items[index]) { + authorized = append(authorized, modelConfigs.Items[index]) + } + } + modelConfigs.Items = authorized return modelConfigs, nil } func (s *Service) Get(ctx context.Context, request GetRequest) (*v1alpha3.ModelConfig, error) { - if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: "ModelConfig", Name: request.Ref.String()}); err != nil { - return nil, err - } - modelConfig := &v1alpha3.ModelConfig{} if err := s.kubeClient.Get(ctx, request.Ref, modelConfig); err != nil { if apierrors.IsNotFound(err) { @@ -87,6 +96,9 @@ func (s *Service) Get(ctx context.Context, request GetRequest) (*v1alpha3.ModelC } return nil, serviceerrors.NewInternal("Failed to get ModelConfig", err) } + if err := s.authorize(ctx, auth.VerbGet, kubeauth.Resource("ModelConfig", modelConfig)); err != nil { + return nil, err + } return modelConfig, nil } @@ -96,10 +108,6 @@ func (s *Service) Create(ctx context.Context, request CreateRequest) (*v1alpha3. return nil, serviceerrors.NewInvalidArgument("Invalid Ref", err) } - if err := s.authorize(ctx, auth.VerbCreate, auth.Resource{Type: "ModelConfig", Name: ref.String()}); err != nil { - return nil, err - } - if err := validateAPIKeySecretRef(request.Spec.APIKeySecret, request.Spec.APIKeySecretKey, request.Spec.Provider); err != nil { return nil, err } @@ -107,13 +115,6 @@ func (s *Service) Create(ctx context.Context, request CreateRequest) (*v1alpha3. return nil, err } - existingConfig := &v1alpha3.ModelConfig{} - if err := s.kubeClient.Get(ctx, ref, existingConfig); err == nil { - return nil, serviceerrors.NewAlreadyExists("ModelConfig already exists", nil) - } else if !apierrors.IsNotFound(err) { - return nil, serviceerrors.NewInternal("Failed to check if ModelConfig exists", err) - } - spec := request.Spec if request.APIKey != "" && spec.APIKeySecret == "" && spec.Provider != v1alpha3.ModelProviderOllama { spec.APIKeySecret = ref.Name @@ -127,6 +128,15 @@ func (s *Service) Create(ctx context.Context, request CreateRequest) (*v1alpha3. }, Spec: spec, } + if err := s.authorize(ctx, auth.VerbCreate, kubeauth.Resource("ModelConfig", modelConfig)); err != nil { + return nil, err + } + existingConfig := &v1alpha3.ModelConfig{} + if err := s.kubeClient.Get(ctx, ref, existingConfig); err == nil { + return nil, serviceerrors.NewAlreadyExists("ModelConfig already exists", nil) + } else if !apierrors.IsNotFound(err) { + return nil, serviceerrors.NewInternal("Failed to check if ModelConfig exists", err) + } if err := s.kubeClient.Create(ctx, modelConfig); err != nil { return nil, serviceerrors.NewInternal("Failed to create ModelConfig", err) @@ -159,10 +169,6 @@ func (s *Service) Create(ctx context.Context, request CreateRequest) (*v1alpha3. } func (s *Service) Update(ctx context.Context, request UpdateRequest) (*v1alpha3.ModelConfig, error) { - if err := s.authorize(ctx, auth.VerbUpdate, auth.Resource{Type: "ModelConfig", Name: request.Ref.String()}); err != nil { - return nil, err - } - if err := validateAPIKeySecretRef(request.Spec.APIKeySecret, request.Spec.APIKeySecretKey, request.Spec.Provider); err != nil { return nil, err } @@ -177,6 +183,9 @@ func (s *Service) Update(ctx context.Context, request UpdateRequest) (*v1alpha3. } return nil, serviceerrors.NewInternal("Failed to get ModelConfig", err) } + if err := s.authorize(ctx, auth.VerbUpdate, kubeauth.Resource("ModelConfig", modelConfig)); err != nil { + return nil, err + } oldRefs := referencedSecretNames(modelConfig.Spec) spec := request.Spec @@ -184,6 +193,11 @@ func (s *Service) Update(ctx context.Context, request UpdateRequest) (*v1alpha3. spec.APIKeySecret = request.Ref.Name spec.APIKeySecretKey = providerAPIKeySecretKey(spec.Provider) } + proposed := modelConfig.DeepCopy() + proposed.Spec = spec + if err := s.authorize(ctx, auth.VerbUpdate, kubeauth.Resource("ModelConfig", proposed)); err != nil { + return nil, err + } if request.APIKey != nil && *request.APIKey != "" && spec.Provider != v1alpha3.ModelProviderOllama { if err := secretmaterial.CreateOrUpdateOwnedOpaqueSecret( @@ -238,10 +252,6 @@ func (s *Service) Update(ctx context.Context, request UpdateRequest) (*v1alpha3. } func (s *Service) Delete(ctx context.Context, request DeleteRequest) (*v1alpha3.ModelConfig, error) { - if err := s.authorize(ctx, auth.VerbDelete, auth.Resource{Type: "ModelConfig", Name: request.Ref.String()}); err != nil { - return nil, err - } - modelConfig := &v1alpha3.ModelConfig{} if err := s.kubeClient.Get(ctx, request.Ref, modelConfig); err != nil { if apierrors.IsNotFound(err) { @@ -249,6 +259,9 @@ func (s *Service) Delete(ctx context.Context, request DeleteRequest) (*v1alpha3. } return nil, serviceerrors.NewInternal("Failed to get ModelConfig", err) } + if err := s.authorize(ctx, auth.VerbDelete, kubeauth.Resource("ModelConfig", modelConfig)); err != nil { + return nil, err + } if err := s.kubeClient.Delete(ctx, modelConfig); err != nil { return nil, serviceerrors.NewInternal("Failed to delete ModelConfig", err) @@ -256,6 +269,18 @@ func (s *Service) Delete(ctx context.Context, request DeleteRequest) (*v1alpha3. return modelConfig, nil } +func (s *Service) scope(ctx context.Context, resourceType string) (auth.AuthorizationScope, error) { + session, ok := auth.AuthSessionFrom(ctx) + if !ok || session == nil { + return auth.AuthorizationScope{}, serviceerrors.NewUnauthenticated("Failed to get authenticated principal", fmt.Errorf("no session found")) + } + scope, err := s.authorizer.Scope(ctx, session.Principal(), auth.VerbList, resourceType) + if err != nil { + return auth.AuthorizationScope{}, serviceerrors.NewPermissionDenied("Not authorized", err) + } + return scope, nil +} + func (s *Service) authorize(ctx context.Context, verb auth.Verb, resource auth.Resource) error { session, ok := auth.AuthSessionFrom(ctx) if !ok || session == nil { diff --git a/go/core/internal/service/model/service_test.go b/go/core/internal/service/model/service_test.go index 6e43d0c5b4..ed25126d06 100644 --- a/go/core/internal/service/model/service_test.go +++ b/go/core/internal/service/model/service_test.go @@ -30,6 +30,33 @@ func (denyAuthorizer) Check(_ context.Context, _ pkgauth.Principal, _ pkgauth.Ve return errors.New("denied") } +func (denyAuthorizer) Scope(_ context.Context, _ pkgauth.Principal, _ pkgauth.Verb, _ string) (pkgauth.AuthorizationScope, error) { + return pkgauth.AuthorizationScope{}, errors.New("denied") +} + +type authorizationCall struct { + verb pkgauth.Verb + resource pkgauth.Resource +} + +type recordingAuthorizer struct { + scope pkgauth.AuthorizationScope + scopeVerb pkgauth.Verb + scopeType string + checkCalls []authorizationCall +} + +func (a *recordingAuthorizer) Check(_ context.Context, _ pkgauth.Principal, verb pkgauth.Verb, resource pkgauth.Resource) error { + a.checkCalls = append(a.checkCalls, authorizationCall{verb: verb, resource: resource}) + return nil +} + +func (a *recordingAuthorizer) Scope(_ context.Context, _ pkgauth.Principal, verb pkgauth.Verb, resourceType string) (pkgauth.AuthorizationScope, error) { + a.scopeVerb = verb + a.scopeType = resourceType + return a.scope, nil +} + type modelUpdateConflictOnceClient struct { ctrlclient.Client conflicted bool @@ -56,7 +83,7 @@ func TestServiceCRUDAndValidation(t *testing.T) { require.NoError(t, v1alpha3.AddToScheme(scheme)) require.NoError(t, corev1.AddToScheme(scheme)) - newService := func(authorizer pkgauth.Authorizer, objects ...ctrlclient.Object) (*model.Service, ctrlclient.Client, context.Context) { + newService := func(authorizer pkgauth.CollectionAuthorizer, objects ...ctrlclient.Object) (*model.Service, ctrlclient.Client, context.Context) { kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(objects...).Build() service := model.NewService(kubeClient, authorizer, "default") ctx := pkgauth.AuthSessionTo(context.Background(), &authimpl.SimpleSession{P: pkgauth.Principal{User: pkgauth.User{ID: "test-user"}}}) @@ -293,3 +320,77 @@ func TestServiceCRUDAndValidation(t *testing.T) { assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied)) }) } + +func TestListAppliesModelConfigScope(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, v1alpha3.AddToScheme(scheme)) + authorizer := &recordingAuthorizer{scope: pkgauth.AuthorizationScope{ + Kind: pkgauth.ScopeAnyOf, + AnyOf: []pkgauth.ScopeClause{{All: []pkgauth.ScopePredicate{{ + Attribute: pkgauth.AttributeNamespace, + Operator: pkgauth.ScopeIn, + Values: []string{"team-a"}, + }}}}, + }} + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "allowed"}}, + &v1alpha3.ModelConfig{ObjectMeta: metav1.ObjectMeta{Namespace: "team-b", Name: "denied"}}, + ).Build() + service := model.NewService(kubeClient, authorizer, "default") + ctx := pkgauth.AuthSessionTo(context.Background(), &authimpl.SimpleSession{P: pkgauth.Principal{User: pkgauth.User{ID: "test-user"}}}) + + list, err := service.List(ctx, model.ListRequest{}) + require.NoError(t, err) + require.Len(t, list.Items, 1) + assert.Equal(t, "allowed", list.Items[0].Name) + assert.Equal(t, pkgauth.VerbList, authorizer.scopeVerb) + assert.Equal(t, "ModelConfig", authorizer.scopeType) + + authorizer.scope = pkgauth.AuthorizationScope{Kind: pkgauth.ScopeAnyOf} + _, err = service.List(ctx, model.ListRequest{}) + require.Error(t, err) + assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied)) +} + +func TestModelConfigCRUDUsesTrustedAttributes(t *testing.T) { + scheme := runtime.NewScheme() + require.NoError(t, v1alpha3.AddToScheme(scheme)) + require.NoError(t, corev1.AddToScheme(scheme)) + existing := &v1alpha3.ModelConfig{ + ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "existing"}, + Spec: v1alpha3.ModelConfigSpec{Model: "old", Provider: v1alpha3.ModelProviderOpenAI}, + } + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existing).Build() + authorizer := &recordingAuthorizer{scope: pkgauth.AuthorizationScope{Kind: pkgauth.ScopeAll}} + service := model.NewService(kubeClient, authorizer, "default") + ctx := pkgauth.AuthSessionTo(context.Background(), &authimpl.SimpleSession{P: pkgauth.Principal{User: pkgauth.User{ID: "test-user"}}}) + + if _, err := service.Get(ctx, model.GetRequest{Ref: types.NamespacedName{Namespace: "team", Name: "existing"}}); err != nil { + t.Fatalf("Get() error = %v", err) + } + if _, err := service.Create(ctx, model.CreateRequest{ + Ref: "team/created", + Spec: v1alpha3.ModelConfigSpec{Model: "created", Provider: v1alpha3.ModelProviderOpenAI}, + }); err != nil { + t.Fatalf("Create() error = %v", err) + } + if _, err := service.Update(ctx, model.UpdateRequest{ + Ref: types.NamespacedName{Namespace: "team", Name: "existing"}, + Spec: v1alpha3.ModelConfigSpec{Model: "updated", Provider: v1alpha3.ModelProviderOpenAI}, + }); err != nil { + t.Fatalf("Update() error = %v", err) + } + if _, err := service.Delete(ctx, model.DeleteRequest{Ref: types.NamespacedName{Namespace: "team", Name: "existing"}}); err != nil { + t.Fatalf("Delete() error = %v", err) + } + + wantVerbs := []pkgauth.Verb{pkgauth.VerbGet, pkgauth.VerbCreate, pkgauth.VerbUpdate, pkgauth.VerbUpdate, pkgauth.VerbDelete} + wantNames := []string{"existing", "created", "existing", "existing", "existing"} + require.Len(t, authorizer.checkCalls, len(wantVerbs)) + for index, call := range authorizer.checkCalls { + assert.Equal(t, wantVerbs[index], call.verb) + assert.Equal(t, "ModelConfig", call.resource.Type) + assert.Equal(t, []string{"team"}, call.resource.Attributes[pkgauth.AttributeNamespace]) + assert.Equal(t, []string{wantNames[index]}, call.resource.Attributes[pkgauth.AttributeName]) + } +} diff --git a/go/core/pkg/auth/auth.go b/go/core/pkg/auth/auth.go index 350851f7e5..55d2af1e64 100644 --- a/go/core/pkg/auth/auth.go +++ b/go/core/pkg/auth/auth.go @@ -4,11 +4,14 @@ import ( "context" "net/http" "net/url" + + apiauthorization "github.com/kagent-dev/kagent/go/api/authorization" ) type Verb string const ( + VerbList Verb = "list" VerbGet Verb = "get" VerbCreate Verb = "create" VerbUpdate Verb = "update" @@ -16,8 +19,9 @@ const ( ) type Resource struct { - Name string - Type string + Type string + Name string + Attributes map[string][]string } type User struct { @@ -56,6 +60,35 @@ type Authorizer interface { Check(ctx context.Context, principal Principal, verb Verb, resource Resource) error } +type CollectionAuthorizer interface { + Authorizer + Scope(ctx context.Context, principal Principal, verb Verb, resourceType string) (AuthorizationScope, error) +} + +type ScopeKind = apiauthorization.ScopeKind + +const ( + AttributeNamespace = apiauthorization.AttributeNamespace + AttributeName = apiauthorization.AttributeName + + ScopeAll = apiauthorization.ScopeAll + ScopeNone = apiauthorization.ScopeNone + ScopeAnyOf = apiauthorization.ScopeAnyOf +) + +type ScopeOperator = apiauthorization.ScopeOperator + +const ( + ScopeIn = apiauthorization.ScopeIn + ScopeMissing = apiauthorization.ScopeMissing +) + +type AuthorizationScope = apiauthorization.AuthorizationScope + +type ScopeClause = apiauthorization.ScopeClause + +type ScopePredicate = apiauthorization.ScopePredicate + // context utils type sessionKeyType struct{} diff --git a/go/core/v2/a2agateway/gateway_test.go b/go/core/v2/a2agateway/gateway_test.go index 72298f0ed9..134ef4e2fa 100644 --- a/go/core/v2/a2agateway/gateway_test.go +++ b/go/core/v2/a2agateway/gateway_test.go @@ -340,7 +340,7 @@ func TestGatewayResolvesAuthenticatedHeadersBeforeSending(t *testing.T) { if store.namespace != "team-a" || store.id != gatewayTestID || store.userID != "alice" { t.Fatalf("store lookup = %q/%q user %q", store.namespace, store.id, store.userID) } - if authorizer.verb != auth.VerbCreate || authorizer.resource != (auth.Resource{Type: "AgentInstance", Name: "team-a/" + gatewayTestID}) { + if authorizer.verb != auth.VerbCreate || authorizer.resource.Type != "AgentInstance" || authorizer.resource.Name != "team-a/"+gatewayTestID || authorizer.resource.Attributes != nil { t.Fatalf("authorization = %q %#v", authorizer.verb, authorizer.resource) } } From 324d369ab1703d6999aeadd5c81605c8c1263626 Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Thu, 3 Sep 2026 11:28:30 -0700 Subject: [PATCH 2/4] Apply scoped authorization to Harness Signed-off-by: Jeremy Alvis --- .../references/scoped-authorization.md | 7 +- design/scoped-authorization.md | 23 +-- go/core/cmd/controller-v2/main.go | 2 +- .../grpcserver/agenttemplate_harness_test.go | 2 +- go/core/internal/grpcserver/harness.go | 4 +- go/core/internal/grpcserver/server.go | 2 +- go/core/internal/service/agent/service.go | 60 ++------ .../internal/service/agent/service_test.go | 138 ------------------ .../internal/service/kubecrud/service_test.go | 32 ++++ 9 files changed, 59 insertions(+), 211 deletions(-) delete mode 100644 go/core/internal/service/agent/service_test.go diff --git a/.claude/skills/kagent-dev/references/scoped-authorization.md b/.claude/skills/kagent-dev/references/scoped-authorization.md index 02922adbf6..99f2de4be2 100644 --- a/.claude/skills/kagent-dev/references/scoped-authorization.md +++ b/.claude/skills/kagent-dev/references/scoped-authorization.md @@ -55,11 +55,10 @@ Pass scopes as ordinary function arguments. Do not place policy decisions in req ## Service integration -- `AgentTemplate` uses `kubecrud.NewScopedService`. The gRPC server accepts the scoped service type, so it cannot be wired with the unscoped constructor accidentally. -- `AgentHarness` is filtered inside the mixed `agent.Service.List` result. `SandboxAgent` keeps its existing authorization behavior. +- `AgentTemplate` and `Harness` use `kubecrud.NewScopedService`. Their gRPC servers accept the scoped service type, so they cannot be wired with the unscoped constructor accidentally. - `ModelConfig` requests require a `CollectionAuthorizer`; `Service.List` filters the returned Kubernetes list before transport conversion. - `ListConfiguredProviders` reads the separate `ModelProviderConfig` resource and remains outside this scope. -- The unrelated `Harness` resource keeps `kubecrud.NewService` and the existing `Authorizer` behavior. +- Legacy `AgentHarness` and `SandboxAgent` operations remain outside this scope. The default OSS no-op authorizer implements `CollectionAuthorizer` and returns `ScopeAll`, preserving current OSS visibility. @@ -79,5 +78,3 @@ Each protected service then needs focused tests proving: - single-resource checks receive trusted `namespace` and `name` attributes; - updates check both stored and proposed objects; - malformed scopes fail closed. - -For mixed collections, also prove that the scope removes only the protected resource kind. diff --git a/design/scoped-authorization.md b/design/scoped-authorization.md index 4db575784f..c36d7ce70f 100644 --- a/design/scoped-authorization.md +++ b/design/scoped-authorization.md @@ -6,7 +6,7 @@ Status: Draft Kagent uses `Authorizer.Check` for resource authorization. -This design adds scoped authorization for `AgentTemplate`, `AgentHarness`, and `ModelConfig` resources. +This design adds scoped authorization for `AgentTemplate`, `Harness`, and `ModelConfig` resources. Kagent supplies trusted resource attributes and enforces authorization before it builds a response. @@ -17,11 +17,9 @@ An `Authorizer` implementation defines roles, policies, identity rules, and cata | Resource type | Operations | Attributes | | --- | --- | --- | | `AgentTemplate` | list, get, create, update, delete | `namespace`, `name` | -| `AgentHarness` | list, get, create, delete | `namespace`, `name` | +| `Harness` | list, create, delete | `namespace`, `name` | | `ModelConfig` | list, get, create, update, delete | `namespace`, `name` | -`ListAgents` returns `SandboxAgent` and `AgentHarness` entries. Catalog authorization applies only to `AgentHarness` entries. - `ListConfiguredProviders` derives entries from the separate `ModelProviderConfig` resource. This design does not change its authorization. Provider discovery does not return `ModelConfig` resources. This design does not change its authorization. @@ -37,9 +35,8 @@ Provider discovery does not return `ModelConfig` resources. This design does not ## Non-goals - This design does not define roles, policies, claims, or catalog keys. -- This design does not protect `SandboxAgent`, `Harness`, `AgentInstance`, or `ModelProviderConfig` resources. +- This design does not protect `SandboxAgent`, `AgentHarness`, `AgentInstance`, or `ModelProviderConfig` resources. - This design does not protect tool servers or prompt templates. -- This design does not authorize `AgentHarness` session actor operations. - This design does not add SQL or backend expressions to the authorization API. ## Authorization API @@ -153,16 +150,6 @@ The current Kubernetes lists do not use server pagination. A complete in-memory If a list adds pagination, it must apply the scope before totals, sorting, and pagination. -## Mixed Agent list - -`ListAgents` will request one scope for the `AgentHarness` resource type. - -The service will apply this scope to each `AgentHarness` entry. - -The service will keep `SandboxAgent` entries under their current authorization behavior. - -`ScopeNone` will remove all `AgentHarness` entries. It will not remove `SandboxAgent` entries. - ## Default OSS behavior The default no-op authorizer will return `ScopeAll`. @@ -183,7 +170,7 @@ Tests must verify trusted attributes for each single-resource operation. Tests must prove that list filtering occurs before sorting and response construction. -Tests must prove that `ListAgents` filters only `AgentHarness` entries. +Tests must prove that each protected list requests the correct resource type and filters unauthorized entries. Tests must verify fail-closed behavior for invalid scopes. @@ -192,7 +179,7 @@ Tests must verify fail-closed behavior for invalid scopes. - [x] Add `name` to the shared authorization attributes. - [x] Add one Kubernetes scope matcher for `namespace` and `name`. - [x] Require `CollectionAuthorizer` for `AgentTemplate` collection operations. -- [x] Require `CollectionAuthorizer` for `AgentHarness` collection operations. +- [x] Require `CollectionAuthorizer` for `Harness` collection operations. - [x] Require `CollectionAuthorizer` for `ModelConfig` collection operations. - [x] Populate trusted attributes for reads and writes. - [x] Filter each protected list before response construction. diff --git a/go/core/cmd/controller-v2/main.go b/go/core/cmd/controller-v2/main.go index da45226cc6..38e1d4e3d9 100644 --- a/go/core/cmd/controller-v2/main.go +++ b/go/core/cmd/controller-v2/main.go @@ -215,7 +215,7 @@ func main() { // Both halves of the pair CreateAgentInstance names. Without these two // the only way to author a Harness or an AgentTemplate is kubectl. AgentTemplateService: kubecrud.NewScopedService(manager.GetClient(), authorizer, &kagentv1alpha3.AgentTemplate{}, &kagentv1alpha3.AgentTemplateList{}, "AgentTemplate"), - HarnessService: kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.Harness{}, &kagentv1alpha3.HarnessList{}, "Harness"), + HarnessService: kubecrud.NewScopedService(manager.GetClient(), authorizer, &kagentv1alpha3.Harness{}, &kagentv1alpha3.HarnessList{}, "Harness"), CheckpointService: checkpoints, A2AHandler: gateway, }) diff --git a/go/core/internal/grpcserver/agenttemplate_harness_test.go b/go/core/internal/grpcserver/agenttemplate_harness_test.go index c45157e387..5cd63610db 100644 --- a/go/core/internal/grpcserver/agenttemplate_harness_test.go +++ b/go/core/internal/grpcserver/agenttemplate_harness_test.go @@ -40,7 +40,7 @@ func newTemplateAndHarnessConnection(t *testing.T, objects ...ctrlclient.Object) Registerer: prometheus.NewRegistry(), Authenticator: &authimpl.UnsecureAuthenticator{}, AgentTemplateService: kubecrud.NewScopedService(kubeClient, &authimpl.NoopAuthorizer{}, &v1alpha3.AgentTemplate{}, &v1alpha3.AgentTemplateList{}, "AgentTemplate"), - HarnessService: kubecrud.NewService(kubeClient, &authimpl.NoopAuthorizer{}, &v1alpha3.Harness{}, &v1alpha3.HarnessList{}, "Harness"), + HarnessService: kubecrud.NewScopedService(kubeClient, &authimpl.NoopAuthorizer{}, &v1alpha3.Harness{}, &v1alpha3.HarnessList{}, "Harness"), }) if err != nil { t.Fatalf("New() error = %v", err) diff --git a/go/core/internal/grpcserver/harness.go b/go/core/internal/grpcserver/harness.go index 5753d98615..587601c86c 100644 --- a/go/core/internal/grpcserver/harness.go +++ b/go/core/internal/grpcserver/harness.go @@ -25,11 +25,11 @@ const ( type harnessServer struct { apiv1alpha1.UnimplementedHarnessServiceServer - service *kubecrud.Service[*v1alpha3.Harness, *v1alpha3.HarnessList] + service *kubecrud.ScopedService[*v1alpha3.Harness, *v1alpha3.HarnessList] maxMessageBytes int } -func newHarnessServer(service *kubecrud.Service[*v1alpha3.Harness, *v1alpha3.HarnessList], maxMessageBytes int) *harnessServer { +func newHarnessServer(service *kubecrud.ScopedService[*v1alpha3.Harness, *v1alpha3.HarnessList], maxMessageBytes int) *harnessServer { return &harnessServer{service: service, maxMessageBytes: maxMessageBytes} } diff --git a/go/core/internal/grpcserver/server.go b/go/core/internal/grpcserver/server.go index b71140ff7c..f36417fc99 100644 --- a/go/core/internal/grpcserver/server.go +++ b/go/core/internal/grpcserver/server.go @@ -53,7 +53,7 @@ type Config struct { Registerer prometheus.Registerer AgentService *agentservice.Service AgentTemplateService *kubecrud.ScopedService[*v1alpha3.AgentTemplate, *v1alpha3.AgentTemplateList] - HarnessService *kubecrud.Service[*v1alpha3.Harness, *v1alpha3.HarnessList] + HarnessService *kubecrud.ScopedService[*v1alpha3.Harness, *v1alpha3.HarnessList] ModelService *modelservice.Service ToolService *toolservice.Service PromptTemplateService *prompttemplateservice.Service diff --git a/go/core/internal/service/agent/service.go b/go/core/internal/service/agent/service.go index b7b26c8dc6..3629f187d0 100644 --- a/go/core/internal/service/agent/service.go +++ b/go/core/internal/service/agent/service.go @@ -6,7 +6,6 @@ import ( "strings" "github.com/kagent-dev/kagent/go/api/v1alpha3" - "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" "github.com/kagent-dev/kagent/go/core/internal/utils" "github.com/kagent-dev/kagent/go/core/pkg/auth" @@ -83,12 +82,12 @@ func WithValidator(validator Validator) ServiceOption { type Service struct { kubeClient client.Client - authorizer auth.CollectionAuthorizer + authorizer auth.Authorizer defaultNamespace string validator Validator } -func NewService(kubeClient client.Client, authorizer auth.CollectionAuthorizer, defaultNamespace string, options ...ServiceOption) *Service { +func NewService(kubeClient client.Client, authorizer auth.Authorizer, defaultNamespace string, options ...ServiceOption) *Service { service := &Service{ kubeClient: kubeClient, authorizer: authorizer, @@ -101,6 +100,10 @@ func NewService(kubeClient client.Client, authorizer auth.CollectionAuthorizer, } func (s *Service) List(ctx context.Context, request ListRequest) ([]View, error) { + if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: "Agent"}); err != nil { + return nil, err + } + options := make([]client.ListOption, 0, 1) if request.Namespace != "" { if strings.TrimSpace(request.Namespace) != request.Namespace { @@ -117,17 +120,6 @@ func (s *Service) List(ctx context.Context, request ListRequest) ([]View, error) } options = append(options, client.InNamespace(request.Namespace)) } - if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: "Agent"}); err != nil { - return nil, err - } - scope, err := s.scope(ctx, string(KindAgentHarness)) - if err != nil { - return nil, err - } - matches, err := kubeauth.ScopeMatcher(scope) - if err != nil { - return nil, serviceerrors.NewPermissionDenied("Not authorized", err) - } sandboxAgents := &v1alpha3.SandboxAgentList{} if err := s.kubeClient.List(ctx, sandboxAgents, options...); err != nil { @@ -145,7 +137,7 @@ func (s *Service) List(ctx context.Context, request ListRequest) ([]View, error) } for index := range harnesses.Items { harness := &harnesses.Items[index] - if !matches(harness) || !v1alpha3.IsKnownAgentHarnessBackend(harness.Spec.Backend) { + if !v1alpha3.IsKnownAgentHarnessBackend(harness.Spec.Backend) { continue } views = append(views, s.harnessView(ctx, harness)) @@ -162,19 +154,13 @@ func (s *Service) GetSandboxAgent(ctx context.Context, request GetRequest) (View } func (s *Service) GetAgentHarness(ctx context.Context, request GetRequest) (View, error) { - if request.Ref.Namespace == "" || request.Ref.Name == "" { - return View{}, serviceerrors.NewInvalidArgument("AgentHarness namespace and name are required", nil) - } harness := &v1alpha3.AgentHarness{} - if err := s.loadForMutation(ctx, request.Ref, harness, "AgentHarness not found", "Failed to get AgentHarness"); err != nil { + if err := s.get(ctx, request.Ref, harness, "AgentHarness not found"); err != nil { return View{}, err } if !v1alpha3.IsKnownAgentHarnessBackend(harness.Spec.Backend) { return View{}, serviceerrors.NewNotFound("AgentHarness not found", nil) } - if err := s.authorize(ctx, auth.VerbGet, kubeauth.Resource(string(KindAgentHarness), harness)); err != nil { - return View{}, err - } return s.harnessView(ctx, harness), nil } @@ -241,12 +227,12 @@ func (s *Service) CreateAgentHarness(ctx context.Context, request CreateAgentHar if harness.Kind == "" { harness.Kind = string(KindAgentHarness) } - if strings.TrimSpace(string(harness.Spec.Backend)) == "" { - return View{}, serviceerrors.NewInvalidArgument("spec.backend is required", nil) - } if _, err := s.prepareCreate(ctx, harness, KindAgentHarness, auth.VerbCreate); err != nil { return View{}, err } + if strings.TrimSpace(string(harness.Spec.Backend)) == "" { + return View{}, serviceerrors.NewInvalidArgument("spec.backend is required", nil) + } if err := s.kubeClient.Create(ctx, harness); err != nil { if apierrors.IsAlreadyExists(err) { return View{}, serviceerrors.NewAlreadyExists("AgentHarness already exists", err) @@ -260,6 +246,9 @@ func (s *Service) DeleteAgentHarness(ctx context.Context, request DeleteRequest) if request.Ref.Namespace == "" || request.Ref.Name == "" { return serviceerrors.NewInvalidArgument("AgentHarness namespace and name are required", nil) } + if err := s.authorize(ctx, auth.VerbDelete, auth.Resource{Type: "Agent", Name: request.Ref.String()}); err != nil { + return err + } harness := &v1alpha3.AgentHarness{} if err := s.loadForMutation(ctx, request.Ref, harness, "AgentHarness not found", "Failed to get AgentHarness"); err != nil { return err @@ -267,9 +256,6 @@ func (s *Service) DeleteAgentHarness(ctx context.Context, request DeleteRequest) if !v1alpha3.IsKnownAgentHarnessBackend(harness.Spec.Backend) { return serviceerrors.NewNotFound("AgentHarness not found", nil) } - if err := s.authorize(ctx, auth.VerbDelete, kubeauth.Resource(string(KindAgentHarness), harness)); err != nil { - return err - } if err := s.kubeClient.Delete(ctx, harness); err != nil { return serviceerrors.NewInternal("Failed to delete AgentHarness", err) } @@ -287,11 +273,7 @@ func (s *Service) prepareCreate(ctx context.Context, object client.Object, kind if ref.Name != object.GetName() || ref.Namespace != object.GetNamespace() { return types.NamespacedName{}, serviceerrors.NewInvalidArgument(fmt.Sprintf("Invalid %s metadata", kind), nil) } - resource := auth.Resource{Type: "Agent", Name: ref.String()} - if kind == KindAgentHarness { - resource = kubeauth.Resource(string(kind), object) - } - if err := s.authorize(ctx, verb, resource); err != nil { + if err := s.authorize(ctx, verb, auth.Resource{Type: "Agent", Name: ref.String()}); err != nil { return types.NamespacedName{}, err } return ref, nil @@ -472,15 +454,3 @@ func (s *Service) authorize(ctx context.Context, verb auth.Verb, resource auth.R } return nil } - -func (s *Service) scope(ctx context.Context, resourceType string) (auth.AuthorizationScope, error) { - session, ok := auth.AuthSessionFrom(ctx) - if !ok || session == nil { - return auth.AuthorizationScope{}, serviceerrors.NewUnauthenticated("Failed to get authenticated principal", fmt.Errorf("no session found")) - } - scope, err := s.authorizer.Scope(ctx, session.Principal(), auth.VerbList, resourceType) - if err != nil { - return auth.AuthorizationScope{}, serviceerrors.NewPermissionDenied("Not authorized", err) - } - return scope, nil -} diff --git a/go/core/internal/service/agent/service_test.go b/go/core/internal/service/agent/service_test.go deleted file mode 100644 index 15fb22fafe..0000000000 --- a/go/core/internal/service/agent/service_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package agent - -import ( - "context" - "testing" - - "github.com/kagent-dev/kagent/go/api/v1alpha3" - "github.com/kagent-dev/kagent/go/core/pkg/auth" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/runtime" - "k8s.io/apimachinery/pkg/types" - "sigs.k8s.io/controller-runtime/pkg/client/fake" -) - -type testSession struct{} - -func (testSession) Principal() auth.Principal { - return auth.Principal{User: auth.User{ID: "test-user"}} -} - -type authorizationCall struct { - verb auth.Verb - resource auth.Resource -} - -type recordingAuthorizer struct { - scope auth.AuthorizationScope - scopeVerb auth.Verb - scopeType string - checkCalls []authorizationCall -} - -func (a *recordingAuthorizer) Check(_ context.Context, _ auth.Principal, verb auth.Verb, resource auth.Resource) error { - a.checkCalls = append(a.checkCalls, authorizationCall{verb: verb, resource: resource}) - return nil -} - -func (a *recordingAuthorizer) Scope(_ context.Context, _ auth.Principal, verb auth.Verb, resourceType string) (auth.AuthorizationScope, error) { - a.scopeVerb = verb - a.scopeType = resourceType - return a.scope, nil -} - -func TestListFiltersOnlyAgentHarnesses(t *testing.T) { - scheme := runtime.NewScheme() - if err := v1alpha3.AddToScheme(scheme); err != nil { - t.Fatal(err) - } - kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects( - &v1alpha3.SandboxAgent{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "sandbox"}}, - &v1alpha3.AgentHarness{ - ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "allowed"}, - Spec: v1alpha3.AgentHarnessSpec{Backend: v1alpha3.AgentHarnessBackendOpenClaw}, - }, - &v1alpha3.AgentHarness{ - ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "denied"}, - Spec: v1alpha3.AgentHarnessSpec{Backend: v1alpha3.AgentHarnessBackendHermes}, - }, - ).Build() - authorizer := &recordingAuthorizer{scope: auth.AuthorizationScope{ - Kind: auth.ScopeAnyOf, - AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{ - Attribute: auth.AttributeName, - Operator: auth.ScopeIn, - Values: []string{"allowed"}, - }}}}, - }} - service := NewService(kubeClient, authorizer, "default") - ctx := auth.AuthSessionTo(t.Context(), testSession{}) - - views, err := service.List(ctx, ListRequest{Namespace: "team"}) - if err != nil { - t.Fatalf("List() error = %v", err) - } - if len(views) != 2 || views[0].Kind != KindSandboxAgent || views[1].Kind != KindAgentHarness || views[1].Ref.Name != "allowed" { - t.Fatalf("List() = %+v, want sandbox plus allowed AgentHarness", views) - } - if authorizer.scopeVerb != auth.VerbList || authorizer.scopeType != string(KindAgentHarness) { - t.Fatalf("Scope() = (%q, %q), want (list, AgentHarness)", authorizer.scopeVerb, authorizer.scopeType) - } - if len(authorizer.checkCalls) != 1 || authorizer.checkCalls[0].resource.Type != "Agent" { - t.Fatalf("SandboxAgent collection Check() calls = %+v", authorizer.checkCalls) - } - - authorizer.scope = auth.AuthorizationScope{Kind: auth.ScopeNone} - views, err = service.List(ctx, ListRequest{Namespace: "team"}) - if err != nil { - t.Fatalf("List(ScopeNone) error = %v", err) - } - if len(views) != 1 || views[0].Kind != KindSandboxAgent { - t.Fatalf("List(ScopeNone) = %+v, want only SandboxAgent", views) - } -} - -func TestAgentHarnessCRUDUsesTrustedAttributes(t *testing.T) { - scheme := runtime.NewScheme() - if err := v1alpha3.AddToScheme(scheme); err != nil { - t.Fatal(err) - } - existing := &v1alpha3.AgentHarness{ - ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "existing"}, - Spec: v1alpha3.AgentHarnessSpec{Backend: v1alpha3.AgentHarnessBackendOpenClaw}, - } - kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects(existing).Build() - authorizer := &recordingAuthorizer{scope: auth.AuthorizationScope{Kind: auth.ScopeAll}} - service := NewService(kubeClient, authorizer, "default") - ctx := auth.AuthSessionTo(t.Context(), testSession{}) - - if _, err := service.GetAgentHarness(ctx, GetRequest{Ref: types.NamespacedName{Namespace: "team", Name: "existing"}}); err != nil { - t.Fatalf("GetAgentHarness() error = %v", err) - } - if _, err := service.CreateAgentHarness(ctx, CreateAgentHarnessRequest{AgentHarness: &v1alpha3.AgentHarness{ - ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "created"}, - Spec: v1alpha3.AgentHarnessSpec{Backend: v1alpha3.AgentHarnessBackendHermes}, - }}); err != nil { - t.Fatalf("CreateAgentHarness() error = %v", err) - } - if err := service.DeleteAgentHarness(ctx, DeleteRequest{Ref: types.NamespacedName{Namespace: "team", Name: "existing"}}); err != nil { - t.Fatalf("DeleteAgentHarness() error = %v", err) - } - - wantVerbs := []auth.Verb{auth.VerbGet, auth.VerbCreate, auth.VerbDelete} - wantNames := []string{"existing", "created", "existing"} - if len(authorizer.checkCalls) != len(wantVerbs) { - t.Fatalf("Check() calls = %d, want %d", len(authorizer.checkCalls), len(wantVerbs)) - } - for index, call := range authorizer.checkCalls { - if call.verb != wantVerbs[index] || call.resource.Type != string(KindAgentHarness) { - t.Errorf("Check() call %d = %+v", index, call) - } - if got := call.resource.Attributes[auth.AttributeNamespace]; len(got) != 1 || got[0] != "team" { - t.Errorf("Check() call %d namespace = %v", index, got) - } - if got := call.resource.Attributes[auth.AttributeName]; len(got) != 1 || got[0] != wantNames[index] { - t.Errorf("Check() call %d name = %v, want %q", index, got, wantNames[index]) - } - } -} diff --git a/go/core/internal/service/kubecrud/service_test.go b/go/core/internal/service/kubecrud/service_test.go index 21ec163e65..d20cdd1a6b 100644 --- a/go/core/internal/service/kubecrud/service_test.go +++ b/go/core/internal/service/kubecrud/service_test.go @@ -108,6 +108,38 @@ func TestScopedServiceFiltersBeforeSortingAndUsesTrustedAttributes(t *testing.T) } } +func TestScopedHarnessServiceFiltersList(t *testing.T) { + scheme := runtime.NewScheme() + if err := v1alpha3.AddToScheme(scheme); err != nil { + t.Fatal(err) + } + authorizer := &recordingAuthorizer{scope: auth.AuthorizationScope{ + Kind: auth.ScopeAnyOf, + AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{ + Attribute: auth.AttributeName, + Operator: auth.ScopeIn, + Values: []string{"allowed"}, + }}}}, + }} + kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects( + &v1alpha3.Harness{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "allowed"}}, + &v1alpha3.Harness{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "denied"}}, + ).Build() + service := NewScopedService(kubeClient, authorizer, &v1alpha3.Harness{}, &v1alpha3.HarnessList{}, "Harness") + ctx := auth.AuthSessionTo(t.Context(), testSession{}) + + listed, err := service.List(ctx, "team") + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(listed) != 1 || listed[0].Name != "allowed" { + t.Fatalf("List() = %v, want [allowed]", listed) + } + if authorizer.scopeVerb != auth.VerbList || authorizer.scopeType != "Harness" { + t.Fatalf("Scope() = (%q, %q), want (list, Harness)", authorizer.scopeVerb, authorizer.scopeType) + } +} + func TestScopedServiceRejectsInvalidScope(t *testing.T) { scheme := runtime.NewScheme() if err := v1alpha3.AddToScheme(scheme); err != nil { From b8044037c23c41950d24b6bea6e3bf90063f68c7 Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Thu, 3 Sep 2026 14:00:12 -0700 Subject: [PATCH 3/4] Simplify Kubernetes scope enforcement Signed-off-by: Jeremy Alvis --- .../references/scoped-authorization.md | 5 +- design/scoped-authorization.md | 11 ++- go/api/authorization/scope.go | 3 +- go/core/internal/grpcserver/agenttemplate.go | 4 +- .../grpcserver/agenttemplate_harness_test.go | 4 +- go/core/internal/grpcserver/harness.go | 4 +- go/core/internal/grpcserver/server.go | 4 +- go/core/internal/service/kubeauth/scope.go | 27 ++---- .../internal/service/kubeauth/scope_test.go | 27 ++---- go/core/internal/service/kubecrud/service.go | 89 ++++--------------- .../internal/service/kubecrud/service_test.go | 12 +-- go/core/pkg/app/app.go | 4 +- go/core/pkg/auth/auth.go | 3 +- 13 files changed, 58 insertions(+), 139 deletions(-) diff --git a/.claude/skills/kagent-dev/references/scoped-authorization.md b/.claude/skills/kagent-dev/references/scoped-authorization.md index 99f2de4be2..1e51cc02f4 100644 --- a/.claude/skills/kagent-dev/references/scoped-authorization.md +++ b/.claude/skills/kagent-dev/references/scoped-authorization.md @@ -11,7 +11,6 @@ An authorizer decides which resources a principal may access. It returns an `Aut - `ANY_OF` is an OR of non-empty clauses. - Each clause is an AND of non-empty predicates. - `IN` matches one of its non-empty values. -- `MISSING` matches an absent attribute and has no values. For example, this scope: @@ -55,7 +54,7 @@ Pass scopes as ordinary function arguments. Do not place policy decisions in req ## Service integration -- `AgentTemplate` and `Harness` use `kubecrud.NewScopedService`. Their gRPC servers accept the scoped service type, so they cannot be wired with the unscoped constructor accidentally. +- `AgentTemplate` and `Harness` use `kubecrud.NewService`, which requires a `CollectionAuthorizer` and always applies collection scopes. - `ModelConfig` requests require a `CollectionAuthorizer`; `Service.List` filters the returned Kubernetes list before transport conversion. - `ListConfiguredProviders` reads the separate `ModelProviderConfig` resource and remains outside this scope. - Legacy `AgentHarness` and `SandboxAgent` operations remain outside this scope. @@ -68,7 +67,7 @@ Test generic scope behavior once in the Kubernetes matcher: - `ALL`, `NONE`, and `ANY_OF`; - OR clauses and AND predicates; -- `IN` and `MISSING`; +- `IN`; - every invalid kind, attribute, operator, clause shape, and value shape. Each protected service then needs focused tests proving: diff --git a/design/scoped-authorization.md b/design/scoped-authorization.md index c36d7ce70f..22f00f2228 100644 --- a/design/scoped-authorization.md +++ b/design/scoped-authorization.md @@ -83,8 +83,7 @@ const ( type ScopeOperator string const ( - ScopeIn ScopeOperator = "IN" - ScopeMissing ScopeOperator = "MISSING" + ScopeIn ScopeOperator = "IN" ) type AuthorizationScope struct { @@ -107,7 +106,9 @@ type ScopePredicate struct { `ScopeAnyOf` joins clauses with OR. Each clause joins predicates with AND. -`ScopeIn` matches a listed value. `ScopeMissing` matches an absent attribute. +`ScopeIn` matches a listed value. + +The initial protected attributes, `namespace` and `name`, are always present and non-empty. An absent-attribute operator would therefore describe a state these resources cannot produce. Add another operator only when a protected resource introduces an attribute whose absence has authorization meaning. The scope contains no SQL, Kubernetes field paths, policy types, or backend expressions. @@ -164,7 +165,7 @@ Kagent will not define policy resources, subjects, grants, or access levels. Tests must cover `ScopeAll`, `ScopeNone`, and `ScopeAnyOf`. -Tests must verify OR clauses, AND predicates, `ScopeIn`, and `ScopeMissing`. +Tests must verify OR clauses, AND predicates, and `ScopeIn`. Tests must verify trusted attributes for each single-resource operation. @@ -192,3 +193,5 @@ Per-item checks after pagination produce incomplete pages and incorrect totals. Separate allowed-name and allowed-namespace lists can lose required AND relationships. Raw query fragments couple an `Authorizer` to storage and create an unsafe trust boundary. + +An absent-attribute predicate adds contract and validation complexity without matching any initial protected resource. diff --git a/go/api/authorization/scope.go b/go/api/authorization/scope.go index 190a987742..b477e8763b 100644 --- a/go/api/authorization/scope.go +++ b/go/api/authorization/scope.go @@ -16,8 +16,7 @@ const ( type ScopeOperator string const ( - ScopeIn ScopeOperator = "IN" - ScopeMissing ScopeOperator = "MISSING" + ScopeIn ScopeOperator = "IN" ) type AuthorizationScope struct { diff --git a/go/core/internal/grpcserver/agenttemplate.go b/go/core/internal/grpcserver/agenttemplate.go index 6c9300b182..2c9dc94d17 100644 --- a/go/core/internal/grpcserver/agenttemplate.go +++ b/go/core/internal/grpcserver/agenttemplate.go @@ -15,11 +15,11 @@ const agentTemplateKind = "AgentTemplate" type agentTemplateServer struct { apiv1alpha1.UnimplementedAgentTemplateServiceServer - service *kubecrud.ScopedService[*v1alpha3.AgentTemplate, *v1alpha3.AgentTemplateList] + service *kubecrud.Service[*v1alpha3.AgentTemplate, *v1alpha3.AgentTemplateList] maxMessageBytes int } -func newAgentTemplateServer(service *kubecrud.ScopedService[*v1alpha3.AgentTemplate, *v1alpha3.AgentTemplateList], maxMessageBytes int) *agentTemplateServer { +func newAgentTemplateServer(service *kubecrud.Service[*v1alpha3.AgentTemplate, *v1alpha3.AgentTemplateList], maxMessageBytes int) *agentTemplateServer { return &agentTemplateServer{service: service, maxMessageBytes: maxMessageBytes} } diff --git a/go/core/internal/grpcserver/agenttemplate_harness_test.go b/go/core/internal/grpcserver/agenttemplate_harness_test.go index 5cd63610db..8c9c13f3f4 100644 --- a/go/core/internal/grpcserver/agenttemplate_harness_test.go +++ b/go/core/internal/grpcserver/agenttemplate_harness_test.go @@ -39,8 +39,8 @@ func newTemplateAndHarnessConnection(t *testing.T, objects ...ctrlclient.Object) Listener: listener, Registerer: prometheus.NewRegistry(), Authenticator: &authimpl.UnsecureAuthenticator{}, - AgentTemplateService: kubecrud.NewScopedService(kubeClient, &authimpl.NoopAuthorizer{}, &v1alpha3.AgentTemplate{}, &v1alpha3.AgentTemplateList{}, "AgentTemplate"), - HarnessService: kubecrud.NewScopedService(kubeClient, &authimpl.NoopAuthorizer{}, &v1alpha3.Harness{}, &v1alpha3.HarnessList{}, "Harness"), + AgentTemplateService: kubecrud.NewService(kubeClient, &authimpl.NoopAuthorizer{}, &v1alpha3.AgentTemplate{}, &v1alpha3.AgentTemplateList{}, "AgentTemplate"), + HarnessService: kubecrud.NewService(kubeClient, &authimpl.NoopAuthorizer{}, &v1alpha3.Harness{}, &v1alpha3.HarnessList{}, "Harness"), }) if err != nil { t.Fatalf("New() error = %v", err) diff --git a/go/core/internal/grpcserver/harness.go b/go/core/internal/grpcserver/harness.go index 587601c86c..5753d98615 100644 --- a/go/core/internal/grpcserver/harness.go +++ b/go/core/internal/grpcserver/harness.go @@ -25,11 +25,11 @@ const ( type harnessServer struct { apiv1alpha1.UnimplementedHarnessServiceServer - service *kubecrud.ScopedService[*v1alpha3.Harness, *v1alpha3.HarnessList] + service *kubecrud.Service[*v1alpha3.Harness, *v1alpha3.HarnessList] maxMessageBytes int } -func newHarnessServer(service *kubecrud.ScopedService[*v1alpha3.Harness, *v1alpha3.HarnessList], maxMessageBytes int) *harnessServer { +func newHarnessServer(service *kubecrud.Service[*v1alpha3.Harness, *v1alpha3.HarnessList], maxMessageBytes int) *harnessServer { return &harnessServer{service: service, maxMessageBytes: maxMessageBytes} } diff --git a/go/core/internal/grpcserver/server.go b/go/core/internal/grpcserver/server.go index 3395334372..6b00cb7f2b 100644 --- a/go/core/internal/grpcserver/server.go +++ b/go/core/internal/grpcserver/server.go @@ -52,8 +52,8 @@ type Config struct { ShareStore ShareStore Registerer prometheus.Registerer AgentService *agentservice.Service - AgentTemplateService *kubecrud.ScopedService[*v1alpha3.AgentTemplate, *v1alpha3.AgentTemplateList] - HarnessService *kubecrud.ScopedService[*v1alpha3.Harness, *v1alpha3.HarnessList] + AgentTemplateService *kubecrud.Service[*v1alpha3.AgentTemplate, *v1alpha3.AgentTemplateList] + HarnessService *kubecrud.Service[*v1alpha3.Harness, *v1alpha3.HarnessList] ModelService *modelservice.Service ToolService *toolservice.Service PromptTemplateService *prompttemplateservice.Service diff --git a/go/core/internal/service/kubeauth/scope.go b/go/core/internal/service/kubeauth/scope.go index 036dfd4433..549d87cc0f 100644 --- a/go/core/internal/service/kubeauth/scope.go +++ b/go/core/internal/service/kubeauth/scope.go @@ -57,21 +57,15 @@ func ScopeMatcher(scope auth.AuthorizationScope) (func(metav1.Object) bool, erro if predicate.Attribute != auth.AttributeNamespace && predicate.Attribute != auth.AttributeName { return nil, fmt.Errorf("unsupported scope attribute %q", predicate.Attribute) } - switch predicate.Operator { - case auth.ScopeIn: - if len(predicate.Values) == 0 { - return nil, fmt.Errorf("scope predicate %d.%d requires at least one value", clauseIndex, predicateIndex) - } - if slices.Contains(predicate.Values, "") { - return nil, fmt.Errorf("scope predicate %d.%d contains an empty value", clauseIndex, predicateIndex) - } - case auth.ScopeMissing: - if len(predicate.Values) != 0 { - return nil, fmt.Errorf("scope predicate %d.%d must not contain values", clauseIndex, predicateIndex) - } - default: + if predicate.Operator != auth.ScopeIn { return nil, fmt.Errorf("unsupported scope operator %q", predicate.Operator) } + if len(predicate.Values) == 0 { + return nil, fmt.Errorf("scope predicate %d.%d requires at least one value", clauseIndex, predicateIndex) + } + if slices.Contains(predicate.Values, "") { + return nil, fmt.Errorf("scope predicate %d.%d contains an empty value", clauseIndex, predicateIndex) + } } } @@ -83,12 +77,7 @@ func ScopeMatcher(scope auth.AuthorizationScope) (func(metav1.Object) bool, erro if predicate.Attribute == auth.AttributeName { value = object.GetName() } - switch predicate.Operator { - case auth.ScopeIn: - matches = value != "" && slices.Contains(predicate.Values, value) - case auth.ScopeMissing: - matches = value == "" - } + matches = value != "" && slices.Contains(predicate.Values, value) if !matches { break } diff --git a/go/core/internal/service/kubeauth/scope_test.go b/go/core/internal/service/kubeauth/scope_test.go index 0cad484523..c266a8113a 100644 --- a/go/core/internal/service/kubeauth/scope_test.go +++ b/go/core/internal/service/kubeauth/scope_test.go @@ -39,12 +39,6 @@ func TestScopeMatcher(t *testing.T) { {Attribute: auth.AttributeName, Operator: auth.ScopeIn, Values: []string{"other"}}, }}}}, }, - { - name: "missing", - scope: auth.AuthorizationScope{Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{ - {Attribute: auth.AttributeNamespace, Operator: auth.ScopeMissing}, - }}}}, - }, } for _, test := range tests { @@ -58,31 +52,20 @@ func TestScopeMatcher(t *testing.T) { } }) } - - missingNamespace, err := ScopeMatcher(auth.AuthorizationScope{Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{ - Attribute: auth.AttributeNamespace, - Operator: auth.ScopeMissing, - }}}}}) - if err != nil { - t.Fatalf("ScopeMatcher() error = %v", err) - } - if !missingNamespace(&metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Name: "cluster-scoped"}}) { - t.Fatal("missing namespace did not match") - } } func TestScopeMatcherRejectsInvalidScopes(t *testing.T) { tests := []auth.AuthorizationScope{ {}, - {Kind: auth.ScopeAll, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: auth.AttributeName, Operator: auth.ScopeMissing}}}}}, - {Kind: auth.ScopeNone, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: auth.AttributeName, Operator: auth.ScopeMissing}}}}}, + {Kind: auth.ScopeAll, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: auth.AttributeName, Operator: auth.ScopeIn, Values: []string{"x"}}}}}}, + {Kind: auth.ScopeNone, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: auth.AttributeName, Operator: auth.ScopeIn, Values: []string{"x"}}}}}}, {Kind: auth.ScopeAnyOf}, {Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{}}}, {Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: "label", Operator: auth.ScopeIn, Values: []string{"x"}}}}}}, + {Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: auth.AttributeName, Operator: "MISSING"}}}}}, {Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: auth.AttributeName, Operator: "EQUALS", Values: []string{"x"}}}}}}, {Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: auth.AttributeName, Operator: auth.ScopeIn}}}}}, {Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: auth.AttributeName, Operator: auth.ScopeIn, Values: []string{""}}}}}}, - {Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{Attribute: auth.AttributeName, Operator: auth.ScopeMissing, Values: []string{"x"}}}}}}, } for index, scope := range tests { @@ -94,8 +77,8 @@ func TestScopeMatcherRejectsInvalidScopes(t *testing.T) { func TestResourceUsesObjectMetadata(t *testing.T) { object := &metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "agent-a"}} - resource := Resource("AgentHarness", object) - if resource.Type != "AgentHarness" || resource.Name != "team-a/agent-a" { + resource := Resource("Harness", object) + if resource.Type != "Harness" || resource.Name != "team-a/agent-a" { t.Fatalf("Resource() = %+v", resource) } if got := resource.Attributes[auth.AttributeNamespace]; len(got) != 1 || got[0] != "team-a" { diff --git a/go/core/internal/service/kubecrud/service.go b/go/core/internal/service/kubecrud/service.go index aa1963a756..edf62a7ade 100644 --- a/go/core/internal/service/kubecrud/service.go +++ b/go/core/internal/service/kubecrud/service.go @@ -11,7 +11,6 @@ import ( "github.com/kagent-dev/kagent/go/core/pkg/auth" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" utilvalidation "k8s.io/apimachinery/pkg/util/validation" @@ -27,19 +26,13 @@ type Service[T Object, L client.ObjectList] struct { client client.Client object T list L - authorizer auth.Authorizer - collection auth.CollectionAuthorizer + authorizer auth.CollectionAuthorizer resource string } -// ScopedService is a Kubernetes CRUD service whose collection operations require a scope. -type ScopedService[T Object, L client.ObjectList] struct { - *Service[T, L] -} - func NewService[T Object, L client.ObjectList]( client client.Client, - authorizer auth.Authorizer, + authorizer auth.CollectionAuthorizer, object T, list L, resource string, @@ -49,37 +42,17 @@ func NewService[T Object, L client.ObjectList]( } } -// NewScopedService constructs a service that applies collection scopes before returning objects. -func NewScopedService[T Object, L client.ObjectList]( - client client.Client, - authorizer auth.CollectionAuthorizer, - object T, - list L, - resource string, -) *ScopedService[T, L] { - service := NewService(client, authorizer, object, list, resource) - service.collection = authorizer - return &ScopedService[T, L]{Service: service} -} - func (s *Service[T, L]) List(ctx context.Context, namespace string) ([]T, error) { if namespace == "" { return nil, serviceerrors.NewInvalidArgument("namespace is required", nil) } - var matches func(metav1.Object) bool - if s.collection == nil { - if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: s.resource}); err != nil { - return nil, err - } - } else { - scope, err := s.scope(ctx) - if err != nil { - return nil, err - } - matches, err = kubeauth.ScopeMatcher(scope) - if err != nil { - return nil, serviceerrors.NewPermissionDenied("Not authorized", err) - } + scope, err := s.scope(ctx) + if err != nil { + return nil, err + } + matches, err := kubeauth.ScopeMatcher(scope) + if err != nil { + return nil, serviceerrors.NewPermissionDenied("Not authorized", err) } list := s.list.DeepCopyObject().(L) if err := s.client.List(ctx, list, client.InNamespace(namespace)); err != nil { @@ -88,7 +61,7 @@ func (s *Service[T, L]) List(ctx context.Context, namespace string) ([]T, error) items := make([]T, 0) if err := meta.EachListItem(list, func(item runtime.Object) error { object := item.(T) - if matches == nil || matches(object) { + if matches(object) { items = append(items, object) } return nil @@ -104,12 +77,6 @@ func (s *Service[T, L]) Get(ctx context.Context, ref types.NamespacedName) (T, e if err := s.validateRef(ref); err != nil { return zero, err } - if s.collection == nil { - if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: s.resource, Name: ref.String()}); err != nil { - return zero, err - } - return s.get(ctx, ref) - } object, err := s.get(ctx, ref) if err != nil { return zero, err @@ -130,11 +97,7 @@ func (s *Service[T, L]) Create(ctx context.Context, object T) (T, error) { if err := s.validateNewRef(ref); err != nil { return zero, err } - resource := auth.Resource{Type: s.resource, Name: ref.String()} - if s.collection != nil { - resource = kubeauth.Resource(s.resource, object) - } - if err := s.authorize(ctx, auth.VerbCreate, resource); err != nil { + if err := s.authorize(ctx, auth.VerbCreate, kubeauth.Resource(s.resource, object)); err != nil { return zero, err } if err := s.client.Create(ctx, object); err != nil { @@ -156,25 +119,16 @@ func (s *Service[T, L]) Update(ctx context.Context, ref types.NamespacedName, ap if err := s.validateRef(ref); err != nil { return zero, err } - if s.collection == nil { - if err := s.authorize(ctx, auth.VerbUpdate, auth.Resource{Type: s.resource, Name: ref.String()}); err != nil { - return zero, err - } - } object, err := s.get(ctx, ref) if err != nil { return zero, err } - if s.collection != nil { - if err := s.authorize(ctx, auth.VerbUpdate, kubeauth.Resource(s.resource, object)); err != nil { - return zero, err - } + if err := s.authorize(ctx, auth.VerbUpdate, kubeauth.Resource(s.resource, object)); err != nil { + return zero, err } apply(object) - if s.collection != nil { - if err := s.authorize(ctx, auth.VerbUpdate, kubeauth.Resource(s.resource, object)); err != nil { - return zero, err - } + if err := s.authorize(ctx, auth.VerbUpdate, kubeauth.Resource(s.resource, object)); err != nil { + return zero, err } if err := s.client.Update(ctx, object); err != nil { if apierrors.IsInvalid(err) { @@ -189,19 +143,12 @@ func (s *Service[T, L]) Delete(ctx context.Context, ref types.NamespacedName) er if err := s.validateRef(ref); err != nil { return err } - if s.collection == nil { - if err := s.authorize(ctx, auth.VerbDelete, auth.Resource{Type: s.resource, Name: ref.String()}); err != nil { - return err - } - } object, err := s.get(ctx, ref) if err != nil { return err } - if s.collection != nil { - if err := s.authorize(ctx, auth.VerbDelete, kubeauth.Resource(s.resource, object)); err != nil { - return err - } + if err := s.authorize(ctx, auth.VerbDelete, kubeauth.Resource(s.resource, object)); err != nil { + return err } if err := s.client.Delete(ctx, object); err != nil { return serviceerrors.NewInternal("Failed to delete "+s.resource, err) @@ -214,7 +161,7 @@ func (s *Service[T, L]) scope(ctx context.Context) (auth.AuthorizationScope, err if !ok || session == nil { return auth.AuthorizationScope{}, serviceerrors.NewUnauthenticated("Failed to get authenticated principal", fmt.Errorf("no session found")) } - scope, err := s.collection.Scope(ctx, session.Principal(), auth.VerbList, s.resource) + scope, err := s.authorizer.Scope(ctx, session.Principal(), auth.VerbList, s.resource) if err != nil { return auth.AuthorizationScope{}, serviceerrors.NewPermissionDenied("Not authorized", err) } diff --git a/go/core/internal/service/kubecrud/service_test.go b/go/core/internal/service/kubecrud/service_test.go index d20cdd1a6b..f891e21d76 100644 --- a/go/core/internal/service/kubecrud/service_test.go +++ b/go/core/internal/service/kubecrud/service_test.go @@ -42,7 +42,7 @@ func (a *recordingAuthorizer) Scope(_ context.Context, _ auth.Principal, verb au return a.scope, nil } -func TestScopedServiceFiltersBeforeSortingAndUsesTrustedAttributes(t *testing.T) { +func TestServiceFiltersBeforeSortingAndUsesTrustedAttributes(t *testing.T) { scheme := runtime.NewScheme() if err := v1alpha3.AddToScheme(scheme); err != nil { t.Fatal(err) @@ -61,7 +61,7 @@ func TestScopedServiceFiltersBeforeSortingAndUsesTrustedAttributes(t *testing.T) &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "a"}}, &v1alpha3.AgentTemplate{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "mutable"}}, ).Build() - service := NewScopedService(kubeClient, authorizer, &v1alpha3.AgentTemplate{}, &v1alpha3.AgentTemplateList{}, "AgentTemplate") + service := NewService(kubeClient, authorizer, &v1alpha3.AgentTemplate{}, &v1alpha3.AgentTemplateList{}, "AgentTemplate") ctx := auth.AuthSessionTo(t.Context(), testSession{}) listed, err := service.List(ctx, "team") @@ -108,7 +108,7 @@ func TestScopedServiceFiltersBeforeSortingAndUsesTrustedAttributes(t *testing.T) } } -func TestScopedHarnessServiceFiltersList(t *testing.T) { +func TestHarnessServiceFiltersList(t *testing.T) { scheme := runtime.NewScheme() if err := v1alpha3.AddToScheme(scheme); err != nil { t.Fatal(err) @@ -125,7 +125,7 @@ func TestScopedHarnessServiceFiltersList(t *testing.T) { &v1alpha3.Harness{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "allowed"}}, &v1alpha3.Harness{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "denied"}}, ).Build() - service := NewScopedService(kubeClient, authorizer, &v1alpha3.Harness{}, &v1alpha3.HarnessList{}, "Harness") + service := NewService(kubeClient, authorizer, &v1alpha3.Harness{}, &v1alpha3.HarnessList{}, "Harness") ctx := auth.AuthSessionTo(t.Context(), testSession{}) listed, err := service.List(ctx, "team") @@ -140,13 +140,13 @@ func TestScopedHarnessServiceFiltersList(t *testing.T) { } } -func TestScopedServiceRejectsInvalidScope(t *testing.T) { +func TestServiceRejectsInvalidScope(t *testing.T) { scheme := runtime.NewScheme() if err := v1alpha3.AddToScheme(scheme); err != nil { t.Fatal(err) } authorizer := &recordingAuthorizer{scope: auth.AuthorizationScope{Kind: auth.ScopeAnyOf}} - service := NewScopedService( + service := NewService( fake.NewClientBuilder().WithScheme(scheme).Build(), authorizer, &v1alpha3.AgentTemplate{}, diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index ff27c0d8e7..957d92116f 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -296,8 +296,8 @@ func Run(ctx context.Context, opts Options) error { AgentInstanceService: instances, // Both halves of the pair CreateAgentInstance names. Without these two // the only way to author a Harness or an AgentTemplate is kubectl. - AgentTemplateService: kubecrud.NewScopedService(manager.GetClient(), authorizer, &kagentv1alpha3.AgentTemplate{}, &kagentv1alpha3.AgentTemplateList{}, "AgentTemplate"), - HarnessService: kubecrud.NewScopedService(manager.GetClient(), authorizer, &kagentv1alpha3.Harness{}, &kagentv1alpha3.HarnessList{}, "Harness"), + AgentTemplateService: kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.AgentTemplate{}, &kagentv1alpha3.AgentTemplateList{}, "AgentTemplate"), + HarnessService: kubecrud.NewService(manager.GetClient(), authorizer, &kagentv1alpha3.Harness{}, &kagentv1alpha3.HarnessList{}, "Harness"), CheckpointService: checkpoints, A2AHandler: gateway, }) diff --git a/go/core/pkg/auth/auth.go b/go/core/pkg/auth/auth.go index 23c3d42d97..56e37e4a3c 100644 --- a/go/core/pkg/auth/auth.go +++ b/go/core/pkg/auth/auth.go @@ -95,8 +95,7 @@ const ( type ScopeOperator = apiauthorization.ScopeOperator const ( - ScopeIn = apiauthorization.ScopeIn - ScopeMissing = apiauthorization.ScopeMissing + ScopeIn = apiauthorization.ScopeIn ) type AuthorizationScope = apiauthorization.AuthorizationScope From 502bfbd17637d9c14bc7ddfe66f54a3cf317cd9e Mon Sep 17 00:00:00 2001 From: Jeremy Alvis Date: Thu, 3 Sep 2026 14:22:45 -0700 Subject: [PATCH 4/4] Expose scoped resource capabilities Signed-off-by: Jeremy Alvis --- design/scoped-authorization.md | 15 +++++ .../kagent/api/v1alpha1/agent_templates.pb.go | 38 +++++++++++-- .../gen/kagent/api/v1alpha1/harnesses.pb.go | 28 ++++++++-- go/api/gen/kagent/api/v1alpha1/models.pb.go | 36 +++++++++++- go/core/internal/grpcserver/agenttemplate.go | 30 ++++++++-- .../grpcserver/agenttemplate_harness_test.go | 6 ++ go/core/internal/grpcserver/harness.go | 17 ++++-- go/core/internal/grpcserver/model.go | 32 ++++++++--- go/core/internal/grpcserver/model_test.go | 3 + .../grpcserver/resource_capabilities.go | 55 ++++++++++++++++++ .../grpcserver/resource_capabilities_test.go | 36 ++++++++++++ go/core/internal/service/kubecrud/service.go | 6 +- go/core/internal/service/model/service.go | 6 +- .../kagent/api/v1alpha1/agent_templates.proto | 4 ++ proto/kagent/api/v1alpha1/harnesses.proto | 3 + proto/kagent/api/v1alpha1/models.proto | 3 + ui/src/api/client.ts | 29 ++++++++-- ui/src/api/domain/agentTemplates.ts | 3 + ui/src/api/domain/common.ts | 4 ++ ui/src/api/domain/harnesses.ts | 2 + ui/src/api/domain/models.ts | 2 + ui/src/api/grpc/operations.ts | 56 ++++++++++++++++--- ui/src/api/hooks/useAgentBuildingBlocks.ts | 45 +++++++++------ ui/src/api/hooks/useApiResource.ts | 21 +++++++ ui/src/api/hooks/useModels.ts | 11 +++- ui/src/api/operations.test.ts | 17 +++++- ui/src/api/operations.ts | 13 ++++- .../kagent/api/v1alpha1/agent_templates_pb.ts | 17 +++++- .../kagent/api/v1alpha1/harnesses_pb.ts | 12 +++- .../kagent/api/v1alpha1/models_pb.ts | 17 +++++- ui/src/mocks/mockBackend.test.ts | 2 +- ui/src/mocks/transport.ts | 13 ++++- ui/src/pages/AgentTemplateDetailsPage.tsx | 2 + ui/src/pages/AgentTemplateNewPage.tsx | 2 +- ui/src/pages/AgentTemplatesPage.tsx | 1 + ui/src/pages/ModelEditPage.tsx | 10 +++- ui/src/pages/ModelNewPage.tsx | 17 +++++- ui/src/pages/ModelsPage.tsx | 40 ++++++++++--- ui/src/pages/agents/AgentsLandingPage.tsx | 51 +++++++++++++++-- ui/src/pages/agents/HarnessNewPage.tsx | 5 +- ui/src/pages/agents/HarnessesTab.tsx | 2 +- 41 files changed, 610 insertions(+), 102 deletions(-) create mode 100644 go/core/internal/grpcserver/resource_capabilities.go create mode 100644 go/core/internal/grpcserver/resource_capabilities_test.go diff --git a/design/scoped-authorization.md b/design/scoped-authorization.md index 22f00f2228..0d2b3d0c70 100644 --- a/design/scoped-authorization.md +++ b/design/scoped-authorization.md @@ -12,6 +12,8 @@ Kagent supplies trusted resource attributes and enforces authorization before it An `Authorizer` implementation defines roles, policies, identity rules, and catalog keys. +The protected RPC responses will also report the actions that the caller can use. + ## Initial scope | Resource type | Operations | Attributes | @@ -112,6 +114,18 @@ The initial protected attributes, `namespace` and `name`, are always present and The scope contains no SQL, Kubernetes field paths, policy types, or backend expressions. +## Response capabilities + +Each protected list response will include `can_create`. + +Each returned `AgentTemplate` and `ModelConfig` will include `can_update` and `can_delete`. + +Each returned `Harness` will include `can_delete` because the service has no update RPC. + +Kagent will calculate these fields from `AuthorizationScope` values for each action. + +The fields help a client control its actions. They do not replace authorization on an RPC. + ## Single-resource enforcement For a read, the service will load the stored resource before authorization. @@ -185,6 +199,7 @@ Tests must verify fail-closed behavior for invalid scopes. - [x] Populate trusted attributes for reads and writes. - [x] Filter each protected list before response construction. - [x] Add focused service and matcher tests. +- [x] Add action capabilities to the protected RPC responses. ## Alternatives diff --git a/go/api/gen/kagent/api/v1alpha1/agent_templates.pb.go b/go/api/gen/kagent/api/v1alpha1/agent_templates.pb.go index dc557ee3bb..08d488ecdb 100644 --- a/go/api/gen/kagent/api/v1alpha1/agent_templates.pb.go +++ b/go/api/gen/kagent/api/v1alpha1/agent_templates.pb.go @@ -38,6 +38,8 @@ type AgentTemplate struct { // caller may legally pair with this template in CreateAgentInstance, and it // is derivable only from the Harness side, so a caller cannot compute it. AdmittingHarnesses []string `protobuf:"bytes,5,rep,name=admitting_harnesses,json=admittingHarnesses,proto3" json:"admitting_harnesses,omitempty"` + CanUpdate bool `protobuf:"varint,6,opt,name=can_update,json=canUpdate,proto3" json:"can_update,omitempty"` + CanDelete bool `protobuf:"varint,7,opt,name=can_delete,json=canDelete,proto3" json:"can_delete,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -107,6 +109,20 @@ func (x *AgentTemplate) GetAdmittingHarnesses() []string { return nil } +func (x *AgentTemplate) GetCanUpdate() bool { + if x != nil { + return x.CanUpdate + } + return false +} + +func (x *AgentTemplate) GetCanDelete() bool { + if x != nil { + return x.CanDelete + } + return false +} + type ListAgentTemplatesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` @@ -154,6 +170,7 @@ func (x *ListAgentTemplatesRequest) GetNamespace() string { type ListAgentTemplatesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` AgentTemplates []*AgentTemplate `protobuf:"bytes,1,rep,name=agent_templates,json=agentTemplates,proto3" json:"agent_templates,omitempty"` + CanCreate bool `protobuf:"varint,2,opt,name=can_create,json=canCreate,proto3" json:"can_create,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -195,6 +212,13 @@ func (x *ListAgentTemplatesResponse) GetAgentTemplates() []*AgentTemplate { return nil } +func (x *ListAgentTemplatesResponse) GetCanCreate() bool { + if x != nil { + return x.CanCreate + } + return false +} + type GetAgentTemplateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Ref *ResourceReference `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"` @@ -559,17 +583,23 @@ var File_kagent_api_v1alpha1_agent_templates_proto protoreflect.FileDescriptor const file_kagent_api_v1alpha1_agent_templates_proto_rawDesc = "" + "\n" + - ")kagent/api/v1alpha1/agent_templates.proto\x12\x13kagent.api.v1alpha1\x1a kagent/api/v1alpha1/common.proto\"\xb1\x02\n" + + ")kagent/api/v1alpha1/agent_templates.proto\x12\x13kagent.api.v1alpha1\x1a kagent/api/v1alpha1/common.proto\"\xef\x02\n" + "\rAgentTemplate\x128\n" + "\x03ref\x18\x01 \x01(\v2&.kagent.api.v1alpha1.ResourceReferenceR\x03ref\x12A\n" + "\bresource\x18\x02 \x01(\v2%.kagent.api.v1alpha1.StructuredObjectR\bresource\x12P\n" + "\x10model_config_ref\x18\x03 \x01(\v2&.kagent.api.v1alpha1.ResourceReferenceR\x0emodelConfigRef\x12 \n" + "\vdescription\x18\x04 \x01(\tR\vdescription\x12/\n" + - "\x13admitting_harnesses\x18\x05 \x03(\tR\x12admittingHarnesses\"9\n" + + "\x13admitting_harnesses\x18\x05 \x03(\tR\x12admittingHarnesses\x12\x1d\n" + + "\n" + + "can_update\x18\x06 \x01(\bR\tcanUpdate\x12\x1d\n" + + "\n" + + "can_delete\x18\a \x01(\bR\tcanDelete\"9\n" + "\x19ListAgentTemplatesRequest\x12\x1c\n" + - "\tnamespace\x18\x01 \x01(\tR\tnamespace\"i\n" + + "\tnamespace\x18\x01 \x01(\tR\tnamespace\"\x88\x01\n" + "\x1aListAgentTemplatesResponse\x12K\n" + - "\x0fagent_templates\x18\x01 \x03(\v2\".kagent.api.v1alpha1.AgentTemplateR\x0eagentTemplates\"S\n" + + "\x0fagent_templates\x18\x01 \x03(\v2\".kagent.api.v1alpha1.AgentTemplateR\x0eagentTemplates\x12\x1d\n" + + "\n" + + "can_create\x18\x02 \x01(\bR\tcanCreate\"S\n" + "\x17GetAgentTemplateRequest\x128\n" + "\x03ref\x18\x01 \x01(\v2&.kagent.api.v1alpha1.ResourceReferenceR\x03ref\"e\n" + "\x18GetAgentTemplateResponse\x12I\n" + diff --git a/go/api/gen/kagent/api/v1alpha1/harnesses.pb.go b/go/api/gen/kagent/api/v1alpha1/harnesses.pb.go index 2cb2082b02..cf46193057 100644 --- a/go/api/gen/kagent/api/v1alpha1/harnesses.pb.go +++ b/go/api/gen/kagent/api/v1alpha1/harnesses.pb.go @@ -36,6 +36,7 @@ type Harness struct { // Ready mirrors the Ready status condition. False also covers a Harness the // controller has not yet observed. Ready bool `protobuf:"varint,5,opt,name=ready,proto3" json:"ready,omitempty"` + CanDelete bool `protobuf:"varint,6,opt,name=can_delete,json=canDelete,proto3" json:"can_delete,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -105,6 +106,13 @@ func (x *Harness) GetReady() bool { return false } +func (x *Harness) GetCanDelete() bool { + if x != nil { + return x.CanDelete + } + return false +} + type ListHarnessesRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Namespace string `protobuf:"bytes,1,opt,name=namespace,proto3" json:"namespace,omitempty"` @@ -152,6 +160,7 @@ func (x *ListHarnessesRequest) GetNamespace() string { type ListHarnessesResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Harnesses []*Harness `protobuf:"bytes,1,rep,name=harnesses,proto3" json:"harnesses,omitempty"` + CanCreate bool `protobuf:"varint,2,opt,name=can_create,json=canCreate,proto3" json:"can_create,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -193,6 +202,13 @@ func (x *ListHarnessesResponse) GetHarnesses() []*Harness { return nil } +func (x *ListHarnessesResponse) GetCanCreate() bool { + if x != nil { + return x.CanCreate + } + return false +} + type CreateHarnessRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Ref *ResourceReference `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"` @@ -373,17 +389,21 @@ var File_kagent_api_v1alpha1_harnesses_proto protoreflect.FileDescriptor const file_kagent_api_v1alpha1_harnesses_proto_rawDesc = "" + "\n" + - "#kagent/api/v1alpha1/harnesses.proto\x12\x13kagent.api.v1alpha1\x1a kagent/api/v1alpha1/common.proto\"\xdd\x01\n" + + "#kagent/api/v1alpha1/harnesses.proto\x12\x13kagent.api.v1alpha1\x1a kagent/api/v1alpha1/common.proto\"\xfc\x01\n" + "\aHarness\x128\n" + "\x03ref\x18\x01 \x01(\v2&.kagent.api.v1alpha1.ResourceReferenceR\x03ref\x12A\n" + "\bresource\x18\x02 \x01(\v2%.kagent.api.v1alpha1.StructuredObjectR\bresource\x12\x18\n" + "\aruntime\x18\x03 \x01(\tR\aruntime\x12%\n" + "\x0eworkload_image\x18\x04 \x01(\tR\rworkloadImage\x12\x14\n" + - "\x05ready\x18\x05 \x01(\bR\x05ready\"4\n" + + "\x05ready\x18\x05 \x01(\bR\x05ready\x12\x1d\n" + + "\n" + + "can_delete\x18\x06 \x01(\bR\tcanDelete\"4\n" + "\x14ListHarnessesRequest\x12\x1c\n" + - "\tnamespace\x18\x01 \x01(\tR\tnamespace\"S\n" + + "\tnamespace\x18\x01 \x01(\tR\tnamespace\"r\n" + "\x15ListHarnessesResponse\x12:\n" + - "\tharnesses\x18\x01 \x03(\v2\x1c.kagent.api.v1alpha1.HarnessR\tharnesses\"\x93\x01\n" + + "\tharnesses\x18\x01 \x03(\v2\x1c.kagent.api.v1alpha1.HarnessR\tharnesses\x12\x1d\n" + + "\n" + + "can_create\x18\x02 \x01(\bR\tcanCreate\"\x93\x01\n" + "\x14CreateHarnessRequest\x128\n" + "\x03ref\x18\x01 \x01(\v2&.kagent.api.v1alpha1.ResourceReferenceR\x03ref\x12A\n" + "\bresource\x18\x02 \x01(\v2%.kagent.api.v1alpha1.StructuredObjectR\bresource\"O\n" + diff --git a/go/api/gen/kagent/api/v1alpha1/models.pb.go b/go/api/gen/kagent/api/v1alpha1/models.pb.go index 3a81b089cf..d5eb6c00c1 100644 --- a/go/api/gen/kagent/api/v1alpha1/models.pb.go +++ b/go/api/gen/kagent/api/v1alpha1/models.pb.go @@ -121,6 +121,8 @@ type ModelConfig struct { state protoimpl.MessageState `protogen:"open.v1"` Ref *ResourceReference `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"` Resource *StructuredObject `protobuf:"bytes,2,opt,name=resource,proto3" json:"resource,omitempty"` + CanUpdate bool `protobuf:"varint,3,opt,name=can_update,json=canUpdate,proto3" json:"can_update,omitempty"` + CanDelete bool `protobuf:"varint,4,opt,name=can_delete,json=canDelete,proto3" json:"can_delete,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -169,9 +171,24 @@ func (x *ModelConfig) GetResource() *StructuredObject { return nil } +func (x *ModelConfig) GetCanUpdate() bool { + if x != nil { + return x.CanUpdate + } + return false +} + +func (x *ModelConfig) GetCanDelete() bool { + if x != nil { + return x.CanDelete + } + return false +} + type ListModelConfigsResponse struct { state protoimpl.MessageState `protogen:"open.v1"` ModelConfigs []*ModelConfig `protobuf:"bytes,1,rep,name=model_configs,json=modelConfigs,proto3" json:"model_configs,omitempty"` + CanCreate bool `protobuf:"varint,2,opt,name=can_create,json=canCreate,proto3" json:"can_create,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -213,6 +230,13 @@ func (x *ListModelConfigsResponse) GetModelConfigs() []*ModelConfig { return nil } +func (x *ListModelConfigsResponse) GetCanCreate() bool { + if x != nil { + return x.CanCreate + } + return false +} + type GetModelConfigRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Ref *ResourceReference `protobuf:"bytes,1,opt,name=ref,proto3" json:"ref,omitempty"` @@ -1270,12 +1294,18 @@ const file_kagent_api_v1alpha1_models_proto_rawDesc = "" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x10\n" + "\x03key\x18\x02 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x03 \x01(\tR\x05value\"\x19\n" + - "\x17ListModelConfigsRequest\"\x8a\x01\n" + + "\x17ListModelConfigsRequest\"\xc8\x01\n" + "\vModelConfig\x128\n" + "\x03ref\x18\x01 \x01(\v2&.kagent.api.v1alpha1.ResourceReferenceR\x03ref\x12A\n" + - "\bresource\x18\x02 \x01(\v2%.kagent.api.v1alpha1.StructuredObjectR\bresource\"a\n" + + "\bresource\x18\x02 \x01(\v2%.kagent.api.v1alpha1.StructuredObjectR\bresource\x12\x1d\n" + + "\n" + + "can_update\x18\x03 \x01(\bR\tcanUpdate\x12\x1d\n" + + "\n" + + "can_delete\x18\x04 \x01(\bR\tcanDelete\"\x80\x01\n" + "\x18ListModelConfigsResponse\x12E\n" + - "\rmodel_configs\x18\x01 \x03(\v2 .kagent.api.v1alpha1.ModelConfigR\fmodelConfigs\"Q\n" + + "\rmodel_configs\x18\x01 \x03(\v2 .kagent.api.v1alpha1.ModelConfigR\fmodelConfigs\x12\x1d\n" + + "\n" + + "can_create\x18\x02 \x01(\bR\tcanCreate\"Q\n" + "\x15GetModelConfigRequest\x128\n" + "\x03ref\x18\x01 \x01(\v2&.kagent.api.v1alpha1.ResourceReferenceR\x03ref\"]\n" + "\x16GetModelConfigResponse\x12C\n" + diff --git a/go/core/internal/grpcserver/agenttemplate.go b/go/core/internal/grpcserver/agenttemplate.go index 2c9dc94d17..ced9123a25 100644 --- a/go/core/internal/grpcserver/agenttemplate.go +++ b/go/core/internal/grpcserver/agenttemplate.go @@ -28,15 +28,19 @@ func (s *agentTemplateServer) ListAgentTemplates(ctx context.Context, request *a if err != nil { return nil, err } + capabilities, err := loadResourceCapabilities(ctx, s.service.Scope, true) + if err != nil { + return nil, err + } templates := make([]*apiv1alpha1.AgentTemplate, 0, len(items)) for _, item := range items { - template, err := s.agentTemplate(item) + template, err := s.agentTemplate(item, capabilities) if err != nil { return nil, err } templates = append(templates, template) } - return &apiv1alpha1.ListAgentTemplatesResponse{AgentTemplates: templates}, nil + return &apiv1alpha1.ListAgentTemplatesResponse{AgentTemplates: templates, CanCreate: capabilities.canCreate}, nil } func (s *agentTemplateServer) GetAgentTemplate(ctx context.Context, request *apiv1alpha1.GetAgentTemplateRequest) (*apiv1alpha1.GetAgentTemplateResponse, error) { @@ -48,7 +52,11 @@ func (s *agentTemplateServer) GetAgentTemplate(ctx context.Context, request *api if err != nil { return nil, err } - template, err := s.agentTemplate(result) + capabilities, err := loadResourceCapabilities(ctx, s.service.Scope, true) + if err != nil { + return nil, err + } + template, err := s.agentTemplate(result, capabilities) if err != nil { return nil, err } @@ -65,7 +73,11 @@ func (s *agentTemplateServer) CreateAgentTemplate(ctx context.Context, request * if err != nil { return nil, err } - template, err := s.agentTemplate(result) + capabilities, err := loadResourceCapabilities(ctx, s.service.Scope, true) + if err != nil { + return nil, err + } + template, err := s.agentTemplate(result, capabilities) if err != nil { return nil, err } @@ -87,7 +99,11 @@ func (s *agentTemplateServer) UpdateAgentTemplate(ctx context.Context, request * if err != nil { return nil, err } - template, err := s.agentTemplate(result) + capabilities, err := loadResourceCapabilities(ctx, s.service.Scope, true) + if err != nil { + return nil, err + } + template, err := s.agentTemplate(result, capabilities) if err != nil { return nil, err } @@ -105,7 +121,7 @@ func (s *agentTemplateServer) DeleteAgentTemplate(ctx context.Context, request * return &apiv1alpha1.DeleteAgentTemplateResponse{}, nil } -func (s *agentTemplateServer) agentTemplate(template *v1alpha3.AgentTemplate) (*apiv1alpha1.AgentTemplate, error) { +func (s *agentTemplateServer) agentTemplate(template *v1alpha3.AgentTemplate, capabilities resourceCapabilities) (*apiv1alpha1.AgentTemplate, error) { resource, err := structuredobject.FromGo(template, v1alpha3.GroupVersion.String(), agentTemplateKind, s.maxMessageBytes) if err != nil { return nil, serviceerrors.NewInternal("Failed to encode AgentTemplate resource", err) @@ -126,6 +142,8 @@ func (s *agentTemplateServer) agentTemplate(template *v1alpha3.AgentTemplate) (* Resource: resource, Description: template.Spec.Description, AdmittingHarnesses: admitting, + CanUpdate: capabilities.canUpdate(template), + CanDelete: capabilities.canDelete(template), }, nil } diff --git a/go/core/internal/grpcserver/agenttemplate_harness_test.go b/go/core/internal/grpcserver/agenttemplate_harness_test.go index 8c9c13f3f4..9e6032c3fc 100644 --- a/go/core/internal/grpcserver/agenttemplate_harness_test.go +++ b/go/core/internal/grpcserver/agenttemplate_harness_test.go @@ -165,6 +165,9 @@ func TestAgentTemplateServiceGeneratedClient(t *testing.T) { if len(listed.GetAgentTemplates()) != 2 { t.Fatalf("ListAgentTemplates() count = %d, want 2", len(listed.GetAgentTemplates())) } + if !listed.GetCanCreate() || !listed.GetAgentTemplates()[0].GetCanUpdate() || !listed.GetAgentTemplates()[0].GetCanDelete() { + t.Fatal("ListAgentTemplates() did not report no-op authorizer capabilities") + } if name := listed.GetAgentTemplates()[0].GetRef().GetName(); name != "a-created" { t.Fatalf("ListAgentTemplates()[0] = %q, want a-created first", name) } @@ -243,6 +246,9 @@ func TestHarnessServiceGeneratedClient(t *testing.T) { if len(listed.GetHarnesses()) != 2 { t.Fatalf("ListHarnesses() count = %d, want 2", len(listed.GetHarnesses())) } + if !listed.GetCanCreate() || !listed.GetHarnesses()[0].GetCanDelete() { + t.Fatal("ListHarnesses() did not report no-op authorizer capabilities") + } if !listed.GetHarnesses()[1].GetReady() { t.Fatal("ListHarnesses()[1].ready = false, want the Ready condition reflected") } diff --git a/go/core/internal/grpcserver/harness.go b/go/core/internal/grpcserver/harness.go index 5753d98615..d9d8b409be 100644 --- a/go/core/internal/grpcserver/harness.go +++ b/go/core/internal/grpcserver/harness.go @@ -38,15 +38,19 @@ func (s *harnessServer) ListHarnesses(ctx context.Context, request *apiv1alpha1. if err != nil { return nil, err } + capabilities, err := loadResourceCapabilities(ctx, s.service.Scope, false) + if err != nil { + return nil, err + } harnesses := make([]*apiv1alpha1.Harness, 0, len(items)) for _, item := range items { - encoded, err := s.harness(item) + encoded, err := s.harness(item, capabilities) if err != nil { return nil, err } harnesses = append(harnesses, encoded) } - return &apiv1alpha1.ListHarnessesResponse{Harnesses: harnesses}, nil + return &apiv1alpha1.ListHarnessesResponse{Harnesses: harnesses, CanCreate: capabilities.canCreate}, nil } func (s *harnessServer) CreateHarness(ctx context.Context, request *apiv1alpha1.CreateHarnessRequest) (*apiv1alpha1.CreateHarnessResponse, error) { @@ -59,7 +63,11 @@ func (s *harnessServer) CreateHarness(ctx context.Context, request *apiv1alpha1. if err != nil { return nil, err } - encoded, err := s.harness(result) + capabilities, err := loadResourceCapabilities(ctx, s.service.Scope, false) + if err != nil { + return nil, err + } + encoded, err := s.harness(result, capabilities) if err != nil { return nil, err } @@ -77,7 +85,7 @@ func (s *harnessServer) DeleteHarness(ctx context.Context, request *apiv1alpha1. return &apiv1alpha1.DeleteHarnessResponse{}, nil } -func (s *harnessServer) harness(object *v1alpha3.Harness) (*apiv1alpha1.Harness, error) { +func (s *harnessServer) harness(object *v1alpha3.Harness, capabilities resourceCapabilities) (*apiv1alpha1.Harness, error) { resource, err := structuredobject.FromGo(object, v1alpha3.GroupVersion.String(), harnessKind, s.maxMessageBytes) if err != nil { return nil, serviceerrors.NewInternal("Failed to encode Harness resource", err) @@ -88,6 +96,7 @@ func (s *harnessServer) harness(object *v1alpha3.Harness) (*apiv1alpha1.Harness, Runtime: harnessRuntime(object), WorkloadImage: object.Spec.Workload.Image, Ready: meta.IsStatusConditionTrue(object.Status.Conditions, v1alpha3.HarnessConditionTypeReady), + CanDelete: capabilities.canDelete(object), }, nil } diff --git a/go/core/internal/grpcserver/model.go b/go/core/internal/grpcserver/model.go index f1bdce04b3..ce8aa9546d 100644 --- a/go/core/internal/grpcserver/model.go +++ b/go/core/internal/grpcserver/model.go @@ -30,16 +30,20 @@ func (s *modelServer) ListModelConfigs(ctx context.Context, _ *apiv1alpha1.ListM if err != nil { return nil, err } + capabilities, err := loadResourceCapabilities(ctx, s.service.Scope, true) + if err != nil { + return nil, err + } modelConfigs := make([]*apiv1alpha1.ModelConfig, 0, len(result.Items)) for index := range result.Items { - modelConfig, err := s.modelConfig(&result.Items[index]) + modelConfig, err := s.modelConfig(&result.Items[index], capabilities) if err != nil { return nil, err } modelConfigs = append(modelConfigs, modelConfig) } - return &apiv1alpha1.ListModelConfigsResponse{ModelConfigs: modelConfigs}, nil + return &apiv1alpha1.ListModelConfigsResponse{ModelConfigs: modelConfigs, CanCreate: capabilities.canCreate}, nil } func (s *modelServer) GetModelConfig(ctx context.Context, request *apiv1alpha1.GetModelConfigRequest) (*apiv1alpha1.GetModelConfigResponse, error) { @@ -51,7 +55,11 @@ func (s *modelServer) GetModelConfig(ctx context.Context, request *apiv1alpha1.G if err != nil { return nil, err } - modelConfig, err := s.modelConfig(result) + capabilities, err := loadResourceCapabilities(ctx, s.service.Scope, true) + if err != nil { + return nil, err + } + modelConfig, err := s.modelConfig(result, capabilities) if err != nil { return nil, err } @@ -76,7 +84,11 @@ func (s *modelServer) CreateModelConfig(ctx context.Context, request *apiv1alpha if err != nil { return nil, err } - modelConfig, err := s.modelConfig(result) + capabilities, err := loadResourceCapabilities(ctx, s.service.Scope, true) + if err != nil { + return nil, err + } + modelConfig, err := s.modelConfig(result, capabilities) if err != nil { return nil, err } @@ -101,7 +113,11 @@ func (s *modelServer) UpdateModelConfig(ctx context.Context, request *apiv1alpha if err != nil { return nil, err } - modelConfig, err := s.modelConfig(result) + capabilities, err := loadResourceCapabilities(ctx, s.service.Scope, true) + if err != nil { + return nil, err + } + modelConfig, err := s.modelConfig(result, capabilities) if err != nil { return nil, err } @@ -182,7 +198,7 @@ func (s *modelServer) ListSupportedModels(ctx context.Context, _ *apiv1alpha1.Li return &apiv1alpha1.ListSupportedModelsResponse{Providers: providers}, nil } -func (s *modelServer) modelConfig(modelConfig *v1alpha3.ModelConfig) (*apiv1alpha1.ModelConfig, error) { +func (s *modelServer) modelConfig(modelConfig *v1alpha3.ModelConfig, capabilities resourceCapabilities) (*apiv1alpha1.ModelConfig, error) { resource, err := structuredobject.FromGo( modelConfig, v1alpha3.GroupVersion.String(), @@ -197,7 +213,9 @@ func (s *modelServer) modelConfig(modelConfig *v1alpha3.ModelConfig) (*apiv1alph Namespace: modelConfig.Namespace, Name: modelConfig.Name, }, - Resource: resource, + Resource: resource, + CanUpdate: capabilities.canUpdate(modelConfig), + CanDelete: capabilities.canDelete(modelConfig), }, nil } diff --git a/go/core/internal/grpcserver/model_test.go b/go/core/internal/grpcserver/model_test.go index 4b98a2574b..81a3fd6bfe 100644 --- a/go/core/internal/grpcserver/model_test.go +++ b/go/core/internal/grpcserver/model_test.go @@ -137,6 +137,9 @@ func TestModelServiceCRUD(t *testing.T) { if len(listed.GetModelConfigs()) != 1 { t.Fatalf("ListModelConfigs() count = %d, want 1", len(listed.GetModelConfigs())) } + if !listed.GetCanCreate() || !listed.GetModelConfigs()[0].GetCanUpdate() || !listed.GetModelConfigs()[0].GetCanDelete() { + t.Fatal("ListModelConfigs() did not report no-op authorizer capabilities") + } _, err = client.DeleteModelConfig(ctx, &apiv1alpha1.DeleteModelConfigRequest{ Ref: &apiv1alpha1.ResourceReference{Namespace: "default", Name: "test-config"}, diff --git a/go/core/internal/grpcserver/resource_capabilities.go b/go/core/internal/grpcserver/resource_capabilities.go new file mode 100644 index 0000000000..bc36c02bf0 --- /dev/null +++ b/go/core/internal/grpcserver/resource_capabilities.go @@ -0,0 +1,55 @@ +package grpcserver + +import ( + "context" + + "github.com/kagent-dev/kagent/go/core/internal/service/kubeauth" + "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" + "github.com/kagent-dev/kagent/go/core/pkg/auth" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type resourceCapabilities struct { + canCreate bool + canUpdate func(metav1.Object) bool + canDelete func(metav1.Object) bool +} + +func loadResourceCapabilities( + ctx context.Context, + scope func(context.Context, auth.Verb) (auth.AuthorizationScope, error), + withUpdate bool, +) (resourceCapabilities, error) { + create, err := scope(ctx, auth.VerbCreate) + if err != nil { + return resourceCapabilities{}, err + } + if _, err := kubeauth.ScopeMatcher(create); err != nil { + return resourceCapabilities{}, serviceerrors.NewPermissionDenied("Not authorized", err) + } + capabilities := resourceCapabilities{canCreate: create.Kind != auth.ScopeNone} + if withUpdate { + capabilities.canUpdate, err = capabilityMatcher(ctx, scope, auth.VerbUpdate) + if err != nil { + return resourceCapabilities{}, err + } + } + capabilities.canDelete, err = capabilityMatcher(ctx, scope, auth.VerbDelete) + return capabilities, err +} + +func capabilityMatcher( + ctx context.Context, + scope func(context.Context, auth.Verb) (auth.AuthorizationScope, error), + verb auth.Verb, +) (func(metav1.Object) bool, error) { + result, err := scope(ctx, verb) + if err != nil { + return nil, err + } + matches, err := kubeauth.ScopeMatcher(result) + if err != nil { + return nil, serviceerrors.NewPermissionDenied("Not authorized", err) + } + return matches, nil +} diff --git a/go/core/internal/grpcserver/resource_capabilities_test.go b/go/core/internal/grpcserver/resource_capabilities_test.go new file mode 100644 index 0000000000..6a24912313 --- /dev/null +++ b/go/core/internal/grpcserver/resource_capabilities_test.go @@ -0,0 +1,36 @@ +package grpcserver + +import ( + "context" + "testing" + + "github.com/kagent-dev/kagent/go/core/pkg/auth" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestResourceCapabilitiesUseActionScopes(t *testing.T) { + scopes := map[auth.Verb]auth.AuthorizationScope{ + auth.VerbCreate: {Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{ + Attribute: auth.AttributeNamespace, + Operator: auth.ScopeIn, + Values: []string{"team-a"}, + }}}}}, + auth.VerbUpdate: {Kind: auth.ScopeAnyOf, AnyOf: []auth.ScopeClause{{All: []auth.ScopePredicate{{ + Attribute: auth.AttributeNamespace, + Operator: auth.ScopeIn, + Values: []string{"team-a"}, + }}}}}, + auth.VerbDelete: {Kind: auth.ScopeNone}, + } + capabilities, err := loadResourceCapabilities(t.Context(), func(_ context.Context, verb auth.Verb) (auth.AuthorizationScope, error) { + return scopes[verb], nil + }, true) + if err != nil { + t.Fatalf("loadResourceCapabilities() error = %v", err) + } + allowed := &metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Namespace: "team-a", Name: "allowed"}} + denied := &metav1.PartialObjectMetadata{ObjectMeta: metav1.ObjectMeta{Namespace: "team-b", Name: "denied"}} + if !capabilities.canCreate || !capabilities.canUpdate(allowed) || capabilities.canUpdate(denied) || capabilities.canDelete(allowed) { + t.Fatalf("loadResourceCapabilities() = %+v", capabilities) + } +} diff --git a/go/core/internal/service/kubecrud/service.go b/go/core/internal/service/kubecrud/service.go index edf62a7ade..6ed9439750 100644 --- a/go/core/internal/service/kubecrud/service.go +++ b/go/core/internal/service/kubecrud/service.go @@ -46,7 +46,7 @@ func (s *Service[T, L]) List(ctx context.Context, namespace string) ([]T, error) if namespace == "" { return nil, serviceerrors.NewInvalidArgument("namespace is required", nil) } - scope, err := s.scope(ctx) + scope, err := s.Scope(ctx, auth.VerbList) if err != nil { return nil, err } @@ -156,12 +156,12 @@ func (s *Service[T, L]) Delete(ctx context.Context, ref types.NamespacedName) er return nil } -func (s *Service[T, L]) scope(ctx context.Context) (auth.AuthorizationScope, error) { +func (s *Service[T, L]) Scope(ctx context.Context, verb auth.Verb) (auth.AuthorizationScope, error) { session, ok := auth.AuthSessionFrom(ctx) if !ok || session == nil { return auth.AuthorizationScope{}, serviceerrors.NewUnauthenticated("Failed to get authenticated principal", fmt.Errorf("no session found")) } - scope, err := s.authorizer.Scope(ctx, session.Principal(), auth.VerbList, s.resource) + scope, err := s.authorizer.Scope(ctx, session.Principal(), verb, s.resource) if err != nil { return auth.AuthorizationScope{}, serviceerrors.NewPermissionDenied("Not authorized", err) } diff --git a/go/core/internal/service/model/service.go b/go/core/internal/service/model/service.go index 8eafa89fbb..2226b2f67f 100644 --- a/go/core/internal/service/model/service.go +++ b/go/core/internal/service/model/service.go @@ -65,7 +65,7 @@ func NewService(kubeClient client.Client, authorizer auth.CollectionAuthorizer, } func (s *Service) List(ctx context.Context, _ ListRequest) (*v1alpha3.ModelConfigList, error) { - scope, err := s.scope(ctx, "ModelConfig") + scope, err := s.Scope(ctx, auth.VerbList) if err != nil { return nil, err } @@ -269,12 +269,12 @@ func (s *Service) Delete(ctx context.Context, request DeleteRequest) (*v1alpha3. return modelConfig, nil } -func (s *Service) scope(ctx context.Context, resourceType string) (auth.AuthorizationScope, error) { +func (s *Service) Scope(ctx context.Context, verb auth.Verb) (auth.AuthorizationScope, error) { session, ok := auth.AuthSessionFrom(ctx) if !ok || session == nil { return auth.AuthorizationScope{}, serviceerrors.NewUnauthenticated("Failed to get authenticated principal", fmt.Errorf("no session found")) } - scope, err := s.authorizer.Scope(ctx, session.Principal(), auth.VerbList, resourceType) + scope, err := s.authorizer.Scope(ctx, session.Principal(), verb, "ModelConfig") if err != nil { return auth.AuthorizationScope{}, serviceerrors.NewPermissionDenied("Not authorized", err) } diff --git a/proto/kagent/api/v1alpha1/agent_templates.proto b/proto/kagent/api/v1alpha1/agent_templates.proto index 9d3381f590..fb50d0f023 100644 --- a/proto/kagent/api/v1alpha1/agent_templates.proto +++ b/proto/kagent/api/v1alpha1/agent_templates.proto @@ -38,6 +38,9 @@ message AgentTemplate { // caller may legally pair with this template in CreateAgentInstance, and it // is derivable only from the Harness side, so a caller cannot compute it. repeated string admitting_harnesses = 5; + + bool can_update = 6; + bool can_delete = 7; } message ListAgentTemplatesRequest { @@ -46,6 +49,7 @@ message ListAgentTemplatesRequest { message ListAgentTemplatesResponse { repeated AgentTemplate agent_templates = 1; + bool can_create = 2; } message GetAgentTemplateRequest { diff --git a/proto/kagent/api/v1alpha1/harnesses.proto b/proto/kagent/api/v1alpha1/harnesses.proto index 01b149871d..2c013607d3 100644 --- a/proto/kagent/api/v1alpha1/harnesses.proto +++ b/proto/kagent/api/v1alpha1/harnesses.proto @@ -42,6 +42,8 @@ message Harness { // Ready mirrors the Ready status condition. False also covers a Harness the // controller has not yet observed. bool ready = 5; + + bool can_delete = 6; } message ListHarnessesRequest { @@ -50,6 +52,7 @@ message ListHarnessesRequest { message ListHarnessesResponse { repeated Harness harnesses = 1; + bool can_create = 2; } message CreateHarnessRequest { diff --git a/proto/kagent/api/v1alpha1/models.proto b/proto/kagent/api/v1alpha1/models.proto index 1d7354b5fc..9b675eeaaa 100644 --- a/proto/kagent/api/v1alpha1/models.proto +++ b/proto/kagent/api/v1alpha1/models.proto @@ -30,10 +30,13 @@ message ListModelConfigsRequest {} message ModelConfig { ResourceReference ref = 1; StructuredObject resource = 2; + bool can_update = 3; + bool can_delete = 4; } message ListModelConfigsResponse { repeated ModelConfig model_configs = 1; + bool can_create = 2; } message GetModelConfigRequest { diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 4ed89f5bdf..a2404d3111 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -45,6 +45,7 @@ import type { AgentTemplate, AgentTemplateResource, } from "./domain/agentTemplates"; +import type { ResourceCollection } from "./domain/common"; import type { SubstrateActorSortField, SubstratePageInput, @@ -109,7 +110,7 @@ export interface AgentsApi { } export interface ModelsApi { - list(options?: ReadOptions): Promise; + list(options?: ReadOptions): Promise>; get(namespace: string, name: string, options?: ReadOptions): Promise; /** Models on offer, grouped by provider name. */ providerModels(options?: ReadOptions): Promise; @@ -184,7 +185,10 @@ export interface AgentBuildingBlocksApi { * `HarnessService`, not `AgentService`: `Harness` and `AgentHarness` are * different CRDs that share nothing but a name. */ - harnesses(namespace?: string, options?: ReadOptions): Promise; + harnesses( + namespace?: string, + options?: ReadOptions, + ): Promise>; /** * Creates a harness. * @@ -200,7 +204,10 @@ export interface AgentBuildingBlocksApi { /** Deletes a harness. Templates admitted only by it then run nowhere. */ removeHarness(namespace: string, name: string): Promise; /** Every `AgentTemplate` — the behaviour half — in one namespace, or in all of them. */ - agentTemplates(namespace?: string, options?: ReadOptions): Promise; + agentTemplates( + namespace?: string, + options?: ReadOptions, + ): Promise>; /** One agent template, whole, as an edit form needs it. */ agentTemplate( namespace: string, @@ -358,7 +365,11 @@ export function createApiClient(): KagentApiClient { }, models: { - list: (options) => invoke("models.list", {}, options).then(sortedByRef), + list: (options) => + invoke("models.list", {}, options).then((result) => ({ + ...result, + items: sortedByRef(result.items), + })), get: (namespace, name, options) => invoke("models.get", { namespace, name }, options), providerModels: (options) => invoke("models.providerModels", {}, options), @@ -407,11 +418,17 @@ export function createApiClient(): KagentApiClient { agentBuildingBlocks: { harnesses: (namespace, options) => - invoke("harnesses.list", { namespace }, options).then(sortedByRef), + invoke("harnesses.list", { namespace }, options).then((result) => ({ + ...result, + items: sortedByRef(result.items), + })), createHarness: (input) => invoke("harnesses.create", input), removeHarness: (namespace, name) => invoke("harnesses.delete", { namespace, name }), agentTemplates: (namespace, options) => - invoke("agentTemplates.list", { namespace }, options).then(sortedByRef), + invoke("agentTemplates.list", { namespace }, options).then((result) => ({ + ...result, + items: sortedByRef(result.items), + })), agentTemplate: (namespace, name, options) => invoke("agentTemplates.get", { namespace, name }, options), createAgentTemplate: (input) => invoke("agentTemplates.create", input), diff --git a/ui/src/api/domain/agentTemplates.ts b/ui/src/api/domain/agentTemplates.ts index a537baa35b..5440217210 100644 --- a/ui/src/api/domain/agentTemplates.ts +++ b/ui/src/api/domain/agentTemplates.ts @@ -170,6 +170,9 @@ export interface AgentTemplate { description: string; + canUpdate?: boolean; + canDelete?: boolean; + /** * The harnesses that will accept this template, by name, within its namespace. * diff --git a/ui/src/api/domain/common.ts b/ui/src/api/domain/common.ts index 03cb22e6ce..8e8ae873bf 100644 --- a/ui/src/api/domain/common.ts +++ b/ui/src/api/domain/common.ts @@ -84,3 +84,7 @@ export function parseRef(ref: ResourceRef): { namespace: string; name: string } export function toRef(namespace: string | undefined, name: string): ResourceRef { return namespace ? `${namespace}/${name}` : name; } +export interface ResourceCollection { + items: T[]; + canCreate: boolean; +} diff --git a/ui/src/api/domain/harnesses.ts b/ui/src/api/domain/harnesses.ts index 593dedfabd..64c0ea2f04 100644 --- a/ui/src/api/domain/harnesses.ts +++ b/ui/src/api/domain/harnesses.ts @@ -27,6 +27,8 @@ export interface Harness { namespace: string; name: string; + canDelete?: boolean; + /** * The adapter the spec selects. * diff --git a/ui/src/api/domain/models.ts b/ui/src/api/domain/models.ts index 70d8ff2401..e3e548b818 100644 --- a/ui/src/api/domain/models.ts +++ b/ui/src/api/domain/models.ts @@ -102,6 +102,8 @@ export interface ModelConfigSpec { export interface ModelConfig { ref: string; spec: ModelConfigSpec; + canUpdate?: boolean; + canDelete?: boolean; } /** A model provider the backend knows how to configure. */ diff --git a/ui/src/api/grpc/operations.ts b/ui/src/api/grpc/operations.ts index 42dc7fa8a4..82ebfb66d9 100644 --- a/ui/src/api/grpc/operations.ts +++ b/ui/src/api/grpc/operations.ts @@ -357,6 +357,8 @@ function toModelConfig( ref: string, resource: StructuredObject | undefined, rpcName: string, + canUpdate = false, + canDelete = false, ): ModelConfig { const object = unwrap<{ spec?: ModelConfigSpec }>( resource, @@ -371,7 +373,7 @@ function toModelConfig( url: rpcName, }); } - return { ref, spec: object.spec }; + return { ref, spec: object.spec, canUpdate, canDelete }; } /** @@ -412,9 +414,18 @@ const models: Pick< const response = await rpc(name, options.signal, () => serviceClient(ModelService).listModelConfigs({}, call("models.list", options)), ); - return list(response.modelConfigs).map((entry) => - toModelConfig(refToString(entry.ref), entry.resource, name), - ); + return { + items: list(response.modelConfigs).map((entry) => + toModelConfig( + refToString(entry.ref), + entry.resource, + name, + entry.canUpdate, + entry.canDelete, + ), + ), + canCreate: response.canCreate, + }; }, "models.get": async (input, options) => { @@ -430,7 +441,13 @@ const models: Pick< name, `model ${input.namespace}/${input.name}`, ); - return toModelConfig(refToString(entry.ref), entry.resource, name); + return toModelConfig( + refToString(entry.ref), + entry.resource, + name, + entry.canUpdate, + entry.canDelete, + ); }, "models.create": async (input, options) => { @@ -447,7 +464,13 @@ const models: Pick< ), ); const entry = required(response.modelConfig, name, "created model"); - return toModelConfig(refToString(entry.ref), entry.resource, name); + return toModelConfig( + refToString(entry.ref), + entry.resource, + name, + entry.canUpdate, + entry.canDelete, + ); }, "models.update": async (input, options) => { @@ -467,7 +490,13 @@ const models: Pick< ), ); const entry = required(response.modelConfig, name, `updated model ${ref}`); - return toModelConfig(refToString(entry.ref), entry.resource, name); + return toModelConfig( + refToString(entry.ref), + entry.resource, + name, + entry.canUpdate, + entry.canDelete, + ); }, "models.delete": async (input, options) => { @@ -1102,6 +1131,7 @@ function toHarness(harness: PbHarness): Harness { runtime: harness.runtime, workloadImage: harness.workloadImage, ready: harness.ready, + canDelete: harness.canDelete, resource: unwrap( harness.resource, "HarnessService/ListHarnesses", @@ -1118,6 +1148,8 @@ function toAgentTemplate(template: PbAgentTemplate): AgentTemplate { name: template.ref?.name ?? "", modelConfigRef: refToString(template.modelConfigRef), description: template.description, + canUpdate: template.canUpdate, + canDelete: template.canDelete, // Reported in status and derivable only from the harness side — a harness // admits templates through a label selector, so nothing on a template says // which ones match it. @@ -1153,7 +1185,10 @@ const agentBuildingBlocks: Pick< call("harnesses.list", options), ), ); - return list(response.harnesses).map(toHarness); + return { + items: list(response.harnesses).map(toHarness), + canCreate: response.canCreate, + }; }, "harnesses.create": async (input, options) => { @@ -1189,7 +1224,10 @@ const agentBuildingBlocks: Pick< call("agentTemplates.list", options), ), ); - return list(response.agentTemplates).map(toAgentTemplate); + return { + items: list(response.agentTemplates).map(toAgentTemplate), + canCreate: response.canCreate, + }; }, "agentTemplates.get": async (input, options) => { diff --git a/ui/src/api/hooks/useAgentBuildingBlocks.ts b/ui/src/api/hooks/useAgentBuildingBlocks.ts index 42df4ec96c..cfdd0defa3 100644 --- a/ui/src/api/hooks/useAgentBuildingBlocks.ts +++ b/ui/src/api/hooks/useAgentBuildingBlocks.ts @@ -10,24 +10,29 @@ import { apiClient } from "../client"; import { admitsHarness, type AgentTemplate } from "../domain/agentTemplates"; import type { Harness } from "../domain/harnesses"; -import { type ApiResource, useApiResource } from "./useApiResource"; +import { + type ApiCollectionResource, + type ApiResource, + useApiCollection, + useApiResource, +} from "./useApiResource"; /** - * The harnesses in one namespace, or in every observed namespace. + * The harnesses in one namespace. * - * Unlike agent instances, `HarnessService` reads across namespaces when given an - * empty one — it is a Kubernetes list, not a per-namespace database query — so - * "all namespaces" is one request rather than a fan-out. + * The hook waits for a namespace because `HarnessService` rejects an empty one. */ -export function useHarnesses(namespace?: string): ApiResource { - return useApiResource(["harnesses.list", namespace ?? ""], () => +export function useHarnesses(namespace?: string): ApiCollectionResource { + return useApiCollection(namespace ? ["harnesses.list", namespace] : null, () => apiClient.agentBuildingBlocks.harnesses(namespace), ); } -/** The agent templates in one namespace, or in every observed namespace. */ -export function useAgentTemplates(namespace?: string): ApiResource { - return useApiResource(["agentTemplates.list", namespace ?? ""], () => +/** The agent templates in one namespace. */ +export function useAgentTemplates( + namespace?: string, +): ApiCollectionResource { + return useApiCollection(namespace ? ["agentTemplates.list", namespace] : null, () => apiClient.agentBuildingBlocks.agentTemplates(namespace), ); } @@ -47,20 +52,23 @@ export function useAgentTemplates(namespace?: string): ApiResource { +): ApiCollectionResource { const key = namespaces ? [...namespaces].sort().join(",") : undefined; - return useApiResource(key ? ["harnesses.listAll", key] : null, async () => { + return useApiCollection(key ? ["harnesses.listAll", key] : null, async () => { const names = key ? key.split(",").filter(Boolean) : []; const settled = await Promise.allSettled( names.map((namespace) => apiClient.agentBuildingBlocks.harnesses(namespace)), ); const harnesses: Harness[] = []; + let canCreate = false; const refused: string[] = []; settled.forEach((outcome, index) => { - if (outcome.status === "fulfilled") harnesses.push(...outcome.value); - else + if (outcome.status === "fulfilled") { + harnesses.push(...outcome.value.items); + canCreate ||= outcome.value.canCreate; + } else refused.push( `${names[index]}: ${ outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason) @@ -71,7 +79,7 @@ export function useHarnessesAcrossNamespaces( if (names.length > 0 && refused.length === names.length) { throw new Error(refused.join("; ")); } - return harnesses; + return { items: harnesses, canCreate }; }); } @@ -79,6 +87,7 @@ export function useHarnessesAcrossNamespaces( export interface AgentTemplatesAcrossNamespaces { templates: AgentTemplate[]; refused: { namespace: string; reason: string }[]; + canCreate: boolean; } /** @@ -117,9 +126,11 @@ export function useAgentTemplatesAcrossNamespaces( const templates: AgentTemplate[] = []; const refused: { namespace: string; reason: string }[] = []; + let canCreate = false; settled.forEach((outcome, index) => { if (outcome.status === "fulfilled") { - templates.push(...outcome.value); + templates.push(...outcome.value.items); + canCreate ||= outcome.value.canCreate; return; } const cause = outcome.reason; @@ -145,7 +156,7 @@ export function useAgentTemplatesAcrossNamespaces( templates.sort( (a, b) => a.namespace.localeCompare(b.namespace) || a.name.localeCompare(b.name), ); - return { templates, refused }; + return { templates, refused, canCreate }; }, ); } diff --git a/ui/src/api/hooks/useApiResource.ts b/ui/src/api/hooks/useApiResource.ts index d0aa2052bb..1890a2dd93 100644 --- a/ui/src/api/hooks/useApiResource.ts +++ b/ui/src/api/hooks/useApiResource.ts @@ -9,6 +9,7 @@ import useSWR, { type SWRConfiguration } from "swr"; import { ApiError } from "../ApiError"; +import type { ResourceCollection } from "../domain/common"; export interface ApiResource { data: T | undefined; @@ -23,6 +24,10 @@ export interface ApiResource { refresh: () => Promise; } +export interface ApiCollectionResource extends ApiResource { + canCreate: boolean; +} + /** * Runs `fetcher` under SWR and reports the result in `ApiResource` terms. * @@ -67,6 +72,22 @@ export function useApiResource( }; } +export function useApiCollection( + key: readonly unknown[] | null, + fetcher: () => Promise>, +): ApiCollectionResource { + const resource = useApiResource(key, fetcher); + return { + ...resource, + data: resource.data?.items, + canCreate: resource.data?.canCreate ?? false, + isEmpty: + resource.data !== undefined && + !resource.error && + resource.data.items.length === 0, + }; +} + function isEmptyResult(data: unknown): boolean { if (data === undefined || data === null) return false; // Not loaded is not empty. if (Array.isArray(data)) return data.length === 0; diff --git a/ui/src/api/hooks/useModels.ts b/ui/src/api/hooks/useModels.ts index df2b8832a7..550cf42776 100644 --- a/ui/src/api/hooks/useModels.ts +++ b/ui/src/api/hooks/useModels.ts @@ -4,11 +4,16 @@ import type { Provider, ProviderModelsResponse, } from "../domain/models"; -import { type ApiResource, useApiResource } from "./useApiResource"; +import { + type ApiCollectionResource, + type ApiResource, + useApiCollection, + useApiResource, +} from "./useApiResource"; /** Every model configuration agents can be pointed at. */ -export function useModels(): ApiResource { - return useApiResource(["models.list"], () => apiClient.models.list()); +export function useModels(): ApiCollectionResource { + return useApiCollection(["models.list"], () => apiClient.models.list()); } /** One model configuration. Holds off until both parts of the ref are known. */ diff --git a/ui/src/api/operations.test.ts b/ui/src/api/operations.test.ts index 3ca11d4bae..5f1abdd635 100644 --- a/ui/src/api/operations.test.ts +++ b/ui/src/api/operations.test.ts @@ -366,6 +366,8 @@ describe("agents.create and agents.update", () => { describe("model configurations", () => { const modelConfigMessage = (name: string, model: string) => ({ ref: { namespace: "kagent", name }, + canUpdate: true, + canDelete: false, resource: { apiVersion: "kagent.dev/v1alpha3", kind: "ModelConfig", @@ -383,13 +385,22 @@ describe("model configurations", () => { service(ModelService, { listModelConfigs: () => ({ modelConfigs: [modelConfigMessage("default", "gpt-4.1")], + canCreate: true, }), }); }); - expect(await apiClient.models.list()).toEqual([ - { ref: "kagent/default", spec: { model: "gpt-4.1", provider: "OpenAI" } }, - ]); + expect(await apiClient.models.list()).toEqual({ + items: [ + { + ref: "kagent/default", + spec: { model: "gpt-4.1", provider: "OpenAI" }, + canUpdate: true, + canDelete: false, + }, + ], + canCreate: true, + }); }); it("sends a whole ModelConfig resource on create, with the ref beside it", async () => { diff --git a/ui/src/api/operations.ts b/ui/src/api/operations.ts index 550c6c57bc..6b7b924b85 100644 --- a/ui/src/api/operations.ts +++ b/ui/src/api/operations.ts @@ -68,6 +68,7 @@ import type { AgentTemplate, AgentTemplateResource, } from "./domain/agentTemplates"; +import type { ResourceCollection } from "./domain/common"; /** An operation that takes nothing. Written `{}` at the call site. */ export type NoInput = Record; @@ -162,7 +163,7 @@ export interface OperationMap { "agents.update": { input: { resource: AgentCreateRequest }; output: Agent }; "agents.delete": { input: AgentRef; output: void }; - "models.list": { input: NoInput; output: ModelConfig[] }; + "models.list": { input: NoInput; output: ResourceCollection }; "models.get": { input: ResourceRefInput; output: ModelConfig }; "models.create": { input: { payload: CreateModelConfigRequest }; output: ModelConfig }; "models.update": { @@ -315,7 +316,10 @@ export interface OperationMap { * `HarnessService`, not `AgentService` — `Harness` and `AgentHarness` are * different CRDs and only the names collide. See `domain/harnesses`. */ - "harnesses.list": { input: { namespace?: string }; output: Harness[] }; + "harnesses.list": { + input: { namespace?: string }; + output: ResourceCollection; + }; /** * Creates a harness from a whole custom resource. * @@ -329,7 +333,10 @@ export interface OperationMap { }; "harnesses.delete": { input: ResourceRefInput; output: void }; /** The agent templates in one namespace, or in every observed namespace. */ - "agentTemplates.list": { input: { namespace?: string }; output: AgentTemplate[] }; + "agentTemplates.list": { + input: { namespace?: string }; + output: ResourceCollection; + }; "agentTemplates.get": { input: ResourceRefInput; output: AgentTemplate }; /** * Creates an agent template from a whole custom resource. diff --git a/ui/src/generated/kagent/api/v1alpha1/agent_templates_pb.ts b/ui/src/generated/kagent/api/v1alpha1/agent_templates_pb.ts index 1b4f0f0595..3523f440c3 100644 --- a/ui/src/generated/kagent/api/v1alpha1/agent_templates_pb.ts +++ b/ui/src/generated/kagent/api/v1alpha1/agent_templates_pb.ts @@ -12,7 +12,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file kagent/api/v1alpha1/agent_templates.proto. */ export const file_kagent_api_v1alpha1_agent_templates: GenFile = /*@__PURE__*/ - fileDesc("CilrYWdlbnQvYXBpL3YxYWxwaGExL2FnZW50X3RlbXBsYXRlcy5wcm90bxITa2FnZW50LmFwaS52MWFscGhhMSLxAQoNQWdlbnRUZW1wbGF0ZRIzCgNyZWYYASABKAsyJi5rYWdlbnQuYXBpLnYxYWxwaGExLlJlc291cmNlUmVmZXJlbmNlEjcKCHJlc291cmNlGAIgASgLMiUua2FnZW50LmFwaS52MWFscGhhMS5TdHJ1Y3R1cmVkT2JqZWN0EkAKEG1vZGVsX2NvbmZpZ19yZWYYAyABKAsyJi5rYWdlbnQuYXBpLnYxYWxwaGExLlJlc291cmNlUmVmZXJlbmNlEhMKC2Rlc2NyaXB0aW9uGAQgASgJEhsKE2FkbWl0dGluZ19oYXJuZXNzZXMYBSADKAkiLgoZTGlzdEFnZW50VGVtcGxhdGVzUmVxdWVzdBIRCgluYW1lc3BhY2UYASABKAkiWQoaTGlzdEFnZW50VGVtcGxhdGVzUmVzcG9uc2USOwoPYWdlbnRfdGVtcGxhdGVzGAEgAygLMiIua2FnZW50LmFwaS52MWFscGhhMS5BZ2VudFRlbXBsYXRlIk4KF0dldEFnZW50VGVtcGxhdGVSZXF1ZXN0EjMKA3JlZhgBIAEoCzImLmthZ2VudC5hcGkudjFhbHBoYTEuUmVzb3VyY2VSZWZlcmVuY2UiVgoYR2V0QWdlbnRUZW1wbGF0ZVJlc3BvbnNlEjoKDmFnZW50X3RlbXBsYXRlGAEgASgLMiIua2FnZW50LmFwaS52MWFscGhhMS5BZ2VudFRlbXBsYXRlIooBChpDcmVhdGVBZ2VudFRlbXBsYXRlUmVxdWVzdBIzCgNyZWYYASABKAsyJi5rYWdlbnQuYXBpLnYxYWxwaGExLlJlc291cmNlUmVmZXJlbmNlEjcKCHJlc291cmNlGAIgASgLMiUua2FnZW50LmFwaS52MWFscGhhMS5TdHJ1Y3R1cmVkT2JqZWN0IlkKG0NyZWF0ZUFnZW50VGVtcGxhdGVSZXNwb25zZRI6Cg5hZ2VudF90ZW1wbGF0ZRgBIAEoCzIiLmthZ2VudC5hcGkudjFhbHBoYTEuQWdlbnRUZW1wbGF0ZSKKAQoaVXBkYXRlQWdlbnRUZW1wbGF0ZVJlcXVlc3QSMwoDcmVmGAEgASgLMiYua2FnZW50LmFwaS52MWFscGhhMS5SZXNvdXJjZVJlZmVyZW5jZRI3CghyZXNvdXJjZRgCIAEoCzIlLmthZ2VudC5hcGkudjFhbHBoYTEuU3RydWN0dXJlZE9iamVjdCJZChtVcGRhdGVBZ2VudFRlbXBsYXRlUmVzcG9uc2USOgoOYWdlbnRfdGVtcGxhdGUYASABKAsyIi5rYWdlbnQuYXBpLnYxYWxwaGExLkFnZW50VGVtcGxhdGUiUQoaRGVsZXRlQWdlbnRUZW1wbGF0ZVJlcXVlc3QSMwoDcmVmGAEgASgLMiYua2FnZW50LmFwaS52MWFscGhhMS5SZXNvdXJjZVJlZmVyZW5jZSIdChtEZWxldGVBZ2VudFRlbXBsYXRlUmVzcG9uc2Uy7AQKFEFnZW50VGVtcGxhdGVTZXJ2aWNlEnUKEkxpc3RBZ2VudFRlbXBsYXRlcxIuLmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdEFnZW50VGVtcGxhdGVzUmVxdWVzdBovLmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdEFnZW50VGVtcGxhdGVzUmVzcG9uc2USbwoQR2V0QWdlbnRUZW1wbGF0ZRIsLmthZ2VudC5hcGkudjFhbHBoYTEuR2V0QWdlbnRUZW1wbGF0ZVJlcXVlc3QaLS5rYWdlbnQuYXBpLnYxYWxwaGExLkdldEFnZW50VGVtcGxhdGVSZXNwb25zZRJ4ChNDcmVhdGVBZ2VudFRlbXBsYXRlEi8ua2FnZW50LmFwaS52MWFscGhhMS5DcmVhdGVBZ2VudFRlbXBsYXRlUmVxdWVzdBowLmthZ2VudC5hcGkudjFhbHBoYTEuQ3JlYXRlQWdlbnRUZW1wbGF0ZVJlc3BvbnNlEngKE1VwZGF0ZUFnZW50VGVtcGxhdGUSLy5rYWdlbnQuYXBpLnYxYWxwaGExLlVwZGF0ZUFnZW50VGVtcGxhdGVSZXF1ZXN0GjAua2FnZW50LmFwaS52MWFscGhhMS5VcGRhdGVBZ2VudFRlbXBsYXRlUmVzcG9uc2USeAoTRGVsZXRlQWdlbnRUZW1wbGF0ZRIvLmthZ2VudC5hcGkudjFhbHBoYTEuRGVsZXRlQWdlbnRUZW1wbGF0ZVJlcXVlc3QaMC5rYWdlbnQuYXBpLnYxYWxwaGExLkRlbGV0ZUFnZW50VGVtcGxhdGVSZXNwb25zZUJJWkdnaXRodWIuY29tL2thZ2VudC1kZXYva2FnZW50L2dvL2FwaS9nZW4va2FnZW50L2FwaS92MWFscGhhMTthcGl2MWFscGhhMWIGcHJvdG8z", [file_kagent_api_v1alpha1_common]); + fileDesc("CilrYWdlbnQvYXBpL3YxYWxwaGExL2FnZW50X3RlbXBsYXRlcy5wcm90bxITa2FnZW50LmFwaS52MWFscGhhMSKZAgoNQWdlbnRUZW1wbGF0ZRIzCgNyZWYYASABKAsyJi5rYWdlbnQuYXBpLnYxYWxwaGExLlJlc291cmNlUmVmZXJlbmNlEjcKCHJlc291cmNlGAIgASgLMiUua2FnZW50LmFwaS52MWFscGhhMS5TdHJ1Y3R1cmVkT2JqZWN0EkAKEG1vZGVsX2NvbmZpZ19yZWYYAyABKAsyJi5rYWdlbnQuYXBpLnYxYWxwaGExLlJlc291cmNlUmVmZXJlbmNlEhMKC2Rlc2NyaXB0aW9uGAQgASgJEhsKE2FkbWl0dGluZ19oYXJuZXNzZXMYBSADKAkSEgoKY2FuX3VwZGF0ZRgGIAEoCBISCgpjYW5fZGVsZXRlGAcgASgIIi4KGUxpc3RBZ2VudFRlbXBsYXRlc1JlcXVlc3QSEQoJbmFtZXNwYWNlGAEgASgJIm0KGkxpc3RBZ2VudFRlbXBsYXRlc1Jlc3BvbnNlEjsKD2FnZW50X3RlbXBsYXRlcxgBIAMoCzIiLmthZ2VudC5hcGkudjFhbHBoYTEuQWdlbnRUZW1wbGF0ZRISCgpjYW5fY3JlYXRlGAIgASgIIk4KF0dldEFnZW50VGVtcGxhdGVSZXF1ZXN0EjMKA3JlZhgBIAEoCzImLmthZ2VudC5hcGkudjFhbHBoYTEuUmVzb3VyY2VSZWZlcmVuY2UiVgoYR2V0QWdlbnRUZW1wbGF0ZVJlc3BvbnNlEjoKDmFnZW50X3RlbXBsYXRlGAEgASgLMiIua2FnZW50LmFwaS52MWFscGhhMS5BZ2VudFRlbXBsYXRlIooBChpDcmVhdGVBZ2VudFRlbXBsYXRlUmVxdWVzdBIzCgNyZWYYASABKAsyJi5rYWdlbnQuYXBpLnYxYWxwaGExLlJlc291cmNlUmVmZXJlbmNlEjcKCHJlc291cmNlGAIgASgLMiUua2FnZW50LmFwaS52MWFscGhhMS5TdHJ1Y3R1cmVkT2JqZWN0IlkKG0NyZWF0ZUFnZW50VGVtcGxhdGVSZXNwb25zZRI6Cg5hZ2VudF90ZW1wbGF0ZRgBIAEoCzIiLmthZ2VudC5hcGkudjFhbHBoYTEuQWdlbnRUZW1wbGF0ZSKKAQoaVXBkYXRlQWdlbnRUZW1wbGF0ZVJlcXVlc3QSMwoDcmVmGAEgASgLMiYua2FnZW50LmFwaS52MWFscGhhMS5SZXNvdXJjZVJlZmVyZW5jZRI3CghyZXNvdXJjZRgCIAEoCzIlLmthZ2VudC5hcGkudjFhbHBoYTEuU3RydWN0dXJlZE9iamVjdCJZChtVcGRhdGVBZ2VudFRlbXBsYXRlUmVzcG9uc2USOgoOYWdlbnRfdGVtcGxhdGUYASABKAsyIi5rYWdlbnQuYXBpLnYxYWxwaGExLkFnZW50VGVtcGxhdGUiUQoaRGVsZXRlQWdlbnRUZW1wbGF0ZVJlcXVlc3QSMwoDcmVmGAEgASgLMiYua2FnZW50LmFwaS52MWFscGhhMS5SZXNvdXJjZVJlZmVyZW5jZSIdChtEZWxldGVBZ2VudFRlbXBsYXRlUmVzcG9uc2Uy7AQKFEFnZW50VGVtcGxhdGVTZXJ2aWNlEnUKEkxpc3RBZ2VudFRlbXBsYXRlcxIuLmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdEFnZW50VGVtcGxhdGVzUmVxdWVzdBovLmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdEFnZW50VGVtcGxhdGVzUmVzcG9uc2USbwoQR2V0QWdlbnRUZW1wbGF0ZRIsLmthZ2VudC5hcGkudjFhbHBoYTEuR2V0QWdlbnRUZW1wbGF0ZVJlcXVlc3QaLS5rYWdlbnQuYXBpLnYxYWxwaGExLkdldEFnZW50VGVtcGxhdGVSZXNwb25zZRJ4ChNDcmVhdGVBZ2VudFRlbXBsYXRlEi8ua2FnZW50LmFwaS52MWFscGhhMS5DcmVhdGVBZ2VudFRlbXBsYXRlUmVxdWVzdBowLmthZ2VudC5hcGkudjFhbHBoYTEuQ3JlYXRlQWdlbnRUZW1wbGF0ZVJlc3BvbnNlEngKE1VwZGF0ZUFnZW50VGVtcGxhdGUSLy5rYWdlbnQuYXBpLnYxYWxwaGExLlVwZGF0ZUFnZW50VGVtcGxhdGVSZXF1ZXN0GjAua2FnZW50LmFwaS52MWFscGhhMS5VcGRhdGVBZ2VudFRlbXBsYXRlUmVzcG9uc2USeAoTRGVsZXRlQWdlbnRUZW1wbGF0ZRIvLmthZ2VudC5hcGkudjFhbHBoYTEuRGVsZXRlQWdlbnRUZW1wbGF0ZVJlcXVlc3QaMC5rYWdlbnQuYXBpLnYxYWxwaGExLkRlbGV0ZUFnZW50VGVtcGxhdGVSZXNwb25zZUJJWkdnaXRodWIuY29tL2thZ2VudC1kZXYva2FnZW50L2dvL2FwaS9nZW4va2FnZW50L2FwaS92MWFscGhhMTthcGl2MWFscGhhMWIGcHJvdG8z", [file_kagent_api_v1alpha1_common]); /** * @generated from message kagent.api.v1alpha1.AgentTemplate @@ -55,6 +55,16 @@ export type AgentTemplate = Message<"kagent.api.v1alpha1.AgentTemplate"> & { * @generated from field: repeated string admitting_harnesses = 5; */ admittingHarnesses: string[]; + + /** + * @generated from field: bool can_update = 6; + */ + canUpdate: boolean; + + /** + * @generated from field: bool can_delete = 7; + */ + canDelete: boolean; }; /** @@ -89,6 +99,11 @@ export type ListAgentTemplatesResponse = Message<"kagent.api.v1alpha1.ListAgentT * @generated from field: repeated kagent.api.v1alpha1.AgentTemplate agent_templates = 1; */ agentTemplates: AgentTemplate[]; + + /** + * @generated from field: bool can_create = 2; + */ + canCreate: boolean; }; /** diff --git a/ui/src/generated/kagent/api/v1alpha1/harnesses_pb.ts b/ui/src/generated/kagent/api/v1alpha1/harnesses_pb.ts index 96e449c4b3..eac514cc5a 100644 --- a/ui/src/generated/kagent/api/v1alpha1/harnesses_pb.ts +++ b/ui/src/generated/kagent/api/v1alpha1/harnesses_pb.ts @@ -12,7 +12,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file kagent/api/v1alpha1/harnesses.proto. */ export const file_kagent_api_v1alpha1_harnesses: GenFile = /*@__PURE__*/ - fileDesc("CiNrYWdlbnQvYXBpL3YxYWxwaGExL2hhcm5lc3Nlcy5wcm90bxITa2FnZW50LmFwaS52MWFscGhhMSKvAQoHSGFybmVzcxIzCgNyZWYYASABKAsyJi5rYWdlbnQuYXBpLnYxYWxwaGExLlJlc291cmNlUmVmZXJlbmNlEjcKCHJlc291cmNlGAIgASgLMiUua2FnZW50LmFwaS52MWFscGhhMS5TdHJ1Y3R1cmVkT2JqZWN0Eg8KB3J1bnRpbWUYAyABKAkSFgoOd29ya2xvYWRfaW1hZ2UYBCABKAkSDQoFcmVhZHkYBSABKAgiKQoUTGlzdEhhcm5lc3Nlc1JlcXVlc3QSEQoJbmFtZXNwYWNlGAEgASgJIkgKFUxpc3RIYXJuZXNzZXNSZXNwb25zZRIvCgloYXJuZXNzZXMYASADKAsyHC5rYWdlbnQuYXBpLnYxYWxwaGExLkhhcm5lc3MihAEKFENyZWF0ZUhhcm5lc3NSZXF1ZXN0EjMKA3JlZhgBIAEoCzImLmthZ2VudC5hcGkudjFhbHBoYTEuUmVzb3VyY2VSZWZlcmVuY2USNwoIcmVzb3VyY2UYAiABKAsyJS5rYWdlbnQuYXBpLnYxYWxwaGExLlN0cnVjdHVyZWRPYmplY3QiRgoVQ3JlYXRlSGFybmVzc1Jlc3BvbnNlEi0KB2hhcm5lc3MYASABKAsyHC5rYWdlbnQuYXBpLnYxYWxwaGExLkhhcm5lc3MiSwoURGVsZXRlSGFybmVzc1JlcXVlc3QSMwoDcmVmGAEgASgLMiYua2FnZW50LmFwaS52MWFscGhhMS5SZXNvdXJjZVJlZmVyZW5jZSIXChVEZWxldGVIYXJuZXNzUmVzcG9uc2UyyAIKDkhhcm5lc3NTZXJ2aWNlEmYKDUxpc3RIYXJuZXNzZXMSKS5rYWdlbnQuYXBpLnYxYWxwaGExLkxpc3RIYXJuZXNzZXNSZXF1ZXN0Gioua2FnZW50LmFwaS52MWFscGhhMS5MaXN0SGFybmVzc2VzUmVzcG9uc2USZgoNQ3JlYXRlSGFybmVzcxIpLmthZ2VudC5hcGkudjFhbHBoYTEuQ3JlYXRlSGFybmVzc1JlcXVlc3QaKi5rYWdlbnQuYXBpLnYxYWxwaGExLkNyZWF0ZUhhcm5lc3NSZXNwb25zZRJmCg1EZWxldGVIYXJuZXNzEikua2FnZW50LmFwaS52MWFscGhhMS5EZWxldGVIYXJuZXNzUmVxdWVzdBoqLmthZ2VudC5hcGkudjFhbHBoYTEuRGVsZXRlSGFybmVzc1Jlc3BvbnNlQklaR2dpdGh1Yi5jb20va2FnZW50LWRldi9rYWdlbnQvZ28vYXBpL2dlbi9rYWdlbnQvYXBpL3YxYWxwaGExO2FwaXYxYWxwaGExYgZwcm90bzM", [file_kagent_api_v1alpha1_common]); + fileDesc("CiNrYWdlbnQvYXBpL3YxYWxwaGExL2hhcm5lc3Nlcy5wcm90bxITa2FnZW50LmFwaS52MWFscGhhMSLDAQoHSGFybmVzcxIzCgNyZWYYASABKAsyJi5rYWdlbnQuYXBpLnYxYWxwaGExLlJlc291cmNlUmVmZXJlbmNlEjcKCHJlc291cmNlGAIgASgLMiUua2FnZW50LmFwaS52MWFscGhhMS5TdHJ1Y3R1cmVkT2JqZWN0Eg8KB3J1bnRpbWUYAyABKAkSFgoOd29ya2xvYWRfaW1hZ2UYBCABKAkSDQoFcmVhZHkYBSABKAgSEgoKY2FuX2RlbGV0ZRgGIAEoCCIpChRMaXN0SGFybmVzc2VzUmVxdWVzdBIRCgluYW1lc3BhY2UYASABKAkiXAoVTGlzdEhhcm5lc3Nlc1Jlc3BvbnNlEi8KCWhhcm5lc3NlcxgBIAMoCzIcLmthZ2VudC5hcGkudjFhbHBoYTEuSGFybmVzcxISCgpjYW5fY3JlYXRlGAIgASgIIoQBChRDcmVhdGVIYXJuZXNzUmVxdWVzdBIzCgNyZWYYASABKAsyJi5rYWdlbnQuYXBpLnYxYWxwaGExLlJlc291cmNlUmVmZXJlbmNlEjcKCHJlc291cmNlGAIgASgLMiUua2FnZW50LmFwaS52MWFscGhhMS5TdHJ1Y3R1cmVkT2JqZWN0IkYKFUNyZWF0ZUhhcm5lc3NSZXNwb25zZRItCgdoYXJuZXNzGAEgASgLMhwua2FnZW50LmFwaS52MWFscGhhMS5IYXJuZXNzIksKFERlbGV0ZUhhcm5lc3NSZXF1ZXN0EjMKA3JlZhgBIAEoCzImLmthZ2VudC5hcGkudjFhbHBoYTEuUmVzb3VyY2VSZWZlcmVuY2UiFwoVRGVsZXRlSGFybmVzc1Jlc3BvbnNlMsgCCg5IYXJuZXNzU2VydmljZRJmCg1MaXN0SGFybmVzc2VzEikua2FnZW50LmFwaS52MWFscGhhMS5MaXN0SGFybmVzc2VzUmVxdWVzdBoqLmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdEhhcm5lc3Nlc1Jlc3BvbnNlEmYKDUNyZWF0ZUhhcm5lc3MSKS5rYWdlbnQuYXBpLnYxYWxwaGExLkNyZWF0ZUhhcm5lc3NSZXF1ZXN0Gioua2FnZW50LmFwaS52MWFscGhhMS5DcmVhdGVIYXJuZXNzUmVzcG9uc2USZgoNRGVsZXRlSGFybmVzcxIpLmthZ2VudC5hcGkudjFhbHBoYTEuRGVsZXRlSGFybmVzc1JlcXVlc3QaKi5rYWdlbnQuYXBpLnYxYWxwaGExLkRlbGV0ZUhhcm5lc3NSZXNwb25zZUJJWkdnaXRodWIuY29tL2thZ2VudC1kZXYva2FnZW50L2dvL2FwaS9nZW4va2FnZW50L2FwaS92MWFscGhhMTthcGl2MWFscGhhMWIGcHJvdG8z", [file_kagent_api_v1alpha1_common]); /** * @generated from message kagent.api.v1alpha1.Harness @@ -54,6 +54,11 @@ export type Harness = Message<"kagent.api.v1alpha1.Harness"> & { * @generated from field: bool ready = 5; */ ready: boolean; + + /** + * @generated from field: bool can_delete = 6; + */ + canDelete: boolean; }; /** @@ -88,6 +93,11 @@ export type ListHarnessesResponse = Message<"kagent.api.v1alpha1.ListHarnessesRe * @generated from field: repeated kagent.api.v1alpha1.Harness harnesses = 1; */ harnesses: Harness[]; + + /** + * @generated from field: bool can_create = 2; + */ + canCreate: boolean; }; /** diff --git a/ui/src/generated/kagent/api/v1alpha1/models_pb.ts b/ui/src/generated/kagent/api/v1alpha1/models_pb.ts index ccba316194..8b0a354a0a 100644 --- a/ui/src/generated/kagent/api/v1alpha1/models_pb.ts +++ b/ui/src/generated/kagent/api/v1alpha1/models_pb.ts @@ -12,7 +12,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file kagent/api/v1alpha1/models.proto. */ export const file_kagent_api_v1alpha1_models: GenFile = /*@__PURE__*/ - fileDesc("CiBrYWdlbnQvYXBpL3YxYWxwaGExL21vZGVscy5wcm90bxITa2FnZW50LmFwaS52MWFscGhhMSI6Cg5TZWNyZXRNYXRlcmlhbBIMCgRuYW1lGAEgASgJEgsKA2tleRgCIAEoCRINCgV2YWx1ZRgDIAEoCSIZChdMaXN0TW9kZWxDb25maWdzUmVxdWVzdCJ7CgtNb2RlbENvbmZpZxIzCgNyZWYYASABKAsyJi5rYWdlbnQuYXBpLnYxYWxwaGExLlJlc291cmNlUmVmZXJlbmNlEjcKCHJlc291cmNlGAIgASgLMiUua2FnZW50LmFwaS52MWFscGhhMS5TdHJ1Y3R1cmVkT2JqZWN0IlMKGExpc3RNb2RlbENvbmZpZ3NSZXNwb25zZRI3Cg1tb2RlbF9jb25maWdzGAEgAygLMiAua2FnZW50LmFwaS52MWFscGhhMS5Nb2RlbENvbmZpZyJMChVHZXRNb2RlbENvbmZpZ1JlcXVlc3QSMwoDcmVmGAEgASgLMiYua2FnZW50LmFwaS52MWFscGhhMS5SZXNvdXJjZVJlZmVyZW5jZSJQChZHZXRNb2RlbENvbmZpZ1Jlc3BvbnNlEjYKDG1vZGVsX2NvbmZpZxgBIAEoCzIgLmthZ2VudC5hcGkudjFhbHBoYTEuTW9kZWxDb25maWcizwEKGENyZWF0ZU1vZGVsQ29uZmlnUmVxdWVzdBIzCgNyZWYYASABKAsyJi5rYWdlbnQuYXBpLnYxYWxwaGExLlJlc291cmNlUmVmZXJlbmNlEjcKCHJlc291cmNlGAIgASgLMiUua2FnZW50LmFwaS52MWFscGhhMS5TdHJ1Y3R1cmVkT2JqZWN0Eg8KB2FwaV9rZXkYAyABKAkSNAoHc2VjcmV0cxgEIAMoCzIjLmthZ2VudC5hcGkudjFhbHBoYTEuU2VjcmV0TWF0ZXJpYWwiUwoZQ3JlYXRlTW9kZWxDb25maWdSZXNwb25zZRI2Cgxtb2RlbF9jb25maWcYASABKAsyIC5rYWdlbnQuYXBpLnYxYWxwaGExLk1vZGVsQ29uZmlnIuABChhVcGRhdGVNb2RlbENvbmZpZ1JlcXVlc3QSMwoDcmVmGAEgASgLMiYua2FnZW50LmFwaS52MWFscGhhMS5SZXNvdXJjZVJlZmVyZW5jZRI3CghyZXNvdXJjZRgCIAEoCzIlLmthZ2VudC5hcGkudjFhbHBoYTEuU3RydWN0dXJlZE9iamVjdBIUCgdhcGlfa2V5GAMgASgJSACIAQESNAoHc2VjcmV0cxgEIAMoCzIjLmthZ2VudC5hcGkudjFhbHBoYTEuU2VjcmV0TWF0ZXJpYWxCCgoIX2FwaV9rZXkiUwoZVXBkYXRlTW9kZWxDb25maWdSZXNwb25zZRI2Cgxtb2RlbF9jb25maWcYASABKAsyIC5rYWdlbnQuYXBpLnYxYWxwaGExLk1vZGVsQ29uZmlnIk8KGERlbGV0ZU1vZGVsQ29uZmlnUmVxdWVzdBIzCgNyZWYYASABKAsyJi5rYWdlbnQuYXBpLnYxYWxwaGExLlJlc291cmNlUmVmZXJlbmNlIhsKGURlbGV0ZU1vZGVsQ29uZmlnUmVzcG9uc2UiYgoSUHJvdmlkZXJEZWZpbml0aW9uEgwKBG5hbWUYASABKAkSDAoEdHlwZRgCIAEoCRIXCg9yZXF1aXJlZF9wYXJhbXMYAyADKAkSFwoPb3B0aW9uYWxfcGFyYW1zGAQgAygJIiQKIkxpc3RTdXBwb3J0ZWRNb2RlbFByb3ZpZGVyc1JlcXVlc3QiYQojTGlzdFN1cHBvcnRlZE1vZGVsUHJvdmlkZXJzUmVzcG9uc2USOgoJcHJvdmlkZXJzGAEgAygLMicua2FnZW50LmFwaS52MWFscGhhMS5Qcm92aWRlckRlZmluaXRpb24iJQojTGlzdFN1cHBvcnRlZE1lbW9yeVByb3ZpZGVyc1JlcXVlc3QiYgokTGlzdFN1cHBvcnRlZE1lbW9yeVByb3ZpZGVyc1Jlc3BvbnNlEjoKCXByb3ZpZGVycxgBIAMoCzInLmthZ2VudC5hcGkudjFhbHBoYTEuUHJvdmlkZXJEZWZpbml0aW9uIkIKEkNvbmZpZ3VyZWRQcm92aWRlchIMCgRuYW1lGAEgASgJEgwKBHR5cGUYAiABKAkSEAoIZW5kcG9pbnQYAyABKAkiIAoeTGlzdENvbmZpZ3VyZWRQcm92aWRlcnNSZXF1ZXN0Il0KH0xpc3RDb25maWd1cmVkUHJvdmlkZXJzUmVzcG9uc2USOgoJcHJvdmlkZXJzGAEgAygLMicua2FnZW50LmFwaS52MWFscGhhMS5Db25maWd1cmVkUHJvdmlkZXIiQwoZTGlzdFByb3ZpZGVyTW9kZWxzUmVxdWVzdBIVCg1wcm92aWRlcl9uYW1lGAEgASgJEg8KB3JlZnJlc2gYAiABKAgiPgoaTGlzdFByb3ZpZGVyTW9kZWxzUmVzcG9uc2USEAoIcHJvdmlkZXIYASABKAkSDgoGbW9kZWxzGAIgAygJIjMKCU1vZGVsSW5mbxIMCgRuYW1lGAEgASgJEhgKEGZ1bmN0aW9uX2NhbGxpbmcYAiABKAgiUgoOUHJvdmlkZXJNb2RlbHMSEAoIcHJvdmlkZXIYASABKAkSLgoGbW9kZWxzGAIgAygLMh4ua2FnZW50LmFwaS52MWFscGhhMS5Nb2RlbEluZm8iHAoaTGlzdFN1cHBvcnRlZE1vZGVsc1JlcXVlc3QiVQobTGlzdFN1cHBvcnRlZE1vZGVsc1Jlc3BvbnNlEjYKCXByb3ZpZGVycxgBIAMoCzIjLmthZ2VudC5hcGkudjFhbHBoYTEuUHJvdmlkZXJNb2RlbHMy5wkKDE1vZGVsU2VydmljZRJvChBMaXN0TW9kZWxDb25maWdzEiwua2FnZW50LmFwaS52MWFscGhhMS5MaXN0TW9kZWxDb25maWdzUmVxdWVzdBotLmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdE1vZGVsQ29uZmlnc1Jlc3BvbnNlEmkKDkdldE1vZGVsQ29uZmlnEioua2FnZW50LmFwaS52MWFscGhhMS5HZXRNb2RlbENvbmZpZ1JlcXVlc3QaKy5rYWdlbnQuYXBpLnYxYWxwaGExLkdldE1vZGVsQ29uZmlnUmVzcG9uc2UScgoRQ3JlYXRlTW9kZWxDb25maWcSLS5rYWdlbnQuYXBpLnYxYWxwaGExLkNyZWF0ZU1vZGVsQ29uZmlnUmVxdWVzdBouLmthZ2VudC5hcGkudjFhbHBoYTEuQ3JlYXRlTW9kZWxDb25maWdSZXNwb25zZRJyChFVcGRhdGVNb2RlbENvbmZpZxItLmthZ2VudC5hcGkudjFhbHBoYTEuVXBkYXRlTW9kZWxDb25maWdSZXF1ZXN0Gi4ua2FnZW50LmFwaS52MWFscGhhMS5VcGRhdGVNb2RlbENvbmZpZ1Jlc3BvbnNlEnIKEURlbGV0ZU1vZGVsQ29uZmlnEi0ua2FnZW50LmFwaS52MWFscGhhMS5EZWxldGVNb2RlbENvbmZpZ1JlcXVlc3QaLi5rYWdlbnQuYXBpLnYxYWxwaGExLkRlbGV0ZU1vZGVsQ29uZmlnUmVzcG9uc2USkAEKG0xpc3RTdXBwb3J0ZWRNb2RlbFByb3ZpZGVycxI3LmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdFN1cHBvcnRlZE1vZGVsUHJvdmlkZXJzUmVxdWVzdBo4LmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdFN1cHBvcnRlZE1vZGVsUHJvdmlkZXJzUmVzcG9uc2USkwEKHExpc3RTdXBwb3J0ZWRNZW1vcnlQcm92aWRlcnMSOC5rYWdlbnQuYXBpLnYxYWxwaGExLkxpc3RTdXBwb3J0ZWRNZW1vcnlQcm92aWRlcnNSZXF1ZXN0Gjkua2FnZW50LmFwaS52MWFscGhhMS5MaXN0U3VwcG9ydGVkTWVtb3J5UHJvdmlkZXJzUmVzcG9uc2UShAEKF0xpc3RDb25maWd1cmVkUHJvdmlkZXJzEjMua2FnZW50LmFwaS52MWFscGhhMS5MaXN0Q29uZmlndXJlZFByb3ZpZGVyc1JlcXVlc3QaNC5rYWdlbnQuYXBpLnYxYWxwaGExLkxpc3RDb25maWd1cmVkUHJvdmlkZXJzUmVzcG9uc2USdQoSTGlzdFByb3ZpZGVyTW9kZWxzEi4ua2FnZW50LmFwaS52MWFscGhhMS5MaXN0UHJvdmlkZXJNb2RlbHNSZXF1ZXN0Gi8ua2FnZW50LmFwaS52MWFscGhhMS5MaXN0UHJvdmlkZXJNb2RlbHNSZXNwb25zZRJ4ChNMaXN0U3VwcG9ydGVkTW9kZWxzEi8ua2FnZW50LmFwaS52MWFscGhhMS5MaXN0U3VwcG9ydGVkTW9kZWxzUmVxdWVzdBowLmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdFN1cHBvcnRlZE1vZGVsc1Jlc3BvbnNlQklaR2dpdGh1Yi5jb20va2FnZW50LWRldi9rYWdlbnQvZ28vYXBpL2dlbi9rYWdlbnQvYXBpL3YxYWxwaGExO2FwaXYxYWxwaGExYgZwcm90bzM", [file_kagent_api_v1alpha1_common]); + fileDesc("CiBrYWdlbnQvYXBpL3YxYWxwaGExL21vZGVscy5wcm90bxITa2FnZW50LmFwaS52MWFscGhhMSI6Cg5TZWNyZXRNYXRlcmlhbBIMCgRuYW1lGAEgASgJEgsKA2tleRgCIAEoCRINCgV2YWx1ZRgDIAEoCSIZChdMaXN0TW9kZWxDb25maWdzUmVxdWVzdCKjAQoLTW9kZWxDb25maWcSMwoDcmVmGAEgASgLMiYua2FnZW50LmFwaS52MWFscGhhMS5SZXNvdXJjZVJlZmVyZW5jZRI3CghyZXNvdXJjZRgCIAEoCzIlLmthZ2VudC5hcGkudjFhbHBoYTEuU3RydWN0dXJlZE9iamVjdBISCgpjYW5fdXBkYXRlGAMgASgIEhIKCmNhbl9kZWxldGUYBCABKAgiZwoYTGlzdE1vZGVsQ29uZmlnc1Jlc3BvbnNlEjcKDW1vZGVsX2NvbmZpZ3MYASADKAsyIC5rYWdlbnQuYXBpLnYxYWxwaGExLk1vZGVsQ29uZmlnEhIKCmNhbl9jcmVhdGUYAiABKAgiTAoVR2V0TW9kZWxDb25maWdSZXF1ZXN0EjMKA3JlZhgBIAEoCzImLmthZ2VudC5hcGkudjFhbHBoYTEuUmVzb3VyY2VSZWZlcmVuY2UiUAoWR2V0TW9kZWxDb25maWdSZXNwb25zZRI2Cgxtb2RlbF9jb25maWcYASABKAsyIC5rYWdlbnQuYXBpLnYxYWxwaGExLk1vZGVsQ29uZmlnIs8BChhDcmVhdGVNb2RlbENvbmZpZ1JlcXVlc3QSMwoDcmVmGAEgASgLMiYua2FnZW50LmFwaS52MWFscGhhMS5SZXNvdXJjZVJlZmVyZW5jZRI3CghyZXNvdXJjZRgCIAEoCzIlLmthZ2VudC5hcGkudjFhbHBoYTEuU3RydWN0dXJlZE9iamVjdBIPCgdhcGlfa2V5GAMgASgJEjQKB3NlY3JldHMYBCADKAsyIy5rYWdlbnQuYXBpLnYxYWxwaGExLlNlY3JldE1hdGVyaWFsIlMKGUNyZWF0ZU1vZGVsQ29uZmlnUmVzcG9uc2USNgoMbW9kZWxfY29uZmlnGAEgASgLMiAua2FnZW50LmFwaS52MWFscGhhMS5Nb2RlbENvbmZpZyLgAQoYVXBkYXRlTW9kZWxDb25maWdSZXF1ZXN0EjMKA3JlZhgBIAEoCzImLmthZ2VudC5hcGkudjFhbHBoYTEuUmVzb3VyY2VSZWZlcmVuY2USNwoIcmVzb3VyY2UYAiABKAsyJS5rYWdlbnQuYXBpLnYxYWxwaGExLlN0cnVjdHVyZWRPYmplY3QSFAoHYXBpX2tleRgDIAEoCUgAiAEBEjQKB3NlY3JldHMYBCADKAsyIy5rYWdlbnQuYXBpLnYxYWxwaGExLlNlY3JldE1hdGVyaWFsQgoKCF9hcGlfa2V5IlMKGVVwZGF0ZU1vZGVsQ29uZmlnUmVzcG9uc2USNgoMbW9kZWxfY29uZmlnGAEgASgLMiAua2FnZW50LmFwaS52MWFscGhhMS5Nb2RlbENvbmZpZyJPChhEZWxldGVNb2RlbENvbmZpZ1JlcXVlc3QSMwoDcmVmGAEgASgLMiYua2FnZW50LmFwaS52MWFscGhhMS5SZXNvdXJjZVJlZmVyZW5jZSIbChlEZWxldGVNb2RlbENvbmZpZ1Jlc3BvbnNlImIKElByb3ZpZGVyRGVmaW5pdGlvbhIMCgRuYW1lGAEgASgJEgwKBHR5cGUYAiABKAkSFwoPcmVxdWlyZWRfcGFyYW1zGAMgAygJEhcKD29wdGlvbmFsX3BhcmFtcxgEIAMoCSIkCiJMaXN0U3VwcG9ydGVkTW9kZWxQcm92aWRlcnNSZXF1ZXN0ImEKI0xpc3RTdXBwb3J0ZWRNb2RlbFByb3ZpZGVyc1Jlc3BvbnNlEjoKCXByb3ZpZGVycxgBIAMoCzInLmthZ2VudC5hcGkudjFhbHBoYTEuUHJvdmlkZXJEZWZpbml0aW9uIiUKI0xpc3RTdXBwb3J0ZWRNZW1vcnlQcm92aWRlcnNSZXF1ZXN0ImIKJExpc3RTdXBwb3J0ZWRNZW1vcnlQcm92aWRlcnNSZXNwb25zZRI6Cglwcm92aWRlcnMYASADKAsyJy5rYWdlbnQuYXBpLnYxYWxwaGExLlByb3ZpZGVyRGVmaW5pdGlvbiJCChJDb25maWd1cmVkUHJvdmlkZXISDAoEbmFtZRgBIAEoCRIMCgR0eXBlGAIgASgJEhAKCGVuZHBvaW50GAMgASgJIiAKHkxpc3RDb25maWd1cmVkUHJvdmlkZXJzUmVxdWVzdCJdCh9MaXN0Q29uZmlndXJlZFByb3ZpZGVyc1Jlc3BvbnNlEjoKCXByb3ZpZGVycxgBIAMoCzInLmthZ2VudC5hcGkudjFhbHBoYTEuQ29uZmlndXJlZFByb3ZpZGVyIkMKGUxpc3RQcm92aWRlck1vZGVsc1JlcXVlc3QSFQoNcHJvdmlkZXJfbmFtZRgBIAEoCRIPCgdyZWZyZXNoGAIgASgIIj4KGkxpc3RQcm92aWRlck1vZGVsc1Jlc3BvbnNlEhAKCHByb3ZpZGVyGAEgASgJEg4KBm1vZGVscxgCIAMoCSIzCglNb2RlbEluZm8SDAoEbmFtZRgBIAEoCRIYChBmdW5jdGlvbl9jYWxsaW5nGAIgASgIIlIKDlByb3ZpZGVyTW9kZWxzEhAKCHByb3ZpZGVyGAEgASgJEi4KBm1vZGVscxgCIAMoCzIeLmthZ2VudC5hcGkudjFhbHBoYTEuTW9kZWxJbmZvIhwKGkxpc3RTdXBwb3J0ZWRNb2RlbHNSZXF1ZXN0IlUKG0xpc3RTdXBwb3J0ZWRNb2RlbHNSZXNwb25zZRI2Cglwcm92aWRlcnMYASADKAsyIy5rYWdlbnQuYXBpLnYxYWxwaGExLlByb3ZpZGVyTW9kZWxzMucJCgxNb2RlbFNlcnZpY2USbwoQTGlzdE1vZGVsQ29uZmlncxIsLmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdE1vZGVsQ29uZmlnc1JlcXVlc3QaLS5rYWdlbnQuYXBpLnYxYWxwaGExLkxpc3RNb2RlbENvbmZpZ3NSZXNwb25zZRJpCg5HZXRNb2RlbENvbmZpZxIqLmthZ2VudC5hcGkudjFhbHBoYTEuR2V0TW9kZWxDb25maWdSZXF1ZXN0Gisua2FnZW50LmFwaS52MWFscGhhMS5HZXRNb2RlbENvbmZpZ1Jlc3BvbnNlEnIKEUNyZWF0ZU1vZGVsQ29uZmlnEi0ua2FnZW50LmFwaS52MWFscGhhMS5DcmVhdGVNb2RlbENvbmZpZ1JlcXVlc3QaLi5rYWdlbnQuYXBpLnYxYWxwaGExLkNyZWF0ZU1vZGVsQ29uZmlnUmVzcG9uc2UScgoRVXBkYXRlTW9kZWxDb25maWcSLS5rYWdlbnQuYXBpLnYxYWxwaGExLlVwZGF0ZU1vZGVsQ29uZmlnUmVxdWVzdBouLmthZ2VudC5hcGkudjFhbHBoYTEuVXBkYXRlTW9kZWxDb25maWdSZXNwb25zZRJyChFEZWxldGVNb2RlbENvbmZpZxItLmthZ2VudC5hcGkudjFhbHBoYTEuRGVsZXRlTW9kZWxDb25maWdSZXF1ZXN0Gi4ua2FnZW50LmFwaS52MWFscGhhMS5EZWxldGVNb2RlbENvbmZpZ1Jlc3BvbnNlEpABChtMaXN0U3VwcG9ydGVkTW9kZWxQcm92aWRlcnMSNy5rYWdlbnQuYXBpLnYxYWxwaGExLkxpc3RTdXBwb3J0ZWRNb2RlbFByb3ZpZGVyc1JlcXVlc3QaOC5rYWdlbnQuYXBpLnYxYWxwaGExLkxpc3RTdXBwb3J0ZWRNb2RlbFByb3ZpZGVyc1Jlc3BvbnNlEpMBChxMaXN0U3VwcG9ydGVkTWVtb3J5UHJvdmlkZXJzEjgua2FnZW50LmFwaS52MWFscGhhMS5MaXN0U3VwcG9ydGVkTWVtb3J5UHJvdmlkZXJzUmVxdWVzdBo5LmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdFN1cHBvcnRlZE1lbW9yeVByb3ZpZGVyc1Jlc3BvbnNlEoQBChdMaXN0Q29uZmlndXJlZFByb3ZpZGVycxIzLmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdENvbmZpZ3VyZWRQcm92aWRlcnNSZXF1ZXN0GjQua2FnZW50LmFwaS52MWFscGhhMS5MaXN0Q29uZmlndXJlZFByb3ZpZGVyc1Jlc3BvbnNlEnUKEkxpc3RQcm92aWRlck1vZGVscxIuLmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdFByb3ZpZGVyTW9kZWxzUmVxdWVzdBovLmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdFByb3ZpZGVyTW9kZWxzUmVzcG9uc2USeAoTTGlzdFN1cHBvcnRlZE1vZGVscxIvLmthZ2VudC5hcGkudjFhbHBoYTEuTGlzdFN1cHBvcnRlZE1vZGVsc1JlcXVlc3QaMC5rYWdlbnQuYXBpLnYxYWxwaGExLkxpc3RTdXBwb3J0ZWRNb2RlbHNSZXNwb25zZUJJWkdnaXRodWIuY29tL2thZ2VudC1kZXYva2FnZW50L2dvL2FwaS9nZW4va2FnZW50L2FwaS92MWFscGhhMTthcGl2MWFscGhhMWIGcHJvdG8z", [file_kagent_api_v1alpha1_common]); /** * @generated from message kagent.api.v1alpha1.SecretMaterial @@ -67,6 +67,16 @@ export type ModelConfig = Message<"kagent.api.v1alpha1.ModelConfig"> & { * @generated from field: kagent.api.v1alpha1.StructuredObject resource = 2; */ resource?: StructuredObject | undefined; + + /** + * @generated from field: bool can_update = 3; + */ + canUpdate: boolean; + + /** + * @generated from field: bool can_delete = 4; + */ + canDelete: boolean; }; /** @@ -84,6 +94,11 @@ export type ListModelConfigsResponse = Message<"kagent.api.v1alpha1.ListModelCon * @generated from field: repeated kagent.api.v1alpha1.ModelConfig model_configs = 1; */ modelConfigs: ModelConfig[]; + + /** + * @generated from field: bool can_create = 2; + */ + canCreate: boolean; }; /** diff --git a/ui/src/mocks/mockBackend.test.ts b/ui/src/mocks/mockBackend.test.ts index 66e9cb1099..c429a50b74 100644 --- a/ui/src/mocks/mockBackend.test.ts +++ b/ui/src/mocks/mockBackend.test.ts @@ -408,7 +408,7 @@ describe("the fixture backend", () => { it("empties the lists", async () => { expect(await invoke("agents.list", {})).toEqual([]); - expect(await invoke("models.list", {})).toEqual([]); + expect(await invoke("models.list", {})).toEqual({ items: [], canCreate: true }); expect(await invoke("namespaces.list", {})).toEqual([]); }); diff --git a/ui/src/mocks/transport.ts b/ui/src/mocks/transport.ts index 343e50e3b8..941dc23ce1 100644 --- a/ui/src/mocks/transport.ts +++ b/ui/src/mocks/transport.ts @@ -501,6 +501,8 @@ function modelMessage(model: ModelConfig) { const ref = splitRef(model.ref); return { ref, + canUpdate: true, + canDelete: true, resource: structured("ModelConfig", { apiVersion: "kagent.dev/v1alpha3", kind: "ModelConfig", @@ -516,6 +518,7 @@ const specOf = (resource: { value?: JsonObject } | undefined) => on(ModelService.method.listModelConfigs, (_input, call) => ({ modelConfigs: call.scenario === "empty" ? [] : allModels().map(modelMessage), + canCreate: true, })); on(ModelService.method.getModelConfig, (input, call) => { @@ -1222,7 +1225,7 @@ const instanceShareMessage = (share: AgentInstanceShare) => ({ // --------------------------------------------------------------------------- on(HarnessService.method.listHarnesses, (input, call) => { - if (call.scenario === "empty") return { harnesses: [] }; + if (call.scenario === "empty") return { harnesses: [], canCreate: true }; const scope = input.namespace.trim(); return { harnesses: allHarnesses() @@ -1233,7 +1236,9 @@ on(HarnessService.method.listHarnesses, (input, call) => { runtime: harness.runtime, workloadImage: harness.workloadImage, ready: harness.ready, + canDelete: true, })), + canCreate: true, }; }); @@ -1243,6 +1248,8 @@ const agentTemplateMessage = (template: AgentTemplate) => ({ modelConfigRef: refPair(template.modelConfigRef), description: template.description, admittingHarnesses: template.admittingHarnesses, + canUpdate: true, + canDelete: true, }); /** The template at this ref, or the controller's own `NotFound`. */ @@ -1331,12 +1338,13 @@ function templateFromResource( } on(AgentTemplateService.method.listAgentTemplates, (input, call) => { - if (call.scenario === "empty") return { agentTemplates: [] }; + if (call.scenario === "empty") return { agentTemplates: [], canCreate: true }; const scope = input.namespace.trim(); return { agentTemplates: allAgentTemplates() .filter((template) => scope === "" || template.namespace === scope) .map(agentTemplateMessage), + canCreate: true, }; }); @@ -1398,6 +1406,7 @@ on(HarnessService.method.createHarness, (input, call) => { runtime: saved.runtime, workloadImage: saved.workloadImage, ready: saved.ready, + canDelete: true, }, }; }); diff --git a/ui/src/pages/AgentTemplateDetailsPage.tsx b/ui/src/pages/AgentTemplateDetailsPage.tsx index 9281514445..46807fd8d2 100644 --- a/ui/src/pages/AgentTemplateDetailsPage.tsx +++ b/ui/src/pages/AgentTemplateDetailsPage.tsx @@ -385,6 +385,7 @@ export function AgentTemplateDetailsPage() { icon={} onClick={() => setEditingRef(ref)} data-testid="template-edit" + disabled={!template.data.canUpdate} > Edit @@ -406,6 +407,7 @@ export function AgentTemplateDetailsPage() { 0} + disabled={!templates.canCreate || problems.length > 0} onClick={() => void create()} data-testid="template-submit" > diff --git a/ui/src/pages/AgentTemplatesPage.tsx b/ui/src/pages/AgentTemplatesPage.tsx index ccdb65ee3e..fdefaf824d 100644 --- a/ui/src/pages/AgentTemplatesPage.tsx +++ b/ui/src/pages/AgentTemplatesPage.tsx @@ -211,6 +211,7 @@ export function AgentTemplatesTab() { apiClient.agentBuildingBlocks.removeAgentTemplate( row.namespace, diff --git a/ui/src/pages/ModelEditPage.tsx b/ui/src/pages/ModelEditPage.tsx index 6713543932..4dcb1c7a81 100644 --- a/ui/src/pages/ModelEditPage.tsx +++ b/ui/src/pages/ModelEditPage.tsx @@ -66,7 +66,15 @@ export function ModelEditPage() { {model.isLoading ? : null} - {model.data ? ( + {model.data && !model.data.canUpdate ? ( + + ) : null} + + {model.data?.canUpdate ? ( { await apiClient.models.create(payload); @@ -31,7 +32,17 @@ export function ModelNewPage() { } > - + {models.isLoading ? ( + + ) : models.canCreate ? ( + + ) : ( + + )} ); } diff --git a/ui/src/pages/ModelsPage.tsx b/ui/src/pages/ModelsPage.tsx index 71e36ff75c..96a3015039 100644 --- a/ui/src/pages/ModelsPage.tsx +++ b/ui/src/pages/ModelsPage.tsx @@ -51,7 +51,7 @@ const PAGE_SIZE = 25; */ export function ModelsPage() { const theme = useTheme(); - const { data, isLoading, error, isEmpty, refresh } = useModels(); + const { data, canCreate, isLoading, error, isEmpty, refresh } = useModels(); const view = useListView(FILTER_IDS); const models = useMemo(() => data ?? [], [data]); @@ -156,18 +156,29 @@ export function ModelsPage() { const { namespace, name } = parseRef(row.ref); return ( - + {row.canUpdate ? ( + + + + ) : ( + - + )} } > diff --git a/ui/src/pages/agents/AgentsLandingPage.tsx b/ui/src/pages/agents/AgentsLandingPage.tsx index 51761a2d5e..dddaad148f 100644 --- a/ui/src/pages/agents/AgentsLandingPage.tsx +++ b/ui/src/pages/agents/AgentsLandingPage.tsx @@ -7,6 +7,11 @@ import { paths } from "@/router/routes"; import { PageFrame } from "@/components/Structure/PageFrame"; import { AgentsTab } from "@/pages/AgentsPage"; import { AgentTemplatesTab } from "@/pages/AgentTemplatesPage"; +import { + useAgentTemplatesAcrossNamespaces, + useHarnessesAcrossNamespaces, + useNamespaces, +} from "@/api"; import { AgentConcepts } from "./AgentConcepts"; import { HarnessesTab } from "./HarnessesTab"; @@ -29,6 +34,10 @@ type TabKey = (typeof TABS)[number]; export function AgentsLandingPage() { const theme = useTheme(); const [params, setParams] = useSearchParams(); + const namespaces = useNamespaces(); + const namespaceNames = namespaces.data?.map((entry) => entry.name); + const templates = useAgentTemplatesAcrossNamespaces(namespaceNames); + const harnesses = useHarnessesAcrossNamespaces(namespaceNames); /* * No page-level refresh, deliberately. @@ -64,16 +73,46 @@ export function AgentsLandingPage() { {/* The point the agents list has always offered, kept where the controls now are rather than left behind in the tab they moved out of. */} - - + + ) : ( + - - - + + ) : ( + - + )} } > diff --git a/ui/src/pages/agents/HarnessNewPage.tsx b/ui/src/pages/agents/HarnessNewPage.tsx index ca19b30f5e..3ebb647bfc 100644 --- a/ui/src/pages/agents/HarnessNewPage.tsx +++ b/ui/src/pages/agents/HarnessNewPage.tsx @@ -3,7 +3,7 @@ import { Alert, Button, Card, Form, Input, Select, Space, Typography } from "ant import { useTheme } from "@emotion/react"; import { useNavigate } from "react-router-dom"; import { PageFrame } from "@/components/Structure/PageFrame"; -import { apiClient, useNamespaces } from "@/api"; +import { apiClient, useHarnesses, useNamespaces } from "@/api"; import { HARNESS_ADAPTERS, HARNESS_IMAGE_PATTERN, @@ -38,6 +38,7 @@ export function HarnessNewPage() { const namespaces = useNamespaces(); const [namespace, setNamespace] = useState(); + const harnesses = useHarnesses(namespace); const [name, setName] = useState(""); const [adapter, setAdapter] = useState("kagent"); const [image, setImage] = useState(""); @@ -234,7 +235,7 @@ export function HarnessNewPage() { type="primary" data-testid="harness-create" loading={saving} - disabled={!ready} + disabled={!harnesses.canCreate || !ready} onClick={() => void create()} > Create harness diff --git a/ui/src/pages/agents/HarnessesTab.tsx b/ui/src/pages/agents/HarnessesTab.tsx index 8f9083735c..d6bcd8b5a1 100644 --- a/ui/src/pages/agents/HarnessesTab.tsx +++ b/ui/src/pages/agents/HarnessesTab.tsx @@ -196,7 +196,7 @@ export function HarnessesTab() { remove(row)} onDeleted={() => undefined} description={describeLoss(admitted(row))}