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..1e51cc02f4 --- /dev/null +++ b/.claude/skills/kagent-dev/references/scoped-authorization.md @@ -0,0 +1,79 @@ +# 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. + +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` 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. + +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`; +- 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. diff --git a/design/scoped-authorization.md b/design/scoped-authorization.md new file mode 100644 index 0000000000..0d2b3d0c70 --- /dev/null +++ b/design/scoped-authorization.md @@ -0,0 +1,212 @@ +# Scoped Authorization for Kagent Configuration Resources + +Status: Draft + +## Summary + +Kagent uses `Authorizer.Check` for resource authorization. + +This design adds scoped authorization for `AgentTemplate`, `Harness`, 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. + +The protected RPC responses will also report the actions that the caller can use. + +## Initial scope + +| Resource type | Operations | Attributes | +| --- | --- | --- | +| `AgentTemplate` | list, get, create, update, delete | `namespace`, `name` | +| `Harness` | list, create, delete | `namespace`, `name` | +| `ModelConfig` | list, get, create, update, delete | `namespace`, `name` | + +`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`, `AgentHarness`, `AgentInstance`, or `ModelProviderConfig` resources. +- This design does not protect tool servers or prompt templates. +- 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" +) + +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. + +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. + +## 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. + +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. + +## 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, and `ScopeIn`. + +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 each protected list requests the correct resource type and filters unauthorized 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 `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. +- [x] Add focused service and matcher tests. +- [x] Add action capabilities to the protected RPC responses. + +## 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. + +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 new file mode 100644 index 0000000000..b477e8763b --- /dev/null +++ b/go/api/authorization/scope.go @@ -0,0 +1,35 @@ +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" +) + +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/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 ce7cb8e690..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 } @@ -81,16 +93,17 @@ 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) + result, err := s.service.Update(ctx, ref, func(existing *v1alpha3.AgentTemplate) { + existing.Spec = *incoming.Spec.DeepCopy() + }) if err != nil { return nil, err } - existing.Spec = *incoming.Spec.DeepCopy() - result, err := s.service.SaveUpdate(ctx, existing) + capabilities, err := loadResourceCapabilities(ctx, s.service.Scope, true) if err != nil { return nil, err } - template, err := s.agentTemplate(result) + template, err := s.agentTemplate(result, capabilities) if err != nil { return nil, err } @@ -108,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) @@ -129,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/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/kubeauth/scope.go b/go/core/internal/service/kubeauth/scope.go new file mode 100644 index 0000000000..549d87cc0f --- /dev/null +++ b/go/core/internal/service/kubeauth/scope.go @@ -0,0 +1,91 @@ +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) + } + 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) + } + } + } + + 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() + } + matches = value != "" && slices.Contains(predicate.Values, 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..c266a8113a --- /dev/null +++ b/go/core/internal/service/kubeauth/scope_test.go @@ -0,0 +1,90 @@ +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"}}, + }}}}, + }, + } + + 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) + } + }) + } +} + +func TestScopeMatcherRejectsInvalidScopes(t *testing.T) { + tests := []auth.AuthorizationScope{ + {}, + {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{""}}}}}}, + } + + 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("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" { + 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..6ed9439750 100644 --- a/go/core/internal/service/kubecrud/service.go +++ b/go/core/internal/service/kubecrud/service.go @@ -6,6 +6,7 @@ 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" @@ -25,13 +26,13 @@ type Service[T Object, L client.ObjectList] struct { client client.Client object T list L - authorizer auth.Authorizer + authorizer auth.CollectionAuthorizer resource string } func NewService[T Object, L client.ObjectList]( client client.Client, - authorizer auth.Authorizer, + authorizer auth.CollectionAuthorizer, object T, list L, resource string, @@ -42,19 +43,27 @@ func NewService[T Object, L client.ObjectList]( } 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) } + scope, err := s.Scope(ctx, auth.VerbList) + 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(object) { + items = append(items, object) + } return nil }); err != nil { return nil, serviceerrors.NewInternal("Failed to read "+s.resource+" list", err) @@ -68,10 +77,14 @@ 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 { + 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 +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 } - if err := s.authorize(ctx, auth.VerbCreate, auth.Resource{Type: s.resource, Name: ref.String()}); 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 { @@ -100,21 +113,23 @@ 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 { + object, err := s.get(ctx, ref) + if 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 err := s.authorize(ctx, auth.VerbUpdate, kubeauth.Resource(s.resource, object)); 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 err := s.client.Update(ctx, object); err != nil { if apierrors.IsInvalid(err) { return zero, serviceerrors.NewInvalidArgument("Invalid "+s.resource, err) @@ -128,19 +143,31 @@ 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 - } object, err := s.get(ctx, ref) if 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) } return nil } +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(), verb, 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..f891e21d76 --- /dev/null +++ b/go/core/internal/service/kubecrud/service_test.go @@ -0,0 +1,162 @@ +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 TestServiceFiltersBeforeSortingAndUsesTrustedAttributes(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 := NewService(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 TestHarnessServiceFiltersList(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 := NewService(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 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 := NewService( + 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..2226b2f67f 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, auth.VerbList) + 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, 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(), verb, "ModelConfig") + 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/app/app.go b/go/core/pkg/app/app.go index 4d829f44a8..957d92116f 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -72,9 +72,9 @@ type Options struct { // Authenticator identifies the caller. Nil selects UnsecureAuthenticator, // which admits every request. Authenticator auth.AuthProvider - // Authorizer decides what an identified caller may do. Nil selects - // NoopAuthorizer, which permits every action. - Authorizer auth.Authorizer + // Authorizer decides what an identified caller may do and which collection + // entries it may see. Nil selects NoopAuthorizer, which permits every action. + Authorizer auth.CollectionAuthorizer // SetupWithManager registers additional controllers and scheme types on // core's manager. It runs after the manager exists and before it starts, so // a scheme added here is in place before any cache is built. Returning an @@ -107,7 +107,7 @@ type Options struct { // resolve substitutes core's defaults for whichever components the caller left // nil. It never returns a nil component, so callers do not have to check. -func (o Options) resolve() (auth.AuthProvider, auth.Authorizer) { +func (o Options) resolve() (auth.AuthProvider, auth.CollectionAuthorizer) { authenticator := o.Authenticator if authenticator == nil { authenticator = &authimpl.UnsecureAuthenticator{} diff --git a/go/core/pkg/app/app_test.go b/go/core/pkg/app/app_test.go index 0213d2260f..153ee3ba73 100644 --- a/go/core/pkg/app/app_test.go +++ b/go/core/pkg/app/app_test.go @@ -28,6 +28,10 @@ func (stubAuthorizer) Check(context.Context, auth.Principal, auth.Verb, auth.Res return nil } +func (stubAuthorizer) Scope(context.Context, auth.Principal, auth.Verb, string) (auth.AuthorizationScope, error) { + return auth.AuthorizationScope{Kind: auth.ScopeAll}, nil +} + func TestOptionsResolve(t *testing.T) { consumerAuthn := stubAuthenticator{} consumerAuthz := stubAuthorizer{} @@ -36,7 +40,7 @@ func TestOptionsResolve(t *testing.T) { name string opts Options wantAuthn auth.AuthProvider - wantAuthz auth.Authorizer + wantAuthz auth.CollectionAuthorizer }{ { name: "both nil selects core defaults", diff --git a/go/core/pkg/auth/auth.go b/go/core/pkg/auth/auth.go index 1d61d06fba..56e37e4a3c 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 { @@ -72,6 +76,34 @@ 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 +) + +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) } } 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))}