diff --git a/design/EP-1270-scoped-authorization.md b/design/EP-1270-scoped-authorization.md index 329525f6e3..dbeab3e06b 100644 --- a/design/EP-1270-scoped-authorization.md +++ b/design/EP-1270-scoped-authorization.md @@ -75,6 +75,10 @@ A decision that permits no resources returns an empty collection. An authorizati ## Summary and Substrate capabilities +`ListNamespaces` requires `Check(VerbGet, Resource{Type: "SystemNamespaces"})` +before reading namespace inventory, including configured watched namespaces. +This whole-inventory capability is independent of object collection scopes. + `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. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 4ea4f4c487..b91a39ddbb 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -45,8 +45,9 @@ resources retain their separate API versions. See the [protobuf API conventions](../protobuf-api.md) for compatibility and generation rules. The public and runtime APIs share a gRPC listener and retain method-level authentication policies. -TaskStore requires workload authority; Memory currently uses ordinary controller -authentication. The runtime namespace identifies its consumers, not an additional +TaskStore and Memory writes/search require verified actor authority. Memory +list/delete accept users and actors, with service authorization and data ownership +checks. Shared APIs use the same authentication provider and resource authorizer. The runtime namespace identifies its consumers, not an additional access restriction. Configuration messages are process inputs, with no RPC services. Browser bindings exclude runtime and configuration contracts. diff --git a/docs/architecture/a2a-gateway.md b/docs/architecture/a2a-gateway.md index ef605561d2..40ccfcaf22 100644 --- a/docs/architecture/a2a-gateway.md +++ b/docs/architecture/a2a-gateway.md @@ -186,21 +186,70 @@ Codex and Claude reserve their native session while approval/input is pending. Their local executor interceptor rejects input for another task before SDK execution. Other harnesses retain their existing policy for parked tasks. All harnesses permit at most one active execution. -## Runtime authority and deployment prerequisite - -TaskStore currently uses a temporary unsigned -`x-kagent-insecure-runtime-identity` header carrying the projected atespace, actor -name, and UID. Go and Python reread those files on every call so restored actors -use their own identity. The API checks the requested session, atespace, and -recorded actor UID; user and share credentials do not grant private persistence -access. Public authentication remains unchanged. - -This is the single prerelease path, with no configuration switch. The header can -be forged and does not establish verified actor identity. These builds are for -isolated deployments until [Substrate #1660](https://github.com/agent-substrate/substrate/issues/1660) -supplies actor JWT injection. That implementation will replace the header path -outright, including trusted issuer/JWKS, signature, audience, and expiry validation; -there will be no fallback to unsigned headers. +## Runtime authority + +Actor and user calls use the same controller listener (`server.bindAddress`, +`:8083` by default). Actor calls require a Substrate-issued Actor JWT in +`Authorization`. Go and Python controller clients send `Bearer placeholder`; +the exact control-plane origin's egress rule replaces it with a five-minute JWT +bound to the `kagent-runtime` audience. Other destinations receive no runtime JWT, +and this rule does not grant network access to sandboxes that cannot already +reach the control plane. + +HTTP, MCP, and gRPC use the same `auth.AuthProvider`. It selects the Actor JWT +verifier using the issuer or actor claim, then binds the verified identity to the +current session's atespace and Actor UID. Invalid or stale actor credentials never +fall back to user authentication. `Principal.Actor` distinguishes a verified +actor from a user-supplied agent header; its associated user identifies the data +owner and does not grant the user's permissions. + +Services authorize resources through `auth.Authorizer` / `CollectionAuthorizer`. +The default actor policy permits reading its own Agent, listing only that Agent, +and reading, sending to, or updating its existing Session. Session collections +filter out every other session before pagination, including sessions for the same +agent and owner. Creating or deleting sessions, using shares, and access to other +protected resources are denied. Namespace inventory requires the +`SystemNamespaces` capability and is denied to actors. Static APIs without protected resources remain +available after authentication. Unconfigured gRPC methods remain denied. + +The same policy applies to MCP tools and HTTP/gRPC A2A calls. Permitted calls to +the private runtime carry the bound owner's ID; the incoming Actor JWT is never +forwarded as a user credential. Cross-agent delegation and configurable actor +grants are not implemented. User permissions continue through the configured +authorizer independently of the actor policy. + +User calls retain configured authentication. Insecure mode intentionally permits +unauthenticated user access. Substrate replaces Authorization only when the actor +sends the header; a request without it follows the configured user authentication +policy. TaskStore and actor-only Memory methods always require an Actor JWT. +Secure user access still requires the configured trusted authentication boundary. + +The controller trusts `substrate.actorJwtIssuer` (default +`https://idp.ate-system.svc`). It fetches OIDC discovery and JWKS over TLS using +`substrate.caFile`, checks the exact issuer, signature, audience, and required +expiration, issued-at, and not-before claims. Time checks allow 30 seconds of +clock skew. It requires an `actor.ate.dev` identity consistent with the subject. +Worker-for-actor tokens are not accepted. Signing keys refresh every +minute; publish new verification keys at least a minute before using them. + +The service checks the verified actor name against the requested session and +matches its atespace and recorded Actor UID. Memory derives its agent namespace +and user from that persisted session binding, ignoring caller-supplied identity +fields. Users can also list and delete their own memories on the same endpoint. +Both actor and user memory operations check the `Memory` resource, whose name is +the agent key (including legacy keys). User management calls use the requested +agent key and the authenticated user ID, +ignoring the request's user ID, and do not require a live actor or session. +User and share credentials cannot grant access to actor-only methods. The +unsigned identity header is not accepted. + +Substrate supplies the calling Actor's current identity after restore and fork; +no token is persisted in a runtime snapshot. Only the projected actor name is +read locally, to choose the session being addressed. Controller and runtime +images must be upgraded together; snapshots of older runtime images still send +the removed header and must be recreated. Memory keys now use the stored +`namespace/name` agent reference; existing memories keyed by runtime display +names are not migrated. ## Push notifications diff --git a/docs/architecture/oidc-proxy-authentication.md b/docs/architecture/oidc-proxy-authentication.md index 12ccdb228a..9991a6aedd 100644 --- a/docs/architecture/oidc-proxy-authentication.md +++ b/docs/architecture/oidc-proxy-authentication.md @@ -102,9 +102,12 @@ claim. Headers alone do not authenticate an agent. This path does not perform a Kubernetes TokenReview or otherwise validate a service-account token. The [gRPC interceptors](../../go/core/internal/grpcserver/interceptors.go) pass -incoming authentication metadata to the configured provider. Private runtime -methods use their separate runtime authenticator; the proxy's agent-header path -does not define authentication for those methods. See the +incoming authentication metadata to the configured provider. Actor calls use the +same listener and shared authentication provider. Actor JWTs are verified and bound +to their current session, then services enforce the actor resource policy. This +also applies to HTTP MCP and A2A. Invalid actor credentials cannot fall back to +user authentication. The proxy's agent-header path does not define authentication +for runtime methods. See the [A2A gateway](a2a-gateway.md) for the runtime boundary. ## Deployment configuration diff --git a/go/adk/cmd/main.go b/go/adk/cmd/main.go index bfa5d6b018..867b476c35 100644 --- a/go/adk/cmd/main.go +++ b/go/adk/cmd/main.go @@ -162,7 +162,6 @@ func run(logLevel, host, port, configDir string) error { var memoryService *kagentmemory.KagentMemoryService if agentConfig.Memory != nil { memSvc, err := kagentmemory.New(kagentmemory.Config{ - AgentName: appName, ControllerClient: controllerClient, TTLDays: agentConfig.Memory.TtlDays, EmbeddingConfig: agentConfig.Memory.Embedding, diff --git a/go/adk/pkg/controllerclient/client.go b/go/adk/pkg/controllerclient/client.go index 5333df5c7a..ae4e94e56c 100644 --- a/go/adk/pkg/controllerclient/client.go +++ b/go/adk/pkg/controllerclient/client.go @@ -130,21 +130,23 @@ func (client *Client) CallContext(ctx context.Context, userID string) (context.C if userID == "" { userID = auth.UserIDFromContext(ctx) } - metadataValues := make([]string, 0, 6) + metadataValues, _ := metadata.FromOutgoingContext(ctx) + metadataValues = metadataValues.Copy() + // Substrate replaces the placeholder at the controller's runtime origin. + token := "placeholder" if client.tokenProvider != nil { - if token := client.tokenProvider.GetToken(); token != "" { - metadataValues = append(metadataValues, "authorization", "Bearer "+token) + if supplied := client.tokenProvider.GetToken(); supplied != "" { + token = supplied } } + metadataValues.Set("authorization", "Bearer "+token) if client.agentName != "" { - metadataValues = append(metadataValues, "x-agent-name", client.agentName) + metadataValues.Set("x-agent-name", client.agentName) } if userID != "" { - metadataValues = append(metadataValues, "x-user-id", userID) - } - if len(metadataValues) > 0 { - ctx = metadata.AppendToOutgoingContext(ctx, metadataValues...) + metadataValues.Set("x-user-id", userID) } + ctx = metadata.NewOutgoingContext(ctx, metadataValues) if client.timeout <= 0 { return ctx, func() {} } diff --git a/go/adk/pkg/controllerclient/client_test.go b/go/adk/pkg/controllerclient/client_test.go index 93a7e28bfd..ef82f4661c 100644 --- a/go/adk/pkg/controllerclient/client_test.go +++ b/go/adk/pkg/controllerclient/client_test.go @@ -110,3 +110,16 @@ func TestClientCanDisableDefaultDeadline(t *testing.T) { _, hasDeadline := ctx.Deadline() assert.False(t, hasDeadline) } + +func TestCallContextRequestsActorJWTWithoutDuplicateCredentials(t *testing.T) { + client, err := New(Config{APIURL: "http://controller.test:8084"}) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, client.Close()) }) + original := metadata.Pairs("authorization", "Bearer inherited", "x-trace-id", "trace") + ctx, cancel := client.CallContext(metadata.NewOutgoingContext(t.Context(), original), "") + defer cancel() + got, _ := metadata.FromOutgoingContext(ctx) + require.Equal(t, []string{"Bearer placeholder"}, got.Get("authorization")) + require.Equal(t, []string{"trace"}, got.Get("x-trace-id")) + require.Equal(t, []string{"Bearer inherited"}, original.Get("authorization")) +} diff --git a/go/adk/pkg/memory/kagent_service.go b/go/adk/pkg/memory/kagent_service.go index 1a469a8c1f..c2ce27f3fb 100644 --- a/go/adk/pkg/memory/kagent_service.go +++ b/go/adk/pkg/memory/kagent_service.go @@ -21,10 +21,9 @@ import ( ) // KagentMemoryService implements memory.Service by storing memories -// via the Kagent backend API (backed by pgvector). Memory ownership comes only -// from the caller identity in context, never from ADK's local session keys. +// via the Kagent backend API (backed by pgvector). The controller derives memory +// ownership from the authenticated actor's session, never from ADK's local keys. type KagentMemoryService struct { - agentName string controllerClient *controllerclient.Client ttlDays int64 embeddingClient *embedding.Client @@ -33,8 +32,6 @@ type KagentMemoryService struct { // Config for creating a new KagentMemoryService. type Config struct { - // AgentName is used as the namespace for memory storage - AgentName string // ControllerClient is the shared authenticated native gRPC client. ControllerClient *controllerclient.Client // TTLDays is the TTL for memory entries in days (0 uses server default of 15) @@ -47,9 +44,6 @@ type Config struct { // New creates a new KagentMemoryService. func New(cfg Config) (*KagentMemoryService, error) { - if cfg.AgentName == "" { - return nil, fmt.Errorf("agent name is required") - } if cfg.ControllerClient == nil { return nil, fmt.Errorf("controller client is required") } @@ -65,7 +59,6 @@ func New(cfg Config) (*KagentMemoryService, error) { } return &KagentMemoryService{ - agentName: cfg.AgentName, controllerClient: cfg.ControllerClient, ttlDays: cfg.TTLDays, embeddingClient: embClient, @@ -131,10 +124,8 @@ func (s *KagentMemoryService) storeMemory(ctx context.Context, content string, v return err } memoryInput := &runtimev1.SessionMemoryInput{ - AgentName: s.agentName, - UserId: userID, - Content: content, - Vector: vector, + Content: content, + Vector: vector, } if s.ttlDays != 0 { memoryInput.TtlDays = new(int32(s.ttlDays)) @@ -181,11 +172,9 @@ func (s *KagentMemoryService) SearchMemory(ctx context.Context, req *memory.Sear // Prepare API request searchRequest := &runtimev1.MemoryServiceSearchRequest{ - AgentName: s.agentName, - UserId: userID, - Vector: vector, - Limit: new(int32(5)), - MinScore: new(0.3), + Vector: vector, + Limit: new(int32(5)), + MinScore: new(0.3), } callContext, cancel := s.controllerClient.CallContext(ctx, userID) defer cancel() diff --git a/go/adk/pkg/memory/kagent_service_grpc_test.go b/go/adk/pkg/memory/kagent_service_grpc_test.go index 57af71290e..04558e4904 100644 --- a/go/adk/pkg/memory/kagent_service_grpc_test.go +++ b/go/adk/pkg/memory/kagent_service_grpc_test.go @@ -126,8 +126,6 @@ func TestKagentMemoryServiceAddSessionUsesGRPC(t *testing.T) { controllerClient := newMemoryControllerClient(t, &memoryTestServer{add: func(ctx context.Context, request *runtimev1.MemoryServiceAddSessionRequest) (*runtimev1.MemoryServiceAddSessionResponse, error) { requestCount++ input := request.GetMemory() - assert.Equal(t, "test-agent", input.GetAgentName()) - assert.Equal(t, "user", input.GetUserId()) assert.NotEmpty(t, input.GetContent()) assert.Len(t, input.GetVector(), 768) assert.Equal(t, int32(15), input.GetTtlDays()) @@ -141,7 +139,6 @@ func TestKagentMemoryServiceAddSessionUsesGRPC(t *testing.T) { embeddingClient, embeddingServer := newMockEmbeddingClient(t) defer embeddingServer.Close() service := &KagentMemoryService{ - agentName: "test-agent", controllerClient: controllerClient, ttlDays: 15, embeddingClient: embeddingClient, @@ -183,8 +180,6 @@ func TestKagentMemoryServiceSearchUsesGRPC(t *testing.T) { requestCount := 0 controllerClient := newMemoryControllerClient(t, &memoryTestServer{search: func(ctx context.Context, request *runtimev1.MemoryServiceSearchRequest) (*runtimev1.MemoryServiceSearchResponse, error) { requestCount++ - assert.Equal(t, "test-agent", request.GetAgentName()) - assert.Equal(t, "user", request.GetUserId()) assert.Len(t, request.GetVector(), 768) assert.Equal(t, int32(5), request.GetLimit()) assert.InDelta(t, 0.3, request.GetMinScore(), 0.0001) @@ -195,7 +190,6 @@ func TestKagentMemoryServiceSearchUsesGRPC(t *testing.T) { embeddingClient, embeddingServer := newMockEmbeddingClient(t) defer embeddingServer.Close() service := &KagentMemoryService{ - agentName: "test-agent", controllerClient: controllerClient, embeddingClient: embeddingClient, } @@ -239,7 +233,6 @@ func TestStoreMemoryPreservesTTLAndReturnsRPCError(t *testing.T) { return &runtimev1.MemoryServiceAddSessionResponse{Id: "memory-1"}, test.rpcErr }}) service := &KagentMemoryService{ - agentName: "test-agent", controllerClient: controllerClient, ttlDays: test.ttlDays, } @@ -276,7 +269,7 @@ func TestKagentMemoryServiceExtractSessionContent(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { - service := &KagentMemoryService{agentName: "test-agent"} + service := &KagentMemoryService{} content := service.extractSessionContent(newMockSession("session", "user", test.events)) if test.wantContent == "" { assert.Empty(t, content) @@ -298,14 +291,12 @@ func TestNew(t *testing.T) { { name: "valid config", config: Config{ - AgentName: "test-agent", ControllerClient: controllerClient, EmbeddingConfig: validEmbedding, }, }, - {name: "missing agent name", config: Config{ControllerClient: controllerClient, EmbeddingConfig: validEmbedding}, wantErr: "agent name is required"}, - {name: "missing controller client", config: Config{AgentName: "test-agent", EmbeddingConfig: validEmbedding}, wantErr: "controller client is required"}, - {name: "missing embedding config", config: Config{AgentName: "test-agent", ControllerClient: controllerClient}, wantErr: "embedding config is required"}, + {name: "missing controller client", config: Config{EmbeddingConfig: validEmbedding}, wantErr: "controller client is required"}, + {name: "missing embedding config", config: Config{ControllerClient: controllerClient}, wantErr: "embedding config is required"}, } for _, test := range tests { @@ -317,7 +308,7 @@ func TestNew(t *testing.T) { return } require.NoError(t, err) - assert.Equal(t, test.config.AgentName, service.agentName) + require.NotNil(t, service) }) } } @@ -385,14 +376,12 @@ func newMockEventWithFunctionCall(author, functionName string) *adksession.Event func TestMemoryUsesCallerInsteadOfPrivateSessionUser(t *testing.T) { controller := newMemoryControllerClient(t, &memoryTestServer{ - add: func(ctx context.Context, req *runtimev1.MemoryServiceAddSessionRequest) (*runtimev1.MemoryServiceAddSessionResponse, error) { - assert.Equal(t, "alice", req.Memory.UserId) + add: func(ctx context.Context, _ *runtimev1.MemoryServiceAddSessionRequest) (*runtimev1.MemoryServiceAddSessionResponse, error) { values, _ := metadata.FromIncomingContext(ctx) assert.Equal(t, []string{"alice"}, values.Get("x-user-id")) return &runtimev1.MemoryServiceAddSessionResponse{}, nil }, - search: func(ctx context.Context, req *runtimev1.MemoryServiceSearchRequest) (*runtimev1.MemoryServiceSearchResponse, error) { - assert.Equal(t, "alice", req.UserId) + search: func(ctx context.Context, _ *runtimev1.MemoryServiceSearchRequest) (*runtimev1.MemoryServiceSearchResponse, error) { values, _ := metadata.FromIncomingContext(ctx) assert.Equal(t, []string{"alice"}, values.Get("x-user-id")) return &runtimev1.MemoryServiceSearchResponse{}, nil @@ -400,7 +389,7 @@ func TestMemoryUsesCallerInsteadOfPrivateSessionUser(t *testing.T) { }) embeddings, server := newMockEmbeddingClient(t) defer server.Close() - svc := &KagentMemoryService{agentName: "agent", controllerClient: controller, embeddingClient: embeddings} + svc := &KagentMemoryService{controllerClient: controller, embeddingClient: embeddings} ctx := auth.WithUserID(t.Context(), "alice") session := newMockSession("conversation", "conversation", []*adksession.Event{newMockEvent("user", "remember")}) require.NoError(t, svc.AddSessionToMemory(ctx, session)) diff --git a/go/adk/pkg/taskstore/store.go b/go/adk/pkg/taskstore/store.go index 88374ec9c1..4fa9610821 100644 --- a/go/adk/pkg/taskstore/store.go +++ b/go/adk/pkg/taskstore/store.go @@ -6,7 +6,6 @@ import ( "context" "fmt" "os" - "path/filepath" "strings" "time" @@ -19,7 +18,6 @@ import ( "google.golang.org/grpc/status" "github.com/kagent-dev/kagent/go/adk/pkg/controllerclient" - apia2a "github.com/kagent-dev/kagent/go/api/a2a" runtimev1 "github.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1" ) @@ -49,7 +47,7 @@ func (s *Store) sessionID() (string, error) { return id, nil } -func (s *Store) callContext(ctx context.Context) (context.Context, context.CancelFunc, error) { +func (s *Store) callContext(ctx context.Context) (context.Context, context.CancelFunc) { ctx, cancel := s.client.CallContext(ctx, "") md, _ := metadata.FromOutgoingContext(ctx) md = md.Copy() @@ -57,19 +55,10 @@ func (s *Store) callContext(ctx context.Context) (context.Context, context.Cance md.Delete("x-user-id") md.Delete("x-agent-name") md.Delete("x-share-token") - // Temporary identity transport until Substrate injects actor credentials (#1660). - // Reread on every call because restore rebinds these files to the new actor. - var identity []string - for _, field := range []string{"atespace", "name", "uid"} { - value, err := os.ReadFile(filepath.Join(filepath.Dir(s.identityPath), field)) - if err != nil { - cancel() - return nil, nil, fmt.Errorf("read runtime identity %s: %w", field, err) - } - identity = append(identity, strings.TrimSpace(string(value))) - } - md.Set(apia2a.InsecureRuntimeIdentityHeader, strings.Join(identity, "/")) - return metadata.NewOutgoingContext(ctx, md), cancel, nil + // Substrate replaces this placeholder with the calling Actor's JWT, including + // after a restore or fork. No credential is kept in the runtime snapshot. + md.Set("authorization", "Bearer placeholder") + return metadata.NewOutgoingContext(ctx, md), cancel } // Create persists the SDK's initial task and returns its committed version. @@ -183,11 +172,8 @@ func (s *Store) List(ctx context.Context, request *a2a.ListTasksRequest) (*a2a.L // retries conflicts, advances a version locally, or invokes the agent again. func (s *Store) retry(ctx context.Context, call func(context.Context) error) error { for attempt := range 4 { - rpcCtx, cancel, err := s.callContext(ctx) - if err != nil { - return err - } - err = call(rpcCtx) + rpcCtx, cancel := s.callContext(ctx) + err := call(rpcCtx) cancel() if (status.Code(err) != codes.Unavailable && status.Code(err) != codes.DeadlineExceeded) || attempt == 3 { return err diff --git a/go/api/a2a/runtime.go b/go/api/a2a/runtime.go index 4abba80a54..29b667cb9b 100644 --- a/go/api/a2a/runtime.go +++ b/go/api/a2a/runtime.go @@ -1,12 +1,8 @@ package a2a // RuntimeIdentityPath is projected by Substrate and rebound on restore. It is -// routing metadata. Temporary TaskStore headers use it until Substrate #1660. +// routing metadata, not an authentication credential. const RuntimeIdentityPath = "/run/kagent/identity/name" -// InsecureRuntimeIdentityHeader temporarily carries atespace/actor-name/actor-UID -// until Substrate supplies verified actor credentials (#1660). -const InsecureRuntimeIdentityHeader = "x-kagent-insecure-runtime-identity" - // DispatchHeader carries the gateway's attempt fence to the runtime TaskStore. const DispatchHeader = "x-kagent-dispatch-id" diff --git a/go/api/gen/kagent/config/v1/config.pb.go b/go/api/gen/kagent/config/v1/config.pb.go index 9b60a3e92b..fa0cfd3527 100644 --- a/go/api/gen/kagent/config/v1/config.pb.go +++ b/go/api/gen/kagent/config/v1/config.pb.go @@ -1052,6 +1052,8 @@ type SubstrateConfig struct { CaFile string `protobuf:"bytes,2,opt,name=ca_file,json=caFile,proto3" json:"ca_file,omitempty"` ClientCertFile string `protobuf:"bytes,3,opt,name=client_cert_file,json=clientCertFile,proto3" json:"client_cert_file,omitempty"` RouterUrl *string `protobuf:"bytes,4,opt,name=router_url,json=routerUrl,proto3,oneof" json:"router_url,omitempty"` + // Trusted issuer for runtime Actor JWTs. Discovery and JWKS use ca_file. + ActorJwtIssuer *string `protobuf:"bytes,5,opt,name=actor_jwt_issuer,json=actorJwtIssuer,proto3,oneof" json:"actor_jwt_issuer,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1114,6 +1116,13 @@ func (x *SubstrateConfig) GetRouterUrl() string { return "" } +func (x *SubstrateConfig) GetActorJwtIssuer() string { + if x != nil && x.ActorJwtIssuer != nil { + return *x.ActorJwtIssuer + } + return "" +} + type SessionsConfig struct { state protoimpl.MessageState `protogen:"open.v1"` // Zero disables idle expiration. @@ -2061,15 +2070,17 @@ const file_kagent_config_v1_config_proto_rawDesc = "" + "\"\x06\b\x84\xfa\x85\xae\"*\x00R\x0fmaxConnLifetime:z\xbaHw\x1au\n" + "\x14postgres_pool.bounds\x12#min_conns must not exceed max_conns\x1a8!has(this.max_conns) || this.min_conns <= this.max_connsB\f\n" + "\n" + - "_max_conns\"\xe6\x01\n" + + "_max_conns\"\xc6\x02\n" + "\x0fSubstrateConfig\x12/\n" + "\fapi_endpoint\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x00R\vapiEndpoint\x88\x01\x01\x12\x17\n" + "\aca_file\x18\x02 \x01(\tR\x06caFile\x12(\n" + "\x10client_cert_file\x18\x03 \x01(\tR\x0eclientCertFile\x12?\n" + "\n" + - "router_url\x18\x04 \x01(\tB\x1b\xbaH\x18r\x162\x11^https?://[^/?#]+\x88\x01\x01H\x01R\trouterUrl\x88\x01\x01B\x0f\n" + + "router_url\x18\x04 \x01(\tB\x1b\xbaH\x18r\x162\x11^https?://[^/?#]+\x88\x01\x01H\x01R\trouterUrl\x88\x01\x01\x12I\n" + + "\x10actor_jwt_issuer\x18\x05 \x01(\tB\x1a\xbaH\x17r\x152\x10^https://[^/?#]+\x88\x01\x01H\x02R\x0eactorJwtIssuer\x88\x01\x01B\x0f\n" + "\r_api_endpointB\r\n" + - "\v_router_url\"\xf6\x02\n" + + "\v_router_urlB\x13\n" + + "\x11_actor_jwt_issuer\"\xf6\x02\n" + "\x0eSessionsConfig\x12F\n" + "\bidle_ttl\x18\x01 \x01(\v2\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n" + "\"\x06\b\x84\xfa\x85\xae\"2\x00R\aidleTtl\x12O\n" + diff --git a/go/api/gen/kagent/runtime/v1/memory.pb.go b/go/api/gen/kagent/runtime/v1/memory.pb.go index 28d7cd5671..de97eed615 100644 --- a/go/api/gen/kagent/runtime/v1/memory.pb.go +++ b/go/api/gen/kagent/runtime/v1/memory.pb.go @@ -25,8 +25,6 @@ const ( type SessionMemoryInput struct { state protoimpl.MessageState `protogen:"open.v1"` - AgentName string `protobuf:"bytes,1,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` - UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` Content string `protobuf:"bytes,3,opt,name=content,proto3" json:"content,omitempty"` Vector []float32 `protobuf:"fixed32,4,rep,packed,name=vector,proto3" json:"vector,omitempty"` Metadata *structpb.Struct `protobuf:"bytes,5,opt,name=metadata,proto3" json:"metadata,omitempty"` @@ -65,20 +63,6 @@ func (*SessionMemoryInput) Descriptor() ([]byte, []int) { return file_kagent_runtime_v1_memory_proto_rawDescGZIP(), []int{0} } -func (x *SessionMemoryInput) GetAgentName() string { - if x != nil { - return x.AgentName - } - return "" -} - -func (x *SessionMemoryInput) GetUserId() string { - if x != nil { - return x.UserId - } - return "" -} - func (x *SessionMemoryInput) GetContent() string { if x != nil { return x.Content @@ -437,8 +421,6 @@ func (x *MemoryServiceAddSessionBatchResponse) GetCount() int32 { type MemoryServiceSearchRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - AgentName string `protobuf:"bytes,1,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` - UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` Vector []float32 `protobuf:"fixed32,3,rep,packed,name=vector,proto3" json:"vector,omitempty"` Limit *int32 `protobuf:"varint,4,opt,name=limit,proto3,oneof" json:"limit,omitempty"` MinScore *float64 `protobuf:"fixed64,5,opt,name=min_score,json=minScore,proto3,oneof" json:"min_score,omitempty"` @@ -476,20 +458,6 @@ func (*MemoryServiceSearchRequest) Descriptor() ([]byte, []int) { return file_kagent_runtime_v1_memory_proto_rawDescGZIP(), []int{7} } -func (x *MemoryServiceSearchRequest) GetAgentName() string { - if x != nil { - return x.AgentName - } - return "" -} - -func (x *MemoryServiceSearchRequest) GetUserId() string { - if x != nil { - return x.UserId - } - return "" -} - func (x *MemoryServiceSearchRequest) GetVector() []float32 { if x != nil { return x.Vector @@ -556,9 +524,11 @@ func (x *MemoryServiceSearchResponse) GetMemories() []*MemorySearchResult { } type MemoryServiceListRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - AgentName string `protobuf:"bytes,1,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` - UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + // User calls must supply the stored agent key. Actor-created memories use + // "namespace/name"; legacy keys remain valid for managing older memories. + // Actor calls ignore this field and use the agent from their persisted session. + AgentName string `protobuf:"bytes,1,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -600,13 +570,6 @@ func (x *MemoryServiceListRequest) GetAgentName() string { return "" } -func (x *MemoryServiceListRequest) GetUserId() string { - if x != nil { - return x.UserId - } - return "" -} - type MemoryServiceListResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Memories []*MemorySummary `protobuf:"bytes,1,rep,name=memories,proto3" json:"memories,omitempty"` @@ -652,9 +615,11 @@ func (x *MemoryServiceListResponse) GetMemories() []*MemorySummary { } type MemoryServiceDeleteRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - AgentName string `protobuf:"bytes,1,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` - UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + // User calls must supply the stored agent key. Actor-created memories use + // "namespace/name"; legacy keys remain valid for managing older memories. + // Actor calls ignore this field and use the agent from their persisted session. + AgentName string `protobuf:"bytes,1,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -696,13 +661,6 @@ func (x *MemoryServiceDeleteRequest) GetAgentName() string { return "" } -func (x *MemoryServiceDeleteRequest) GetUserId() string { - if x != nil { - return x.UserId - } - return "" -} - type MemoryServiceDeleteResponse struct { state protoimpl.MessageState `protogen:"open.v1"` Status string `protobuf:"bytes,1,opt,name=status,proto3" json:"status,omitempty"` @@ -751,16 +709,14 @@ var File_kagent_runtime_v1_memory_proto protoreflect.FileDescriptor const file_kagent_runtime_v1_memory_proto_rawDesc = "" + "\n" + - "\x1ekagent/runtime/v1/memory.proto\x12\x11kagent.runtime.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xe0\x01\n" + - "\x12SessionMemoryInput\x12\x1d\n" + - "\n" + - "agent_name\x18\x01 \x01(\tR\tagentName\x12\x17\n" + - "\auser_id\x18\x02 \x01(\tR\x06userId\x12\x18\n" + + "\x1ekagent/runtime/v1/memory.proto\x12\x11kagent.runtime.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc9\x01\n" + + "\x12SessionMemoryInput\x12\x18\n" + "\acontent\x18\x03 \x01(\tR\acontent\x12\x16\n" + "\x06vector\x18\x04 \x03(\x02R\x06vector\x123\n" + "\bmetadata\x18\x05 \x01(\v2\x17.google.protobuf.StructR\bmetadata\x12\x1e\n" + "\bttl_days\x18\x06 \x01(\x05H\x00R\attlDays\x88\x01\x01B\v\n" + - "\t_ttl_days\"\xc4\x01\n" + + "\t_ttl_daysJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\n" + + "agent_nameR\auser_id\"\xc4\x01\n" + "\x12MemorySearchResult\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n" + "\acontent\x18\x02 \x01(\tR\acontent\x12\x14\n" + @@ -783,29 +739,25 @@ const file_kagent_runtime_v1_memory_proto_rawDesc = "" + "#MemoryServiceAddSessionBatchRequest\x12;\n" + "\x05items\x18\x01 \x03(\v2%.kagent.runtime.v1.SessionMemoryInputR\x05items\"<\n" + "$MemoryServiceAddSessionBatchResponse\x12\x14\n" + - "\x05count\x18\x01 \x01(\x05R\x05count\"\xc1\x01\n" + - "\x1aMemoryServiceSearchRequest\x12\x1d\n" + - "\n" + - "agent_name\x18\x01 \x01(\tR\tagentName\x12\x17\n" + - "\auser_id\x18\x02 \x01(\tR\x06userId\x12\x16\n" + + "\x05count\x18\x01 \x01(\x05R\x05count\"\xaa\x01\n" + + "\x1aMemoryServiceSearchRequest\x12\x16\n" + "\x06vector\x18\x03 \x03(\x02R\x06vector\x12\x19\n" + "\x05limit\x18\x04 \x01(\x05H\x00R\x05limit\x88\x01\x01\x12 \n" + "\tmin_score\x18\x05 \x01(\x01H\x01R\bminScore\x88\x01\x01B\b\n" + "\x06_limitB\f\n" + "\n" + - "_min_score\"`\n" + + "_min_scoreJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\n" + + "agent_nameR\auser_id\"`\n" + "\x1bMemoryServiceSearchResponse\x12A\n" + - "\bmemories\x18\x01 \x03(\v2%.kagent.runtime.v1.MemorySearchResultR\bmemories\"R\n" + + "\bmemories\x18\x01 \x03(\v2%.kagent.runtime.v1.MemorySearchResultR\bmemories\"H\n" + "\x18MemoryServiceListRequest\x12\x1d\n" + "\n" + - "agent_name\x18\x01 \x01(\tR\tagentName\x12\x17\n" + - "\auser_id\x18\x02 \x01(\tR\x06userId\"Y\n" + + "agent_name\x18\x01 \x01(\tR\tagentNameJ\x04\b\x02\x10\x03R\auser_id\"Y\n" + "\x19MemoryServiceListResponse\x12<\n" + - "\bmemories\x18\x01 \x03(\v2 .kagent.runtime.v1.MemorySummaryR\bmemories\"T\n" + + "\bmemories\x18\x01 \x03(\v2 .kagent.runtime.v1.MemorySummaryR\bmemories\"J\n" + "\x1aMemoryServiceDeleteRequest\x12\x1d\n" + "\n" + - "agent_name\x18\x01 \x01(\tR\tagentName\x12\x17\n" + - "\auser_id\x18\x02 \x01(\tR\x06userId\"5\n" + + "agent_name\x18\x01 \x01(\tR\tagentNameJ\x04\b\x02\x10\x03R\auser_id\"5\n" + "\x1bMemoryServiceDeleteResponse\x12\x16\n" + "\x06status\x18\x01 \x01(\tR\x06status2\xbe\x04\n" + "\rMemoryService\x12s\n" + diff --git a/go/api/gen/kagent/runtime/v1/memory_grpc.pb.go b/go/api/gen/kagent/runtime/v1/memory_grpc.pb.go index 24a631508c..f2a02810ef 100644 --- a/go/api/gen/kagent/runtime/v1/memory_grpc.pb.go +++ b/go/api/gen/kagent/runtime/v1/memory_grpc.pb.go @@ -29,11 +29,20 @@ const ( // MemoryServiceClient is the client API for MemoryService service. // // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// Memory is scoped to an agent and a user. Actor calls derive both from the +// persisted session bound to the verified Actor JWT. List and Delete also +// accept user authentication and use the authenticated user's identity. type MemoryServiceClient interface { + // Requires an Actor JWT. AddSession(ctx context.Context, in *MemoryServiceAddSessionRequest, opts ...grpc.CallOption) (*MemoryServiceAddSessionResponse, error) + // Requires an Actor JWT. AddSessionBatch(ctx context.Context, in *MemoryServiceAddSessionBatchRequest, opts ...grpc.CallOption) (*MemoryServiceAddSessionBatchResponse, error) + // Requires an Actor JWT. Search(ctx context.Context, in *MemoryServiceSearchRequest, opts ...grpc.CallOption) (*MemoryServiceSearchResponse, error) + // Accepts an Actor JWT or user authentication on the same RPC endpoint. List(ctx context.Context, in *MemoryServiceListRequest, opts ...grpc.CallOption) (*MemoryServiceListResponse, error) + // Accepts an Actor JWT or user authentication on the same RPC endpoint. Delete(ctx context.Context, in *MemoryServiceDeleteRequest, opts ...grpc.CallOption) (*MemoryServiceDeleteResponse, error) } @@ -98,11 +107,20 @@ func (c *memoryServiceClient) Delete(ctx context.Context, in *MemoryServiceDelet // MemoryServiceServer is the server API for MemoryService service. // All implementations must embed UnimplementedMemoryServiceServer // for forward compatibility. +// +// Memory is scoped to an agent and a user. Actor calls derive both from the +// persisted session bound to the verified Actor JWT. List and Delete also +// accept user authentication and use the authenticated user's identity. type MemoryServiceServer interface { + // Requires an Actor JWT. AddSession(context.Context, *MemoryServiceAddSessionRequest) (*MemoryServiceAddSessionResponse, error) + // Requires an Actor JWT. AddSessionBatch(context.Context, *MemoryServiceAddSessionBatchRequest) (*MemoryServiceAddSessionBatchResponse, error) + // Requires an Actor JWT. Search(context.Context, *MemoryServiceSearchRequest) (*MemoryServiceSearchResponse, error) + // Accepts an Actor JWT or user authentication on the same RPC endpoint. List(context.Context, *MemoryServiceListRequest) (*MemoryServiceListResponse, error) + // Accepts an Actor JWT or user authentication on the same RPC endpoint. Delete(context.Context, *MemoryServiceDeleteRequest) (*MemoryServiceDeleteResponse, error) mustEmbedUnimplementedMemoryServiceServer() } diff --git a/go/core/internal/a2agateway/actor_auth_test.go b/go/core/internal/a2agateway/actor_auth_test.go new file mode 100644 index 0000000000..690a766d69 --- /dev/null +++ b/go/core/internal/a2agateway/actor_auth_test.go @@ -0,0 +1,68 @@ +package a2agateway + +import ( + "context" + "net/http/httptest" + "testing" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" + "github.com/kagent-dev/kagent/go/core/internal/database" + authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/core/internal/jwttest" + "github.com/kagent-dev/kagent/go/core/internal/runtimeauth" + "github.com/kagent-dev/kagent/go/core/internal/substrate" + "github.com/kagent-dev/kagent/go/core/pkg/auth" +) + +type actorBinding struct{ session *apiv1.Session } + +func (s actorBinding) GetSessionForRuntime(_ context.Context, id, uid string) (*apiv1.Session, error) { + if id != s.session.Id || uid != "actor-uid" { + return nil, database.ErrNotFound + } + return s.session, nil +} + +func TestHTTPActorA2AUsesSessionAuthorization(t *testing.T) { + session := gatewayTestSession() + session.A2AAuthority = substrate.ActorHost("team-a", substrate.ActorName(session.Id), "") + binding := actorBinding{session: session} + store := &gatewayTestStore{session: session, task: &a2atype.Task{ID: "runtime-task", ContextID: session.Id, Status: a2atype.TaskStatus{State: a2atype.TaskStateCompleted}}} + issuer := jwttest.New(t) + verifier, err := substrate.NewActorJWTVerifier(t.Context(), issuer.URL, issuer.CAFile) + require.NoError(t, err) + provider := runtimeauth.NewProvider(&authimpl.InsecureAuthenticator{}, verifier, binding, issuer.URL) + policy := runtimeauth.NewAuthorizer(auth.NoopAuthorizer{}, binding) + runtime := &gatewayTestRuntime{task: store.task} + gateway := newTestGateway(store, policy, &gatewayTestDialer{client: gatewayTestClient(t, runtime)}, gatewayTestURL) + server := httptest.NewServer(NewHTTPHandler(gateway, provider, nil)) + t.Cleanup(server.Close) + transport := newGatewayTestTransport(t, server.Listener.Addr().String(), a2atype.TransportProtocolJSONRPC) + token := "Bearer " + issuer.Token(t, issuer.Claims("team-a", substrate.ActorName(session.Id), "actor-uid")) + actor := a2aclient.ServiceParams{"authorization": {token}, "x-user-id": {"forged"}} + user := a2aclient.ServiceParams{"x-user-id": {"alice"}} + for _, params := range []a2aclient.ServiceParams{actor, user} { + task, err := transport.GetTask(t.Context(), params, &a2atype.GetTaskRequest{ID: "runtime-task"}) + require.NoError(t, err) + require.Equal(t, store.task.ID, task.ID) + require.Equal(t, "alice", store.userID) + } + _, err = transport.SendMessage(t.Context(), actor, gatewayTestRequest()) + require.NoError(t, err) + require.True(t, runtime.sent, "an authorized actor call reaches its runtime") + _, err = transport.GetTask(t.Context(), a2aclient.ServiceParams{"authorization": {token}, "x-share-token": {"share"}}, &a2atype.GetTaskRequest{ID: "runtime-task"}) + require.Error(t, err) + // A different session for the same owner is still outside actor authority. + other := gatewayTestSession() + other.Id = uuid.NewString() + store.session = other + _, err = transport.GetTask(t.Context(), actor, &a2atype.GetTaskRequest{ID: "runtime-task"}) + require.ErrorIs(t, err, a2atype.ErrUnauthorized) + _, err = transport.GetTask(t.Context(), user, &a2atype.GetTaskRequest{ID: "runtime-task"}) + require.NoError(t, err) +} diff --git a/go/core/internal/a2agateway/routing_test.go b/go/core/internal/a2agateway/routing_test.go index cdc44781b5..7a062f307d 100644 --- a/go/core/internal/a2agateway/routing_test.go +++ b/go/core/internal/a2agateway/routing_test.go @@ -31,7 +31,7 @@ func (s gatewayTestAgents) Get(ctx context.Context, ref types.NamespacedName) (* if !ok { return nil, serviceerrors.NewUnauthenticated("authentication required", nil) } - if err := s.authorizer.Check(ctx, authSession.Principal(), auth.VerbGet, auth.Resource{Type: "Agent", Name: ref.String()}); err != nil { + if err := s.authorizer.Check(ctx, authSession.Principal(), auth.VerbGet, auth.Resource{Type: "Agent", Namespace: ref.Namespace, Name: ref.Name}); err != nil { return nil, serviceerrors.NewPermissionDenied("not authorized", err) } if s.store.err != nil { diff --git a/go/core/internal/grpcserver/interceptors.go b/go/core/internal/grpcserver/interceptors.go index 3aec54edac..8c5207c00a 100644 --- a/go/core/internal/grpcserver/interceptors.go +++ b/go/core/internal/grpcserver/interceptors.go @@ -18,7 +18,6 @@ import ( "google.golang.org/grpc/peer" "google.golang.org/grpc/status" - apia2a "github.com/kagent-dev/kagent/go/api/a2a" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" sessionsvc "github.com/kagent-dev/kagent/go/core/internal/service/session" "github.com/kagent-dev/kagent/go/core/pkg/auth" @@ -26,16 +25,15 @@ import ( ) var forwardedMetadataKeys = map[string]string{ - apia2a.InsecureRuntimeIdentityHeader: apia2a.InsecureRuntimeIdentityHeader, - "authorization": "Authorization", - "x-user-id": "X-User-Id", - "x-agent-name": "X-Agent-Name", - "x-share-token": "X-Share-Token", + "authorization": "Authorization", + "x-user-id": "X-User-Id", + "x-agent-name": "X-Agent-Name", + "x-share-token": "X-Share-Token", } -func authenticationUnaryInterceptor(authenticator, runtimeAuthenticator auth.AuthProvider, shareStore sessionsvc.ShareStore, policies MethodPolicies) grpc.UnaryServerInterceptor { +func authenticationUnaryInterceptor(authenticator auth.AuthProvider, shareStore sessionsvc.ShareStore, policies MethodPolicies) grpc.UnaryServerInterceptor { return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { - authenticatedContext, err := authenticate(ctx, info.FullMethod, authenticator, runtimeAuthenticator, shareStore, policies) + authenticatedContext, err := authenticate(ctx, info.FullMethod, authenticator, shareStore, policies) if err != nil { return nil, err } @@ -43,9 +41,9 @@ func authenticationUnaryInterceptor(authenticator, runtimeAuthenticator auth.Aut } } -func authenticationStreamInterceptor(authenticator, runtimeAuthenticator auth.AuthProvider, shareStore sessionsvc.ShareStore, policies MethodPolicies) grpc.StreamServerInterceptor { +func authenticationStreamInterceptor(authenticator auth.AuthProvider, shareStore sessionsvc.ShareStore, policies MethodPolicies) grpc.StreamServerInterceptor { return func(srv any, stream grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { - authenticatedContext, err := authenticate(stream.Context(), info.FullMethod, authenticator, runtimeAuthenticator, shareStore, policies) + authenticatedContext, err := authenticate(stream.Context(), info.FullMethod, authenticator, shareStore, policies) if err != nil { return err } @@ -53,34 +51,26 @@ func authenticationStreamInterceptor(authenticator, runtimeAuthenticator auth.Au } } -func authenticate(ctx context.Context, fullMethod string, authenticator, runtimeAuthenticator auth.AuthProvider, shareStore sessionsvc.ShareStore, policies MethodPolicies) (context.Context, error) { +func authenticate(ctx context.Context, fullMethod string, authenticator auth.AuthProvider, shareStore sessionsvc.ShareStore, policies MethodPolicies) (context.Context, error) { access, ok := policies[fullMethod] if !ok { return ctx, status.Error(codes.PermissionDenied, "RPC authorization policy is not configured") } - if access == auth.AccessPublic { + headers := incomingHTTPHeaders(ctx) + if access == auth.AccessPublic && len(headers.Values("Authorization")) == 0 { return ctx, nil } - if access == auth.AccessRuntime { - authenticator = runtimeAuthenticator - } if authenticator == nil { return ctx, status.Error(codes.Unauthenticated, "authentication is not configured") } - - headers := incomingHTTPHeaders(ctx) session, err := authenticator.Authenticate(ctx, headers, url.Values{}) if err != nil || session == nil { return ctx, status.Error(codes.Unauthenticated, "invalid credentials") } - - authenticatedContext := auth.AuthSessionTo(ctx, session) - if access == auth.AccessRuntime { - if headers.Get("X-Share-Token") != "" { - return ctx, status.Error(codes.PermissionDenied, "share credentials cannot access runtime storage") - } - return authenticatedContext, nil + if access == auth.AccessRuntime && session.Principal().Actor == nil { + return ctx, status.Error(codes.PermissionDenied, "runtime actor identity is required") } + authenticatedContext := auth.AuthSessionTo(ctx, session) share, err := sessionsvc.ResolveShare(authenticatedContext, shareStore, headers.Get("X-Share-Token")) if err != nil { return ctx, mapError(err) diff --git a/go/core/internal/grpcserver/interceptors_test.go b/go/core/internal/grpcserver/interceptors_test.go index fe4698e061..5f031e1235 100644 --- a/go/core/internal/grpcserver/interceptors_test.go +++ b/go/core/internal/grpcserver/interceptors_test.go @@ -14,11 +14,10 @@ import ( "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" - apia2a "github.com/kagent-dev/kagent/go/api/a2a" apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" + runtimev1 "github.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1" "github.com/kagent-dev/kagent/go/core/internal/database" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" - "github.com/kagent-dev/kagent/go/core/internal/service/taskstore" pkgauth "github.com/kagent-dev/kagent/go/core/pkg/auth" ) @@ -75,7 +74,7 @@ func TestAuthenticationUnaryInterceptor(t *testing.T) { t.Run("public method bypasses authentication", func(t *testing.T) { called := false - _, err := authenticationUnaryInterceptor(nil, nil, nil, policies)( + _, err := authenticationUnaryInterceptor(nil, nil, policies)( t.Context(), nil, &grpc.UnaryServerInfo{FullMethod: "/test.Service/Public"}, func(context.Context, any) (any, error) { called = true @@ -88,7 +87,7 @@ func TestAuthenticationUnaryInterceptor(t *testing.T) { }) t.Run("unconfigured policy is denied", func(t *testing.T) { - _, err := authenticationUnaryInterceptor(nil, nil, nil, policies)( + _, err := authenticationUnaryInterceptor(nil, nil, policies)( t.Context(), nil, &grpc.UnaryServerInfo{FullMethod: "/test.Service/Missing"}, func(context.Context, any) (any, error) { return nil, nil }, ) @@ -105,7 +104,7 @@ func TestAuthenticationUnaryInterceptor(t *testing.T) { "x-agent-name", "default/agent", "x-unapproved", "do-not-forward", )) - _, err := authenticationUnaryInterceptor(authenticator, nil, nil, policies)( + _, err := authenticationUnaryInterceptor(authenticator, nil, policies)( ctx, nil, &grpc.UnaryServerInfo{FullMethod: readMethod}, func(ctx context.Context, _ any) (any, error) { gotSession, ok := pkgauth.AuthSessionFrom(ctx) @@ -134,7 +133,7 @@ func TestAuthenticationUnaryInterceptor(t *testing.T) { sessionShare: &apiv1.SessionShare{SessionId: testSessionID.String()}, ownerUserID: "owner", } ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("x-share-token", "share")) - _, err := authenticationUnaryInterceptor(&testAuthenticator{session: session}, nil, store, policies)( + _, err := authenticationUnaryInterceptor(&testAuthenticator{session: session}, store, policies)( ctx, nil, &grpc.UnaryServerInfo{FullMethod: readMethod}, func(ctx context.Context, _ any) (any, error) { share, ok := pkgauth.ShareContextFrom(ctx) @@ -162,7 +161,7 @@ func TestAuthenticationUnaryInterceptor(t *testing.T) { sessionShare: &apiv1.SessionShare{SessionId: testSessionID.String()}, ownerUserID: "owner", } ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("x-share-token", "share")) - _, err := authenticationUnaryInterceptor(&testAuthenticator{session: session}, nil, store, policies)( + _, err := authenticationUnaryInterceptor(&testAuthenticator{session: session}, store, policies)( ctx, nil, &grpc.UnaryServerInfo{FullMethod: createMethod}, func(context.Context, any) (any, error) { t.Fatal("handler should not run") @@ -177,7 +176,7 @@ func TestAuthenticationUnaryInterceptor(t *testing.T) { t.Run("invalid share token is denied", func(t *testing.T) { store := &testShareStore{sessionShareErr: database.ErrNotFound} ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("x-share-token", "missing")) - _, err := authenticationUnaryInterceptor(&testAuthenticator{session: session}, nil, store, policies)( + _, err := authenticationUnaryInterceptor(&testAuthenticator{session: session}, store, policies)( ctx, nil, &grpc.UnaryServerInfo{FullMethod: readMethod}, func(context.Context, any) (any, error) { return nil, nil }, ) @@ -202,7 +201,7 @@ func TestA2AShareAuthorizationIsDelegatedToGateway(t *testing.T) { } { t.Run(method, func(t *testing.T) { ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("x-share-token", "share")) - ctx, err := authenticate(ctx, method, &testAuthenticator{session: session}, nil, store, DefaultMethodPolicies()) + ctx, err := authenticate(ctx, method, &testAuthenticator{session: session}, store, DefaultMethodPolicies()) if err != nil { t.Fatalf("A2A authorization must reach the gateway: %v", err) } @@ -218,24 +217,11 @@ func TestA2AShareAuthorizationIsDelegatedToGateway(t *testing.T) { } } -func TestInsecureRuntimeIdentityDoesNotAuthorizePublicAPI(t *testing.T) { - const runtimeMethod = "/test.TaskStore/Get" - policies := MethodPolicies{runtimeMethod: pkgauth.AccessRuntime, readMethod: pkgauth.AccessRead} - ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs(apia2a.InsecureRuntimeIdentityHeader, "team-a/session-"+uuid.NewString()+"/actor-uid")) - public := &testAuthenticator{err: errors.New("public credentials required")} - for _, method := range []string{runtimeMethod, readMethod} { - _, err := authenticate(ctx, method, public, nil, nil, policies) - if status.Code(err) != codes.Unauthenticated { - t.Fatalf("default %s: %v", method, err) - } - } - _, err := authenticate(ctx, readMethod, public, &taskstore.Authenticator{}, nil, policies) - if status.Code(err) != codes.Unauthenticated { - t.Fatalf("runtime test mode authorized public API: %v", err) - } - _, err = authenticate(ctx, runtimeMethod, public, &taskstore.Authenticator{}, nil, policies) - if err != nil { - t.Fatalf("explicit runtime test mode rejected identity: %v", err) +func TestUserAgentHeaderDoesNotEstablishActorIdentity(t *testing.T) { + provider := &testAuthenticator{session: &testSession{principal: pkgauth.Principal{User: pkgauth.User{ID: "user"}, Agent: pkgauth.Agent{ID: "actor"}}}} + _, err := authenticate(t.Context(), runtimev1.TaskStoreService_GetTask_FullMethodName, provider, nil, DefaultMethodPolicies()) + if status.Code(err) != codes.PermissionDenied { + t.Fatalf("user agent hint authorized runtime storage: %v", err) } } diff --git a/go/core/internal/grpcserver/memory.go b/go/core/internal/grpcserver/memory.go index e4be0b13cb..2c31692a65 100644 --- a/go/core/internal/grpcserver/memory.go +++ b/go/core/internal/grpcserver/memory.go @@ -52,11 +52,9 @@ func (s *memoryServer) AddSessionBatch(ctx context.Context, request *runtimev1.M func (s *memoryServer) Search(ctx context.Context, request *runtimev1.MemoryServiceSearchRequest) (*runtimev1.MemoryServiceSearchResponse, error) { results, err := s.service.Search(ctx, memoryservice.SearchRequest{ - AgentName: request.GetAgentName(), - UserID: request.GetUserId(), - Vector: request.GetVector(), - Limit: int(request.GetLimit()), - MinScore: request.GetMinScore(), + Vector: request.GetVector(), + Limit: int(request.GetLimit()), + MinScore: request.GetMinScore(), }) if err != nil { return nil, err @@ -83,7 +81,7 @@ func (s *memoryServer) Search(ctx context.Context, request *runtimev1.MemoryServ } func (s *memoryServer) List(ctx context.Context, request *runtimev1.MemoryServiceListRequest) (*runtimev1.MemoryServiceListResponse, error) { - results, err := s.service.List(ctx, request.GetAgentName(), request.GetUserId()) + results, err := s.service.List(ctx, request.GetAgentName()) if err != nil { return nil, err } @@ -96,7 +94,7 @@ func (s *memoryServer) List(ctx context.Context, request *runtimev1.MemoryServic } func (s *memoryServer) Delete(ctx context.Context, request *runtimev1.MemoryServiceDeleteRequest) (*runtimev1.MemoryServiceDeleteResponse, error) { - if err := s.service.Delete(ctx, request.GetAgentName(), request.GetUserId()); err != nil { + if err := s.service.Delete(ctx, request.GetAgentName()); err != nil { return nil, err } return &runtimev1.MemoryServiceDeleteResponse{Status: "deleted"}, nil @@ -115,12 +113,10 @@ func memoryInputFromProto(input *runtimev1.SessionMemoryInput) (memoryservice.In metadata = encoded } return memoryservice.Input{ - AgentName: input.GetAgentName(), - UserID: input.GetUserId(), - Content: input.GetContent(), - Vector: input.GetVector(), - Metadata: metadata, - TTLDays: int(input.GetTtlDays()), + Content: input.GetContent(), + Vector: input.GetVector(), + Metadata: metadata, + TTLDays: int(input.GetTtlDays()), }, nil } diff --git a/go/core/internal/grpcserver/memory_test.go b/go/core/internal/grpcserver/memory_test.go new file mode 100644 index 0000000000..d3bbc38e73 --- /dev/null +++ b/go/core/internal/grpcserver/memory_test.go @@ -0,0 +1,136 @@ +package grpcserver + +import ( + "context" + "encoding/base64" + "net" + "slices" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + + apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" + runtimev1 "github.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1" + "github.com/kagent-dev/kagent/go/core/internal/database" + authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/core/internal/jwttest" + "github.com/kagent-dev/kagent/go/core/internal/runtimeauth" + memoryservice "github.com/kagent-dev/kagent/go/core/internal/service/memory" + "github.com/kagent-dev/kagent/go/core/internal/substrate" + pkgauth "github.com/kagent-dev/kagent/go/core/pkg/auth" +) + +type userMemoryStore struct { + *database.Client + memories []database.Memory + session *apiv1.Session +} + +var _ memoryservice.Store = (*userMemoryStore)(nil) + +func (s *userMemoryStore) GetSessionForRuntime(_ context.Context, id, actorUID string) (*apiv1.Session, error) { + if id != s.session.Id || actorUID != "actor-uid" { + return nil, database.ErrNotFound + } + return s.session, nil +} + +func (s *userMemoryStore) ListAgentMemories(_ context.Context, agentName, userID string) ([]database.Memory, error) { + var result []database.Memory + for _, memory := range s.memories { + if memory.AgentName == agentName && memory.UserID == userID { + result = append(result, memory) + } + } + return result, nil +} + +func (s *userMemoryStore) DeleteAgentMemory(_ context.Context, agentName, userID string) error { + s.memories = slices.DeleteFunc(s.memories, func(memory database.Memory) bool { + return memory.AgentName == agentName && memory.UserID == userID + }) + return nil +} + +func TestMemoryUsersAndActorsShareEndpointWithSeparateScopes(t *testing.T) { + id := uuid.NewString() + store := &userMemoryStore{memories: []database.Memory{ + {ID: "own", AgentName: "team/agent", UserID: "alice", Content: "private memory"}, + {ID: "other-user", AgentName: "team/agent", UserID: "bob"}, + {ID: "other-agent", AgentName: "team/other", UserID: "alice"}, + }, session: &apiv1.Session{ + Id: id, Creator: "bob", Agent: &apiv1.ResourceReference{Namespace: "team", Name: "agent"}, + A2AAuthority: substrate.ActorHost("team", substrate.ActorName(id), ""), + }} + issuer := jwttest.New(t) + verifier, err := substrate.NewActorJWTVerifier(t.Context(), issuer.URL, issuer.CAFile) + require.NoError(t, err) + listener := bufconn.Listen(DefaultMaxMessageSize) + server, err := New(Config{ + Listener: listener, + SystemService: testSystemService(), + Authenticator: runtimeauth.NewProvider(authimpl.NewProxyAuthenticator("sub"), verifier, store, issuer.URL), + MemoryService: memoryservice.NewService(store, runtimeauth.NewAuthorizer(pkgauth.NoopAuthorizer{}, store)), + }) + require.NoError(t, err) + ctx, cancel := context.WithCancel(t.Context()) + done := make(chan error, 1) + go func() { done <- server.Start(ctx) }() + t.Cleanup(func() { cancel(); require.NoError(t, <-done) }) + conn, err := grpc.NewClient("passthrough:///memory", grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() })) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close()) }) + client := runtimev1.NewMemoryServiceClient(conn) + list := &runtimev1.MemoryServiceListRequest{AgentName: "team/agent"} + deleteRequest := &runtimev1.MemoryServiceDeleteRequest{AgentName: "team/agent"} + + for _, claims := range []string{"", `{"iss":"` + issuer.URL + `","sub":"alice"}`} { + unauthenticated := t.Context() + if claims != "" { + unauthenticated = metadata.NewOutgoingContext(unauthenticated, metadata.Pairs("authorization", "Bearer e30."+base64.RawURLEncoding.EncodeToString([]byte(claims))+".signature")) + } + _, err = client.List(unauthenticated, list) + require.Equal(t, codes.Unauthenticated, status.Code(err)) + _, err = client.Delete(unauthenticated, deleteRequest) + require.Equal(t, codes.Unauthenticated, status.Code(err)) + } + actor := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("authorization", + "Bearer "+issuer.Token(t, issuer.Claims("team", substrate.ActorName(id), "actor-uid")), "x-user-id", "alice")) + actorList := &runtimev1.MemoryServiceListRequest{AgentName: "team/other"} + listed, err := client.List(actor, actorList) + require.NoError(t, err) + require.Len(t, listed.Memories, 1) + require.Equal(t, "other-user", listed.Memories[0].Id, "actor scope comes from its stored session") + _, err = apiv1.NewSystemServiceClient(conn).GetVersion(actor, &apiv1.GetVersionRequest{}) + require.NoError(t, err) + _, err = client.List(metadata.AppendToOutgoingContext(actor, "x-share-token", "share"), actorList) + require.Equal(t, codes.Unauthenticated, status.Code(err)) + // The configured proxy authenticates the bearer claims. A conflicting + // forwarded user header cannot select another user's data. + user := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("authorization", + "Bearer e30."+base64.RawURLEncoding.EncodeToString([]byte(`{"sub":"alice"}`))+".signature", "x-user-id", "bob")) + listed, err = client.List(user, list) + require.NoError(t, err) + require.Len(t, listed.Memories, 1) + require.Equal(t, "own", listed.Memories[0].Id) + _, err = client.Delete(user, deleteRequest) + require.NoError(t, err) + listed, err = client.List(user, list) + require.NoError(t, err) + require.Empty(t, listed.Memories) + require.Len(t, store.memories, 2) + require.Equal(t, "other-user", store.memories[0].ID) + require.Equal(t, "other-agent", store.memories[1].ID) + _, err = client.Delete(actor, &runtimev1.MemoryServiceDeleteRequest{AgentName: "team/other"}) + require.NoError(t, err) + require.Len(t, store.memories, 1) + require.Equal(t, "other-agent", store.memories[0].ID, "actor deletion cannot select another user's memory") +} diff --git a/go/core/internal/grpcserver/policy.go b/go/core/internal/grpcserver/policy.go index 6035316e5a..5cf4214d6c 100644 --- a/go/core/internal/grpcserver/policy.go +++ b/go/core/internal/grpcserver/policy.go @@ -1,6 +1,8 @@ package grpcserver import ( + "maps" + a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" guestpb "github.com/agent-substrate/env/proto/ateenv/v1alpha" grpc_health_v1 "google.golang.org/grpc/health/grpc_health_v1" @@ -14,22 +16,12 @@ type MethodPolicies map[string]auth.AccessMode func DefaultMethodPolicies() MethodPolicies { policies := MethodPolicies{ - runtimev1.TaskStoreService_CreateTask_FullMethodName: auth.AccessRuntime, - runtimev1.TaskStoreService_GetTask_FullMethodName: auth.AccessRuntime, - runtimev1.TaskStoreService_UpdateTask_FullMethodName: auth.AccessRuntime, - runtimev1.TaskStoreService_SettleTask_FullMethodName: auth.AccessRuntime, - runtimev1.TaskStoreService_ListTasks_FullMethodName: auth.AccessRuntime, apiv1.SystemService_GetVersion_FullMethodName: auth.AccessPublic, apiv1.SystemService_GetCurrentUser_FullMethodName: auth.AccessRead, apiv1.SystemService_ListNamespaces_FullMethodName: auth.AccessRead, apiv1.SystemService_GetSubstrateSummary_FullMethodName: auth.AccessRead, apiv1.SystemService_ListSubstrateActors_FullMethodName: auth.AccessRead, apiv1.SystemService_ListSubstrateWorkers_FullMethodName: auth.AccessRead, - runtimev1.MemoryService_AddSession_FullMethodName: auth.AccessCreate, - runtimev1.MemoryService_AddSessionBatch_FullMethodName: auth.AccessCreate, - runtimev1.MemoryService_Search_FullMethodName: auth.AccessRead, - runtimev1.MemoryService_List_FullMethodName: auth.AccessRead, - runtimev1.MemoryService_Delete_FullMethodName: auth.AccessDelete, apiv1.ModelService_ListModelConfigs_FullMethodName: auth.AccessRead, apiv1.ModelService_GetModelConfig_FullMethodName: auth.AccessRead, apiv1.ModelService_CreateModelConfig_FullMethodName: auth.AccessCreate, @@ -124,5 +116,23 @@ func DefaultMethodPolicies() MethodPolicies { policies[guestpb.ProcessService_StreamProcessOutputs_FullMethodName] = auth.AccessRead policies[guestpb.FileSystemService_ReadFile_FullMethodName] = auth.AccessRead policies[guestpb.FileSystemService_WriteFile_FullMethodName] = auth.AccessUpdate + maps.Copy(policies, runtimeMethodPolicies()) + policies[runtimev1.MemoryService_List_FullMethodName] = auth.AccessRead + policies[runtimev1.MemoryService_Delete_FullMethodName] = auth.AccessDelete return policies } + +// runtimeMethodPolicies marks storage methods that require a verified actor. +// Shared APIs accept either caller identity and authorize in their services. +func runtimeMethodPolicies() MethodPolicies { + return MethodPolicies{ + runtimev1.TaskStoreService_CreateTask_FullMethodName: auth.AccessRuntime, + runtimev1.TaskStoreService_GetTask_FullMethodName: auth.AccessRuntime, + runtimev1.TaskStoreService_UpdateTask_FullMethodName: auth.AccessRuntime, + runtimev1.TaskStoreService_SettleTask_FullMethodName: auth.AccessRuntime, + runtimev1.TaskStoreService_ListTasks_FullMethodName: auth.AccessRuntime, + runtimev1.MemoryService_AddSession_FullMethodName: auth.AccessRuntime, + runtimev1.MemoryService_AddSessionBatch_FullMethodName: auth.AccessRuntime, + runtimev1.MemoryService_Search_FullMethodName: auth.AccessRuntime, + } +} diff --git a/go/core/internal/grpcserver/policy_test.go b/go/core/internal/grpcserver/policy_test.go index 7d9b9558f1..51c3d3cf3f 100644 --- a/go/core/internal/grpcserver/policy_test.go +++ b/go/core/internal/grpcserver/policy_test.go @@ -2,13 +2,23 @@ package grpcserver import ( "testing" + "time" + a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" guestpb "github.com/agent-substrate/env/proto/ateenv/v1alpha" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" + runtimev1 "github.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1" + authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/core/internal/jwttest" + "github.com/kagent-dev/kagent/go/core/internal/runtimeauth" + "github.com/kagent-dev/kagent/go/core/internal/substrate" pkgauth "github.com/kagent-dev/kagent/go/core/pkg/auth" ) @@ -72,7 +82,7 @@ func TestReadOnlyShareCannotRenameAConversation(t *testing.T) { sessionShare: &apiv1.SessionShare{SessionId: testSessionID.String()}, ownerUserID: "owner", } ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("x-share-token", "token")) - _, err := authenticate(ctx, test.method, &testAuthenticator{session: session}, nil, shareStore, DefaultMethodPolicies()) + _, err := authenticate(ctx, test.method, &testAuthenticator{session: session}, shareStore, DefaultMethodPolicies()) if got := status.Code(err); got != test.wantCode { t.Fatalf("authenticate(%s) code = %v (%v), want %v", test.method, got, err, test.wantCode) } @@ -97,3 +107,75 @@ func TestSandboxGuestPoliciesMatchTheirEffect(t *testing.T) { }) } } + +func TestActorAndUserPoliciesOnSharedEndpoint(t *testing.T) { + issuer := jwttest.New(t) + verifier, err := substrate.NewActorJWTVerifier(t.Context(), issuer.URL, issuer.CAFile) + require.NoError(t, err) + sessionID := uuid.NewString() + store := &userMemoryStore{session: &apiv1.Session{Id: sessionID, Creator: "owner", Agent: &apiv1.ResourceReference{Namespace: "team-a", Name: "agent"}, A2AAuthority: substrate.ActorHost("team-a", substrate.ActorName(sessionID), "")}} + claims := issuer.Claims("team-a", "session-"+sessionID, "actor-uid") + actor := "Bearer " + issuer.Token(t, claims) + claims["exp"] = time.Now().Add(-time.Minute).Unix() + expired := "Bearer " + issuer.Token(t, claims) + forged := "Bearer " + issuer.Token(t, jwt.MapClaims{"iss": issuer.URL, "sub": "alice"}) + user := "Bearer " + issuer.Token(t, jwt.MapClaims{"iss": "https://users.example", "sub": "alice"}) + policies := DefaultMethodPolicies() + policies["/extension.Service/Call"] = pkgauth.AccessRead + for _, mode := range []struct { + name string + provider pkgauth.AuthProvider + anonymous codes.Code + runtimeCode codes.Code + }{ + {"insecure", &authimpl.InsecureAuthenticator{}, codes.OK, codes.PermissionDenied}, + {"trusted proxy", authimpl.NewProxyAuthenticator("sub"), codes.Unauthenticated, codes.Unauthenticated}, + } { + t.Run(mode.name, func(t *testing.T) { + users := runtimeauth.NewProvider(mode.provider, verifier, store, issuer.URL) + for _, test := range []struct { + name, token, method string + want codes.Code + }{ + {"actor task", actor, runtimev1.TaskStoreService_GetTask_FullMethodName, codes.OK}, + {"actor memory list", actor, runtimev1.MemoryService_List_FullMethodName, codes.OK}, + {"actor memory delete", actor, runtimev1.MemoryService_Delete_FullMethodName, codes.OK}, + {"user memory list", user, runtimev1.MemoryService_List_FullMethodName, codes.OK}, + {"user memory delete", user, runtimev1.MemoryService_Delete_FullMethodName, codes.OK}, + {"user task denied", user, runtimev1.TaskStoreService_GetTask_FullMethodName, codes.PermissionDenied}, + {"anonymous task denied", "", runtimev1.TaskStoreService_GetTask_FullMethodName, mode.runtimeCode}, + {"anonymous memory uses configured auth", "", runtimev1.MemoryService_List_FullMethodName, mode.anonymous}, + {"anonymous user API uses configured auth", "", apiv1.SessionService_ListSessions_FullMethodName, mode.anonymous}, + {"anonymous public API", "", apiv1.SystemService_GetVersion_FullMethodName, codes.OK}, + {"actor user API delegates authorization", actor, apiv1.SessionService_CreateSession_FullMethodName, codes.OK}, + {"actor public API delegates authorization", actor, apiv1.SystemService_GetVersion_FullMethodName, codes.OK}, + {"actor A2A delegates authorization", actor, a2apb.A2AService_SendMessage_FullMethodName, codes.OK}, + {"actor streaming A2A delegates authorization", actor, a2apb.A2AService_SendStreamingMessage_FullMethodName, codes.OK}, + {"actor extension delegates authorization", actor, "/extension.Service/Call", codes.OK}, + {"unknown method denied", actor, "/unconfigured.Service/Call", codes.PermissionDenied}, + {"expired actor has no user fallback", expired, runtimev1.MemoryService_List_FullMethodName, codes.Unauthenticated}, + {"forged actor has no user fallback", forged, runtimev1.MemoryService_Delete_FullMethodName, codes.Unauthenticated}, + {"expired actor cannot use public API", expired, apiv1.SystemService_GetVersion_FullMethodName, codes.Unauthenticated}, + } { + t.Run(test.name, func(t *testing.T) { + md := metadata.Pairs("x-user-id", "alice") + if test.token != "" { + md.Set("authorization", test.token) + } + ctx := metadata.NewIncomingContext(t.Context(), md) + result, err := authenticate(ctx, test.method, users, nil, policies) + require.Equal(t, test.want, status.Code(err), "%v", err) + if test.want == codes.OK && test.token == actor { + identity, ok := pkgauth.AuthSessionFrom(result) + require.True(t, ok) + require.Equal(t, "owner", identity.Principal().User.ID) + require.Equal(t, sessionID, identity.Principal().Actor.SessionID) + } + }) + } + shared := metadata.NewIncomingContext(t.Context(), metadata.Pairs("authorization", actor, "x-share-token", "share")) + _, err := authenticate(shared, runtimev1.MemoryService_List_FullMethodName, users, nil, policies) + require.Equal(t, codes.Unauthenticated, status.Code(err)) + }) + } +} diff --git a/go/core/internal/grpcserver/server.go b/go/core/internal/grpcserver/server.go index 4d4916a588..bc5161c720 100644 --- a/go/core/internal/grpcserver/server.go +++ b/go/core/internal/grpcserver/server.go @@ -55,7 +55,6 @@ type Config struct { TLSCertFile string TLSKeyFile string Authenticator auth.AuthProvider - RuntimeAuthenticator auth.AuthProvider ShareStore sessionsvc.ShareStore AgentService *kubecrud.Service[*v1alpha3.Agent, *v1alpha3.AgentList] AgentTemplateService *kubecrud.Service[*v1alpha3.AgentTemplate, *v1alpha3.AgentTemplateList] @@ -112,14 +111,14 @@ func New(config Config) (*Server, error) { grpc.ChainUnaryInterceptor( loggingUnaryInterceptor, recoverUnaryInterceptor, - authenticationUnaryInterceptor(config.Authenticator, config.RuntimeAuthenticator, config.ShareStore, config.MethodPolicies), + authenticationUnaryInterceptor(config.Authenticator, config.ShareStore, config.MethodPolicies), protovalidatemiddleware.UnaryServerInterceptor(validator), errorMappingUnaryInterceptor, ), grpc.ChainStreamInterceptor( loggingStreamInterceptor, recoverStreamInterceptor, - authenticationStreamInterceptor(config.Authenticator, config.RuntimeAuthenticator, config.ShareStore, config.MethodPolicies), + authenticationStreamInterceptor(config.Authenticator, config.ShareStore, config.MethodPolicies), protovalidatemiddleware.StreamServerInterceptor(validator), errorMappingStreamInterceptor, ), diff --git a/go/core/internal/grpcserver/task_store_test.go b/go/core/internal/grpcserver/task_store_test.go index 7ae3ad4cdb..e3d66a7ca6 100644 --- a/go/core/internal/grpcserver/task_store_test.go +++ b/go/core/internal/grpcserver/task_store_test.go @@ -37,13 +37,15 @@ import ( "github.com/kagent-dev/kagent/go/adk/pkg/controllerclient" runtimetaskstore "github.com/kagent-dev/kagent/go/adk/pkg/taskstore" - apia2a "github.com/kagent-dev/kagent/go/api/a2a" apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" runtimev1 "github.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1" "github.com/kagent-dev/kagent/go/core/internal/a2agateway" "github.com/kagent-dev/kagent/go/core/internal/database" "github.com/kagent-dev/kagent/go/core/internal/dbtest" authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/core/internal/jwttest" + "github.com/kagent-dev/kagent/go/core/internal/runtimeauth" + memoryservice "github.com/kagent-dev/kagent/go/core/internal/service/memory" sessionsvc "github.com/kagent-dev/kagent/go/core/internal/service/session" "github.com/kagent-dev/kagent/go/core/internal/service/taskstore" "github.com/kagent-dev/kagent/go/core/internal/substrate" @@ -84,6 +86,16 @@ func (s *lostRuntimeSaveResponse) CreateRuntimeTask(ctx context.Context, id stri type taskStoreRuntimeDialer struct{ listener *bufconn.Listener } +type runtimeMemoryStore struct { + *database.Client + agentName, userID string +} + +func (s *runtimeMemoryStore) ListAgentMemories(_ context.Context, agentName, userID string) ([]database.Memory, error) { + s.agentName, s.userID = agentName, userID + return nil, nil +} + func (d taskStoreRuntimeDialer) Dial(ctx context.Context, _ *apiv1.Session) (*a2aclient.Client, error) { return a2aclient.NewFromEndpoints(ctx, []*a2a.AgentInterface{{ URL: "127.0.0.1:1234", ProtocolBinding: a2a.TransportProtocolGRPC, ProtocolVersion: a2a.Version, @@ -113,7 +125,7 @@ func (e *runtimeCancelableExecutor) Cleanup(_ context.Context, input *a2asrv.Exe } } -// Exercise the real SDK -> private gRPC -> PostgreSQL path. The public observer +// Exercise the real SDK -> controller gRPC -> PostgreSQL path. The public observer // is disconnected before completion, and a successful save response is lost. // Neither fault may cause execution or artifact appends to repeat. func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { @@ -128,10 +140,18 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { listener := bufconn.Listen(DefaultMaxMessageSize) pushWake := make(chan struct{}, 1) tasks := taskstore.NewService(store, nil, pushWake) + issuer := jwttest.New(t) + verifier, err := substrate.NewActorJWTVerifier(t.Context(), issuer.URL, issuer.CAFile) + require.NoError(t, err) + runtimeToken := "Bearer " + issuer.Token(t, issuer.Claims("team-a", substrate.ActorName(id), "actor-uid")) + memories := &runtimeMemoryStore{Client: store.Client} + policy := runtimeauth.NewAuthorizer(auth.NoopAuthorizer{}, store) server, err := New(Config{ Listener: listener, SystemService: testSystemService(), - Authenticator: &authimpl.InsecureAuthenticator{}, RuntimeAuthenticator: &taskstore.Authenticator{}, + Authenticator: runtimeauth.NewProvider(&authimpl.InsecureAuthenticator{}, verifier, store, issuer.URL), TaskStoreService: tasks, + MemoryService: memoryservice.NewService(memories, policy), + SessionService: sessionsvc.NewService(store, policy, nil), }) require.NoError(t, err) serverCtx, stopServer := context.WithCancel(t.Context()) @@ -152,26 +172,43 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { private := controller.TaskStoreService() read := &runtimev1.TaskStoreServiceGetTaskRequest{SessionId: id, TaskId: "absent"} _, err = private.GetTask(t.Context(), read) - require.Equal(t, codes.Unauthenticated, status.Code(err)) + require.Equal(t, codes.PermissionDenied, status.Code(err)) forged := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("x-user-id", "alice", "x-agent-name", id)) _, err = private.GetTask(forged, read) - require.Equal(t, codes.Unauthenticated, status.Code(err)) - authenticated := metadata.NewOutgoingContext(t.Context(), metadata.Pairs(apia2a.InsecureRuntimeIdentityHeader, "team-a/session-"+id+"/actor-uid")) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + unsigned := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("x-kagent-insecure-runtime-identity", "team-a/session-"+id+"/actor-uid")) + _, err = private.GetTask(unsigned, read) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + authenticated := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("authorization", runtimeToken)) + publicConn, err := grpc.NewClient("passthrough:///sessions", grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() })) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, publicConn.Close()) }) + publicSessions := apiv1.NewSessionServiceClient(publicConn) + _, err = publicSessions.GetSession(authenticated, &apiv1.GetSessionRequest{SessionId: id}) + require.NoError(t, err) + _, err = publicSessions.GetSession(authenticated, &apiv1.GetSessionRequest{SessionId: uuid.NewString()}) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + _, err = controller.MemoryService().List(authenticated, &runtimev1.MemoryServiceListRequest{AgentName: "forged/agent"}) + require.NoError(t, err) + require.Equal(t, session.Agent.Namespace+"/"+session.Agent.Name, memories.agentName) + require.Equal(t, session.Creator, memories.userID) _, err = private.GetTask(authenticated, &runtimev1.TaskStoreServiceGetTaskRequest{SessionId: uuid.NewString(), TaskId: "absent"}) require.Equal(t, codes.PermissionDenied, status.Code(err)) _, err = private.GetTask(metadata.AppendToOutgoingContext(authenticated, "x-share-token", "share"), read) - require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Equal(t, codes.Unauthenticated, status.Code(err)) for _, test := range []struct { name string atespace string actorUID string want codes.Code }{ - {"replaced actor", "team-a", "replacement-uid", codes.NotFound}, - {"wrong atespace", "another-team", "actor-uid", codes.PermissionDenied}, + {"replaced actor", "team-a", "replacement-uid", codes.Unauthenticated}, + {"wrong atespace", "another-team", "actor-uid", codes.Unauthenticated}, } { t.Run(test.name, func(t *testing.T) { - badIdentity := metadata.NewOutgoingContext(t.Context(), metadata.Pairs(apia2a.InsecureRuntimeIdentityHeader, fmt.Sprintf("%s/session-%s/%s", test.atespace, id, test.actorUID))) + token := issuer.Token(t, issuer.Claims(test.atespace, substrate.ActorName(id), test.actorUID)) + badIdentity := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("authorization", "Bearer "+token)) _, err = private.GetTask(badIdentity, read) require.Equal(t, test.want, status.Code(err)) }) @@ -181,9 +218,23 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { identityPath := filepath.Join(t.TempDir(), "name") require.NoError(t, os.WriteFile(identityPath, []byte("session-"+id), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(identityPath), "atespace"), []byte("team-a"), 0o600)) - require.NoError(t, os.WriteFile(filepath.Join(filepath.Dir(identityPath), "uid"), []byte("actor-uid"), 0o600)) - runtimeStore := runtimetaskstore.New(controller, identityPath) + // Model the external egress boundary: only the runtime client's placeholder + // is replaced, before the real gRPC authenticator verifies the signed JWT. + runtimeController, err := controllerclient.New(controllerclient.Config{ + APIURL: "http://api.test", DialOptions: []grpc.DialOption{ + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), + grpc.WithUnaryInterceptor(func(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoke grpc.UnaryInvoker, opts ...grpc.CallOption) error { + md, _ := metadata.FromOutgoingContext(ctx) + require.Equal(t, []string{"Bearer placeholder"}, md.Get("authorization")) + md = md.Copy() + md.Set("authorization", runtimeToken) + return invoke(metadata.NewOutgoingContext(ctx, md), method, req, reply, cc, opts...) + }), + }, + }) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, runtimeController.Close()) }) + runtimeStore := runtimetaskstore.New(runtimeController, identityPath) release := make(chan struct{}) var executions atomic.Int32 executor := a2asrv.AgentExecutorFunc(func(ctx context.Context, exec *a2asrv.ExecutorContext) iter.Seq2[a2a.Event, error] { @@ -580,8 +631,18 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { // venv's interpreter. listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) - go func() { _ = server.server.Serve(listener) }() - t.Cleanup(func() { _ = listener.Close() }) + protocols := new(http.Protocols) + protocols.SetUnencryptedHTTP2(true) + egress := &http.Server{Protocols: protocols, Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("authorization") != "Bearer placeholder" { + http.Error(w, "runtime placeholder required", http.StatusUnauthorized) + return + } + r.Header.Set("authorization", runtimeToken) + server.server.ServeHTTP(w, r) + })} + go func() { _ = egress.Serve(listener) }() + t.Cleanup(func() { require.NoError(t, egress.Close()) }) root, err := filepath.Abs("../../../..") require.NoError(t, err) paths, err := filepath.Glob(filepath.Join(root, "python/packages/*/src")) diff --git a/go/core/internal/jwttest/issuer.go b/go/core/internal/jwttest/issuer.go new file mode 100644 index 0000000000..17b2edf4c1 --- /dev/null +++ b/go/core/internal/jwttest/issuer.go @@ -0,0 +1,85 @@ +// Package jwttest serves a test OIDC issuer for runtime authentication tests. +package jwttest + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "encoding/pem" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/lestrrat-go/jwx/jwk" + "github.com/stretchr/testify/require" +) + +type Issuer struct { + URL string + CAFile string + Key *ecdsa.PrivateKey + keys atomic.Value +} + +func New(t *testing.T) *Issuer { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + i := &Issuer{Key: key} + i.SetKey(t, &key.PublicKey, "test-key") + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/.well-known/openid-configuration": + require.NoError(t, json.NewEncoder(w).Encode(map[string]string{"issuer": i.URL, "jwks_uri": i.URL + "/keys"})) + case "/keys": + _, err := w.Write(i.keys.Load().([]byte)) + require.NoError(t, err) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(server.Close) + i.URL = server.URL + i.CAFile = filepath.Join(t.TempDir(), "ca.pem") + require.NoError(t, os.WriteFile(i.CAFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0o600)) + return i +} + +func (i *Issuer) SetKey(t *testing.T, publicKey any, kid string) { + t.Helper() + key, err := jwk.New(publicKey) + require.NoError(t, err) + require.NoError(t, key.Set(jwk.KeyIDKey, kid)) + require.NoError(t, key.Set(jwk.AlgorithmKey, "ES256")) + require.NoError(t, key.Set(jwk.KeyUsageKey, "sig")) + set := jwk.NewSet() + set.Add(key) + data, err := json.Marshal(set) + require.NoError(t, err) + i.keys.Store(data) +} + +func (i *Issuer) Claims(atespace, name, uid string) jwt.MapClaims { + now := time.Now() + return jwt.MapClaims{ + "iss": i.URL, "aud": []string{"kagent-runtime"}, "sub": "actor/" + atespace + "/" + name, + "exp": now.Add(5 * time.Minute).Unix(), "iat": now.Unix(), "nbf": now.Add(-5 * time.Minute).Unix(), + "actor.ate.dev": map[string]string{"atespace": atespace, "actorName": name, "actorUID": uid}, + } +} + +func (i *Issuer) Token(t *testing.T, claims jwt.Claims) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodES256, claims) + token.Header["kid"] = "test-key" + raw, err := token.SignedString(i.Key) + require.NoError(t, err) + return raw +} diff --git a/go/core/internal/mcp/auth_test.go b/go/core/internal/mcp/auth_test.go new file mode 100644 index 0000000000..b13c9fa56b --- /dev/null +++ b/go/core/internal/mcp/auth_test.go @@ -0,0 +1,85 @@ +package mcp + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" + "github.com/kagent-dev/kagent/go/core/internal/database" + authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/core/internal/jwttest" + "github.com/kagent-dev/kagent/go/core/internal/runtimeauth" + sessionsvc "github.com/kagent-dev/kagent/go/core/internal/service/session" + "github.com/kagent-dev/kagent/go/core/internal/substrate" + "github.com/kagent-dev/kagent/go/core/pkg/auth" +) + +type actorSessionStore struct { + *fakeSessionStore + sessions []*apiv1.Session +} + +func (s *actorSessionStore) GetSessionForRuntime(_ context.Context, id, uid string) (*apiv1.Session, error) { + if id == s.sessions[0].Id && uid == "actor-uid" { + return s.sessions[0], nil + } + return nil, database.ErrNotFound +} + +func (s *actorSessionStore) ListSessions(_ context.Context, query database.SessionQuery) ([]*apiv1.Session, error) { + var result []*apiv1.Session + for _, session := range s.sessions { + if session.Creator == query.UserID && session.Id > query.AfterID { + result = append(result, session) + } + } + return result, nil +} + +func TestMCPActorListsOnlyItsSession(t *testing.T) { + store := &actorSessionStore{fakeSessionStore: &fakeSessionStore{}} + for range 3 { + store.sessions = append(store.sessions, &apiv1.Session{Id: uuid.NewString(), Creator: "owner", Agent: &apiv1.ResourceReference{Namespace: "team-a", Name: "agent"}, State: apiv1.RuntimeState_RUNTIME_STATE_READY}) + } + own := store.sessions[0] + own.A2AAuthority = substrate.ActorHost("team-a", substrate.ActorName(own.Id), "") + store.sessions[2].Creator = "other-user" + issuer := jwttest.New(t) + verifier, err := substrate.NewActorJWTVerifier(t.Context(), issuer.URL, issuer.CAFile) + require.NoError(t, err) + provider := runtimeauth.NewProvider(&authimpl.InsecureAuthenticator{}, verifier, store, issuer.URL) + sessions := sessionsvc.NewService(store, runtimeauth.NewAuthorizer(auth.NoopAuthorizer{}, store), &fakeSessionWorkflow{}) + handler, err := New(sessions, testCheckpointService(), &fakeGateway{}, nil, nil) + require.NoError(t, err) + server := httptest.NewServer(auth.AuthnMiddleware(provider)(handler)) + t.Cleanup(server.Close) + token := "Bearer " + issuer.Token(t, issuer.Claims("team-a", substrate.ActorName(own.Id), "actor-uid")) + for _, test := range []struct { + name string + headers http.Header + count int + }{ + {"actor", http.Header{"Authorization": {token}, "X-User-Id": {"other-user"}}, 1}, + {"user", http.Header{"X-User-Id": {"owner"}}, 2}, + } { + t.Run(test.name, func(t *testing.T) { + response := rawMCPCall(t, server.URL, "tools/call", map[string]any{"name": listToolName, "arguments": map[string]any{}}, false, test.headers) + result := response["result"].(map[string]any) + require.NotEqual(t, true, result["isError"]) + listed := result["structuredContent"].(map[string]any)["sessions"].([]any) + require.Len(t, listed, test.count) + if test.name == "actor" { + require.Equal(t, own.Id, listed[0].(map[string]any)["id"]) + } + }) + } + // Knowing another session's ID, even for the same agent and owner, cannot + // grant invocation authority through a different MCP tool. + response := rawMCPCall(t, server.URL, "tools/call", map[string]any{"name": invokeToolName, "arguments": map[string]any{"session_id": store.sessions[1].Id, "message": "hello"}}, false, http.Header{"Authorization": {token}}) + require.Equal(t, true, response["result"].(map[string]any)["isError"]) +} diff --git a/go/core/internal/mcp/tasks_test.go b/go/core/internal/mcp/tasks_test.go index 3012863359..94148ae04b 100644 --- a/go/core/internal/mcp/tasks_test.go +++ b/go/core/internal/mcp/tasks_test.go @@ -352,7 +352,7 @@ func taskParamsBase() mcp.ParamsBase { }} } -func rawMCPCall(t *testing.T, endpoint, method string, params map[string]any, tasks bool) map[string]any { +func rawMCPCall(t *testing.T, endpoint, method string, params map[string]any, tasks bool, headers ...http.Header) map[string]any { t.Helper() extensions := map[string]any{} if tasks { @@ -373,6 +373,13 @@ func rawMCPCall(t *testing.T, endpoint, method string, params map[string]any, ta if err != nil { t.Fatal(err) } + for _, header := range headers { + for key, values := range header { + for _, value := range values { + req.Header.Add(key, value) + } + } + } req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set("Mcp-Protocol-Version", "2026-07-28") diff --git a/go/core/internal/runtimeauth/auth.go b/go/core/internal/runtimeauth/auth.go new file mode 100644 index 0000000000..66179781de --- /dev/null +++ b/go/core/internal/runtimeauth/auth.go @@ -0,0 +1,103 @@ +// Package runtimeauth authenticates Substrate actors without granting user authority. +package runtimeauth + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/google/uuid" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" + "github.com/kagent-dev/kagent/go/core/internal/database" + "github.com/kagent-dev/kagent/go/core/internal/substrate" + "github.com/kagent-dev/kagent/go/core/pkg/auth" + "github.com/kagent-dev/kagent/go/pkg/logging" +) + +type actorJWTVerifier interface { + Verify(context.Context, string) (substrate.ActorIdentity, error) +} + +// Authenticator verifies runtime credentials. Session, atespace, and recorded +// Actor UID authorization remain in the service. +type Authenticator struct{ verifier actorJWTVerifier } + +func NewAuthenticator(verifier actorJWTVerifier) *Authenticator { + return &Authenticator{verifier: verifier} +} + +var _ auth.AuthProvider = (*Authenticator)(nil) +var _ actorJWTVerifier = (*substrate.ActorJWTVerifier)(nil) + +func (a *Authenticator) Authenticate(ctx context.Context, headers http.Header, _ url.Values) (auth.Session, error) { + values := headers.Values("authorization") + if len(values) != 1 { + return nil, fmt.Errorf("one runtime authorization header is required") + } + scheme, token, ok := strings.Cut(values[0], " ") + if !ok || !strings.EqualFold(scheme, "Bearer") || token == "" { + return nil, fmt.Errorf("runtime authorization requires a Bearer token") + } + identity, err := a.verifier.Verify(ctx, token) + if err != nil { + logging.FromContext(ctx).WarnContext(ctx, "runtime actor authentication failed", "error", err) + return nil, err + } + id, ok := strings.CutPrefix(identity.ActorName, "session-") + if !ok || identity.Atespace == "" || identity.ActorUID == "" { + return nil, fmt.Errorf("incomplete Substrate actor identity") + } + if _, err := uuid.Parse(id); err != nil { + return nil, fmt.Errorf("invalid runtime session identity: %w", err) + } + return runtimeSession{sessionID: id, atespace: identity.Atespace, actorUID: identity.ActorUID}, nil +} + +func (*Authenticator) UpstreamAuth(request *http.Request, session auth.Session, target auth.Principal) error { + identity, ok := session.(runtimeSession) + if !ok || identity.owner == "" || target.Agent.ID != identity.sessionID { + return fmt.Errorf("actor cannot access the upstream session") + } + // The controller has authorized this call. Forward only the bound owner to + // the private runtime; an Actor JWT must never become a user's credential. + request.Header.Del("Authorization") + request.Header.Set("X-User-Id", identity.owner) + return nil +} + +type runtimeSession struct{ sessionID, atespace, actorUID, owner, agent string } + +func (s runtimeSession) Principal() auth.Principal { + return auth.Principal{User: auth.User{ID: s.owner}, Agent: auth.Agent{ID: s.agent}, Actor: &auth.Actor{SessionID: s.sessionID}} +} + +type SessionStore interface { + GetSessionForRuntime(context.Context, string, string) (*apiv1.Session, error) +} + +// Session resolves the current session binding, including the recorded actor UID. +// It never accepts caller-supplied user, agent, or share headers as authority. +func Session(ctx context.Context, store SessionStore) (*apiv1.Session, error) { + authSession, _ := auth.AuthSessionFrom(ctx) + identity, ok := authSession.(runtimeSession) + if !ok { + return nil, status.Error(codes.PermissionDenied, "runtime actor identity is required") + } + session, err := store.GetSessionForRuntime(ctx, identity.sessionID, identity.actorUID) + if errors.Is(err, database.ErrNotFound) { + return nil, status.Error(codes.PermissionDenied, "actor does not belong to an active session") + } + if err != nil { + return nil, fmt.Errorf("resolve actor session: %w", err) + } + if session.A2AAuthority != substrate.ActorHost(identity.atespace, substrate.ActorName(identity.sessionID), "") { + return nil, status.Error(codes.PermissionDenied, "actor does not belong to this session") + } + return session, nil +} diff --git a/go/core/internal/runtimeauth/auth_test.go b/go/core/internal/runtimeauth/auth_test.go new file mode 100644 index 0000000000..4f3d9bd5a5 --- /dev/null +++ b/go/core/internal/runtimeauth/auth_test.go @@ -0,0 +1,51 @@ +package runtimeauth + +import ( + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/kagent-dev/kagent/go/core/internal/jwttest" + "github.com/kagent-dev/kagent/go/core/internal/substrate" +) + +func TestRuntimeJWTAuthentication(t *testing.T) { + i := jwttest.New(t) + v, err := substrate.NewActorJWTVerifier(t.Context(), i.URL, i.CAFile) + require.NoError(t, err) + a := NewAuthenticator(v) + id := uuid.NewString() + valid := "Bearer " + i.Token(t, i.Claims("team-a", "session-"+id, "actor-uid")) + for _, test := range []struct { + name string + values []string + valid bool + }{ + {"valid", []string{valid}, true}, + {"missing", nil, false}, + {"duplicate", []string{valid, valid}, false}, + {"placeholder", []string{"Bearer placeholder"}, false}, + {"wrong scheme", []string{"Basic abc"}, false}, + {"empty token", []string{"Bearer "}, false}, + {"invalid session", []string{"Bearer " + i.Token(t, i.Claims("team-a", "session-invalid", "actor-uid"))}, false}, + {"sandbox actor", []string{"Bearer " + i.Token(t, i.Claims("team-a", "sandbox-"+id, "actor-uid"))}, false}, + } { + t.Run(test.name, func(t *testing.T) { + headers := http.Header{} + // The removed header never supplies or overrides authenticated identity. + headers.Set("x-kagent-insecure-runtime-identity", "other/session-"+uuid.NewString()+"/forged") + for _, value := range test.values { + headers.Add("authorization", value) + } + session, err := a.Authenticate(t.Context(), headers, nil) + if !test.valid { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, runtimeSession{sessionID: id, atespace: "team-a", actorUID: "actor-uid"}, session) + }) + } +} diff --git a/go/core/internal/runtimeauth/authorizer.go b/go/core/internal/runtimeauth/authorizer.go new file mode 100644 index 0000000000..082c8ed5f0 --- /dev/null +++ b/go/core/internal/runtimeauth/authorizer.go @@ -0,0 +1,70 @@ +package runtimeauth + +import ( + "context" + "fmt" + + "github.com/kagent-dev/kagent/go/api/authorization" + "github.com/kagent-dev/kagent/go/core/pkg/auth" +) + +// Authorizer keeps the default actor policy separate from configured user +// permissions. Resource checks apply equally to HTTP, MCP, and gRPC callers. +type Authorizer struct { + users auth.CollectionAuthorizer + store SessionStore +} + +var _ auth.CollectionAuthorizer = (*Authorizer)(nil) + +func NewAuthorizer(users auth.CollectionAuthorizer, store SessionStore) *Authorizer { + return &Authorizer{users: users, store: store} +} + +func (a *Authorizer) Check(ctx context.Context, principal auth.Principal, verb auth.Verb, resource auth.Resource) error { + if principal.Actor == nil { + return a.users.Check(ctx, principal, verb, resource) + } + session, err := Session(ctx, a.store) + if err != nil { + return err + } + switch resource.Type { + case "Agent": + if verb == auth.VerbGet && resource.Namespace == session.GetAgent().GetNamespace() && resource.Name == session.GetAgent().GetName() { + return nil + } + case "Session": + // Empty get authorizes listing; the service checks each session before + // pagination. Empty create never grants creation of another session. + if verb == auth.VerbGet && resource.Name == "" { + return nil + } + if resource.Name == session.Id && (verb == auth.VerbGet || verb == auth.VerbCreate || verb == auth.VerbUpdate) { + return nil + } + case "Memory": + if resource.Name == session.GetAgent().GetNamespace()+"/"+session.GetAgent().GetName() && + (verb == auth.VerbGet || verb == auth.VerbList || verb == auth.VerbCreate || verb == auth.VerbDelete) { + return nil + } + } + return fmt.Errorf("actor is not authorized for %s on %s", verb, resource.Type) +} + +func (a *Authorizer) Scope(ctx context.Context, principal auth.Principal, verb auth.Verb, resourceType string) (authorization.AuthorizationScope, error) { + if principal.Actor == nil { + return a.users.Scope(ctx, principal, verb, resourceType) + } + session, err := Session(ctx, a.store) + if err != nil { + return authorization.AuthorizationScope{}, err + } + if verb != auth.VerbList || resourceType != "Agent" { + return authorization.AuthorizationScope{Kind: authorization.ScopeNone}, nil + } + return authorization.AuthorizationScope{Kind: authorization.ScopeAnyOf, AnyOf: []authorization.ScopeClause{{All: []authorization.ScopePredicate{ + {Attribute: authorization.AttributeNamespace, Operator: authorization.ScopeIn, Values: []string{session.GetAgent().GetNamespace()}}, + {Attribute: authorization.AttributeName, Operator: authorization.ScopeIn, Values: []string{session.GetAgent().GetName()}}, + }}}}, nil +} diff --git a/go/core/internal/runtimeauth/authorizer_test.go b/go/core/internal/runtimeauth/authorizer_test.go new file mode 100644 index 0000000000..3a9188c62e --- /dev/null +++ b/go/core/internal/runtimeauth/authorizer_test.go @@ -0,0 +1,94 @@ +package runtimeauth_test + +import ( + "context" + "fmt" + "net/http" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/kagent-dev/kagent/go/api/authorization" + authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/core/internal/jwttest" + "github.com/kagent-dev/kagent/go/core/internal/runtimeauth" + "github.com/kagent-dev/kagent/go/core/internal/substrate" + "github.com/kagent-dev/kagent/go/core/pkg/auth" +) + +type denyUsers struct{ auth.NoopAuthorizer } + +func (denyUsers) Check(context.Context, auth.Principal, auth.Verb, auth.Resource) error { + return fmt.Errorf("user denied") +} + +func (denyUsers) Scope(context.Context, auth.Principal, auth.Verb, string) (authorization.AuthorizationScope, error) { + return authorization.AuthorizationScope{Kind: authorization.ScopeNone}, nil +} + +func TestActorPolicyDoesNotInheritOwnerPermissions(t *testing.T) { + issuer := jwttest.New(t) + verifier, err := substrate.NewActorJWTVerifier(t.Context(), issuer.URL, issuer.CAFile) + require.NoError(t, err) + store := newBinding() + provider := runtimeauth.NewProvider(&authimpl.InsecureAuthenticator{}, verifier, store, issuer.URL) + identity, err := provider.Authenticate(t.Context(), http.Header{"Authorization": {"Bearer " + issuer.Token(t, issuer.Claims("team-a", substrate.ActorName(store.session.Id), store.actorUID))}}, nil) + require.NoError(t, err) + ctx := auth.AuthSessionTo(t.Context(), identity) + for _, users := range []auth.CollectionAuthorizer{auth.NoopAuthorizer{}, denyUsers{}} { + policy := runtimeauth.NewAuthorizer(users, store) + for _, test := range []struct { + name string + verb auth.Verb + resource auth.Resource + allowed bool + }{ + {"own agent", auth.VerbGet, auth.Resource{Type: "Agent", Namespace: "team-a", Name: "agent"}, true}, + {"other agent", auth.VerbGet, auth.Resource{Type: "Agent", Namespace: "team-a", Name: "other"}, false}, + {"other namespace", auth.VerbGet, auth.Resource{Type: "Agent", Namespace: "other", Name: "agent"}, false}, + {"mutate agent", auth.VerbUpdate, auth.Resource{Type: "Agent", Namespace: "team-a", Name: "agent"}, false}, + {"own session", auth.VerbGet, auth.Resource{Type: "Session", Name: store.session.Id}, true}, + {"own session send", auth.VerbCreate, auth.Resource{Type: "Session", Name: store.session.Id}, true}, + {"own session continue", auth.VerbUpdate, auth.Resource{Type: "Session", Name: store.session.Id}, true}, + {"session collection", auth.VerbGet, auth.Resource{Type: "Session"}, true}, + {"other session", auth.VerbGet, auth.Resource{Type: "Session", Name: uuid.NewString()}, false}, + {"new session", auth.VerbCreate, auth.Resource{Type: "Session"}, false}, + {"all creators", auth.VerbGet, auth.Resource{Type: "SessionAllCreators"}, false}, + {"delete session", auth.VerbDelete, auth.Resource{Type: "Session", Name: store.session.Id}, false}, + {"share", auth.VerbCreate, auth.Resource{Type: "SessionShare", Name: store.session.Id}, false}, + {"own memory", auth.VerbList, auth.Resource{Type: "Memory", Name: "team-a/agent"}, true}, + {"other memory", auth.VerbDelete, auth.Resource{Type: "Memory", Name: "team-a/other"}, false}, + {"sandbox", auth.VerbGet, auth.Resource{Type: "Sandbox", Name: store.session.Id}, false}, + {"namespaces", auth.VerbGet, auth.Resource{Type: "SystemNamespaces"}, false}, + {"unknown", auth.VerbGet, auth.Resource{Type: "Extension"}, false}, + } { + t.Run(test.name, func(t *testing.T) { + err := policy.Check(ctx, identity.Principal(), test.verb, test.resource) + if test.allowed { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } + scope, err := policy.Scope(ctx, identity.Principal(), auth.VerbList, "Agent") + require.NoError(t, err) + require.Equal(t, authorization.AuthorizationScope{Kind: authorization.ScopeAnyOf, AnyOf: []authorization.ScopeClause{{All: []authorization.ScopePredicate{ + {Attribute: authorization.AttributeNamespace, Operator: authorization.ScopeIn, Values: []string{"team-a"}}, + {Attribute: authorization.AttributeName, Operator: authorization.ScopeIn, Values: []string{"agent"}}, + }}}}, scope) + scope, err = policy.Scope(ctx, identity.Principal(), auth.VerbList, "SandboxTemplate") + require.NoError(t, err) + require.Equal(t, authorization.ScopeNone, scope.Kind) + } + + policy := runtimeauth.NewAuthorizer(denyUsers{}, store) + user, err := provider.Authenticate(t.Context(), http.Header{"X-User-Id": {"owner"}}, nil) + require.NoError(t, err) + require.EqualError(t, policy.Check(auth.AuthSessionTo(t.Context(), user), user.Principal(), auth.VerbGet, auth.Resource{Type: "Session", Name: store.session.Id}), "user denied") + store.actorUID = "replacement" + require.Error(t, policy.Check(ctx, identity.Principal(), auth.VerbGet, auth.Resource{Type: "Session", Name: store.session.Id})) + _, err = policy.Scope(ctx, identity.Principal(), auth.VerbList, "Agent") + require.Error(t, err) +} diff --git a/go/core/internal/runtimeauth/provider.go b/go/core/internal/runtimeauth/provider.go new file mode 100644 index 0000000000..3caa64f306 --- /dev/null +++ b/go/core/internal/runtimeauth/provider.go @@ -0,0 +1,83 @@ +package runtimeauth + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/golang-jwt/jwt/v5" + + "github.com/kagent-dev/kagent/go/core/pkg/auth" +) + +type Provider struct { + users auth.AuthProvider + actors *Authenticator + store SessionStore + actorIssuer string +} + +var _ auth.AuthProvider = (*Provider)(nil) + +// NewProvider authenticates users and actors on the same HTTP and gRPC APIs. +// Actor credentials never fall back to the configured user provider. +func NewProvider(users auth.AuthProvider, verifier actorJWTVerifier, store SessionStore, actorIssuer string) *Provider { + return &Provider{users: users, actors: NewAuthenticator(verifier), store: store, actorIssuer: actorIssuer} +} + +func (p *Provider) Authenticate(ctx context.Context, headers http.Header, query url.Values) (auth.Session, error) { + if !actorCredentials(headers, p.actorIssuer) { + return p.users.Authenticate(ctx, headers, query) + } + identity, err := p.actors.Authenticate(ctx, headers, query) + if err != nil { + return nil, err + } + if headers.Get("X-Share-Token") != "" { + return nil, fmt.Errorf("actors cannot use share credentials") + } + session, err := Session(auth.AuthSessionTo(ctx, identity), p.store) + if err != nil { + return nil, err + } + if session.Creator == "" || session.GetAgent().GetNamespace() == "" || session.GetAgent().GetName() == "" { + return nil, fmt.Errorf("actor session has no owner or agent") + } + bound := identity.(runtimeSession) + bound.owner = session.Creator + bound.agent = session.Agent.Namespace + "/" + session.Agent.Name + return bound, nil +} + +func (p *Provider) UpstreamAuth(request *http.Request, session auth.Session, target auth.Principal) error { + if session != nil && session.Principal().Actor != nil { + if _, err := Session(auth.AuthSessionTo(request.Context(), session), p.store); err != nil { + return err + } + return p.actors.UpstreamAuth(request, session, target) + } + return p.users.UpstreamAuth(request, session, target) +} + +func actorCredentials(headers http.Header, actorIssuer string) bool { + for _, value := range headers.Values("Authorization") { + scheme, raw, ok := strings.Cut(value, " ") + if !ok || !strings.EqualFold(scheme, "Bearer") { + continue + } + claims := jwt.MapClaims{} + // Unverified claims only select the verifier; they never grant authority. + // Opaque user credentials remain the configured provider's responsibility. + if _, _, err := jwt.NewParser().ParseUnverified(raw, claims); err != nil && len(claims) == 0 { + continue + } + issuer, issuerErr := claims.GetIssuer() + _, actor := claims["actor.ate.dev"] + if actor || (issuerErr == nil && actorIssuer != "" && issuer == actorIssuer) { + return true + } + } + return false +} diff --git a/go/core/internal/runtimeauth/provider_test.go b/go/core/internal/runtimeauth/provider_test.go new file mode 100644 index 0000000000..8795152de2 --- /dev/null +++ b/go/core/internal/runtimeauth/provider_test.go @@ -0,0 +1,127 @@ +package runtimeauth_test + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" + "github.com/kagent-dev/kagent/go/core/internal/database" + authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/core/internal/jwttest" + "github.com/kagent-dev/kagent/go/core/internal/runtimeauth" + "github.com/kagent-dev/kagent/go/core/internal/substrate" + "github.com/kagent-dev/kagent/go/core/pkg/auth" +) + +type bindingStore struct { + session *apiv1.Session + actorUID string +} + +var _ runtimeauth.SessionStore = (*bindingStore)(nil) + +func (s *bindingStore) GetSessionForRuntime(_ context.Context, id, uid string) (*apiv1.Session, error) { + if s.session == nil || s.session.Id != id || uid != s.actorUID { + return nil, database.ErrNotFound + } + return s.session, nil +} + +func newBinding() *bindingStore { + id := uuid.NewString() + return &bindingStore{actorUID: "actor-uid", session: &apiv1.Session{ + Id: id, Creator: "owner", Agent: &apiv1.ResourceReference{Namespace: "team-a", Name: "agent"}, + A2AAuthority: substrate.ActorHost("team-a", substrate.ActorName(id), ""), + }} +} + +func TestSharedAuthenticationNeverFallsBackForActorCredentials(t *testing.T) { + issuer := jwttest.New(t) + verifier, err := substrate.NewActorJWTVerifier(t.Context(), issuer.URL, issuer.CAFile) + require.NoError(t, err) + store := newBinding() + provider := runtimeauth.NewProvider(&authimpl.InsecureAuthenticator{}, verifier, store, issuer.URL) + for _, claims := range []jwt.MapClaims{ + issuer.Claims("team-a", substrate.ActorName(store.session.Id), "replaced-uid"), + issuer.Claims("other", substrate.ActorName(store.session.Id), store.actorUID), + issuer.Claims("team-a", substrate.ActorName(uuid.NewString()), store.actorUID), + {"iss": issuer.URL, "sub": "forged-user"}, + {"iss": "other", "actor.ate.dev": map[string]any{}}, + } { + headers := http.Header{"Authorization": {"Bearer " + issuer.Token(t, claims)}, "X-User-Id": {"admin"}} + _, err := provider.Authenticate(t.Context(), headers, nil) + require.Error(t, err) + } + user, err := provider.Authenticate(t.Context(), http.Header{"X-User-Id": {"alice"}, "X-Agent-Name": {"team-a/agent"}}, nil) + require.NoError(t, err) + require.Equal(t, "alice", user.Principal().User.ID) + require.Nil(t, user.Principal().Actor) +} + +func TestSharedHTTPAuthenticationBindsActorsAndPreservesUserMode(t *testing.T) { + issuer := jwttest.New(t) + verifier, err := substrate.NewActorJWTVerifier(t.Context(), issuer.URL, issuer.CAFile) + require.NoError(t, err) + store := newBinding() + token := "Bearer " + issuer.Token(t, issuer.Claims("team-a", substrate.ActorName(store.session.Id), store.actorUID)) + for _, mode := range []struct { + name string + users auth.AuthProvider + anonymous int + }{ + {"insecure", &authimpl.InsecureAuthenticator{}, http.StatusOK}, + {"proxy", authimpl.NewProxyAuthenticator("sub"), http.StatusUnauthorized}, + } { + t.Run(mode.name, func(t *testing.T) { + provider := runtimeauth.NewProvider(mode.users, verifier, store, issuer.URL) + handler := auth.AuthnMiddleware(provider)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + session, ok := auth.AuthSessionFrom(r.Context()) + require.True(t, ok) + if r.Header.Get("Authorization") == token { + require.Equal(t, auth.Principal{User: auth.User{ID: "owner"}, Agent: auth.Agent{ID: "team-a/agent"}, Actor: &auth.Actor{SessionID: store.session.Id}}, session.Principal()) + } + w.WriteHeader(http.StatusOK) + })) + for _, test := range []struct { + token, share string + status int + }{ + {token, "", http.StatusOK}, {token, "share", http.StatusUnauthorized}, {"", "", mode.anonymous}, + } { + request := httptest.NewRequest(http.MethodPost, "/mcp?user_id=forged", nil) + request.Header.Set("Authorization", test.token) + request.Header.Set("X-User-Id", "forged") + request.Header.Set("X-Agent-Name", "other/agent") + request.Header.Set("X-Share-Token", test.share) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + require.Equal(t, test.status, response.Code) + } + }) + } +} + +func TestActorUpstreamIdentityIsBoundAndDoesNotForwardBearer(t *testing.T) { + issuer := jwttest.New(t) + verifier, err := substrate.NewActorJWTVerifier(t.Context(), issuer.URL, issuer.CAFile) + require.NoError(t, err) + store := newBinding() + provider := runtimeauth.NewProvider(&authimpl.InsecureAuthenticator{}, verifier, store, issuer.URL) + identity, err := provider.Authenticate(t.Context(), http.Header{"Authorization": {"Bearer " + issuer.Token(t, issuer.Claims("team-a", substrate.ActorName(store.session.Id), store.actorUID))}}, nil) + require.NoError(t, err) + target := auth.Principal{Agent: auth.Agent{ID: store.session.Id}} + request := httptest.NewRequest(http.MethodPost, "http://runtime", nil) + request.Header.Set("Authorization", "Bearer untrusted") + require.NoError(t, provider.UpstreamAuth(request, identity, target)) + require.Empty(t, request.Header.Get("Authorization")) + require.Equal(t, "owner", request.Header.Get("X-User-Id")) + require.Error(t, provider.UpstreamAuth(request, identity, auth.Principal{Agent: auth.Agent{ID: uuid.NewString()}})) + store.actorUID = "replacement" + require.Error(t, provider.UpstreamAuth(request, identity, target)) +} diff --git a/go/core/internal/service/memory/service.go b/go/core/internal/service/memory/service.go index 4f909f73b2..f069e9aeff 100644 --- a/go/core/internal/service/memory/service.go +++ b/go/core/internal/service/memory/service.go @@ -9,7 +9,9 @@ import ( "github.com/pgvector/pgvector-go" "github.com/kagent-dev/kagent/go/core/internal/database" + "github.com/kagent-dev/kagent/go/core/internal/runtimeauth" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" + "github.com/kagent-dev/kagent/go/core/pkg/auth" ) const ( @@ -20,6 +22,7 @@ const ( ) type Store interface { + runtimeauth.SessionStore StoreAgentMemories(context.Context, ...*database.Memory) error SearchAgentMemory(context.Context, string, string, pgvector.Vector, int) ([]database.AgentMemorySearchResult, error) ListAgentMemories(context.Context, string, string) ([]database.Memory, error) @@ -27,20 +30,16 @@ type Store interface { } type Input struct { - AgentName string - UserID string - Content string - Vector []float32 - Metadata json.RawMessage - TTLDays int + Content string + Vector []float32 + Metadata json.RawMessage + TTLDays int } type SearchRequest struct { - AgentName string - UserID string - Vector []float32 - Limit int - MinScore float64 + Vector []float32 + Limit int + MinScore float64 } type SearchResult struct { @@ -60,12 +59,13 @@ func WithClock(now func() time.Time) Option { } type Service struct { - store Store - now func() time.Time + store Store + authorizer auth.Authorizer + now func() time.Time } -func NewService(store Store, options ...Option) *Service { - service := &Service{store: store, now: time.Now} +func NewService(store Store, authorizer auth.Authorizer, options ...Option) *Service { + service := &Service{store: store, authorizer: authorizer, now: time.Now} for _, option := range options { option(service) } @@ -73,13 +73,17 @@ func NewService(store Store, options ...Option) *Service { } func (s *Service) Add(ctx context.Context, input Input) (string, error) { - if err := validateInput(input, "Missing required fields (agent_name, user_id, vector)"); err != nil { + agentName, userID, err := s.scope(ctx, auth.VerbCreate) + if err != nil { + return "", err + } + if err := validateVector(input.Vector); err != nil { return "", err } if s.store == nil { return "", serviceerrors.NewInternal("failed to save memory", fmt.Errorf("database client is not configured")) } - memory, err := s.toMemory(input) + memory, err := s.toMemory(input, agentName, userID) if err != nil { return "", err } @@ -103,12 +107,16 @@ func (s *Service) AddBatch(ctx context.Context, inputs []Input) (int, error) { return 0, serviceerrors.NewInternal("failed to save memory batch", fmt.Errorf("database client is not configured")) } + agentName, userID, err := s.scope(ctx, auth.VerbCreate) + if err != nil { + return 0, err + } memories := make([]*database.Memory, 0, len(inputs)) for _, input := range inputs { - if err := validateInput(input, "Missing required fields in batch item"); err != nil { + if err := validateVector(input.Vector); err != nil { return 0, err } - memory, err := s.toMemory(input) + memory, err := s.toMemory(input, agentName, userID) if err != nil { return 0, err } @@ -121,8 +129,9 @@ func (s *Service) AddBatch(ctx context.Context, inputs []Input) (int, error) { } func (s *Service) Search(ctx context.Context, request SearchRequest) ([]SearchResult, error) { - if request.AgentName == "" || request.UserID == "" || len(request.Vector) == 0 { - return nil, serviceerrors.NewInvalidArgument("Missing required fields (agent_name, user_id, vector)", nil) + agentName, userID, err := s.scope(ctx, auth.VerbGet) + if err != nil { + return nil, err } if err := validateVector(request.Vector); err != nil { return nil, err @@ -135,8 +144,8 @@ func (s *Service) Search(ctx context.Context, request SearchRequest) ([]SearchRe } results, err := s.store.SearchAgentMemory( ctx, - request.AgentName, - request.UserID, + agentName, + userID, pgvector.NewVector(request.Vector), request.Limit, ) @@ -164,7 +173,11 @@ func (s *Service) Search(ctx context.Context, request SearchRequest) ([]SearchRe return response, nil } -func (s *Service) List(ctx context.Context, agentName, userID string) ([]database.Memory, error) { +func (s *Service) List(ctx context.Context, agentName string) ([]database.Memory, error) { + agentName, userID, err := s.managementScope(ctx, auth.VerbList, agentName) + if err != nil { + return nil, err + } if agentName == "" || userID == "" { return nil, serviceerrors.NewInvalidArgument("Missing required query parameters (agent_name, user_id)", nil) } @@ -178,7 +191,11 @@ func (s *Service) List(ctx context.Context, agentName, userID string) ([]databas return memories, nil } -func (s *Service) Delete(ctx context.Context, agentName, userID string) error { +func (s *Service) Delete(ctx context.Context, agentName string) error { + agentName, userID, err := s.managementScope(ctx, auth.VerbDelete, agentName) + if err != nil { + return err + } if agentName == "" || userID == "" { return serviceerrors.NewInvalidArgument("Missing required query parameters (agent_name, user_id)", nil) } @@ -191,7 +208,46 @@ func (s *Service) Delete(ctx context.Context, agentName, userID string) error { return nil } -func (s *Service) toMemory(input Input) (*database.Memory, error) { +// Users can manage their own memories even when the agent or session no longer +// exists. Actors remain bound to their current session's agent and owner. +func (s *Service) managementScope(ctx context.Context, verb auth.Verb, agentName string) (string, string, error) { + if session, ok := auth.AuthSessionFrom(ctx); ok && session != nil { + if principal := session.Principal(); principal.Actor == nil && principal.User.ID != "" { + return agentName, principal.User.ID, s.authorize(ctx, verb, agentName) + } + } + return s.scope(ctx, verb) +} + +// scope binds memory to the persisted session's agent and owner. Request fields +// and forwarded headers never select another identity's memory namespace. +func (s *Service) scope(ctx context.Context, verb auth.Verb) (string, string, error) { + if s.store == nil { + return "", "", serviceerrors.NewInternal("memory store is not configured", nil) + } + session, err := runtimeauth.Session(ctx, s.store) + if err != nil { + return "", "", err + } + if session.GetAgent().GetNamespace() == "" || session.GetAgent().GetName() == "" || session.Creator == "" { + return "", "", serviceerrors.NewPermissionDenied("actor has no memory scope", nil) + } + agentName := session.Agent.Namespace + "/" + session.Agent.Name + return agentName, session.Creator, s.authorize(ctx, verb, agentName) +} + +func (s *Service) authorize(ctx context.Context, verb auth.Verb, agentName string) error { + session, ok := auth.AuthSessionFrom(ctx) + if !ok { + return serviceerrors.NewUnauthenticated("Authenticated identity is required", nil) + } + if err := s.authorizer.Check(ctx, session.Principal(), verb, auth.Resource{Type: "Memory", Name: agentName}); err != nil { + return serviceerrors.NewPermissionDenied("Not authorized to access memory", err) + } + return nil +} + +func (s *Service) toMemory(input Input, agentName, userID string) (*database.Memory, error) { metadata := input.Metadata if len(metadata) == 0 { metadata = json.RawMessage("{}") @@ -204,8 +260,8 @@ func (s *Service) toMemory(input Input) (*database.Memory, error) { } expiresAt := s.now().Add(time.Duration(ttlDays) * 24 * time.Hour) return &database.Memory{ - AgentName: input.AgentName, - UserID: input.UserID, + AgentName: agentName, + UserID: userID, Content: input.Content, Embedding: pgvector.NewVector(input.Vector), Metadata: string(metadata), @@ -213,13 +269,6 @@ func (s *Service) toMemory(input Input) (*database.Memory, error) { }, nil } -func validateInput(input Input, missingFieldsMessage string) error { - if input.AgentName == "" || input.UserID == "" || len(input.Vector) == 0 { - return serviceerrors.NewInvalidArgument(missingFieldsMessage, nil) - } - return validateVector(input.Vector) -} - func validateVector(vector []float32) error { if len(vector) != VectorDimension { return serviceerrors.NewInvalidArgument( diff --git a/go/core/internal/service/memory/service_test.go b/go/core/internal/service/memory/service_test.go index e48423e01c..ee3ada8ad3 100644 --- a/go/core/internal/service/memory/service_test.go +++ b/go/core/internal/service/memory/service_test.go @@ -3,18 +3,27 @@ package memory import ( "context" "errors" + "net/http" "testing" "time" "github.com/pgvector/pgvector-go" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" "github.com/kagent-dev/kagent/go/core/internal/database" + authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" + "github.com/kagent-dev/kagent/go/core/internal/runtimeauth" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" + "github.com/kagent-dev/kagent/go/core/internal/substrate" + "github.com/kagent-dev/kagent/go/core/pkg/auth" ) type memoryStore struct { + bindingErr error stored []*database.Memory searchResult []database.AgentMemorySearchResult searchLimit int @@ -22,6 +31,8 @@ type memoryStore struct { err error deletedAgent string deletedUser string + readAgent string + readUser string } func (store *memoryStore) StoreAgentMemories(_ context.Context, memories ...*database.Memory) error { @@ -35,12 +46,14 @@ func (store *memoryStore) StoreAgentMemories(_ context.Context, memories ...*dat return nil } -func (store *memoryStore) SearchAgentMemory(_ context.Context, _, _ string, _ pgvector.Vector, limit int) ([]database.AgentMemorySearchResult, error) { +func (store *memoryStore) SearchAgentMemory(_ context.Context, agentName, userID string, _ pgvector.Vector, limit int) ([]database.AgentMemorySearchResult, error) { + store.readAgent, store.readUser = agentName, userID store.searchLimit = limit return store.searchResult, store.err } -func (store *memoryStore) ListAgentMemories(context.Context, string, string) ([]database.Memory, error) { +func (store *memoryStore) ListAgentMemories(_ context.Context, agentName, userID string) ([]database.Memory, error) { + store.readAgent, store.readUser = agentName, userID return store.listResult, store.err } @@ -53,13 +66,11 @@ func (store *memoryStore) DeleteAgentMemory(_ context.Context, agentName, userID func TestAddDefaultsMetadataAndTTL(t *testing.T) { now := time.Date(2026, time.August, 3, 10, 0, 0, 0, time.UTC) store := &memoryStore{} - service := NewService(store, WithClock(func() time.Time { return now })) + service := NewService(store, auth.NoopAuthorizer{}, WithClock(func() time.Time { return now })) - id, err := service.Add(t.Context(), Input{ - AgentName: "agent", - UserID: "user", - Content: "remember this", - Vector: vector(VectorDimension, 0.25), + id, err := service.Add(memoryContext(t), Input{ + Content: "remember this", + Vector: vector(VectorDimension, 0.25), }) require.NoError(t, err) assert.Equal(t, "memory-1", id) @@ -70,20 +81,20 @@ func TestAddDefaultsMetadataAndTTL(t *testing.T) { } func TestAddAndBatchValidation(t *testing.T) { - service := NewService(&memoryStore{}) + service := NewService(&memoryStore{}, auth.NoopAuthorizer{}) - _, err := service.Add(t.Context(), Input{AgentName: "agent", UserID: "user", Vector: vector(16, 1)}) + _, err := service.Add(memoryContext(t), Input{Vector: vector(16, 1)}) assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeInvalidArgument)) assert.EqualError(t, err, "vector must have exactly 768 dimensions, got 16") - _, err = service.Add(t.Context(), Input{AgentName: "agent", UserID: "user", Vector: vector(VectorDimension, 1), Metadata: []byte("{")}) + _, err = service.Add(memoryContext(t), Input{Vector: vector(VectorDimension, 1), Metadata: []byte("{")}) assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeInvalidArgument)) - _, err = service.AddBatch(t.Context(), nil) + _, err = service.AddBatch(memoryContext(t), nil) assert.EqualError(t, err, "Empty batch") inputs := make([]Input, MaxBatchSize+1) - _, err = service.AddBatch(t.Context(), inputs) + _, err = service.AddBatch(memoryContext(t), inputs) assert.EqualError(t, err, "batch size 51 exceeds maximum allowed size of 50") } @@ -94,13 +105,11 @@ func TestSearchDefaultsFiltersAndNormalizesMetadata(t *testing.T) { {Memory: database.Memory{ID: "invalid", Content: "normalize", Metadata: "{"}, Score: 0.8}, {Memory: database.Memory{ID: "low", Content: "drop"}, Score: 0.4}, }} - service := NewService(store) + service := NewService(store, auth.NoopAuthorizer{}) - results, err := service.Search(t.Context(), SearchRequest{ - AgentName: "agent", - UserID: "user", - Vector: vector(VectorDimension, 0.25), - MinScore: 0.5, + results, err := service.Search(memoryContext(t), SearchRequest{ + Vector: vector(VectorDimension, 0.25), + MinScore: 0.5, }) require.NoError(t, err) assert.Equal(t, DefaultSearchLimit, store.searchLimit) @@ -112,17 +121,17 @@ func TestSearchDefaultsFiltersAndNormalizesMetadata(t *testing.T) { func TestListDeleteAndStoreErrors(t *testing.T) { store := &memoryStore{listResult: []database.Memory{{ID: "memory-1"}}} - service := NewService(store) + service := NewService(store, auth.NoopAuthorizer{}) - memories, err := service.List(t.Context(), "agent", "user") + memories, err := service.List(memoryContext(t), "") require.NoError(t, err) assert.Equal(t, "memory-1", memories[0].ID) - require.NoError(t, service.Delete(t.Context(), "agent", "user")) - assert.Equal(t, "agent", store.deletedAgent) + require.NoError(t, service.Delete(memoryContext(t), "")) + assert.Equal(t, "team-a/agent", store.deletedAgent) assert.Equal(t, "user", store.deletedUser) store.err = errors.New("database unavailable") - _, err = service.Search(t.Context(), SearchRequest{AgentName: "agent", UserID: "user", Vector: vector(VectorDimension, 1)}) + _, err = service.Search(memoryContext(t), SearchRequest{Vector: vector(VectorDimension, 1)}) assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeInternal)) assert.ErrorContains(t, err, "database unavailable") } @@ -134,3 +143,124 @@ func vector(length int, value float32) []float32 { } return result } + +func (store *memoryStore) GetSessionForRuntime(_ context.Context, id, _ string) (*apiv1.Session, error) { + if store.bindingErr != nil { + return nil, store.bindingErr + } + return &apiv1.Session{Id: id, Creator: "user", Agent: &apiv1.ResourceReference{Namespace: "team-a", Name: "agent"}, A2AAuthority: substrate.ActorHost("team-a", substrate.ActorName(id), "")}, nil +} + +type testActorVerifier struct{} + +func (testActorVerifier) Verify(context.Context, string) (substrate.ActorIdentity, error) { + return substrate.ActorIdentity{ActorName: "session-00000000-0000-0000-0000-000000000001", Atespace: "team-a", ActorUID: "actor"}, nil +} + +func memoryContext(t *testing.T) context.Context { + t.Helper() + session, err := runtimeauth.NewAuthenticator(testActorVerifier{}).Authenticate(t.Context(), http.Header{"Authorization": {"Bearer test"}, "X-User-Id": {"forged"}, "X-Agent-Name": {"forged"}}, nil) + require.NoError(t, err) + require.Empty(t, session.Principal().User.ID) + return auth.AuthSessionTo(t.Context(), session) +} + +func TestActorMemoryUsesSessionScope(t *testing.T) { + store := &memoryStore{} + service := NewService(store, auth.NoopAuthorizer{}) + ctx := memoryContext(t) + input := Input{Content: "scoped", Vector: vector(VectorDimension, 1)} + _, err := service.Add(ctx, input) + require.NoError(t, err) + _, err = service.AddBatch(ctx, []Input{input}) + require.NoError(t, err) + require.Len(t, store.stored, 2) + for _, memory := range store.stored { + require.Equal(t, "team-a/agent", memory.AgentName) + require.Equal(t, "user", memory.UserID) + } + require.NoError(t, service.Delete(ctx, "other/agent")) + require.Equal(t, "team-a/agent", store.deletedAgent) + require.Equal(t, "user", store.deletedUser) + _, err = service.List(ctx, "other/agent") + require.NoError(t, err) + require.Equal(t, "team-a/agent", store.readAgent) + require.Equal(t, "user", store.readUser) + _, err = service.Search(ctx, SearchRequest{Vector: input.Vector}) + require.NoError(t, err) + require.Equal(t, "team-a/agent", store.readAgent) + require.Equal(t, "user", store.readUser) +} + +func TestMemoryRejectsUnboundActors(t *testing.T) { + for _, test := range []struct { + name string + authenticated bool + bindingErr error + }{ + {name: "missing identity"}, + {name: "deleted or replaced actor", authenticated: true, bindingErr: database.ErrNotFound}, + } { + t.Run(test.name, func(t *testing.T) { + store := &memoryStore{bindingErr: test.bindingErr} + service := NewService(store, auth.NoopAuthorizer{}) + ctx := t.Context() + if test.authenticated { + ctx = memoryContext(t) + } + input := Input{Vector: vector(VectorDimension, 1)} + _, err := service.Add(ctx, input) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + _, err = service.AddBatch(ctx, []Input{input}) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + _, err = service.Search(ctx, SearchRequest{Vector: input.Vector}) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + _, err = service.List(ctx, "other/agent") + require.Equal(t, codes.PermissionDenied, status.Code(err)) + err = service.Delete(ctx, "other/agent") + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Empty(t, store.stored) + require.Empty(t, store.deletedAgent) + }) + } +} + +func TestUserMemoryManagementDoesNotRequireRuntime(t *testing.T) { + store := &memoryStore{bindingErr: database.ErrNotFound} + service := NewService(store, auth.NoopAuthorizer{}) + ctx := auth.AuthSessionTo(t.Context(), &authimpl.SimpleSession{P: auth.Principal{User: auth.User{ID: "owner"}}}) + + for _, agentName := range []string{"team-a/agent", "legacy-display-name"} { + _, err := service.List(ctx, agentName) + require.NoError(t, err) + require.Equal(t, agentName, store.readAgent) + require.Equal(t, "owner", store.readUser) + require.NoError(t, service.Delete(ctx, agentName)) + require.Equal(t, agentName, store.deletedAgent) + require.Equal(t, "owner", store.deletedUser) + } + + _, err := service.List(ctx, "") + require.True(t, serviceerrors.IsCode(err, serviceerrors.CodeInvalidArgument)) + require.True(t, serviceerrors.IsCode(service.Delete(ctx, ""), serviceerrors.CodeInvalidArgument)) +} + +type deniedMemoryAuthorizer struct{ auth.NoopAuthorizer } + +func (deniedMemoryAuthorizer) Check(context.Context, auth.Principal, auth.Verb, auth.Resource) error { + return errors.New("memory access denied") +} + +func TestMemoryManagementHonorsConfiguredAuthorization(t *testing.T) { + store := &memoryStore{} + service := NewService(store, deniedMemoryAuthorizer{}) + user := auth.AuthSessionTo(t.Context(), &authimpl.SimpleSession{P: auth.Principal{User: auth.User{ID: "user"}}}) + for _, ctx := range []context.Context{user, memoryContext(t)} { + _, err := service.List(ctx, "team-a/agent") + require.True(t, serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied)) + err = service.Delete(ctx, "team-a/agent") + require.True(t, serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied)) + } + require.Empty(t, store.deletedAgent) + require.Empty(t, store.deletedUser) +} diff --git a/go/core/internal/service/session/quiescence_wake_test.go b/go/core/internal/service/session/quiescence_wake_test.go index 5312f3dbd8..05c503c0d5 100644 --- a/go/core/internal/service/session/quiescence_wake_test.go +++ b/go/core/internal/service/session/quiescence_wake_test.go @@ -14,10 +14,11 @@ import ( "github.com/stretchr/testify/require" "go.ate.dev/substrate/pkg/proto/ateapipb" - apia2a "github.com/kagent-dev/kagent/go/api/a2a" apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" runtimev1 "github.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1" "github.com/kagent-dev/kagent/go/core/internal/database" + "github.com/kagent-dev/kagent/go/core/internal/jwttest" + "github.com/kagent-dev/kagent/go/core/internal/runtimeauth" "github.com/kagent-dev/kagent/go/core/internal/service/taskstore" "github.com/kagent-dev/kagent/go/core/internal/substrate" "github.com/kagent-dev/kagent/go/core/pkg/auth" @@ -71,8 +72,11 @@ func (a *gatedQuiescenceActors) GetActor(ctx context.Context, atespace, name str func settlementContext(t *testing.T, session *apiv1.Session) context.Context { t.Helper() headers := http.Header{} - headers.Set(apia2a.InsecureRuntimeIdentityHeader, "team-a/"+substrate.ActorName(session.Id)+"/actor-uid") - identity, err := (&taskstore.Authenticator{}).Authenticate(t.Context(), headers, nil) + i := jwttest.New(t) + v, err := substrate.NewActorJWTVerifier(t.Context(), i.URL, i.CAFile) + require.NoError(t, err) + headers.Set("authorization", "Bearer "+i.Token(t, i.Claims("team-a", substrate.ActorName(session.Id), "actor-uid"))) + identity, err := runtimeauth.NewAuthenticator(v).Authenticate(t.Context(), headers, nil) require.NoError(t, err) return auth.AuthSessionTo(t.Context(), identity) } diff --git a/go/core/internal/service/session/service.go b/go/core/internal/service/session/service.go index e927c0d49e..62d92a3cb9 100644 --- a/go/core/internal/service/session/service.go +++ b/go/core/internal/service/session/service.go @@ -409,6 +409,10 @@ func (s *Service) authorize(ctx context.Context, verb auth.Verb, name string) (s return "", serviceerrors.NewUnauthenticated("Failed to get authenticated principal", nil) } if share, ok := auth.ShareContextFrom(ctx); ok { + session, _ := auth.AuthSessionFrom(ctx) + if session.Principal().Actor != nil { + return "", serviceerrors.NewPermissionDenied("Actors cannot use share credentials", nil) + } if verb != auth.VerbGet { return "", serviceerrors.NewPermissionDenied("This share link is read-only", nil) } diff --git a/go/core/internal/service/session/service_test.go b/go/core/internal/service/session/service_test.go index 177aa43e02..3231a4be60 100644 --- a/go/core/internal/service/session/service_test.go +++ b/go/core/internal/service/session/service_test.go @@ -14,6 +14,7 @@ import ( apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" "github.com/kagent-dev/kagent/go/core/internal/database" + authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" "github.com/kagent-dev/kagent/go/core/pkg/auth" ) @@ -588,6 +589,24 @@ func TestServiceShareCannotCreateSession(t *testing.T) { } } +func TestActorCannotUseShareAuthorityWithoutTransport(t *testing.T) { + id := uuid.NewString() + ctx := auth.AuthSessionTo(t.Context(), &authimpl.SimpleSession{P: auth.Principal{ + User: auth.User{ID: "owner"}, Actor: &auth.Actor{SessionID: uuid.NewString()}, + }}) + if _, err := ResolveShare(ctx, nil, "share-token"); !serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied) { + t.Fatalf("ResolveShare() = %v, want permission denied before reading the share", err) + } + ctx = auth.ShareContextTo(ctx, &auth.ShareContext{SessionID: id, UserID: "owner"}) + service := NewService(nil, serviceTestAuthorizer{}, nil) + if _, err := service.Get(ctx, id); !serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied) { + t.Fatalf("Get() = %v, want permission denied before reading another session", err) + } + if _, err := service.List(ctx, ListRequest{}); !serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied) { + t.Fatalf("List() = %v, want permission denied before listing shared sessions", err) + } +} + func TestServiceSharedListCannotBroadenAccess(t *testing.T) { id := uuid.NewString() agent := &apiv1.ResourceReference{Namespace: "team-a", Name: "assistant"} diff --git a/go/core/internal/service/session/share.go b/go/core/internal/service/session/share.go index 6f12bd06ad..fb36552bc3 100644 --- a/go/core/internal/service/session/share.go +++ b/go/core/internal/service/session/share.go @@ -22,6 +22,9 @@ func ResolveShare(ctx context.Context, store ShareStore, token string) (*auth.Sh if token == "" { return nil, nil } + if session, ok := auth.AuthSessionFrom(ctx); ok && session.Principal().Actor != nil { + return nil, serviceerrors.NewPermissionDenied("Actors cannot use share credentials", nil) + } if store == nil { return nil, serviceerrors.NewInternal("share-token validation is unavailable", nil) } diff --git a/go/core/internal/service/session/workflow_test.go b/go/core/internal/service/session/workflow_test.go index 495ff97632..661a41cea5 100644 --- a/go/core/internal/service/session/workflow_test.go +++ b/go/core/internal/service/session/workflow_test.go @@ -404,8 +404,10 @@ func TestActorCreationFailsBeforeIssueWhenEgressPolicyCannotCompile(t *testing.T require.Equal(t, apiv1.RuntimeState_RUNTIME_STATE_DELETED, deleted.State) } +const testControlPlaneURL = "http://kagent-controller.kagent.svc:8083" + func TestActorEgressPolicy(t *testing.T) { - policy, err := substrate.ActorEgressPolicy("team-a", []string{"https://API.Example.com.", "https://api.example.com:443", "https://api.example.com:0443", "http://api.example.com:8080"}, nil) + policy, err := substrate.ActorEgressPolicy("team-a", []string{"https://API.Example.com.", "https://api.example.com:443", "https://api.example.com:0443", "http://api.example.com:8080"}, nil, testControlPlaneURL) require.NoError(t, err) require.Equal(t, &ateapipb.ResourceMetadata{Atespace: "team-a", Name: "default"}, policy.Metadata) require.Len(t, policy.Rules, 2) @@ -413,20 +415,20 @@ func TestActorEgressPolicy(t *testing.T) { require.Equal(t, []int32{8080}, policy.Rules[0].GetHttp().GetPorts().GetNumbers()) require.Equal(t, []string{"api.example.com"}, policy.Rules[1].GetHttps().GetHostnames()) require.Equal(t, []int32{443}, policy.Rules[1].GetHttps().GetPorts().GetNumbers()) - policy, err = substrate.ActorEgressPolicy("team-a", nil, nil) + policy, err = substrate.ActorEgressPolicy("team-a", nil, nil, testControlPlaneURL) require.NoError(t, err) require.Empty(t, policy.Rules, "no destinations must deny all egress") for _, destination := range []string{"", "*", "api.example.com", "https://api.example.com/path", "http://api.example.com:0", "http://api.example.com:65536", "http://user@api.example.com", "http://api.example.com?key=value"} { t.Run(destination, func(t *testing.T) { - _, err := substrate.ActorEgressPolicy("team-a", []string{destination}, nil) + _, err := substrate.ActorEgressPolicy("team-a", []string{destination}, nil, testControlPlaneURL) require.Error(t, err) }) } for _, destination := range []string{"http://192.0.2.1", "http://[2001:db8::1]", "http://[::ffff:192.0.2.1]"} { - _, err := substrate.ActorEgressPolicy("team-a", []string{destination}, nil) + _, err := substrate.ActorEgressPolicy("team-a", []string{destination}, nil, testControlPlaneURL) require.ErrorContains(t, err, "expected a DNS name") } - _, err = substrate.ActorEgressPolicy("team-a", []string{"http://api.example.com:8443", "https://api.example.com:8443"}, nil) + _, err = substrate.ActorEgressPolicy("team-a", []string{"http://api.example.com:8443", "https://api.example.com:8443"}, nil, testControlPlaneURL) require.ErrorContains(t, err, "both HTTP and HTTPS on the same port") } @@ -435,9 +437,9 @@ func TestActorEgressCredentialsRequireAllowedDestination(t *testing.T) { {Origin: "https://api.example.com:8443", Header: "authorization", Prefix: "Bearer ", URI: "ate-secret://k8s.io/default/team/auth/token"}, {Origin: "https://api.example.com:8443", Header: "x-api-key", URI: "ate-secret://k8s.io/default/team/auth/key"}, } - _, err := substrate.ActorEgressPolicy("team", []string{"https://other.example.com"}, bindings) + _, err := substrate.ActorEgressPolicy("team", []string{"https://other.example.com"}, bindings, testControlPlaneURL) require.ErrorContains(t, err, "is not explicitly allowed") - policy, err := substrate.ActorEgressPolicy("team", []string{"http://api.example.com:8080", "https://api.example.com:8443", "https://other.example.com"}, bindings) + policy, err := substrate.ActorEgressPolicy("team", []string{"http://api.example.com:8080", "https://api.example.com:8443", "https://other.example.com"}, bindings, testControlPlaneURL) require.NoError(t, err) require.Len(t, policy.Rules, 3) require.Empty(t, policy.Rules[0].GetHttp().GetEffects().GetReplaceHeaders(), "credentials must not cross schemes or ports") diff --git a/go/core/internal/service/system/service.go b/go/core/internal/service/system/service.go index 38fb882341..602f2e7ce8 100644 --- a/go/core/internal/service/system/service.go +++ b/go/core/internal/service/system/service.go @@ -78,6 +78,9 @@ func (s *Service) GetCurrentUser(ctx context.Context) (map[string]any, error) { } func (s *Service) ListNamespaces(ctx context.Context) ([]Namespace, error) { + if err := s.authorize(ctx, auth.VerbGet, auth.Resource{Type: "SystemNamespaces"}); err != nil { + return nil, err + } if len(s.observedNamespaces) == 0 { namespaceList := &corev1.NamespaceList{} if err := s.kubeClient.List(ctx, namespaceList); err != nil { diff --git a/go/core/internal/service/system/service_test.go b/go/core/internal/service/system/service_test.go index f28c0743c5..3b66833237 100644 --- a/go/core/internal/service/system/service_test.go +++ b/go/core/internal/service/system/service_test.go @@ -142,15 +142,16 @@ func TestCurrentUser(t *testing.T) { func TestListNamespaces(t *testing.T) { scheme := runtime.NewScheme() require.NoError(t, corev1.AddToScheme(scheme)) + ctx := pkgAuth.AuthSessionTo(t.Context(), &authimpl.SimpleSession{P: pkgAuth.Principal{User: pkgAuth.User{ID: "user"}}}) t.Run("lists all and sorts case insensitively", func(t *testing.T) { kubeClient := fake.NewClientBuilder().WithScheme(scheme).WithObjects( &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "Zoo"}, Status: corev1.NamespaceStatus{Phase: corev1.NamespaceActive}}, &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: "alpha"}, Status: corev1.NamespaceStatus{Phase: corev1.NamespaceTerminating}}, ).Build() - service := system.NewService(kubeClient, nil, nil, nil) + service := system.NewService(kubeClient, nil, pkgAuth.NoopAuthorizer{}, nil) - result, err := service.ListNamespaces(t.Context()) + result, err := service.ListNamespaces(ctx) require.NoError(t, err) assert.Equal(t, []system.Namespace{ {Name: "alpha", Status: "Terminating"}, @@ -164,12 +165,19 @@ func TestListNamespaces(t *testing.T) { return apierrors.NewForbidden(schema.GroupResource{Resource: "namespaces"}, "", nil) }, }).Build() - service := system.NewService(kubeClient, []string{"team-b", "team-a"}, nil, nil) + service := system.NewService(kubeClient, []string{"team-b", "team-a"}, pkgAuth.NoopAuthorizer{}, nil) - result, err := service.ListNamespaces(t.Context()) + result, err := service.ListNamespaces(ctx) require.NoError(t, err) assert.Equal(t, []system.Namespace{{Name: "team-a"}, {Name: "team-b"}}, result) }) + t.Run("denied before inventory reads", func(t *testing.T) { + authorizer := &summaryAuthorizer{denyType: "SystemNamespaces"} + service := system.NewService(nil, nil, authorizer, nil) + _, err := service.ListNamespaces(ctx) + require.True(t, serviceerrors.IsCode(err, serviceerrors.CodePermissionDenied)) + require.Equal(t, []string{"get SystemNamespaces"}, authorizer.calls) + }) } // substrateActor builds an ate-api actor placed on a worker pod, or on none when diff --git a/go/core/internal/service/taskstore/authority.go b/go/core/internal/service/taskstore/authority.go deleted file mode 100644 index 0ee97bf712..0000000000 --- a/go/core/internal/service/taskstore/authority.go +++ /dev/null @@ -1,50 +0,0 @@ -package taskstore - -import ( - "context" - "fmt" - "net/http" - "net/url" - "strings" - - "github.com/google/uuid" - - apia2a "github.com/kagent-dev/kagent/go/api/a2a" - "github.com/kagent-dev/kagent/go/core/pkg/auth" -) - -// Authenticator temporarily trusts an unsigned actor identity header. Replace -// this with Substrate-issued actor JWT verification when #1660 is available. -// Session, atespace, and recorded actor UID checks remain in the service. -type Authenticator struct{} - -var _ auth.AuthProvider = (*Authenticator)(nil) - -func (*Authenticator) Authenticate(_ context.Context, headers http.Header, _ url.Values) (auth.Session, error) { - values := headers.Values(apia2a.InsecureRuntimeIdentityHeader) - if len(values) != 1 { - return nil, fmt.Errorf("one runtime identity header is required") - } - parts := strings.Split(values[0], "/") - if len(parts) != 3 { - return nil, fmt.Errorf("runtime identity must be atespace/actor-name/actor-UID") - } - id, ok := strings.CutPrefix(parts[1], "session-") - if !ok || parts[0] == "" || parts[2] == "" { - return nil, fmt.Errorf("incomplete Substrate actor identity") - } - if _, err := uuid.Parse(id); err != nil { - return nil, fmt.Errorf("invalid runtime session identity: %w", err) - } - return runtimeSession{sessionID: id, atespace: parts[0], actorUID: parts[2]}, nil -} - -func (*Authenticator) UpstreamAuth(*http.Request, auth.Session, auth.Principal) error { - return fmt.Errorf("runtime authentication cannot forward public credentials") -} - -type runtimeSession struct{ sessionID, atespace, actorUID string } - -func (s runtimeSession) Principal() auth.Principal { - return auth.Principal{Agent: auth.Agent{ID: s.sessionID}} -} diff --git a/go/core/internal/service/taskstore/authority_test.go b/go/core/internal/service/taskstore/authority_test.go deleted file mode 100644 index 84abd8f750..0000000000 --- a/go/core/internal/service/taskstore/authority_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package taskstore - -import ( - "net/http" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" - - apia2a "github.com/kagent-dev/kagent/go/api/a2a" -) - -func TestInsecureRuntimeIdentity(t *testing.T) { - id := uuid.NewString() - valid := "team-a/session-" + id + "/actor-uid" - for _, test := range []struct { - name string - values []string - valid bool - }{ - {"valid", []string{valid}, true}, - {"missing", nil, false}, - {"duplicate", []string{valid, valid}, false}, - {"invalid session", []string{"team-a/session-invalid/actor-uid"}, false}, - {"missing namespace", []string{"/session-" + id + "/actor-uid"}, false}, - {"missing UID", []string{"team-a/session-" + id + "/"}, false}, - {"extra component", []string{valid + "/extra"}, false}, - } { - t.Run(test.name, func(t *testing.T) { - headers := http.Header{} - for _, value := range test.values { - headers.Add(apia2a.InsecureRuntimeIdentityHeader, value) - } - session, err := (&Authenticator{}).Authenticate(t.Context(), headers, nil) - if !test.valid { - require.Error(t, err) - return - } - require.NoError(t, err) - require.Equal(t, runtimeSession{sessionID: id, atespace: "team-a", actorUID: "actor-uid"}, session) - }) - } -} diff --git a/go/core/internal/service/taskstore/service.go b/go/core/internal/service/taskstore/service.go index ee25fa8a40..852b26d06c 100644 --- a/go/core/internal/service/taskstore/service.go +++ b/go/core/internal/service/taskstore/service.go @@ -19,8 +19,7 @@ import ( apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" runtimev1 "github.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1" "github.com/kagent-dev/kagent/go/core/internal/database" - "github.com/kagent-dev/kagent/go/core/internal/substrate" - "github.com/kagent-dev/kagent/go/core/pkg/auth" + "github.com/kagent-dev/kagent/go/core/internal/runtimeauth" ) type Store interface { @@ -45,16 +44,11 @@ func NewService(store Store, quiescenceWake, pushWake chan<- struct{}) *Service } func (s *Service) session(ctx context.Context, sessionID string) (*apiv1.Session, error) { - authSession, _ := auth.AuthSessionFrom(ctx) - identity, ok := authSession.(runtimeSession) - if !ok || identity.sessionID != sessionID { - return nil, status.Error(codes.PermissionDenied, "actor does not belong to this session") - } - session, err := s.store.GetSessionForRuntime(ctx, sessionID, identity.actorUID) + session, err := runtimeauth.Session(ctx, s.store) if err != nil { return nil, storageError(err) } - if session.A2AAuthority != substrate.ActorHost(identity.atespace, substrate.ActorName(sessionID), "") { + if session.Id != sessionID { return nil, status.Error(codes.PermissionDenied, "actor does not belong to this session") } return session, nil diff --git a/go/core/internal/service/taskstore/service_test.go b/go/core/internal/service/taskstore/service_test.go index a707f8c46a..edcbc90282 100644 --- a/go/core/internal/service/taskstore/service_test.go +++ b/go/core/internal/service/taskstore/service_test.go @@ -2,6 +2,7 @@ package taskstore import ( "context" + "net/http" "testing" "testing/synctest" @@ -12,6 +13,7 @@ import ( apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" runtimev1 "github.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1" "github.com/kagent-dev/kagent/go/core/internal/database" + "github.com/kagent-dev/kagent/go/core/internal/runtimeauth" "github.com/kagent-dev/kagent/go/core/internal/substrate" "github.com/kagent-dev/kagent/go/core/pkg/auth" ) @@ -61,11 +63,11 @@ func TestSettleTaskWakesOnlyAfterCommit(t *testing.T) { } before := len(wakes) service := NewService(store, wakes, pushWakes) - ctx, cancel := context.WithCancel(auth.AuthSessionTo(t.Context(), runtimeSession{sessionID: "session", atespace: "team-a", actorUID: "actor"})) + ctx, cancel := context.WithCancel(runtimeContext(t)) cancel() // A disconnected caller must not suppress the post-commit hint. done := make(chan error, 1) go func() { - _, err := service.SettleTask(ctx, &runtimev1.TaskStoreServiceSettleTaskRequest{SessionId: "session", TaskId: "task", Version: 1}) + _, err := service.SettleTask(ctx, &runtimev1.TaskStoreServiceSettleTaskRequest{SessionId: "00000000-0000-0000-0000-000000000001", TaskId: "task", Version: 1}) done <- err }() synctest.Wait() @@ -87,3 +89,16 @@ func TestSettleTaskWakesOnlyAfterCommit(t *testing.T) { }) } } + +type testActorVerifier struct{} + +func (testActorVerifier) Verify(context.Context, string) (substrate.ActorIdentity, error) { + return substrate.ActorIdentity{ActorName: "session-00000000-0000-0000-0000-000000000001", Atespace: "team-a", ActorUID: "actor"}, nil +} + +func runtimeContext(t *testing.T) context.Context { + t.Helper() + session, err := runtimeauth.NewAuthenticator(testActorVerifier{}).Authenticate(t.Context(), http.Header{"Authorization": {"Bearer test"}}, nil) + require.NoError(t, err) + return auth.AuthSessionTo(t.Context(), session) +} diff --git a/go/core/internal/substrate/actor_jwt.go b/go/core/internal/substrate/actor_jwt.go new file mode 100644 index 0000000000..de36529027 --- /dev/null +++ b/go/core/internal/substrate/actor_jwt.go @@ -0,0 +1,151 @@ +package substrate + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/lestrrat-go/jwx/jwk" + + "github.com/kagent-dev/kagent/go/pkg/logging" +) + +const ( + runtimeJWTAudience = "kagent-runtime" + runtimeJWTClockSkew = 30 * time.Second +) + +// ActorIdentity is the identity asserted by Substrate's actor.ate.dev claim. +// Worker-for-actor tokens deliberately use a different claim and subject. +type ActorIdentity struct { + Atespace string `json:"atespace"` + ActorName string `json:"actorName"` + ActorUID string `json:"actorUID"` +} + +type actorClaims struct { + jwt.RegisteredClaims + Actor ActorIdentity `json:"actor.ate.dev"` +} + +// ActorJWTVerifier trusts one configured issuer and refreshes its signing keys. +type ActorJWTVerifier struct { + issuer string + jwksURL string + keys *jwk.AutoRefresh +} + +// NewActorJWTVerifier loads discovery over verified TLS. The context owns key +// refreshes; callers must cancel it when the verifier is no longer used. +func NewActorJWTVerifier(ctx context.Context, issuer, caFile string) (*ActorJWTVerifier, error) { + if err := validateIssuerURL(issuer); err != nil { + return nil, err + } + tlsConfig, err := ateAPITLSConfig(Config{CAFile: caFile}) + if err != nil { + return nil, err + } + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = tlsConfig + client := &http.Client{ + Transport: transport, Timeout: 10 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + defer transport.CloseIdleConnections() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(issuer, "/")+"/.well-known/openid-configuration", nil) + if err != nil { + return nil, fmt.Errorf("create Actor JWT discovery request: %w", err) + } + response, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("fetch Actor JWT discovery: %w", err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("actor JWT discovery returned HTTP %d", response.StatusCode) + } + var discovery struct { + Issuer string `json:"issuer"` + JWKSURI string `json:"jwks_uri"` + } + if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&discovery); err != nil { + return nil, fmt.Errorf("decode Actor JWT discovery: %w", err) + } + if discovery.Issuer != issuer { + return nil, fmt.Errorf("actor JWT discovery issuer does not match configured issuer") + } + if err := validateIssuerURL(discovery.JWKSURI); err != nil { + return nil, fmt.Errorf("invalid Actor JWT signing key URL: %w", err) + } + keys := jwk.NewAutoRefresh(ctx) + // Refresh on a fixed interval instead of allowing untrusted token kids to + // trigger network requests. Publish new keys at least a minute before use. + keys.Configure(discovery.JWKSURI, jwk.WithHTTPClient(client), jwk.WithRefreshInterval(time.Minute)) + if _, err := keys.Refresh(ctx, discovery.JWKSURI); err != nil { + return nil, fmt.Errorf("load Actor JWT signing keys: %w", err) + } + errors := make(chan jwk.AutoRefreshError, 1) + keys.ErrorSink(errors) + go func() { + defer transport.CloseIdleConnections() + for { + select { + case <-ctx.Done(): + return + case err := <-errors: + logging.FromContext(ctx).ErrorContext(ctx, "failed to refresh Actor JWT signing keys", "error", err.Error) + } + } + }() + return &ActorJWTVerifier{issuer: issuer, jwksURL: discovery.JWKSURI, keys: keys}, nil +} + +func validateIssuerURL(raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.ForceQuery || strings.Contains(raw, "#") { + return fmt.Errorf("expected an HTTPS issuer URL without credentials, query, or fragment") + } + return nil +} + +// Verify authenticates a runtime token. Session authorization remains with the +// control-plane service and checks this identity against the recorded Actor UID. +func (v *ActorJWTVerifier) Verify(ctx context.Context, raw string) (ActorIdentity, error) { + claims := &actorClaims{} + _, err := jwt.ParseWithClaims(raw, claims, func(token *jwt.Token) (any, error) { + kid, ok := token.Header["kid"].(string) + if !ok || kid == "" { + return nil, fmt.Errorf("actor JWT requires a signing key ID") + } + keys, err := v.keys.Fetch(ctx, v.jwksURL) + if err != nil { + return nil, fmt.Errorf("get Actor JWT signing keys: %w", err) + } + key, ok := keys.LookupKeyID(kid) + if !ok || key.Algorithm() != token.Method.Alg() || key.KeyUsage() != "sig" { + return nil, fmt.Errorf("actor JWT signing key is not trusted") + } + var publicKey any + if err := key.Raw(&publicKey); err != nil { + return nil, fmt.Errorf("decode Actor JWT signing key: %w", err) + } + return publicKey, nil + }, jwt.WithValidMethods([]string{"ES256", "RS256", "RS384", "RS512"}), + jwt.WithIssuer(v.issuer), jwt.WithAudience(runtimeJWTAudience), jwt.WithExpirationRequired(), jwt.WithIssuedAt(), + jwt.WithLeeway(runtimeJWTClockSkew)) + if err != nil { + return ActorIdentity{}, fmt.Errorf("verify Actor JWT: %w", err) + } + a := claims.Actor + if a.Atespace == "" || a.ActorName == "" || a.ActorUID == "" || strings.ContainsAny(a.Atespace+a.ActorName, "/") || + claims.Subject != "actor/"+a.Atespace+"/"+a.ActorName || claims.NotBefore == nil || claims.IssuedAt == nil { + return ActorIdentity{}, fmt.Errorf("actor JWT has incomplete or inconsistent identity claims") + } + return a, nil +} diff --git a/go/core/internal/substrate/actor_jwt_test.go b/go/core/internal/substrate/actor_jwt_test.go new file mode 100644 index 0000000000..ed76b0dfc3 --- /dev/null +++ b/go/core/internal/substrate/actor_jwt_test.go @@ -0,0 +1,124 @@ +package substrate + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" + + "github.com/kagent-dev/kagent/go/core/internal/jwttest" +) + +func TestActorJWTVerifier(t *testing.T) { + i := jwttest.New(t) + v, err := NewActorJWTVerifier(t.Context(), i.URL, i.CAFile) + require.NoError(t, err) + for _, test := range []struct { + name string + change func(jwt.MapClaims) + }{ + {"wrong issuer", func(c jwt.MapClaims) { c["iss"] = "https://untrusted.example" }}, + {"wrong audience", func(c jwt.MapClaims) { c["aud"] = []string{"another-service"} }}, + {"missing audience", func(c jwt.MapClaims) { delete(c, "aud") }}, + {"expired", func(c jwt.MapClaims) { c["exp"] = time.Now().Add(-time.Minute).Unix() }}, + {"missing expiration", func(c jwt.MapClaims) { delete(c, "exp") }}, + {"not yet valid", func(c jwt.MapClaims) { c["nbf"] = time.Now().Add(time.Minute).Unix() }}, + {"future issuance", func(c jwt.MapClaims) { c["iat"] = time.Now().Add(time.Minute).Unix() }}, + {"missing issuance", func(c jwt.MapClaims) { delete(c, "iat") }}, + {"missing not-before", func(c jwt.MapClaims) { delete(c, "nbf") }}, + {"wrong subject", func(c jwt.MapClaims) { c["sub"] = "actor/team-a/another-actor" }}, + {"missing actor", func(c jwt.MapClaims) { delete(c, "actor.ate.dev") }}, + {"missing UID", func(c jwt.MapClaims) { delete(c["actor.ate.dev"].(map[string]string), "actorUID") }}, + {"worker token", func(c jwt.MapClaims) { + c["sub"] = "worker-for-actor/team-a/session-example" + c["worker-for-actor.ate.dev"] = c["actor.ate.dev"] + delete(c, "actor.ate.dev") + }}, + } { + t.Run(test.name, func(t *testing.T) { + claims := i.Claims("team-a", "session-example", "actor-uid") + test.change(claims) + _, err := v.Verify(t.Context(), i.Token(t, claims)) + require.Error(t, err) + }) + } + claims := i.Claims("team-a", "session-example", "actor-uid") + raw := i.Token(t, claims) + identity, err := v.Verify(t.Context(), raw) + require.NoError(t, err) + require.Equal(t, ActorIdentity{Atespace: "team-a", ActorName: "session-example", ActorUID: "actor-uid"}, identity) + other := jwttest.New(t) + _, err = v.Verify(t.Context(), other.Token(t, claims)) + require.Error(t, err, "a matching kid from another signer must not authenticate") + for _, method := range []jwt.SigningMethod{jwt.SigningMethodNone, jwt.SigningMethodHS256} { + token := jwt.NewWithClaims(method, claims) + token.Header["kid"] = "test-key" + var key any = []byte("secret") + if method == jwt.SigningMethodNone { + key = jwt.UnsafeAllowNoneSignatureType + } + forged, err := token.SignedString(key) + require.NoError(t, err) + _, err = v.Verify(t.Context(), forged) + require.Error(t, err) + } + // A normal JWKS refresh replaces trust, including revocation of old keys. + rotated, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + i.SetKey(t, &rotated.PublicKey, "test-key") + _, err = v.keys.Refresh(t.Context(), v.jwksURL) + require.NoError(t, err) + _, err = v.Verify(t.Context(), raw) + require.Error(t, err) + i.Key = rotated + _, err = v.Verify(t.Context(), i.Token(t, claims)) + require.NoError(t, err) +} + +func TestActorJWTVerifierRequiresTrustedDiscovery(t *testing.T) { + i := jwttest.New(t) + _, err := NewActorJWTVerifier(t.Context(), i.URL, "") + require.Error(t, err, "untrusted TLS certificate") + for _, issuer := range []string{"", "http://issuer.test", "https://user:password@issuer.test", "https://issuer.test#fragment", "https://issuer.test?query"} { + _, err := NewActorJWTVerifier(t.Context(), issuer, i.CAFile) + require.Error(t, err, issuer) + } +} + +func TestActorJWTVerifierClockSkew(t *testing.T) { + i := jwttest.New(t) + v, err := NewActorJWTVerifier(t.Context(), i.URL, i.CAFile) + require.NoError(t, err) + for _, test := range []struct { + name string + claim string + offset time.Duration + wantErr error + }{ + {name: "recent expiration", claim: "exp", offset: -15 * time.Second}, + {name: "expired beyond tolerance", claim: "exp", offset: -45 * time.Second, wantErr: jwt.ErrTokenExpired}, + {name: "issuance slightly ahead", claim: "iat", offset: 15 * time.Second}, + {name: "issuance beyond tolerance", claim: "iat", offset: 45 * time.Second, wantErr: jwt.ErrTokenUsedBeforeIssued}, + {name: "not-before slightly ahead", claim: "nbf", offset: 15 * time.Second}, + {name: "not-before beyond tolerance", claim: "nbf", offset: 45 * time.Second, wantErr: jwt.ErrTokenNotValidYet}, + } { + t.Run(test.name, func(t *testing.T) { + claims := i.Claims("team-a", "session-example", "actor-uid") + now := time.Now() + claims["iat"] = now.Add(-5 * time.Minute).Unix() + claims[test.claim] = now.Add(test.offset).Unix() + identity, err := v.Verify(t.Context(), i.Token(t, claims)) + if test.wantErr != nil { + require.ErrorIs(t, err, test.wantErr) + require.Empty(t, identity) + return + } + require.NoError(t, err) + require.Equal(t, ActorIdentity{Atespace: "team-a", ActorName: "session-example", ActorUID: "actor-uid"}, identity) + }) + } +} diff --git a/go/core/internal/substrate/actor_template.go b/go/core/internal/substrate/actor_template.go index 3e229d2d87..c6aca14570 100644 --- a/go/core/internal/substrate/actor_template.go +++ b/go/core/internal/substrate/actor_template.go @@ -122,8 +122,6 @@ func ActorTemplateForRevision(spec *translator.Revision, revisionID translator.R {Name: actorIdentityVolume, SystemInfo: &ateapipb.SystemInfoVolumeSource{DataSources: []*ateapipb.SystemInfoDataSource{ {ActorMetadata: &ateapipb.ActorMetadataDataSource{Items: []*ateapipb.ActorMetadataItem{ {Field: ateapipb.ActorMetadataField_ACTOR_METADATA_FIELD_NAME, Path: "name"}, - {Field: ateapipb.ActorMetadataField_ACTOR_METADATA_FIELD_ATESPACE, Path: "atespace"}, - {Field: ateapipb.ActorMetadataField_ACTOR_METADATA_FIELD_UID, Path: "uid"}, }}}, }}}, {Name: egressTrustVolume, SystemInfo: &ateapipb.SystemInfoVolumeSource{DataSources: []*ateapipb.SystemInfoDataSource{ diff --git a/go/core/internal/substrate/actor_template_test.go b/go/core/internal/substrate/actor_template_test.go index 99539b5e7e..d7cfea67a5 100644 --- a/go/core/internal/substrate/actor_template_test.go +++ b/go/core/internal/substrate/actor_template_test.go @@ -99,7 +99,7 @@ func TestActorTemplateForRevision(t *testing.T) { t.Fatal("gateway trust bundle was not projected") } identity := template.Volumes[1].GetSystemInfo().GetDataSources()[0].GetActorMetadata().GetItems() - if len(identity) != 3 || identity[0].GetField() != ateapipb.ActorMetadataField_ACTOR_METADATA_FIELD_NAME || identity[0].GetPath() != "name" || container.VolumeMounts[2].GetMountPath() != actorIdentityMount { + if len(identity) != 1 || identity[0].GetField() != ateapipb.ActorMetadataField_ACTOR_METADATA_FIELD_NAME || identity[0].GetPath() != "name" || container.VolumeMounts[2].GetMountPath() != actorIdentityMount { t.Fatal("actor routing identity was not projected") } var rendered a2atype.AgentCard diff --git a/go/core/internal/substrate/config.go b/go/core/internal/substrate/config.go index 40cf9d4c7a..96847756ab 100644 --- a/go/core/internal/substrate/config.go +++ b/go/core/internal/substrate/config.go @@ -12,6 +12,8 @@ type Config struct { EgressPolicies egress.PolicyStore // EgressPolicyCompilers extend each actor's EgressPolicy in order. EgressPolicyCompilers []egresspolicy.Compiler + // ControlPlaneURL is the runtime endpoint whose egress rule injects Actor JWTs. + ControlPlaneURL string // AteAPIEndpoint is a gRPC target (e.g. dns:///api.ate-system.svc:443). AteAPIEndpoint string // CAFile contains the CA certificates used to verify ate-api. diff --git a/go/core/internal/substrate/egress.go b/go/core/internal/substrate/egress.go index f488ff1f3a..e7d2c2c6d5 100644 --- a/go/core/internal/substrate/egress.go +++ b/go/core/internal/substrate/egress.go @@ -21,7 +21,7 @@ func (c *Client) CheckActorEgressPolicy(ctx context.Context, atespace, revision if err != nil { return fmt.Errorf("load intended actor egress: %w", err) } - _, err = compileActorEgressPolicy(ctx, atespace, access, c.cfg.EgressPolicyCompilers) + _, err = compileActorEgressPolicy(ctx, atespace, access, c.cfg.ControlPlaneURL, c.cfg.EgressPolicyCompilers) return err } @@ -46,10 +46,10 @@ func (c *Client) EnsureActorEgressPolicy(ctx context.Context, atespace, name, re if err != nil { return fmt.Errorf("load intended actor egress: %w", err) } - policy, chainErr := compileActorEgressPolicy(ctx, atespace, access, c.cfg.EgressPolicyCompilers) + policy, chainErr := compileActorEgressPolicy(ctx, atespace, access, c.cfg.ControlPlaneURL, c.cfg.EgressPolicyCompilers) if chainErr != nil { // No rules denies all; extensions may have removed built-in access. - policy, _ = ActorEgressPolicy(atespace, nil, nil) + policy = &ateapipb.EgressPolicy{Metadata: &ateapipb.ResourceMetadata{Atespace: atespace, Name: "default"}} } if existing == nil { _, err = c.CreateActorEgressPolicy(ctx, &ateapipb.CreateActorEgressPolicyRequest{Actor: actor, EgressPolicy: policy}) diff --git a/go/core/internal/substrate/egress_test.go b/go/core/internal/substrate/egress_test.go index 7a31b724c4..e58fd0135b 100644 --- a/go/core/internal/substrate/egress_test.go +++ b/go/core/internal/substrate/egress_test.go @@ -28,7 +28,7 @@ var _ egress.PolicyStore = egressPolicyStoreFunc(nil) func TestEnsureActorEgressPolicyUpdatesCurrentGrants(t *testing.T) { fake := &egressPolicyFake{createErr: context.DeadlineExceeded} access := egress.Access{Destinations: []string{"https://api.example.com:443"}} - client := &Client{ControlClient: fake, cfg: Config{EgressPolicies: egressPolicyStoreFunc(func(_ context.Context, revision string) (egress.Access, error) { + client := &Client{ControlClient: fake, cfg: Config{ControlPlaneURL: testControlPlaneURL, EgressPolicies: egressPolicyStoreFunc(func(_ context.Context, revision string) (egress.Access, error) { require.Equal(t, "pinned-revision", revision) return access, nil })}} @@ -61,7 +61,7 @@ func TestEnsureActorEgressPolicyUpdatesCurrentGrants(t *testing.T) { func TestEnsureActorEgressPolicyReloadsAfterConflict(t *testing.T) { fake := &egressPolicyFake{} access := egress.Access{} - client := &Client{ControlClient: fake, cfg: Config{EgressPolicies: egressPolicyStoreFunc(func(context.Context, string) (egress.Access, error) { return access, nil })}} + client := &Client{ControlClient: fake, cfg: Config{ControlPlaneURL: testControlPlaneURL, EgressPolicies: egressPolicyStoreFunc(func(context.Context, string) (egress.Access, error) { return access, nil })}} reconcile := func() error { return client.EnsureActorEgressPolicy(t.Context(), "team-a", "actor", "revision") } @@ -94,7 +94,7 @@ func TestEnsureActorEgressPolicyDeniesWhenExtensionFails(t *testing.T) { policy.Rules[0].GetHttps().Effects = &ateapipb.HttpRuleEffects{ReplaceHeaders: []*ateapipb.CredentialHeader{{Header: "authorization", Prefix: "Bearer ", CredentialUri: "ate-secret://k8s.io/default/team-a/token/value"}}} return failure }) - client := &Client{ControlClient: fake, cfg: Config{EgressPolicies: store, EgressPolicyCompilers: []egresspolicy.Compiler{extension}}} + client := &Client{ControlClient: fake, cfg: Config{ControlPlaneURL: testControlPlaneURL, EgressPolicies: store, EgressPolicyCompilers: []egresspolicy.Compiler{extension}}} reconcile := func() error { return client.EnsureActorEgressPolicy(t.Context(), "team-a", "actor", "revision") } require.NoError(t, reconcile()) @@ -161,7 +161,7 @@ func (f *egressPolicyFake) UpdateActorEgressPolicy(_ context.Context, req *ateap func TestEnsureActorEgressPolicyCatchesUpdateDuringCreation(t *testing.T) { fake := &egressPolicyFake{} access := egress.Access{Destinations: []string{"https://revoked.example.com:443"}} - client := &Client{ControlClient: fake, cfg: Config{EgressPolicies: egressPolicyStoreFunc(func(context.Context, string) (egress.Access, error) { return access, nil })}} + client := &Client{ControlClient: fake, cfg: Config{ControlPlaneURL: testControlPlaneURL, EgressPolicies: egressPolicyStoreFunc(func(context.Context, string) (egress.Access, error) { return access, nil })}} fake.afterCreate = func() { // The controller published revocation, but its actor discovery ran before // creation. There is no concurrent Substrate write to cause a CAS conflict. @@ -177,7 +177,7 @@ func TestEnsureActorEgressPolicyCatchesUpdateDuringCreation(t *testing.T) { func TestEnsureActorEgressPolicyRequiresPersistedPolicy(t *testing.T) { fake := &egressPolicyFake{} unavailable := errors.New("intended policy unavailable") - client := &Client{ControlClient: fake, cfg: Config{EgressPolicies: egressPolicyStoreFunc(func(context.Context, string) (egress.Access, error) { return egress.Access{}, unavailable })}} + client := &Client{ControlClient: fake, cfg: Config{ControlPlaneURL: testControlPlaneURL, EgressPolicies: egressPolicyStoreFunc(func(context.Context, string) (egress.Access, error) { return egress.Access{}, unavailable })}} err := client.EnsureActorEgressPolicy(t.Context(), "team", "actor", "revision") require.ErrorIs(t, err, unavailable) require.Zero(t, fake.created) diff --git a/go/core/internal/substrate/policy.go b/go/core/internal/substrate/policy.go index 396d85f526..994e81bcc4 100644 --- a/go/core/internal/substrate/policy.go +++ b/go/core/internal/substrate/policy.go @@ -17,13 +17,21 @@ import ( // ActorEgressPolicy compiles HTTP(S) origins into an actor's default allowlist. // Credential bindings are already canonicalized by the store. -func ActorEgressPolicy(atespace string, destinations []string, credentials []egress.Credential) (*ateapipb.EgressPolicy, error) { +func ActorEgressPolicy(atespace string, destinations []string, credentials []egress.Credential, controlPlaneURL string) (*ateapipb.EgressPolicy, error) { access, err := egress.CanonicalAccess(destinations, credentials, nil) if err != nil { return nil, err } + u, err := egress.ParseOrigin(controlPlaneURL) + if err != nil { + return nil, fmt.Errorf("invalid runtime control-plane origin: %w", err) + } + runtimeOrigin := u.String() effects := map[string]*ateapipb.HttpRuleEffects{} for _, binding := range access.Credentials { + if binding.Origin == runtimeOrigin && binding.Header == "authorization" { + return nil, fmt.Errorf("runtime authorization header is reserved for Actor JWTs") + } if effects[binding.Origin] == nil { effects[binding.Origin] = &ateapipb.HttpRuleEffects{} } @@ -31,6 +39,15 @@ func ActorEgressPolicy(atespace string, destinations []string, credentials []egr } policy := &ateapipb.EgressPolicy{Metadata: &ateapipb.ResourceMetadata{Atespace: atespace, Name: "default"}} for _, origin := range access.Destinations { + if origin == runtimeOrigin { + if effects[origin] == nil { + effects[origin] = &ateapipb.HttpRuleEffects{} + } + effects[origin].ReplaceHeaders = append(effects[origin].ReplaceHeaders, &ateapipb.CredentialHeader{ + Header: "authorization", Prefix: "Bearer ", + ActorJwt: &ateapipb.ActorJwtSource{Audiences: []string{runtimeJWTAudience}, ExpirationSeconds: 300}, + }) + } target, _ := egress.ParseOrigin(origin) // validated by CanonicalAccess port, _ := strconv.ParseInt(target.Port(), 10, 32) ports := &ateapipb.Ports{Numbers: []int32{int32(port)}} @@ -45,11 +62,14 @@ func ActorEgressPolicy(atespace string, destinations []string, credentials []egr return policy, nil } -// defaultEgressPolicyCompiler adds the allowlist and Secret credentials. It runs first. -type defaultEgressPolicyCompiler struct{ access egress.Access } +// defaultEgressPolicyCompiler adds the allowlist, Secret credentials, and Actor JWTs. It runs first. +type defaultEgressPolicyCompiler struct { + access egress.Access + controlPlaneURL string +} func (c defaultEgressPolicyCompiler) Compile(_ context.Context, input egresspolicy.Input, policy *ateapipb.EgressPolicy) error { - base, err := ActorEgressPolicy(input.Atespace, c.access.Destinations, c.access.Credentials) + base, err := ActorEgressPolicy(input.Atespace, c.access.Destinations, c.access.Credentials, c.controlPlaneURL) if err != nil { return err } @@ -60,9 +80,9 @@ func (c defaultEgressPolicyCompiler) Compile(_ context.Context, input egresspoli // compileActorEgressPolicy runs the built-in compiler, then each extension. // Extensions may change anything; the result must still validate, and with // any extension every tokenPassthrough target must end bound. -func compileActorEgressPolicy(ctx context.Context, atespace string, access egress.Access, compilers []egresspolicy.Compiler) (*ateapipb.EgressPolicy, error) { +func compileActorEgressPolicy(ctx context.Context, atespace string, access egress.Access, controlPlaneURL string, compilers []egresspolicy.Compiler) (*ateapipb.EgressPolicy, error) { policy := &ateapipb.EgressPolicy{Metadata: &ateapipb.ResourceMetadata{Atespace: atespace, Name: "default"}} - chain := append([]egresspolicy.Compiler{defaultEgressPolicyCompiler{access: access}}, compilers...) + chain := append([]egresspolicy.Compiler{defaultEgressPolicyCompiler{access: access, controlPlaneURL: controlPlaneURL}}, compilers...) for _, compiler := range chain { input := egresspolicy.Input{Atespace: atespace, GatewayTargets: slices.Clone(access.TokenPassthroughTargets)} if err := compiler.Compile(ctx, input, policy); err != nil { diff --git a/go/core/internal/substrate/policy_test.go b/go/core/internal/substrate/policy_test.go index 94ad9e1135..a27d977f4f 100644 --- a/go/core/internal/substrate/policy_test.go +++ b/go/core/internal/substrate/policy_test.go @@ -12,11 +12,29 @@ import ( "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) +const testControlPlaneURL = "http://kagent-controller.kagent.svc:8083" + +func TestActorPolicyRequiresControlPlaneOrigin(t *testing.T) { + for _, test := range []struct { + name string + url string + }{ + {"missing", ""}, + {"invalid", "kagent-controller.kagent.svc:8083"}, + } { + t.Run(test.name, func(t *testing.T) { + policy, err := ActorEgressPolicy("team", nil, nil, test.url) + require.ErrorContains(t, err, "invalid runtime control-plane origin") + require.Nil(t, policy) + }) + } +} + func TestActorPolicyKeepsCredentialsOnExactOrigin(t *testing.T) { policy, err := ActorEgressPolicy("team", []string{"https://*.example.com", "https://api.example.com:443", "https://api.example.com:8443", "http://api.example.com"}, []egress.Credential{ {Origin: "https://api.example.com", Header: "authorization", Prefix: "Bearer ", URI: "ate-secret://k8s.io/default/team/auth/token"}, {Origin: "https://api.example.com:8443", Header: "x-api-key", URI: "ate-secret://k8s.io/default/team/other/key"}, - }) + }, testControlPlaneURL) require.NoError(t, err) require.Len(t, policy.Rules, 4) require.Empty(t, policy.Rules[0].GetHttp().GetEffects().GetReplaceHeaders()) @@ -91,7 +109,7 @@ func TestCompileActorEgressPolicyExtensions(t *testing.T) { })}}, } { t.Run(test.name, func(t *testing.T) { - policy, err := compileActorEgressPolicy(t.Context(), "team", access, test.compilers) + policy, err := compileActorEgressPolicy(t.Context(), "team", access, testControlPlaneURL, test.compilers) if test.wantErr != "" { require.ErrorContains(t, err, test.wantErr) require.Nil(t, policy) @@ -105,7 +123,28 @@ func TestCompileActorEgressPolicyExtensions(t *testing.T) { func TestCompileActorEgressPolicyWithoutExtensionsLeavesTargetsUnbound(t *testing.T) { target := egresspolicy.GatewayTarget{Origin: "https://api.example.com:443", Header: "authorization", Prefix: "Bearer "} - policy, err := compileActorEgressPolicy(t.Context(), "team", egress.Access{Destinations: []string{target.Origin}, TokenPassthroughTargets: []egresspolicy.GatewayTarget{target}}, nil) + policy, err := compileActorEgressPolicy(t.Context(), "team", egress.Access{Destinations: []string{target.Origin}, TokenPassthroughTargets: []egresspolicy.GatewayTarget{target}}, testControlPlaneURL, nil) require.NoError(t, err, "runtimes forward the caller token without an extension") require.Empty(t, policy.GetRules()[0].GetHttps().GetEffects().GetReplaceHeaders()) } + +func TestActorPolicyInjectsRuntimeJWTOnlyAtControlPlane(t *testing.T) { + const controlPlane = testControlPlaneURL + policy, err := ActorEgressPolicy("team", []string{controlPlane, "https://models.example"}, nil, "http://KAGENT-CONTROLLER.kagent.svc.:8083") + require.NoError(t, err) + headers := policy.Rules[0].GetHttp().GetEffects().GetReplaceHeaders() + require.Len(t, headers, 1) + require.Equal(t, "authorization", headers[0].Header) + require.Equal(t, "Bearer ", headers[0].Prefix) + require.Empty(t, headers[0].CredentialUri) + require.Equal(t, []string{runtimeJWTAudience}, headers[0].ActorJwt.Audiences) + require.EqualValues(t, 300, headers[0].ActorJwt.ExpirationSeconds) + require.Empty(t, policy.Rules[1].GetHttps().GetEffects().GetReplaceHeaders()) + policy, err = ActorEgressPolicy("team", nil, nil, controlPlane) + require.NoError(t, err) + require.Empty(t, policy.Rules, "runtime authentication must not grant sandbox network access") + _, err = ActorEgressPolicy("team", []string{controlPlane}, []egress.Credential{ + {Origin: controlPlane, Header: "Authorization", URI: "ate-secret://k8s.io/default/team/auth/token"}, + }, controlPlane) + require.Error(t, err, "a credential binding must not override runtime identity") +} diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index 6e78d5658b..37aedd4c07 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -53,6 +53,7 @@ import ( "github.com/kagent-dev/kagent/go/core/internal/grpcserver" authimpl "github.com/kagent-dev/kagent/go/core/internal/httpserver/auth" "github.com/kagent-dev/kagent/go/core/internal/mcp" + "github.com/kagent-dev/kagent/go/core/internal/runtimeauth" "github.com/kagent-dev/kagent/go/core/internal/service/checkpoint" "github.com/kagent-dev/kagent/go/core/internal/service/kubecrud" memoryservice "github.com/kagent-dev/kagent/go/core/internal/service/memory" @@ -153,6 +154,8 @@ func SetupLogger(level string) error { // fails. It returns the first error rather than exiting, so a library consumer // keeps control of how the process ends. func Run(ctx context.Context, input *configv1.ControllerConfig, opts Options) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() if input == nil { return fmt.Errorf("controller configuration is required") } @@ -283,6 +286,7 @@ func Run(ctx context.Context, input *configv1.ControllerConfig, opts Options) er actors, err := substrate.Dial(ctx, substrate.Config{ EgressPolicies: store, EgressPolicyCompilers: opts.EgressPolicyCompilers, + ControlPlaneURL: cfg.GetRuntimeDefaults().GetControlPlaneUrl(), AteAPIEndpoint: cfg.GetSubstrate().GetApiEndpoint(), CAFile: cfg.GetSubstrate().GetCaFile(), ClientCertFile: cfg.GetSubstrate().GetClientCertFile(), @@ -292,6 +296,10 @@ func Run(ctx context.Context, input *configv1.ControllerConfig, opts Options) er return err } defer actors.Close() + actorVerifier, err := substrate.NewActorJWTVerifier(ctx, cfg.GetSubstrate().GetActorJwtIssuer(), cfg.GetSubstrate().GetCaFile()) + if err != nil { + return fmt.Errorf("configure runtime authentication: %w", err) + } reconciler, err := controller.NewReconciler(kubeConfig, runtime.Collections, store, actors) if err != nil { return err @@ -318,12 +326,14 @@ func Run(ctx context.Context, input *configv1.ControllerConfig, opts Options) er } authenticator, authorizer := opts.resolve() + authenticator = runtimeauth.NewProvider(authenticator, actorVerifier, store, cfg.GetSubstrate().GetActorJwtIssuer()) + authorizer = runtimeauth.NewAuthorizer(authorizer, store) resourceNamespace := cfg.GetKubernetes().GetNamespace() models := modelservice.NewService(manager.GetClient(), authorizer, resourceNamespace) tools := toolservice.NewService(manager.GetClient(), store, authorizer, resourceNamespace, mcpClient) prompts := prompttemplateservice.NewService(manager.GetClient(), authorizer) system := systemservice.NewService(manager.GetClient(), watchNamespaces, authorizer, actors) - memory := memoryservice.NewService(store) + memory := memoryservice.NewService(store, authorizer) quiescenceWake := make(chan struct{}, 1) pushWake := make(chan struct{}, 1) sessionWorkflow := sessionsvc.NewActorWorkflow(store, actors, quiescenceWake, quiescenceInterval) @@ -420,7 +430,6 @@ func Run(ctx context.Context, input *configv1.ControllerConfig, opts Options) er BindAddress: cfg.GetServer().GetBindAddress(), Reflection: cfg.GetServer().GetGrpcReflection(), Authenticator: authenticator, - RuntimeAuthenticator: &taskstore.Authenticator{}, ShareStore: store, ModelService: models, ToolService: tools, diff --git a/go/core/pkg/auth/auth.go b/go/core/pkg/auth/auth.go index 1a7ebe38fe..182dd4ac02 100644 --- a/go/core/pkg/auth/auth.go +++ b/go/core/pkg/auth/auth.go @@ -32,10 +32,18 @@ type Agent struct { ID string } +// Actor identifies a verified runtime caller. User is its session's owner, not +// a grant of that user's permissions; Agent is the session's namespaced agent. +// Caller-supplied agent headers never establish an Actor identity. +type Actor struct { + SessionID string +} + // Authn type Principal struct { User User Agent Agent + Actor *Actor Claims map[string]any // Raw JWT claims (nil for non-JWT auth) } @@ -70,8 +78,8 @@ type AuthProvider interface { // // It lives here rather than beside the server because a library consumer // registering its own services has to name these, and the server package is -// internal. AccessPublic skips authentication. AccessRuntime requires the -// separate workload authenticator; the remaining modes use public identity. +// internal. AccessPublic permits anonymous calls. AccessRuntime requires a +// verified actor; other modes accept either identity and authorize in services. type AccessMode string const ( @@ -142,19 +150,12 @@ func UpstreamMethodTo(ctx context.Context, method string) context.Context { func AuthnMiddleware(authn AuthProvider) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Skip authentication for the health endpoint used by probes. - if r.URL.Path == "/health" { - next.ServeHTTP(w, r) - return - } session, err := authn.Authenticate(r.Context(), r.Header, r.URL.Query()) - if err != nil { + if err != nil || session == nil { http.Error(w, "Unauthorized", http.StatusUnauthorized) return } - if session != nil { - r = r.WithContext(AuthSessionTo(r.Context(), session)) - } + r = r.WithContext(AuthSessionTo(r.Context(), session)) next.ServeHTTP(w, r) }) } diff --git a/go/core/pkg/config/defaults.go b/go/core/pkg/config/defaults.go index 7896ec55e6..e5b2a7c265 100644 --- a/go/core/pkg/config/defaults.go +++ b/go/core/pkg/config/defaults.go @@ -36,7 +36,7 @@ func DefaultController(cfg *configv1.ControllerConfig) { Kubernetes: &configv1.KubernetesConfig{Namespace: new("kagent"), LeaderElection: new(true)}, Server: &configv1.ControllerServerConfig{BindAddress: new(":8083"), Auth: &configv1.AuthenticationConfig{Mode: new("insecure")}}, Database: defaultDatabase(), - Substrate: &configv1.SubstrateConfig{ApiEndpoint: new("dns:///api.ate-system.svc:443"), RouterUrl: new("http://atenet-router.ate-system.svc:80")}, + Substrate: &configv1.SubstrateConfig{ApiEndpoint: new("dns:///api.ate-system.svc:443"), RouterUrl: new("http://atenet-router.ate-system.svc:80"), ActorJwtIssuer: new("https://idp.ate-system.svc")}, Sessions: &configv1.SessionsConfig{IdleTtl: durationpb.New(7 * 24 * time.Hour), ExpirationInterval: durationpb.New(time.Minute), QuiescenceRecoveryInterval: durationpb.New(time.Minute)}, Gateway: &configv1.GatewayConfig{PublicUrl: new("http://localhost:8083")}, Push: &configv1.PushConfig{RecoveryInterval: durationpb.New(time.Minute)}, diff --git a/go/core/pkg/config/load_test.go b/go/core/pkg/config/load_test.go index aae976bbe9..115fb0aa17 100644 --- a/go/core/pkg/config/load_test.go +++ b/go/core/pkg/config/load_test.go @@ -16,6 +16,9 @@ func TestControllerConfig(t *testing.T) { invalid bool }{ {"defaults", "{}", false}, + {"actor JWT issuer", "substrate: {actorJwtIssuer: https://actors.example}", false}, + {"empty actor JWT issuer", "substrate: {actorJwtIssuer: ''}", true}, + {"HTTP actor JWT issuer", "substrate: {actorJwtIssuer: http://actors.example}", true}, {"partial images", "harnessImages: {kagent: 'registry/image@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'}", false}, {"empty image", "harnessImages: {codex: ''}", false}, {"image tag", "harnessImages: {claude: 'registry/image:latest'}", true}, diff --git a/go/core/test/e2e/README.md b/go/core/test/e2e/README.md index 209c5cad44..58c1687504 100644 --- a/go/core/test/e2e/README.md +++ b/go/core/test/e2e/README.md @@ -61,10 +61,12 @@ mock servers, and cleanup. The same assertions run against all model protocols. The opaque BYO fixture has its own test because it does not consume managed agent configuration. -Until Substrate supplies runtime credentials (#1660), TaskStore uses a temporary -unsigned identity header in every deployment; no test authentication flag is -needed. Run these prerelease builds in isolated deployments. Verified actor -credentials will replace this path outright. +`TestRuntimeActorJWTAuthentication` exercises real Substrate Actor JWT injection +for every harness on the same controller endpoint used by user calls. It verifies +that missing, forged, and uninjected credentials are rejected for TaskStore and +actor-only Memory methods. Controller and runtime images must match. The gRPC +integration tests cover user and actor memory access on a shared listener, actor +API restrictions, and rejection of invalid Actor JWTs in both user auth modes. Add portable tests using this pattern: @@ -273,6 +275,12 @@ prefix) and X-API-Key (without a prefix) match the Secret. The script has only inert placeholders. The test and its harness subtests run in parallel. Actor identity, policy checks, and credential injection use the normal Substrate path. +`TestRuntimeActorJWTAuthentication` exercises TaskStore persistence through +Substrate's Actor JWT injection on every harness, then verifies that direct +calls with unsigned identity headers, public credentials, or an uninjected +placeholder are rejected. The checkpoint and suspension tests also exercise +authentication after the runtime's projected identity changes. + `TestSessionHTTPInteraction` discovers an Agent's Agent Card, invokes the advertised JSON-RPC interface, streams a second turn, and checks task persistence across HTTP and gRPC. `TestSessionHTTPResubscribeAndCancel` diff --git a/go/core/test/e2e/egress_test.go b/go/core/test/e2e/egress_test.go index 04855a2525..c63fef93ed 100644 --- a/go/core/test/e2e/egress_test.go +++ b/go/core/test/e2e/egress_test.go @@ -41,12 +41,22 @@ const egressProbeScript = `#!/bin/sh control=$1 target=$2 run=$3 +control_log=$(mktemp) +trap 'rm -f "$control_log"' EXIT +control_get() { + if wget -T 5 -O- "$control/$1" 2>"$control_log"; then + return 0 + fi + echo "EGRESS_CONTROL_FAILED_$1" >&2 + cat "$control_log" >&2 + return 1 +} echo "EGRESS_PROBE_STARTED_$run" for i in $(seq 1 900); do - phase=$(wget -T 5 -qO- "$control/phase") || exit 1 + phase=$(control_get phase) || exit 1 if [ "$phase" = stop ]; then echo "EGRESS_PROBE_FINISHED_$run" - wget -T 5 -qO- "$control/stopped" >/dev/null + control_get stopped >/dev/null exit fi if wget -T 3 -qO- --header 'Authorization: Bearer kagent-credential-injected' --header 'X-API-Key: kagent-credential-injected' "$target/?phase=$phase"; then @@ -55,7 +65,7 @@ for i in $(seq 1 900); do result=DENIED fi echo "EGRESS_RESULT_${phase}_$result" - wget -T 5 -qO- "$control/result?phase=$phase&result=$result" >/dev/null || exit 1 + control_get "result?phase=$phase&result=$result" >/dev/null || exit 1 sleep 0.2 done exit 1 diff --git a/go/core/test/e2e/runtime_auth_test.go b/go/core/test/e2e/runtime_auth_test.go new file mode 100644 index 0000000000..c15a5a4c74 --- /dev/null +++ b/go/core/test/e2e/runtime_auth_test.go @@ -0,0 +1,53 @@ +package e2e_test + +import ( + "testing" + + a2atype "github.com/a2aproject/a2a-go/v2/a2a" + a2apb "github.com/a2aproject/a2a-go/v2/a2apb/v1" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + + runtimev1 "github.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1" +) + +// Runtime writes traverse real Substrate JWT injection. Direct callers cannot +// acquire the same authority by supplying actor metadata or public credentials. +func TestRuntimeActorJWTAuthentication(t *testing.T) { + t.Parallel() + forEachHarness(t, func(t *testing.T, harness testHarness) { + t.Parallel() + target := interactionTarget(t) + fixture := newInteractionFixture(t, harness, target, startInteractionMock(t)) + _, _, task := fixture.send(t, "What is 2+2?") + require.Equal(t, a2atype.TaskStateCompleted, task.Status.State) + stored, err := fixture.client.GetTask(fixture.ctx, &a2apb.GetTaskRequest{Tenant: fixture.tenant, Id: string(task.ID)}) + require.NoError(t, err) + require.Equal(t, string(task.ID), stored.GetId(), "the Actor must persist through authenticated runtime RPCs") + runtimeConn := newControllerConn(t, target) + private := runtimev1.NewTaskStoreServiceClient(runtimeConn) + memory := runtimev1.NewMemoryServiceClient(runtimeConn) + request := &runtimev1.TaskStoreServiceGetTaskRequest{SessionId: fixture.sessionID, TaskId: string(task.ID)} + // The E2E installation uses insecure user auth. These callers become + // users, but cannot acquire the actor identity required by runtime RPCs. + for _, test := range []struct { + name string + headers []string + }{ + {"missing", nil}, + {"unsigned identity", []string{"x-kagent-insecure-runtime-identity", "kagent/session-" + fixture.sessionID + "/forged-uid"}}, + {"public identity", []string{"x-user-id", "e2e", "x-agent-name", fixture.sessionID}}, + {"uninjected placeholder", []string{"authorization", "Bearer placeholder"}}, + } { + t.Run(test.name, func(t *testing.T) { + ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs(test.headers...)) + _, err := private.GetTask(ctx, request) + require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) + _, err = memory.Search(ctx, &runtimev1.MemoryServiceSearchRequest{}) + require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) + }) + } + }) +} diff --git a/go/go.mod b/go/go.mod index 2c182a6df8..5093c22728 100644 --- a/go/go.mod +++ b/go/go.mod @@ -36,6 +36,7 @@ require ( github.com/jedib0t/go-pretty/v6 v6.8.3 github.com/kagent-dev/mockllm v0.1.0 github.com/kagent-dev/mockmcp v0.0.0-20260520211643-dcd475b74085 + github.com/lestrrat-go/jwx v1.2.31 github.com/modelcontextprotocol/go-sdk v1.8.0 github.com/muesli/reflow v0.3.0 github.com/ollama/ollama v0.40.5 @@ -307,7 +308,6 @@ require ( github.com/lestrrat-go/blackmagic v1.0.4 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect - github.com/lestrrat-go/jwx v1.2.31 // indirect github.com/lestrrat-go/option v1.0.1 // indirect github.com/lucasb-eyer/go-colorful v1.4.1 // indirect github.com/lufia/plan9stats v0.0.0-20260802145828-341c2f0c90b5 // indirect diff --git a/helm/README.md b/helm/README.md index 5e95e958c1..015f62450f 100644 --- a/helm/README.md +++ b/helm/README.md @@ -28,6 +28,15 @@ The Kagent chart does not deploy or initialize PostgreSQL, and it does not insta `kagent install` does all of this for development: it installs Substrate in `ate-system`, deploys a PostgreSQL instance in the Kagent namespace, creates the identities and schemas below, writes the connection Secrets, and then installs Kagent. Kagent, Substrate, and PostgreSQL authenticate with Pod Certificates rather than passwords. The development database has no pgvector extension and is not intended for production. It pulls `postgres:18-alpine` from Docker Hub; set `install.bundledPostgresImage` in the CLI config to use a mirror. +Runtime TaskStore calls require Actor JWTs injected by Substrate egress. Set +`config.substrate.actorJwtIssuer` to Substrate's `ateApi.actorJWTIssuer` (both +default to `https://idp.ate-system.svc`). The controller uses +`config.substrate.caFile` to verify discovery and JWKS TLS; enabling +`controller.substrateClientCertificate.enabled` supplies the Substrate ServiceDNS +trust bundle. Discovery and signing keys must be reachable at controller startup. +Upgrade controller and runtime images together and recreate snapshots of older +runtime images that sent unsigned identity headers. + Substrate v0.6.0-alpha3 restricts API ingress to namespaces labeled `ate.dev/api-access=true`. The CLI installer applies this label to the Kagent namespace. For direct Helm installations, label the controller namespace before diff --git a/helm/kagent/tests/controller-configmap_test.yaml b/helm/kagent/tests/controller-configmap_test.yaml index ce14b8075e..15a6c077a6 100644 --- a/helm/kagent/tests/controller-configmap_test.yaml +++ b/helm/kagent/tests/controller-configmap_test.yaml @@ -3,6 +3,19 @@ templates: - controller-configmap.yaml - controller-deployment.yaml tests: + - it: configures Actor JWT discovery with the Substrate trust bundle + template: controller-configmap.yaml + set: + controller.substrateClientCertificate.enabled: true + config.substrate.actorJwtIssuer: https://actors.example + asserts: + - matchRegex: + path: data["controller.yaml"] + pattern: 'actorJwtIssuer: https://actors.example' + - matchRegex: + path: data["controller.yaml"] + pattern: 'caFile: /run/substrate-servicedns/trust-bundle.pem' + - it: forwards the built-in bash setting to runtime defaults template: controller-configmap.yaml set: diff --git a/helm/kagent/tests/controller-service_test.yaml b/helm/kagent/tests/controller-service_test.yaml index b3ab1e7315..b1873c4efb 100644 --- a/helm/kagent/tests/controller-service_test.yaml +++ b/helm/kagent/tests/controller-service_test.yaml @@ -83,3 +83,9 @@ tests: value: PrivateDNSName: kagent service.beta.kubernetes.io/aws-load-balancer-type: nlb-ip + + - it: should use one controller port for users and actors + asserts: + - lengthEqual: + path: spec.ports + count: 1 diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 05fccbbfea..063fd80697 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -188,6 +188,8 @@ config: substrate: apiEndpoint: dns:///api.ate-system.svc:443 routerUrl: http://atenet-router.ate-system.svc:80 + # Must match Substrate's ateApi.actorJWTIssuer; discovery uses substrate.caFile. + actorJwtIssuer: https://idp.ate-system.svc logging: level: info metrics: diff --git a/proto/buf.yaml b/proto/buf.yaml index c1b90f9dac..2023d11b34 100644 --- a/proto/buf.yaml +++ b/proto/buf.yaml @@ -14,6 +14,11 @@ lint: breaking: use: - FILE + # Actor JWT migration removes caller-owned memory identity fields. Remove + # this exception once the removal is present on the comparison branch. + ignore_only: + FIELD_NO_DELETE: + - kagent/runtime/v1/memory.proto # Vendored upstream alpha API; upgrades follow go.mod. Kagent does not expose # its egress policy types, which changed incompatibly in alpha3. ignore: diff --git a/proto/kagent/config/v1/config.proto b/proto/kagent/config/v1/config.proto index 10fb118f9d..c505883b57 100644 --- a/proto/kagent/config/v1/config.proto +++ b/proto/kagent/config/v1/config.proto @@ -201,6 +201,11 @@ message SubstrateConfig { uri: true pattern: "^https?://[^/?#]+" }]; + // Trusted issuer for runtime Actor JWTs. Discovery and JWKS use ca_file. + optional string actor_jwt_issuer = 5 [(buf.validate.field).string = { + uri: true + pattern: "^https://[^/?#]+" + }]; } message SessionsConfig { diff --git a/proto/kagent/runtime/v1/memory.proto b/proto/kagent/runtime/v1/memory.proto index 5bf9abd2c4..0651311102 100644 --- a/proto/kagent/runtime/v1/memory.proto +++ b/proto/kagent/runtime/v1/memory.proto @@ -7,17 +7,25 @@ import "google/protobuf/timestamp.proto"; option go_package = "github.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1;runtimev1"; +// Memory is scoped to an agent and a user. Actor calls derive both from the +// persisted session bound to the verified Actor JWT. List and Delete also +// accept user authentication and use the authenticated user's identity. service MemoryService { + // Requires an Actor JWT. rpc AddSession(MemoryServiceAddSessionRequest) returns (MemoryServiceAddSessionResponse); + // Requires an Actor JWT. rpc AddSessionBatch(MemoryServiceAddSessionBatchRequest) returns (MemoryServiceAddSessionBatchResponse); + // Requires an Actor JWT. rpc Search(MemoryServiceSearchRequest) returns (MemoryServiceSearchResponse); + // Accepts an Actor JWT or user authentication on the same RPC endpoint. rpc List(MemoryServiceListRequest) returns (MemoryServiceListResponse); + // Accepts an Actor JWT or user authentication on the same RPC endpoint. rpc Delete(MemoryServiceDeleteRequest) returns (MemoryServiceDeleteResponse); } message SessionMemoryInput { - string agent_name = 1; - string user_id = 2; + reserved 1, 2; + reserved "agent_name", "user_id"; string content = 3; repeated float vector = 4; google.protobuf.Struct metadata = 5; @@ -57,8 +65,8 @@ message MemoryServiceAddSessionBatchResponse { } message MemoryServiceSearchRequest { - string agent_name = 1; - string user_id = 2; + reserved 1, 2; + reserved "agent_name", "user_id"; repeated float vector = 3; optional int32 limit = 4; optional double min_score = 5; @@ -69,8 +77,13 @@ message MemoryServiceSearchResponse { } message MemoryServiceListRequest { + reserved 2; + reserved "user_id"; + + // User calls must supply the stored agent key. Actor-created memories use + // "namespace/name"; legacy keys remain valid for managing older memories. + // Actor calls ignore this field and use the agent from their persisted session. string agent_name = 1; - string user_id = 2; } message MemoryServiceListResponse { @@ -78,8 +91,13 @@ message MemoryServiceListResponse { } message MemoryServiceDeleteRequest { + reserved 2; + reserved "user_id"; + + // User calls must supply the stored agent key. Actor-created memories use + // "namespace/name"; legacy keys remain valid for managing older memories. + // Actor calls ignore this field and use the agent from their persisted session. string agent_name = 1; - string user_id = 2; } message MemoryServiceDeleteResponse { diff --git a/python/packages/kagent-adk/src/kagent/adk/_a2a.py b/python/packages/kagent-adk/src/kagent/adk/_a2a.py index ccbca4f904..253343a5af 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_a2a.py +++ b/python/packages/kagent-adk/src/kagent/adk/_a2a.py @@ -121,7 +121,6 @@ def build(self, local=False) -> FastAPI: if self.config.agent.HasField("memory"): memory_service = KagentMemoryService( - agent_name=self.app_name, controller_client=controller_client, embedding_config=optional_field(self.config.agent.memory, "embedding"), ttl_days=self.config.agent.memory.ttl_days, diff --git a/python/packages/kagent-adk/src/kagent/adk/_memory_service.py b/python/packages/kagent-adk/src/kagent/adk/_memory_service.py index 3a39d70f59..4c4393e828 100644 --- a/python/packages/kagent-adk/src/kagent/adk/_memory_service.py +++ b/python/packages/kagent-adk/src/kagent/adk/_memory_service.py @@ -41,7 +41,6 @@ class KagentMemoryService(BaseMemoryService): def __init__( self, - agent_name: str, controller_client: AsyncControllerClient, embedding_config: Optional[EmbeddingConfig] = None, ttl_days: int = 0, @@ -49,12 +48,10 @@ def __init__( """Initialize KagentMemoryService. Args: - agent_name: Name of the agent (used as namespace in storage) controller_client: Shared authenticated controller gRPC client embedding_config: Configuration for embedding model (EmbeddingConfig only). ttl_days: TTL for memory entries in days. 0 means use the server default. """ - self.agent_name = agent_name self.client = controller_client self.embedding_config = embedding_config self.ttl_days = ttl_days @@ -124,8 +121,6 @@ async def _add_session_to_memory_background(self, session: Session, model: Optio continue item = memory_pb2.SessionMemoryInput( - agent_name=self.agent_name, - user_id=user_id, content=content_item, vector=vector, ) @@ -174,8 +169,6 @@ async def add_memory( return memory = memory_pb2.SessionMemoryInput( - agent_name=self.agent_name, - user_id=user_id, content=content, vector=vector, ) @@ -205,8 +198,8 @@ async def search_memory( """Search memory for relevant content. Args: - app_name: The application name (used for filtering) - user_id: ADK compatibility parameter; ownership comes from request identity. + app_name: ADK compatibility parameter; scope comes from the actor's session. + user_id: ADK compatibility parameter; ownership comes from the actor's session. query: The search query text Returns: @@ -225,8 +218,6 @@ async def search_memory( try: response = await self.client.memory_service.Search( memory_pb2.MemoryServiceSearchRequest( - agent_name=self.agent_name, - user_id=user_id, vector=vector, limit=5, min_score=0.3, diff --git a/python/packages/kagent-adk/tests/unittests/test_memory_service_grpc.py b/python/packages/kagent-adk/tests/unittests/test_memory_service_grpc.py index 8b2bf19ca6..51624473ff 100644 --- a/python/packages/kagent-adk/tests/unittests/test_memory_service_grpc.py +++ b/python/packages/kagent-adk/tests/unittests/test_memory_service_grpc.py @@ -37,7 +37,7 @@ def client(): @pytest.fixture def service(client): - value = KagentMemoryService(agent_name="ns__NS__agent", controller_client=client, ttl_days=7) + value = KagentMemoryService(controller_client=client, ttl_days=7) value._embedding_client = MagicMock() return value @@ -53,8 +53,6 @@ async def test_add_memory_uses_generated_rpc_with_metadata_and_ttl(service, clie ) request = client.memory_service.AddSession.await_args.args[0] - assert request.memory.agent_name == "ns__NS__agent" - assert request.memory.user_id == caller assert request.memory.content == "remember this" assert list(request.memory.vector) == [0.25, 0.75] assert request.memory.ttl_days == 7 @@ -69,8 +67,6 @@ async def test_search_memory_uses_defaults_and_maps_results(service, client, cal response = await service.search_memory(app_name="ignored", user_id="user-2", query="what matters?") request = client.memory_service.Search.await_args.args[0] - assert request.agent_name == "ns__NS__agent" - assert request.user_id == caller assert list(request.vector) == [0.5, 0.125] assert request.limit == 5 assert request.min_score == pytest.approx(0.3) @@ -106,7 +102,6 @@ async def test_session_memory_batches_generated_inputs(service, client, caller): pytest.approx([0.3, 0.4]), ] assert all(item.ttl_days == 7 for item in request.items) - assert all(item.user_id == caller for item in request.items) client.call_options.assert_awaited_once_with(caller) @@ -118,8 +113,6 @@ async def test_memory_uses_request_user_instead_of_private_native_key(service, c await service.search_memory(app_name="app", user_id="conversation", query="remember") finally: request_user_id.reset(token) - assert client.memory_service.AddSession.await_args.args[0].memory.user_id == "alice" - assert client.memory_service.Search.await_args.args[0].user_id == "alice" assert all(call.args == ("alice",) for call in client.call_options.await_args_list) @@ -175,6 +168,4 @@ def capture_task(coro): await asyncio.gather(*tasks) finally: request_user_id.reset(token) - request = client.memory_service.AddSessionBatch.await_args.args[0] - assert request.items[0].user_id == "alice" client.call_options.assert_awaited_once_with("alice") diff --git a/python/packages/kagent-core/src/kagent/core/_grpc.py b/python/packages/kagent-core/src/kagent/core/_grpc.py index 132f85ba1a..3dfb73e628 100644 --- a/python/packages/kagent-core/src/kagent/core/_grpc.py +++ b/python/packages/kagent-core/src/kagent/core/_grpc.py @@ -91,11 +91,9 @@ def task_store_service(self) -> task_store_pb2_grpc.TaskStoreServiceStub: return self._task_store_service async def call_options(self, user_id: str | None = None) -> dict[str, Any]: - metadata: list[tuple[str, str]] = [] - if self.token_provider is not None: - token = await self.token_provider.get_token() - if token: - metadata.append(("authorization", f"Bearer {token}")) + # Substrate replaces this placeholder at the controller's runtime origin. + token = await self.token_provider.get_token() if self.token_provider is not None else None + metadata: list[tuple[str, str]] = [("authorization", f"Bearer {token or 'placeholder'}")] if self.agent_name: metadata.append(("x-agent-name", self.agent_name)) if not user_id: diff --git a/python/packages/kagent-core/src/kagent/core/a2a/_task_store.py b/python/packages/kagent-core/src/kagent/core/a2a/_task_store.py index 2f9df75a07..94808522a4 100644 --- a/python/packages/kagent-core/src/kagent/core/a2a/_task_store.py +++ b/python/packages/kagent-core/src/kagent/core/a2a/_task_store.py @@ -44,12 +44,9 @@ async def _session_id(self) -> str: return str(UUID(name.removeprefix("session-"))) async def _call(self, method, request): - # Temporary identity transport until Substrate injects actor credentials (#1660). - # Reread on every call because restore rebinds these files to the new actor. - identity = [] - for field in ("atespace", "name", "uid"): - identity.append((await asyncio.to_thread((self.identity_path.parent / field).read_text)).strip()) - metadata = (("x-kagent-insecure-runtime-identity", "/".join(identity)),) + # Substrate replaces the placeholder with the calling Actor's JWT. + # Credentials never become part of a runtime snapshot. + metadata = (("authorization", "Bearer placeholder"),) for attempt in range(4): try: return await method( diff --git a/python/packages/kagent-core/tests/test_controller_urls.py b/python/packages/kagent-core/tests/test_controller_urls.py index 63ef1fc27f..dfe61a37bf 100644 --- a/python/packages/kagent-core/tests/test_controller_urls.py +++ b/python/packages/kagent-core/tests/test_controller_urls.py @@ -1,6 +1,6 @@ import pytest -from kagent.core._grpc import _target_from_url +from kagent.core._grpc import AsyncControllerClient, _target_from_url @pytest.mark.parametrize( @@ -17,3 +17,10 @@ def test_target_from_url(url: str, target: str, secure: bool) -> None: def test_target_from_url_rejects_paths() -> None: with pytest.raises(ValueError, match="scheme and authority"): _target_from_url("https://api.example.com/path") + + +@pytest.mark.asyncio +async def test_controller_calls_request_actor_jwt_without_user_token() -> None: + client = AsyncControllerClient("http://controller.test:8084") + options = await client.call_options() + assert dict(options["metadata"])["authorization"] == "Bearer placeholder" diff --git a/python/packages/kagent-core/tests/test_task_store.py b/python/packages/kagent-core/tests/test_task_store.py index 1a09078579..0b32748971 100644 --- a/python/packages/kagent-core/tests/test_task_store.py +++ b/python/packages/kagent-core/tests/test_task_store.py @@ -62,9 +62,7 @@ async def _save(self, request, key, context): self.dispatch_ids.append(request.dispatch_id) if self.reject_saves: await context.abort(grpc.StatusCode.UNAVAILABLE, "storage outage") - assert dict(context.invocation_metadata())["x-kagent-insecure-runtime-identity"] == ( - f"team-a/session-{self.session_id}/actor-uid" - ) + assert dict(context.invocation_metadata())["authorization"] == "Bearer placeholder" payload = request.SerializeToString(deterministic=True) if key in self.receipts: digest, version = self.receipts[key] @@ -201,8 +199,6 @@ async def runtime(tmp_path: Path): session_id = str(uuid4()) identity = tmp_path / "name" identity.write_text("session-" + session_id) - (tmp_path / "atespace").write_text("team-a") - (tmp_path / "uid").write_text("actor-uid") service = Storage(session_id) server = grpc.aio.server() storerpc.add_TaskStoreServiceServicer_to_server(service, server) diff --git a/python/packages/kagent-proto/src/kagent/runtime/v1/memory_pb2.py b/python/packages/kagent-proto/src/kagent/runtime/v1/memory_pb2.py index 7b995e60fe..6fa502327e 100644 --- a/python/packages/kagent-proto/src/kagent/runtime/v1/memory_pb2.py +++ b/python/packages/kagent-proto/src/kagent/runtime/v1/memory_pb2.py @@ -26,7 +26,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1ekagent/runtime/v1/memory.proto\x12\x11kagent.runtime.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xe0\x01\n\x12SessionMemoryInput\x12\x1d\n\nagent_name\x18\x01 \x01(\tR\tagentName\x12\x17\n\x07user_id\x18\x02 \x01(\tR\x06userId\x12\x18\n\x07\x63ontent\x18\x03 \x01(\tR\x07\x63ontent\x12\x16\n\x06vector\x18\x04 \x03(\x02R\x06vector\x12\x33\n\x08metadata\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\x12\x1e\n\x08ttl_days\x18\x06 \x01(\x05H\x00R\x07ttlDays\x88\x01\x01\x42\x0b\n\t_ttl_days\"\xc4\x01\n\x12MemorySearchResult\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n\x07\x63ontent\x18\x02 \x01(\tR\x07\x63ontent\x12\x14\n\x05score\x18\x03 \x01(\x01R\x05score\x12\x33\n\x08metadata\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\x12\x39\n\ncreated_at\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\tcreatedAt\"\xd2\x01\n\rMemorySummary\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n\x07\x63ontent\x18\x02 \x01(\tR\x07\x63ontent\x12!\n\x0c\x61\x63\x63\x65ss_count\x18\x03 \x01(\x03R\x0b\x61\x63\x63\x65ssCount\x12\x39\n\ncreated_at\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x39\n\nexpires_at\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\texpiresAt\"_\n\x1eMemoryServiceAddSessionRequest\x12=\n\x06memory\x18\x01 \x01(\x0b\x32%.kagent.runtime.v1.SessionMemoryInputR\x06memory\"1\n\x1fMemoryServiceAddSessionResponse\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\"b\n#MemoryServiceAddSessionBatchRequest\x12;\n\x05items\x18\x01 \x03(\x0b\x32%.kagent.runtime.v1.SessionMemoryInputR\x05items\"<\n$MemoryServiceAddSessionBatchResponse\x12\x14\n\x05\x63ount\x18\x01 \x01(\x05R\x05\x63ount\"\xc1\x01\n\x1aMemoryServiceSearchRequest\x12\x1d\n\nagent_name\x18\x01 \x01(\tR\tagentName\x12\x17\n\x07user_id\x18\x02 \x01(\tR\x06userId\x12\x16\n\x06vector\x18\x03 \x03(\x02R\x06vector\x12\x19\n\x05limit\x18\x04 \x01(\x05H\x00R\x05limit\x88\x01\x01\x12 \n\tmin_score\x18\x05 \x01(\x01H\x01R\x08minScore\x88\x01\x01\x42\x08\n\x06_limitB\x0c\n\n_min_score\"`\n\x1bMemoryServiceSearchResponse\x12\x41\n\x08memories\x18\x01 \x03(\x0b\x32%.kagent.runtime.v1.MemorySearchResultR\x08memories\"R\n\x18MemoryServiceListRequest\x12\x1d\n\nagent_name\x18\x01 \x01(\tR\tagentName\x12\x17\n\x07user_id\x18\x02 \x01(\tR\x06userId\"Y\n\x19MemoryServiceListResponse\x12<\n\x08memories\x18\x01 \x03(\x0b\x32 .kagent.runtime.v1.MemorySummaryR\x08memories\"T\n\x1aMemoryServiceDeleteRequest\x12\x1d\n\nagent_name\x18\x01 \x01(\tR\tagentName\x12\x17\n\x07user_id\x18\x02 \x01(\tR\x06userId\"5\n\x1bMemoryServiceDeleteResponse\x12\x16\n\x06status\x18\x01 \x01(\tR\x06status2\xbe\x04\n\rMemoryService\x12s\n\nAddSession\x12\x31.kagent.runtime.v1.MemoryServiceAddSessionRequest\x1a\x32.kagent.runtime.v1.MemoryServiceAddSessionResponse\x12\x82\x01\n\x0f\x41\x64\x64SessionBatch\x12\x36.kagent.runtime.v1.MemoryServiceAddSessionBatchRequest\x1a\x37.kagent.runtime.v1.MemoryServiceAddSessionBatchResponse\x12g\n\x06Search\x12-.kagent.runtime.v1.MemoryServiceSearchRequest\x1a..kagent.runtime.v1.MemoryServiceSearchResponse\x12\x61\n\x04List\x12+.kagent.runtime.v1.MemoryServiceListRequest\x1a,.kagent.runtime.v1.MemoryServiceListResponse\x12g\n\x06\x44\x65lete\x12-.kagent.runtime.v1.MemoryServiceDeleteRequest\x1a..kagent.runtime.v1.MemoryServiceDeleteResponseBEZCgithub.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1;runtimev1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1ekagent/runtime/v1/memory.proto\x12\x11kagent.runtime.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xc9\x01\n\x12SessionMemoryInput\x12\x18\n\x07\x63ontent\x18\x03 \x01(\tR\x07\x63ontent\x12\x16\n\x06vector\x18\x04 \x03(\x02R\x06vector\x12\x33\n\x08metadata\x18\x05 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\x12\x1e\n\x08ttl_days\x18\x06 \x01(\x05H\x00R\x07ttlDays\x88\x01\x01\x42\x0b\n\t_ttl_daysJ\x04\x08\x01\x10\x02J\x04\x08\x02\x10\x03R\nagent_nameR\x07user_id\"\xc4\x01\n\x12MemorySearchResult\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n\x07\x63ontent\x18\x02 \x01(\tR\x07\x63ontent\x12\x14\n\x05score\x18\x03 \x01(\x01R\x05score\x12\x33\n\x08metadata\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructR\x08metadata\x12\x39\n\ncreated_at\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\tcreatedAt\"\xd2\x01\n\rMemorySummary\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\x12\x18\n\x07\x63ontent\x18\x02 \x01(\tR\x07\x63ontent\x12!\n\x0c\x61\x63\x63\x65ss_count\x18\x03 \x01(\x03R\x0b\x61\x63\x63\x65ssCount\x12\x39\n\ncreated_at\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\tcreatedAt\x12\x39\n\nexpires_at\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.TimestampR\texpiresAt\"_\n\x1eMemoryServiceAddSessionRequest\x12=\n\x06memory\x18\x01 \x01(\x0b\x32%.kagent.runtime.v1.SessionMemoryInputR\x06memory\"1\n\x1fMemoryServiceAddSessionResponse\x12\x0e\n\x02id\x18\x01 \x01(\tR\x02id\"b\n#MemoryServiceAddSessionBatchRequest\x12;\n\x05items\x18\x01 \x03(\x0b\x32%.kagent.runtime.v1.SessionMemoryInputR\x05items\"<\n$MemoryServiceAddSessionBatchResponse\x12\x14\n\x05\x63ount\x18\x01 \x01(\x05R\x05\x63ount\"\xaa\x01\n\x1aMemoryServiceSearchRequest\x12\x16\n\x06vector\x18\x03 \x03(\x02R\x06vector\x12\x19\n\x05limit\x18\x04 \x01(\x05H\x00R\x05limit\x88\x01\x01\x12 \n\tmin_score\x18\x05 \x01(\x01H\x01R\x08minScore\x88\x01\x01\x42\x08\n\x06_limitB\x0c\n\n_min_scoreJ\x04\x08\x01\x10\x02J\x04\x08\x02\x10\x03R\nagent_nameR\x07user_id\"`\n\x1bMemoryServiceSearchResponse\x12\x41\n\x08memories\x18\x01 \x03(\x0b\x32%.kagent.runtime.v1.MemorySearchResultR\x08memories\"H\n\x18MemoryServiceListRequest\x12\x1d\n\nagent_name\x18\x01 \x01(\tR\tagentNameJ\x04\x08\x02\x10\x03R\x07user_id\"Y\n\x19MemoryServiceListResponse\x12<\n\x08memories\x18\x01 \x03(\x0b\x32 .kagent.runtime.v1.MemorySummaryR\x08memories\"J\n\x1aMemoryServiceDeleteRequest\x12\x1d\n\nagent_name\x18\x01 \x01(\tR\tagentNameJ\x04\x08\x02\x10\x03R\x07user_id\"5\n\x1bMemoryServiceDeleteResponse\x12\x16\n\x06status\x18\x01 \x01(\tR\x06status2\xbe\x04\n\rMemoryService\x12s\n\nAddSession\x12\x31.kagent.runtime.v1.MemoryServiceAddSessionRequest\x1a\x32.kagent.runtime.v1.MemoryServiceAddSessionResponse\x12\x82\x01\n\x0f\x41\x64\x64SessionBatch\x12\x36.kagent.runtime.v1.MemoryServiceAddSessionBatchRequest\x1a\x37.kagent.runtime.v1.MemoryServiceAddSessionBatchResponse\x12g\n\x06Search\x12-.kagent.runtime.v1.MemoryServiceSearchRequest\x1a..kagent.runtime.v1.MemoryServiceSearchResponse\x12\x61\n\x04List\x12+.kagent.runtime.v1.MemoryServiceListRequest\x1a,.kagent.runtime.v1.MemoryServiceListResponse\x12g\n\x06\x44\x65lete\x12-.kagent.runtime.v1.MemoryServiceDeleteRequest\x1a..kagent.runtime.v1.MemoryServiceDeleteResponseBEZCgithub.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1;runtimev1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -35,31 +35,31 @@ _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'ZCgithub.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1;runtimev1' _globals['_SESSIONMEMORYINPUT']._serialized_start=117 - _globals['_SESSIONMEMORYINPUT']._serialized_end=341 - _globals['_MEMORYSEARCHRESULT']._serialized_start=344 - _globals['_MEMORYSEARCHRESULT']._serialized_end=540 - _globals['_MEMORYSUMMARY']._serialized_start=543 - _globals['_MEMORYSUMMARY']._serialized_end=753 - _globals['_MEMORYSERVICEADDSESSIONREQUEST']._serialized_start=755 - _globals['_MEMORYSERVICEADDSESSIONREQUEST']._serialized_end=850 - _globals['_MEMORYSERVICEADDSESSIONRESPONSE']._serialized_start=852 - _globals['_MEMORYSERVICEADDSESSIONRESPONSE']._serialized_end=901 - _globals['_MEMORYSERVICEADDSESSIONBATCHREQUEST']._serialized_start=903 - _globals['_MEMORYSERVICEADDSESSIONBATCHREQUEST']._serialized_end=1001 - _globals['_MEMORYSERVICEADDSESSIONBATCHRESPONSE']._serialized_start=1003 - _globals['_MEMORYSERVICEADDSESSIONBATCHRESPONSE']._serialized_end=1063 - _globals['_MEMORYSERVICESEARCHREQUEST']._serialized_start=1066 - _globals['_MEMORYSERVICESEARCHREQUEST']._serialized_end=1259 - _globals['_MEMORYSERVICESEARCHRESPONSE']._serialized_start=1261 - _globals['_MEMORYSERVICESEARCHRESPONSE']._serialized_end=1357 - _globals['_MEMORYSERVICELISTREQUEST']._serialized_start=1359 - _globals['_MEMORYSERVICELISTREQUEST']._serialized_end=1441 - _globals['_MEMORYSERVICELISTRESPONSE']._serialized_start=1443 - _globals['_MEMORYSERVICELISTRESPONSE']._serialized_end=1532 - _globals['_MEMORYSERVICEDELETEREQUEST']._serialized_start=1534 - _globals['_MEMORYSERVICEDELETEREQUEST']._serialized_end=1618 - _globals['_MEMORYSERVICEDELETERESPONSE']._serialized_start=1620 - _globals['_MEMORYSERVICEDELETERESPONSE']._serialized_end=1673 - _globals['_MEMORYSERVICE']._serialized_start=1676 - _globals['_MEMORYSERVICE']._serialized_end=2250 + _globals['_SESSIONMEMORYINPUT']._serialized_end=318 + _globals['_MEMORYSEARCHRESULT']._serialized_start=321 + _globals['_MEMORYSEARCHRESULT']._serialized_end=517 + _globals['_MEMORYSUMMARY']._serialized_start=520 + _globals['_MEMORYSUMMARY']._serialized_end=730 + _globals['_MEMORYSERVICEADDSESSIONREQUEST']._serialized_start=732 + _globals['_MEMORYSERVICEADDSESSIONREQUEST']._serialized_end=827 + _globals['_MEMORYSERVICEADDSESSIONRESPONSE']._serialized_start=829 + _globals['_MEMORYSERVICEADDSESSIONRESPONSE']._serialized_end=878 + _globals['_MEMORYSERVICEADDSESSIONBATCHREQUEST']._serialized_start=880 + _globals['_MEMORYSERVICEADDSESSIONBATCHREQUEST']._serialized_end=978 + _globals['_MEMORYSERVICEADDSESSIONBATCHRESPONSE']._serialized_start=980 + _globals['_MEMORYSERVICEADDSESSIONBATCHRESPONSE']._serialized_end=1040 + _globals['_MEMORYSERVICESEARCHREQUEST']._serialized_start=1043 + _globals['_MEMORYSERVICESEARCHREQUEST']._serialized_end=1213 + _globals['_MEMORYSERVICESEARCHRESPONSE']._serialized_start=1215 + _globals['_MEMORYSERVICESEARCHRESPONSE']._serialized_end=1311 + _globals['_MEMORYSERVICELISTREQUEST']._serialized_start=1313 + _globals['_MEMORYSERVICELISTREQUEST']._serialized_end=1385 + _globals['_MEMORYSERVICELISTRESPONSE']._serialized_start=1387 + _globals['_MEMORYSERVICELISTRESPONSE']._serialized_end=1476 + _globals['_MEMORYSERVICEDELETEREQUEST']._serialized_start=1478 + _globals['_MEMORYSERVICEDELETEREQUEST']._serialized_end=1552 + _globals['_MEMORYSERVICEDELETERESPONSE']._serialized_start=1554 + _globals['_MEMORYSERVICEDELETERESPONSE']._serialized_end=1607 + _globals['_MEMORYSERVICE']._serialized_start=1610 + _globals['_MEMORYSERVICE']._serialized_end=2184 # @@protoc_insertion_point(module_scope) diff --git a/python/packages/kagent-proto/src/kagent/runtime/v1/memory_pb2.pyi b/python/packages/kagent-proto/src/kagent/runtime/v1/memory_pb2.pyi index 0e884e52b1..eff311559c 100644 --- a/python/packages/kagent-proto/src/kagent/runtime/v1/memory_pb2.pyi +++ b/python/packages/kagent-proto/src/kagent/runtime/v1/memory_pb2.pyi @@ -11,20 +11,16 @@ from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union DESCRIPTOR: _descriptor.FileDescriptor class SessionMemoryInput(_message.Message): - __slots__ = ("agent_name", "user_id", "content", "vector", "metadata", "ttl_days") - AGENT_NAME_FIELD_NUMBER: _ClassVar[int] - USER_ID_FIELD_NUMBER: _ClassVar[int] + __slots__ = ("content", "vector", "metadata", "ttl_days") CONTENT_FIELD_NUMBER: _ClassVar[int] VECTOR_FIELD_NUMBER: _ClassVar[int] METADATA_FIELD_NUMBER: _ClassVar[int] TTL_DAYS_FIELD_NUMBER: _ClassVar[int] - agent_name: str - user_id: str content: str vector: _containers.RepeatedScalarFieldContainer[float] metadata: _struct_pb2.Struct ttl_days: int - def __init__(self, agent_name: _Optional[str] = ..., user_id: _Optional[str] = ..., content: _Optional[str] = ..., vector: _Optional[_Iterable[float]] = ..., metadata: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., ttl_days: _Optional[int] = ...) -> None: ... + def __init__(self, content: _Optional[str] = ..., vector: _Optional[_Iterable[float]] = ..., metadata: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., ttl_days: _Optional[int] = ...) -> None: ... class MemorySearchResult(_message.Message): __slots__ = ("id", "content", "score", "metadata", "created_at") @@ -79,18 +75,14 @@ class MemoryServiceAddSessionBatchResponse(_message.Message): def __init__(self, count: _Optional[int] = ...) -> None: ... class MemoryServiceSearchRequest(_message.Message): - __slots__ = ("agent_name", "user_id", "vector", "limit", "min_score") - AGENT_NAME_FIELD_NUMBER: _ClassVar[int] - USER_ID_FIELD_NUMBER: _ClassVar[int] + __slots__ = ("vector", "limit", "min_score") VECTOR_FIELD_NUMBER: _ClassVar[int] LIMIT_FIELD_NUMBER: _ClassVar[int] MIN_SCORE_FIELD_NUMBER: _ClassVar[int] - agent_name: str - user_id: str vector: _containers.RepeatedScalarFieldContainer[float] limit: int min_score: float - def __init__(self, agent_name: _Optional[str] = ..., user_id: _Optional[str] = ..., vector: _Optional[_Iterable[float]] = ..., limit: _Optional[int] = ..., min_score: _Optional[float] = ...) -> None: ... + def __init__(self, vector: _Optional[_Iterable[float]] = ..., limit: _Optional[int] = ..., min_score: _Optional[float] = ...) -> None: ... class MemoryServiceSearchResponse(_message.Message): __slots__ = ("memories",) @@ -99,12 +91,10 @@ class MemoryServiceSearchResponse(_message.Message): def __init__(self, memories: _Optional[_Iterable[_Union[MemorySearchResult, _Mapping]]] = ...) -> None: ... class MemoryServiceListRequest(_message.Message): - __slots__ = ("agent_name", "user_id") + __slots__ = ("agent_name",) AGENT_NAME_FIELD_NUMBER: _ClassVar[int] - USER_ID_FIELD_NUMBER: _ClassVar[int] agent_name: str - user_id: str - def __init__(self, agent_name: _Optional[str] = ..., user_id: _Optional[str] = ...) -> None: ... + def __init__(self, agent_name: _Optional[str] = ...) -> None: ... class MemoryServiceListResponse(_message.Message): __slots__ = ("memories",) @@ -113,12 +103,10 @@ class MemoryServiceListResponse(_message.Message): def __init__(self, memories: _Optional[_Iterable[_Union[MemorySummary, _Mapping]]] = ...) -> None: ... class MemoryServiceDeleteRequest(_message.Message): - __slots__ = ("agent_name", "user_id") + __slots__ = ("agent_name",) AGENT_NAME_FIELD_NUMBER: _ClassVar[int] - USER_ID_FIELD_NUMBER: _ClassVar[int] agent_name: str - user_id: str - def __init__(self, agent_name: _Optional[str] = ..., user_id: _Optional[str] = ...) -> None: ... + def __init__(self, agent_name: _Optional[str] = ...) -> None: ... class MemoryServiceDeleteResponse(_message.Message): __slots__ = ("status",) diff --git a/python/packages/kagent-proto/src/kagent/runtime/v1/memory_pb2_grpc.py b/python/packages/kagent-proto/src/kagent/runtime/v1/memory_pb2_grpc.py index 729f792e58..0d24b7a89d 100644 --- a/python/packages/kagent-proto/src/kagent/runtime/v1/memory_pb2_grpc.py +++ b/python/packages/kagent-proto/src/kagent/runtime/v1/memory_pb2_grpc.py @@ -6,7 +6,10 @@ class MemoryServiceStub: - """Missing associated documentation comment in .proto file.""" + """Memory is scoped to an agent and a user. Actor calls derive both from the + persisted session bound to the verified Actor JWT. List and Delete also + accept user authentication and use the authenticated user's identity. + """ def __init__(self, channel): """Constructor. @@ -42,34 +45,42 @@ def __init__(self, channel): class MemoryServiceServicer: - """Missing associated documentation comment in .proto file.""" + """Memory is scoped to an agent and a user. Actor calls derive both from the + persisted session bound to the verified Actor JWT. List and Delete also + accept user authentication and use the authenticated user's identity. + """ def AddSession(self, request, context): - """Missing associated documentation comment in .proto file.""" + """Requires an Actor JWT. + """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') def AddSessionBatch(self, request, context): - """Missing associated documentation comment in .proto file.""" + """Requires an Actor JWT. + """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') def Search(self, request, context): - """Missing associated documentation comment in .proto file.""" + """Requires an Actor JWT. + """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') def List(self, request, context): - """Missing associated documentation comment in .proto file.""" + """Accepts an Actor JWT or user authentication on the same RPC endpoint. + """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') def Delete(self, request, context): - """Missing associated documentation comment in .proto file.""" + """Accepts an Actor JWT or user authentication on the same RPC endpoint. + """ context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') @@ -111,7 +122,10 @@ def add_MemoryServiceServicer_to_server(servicer, server): # This class is part of an EXPERIMENTAL API. class MemoryService: - """Missing associated documentation comment in .proto file.""" + """Memory is scoped to an agent and a user. Actor calls derive both from the + persisted session bound to the verified Actor JWT. List and Delete also + accept user authentication and use the authenticated user's identity. + """ @staticmethod def AddSession(request, diff --git a/python/packages/kagent-proto/tests/test_memory_client.py b/python/packages/kagent-proto/tests/test_memory_client.py index 34c6558fc2..be987f38c5 100644 --- a/python/packages/kagent-proto/tests/test_memory_client.py +++ b/python/packages/kagent-proto/tests/test_memory_client.py @@ -27,7 +27,7 @@ async def test_generated_async_client_forwards_metadata_and_deadline() -> None: try: async with grpc.aio.insecure_channel(f"127.0.0.1:{port}") as channel: response = await memory_pb2_grpc.MemoryServiceStub(channel).Search( - memory_pb2.MemoryServiceSearchRequest(agent_name="agent", user_id="user", vector=[1.0]), + memory_pb2.MemoryServiceSearchRequest(vector=[1.0]), timeout=5, metadata=( ("authorization", "Bearer token"),