Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 13 additions & 14 deletions .agents/skills/kagent-dev/references/scoped-authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,13 @@ is represented as two `AnyOf` clauses. The first contains both predicates in `Al

The shared scope types and attribute names live in [`go/api/authorization`](../../../../go/api/authorization/scope.go). Services use those public types directly.

## Trusted resource attributes
## Resource identities

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.

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 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.
Current object checks use `auth.Resource{Type, Namespace, Name}`.
Validate the requested reference and authorize its exact namespace/name before reading protected content or writing storage.
This ordering prevents a denied caller from distinguishing a missing object from an existing one.
Creates validate and normalize the proposed object identity before checking authorization.
Do not add caller-controlled attributes beyond this identity without defining their trusted source and validation.

## Collection flow

Expand All @@ -54,10 +50,13 @@ Pass scopes as ordinary function arguments. Do not place policy decisions in req

## 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.
- `Agent`, `AgentTemplate`, `Harness`, and `SandboxTemplate` use `kubecrud.NewService` with a required `CollectionAuthorizer`.
- `ModelConfig` and `ModelProviderConfig` use separate typed CRUD services and resource types. Configured-provider reads and refresh are authorized independently from ModelConfig operations.
- Prompt libraries, tool discovery, and WorkerPool summary rows use `kubeauth.CollectionMatcher` for custom collection projection.
- Flat tool discovery must filter through the same exact owning-server identities as server discovery.
- `GetSubstrateSummary` requires `Check(VerbGet, Resource{Type: "SystemSummary"})` before any resource reads; denial fails the whole request with permission denied. WorkerPool rows then use their own `VerbList` scope.
- The summary capability covers its ATE inventory. Dedicated actor and worker endpoints independently require `Check(VerbGet, Resource{Type: "Substrate"})`. Both capabilities have empty namespace/name; neither uses `Scope(VerbGet, ...)`.
- Static provider definitions and supported tool-server types expose no configured object and remain available after authentication.

The default OSS no-op authorizer implements `CollectionAuthorizer` and returns `ScopeAll`, preserving current OSS visibility.

Expand Down
31 changes: 26 additions & 5 deletions design/EP-1270-scoped-authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,25 @@ Kagent needs resource-scoped authorization for configuration resources without c

Authorization decisions use trusted resource identity. Unauthorized resources are omitted from collections, and unauthorized operations fail with the standard permission-denied response.

## Initial scope
## Protected resources

| Resource type | Operations | Attributes |
| --- | --- | --- |
| `Agent` | list, get, create, update, delete | `namespace`, `name` |
| `AgentTemplate` | list, get, create, update, delete | `namespace`, `name` |
| `Harness` | list, create, delete | `namespace`, `name` |
| `SandboxTemplate` | list, create, delete | `namespace`, `name` |
| `ModelConfig` | list, get, create, update, delete | `namespace`, `name` |
| `ModelProviderConfig` | list configured providers, get cached models, update to refresh models | `namespace`, `name` |
| `ToolServer` | list servers and tools, create, delete, MCP operations | `namespace`, `name` |
| `PromptTemplate` | list, get, create, update, delete prompt libraries | `namespace`, `name` |
| `WorkerPool` | list rows within the system summary | `namespace`, `name` |

`ListConfiguredProviders` derives entries from the separate `ModelProviderConfig` resource and is not changed by this design.
`ListConfiguredProviders` applies the `ModelProviderConfig` list scope before projecting ready providers. Cached provider-model reads require get on the exact provider config; refresh requires update before invoking discovery. Session shares cannot refresh models, even when the authorizer permits update.

Tool discovery applies the `ToolServer` list scope to exact owning-server identities. Flat tool lists contain only tools belonging to those same visible servers. MCP tool discovery and resource reads require get on the referenced server; tool invocation requires create. Prompt-library identity is the namespace and name of its backing ConfigMap, rather than an individual prompt key.

Static provider definitions and supported tool-server types contain no configured resource and remain available after authentication.

## Goals

Expand All @@ -27,7 +37,8 @@ Authorization decisions use trusted resource identity. Unauthorized resources ar
## Non-goals

- Define roles, policies, claims, subjects, grants, or catalog keys.
- Protect `SandboxAgent`, `AgentHarness`, `Session`, `ModelProviderConfig`, tool server, or prompt template resources.
- Extend this configuration-resource contract to legacy `SandboxAgent` or `AgentHarness` resources or to `Session` authorization.
- Filter ATE actor, worker, or actor-template inventory using Kubernetes object scopes.
- Expose policy-engine, SQL, Kubernetes, or other backend expressions.
- Predict authorization for UI controls.

Expand All @@ -42,7 +53,7 @@ A collection decision may allow the complete collection, deny the complete colle

For example, a principal may be allowed resources named `agent-a` or `agent-b` in `team-a`, as well as any resource in `shared`. The relationship between name and namespace must remain intact in the authorization decision.

Only namespace and name are supported for the initial resource set. Additional attributes or operations require a separate design decision based on a concrete authorization need.
Only namespace and name are supported for scoped resource collections. Additional attributes or operations require a separate design decision based on a concrete authorization need.

Unsupported or invalid authorization decisions fail closed.

Expand All @@ -62,6 +73,16 @@ A protected collection returns only resources permitted by its collection decisi

A decision that permits no resources returns an empty collection. An authorization failure or a decision that cannot be safely applied fails the request; it never broadens access.

## Summary and Substrate capabilities

`GetSubstrateSummary` first requires `Check(VerbGet, Resource{Type: "SystemSummary"})`, with empty namespace and name. This is a whole-summary capability that policies may grant to operators or administrators. Denial fails the request with permission denied before any Kubernetes or ATE reads; the service does not return an apparently empty inventory for denied access.

After this check, WorkerPool rows use `Scope(VerbList, "WorkerPool")` and are intersected with configured and requested namespaces. A `NONE` pool scope returns no pool rows while still allowing ATE inventory under the summary capability. Invalid scopes and evaluator errors fail the request.

The summary capability grants the ATE counts and actor-template inventory included in the summary. It does not require or imply permission to use the dedicated actor and worker endpoints. Those endpoints independently require `Check(VerbGet, Resource{Type: "Substrate"})`, also with empty namespace and name. External authorizers may intentionally grant these two capabilities independently. Existing ATE atespace and worker-namespace filters remain in place; WorkerPool scope does not isolate ATE inventory.

`Scope` is reserved for `VerbList` collection filtering. Neither capability uses `Scope(VerbGet, ...)`.

## Client behavior

Catalog responses do not include create, update, or delete capability hints for presentation logic. Such hints duplicate policy decisions, can become stale, and couple the public API to a particular client experience.
Expand All @@ -72,7 +93,7 @@ A client may therefore display an action that the caller cannot complete. The at

The default OSS installation permits the complete protected collections and their existing operations. External authorization integrations may narrow that access.

Resources outside the initial scope retain their existing authorization behavior.
Resources outside this scope retain their existing authorization behavior.

## Alternatives considered

Expand Down
10 changes: 7 additions & 3 deletions go/core/internal/grpcserver/agenttemplate_harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,18 +37,22 @@ func newTemplateAndHarnessConnection(t *testing.T, objects ...ctrlclient.Object)
return newConfigurationConnection(t, kubeClient)
}

func newConfigurationConnection(t *testing.T, kubeClient ctrlclient.Client) *grpc.ClientConn {
func newConfigurationConnection(t *testing.T, kubeClient ctrlclient.Client, configure ...func(*Config)) *grpc.ClientConn {
t.Helper()
listener := bufconn.Listen(DefaultMaxMessageSize)
server, err := New(Config{
config := Config{
Listener: listener,
Authenticator: &authimpl.InsecureAuthenticator{},
SystemService: testSystemService(),
AgentService: kubecrud.NewService(kubeClient, &pkgauth.NoopAuthorizer{}, &v1alpha3.Agent{}, &v1alpha3.AgentList{}, "Agent"),
AgentTemplateService: kubecrud.NewService(kubeClient, &pkgauth.NoopAuthorizer{}, &v1alpha3.AgentTemplate{}, &v1alpha3.AgentTemplateList{}, "AgentTemplate"),
HarnessService: kubecrud.NewService(kubeClient, &pkgauth.NoopAuthorizer{}, &v1alpha3.Harness{}, &v1alpha3.HarnessList{}, "Harness"),
SandboxTemplateService: kubecrud.NewService(kubeClient, &pkgauth.NoopAuthorizer{}, &v1alpha3.SandboxTemplate{}, &v1alpha3.SandboxTemplateList{}, v1alpha3.SandboxTemplateKind),
})
}
for _, apply := range configure {
apply(&config)
}
server, err := New(config)
if err != nil {
t.Fatalf("New() error = %v", err)
}
Expand Down
136 changes: 136 additions & 0 deletions go/core/internal/grpcserver/resource_authorization_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
package grpcserver

import (
"context"
"errors"
"sync/atomic"
"testing"

"github.com/stretchr/testify/require"
atev1alpha1 "go.ate.dev/substrate/pkg/api/v1alpha1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"sigs.k8s.io/controller-runtime/pkg/client/fake"

apiauthorization "github.com/kagent-dev/kagent/go/api/authorization"
apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1"
"github.com/kagent-dev/kagent/go/api/v1alpha3"
modelservice "github.com/kagent-dev/kagent/go/core/internal/service/model"
promptservice "github.com/kagent-dev/kagent/go/core/internal/service/prompttemplate"
systemservice "github.com/kagent-dev/kagent/go/core/internal/service/system"
"github.com/kagent-dev/kagent/go/core/pkg/auth"
)

type resourceAuthorizer struct{ denySummary bool }

func (a resourceAuthorizer) Check(_ context.Context, _ auth.Principal, verb auth.Verb, resource auth.Resource) error {
if resource.Type == "SystemSummary" && !a.denySummary {
return nil
}
if resource.Name != "allowed" || (resource.Type == "ModelProviderConfig" && verb == auth.VerbUpdate) {
return errors.New("denied")
}
return nil
}

func (resourceAuthorizer) Scope(_ context.Context, _ auth.Principal, _ auth.Verb, _ string) (apiauthorization.AuthorizationScope, error) {
return apiauthorization.AuthorizationScope{Kind: apiauthorization.ScopeAnyOf, AnyOf: []apiauthorization.ScopeClause{{All: []apiauthorization.ScopePredicate{{Attribute: apiauthorization.AttributeName, Operator: apiauthorization.ScopeIn, Values: []string{"allowed"}}}}}}, nil
}

type deniedProviderRefresher struct{ calls atomic.Int32 }

func (r *deniedProviderRefresher) RefreshModelProviderConfigModels(context.Context, string, string) ([]string, error) {
r.calls.Add(1)
return []string{"unexpected"}, nil
}

func TestKubernetesResourceScopesThroughGeneratedClients(t *testing.T) {
scheme := runtime.NewScheme()
require.NoError(t, corev1.AddToScheme(scheme))
require.NoError(t, v1alpha3.AddToScheme(scheme))
require.NoError(t, atev1alpha1.AddToScheme(scheme))
kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
&corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "allowed", Labels: map[string]string{"kagent.dev/prompt-library": "true"}}, Data: map[string]string{"visible": "prompt"}},
&corev1.ConfigMap{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "hidden", Labels: map[string]string{"kagent.dev/prompt-library": "true"}}, Data: map[string]string{"private": "prompt"}},
&v1alpha3.ModelProviderConfig{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "allowed"}, Status: v1alpha3.ModelProviderConfigStatus{Conditions: []metav1.Condition{{Type: v1alpha3.ModelProviderConfigConditionTypeReady, Status: metav1.ConditionTrue}}, DiscoveredModels: []string{"cached"}}},
&v1alpha3.ModelProviderConfig{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "hidden"}, Status: v1alpha3.ModelProviderConfigStatus{Conditions: []metav1.Condition{{Type: v1alpha3.ModelProviderConfigConditionTypeReady, Status: metav1.ConditionTrue}}}},
&atev1alpha1.WorkerPool{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "allowed"}},
&atev1alpha1.WorkerPool{ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "hidden"}},
).Build()
authorizer := resourceAuthorizer{}
refresher := &deniedProviderRefresher{}
connection := newConfigurationConnection(t, kube, func(config *Config) {
config.PromptTemplateService = promptservice.NewService(kube, authorizer)
config.ModelService = modelservice.NewService(kube, authorizer, "team", modelservice.WithProviderModelRefresher(refresher))
config.SystemService = systemservice.NewService(kube, []string{"team"}, authorizer, emptySystemATEClient{})
})
ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("x-user-id", "reader"))
prompts := apiv1.NewPromptTemplateServiceClient(connection)
libraries, err := prompts.ListPromptTemplates(ctx, &apiv1.ListPromptTemplatesRequest{Namespace: "team"})
require.NoError(t, err)
require.Len(t, libraries.GetPromptTemplates(), 1)
require.Equal(t, "allowed", libraries.GetPromptTemplates()[0].GetRef().GetName())
_, err = prompts.GetPromptTemplate(ctx, &apiv1.GetPromptTemplateRequest{Ref: &apiv1.ResourceReference{Namespace: "team", Name: "hidden"}})
assertGRPCCode(t, err, codes.PermissionDenied)
_, err = prompts.CreatePromptTemplate(ctx, &apiv1.CreatePromptTemplateRequest{Ref: &apiv1.ResourceReference{Namespace: "team", Name: "denied"}, Data: map[string]string{"key": "value"}})
assertGRPCCode(t, err, codes.PermissionDenied)

models := apiv1.NewModelServiceClient(connection)
providers, err := models.ListConfiguredProviders(ctx, &apiv1.ListConfiguredProvidersRequest{})
require.NoError(t, err)
require.Len(t, providers.GetProviders(), 1)
require.Equal(t, "allowed", providers.GetProviders()[0].GetName())
cached, err := models.ListProviderModels(ctx, &apiv1.ListProviderModelsRequest{ProviderName: "allowed"})
require.NoError(t, err)
require.Equal(t, []string{"cached"}, cached.GetModels())
_, err = models.ListProviderModels(ctx, &apiv1.ListProviderModelsRequest{ProviderName: "hidden"})
assertGRPCCode(t, err, codes.PermissionDenied)
_, err = models.ListProviderModels(ctx, &apiv1.ListProviderModelsRequest{ProviderName: "allowed", Refresh: true})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also exercise this through a real share-bearing RPC? The service test constructs ShareContext directly, while this generated-client case only covers ordinary authorizer denial. A valid x-share-token with an otherwise permissive authorizer would verify the interceptor-to-service path and that refresh is denied before calling the refresher.

assertGRPCCode(t, err, codes.PermissionDenied)
require.Zero(t, refresher.calls.Load())

summary, err := apiv1.NewSystemServiceClient(connection).GetSubstrateSummary(ctx, &apiv1.GetSubstrateSummaryRequest{Namespace: "team"})
require.NoError(t, err)
require.Len(t, summary.GetWorkerPools(), 1)
require.Equal(t, "allowed", summary.GetWorkerPools()[0].GetRef().GetName())
require.Zero(t, summary.GetActorCount())
require.Empty(t, summary.GetActorTemplates())
}

func TestProviderModelRefreshDeniedThroughShareRPC(t *testing.T) {
scheme := runtime.NewScheme()
require.NoError(t, v1alpha3.AddToScheme(scheme))
kube := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
&v1alpha3.ModelProviderConfig{
ObjectMeta: metav1.ObjectMeta{Namespace: "team", Name: "provider"},
Status: v1alpha3.ModelProviderConfigStatus{DiscoveredModels: []string{"cached"}},
},
).Build()
refresher := &deniedProviderRefresher{}
connection := newConfigurationConnection(t, kube, func(config *Config) {
config.ShareStore = &testShareStore{
sessionShare: &apiv1.SessionShare{SessionId: testSessionID.String()}, ownerUserID: "owner",
}
config.ModelService = modelservice.NewService(kube, &auth.NoopAuthorizer{}, "team", modelservice.WithProviderModelRefresher(refresher))
})
ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("x-user-id", "visitor", "x-share-token", "share"))
models := apiv1.NewModelServiceClient(connection)
cached, err := models.ListProviderModels(ctx, &apiv1.ListProviderModelsRequest{ProviderName: "provider"})
require.NoError(t, err)
require.Equal(t, []string{"cached"}, cached.GetModels())
_, err = models.ListProviderModels(ctx, &apiv1.ListProviderModelsRequest{ProviderName: "provider", Refresh: true})
assertGRPCCode(t, err, codes.PermissionDenied)
require.Zero(t, refresher.calls.Load())
}

func TestSummaryCapabilityDeniedThroughGeneratedClient(t *testing.T) {
connection := newConfigurationConnection(t, nil, func(config *Config) {
config.SystemService = systemservice.NewService(nil, nil, resourceAuthorizer{denySummary: true}, nil)
})
ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("x-user-id", "reader"))
_, err := apiv1.NewSystemServiceClient(connection).GetSubstrateSummary(ctx, &apiv1.GetSubstrateSummaryRequest{})
assertGRPCCode(t, err, codes.PermissionDenied)
}
Loading
Loading