From d14e92f3dca7cb217dfc551e09f910f935a3b1d0 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Mon, 19 Oct 2026 15:53:33 +0000 Subject: [PATCH 01/11] feat(auth): verify runtime calls with Substrate Actor JWTs Signed-off-by: Eitan Yarmush --- docs/architecture/a2a-gateway.md | 40 +++-- go/adk/pkg/taskstore/store.go | 27 +--- go/api/a2a/runtime.go | 8 +- go/api/gen/kagent/config/v1/config.pb.go | 17 +- go/core/internal/grpcserver/interceptors.go | 10 +- .../internal/grpcserver/interceptors_test.go | 10 +- .../internal/grpcserver/task_store_test.go | 49 +++++- go/core/internal/jwttest/issuer.go | 85 ++++++++++ .../service/session/quiescence_wake_test.go | 8 +- .../internal/service/session/workflow_test.go | 14 +- .../internal/service/taskstore/authority.go | 41 +++-- .../service/taskstore/authority_test.go | 25 ++- go/core/internal/substrate/actor_jwt.go | 147 ++++++++++++++++++ go/core/internal/substrate/actor_jwt_test.go | 90 +++++++++++ go/core/internal/substrate/actor_template.go | 2 - .../internal/substrate/actor_template_test.go | 2 +- go/core/internal/substrate/config.go | 2 + go/core/internal/substrate/egress.go | 6 +- go/core/internal/substrate/policy.go | 36 ++++- go/core/internal/substrate/policy_test.go | 31 +++- go/core/pkg/app/app.go | 9 +- go/core/pkg/config/defaults.go | 2 +- go/core/pkg/config/load_test.go | 3 + go/core/test/e2e/README.md | 6 + go/core/test/e2e/runtime_auth_test.go | 48 ++++++ go/go.mod | 2 +- helm/README.md | 9 ++ .../tests/controller-configmap_test.yaml | 13 ++ helm/kagent/values.yaml | 2 + proto/kagent/config/v1/config.proto | 5 + .../src/kagent/core/a2a/_task_store.py | 9 +- .../kagent-core/tests/test_task_store.py | 6 +- 32 files changed, 645 insertions(+), 119 deletions(-) create mode 100644 go/core/internal/jwttest/issuer.go create mode 100644 go/core/internal/substrate/actor_jwt.go create mode 100644 go/core/internal/substrate/actor_jwt_test.go create mode 100644 go/core/test/e2e/runtime_auth_test.go diff --git a/docs/architecture/a2a-gateway.md b/docs/architecture/a2a-gateway.md index ef605561d2..6133121118 100644 --- a/docs/architecture/a2a-gateway.md +++ b/docs/architecture/a2a-gateway.md @@ -186,21 +186,31 @@ 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 + +TaskStore requires a Substrate-issued Actor JWT. Go and Python send +`x-kagent-runtime-authorization: Bearer placeholder`; the control-plane origin's +egress rule replaces it with a five-minute JWT bound to the `kagent-runtime` +audience. The separate header preserves public credentials used on the same +origin. Other destinations receive no runtime JWT, and this rule does not grant +network access to sandboxes that cannot already reach the control plane. + +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, expiration and +not-before time, and 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. Public and share credentials cannot +grant TaskStore access. 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. ## Push notifications diff --git a/go/adk/pkg/taskstore/store.go b/go/adk/pkg/taskstore/store.go index 88374ec9c1..2a862e2d80 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" @@ -49,7 +48,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 +56,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(apia2a.RuntimeAuthorizationHeader, "Bearer placeholder") + return metadata.NewOutgoingContext(ctx, md), cancel } // Create persists the SDK's initial task and returns its committed version. @@ -183,11 +173,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..cf343526d1 100644 --- a/go/api/a2a/runtime.go +++ b/go/api/a2a/runtime.go @@ -1,12 +1,12 @@ 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" +// RuntimeAuthorizationHeader carries the Actor JWT injected by Substrate egress. +// Runtime clients send a placeholder; the gateway replaces it with a signed token. +const RuntimeAuthorizationHeader = "x-kagent-runtime-authorization" // 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/core/internal/grpcserver/interceptors.go b/go/core/internal/grpcserver/interceptors.go index 3aec54edac..e352380bde 100644 --- a/go/core/internal/grpcserver/interceptors.go +++ b/go/core/internal/grpcserver/interceptors.go @@ -26,11 +26,11 @@ 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", + apia2a.RuntimeAuthorizationHeader: apia2a.RuntimeAuthorizationHeader, + "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 { diff --git a/go/core/internal/grpcserver/interceptors_test.go b/go/core/internal/grpcserver/interceptors_test.go index fe4698e061..7d938de145 100644 --- a/go/core/internal/grpcserver/interceptors_test.go +++ b/go/core/internal/grpcserver/interceptors_test.go @@ -18,7 +18,6 @@ import ( 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/service/serviceerrors" - "github.com/kagent-dev/kagent/go/core/internal/service/taskstore" pkgauth "github.com/kagent-dev/kagent/go/core/pkg/auth" ) @@ -218,10 +217,10 @@ func TestA2AShareAuthorizationIsDelegatedToGateway(t *testing.T) { } } -func TestInsecureRuntimeIdentityDoesNotAuthorizePublicAPI(t *testing.T) { +func TestRuntimeIdentityDoesNotAuthorizePublicAPI(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")) + ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs(apia2a.RuntimeAuthorizationHeader, "Bearer signed-actor-token")) public := &testAuthenticator{err: errors.New("public credentials required")} for _, method := range []string{runtimeMethod, readMethod} { _, err := authenticate(ctx, method, public, nil, nil, policies) @@ -229,11 +228,12 @@ func TestInsecureRuntimeIdentityDoesNotAuthorizePublicAPI(t *testing.T) { t.Fatalf("default %s: %v", method, err) } } - _, err := authenticate(ctx, readMethod, public, &taskstore.Authenticator{}, nil, policies) + runtime := &testAuthenticator{session: &testSession{principal: pkgauth.Principal{Agent: pkgauth.Agent{ID: "actor"}}}} + _, err := authenticate(ctx, readMethod, public, runtime, 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) + _, err = authenticate(ctx, runtimeMethod, public, runtime, nil, policies) if err != nil { t.Fatalf("explicit runtime test mode rejected identity: %v", err) } diff --git a/go/core/internal/grpcserver/task_store_test.go b/go/core/internal/grpcserver/task_store_test.go index 7ae3ad4cdb..c8bfc35aed 100644 --- a/go/core/internal/grpcserver/task_store_test.go +++ b/go/core/internal/grpcserver/task_store_test.go @@ -44,6 +44,7 @@ import ( "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" 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" @@ -128,9 +129,13 @@ 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")) server, err := New(Config{ Listener: listener, SystemService: testSystemService(), - Authenticator: &authimpl.InsecureAuthenticator{}, RuntimeAuthenticator: &taskstore.Authenticator{}, + Authenticator: &authimpl.InsecureAuthenticator{}, RuntimeAuthenticator: taskstore.NewAuthenticator(verifier), TaskStoreService: tasks, }) require.NoError(t, err) @@ -156,7 +161,10 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { 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")) + 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.Unauthenticated, status.Code(err)) + authenticated := metadata.NewOutgoingContext(t.Context(), metadata.Pairs(apia2a.RuntimeAuthorizationHeader, runtimeToken)) _, 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) @@ -171,7 +179,8 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { {"wrong atespace", "another-team", "actor-uid", codes.PermissionDenied}, } { 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(apia2a.RuntimeAuthorizationHeader, "Bearer "+token)) _, err = private.GetTask(badIdentity, read) require.Equal(t, test.want, status.Code(err)) }) @@ -181,9 +190,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(apia2a.RuntimeAuthorizationHeader)) + md = md.Copy() + md.Set(apia2a.RuntimeAuthorizationHeader, 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 +603,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(apia2a.RuntimeAuthorizationHeader) != "Bearer placeholder" { + http.Error(w, "runtime placeholder required", http.StatusUnauthorized) + return + } + r.Header.Set(apia2a.RuntimeAuthorizationHeader, 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/service/session/quiescence_wake_test.go b/go/core/internal/service/session/quiescence_wake_test.go index 5312f3dbd8..9470889a03 100644 --- a/go/core/internal/service/session/quiescence_wake_test.go +++ b/go/core/internal/service/session/quiescence_wake_test.go @@ -18,6 +18,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/jwttest" "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(apia2a.RuntimeAuthorizationHeader, "Bearer "+i.Token(t, i.Claims("team-a", substrate.ActorName(session.Id), "actor-uid"))) + identity, err := taskstore.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/workflow_test.go b/go/core/internal/service/session/workflow_test.go index 495ff97632..14cbe1012a 100644 --- a/go/core/internal/service/session/workflow_test.go +++ b/go/core/internal/service/session/workflow_test.go @@ -405,7 +405,7 @@ func TestActorCreationFailsBeforeIssueWhenEgressPolicyCannotCompile(t *testing.T } 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, "") require.NoError(t, err) require.Equal(t, &ateapipb.ResourceMetadata{Atespace: "team-a", Name: "default"}, policy.Metadata) require.Len(t, policy.Rules, 2) @@ -413,20 +413,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, "") 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, "") 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, "") 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, "") require.ErrorContains(t, err, "both HTTP and HTTPS on the same port") } @@ -435,9 +435,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, "") 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, "") 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/taskstore/authority.go b/go/core/internal/service/taskstore/authority.go index 0ee97bf712..4b709498cb 100644 --- a/go/core/internal/service/taskstore/authority.go +++ b/go/core/internal/service/taskstore/authority.go @@ -10,33 +10,48 @@ import ( "github.com/google/uuid" apia2a "github.com/kagent-dev/kagent/go/api/a2a" + "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" ) -// 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{} +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 (*Authenticator) Authenticate(_ context.Context, headers http.Header, _ url.Values) (auth.Session, error) { - values := headers.Values(apia2a.InsecureRuntimeIdentityHeader) +func (a *Authenticator) Authenticate(ctx context.Context, headers http.Header, _ url.Values) (auth.Session, error) { + values := headers.Values(apia2a.RuntimeAuthorizationHeader) if len(values) != 1 { - return nil, fmt.Errorf("one runtime identity header is required") + 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") } - parts := strings.Split(values[0], "/") - if len(parts) != 3 { - return nil, fmt.Errorf("runtime identity must be atespace/actor-name/actor-UID") + identity, err := a.verifier.Verify(ctx, token) + if err != nil { + logging.FromContext(ctx).DebugContext(ctx, "runtime actor authentication failed", "error", err) + return nil, err } - id, ok := strings.CutPrefix(parts[1], "session-") - if !ok || parts[0] == "" || parts[2] == "" { + 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: parts[0], actorUID: parts[2]}, nil + return runtimeSession{sessionID: id, atespace: identity.Atespace, actorUID: identity.ActorUID}, nil } func (*Authenticator) UpstreamAuth(*http.Request, auth.Session, auth.Principal) error { diff --git a/go/core/internal/service/taskstore/authority_test.go b/go/core/internal/service/taskstore/authority_test.go index 84abd8f750..c8c2ba1d50 100644 --- a/go/core/internal/service/taskstore/authority_test.go +++ b/go/core/internal/service/taskstore/authority_test.go @@ -8,11 +8,17 @@ import ( "github.com/stretchr/testify/require" apia2a "github.com/kagent-dev/kagent/go/api/a2a" + "github.com/kagent-dev/kagent/go/core/internal/jwttest" + "github.com/kagent-dev/kagent/go/core/internal/substrate" ) -func TestInsecureRuntimeIdentity(t *testing.T) { +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 := "team-a/session-" + id + "/actor-uid" + valid := "Bearer " + i.Token(t, i.Claims("team-a", "session-"+id, "actor-uid")) for _, test := range []struct { name string values []string @@ -21,17 +27,20 @@ func TestInsecureRuntimeIdentity(t *testing.T) { {"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}, + {"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(apia2a.InsecureRuntimeIdentityHeader, value) + headers.Add(apia2a.RuntimeAuthorizationHeader, value) } - session, err := (&Authenticator{}).Authenticate(t.Context(), headers, nil) + session, err := a.Authenticate(t.Context(), headers, nil) if !test.valid { require.Error(t, err) return diff --git a/go/core/internal/substrate/actor_jwt.go b/go/core/internal/substrate/actor_jwt.go new file mode 100644 index 0000000000..0133b256b5 --- /dev/null +++ b/go/core/internal/substrate/actor_jwt.go @@ -0,0 +1,147 @@ +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" + +// 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()) + 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..929ee61005 --- /dev/null +++ b/go/core/internal/substrate/actor_jwt_test.go @@ -0,0 +1,90 @@ +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) + } +} 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/policy.go b/go/core/internal/substrate/policy.go index 396d85f526..0ee723ffd8 100644 --- a/go/core/internal/substrate/policy.go +++ b/go/core/internal/substrate/policy.go @@ -11,19 +11,31 @@ import ( "go.ate.dev/substrate/pkg/proto/ateapipb" + apia2a "github.com/kagent-dev/kagent/go/api/a2a" "github.com/kagent-dev/kagent/go/core/internal/egress" "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) // 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 } + var runtimeOrigin string + if controlPlaneURL != "" { + 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 == apia2a.RuntimeAuthorizationHeader { + return nil, fmt.Errorf("runtime authorization header is reserved for Actor JWTs") + } if effects[binding.Origin] == nil { effects[binding.Origin] = &ateapipb.HttpRuleEffects{} } @@ -31,6 +43,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: apia2a.RuntimeAuthorizationHeader, 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 +66,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 +84,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..44582bde50 100644 --- a/go/core/internal/substrate/policy_test.go +++ b/go/core/internal/substrate/policy_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "go.ate.dev/substrate/pkg/proto/ateapipb" + apia2a "github.com/kagent-dev/kagent/go/api/a2a" "github.com/kagent-dev/kagent/go/core/internal/egress" "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) @@ -16,7 +17,7 @@ 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"}, - }) + }, "") require.NoError(t, err) require.Len(t, policy.Rules, 4) require.Empty(t, policy.Rules[0].GetHttp().GetEffects().GetReplaceHeaders()) @@ -91,7 +92,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, "http://kagent-controller.kagent.svc:8083", test.compilers) if test.wantErr != "" { require.ErrorContains(t, err, test.wantErr) require.Nil(t, policy) @@ -105,7 +106,31 @@ 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}}, "http://kagent-controller.kagent.svc:8083", 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 = "http://kagent-controller.kagent.svc:8083" + policy, err := ActorEgressPolicy("team", []string{controlPlane, "https://models.example"}, []egress.Credential{ + {Origin: controlPlane, Header: "authorization", URI: "ate-secret://k8s.io/default/team/auth/token"}, + }, "http://KAGENT-CONTROLLER.kagent.svc.:8083") + require.NoError(t, err) + headers := policy.Rules[0].GetHttp().GetEffects().GetReplaceHeaders() + require.Len(t, headers, 2) + require.Equal(t, "authorization", headers[0].Header, "public credentials coexist with runtime authentication") + require.Equal(t, apia2a.RuntimeAuthorizationHeader, headers[1].Header) + require.Equal(t, "Bearer ", headers[1].Prefix) + require.Empty(t, headers[1].CredentialUri) + require.Equal(t, []string{runtimeJWTAudience}, headers[1].ActorJwt.Audiences) + require.EqualValues(t, 300, headers[1].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: "X-Kagent-Runtime-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..498e3d544a 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -153,6 +153,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 +285,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 +295,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 @@ -420,7 +427,7 @@ func Run(ctx context.Context, input *configv1.ControllerConfig, opts Options) er BindAddress: cfg.GetServer().GetBindAddress(), Reflection: cfg.GetServer().GetGrpcReflection(), Authenticator: authenticator, - RuntimeAuthenticator: &taskstore.Authenticator{}, + RuntimeAuthenticator: taskstore.NewAuthenticator(actorVerifier), ShareStore: store, ModelService: models, ToolService: tools, 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..48b3b24da6 100644 --- a/go/core/test/e2e/README.md +++ b/go/core/test/e2e/README.md @@ -273,6 +273,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/runtime_auth_test.go b/go/core/test/e2e/runtime_auth_test.go new file mode 100644 index 0000000000..b082cb599e --- /dev/null +++ b/go/core/test/e2e/runtime_auth_test.go @@ -0,0 +1,48 @@ +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" + + apia2a "github.com/kagent-dev/kagent/go/api/a2a" + 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") + private := runtimev1.NewTaskStoreServiceClient(newControllerConn(t, target)) + request := &runtimev1.TaskStoreServiceGetTaskRequest{SessionId: fixture.sessionID, TaskId: string(task.ID)} + 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{apia2a.RuntimeAuthorizationHeader, "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.Unauthenticated, status.Code(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/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/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/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..cfaa4e6393 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 = (("x-kagent-runtime-authorization", "Bearer placeholder"),) for attempt in range(4): try: return await method( diff --git a/python/packages/kagent-core/tests/test_task_store.py b/python/packages/kagent-core/tests/test_task_store.py index 1a09078579..682f2eb8df 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())["x-kagent-runtime-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) From cdcfe556701dcfa072e9d686e19d525163058cfb Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Mon, 19 Oct 2026 16:06:57 +0000 Subject: [PATCH 02/11] fix(auth): require control-plane origin for actor policies Signed-off-by: Eitan Yarmush --- .../internal/service/session/workflow_test.go | 16 +++++++----- go/core/internal/substrate/egress_test.go | 10 +++---- go/core/internal/substrate/policy.go | 11 +++----- go/core/internal/substrate/policy_test.go | 26 ++++++++++++++++--- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/go/core/internal/service/session/workflow_test.go b/go/core/internal/service/session/workflow_test.go index 14cbe1012a..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/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 0ee723ffd8..7bd2cbedb5 100644 --- a/go/core/internal/substrate/policy.go +++ b/go/core/internal/substrate/policy.go @@ -23,14 +23,11 @@ func ActorEgressPolicy(atespace string, destinations []string, credentials []egr if err != nil { return nil, err } - var runtimeOrigin string - if controlPlaneURL != "" { - u, err := egress.ParseOrigin(controlPlaneURL) - if err != nil { - return nil, fmt.Errorf("invalid runtime control-plane origin: %w", err) - } - runtimeOrigin = u.String() + 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 == apia2a.RuntimeAuthorizationHeader { diff --git a/go/core/internal/substrate/policy_test.go b/go/core/internal/substrate/policy_test.go index 44582bde50..1a3c43304a 100644 --- a/go/core/internal/substrate/policy_test.go +++ b/go/core/internal/substrate/policy_test.go @@ -13,11 +13,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()) @@ -92,7 +110,7 @@ func TestCompileActorEgressPolicyExtensions(t *testing.T) { })}}, } { t.Run(test.name, func(t *testing.T) { - policy, err := compileActorEgressPolicy(t.Context(), "team", access, "http://kagent-controller.kagent.svc:8083", 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) @@ -106,13 +124,13 @@ 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}}, "http://kagent-controller.kagent.svc:8083", 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 = "http://kagent-controller.kagent.svc:8083" + const controlPlane = testControlPlaneURL policy, err := ActorEgressPolicy("team", []string{controlPlane, "https://models.example"}, []egress.Credential{ {Origin: controlPlane, Header: "authorization", URI: "ate-secret://k8s.io/default/team/auth/token"}, }, "http://KAGENT-CONTROLLER.kagent.svc.:8083") From 5b7c46cafb4448c61341eb6e0643f7d6e078767e Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Mon, 19 Oct 2026 16:27:30 +0000 Subject: [PATCH 03/11] fix(auth): restrict actor JWTs to authenticated runtime APIs Signed-off-by: Eitan Yarmush --- docs/architecture/a2a-gateway.md | 29 +++-- .../architecture/oidc-proxy-authentication.md | 7 +- docs/architecture/process-configuration.md | 9 +- go/adk/pkg/controllerclient/client.go | 18 +-- go/adk/pkg/controllerclient/client_test.go | 13 ++ go/adk/pkg/taskstore/store.go | 3 +- go/api/a2a/runtime.go | 4 - go/api/gen/kagent/config/v1/config.pb.go | 21 ++- go/core/internal/grpcserver/interceptors.go | 10 +- .../internal/grpcserver/interceptors_test.go | 3 +- go/core/internal/grpcserver/memory.go | 8 +- go/core/internal/grpcserver/policy.go | 30 +++-- go/core/internal/grpcserver/policy_test.go | 24 ++++ .../internal/grpcserver/task_store_test.go | 36 ++++-- .../authority.go => runtimeauth/auth.go} | 36 +++++- .../auth_test.go} | 5 +- go/core/internal/runtimeauth/users.go | 48 +++++++ go/core/internal/runtimeauth/users_test.go | 56 ++++++++ go/core/internal/service/memory/service.go | 45 ++++++- .../internal/service/memory/service_test.go | 121 ++++++++++++++++-- .../service/session/quiescence_wake_test.go | 6 +- go/core/internal/service/taskstore/service.go | 12 +- .../service/taskstore/service_test.go | 19 ++- go/core/internal/substrate/policy.go | 5 +- go/core/internal/substrate/policy_test.go | 20 ++- go/core/pkg/app/app.go | 19 ++- go/core/pkg/auth/auth.go | 11 +- go/core/pkg/config/defaults.go | 4 +- go/core/pkg/config/helm_test.go | 2 +- go/core/test/e2e/README.md | 10 +- go/core/test/e2e/runtime_auth_test.go | 45 ++++++- helm/kagent/templates/_helpers.tpl | 5 + .../templates/controller-configmap.yaml | 5 +- .../templates/controller-deployment.yaml | 3 + helm/kagent/templates/controller-service.yaml | 4 + .../tests/controller-configmap_test.yaml | 6 +- .../tests/controller-deployment_test.yaml | 2 +- .../kagent/tests/controller-service_test.yaml | 10 ++ helm/kagent/values.yaml | 2 + proto/kagent/config/v1/config.proto | 2 + .../kagent-core/src/kagent/core/_grpc.py | 8 +- .../src/kagent/core/a2a/_task_store.py | 2 +- .../kagent-core/tests/test_controller_urls.py | 9 +- .../kagent-core/tests/test_task_store.py | 2 +- 44 files changed, 588 insertions(+), 151 deletions(-) rename go/core/internal/{service/taskstore/authority.go => runtimeauth/auth.go} (59%) rename go/core/internal/{service/taskstore/authority_test.go => runtimeauth/auth_test.go} (92%) create mode 100644 go/core/internal/runtimeauth/users.go create mode 100644 go/core/internal/runtimeauth/users_test.go diff --git a/docs/architecture/a2a-gateway.md b/docs/architecture/a2a-gateway.md index 6133121118..d471d9ec95 100644 --- a/docs/architecture/a2a-gateway.md +++ b/docs/architecture/a2a-gateway.md @@ -188,12 +188,20 @@ execution. Other harnesses retain their existing policy for parked tasks. All ha ## Runtime authority -TaskStore requires a Substrate-issued Actor JWT. Go and Python send -`x-kagent-runtime-authorization: Bearer placeholder`; the control-plane origin's -egress rule replaces it with a five-minute JWT bound to the `kagent-runtime` -audience. The separate header preserves public credentials used on the same -origin. Other destinations receive no runtime JWT, and this rule does not grant -network access to sandboxes that cannot already reach the control plane. +Actor calls use the controller's runtime listener (`server.runtimeBindAddress`, +`:8084` by default). Every permitted RPC requires 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. + +The runtime method allowlist contains TaskStore and Memory only. User APIs, +A2A, MCP, reflection, and unconfigured methods are not available through that +listener. Actors do not inherit their session owner's general permissions. +The user listener retains configured authentication and rejects Actor JWTs as +user credentials. Insecure user authentication remains insecure by design; +protect access to that listener according to the selected user authentication mode. The controller trusts `substrate.actorJwtIssuer` (default `https://idp.ate-system.svc`). It fetches OIDC discovery and JWKS over TLS using @@ -203,14 +211,17 @@ 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. Public and share credentials cannot -grant TaskStore access. The unsigned identity header is not accepted. +matches its atespace and recorded Actor UID. Memory derives its agent namespace +and user from that persisted session binding, ignoring caller-supplied identity +fields. Public and share credentials cannot grant runtime access. 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. +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..3922baa6d1 100644 --- a/docs/architecture/oidc-proxy-authentication.md +++ b/docs/architecture/oidc-proxy-authentication.md @@ -102,9 +102,10 @@ 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 separate +runtime listener, which always verifies Actor JWTs and allows only TaskStore and +Memory. The user listener rejects Actor JWTs; 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/docs/architecture/process-configuration.md b/docs/architecture/process-configuration.md index d18f84f2a3..20206594eb 100644 --- a/docs/architecture/process-configuration.md +++ b/docs/architecture/process-configuration.md @@ -69,6 +69,7 @@ kubernetes: leaderElection: true server: bindAddress: :8083 + runtimeBindAddress: :8084 auth: mode: insecure database: @@ -88,8 +89,8 @@ push: sessions: idleTtl: 604800s runtimeDefaults: - controlPlaneUrl: http://kagent-controller.kagent.svc:8083 - gatewayUrl: http://kagent-controller.kagent.svc:8083 + controlPlaneUrl: http://kagent-controller.kagent.svc:8084 + gatewayUrl: http://kagent-controller.kagent.svc:8084 logging: level: info ``` @@ -168,8 +169,8 @@ compiles `runtimeDefaults` into these payloads independently of its own settings ```json { "runtime": { - "control_plane_url": "http://kagent-controller.kagent.svc:8083", - "gateway_url": "http://kagent-controller.kagent.svc:8083", + "control_plane_url": "http://kagent-controller.kagent.svc:8084", + "gateway_url": "http://kagent-controller.kagent.svc:8084", "name": "researcher", "namespace": "kagent", "log_level": "info", 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/taskstore/store.go b/go/adk/pkg/taskstore/store.go index 2a862e2d80..4fa9610821 100644 --- a/go/adk/pkg/taskstore/store.go +++ b/go/adk/pkg/taskstore/store.go @@ -18,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" ) @@ -58,7 +57,7 @@ func (s *Store) callContext(ctx context.Context) (context.Context, context.Cance md.Delete("x-share-token") // 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(apia2a.RuntimeAuthorizationHeader, "Bearer placeholder") + md.Set("authorization", "Bearer placeholder") return metadata.NewOutgoingContext(ctx, md), cancel } diff --git a/go/api/a2a/runtime.go b/go/api/a2a/runtime.go index cf343526d1..29b667cb9b 100644 --- a/go/api/a2a/runtime.go +++ b/go/api/a2a/runtime.go @@ -4,9 +4,5 @@ package a2a // routing metadata, not an authentication credential. const RuntimeIdentityPath = "/run/kagent/identity/name" -// RuntimeAuthorizationHeader carries the Actor JWT injected by Substrate egress. -// Runtime clients send a placeholder; the gateway replaces it with a signed token. -const RuntimeAuthorizationHeader = "x-kagent-runtime-authorization" - // 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 fa0cfd3527..08d44b2b29 100644 --- a/go/api/gen/kagent/config/v1/config.pb.go +++ b/go/api/gen/kagent/config/v1/config.pb.go @@ -719,8 +719,10 @@ type ControllerServerConfig struct { BindAddress *string `protobuf:"bytes,1,opt,name=bind_address,json=bindAddress,proto3,oneof" json:"bind_address,omitempty"` GrpcReflection bool `protobuf:"varint,2,opt,name=grpc_reflection,json=grpcReflection,proto3" json:"grpc_reflection,omitempty"` Auth *AuthenticationConfig `protobuf:"bytes,3,opt,name=auth,proto3" json:"auth,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Actor-only listener. Every runtime call requires a Substrate Actor JWT. + RuntimeBindAddress *string `protobuf:"bytes,4,opt,name=runtime_bind_address,json=runtimeBindAddress,proto3,oneof" json:"runtime_bind_address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ControllerServerConfig) Reset() { @@ -774,6 +776,13 @@ func (x *ControllerServerConfig) GetAuth() *AuthenticationConfig { return nil } +func (x *ControllerServerConfig) GetRuntimeBindAddress() string { + if x != nil && x.RuntimeBindAddress != nil { + return *x.RuntimeBindAddress + } + return "" +} + type AuthenticationConfig struct { state protoimpl.MessageState `protogen:"open.v1"` Mode *string `protobuf:"bytes,1,opt,name=mode,proto3,oneof" json:"mode,omitempty"` @@ -2037,12 +2046,14 @@ const file_kagent_config_v1_config_proto_rawDesc = "" + "\x0fleader_election\x18\x03 \x01(\bH\x01R\x0eleaderElection\x88\x01\x01B\f\n" + "\n" + "_namespaceB\x12\n" + - "\x10_leader_election\"\xbf\x01\n" + + "\x10_leader_election\"\x98\x02\n" + "\x16ControllerServerConfig\x12/\n" + "\fbind_address\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x00R\vbindAddress\x88\x01\x01\x12'\n" + "\x0fgrpc_reflection\x18\x02 \x01(\bR\x0egrpcReflection\x12:\n" + - "\x04auth\x18\x03 \x01(\v2&.kagent.config.v1.AuthenticationConfigR\x04authB\x0f\n" + - "\r_bind_address\"|\n" + + "\x04auth\x18\x03 \x01(\v2&.kagent.config.v1.AuthenticationConfigR\x04auth\x12>\n" + + "\x14runtime_bind_address\x18\x04 \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x01R\x12runtimeBindAddress\x88\x01\x01B\x0f\n" + + "\r_bind_addressB\x17\n" + + "\x15_runtime_bind_address\"|\n" + "\x14AuthenticationConfig\x127\n" + "\x04mode\x18\x01 \x01(\tB\x1e\xbaH\x1br\x19R\binsecureR\rtrusted-proxyH\x00R\x04mode\x88\x01\x01\x12\"\n" + "\ruser_id_claim\x18\x02 \x01(\tR\vuserIdClaimB\a\n" + diff --git a/go/core/internal/grpcserver/interceptors.go b/go/core/internal/grpcserver/interceptors.go index e352380bde..dc81a5a797 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,11 +25,10 @@ import ( ) var forwardedMetadataKeys = map[string]string{ - apia2a.RuntimeAuthorizationHeader: apia2a.RuntimeAuthorizationHeader, - "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 { diff --git a/go/core/internal/grpcserver/interceptors_test.go b/go/core/internal/grpcserver/interceptors_test.go index 7d938de145..352d8742b5 100644 --- a/go/core/internal/grpcserver/interceptors_test.go +++ b/go/core/internal/grpcserver/interceptors_test.go @@ -14,7 +14,6 @@ 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" "github.com/kagent-dev/kagent/go/core/internal/database" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" @@ -220,7 +219,7 @@ func TestA2AShareAuthorizationIsDelegatedToGateway(t *testing.T) { func TestRuntimeIdentityDoesNotAuthorizePublicAPI(t *testing.T) { const runtimeMethod = "/test.TaskStore/Get" policies := MethodPolicies{runtimeMethod: pkgauth.AccessRuntime, readMethod: pkgauth.AccessRead} - ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs(apia2a.RuntimeAuthorizationHeader, "Bearer signed-actor-token")) + ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("authorization", "Bearer signed-actor-token")) public := &testAuthenticator{err: errors.New("public credentials required")} for _, method := range []string{runtimeMethod, readMethod} { _, err := authenticate(ctx, method, public, nil, nil, policies) diff --git a/go/core/internal/grpcserver/memory.go b/go/core/internal/grpcserver/memory.go index e4be0b13cb..16891c392f 100644 --- a/go/core/internal/grpcserver/memory.go +++ b/go/core/internal/grpcserver/memory.go @@ -82,8 +82,8 @@ func (s *memoryServer) Search(ctx context.Context, request *runtimev1.MemoryServ return &runtimev1.MemoryServiceSearchResponse{Memories: memories}, nil } -func (s *memoryServer) List(ctx context.Context, request *runtimev1.MemoryServiceListRequest) (*runtimev1.MemoryServiceListResponse, error) { - results, err := s.service.List(ctx, request.GetAgentName(), request.GetUserId()) +func (s *memoryServer) List(ctx context.Context, _ *runtimev1.MemoryServiceListRequest) (*runtimev1.MemoryServiceListResponse, error) { + results, err := s.service.List(ctx) if err != nil { return nil, err } @@ -95,8 +95,8 @@ func (s *memoryServer) List(ctx context.Context, request *runtimev1.MemoryServic return &runtimev1.MemoryServiceListResponse{Memories: memories}, nil } -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 { +func (s *memoryServer) Delete(ctx context.Context, _ *runtimev1.MemoryServiceDeleteRequest) (*runtimev1.MemoryServiceDeleteResponse, error) { + if err := s.service.Delete(ctx); err != nil { return nil, err } return &runtimev1.MemoryServiceDeleteResponse{Status: "deleted"}, nil diff --git a/go/core/internal/grpcserver/policy.go b/go/core/internal/grpcserver/policy.go index 6035316e5a..824788e111 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()) return policies } + +// RuntimeMethodPolicies is the explicit allowlist for actor calls. +// New public or extension methods never become callable by actors implicitly. +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, + runtimev1.MemoryService_List_FullMethodName: auth.AccessRuntime, + runtimev1.MemoryService_Delete_FullMethodName: auth.AccessRuntime, + } +} diff --git a/go/core/internal/grpcserver/policy_test.go b/go/core/internal/grpcserver/policy_test.go index 7d9b9558f1..9a3babe74e 100644 --- a/go/core/internal/grpcserver/policy_test.go +++ b/go/core/internal/grpcserver/policy_test.go @@ -4,11 +4,13 @@ import ( "testing" guestpb "github.com/agent-substrate/env/proto/ateenv/v1alpha" + "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" pkgauth "github.com/kagent-dev/kagent/go/core/pkg/auth" ) @@ -97,3 +99,25 @@ func TestSandboxGuestPoliciesMatchTheirEffect(t *testing.T) { }) } } + +func TestRuntimeEndpointDeniesUserAndUnconfiguredMethods(t *testing.T) { + actor := &testAuthenticator{session: &testSession{principal: pkgauth.Principal{Agent: pkgauth.Agent{ID: "actor"}}}} + for _, method := range []string{ + apiv1.SessionService_CreateSession_FullMethodName, + apiv1.AgentService_DeleteAgent_FullMethodName, + apiv1.SystemService_GetVersion_FullMethodName, + "/a2a.v1.A2AService/SendMessage", + "/extension.Service/Call", + } { + t.Run(method, func(t *testing.T) { + _, err := authenticate(t.Context(), method, actor, actor, nil, RuntimeMethodPolicies()) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + }) + } + for _, method := range []string{runtimev1.TaskStoreService_GetTask_FullMethodName, runtimev1.MemoryService_List_FullMethodName} { + _, err := authenticate(t.Context(), method, actor, nil, nil, RuntimeMethodPolicies()) + require.Equal(t, codes.Unauthenticated, status.Code(err)) + _, err = authenticate(t.Context(), method, nil, actor, nil, RuntimeMethodPolicies()) + require.NoError(t, err) + } +} diff --git a/go/core/internal/grpcserver/task_store_test.go b/go/core/internal/grpcserver/task_store_test.go index c8bfc35aed..cace05b32f 100644 --- a/go/core/internal/grpcserver/task_store_test.go +++ b/go/core/internal/grpcserver/task_store_test.go @@ -37,7 +37,6 @@ 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" @@ -45,6 +44,8 @@ import ( "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" @@ -85,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, @@ -133,10 +144,13 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.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} server, err := New(Config{ Listener: listener, SystemService: testSystemService(), - Authenticator: &authimpl.InsecureAuthenticator{}, RuntimeAuthenticator: taskstore.NewAuthenticator(verifier), + Authenticator: runtimeauth.NewAuthenticator(verifier), RuntimeAuthenticator: runtimeauth.NewAuthenticator(verifier), + MethodPolicies: RuntimeMethodPolicies(), TaskStoreService: tasks, + MemoryService: memoryservice.NewService(memories), }) require.NoError(t, err) serverCtx, stopServer := context.WithCancel(t.Context()) @@ -164,7 +178,11 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { 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.Unauthenticated, status.Code(err)) - authenticated := metadata.NewOutgoingContext(t.Context(), metadata.Pairs(apia2a.RuntimeAuthorizationHeader, runtimeToken)) + authenticated := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("authorization", runtimeToken)) + _, err = controller.MemoryService().List(authenticated, &runtimev1.MemoryServiceListRequest{AgentName: "forged/agent", UserId: "another-user"}) + 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) @@ -175,12 +193,12 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { actorUID string want codes.Code }{ - {"replaced actor", "team-a", "replacement-uid", codes.NotFound}, + {"replaced actor", "team-a", "replacement-uid", codes.PermissionDenied}, {"wrong atespace", "another-team", "actor-uid", codes.PermissionDenied}, } { t.Run(test.name, func(t *testing.T) { token := issuer.Token(t, issuer.Claims(test.atespace, substrate.ActorName(id), test.actorUID)) - badIdentity := metadata.NewOutgoingContext(t.Context(), metadata.Pairs(apia2a.RuntimeAuthorizationHeader, "Bearer "+token)) + badIdentity := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("authorization", "Bearer "+token)) _, err = private.GetTask(badIdentity, read) require.Equal(t, test.want, status.Code(err)) }) @@ -197,9 +215,9 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { 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(apia2a.RuntimeAuthorizationHeader)) + require.Equal(t, []string{"Bearer placeholder"}, md.Get("authorization")) md = md.Copy() - md.Set(apia2a.RuntimeAuthorizationHeader, runtimeToken) + md.Set("authorization", runtimeToken) return invoke(metadata.NewOutgoingContext(ctx, md), method, req, reply, cc, opts...) }), }, @@ -606,11 +624,11 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { 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(apia2a.RuntimeAuthorizationHeader) != "Bearer placeholder" { + if r.Header.Get("authorization") != "Bearer placeholder" { http.Error(w, "runtime placeholder required", http.StatusUnauthorized) return } - r.Header.Set(apia2a.RuntimeAuthorizationHeader, runtimeToken) + r.Header.Set("authorization", runtimeToken) server.server.ServeHTTP(w, r) })} go func() { _ = egress.Serve(listener) }() diff --git a/go/core/internal/service/taskstore/authority.go b/go/core/internal/runtimeauth/auth.go similarity index 59% rename from go/core/internal/service/taskstore/authority.go rename to go/core/internal/runtimeauth/auth.go index 4b709498cb..034e284415 100644 --- a/go/core/internal/service/taskstore/authority.go +++ b/go/core/internal/runtimeauth/auth.go @@ -1,15 +1,20 @@ -package taskstore +// 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" - apia2a "github.com/kagent-dev/kagent/go/api/a2a" + 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" @@ -31,7 +36,7 @@ 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(apia2a.RuntimeAuthorizationHeader) + values := headers.Values("authorization") if len(values) != 1 { return nil, fmt.Errorf("one runtime authorization header is required") } @@ -63,3 +68,28 @@ type runtimeSession struct{ sessionID, atespace, actorUID string } func (s runtimeSession) Principal() auth.Principal { return auth.Principal{Agent: auth.Agent{ID: 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/service/taskstore/authority_test.go b/go/core/internal/runtimeauth/auth_test.go similarity index 92% rename from go/core/internal/service/taskstore/authority_test.go rename to go/core/internal/runtimeauth/auth_test.go index c8c2ba1d50..4f3d9bd5a5 100644 --- a/go/core/internal/service/taskstore/authority_test.go +++ b/go/core/internal/runtimeauth/auth_test.go @@ -1,4 +1,4 @@ -package taskstore +package runtimeauth import ( "net/http" @@ -7,7 +7,6 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" - apia2a "github.com/kagent-dev/kagent/go/api/a2a" "github.com/kagent-dev/kagent/go/core/internal/jwttest" "github.com/kagent-dev/kagent/go/core/internal/substrate" ) @@ -38,7 +37,7 @@ func TestRuntimeJWTAuthentication(t *testing.T) { // 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(apia2a.RuntimeAuthorizationHeader, value) + headers.Add("authorization", value) } session, err := a.Authenticate(t.Context(), headers, nil) if !test.valid { diff --git a/go/core/internal/runtimeauth/users.go b/go/core/internal/runtimeauth/users.go new file mode 100644 index 0000000000..503601ce1e --- /dev/null +++ b/go/core/internal/runtimeauth/users.go @@ -0,0 +1,48 @@ +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 userAuthenticator struct { + auth.AuthProvider + actorIssuer string +} + +var _ auth.AuthProvider = (*userAuthenticator)(nil) + +// Users preserves the configured user authentication mode while preventing +// workload tokens from being interpreted as user credentials, including in +// insecure or trusted-proxy mode. +func Users(provider auth.AuthProvider, actorIssuer string) auth.AuthProvider { + return &userAuthenticator{AuthProvider: provider, actorIssuer: actorIssuer} +} + +func (u *userAuthenticator) Authenticate(ctx context.Context, headers http.Header, query url.Values) (auth.Session, error) { + for _, value := range headers.Values("Authorization") { + scheme, raw, ok := strings.Cut(value, " ") + if !ok || !strings.EqualFold(scheme, "Bearer") { + continue + } + claims := jwt.MapClaims{} + // Unverified claims can only reject a request here; they never grant access. + // 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 && issuer == u.actorIssuer) { + return nil, fmt.Errorf("actor credentials require the runtime endpoint") + } + } + return u.AuthProvider.Authenticate(ctx, headers, query) +} diff --git a/go/core/internal/runtimeauth/users_test.go b/go/core/internal/runtimeauth/users_test.go new file mode 100644 index 0000000000..68c35d9bbd --- /dev/null +++ b/go/core/internal/runtimeauth/users_test.go @@ -0,0 +1,56 @@ +package runtimeauth_test + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + 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" +) + +func TestActorCredentialsNeverAuthenticateAsUsers(t *testing.T) { + issuer := jwttest.New(t) + provider := runtimeauth.Users(&authimpl.InsecureAuthenticator{}, issuer.URL) + for _, claims := range []jwt.MapClaims{ + issuer.Claims("team-a", "session-"+uuid.NewString(), "actor-uid"), + {"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"}}, nil) + require.NoError(t, err) + require.Equal(t, "alice", user.Principal().User.ID, "configured user auth remains in effect") +} + +func TestRuntimeHTTPRequiresActorCredentials(t *testing.T) { + issuer := jwttest.New(t) + verifier, err := substrate.NewActorJWTVerifier(t.Context(), issuer.URL, issuer.CAFile) + require.NoError(t, err) + handler := auth.AuthnMiddleware(runtimeauth.NewAuthenticator(verifier))(http.NotFoundHandler()) + token := "Bearer " + issuer.Token(t, issuer.Claims("team-a", "session-"+uuid.NewString(), "actor-uid")) + for _, path := range []string{"/mcp", "/agents/team-a/agent", "/health"} { + for _, test := range []struct { + token string + status int + }{ + {"", http.StatusUnauthorized}, {"Bearer placeholder", http.StatusUnauthorized}, {token, http.StatusNotFound}, + } { + request := httptest.NewRequest(http.MethodPost, path, nil) + request.Header.Set("Authorization", test.token) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + require.Equal(t, test.status, response.Code) + } + } +} diff --git a/go/core/internal/service/memory/service.go b/go/core/internal/service/memory/service.go index 4f909f73b2..8278ed221e 100644 --- a/go/core/internal/service/memory/service.go +++ b/go/core/internal/service/memory/service.go @@ -9,6 +9,7 @@ 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" ) @@ -20,6 +21,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) @@ -73,6 +75,11 @@ func NewService(store Store, options ...Option) *Service { } func (s *Service) Add(ctx context.Context, input Input) (string, error) { + agentName, userID, err := s.scope(ctx) + if err != nil { + return "", err + } + input.AgentName, input.UserID = agentName, userID if err := validateInput(input, "Missing required fields (agent_name, user_id, vector)"); err != nil { return "", err } @@ -103,8 +110,13 @@ 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) + if err != nil { + return 0, err + } memories := make([]*database.Memory, 0, len(inputs)) for _, input := range inputs { + input.AgentName, input.UserID = agentName, userID if err := validateInput(input, "Missing required fields in batch item"); err != nil { return 0, err } @@ -121,6 +133,11 @@ func (s *Service) AddBatch(ctx context.Context, inputs []Input) (int, error) { } func (s *Service) Search(ctx context.Context, request SearchRequest) ([]SearchResult, error) { + agentName, userID, err := s.scope(ctx) + if err != nil { + return nil, err + } + request.AgentName, request.UserID = agentName, userID if request.AgentName == "" || request.UserID == "" || len(request.Vector) == 0 { return nil, serviceerrors.NewInvalidArgument("Missing required fields (agent_name, user_id, vector)", nil) } @@ -164,7 +181,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) ([]database.Memory, error) { + agentName, userID, err := s.scope(ctx) + if err != nil { + return nil, err + } if agentName == "" || userID == "" { return nil, serviceerrors.NewInvalidArgument("Missing required query parameters (agent_name, user_id)", nil) } @@ -178,7 +199,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) error { + agentName, userID, err := s.scope(ctx) + if err != nil { + return err + } if agentName == "" || userID == "" { return serviceerrors.NewInvalidArgument("Missing required query parameters (agent_name, user_id)", nil) } @@ -191,6 +216,22 @@ func (s *Service) Delete(ctx context.Context, agentName, userID string) error { return nil } +// 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) (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) + } + return session.Agent.Namespace + "/" + session.Agent.Name, session.Creator, nil +} + func (s *Service) toMemory(input Input) (*database.Memory, error) { metadata := input.Metadata if len(metadata) == 0 { diff --git a/go/core/internal/service/memory/service_test.go b/go/core/internal/service/memory/service_test.go index e48423e01c..52dd5efba5 100644 --- a/go/core/internal/service/memory/service_test.go +++ b/go/core/internal/service/memory/service_test.go @@ -3,18 +3,26 @@ 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" + "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 +30,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 +45,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 } @@ -55,8 +67,8 @@ func TestAddDefaultsMetadataAndTTL(t *testing.T) { store := &memoryStore{} service := NewService(store, WithClock(func() time.Time { return now })) - id, err := service.Add(t.Context(), Input{ - AgentName: "agent", + id, err := service.Add(memoryContext(t), Input{ + AgentName: "team-a/agent", UserID: "user", Content: "remember this", Vector: vector(VectorDimension, 0.25), @@ -72,18 +84,18 @@ func TestAddDefaultsMetadataAndTTL(t *testing.T) { func TestAddAndBatchValidation(t *testing.T) { service := NewService(&memoryStore{}) - _, err := service.Add(t.Context(), Input{AgentName: "agent", UserID: "user", Vector: vector(16, 1)}) + _, err := service.Add(memoryContext(t), Input{AgentName: "team-a/agent", UserID: "user", 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{AgentName: "team-a/agent", UserID: "user", 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") } @@ -96,8 +108,8 @@ func TestSearchDefaultsFiltersAndNormalizesMetadata(t *testing.T) { }} service := NewService(store) - results, err := service.Search(t.Context(), SearchRequest{ - AgentName: "agent", + results, err := service.Search(memoryContext(t), SearchRequest{ + AgentName: "team-a/agent", UserID: "user", Vector: vector(VectorDimension, 0.25), MinScore: 0.5, @@ -114,15 +126,15 @@ func TestListDeleteAndStoreErrors(t *testing.T) { store := &memoryStore{listResult: []database.Memory{{ID: "memory-1"}}} service := NewService(store) - 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{AgentName: "team-a/agent", UserID: "user", Vector: vector(VectorDimension, 1)}) assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeInternal)) assert.ErrorContains(t, err, "database unavailable") } @@ -134,3 +146,84 @@ 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 TestActorMemoryScopeIgnoresCallerIdentity(t *testing.T) { + store := &memoryStore{} + service := NewService(store) + ctx := memoryContext(t) + input := Input{AgentName: "other/agent", UserID: "victim", 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)) + require.Equal(t, "team-a/agent", store.deletedAgent) + require.Equal(t, "user", store.deletedUser) + _, err = service.List(ctx) + require.NoError(t, err) + require.Equal(t, "team-a/agent", store.readAgent) + require.Equal(t, "user", store.readUser) + _, err = service.Search(ctx, SearchRequest{AgentName: "other/agent", UserID: "victim", 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) + 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) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + err = service.Delete(ctx) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Empty(t, store.stored) + require.Empty(t, store.deletedAgent) + }) + } +} diff --git a/go/core/internal/service/session/quiescence_wake_test.go b/go/core/internal/service/session/quiescence_wake_test.go index 9470889a03..05c503c0d5 100644 --- a/go/core/internal/service/session/quiescence_wake_test.go +++ b/go/core/internal/service/session/quiescence_wake_test.go @@ -14,11 +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" @@ -75,8 +75,8 @@ func settlementContext(t *testing.T, session *apiv1.Session) context.Context { i := jwttest.New(t) v, err := substrate.NewActorJWTVerifier(t.Context(), i.URL, i.CAFile) require.NoError(t, err) - headers.Set(apia2a.RuntimeAuthorizationHeader, "Bearer "+i.Token(t, i.Claims("team-a", substrate.ActorName(session.Id), "actor-uid"))) - identity, err := taskstore.NewAuthenticator(v).Authenticate(t.Context(), headers, nil) + 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/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/policy.go b/go/core/internal/substrate/policy.go index 7bd2cbedb5..994e81bcc4 100644 --- a/go/core/internal/substrate/policy.go +++ b/go/core/internal/substrate/policy.go @@ -11,7 +11,6 @@ import ( "go.ate.dev/substrate/pkg/proto/ateapipb" - apia2a "github.com/kagent-dev/kagent/go/api/a2a" "github.com/kagent-dev/kagent/go/core/internal/egress" "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) @@ -30,7 +29,7 @@ func ActorEgressPolicy(atespace string, destinations []string, credentials []egr runtimeOrigin := u.String() effects := map[string]*ateapipb.HttpRuleEffects{} for _, binding := range access.Credentials { - if binding.Origin == runtimeOrigin && binding.Header == apia2a.RuntimeAuthorizationHeader { + if binding.Origin == runtimeOrigin && binding.Header == "authorization" { return nil, fmt.Errorf("runtime authorization header is reserved for Actor JWTs") } if effects[binding.Origin] == nil { @@ -45,7 +44,7 @@ func ActorEgressPolicy(atespace string, destinations []string, credentials []egr effects[origin] = &ateapipb.HttpRuleEffects{} } effects[origin].ReplaceHeaders = append(effects[origin].ReplaceHeaders, &ateapipb.CredentialHeader{ - Header: apia2a.RuntimeAuthorizationHeader, Prefix: "Bearer ", + Header: "authorization", Prefix: "Bearer ", ActorJwt: &ateapipb.ActorJwtSource{Audiences: []string{runtimeJWTAudience}, ExpirationSeconds: 300}, }) } diff --git a/go/core/internal/substrate/policy_test.go b/go/core/internal/substrate/policy_test.go index 1a3c43304a..a27d977f4f 100644 --- a/go/core/internal/substrate/policy_test.go +++ b/go/core/internal/substrate/policy_test.go @@ -8,7 +8,6 @@ import ( "github.com/stretchr/testify/require" "go.ate.dev/substrate/pkg/proto/ateapipb" - apia2a "github.com/kagent-dev/kagent/go/api/a2a" "github.com/kagent-dev/kagent/go/core/internal/egress" "github.com/kagent-dev/kagent/go/core/pkg/egresspolicy" ) @@ -131,24 +130,21 @@ func TestCompileActorEgressPolicyWithoutExtensionsLeavesTargetsUnbound(t *testin func TestActorPolicyInjectsRuntimeJWTOnlyAtControlPlane(t *testing.T) { const controlPlane = testControlPlaneURL - policy, err := ActorEgressPolicy("team", []string{controlPlane, "https://models.example"}, []egress.Credential{ - {Origin: controlPlane, Header: "authorization", URI: "ate-secret://k8s.io/default/team/auth/token"}, - }, "http://KAGENT-CONTROLLER.kagent.svc.:8083") + 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, 2) - require.Equal(t, "authorization", headers[0].Header, "public credentials coexist with runtime authentication") - require.Equal(t, apia2a.RuntimeAuthorizationHeader, headers[1].Header) - require.Equal(t, "Bearer ", headers[1].Prefix) - require.Empty(t, headers[1].CredentialUri) - require.Equal(t, []string{runtimeJWTAudience}, headers[1].ActorJwt.Audiences) - require.EqualValues(t, 300, headers[1].ActorJwt.ExpirationSeconds) + 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: "X-Kagent-Runtime-Authorization", URI: "ate-secret://k8s.io/default/team/auth/token"}, + {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 498e3d544a..7fc4126024 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" @@ -325,6 +326,7 @@ func Run(ctx context.Context, input *configv1.ControllerConfig, opts Options) er } authenticator, authorizer := opts.resolve() + authenticator = runtimeauth.Users(authenticator, cfg.GetSubstrate().GetActorJwtIssuer()) resourceNamespace := cfg.GetKubernetes().GetNamespace() models := modelservice.NewService(manager.GetClient(), authorizer, resourceNamespace) tools := toolservice.NewService(manager.GetClient(), store, authorizer, resourceNamespace, mcpClient) @@ -427,7 +429,6 @@ func Run(ctx context.Context, input *configv1.ControllerConfig, opts Options) er BindAddress: cfg.GetServer().GetBindAddress(), Reflection: cfg.GetServer().GetGrpcReflection(), Authenticator: authenticator, - RuntimeAuthenticator: taskstore.NewAuthenticator(actorVerifier), ShareStore: store, ModelService: models, ToolService: tools, @@ -451,10 +452,26 @@ func Run(ctx context.Context, input *configv1.ControllerConfig, opts Options) er return err } + actorAuthenticator := runtimeauth.NewAuthenticator(actorVerifier) + runtimeServer, err := grpcserver.New(grpcserver.Config{ + BindAddress: cfg.GetServer().GetRuntimeBindAddress(), + MethodPolicies: grpcserver.RuntimeMethodPolicies(), + Authenticator: actorAuthenticator, + RuntimeAuthenticator: actorAuthenticator, + SystemService: system, + MemoryService: memory, + TaskStoreService: runtimeTasks, + HTTPHandler: auth.AuthnMiddleware(actorAuthenticator)(http.NotFoundHandler()), + }) + if err != nil { + return err + } + group, ctx := errgroup.WithContext(ctx) group.Go(func() error { return runtime.Start(ctx) }) group.Go(func() error { return manager.Start(ctx) }) group.Go(func() error { return server.Start(ctx) }) + group.Go(func() error { return runtimeServer.Start(ctx) }) return group.Wait() } diff --git a/go/core/pkg/auth/auth.go b/go/core/pkg/auth/auth.go index 1a7ebe38fe..e8d419cad3 100644 --- a/go/core/pkg/auth/auth.go +++ b/go/core/pkg/auth/auth.go @@ -142,19 +142,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 e5b2a7c265..2c17a7c583 100644 --- a/go/core/pkg/config/defaults.go +++ b/go/core/pkg/config/defaults.go @@ -34,7 +34,7 @@ func DefaultCLI(cfg *configv1.CLIConfig) { func DefaultController(cfg *configv1.ControllerConfig) { defaults(cfg, &configv1.ControllerConfig{ Kubernetes: &configv1.KubernetesConfig{Namespace: new("kagent"), LeaderElection: new(true)}, - Server: &configv1.ControllerServerConfig{BindAddress: new(":8083"), Auth: &configv1.AuthenticationConfig{Mode: new("insecure")}}, + Server: &configv1.ControllerServerConfig{BindAddress: new(":8083"), RuntimeBindAddress: new(":8084"), 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"), 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)}, @@ -46,7 +46,7 @@ func DefaultController(cfg *configv1.ControllerConfig) { Metrics: &configv1.MetricsConfig{BindAddress: new("0")}, Logging: &configv1.LoggingConfig{Level: new("info")}, Telemetry: &configv1.TelemetryConfig{}, - RuntimeDefaults: &configv1.RuntimeDefaultsConfig{Cpu: new("1"), Memory: new("1Gi"), ControlPlaneUrl: new("http://kagent-controller.kagent:8083"), GatewayUrl: new("http://kagent-controller.kagent:8083"), Logging: &configv1.LoggingConfig{Level: new("info")}, Telemetry: &configv1.TelemetryConfig{}}, + RuntimeDefaults: &configv1.RuntimeDefaultsConfig{Cpu: new("1"), Memory: new("1Gi"), ControlPlaneUrl: new("http://kagent-controller.kagent:8084"), GatewayUrl: new("http://kagent-controller.kagent:8084"), Logging: &configv1.LoggingConfig{Level: new("info")}, Telemetry: &configv1.TelemetryConfig{}}, }) if cfg.Push.Issuer == nil { cfg.Push.Issuer = new(cfg.Gateway.GetPublicUrl()) diff --git a/go/core/pkg/config/helm_test.go b/go/core/pkg/config/helm_test.go index b08e559953..d1a253ab7c 100644 --- a/go/core/pkg/config/helm_test.go +++ b/go/core/pkg/config/helm_test.go @@ -69,7 +69,7 @@ func TestHelmControllerConfig(t *testing.T) { base, checksum, err := render("{}") require.NoError(t, err) require.Equal(t, "team", base.Kubernetes.GetNamespace()) - require.Equal(t, "http://demo-controller.team.svc.cluster.local:8083", base.RuntimeDefaults.GetControlPlaneUrl()) + require.Equal(t, "http://demo-controller.team.svc.cluster.local:8084", base.RuntimeDefaults.GetControlPlaneUrl()) require.NotEmpty(t, checksum) require.Equal(t, "kagent", base.Database.GetSchema()) require.Equal(t, "extensions", base.Database.GetVectorSchema()) diff --git a/go/core/test/e2e/README.md b/go/core/test/e2e/README.md index 48b3b24da6..d0018a1fb1 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. The chart exposes the actor-only runtime listener on its named +`runtime` service port (8084 by default). The test uses the controller's reachable +host with that service port to verify missing, forged, and uninjected credentials +are rejected for both TaskStore and Memory. It also checks that user APIs are +denied on the runtime listener. Controller and runtime images must match. Add portable tests using this pattern: diff --git a/go/core/test/e2e/runtime_auth_test.go b/go/core/test/e2e/runtime_auth_test.go index b082cb599e..d934ce8042 100644 --- a/go/core/test/e2e/runtime_auth_test.go +++ b/go/core/test/e2e/runtime_auth_test.go @@ -1,16 +1,24 @@ package e2e_test import ( + "context" + "net" + "strconv" "testing" + "time" 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" "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" + corev1 "k8s.io/api/core/v1" + ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" - 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" ) @@ -27,7 +35,9 @@ func TestRuntimeActorJWTAuthentication(t *testing.T) { 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") - private := runtimev1.NewTaskStoreServiceClient(newControllerConn(t, target)) + runtimeConn := runtimeControllerConn(t, target) + private := runtimev1.NewTaskStoreServiceClient(runtimeConn) + memory := runtimev1.NewMemoryServiceClient(runtimeConn) request := &runtimev1.TaskStoreServiceGetTaskRequest{SessionId: fixture.sessionID, TaskId: string(task.ID)} for _, test := range []struct { name string @@ -36,13 +46,42 @@ func TestRuntimeActorJWTAuthentication(t *testing.T) { {"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{apia2a.RuntimeAuthorizationHeader, "Bearer placeholder"}}, + {"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.Unauthenticated, status.Code(err)) + _, err = memory.List(ctx, &runtimev1.MemoryServiceListRequest{AgentName: "forged", UserId: "forged"}) + require.Equal(t, codes.Unauthenticated, status.Code(err)) }) } + _, err = apiv1.NewSessionServiceClient(runtimeConn).ListSessions(t.Context(), &apiv1.ListSessionsRequest{}) + require.Equal(t, codes.PermissionDenied, status.Code(err)) }) } + +func runtimeControllerConn(t *testing.T, target string) *grpc.ClientConn { + t.Helper() + services := &corev1.ServiceList{} + require.NoError(t, interactionKubeClient(t).List(t.Context(), services, ctrlclient.InNamespace("kagent"), ctrlclient.MatchingLabels{"app.kubernetes.io/component": "controller"})) + port := int32(0) + for _, service := range services.Items { + for _, candidate := range service.Spec.Ports { + if candidate.Name == "runtime" { + port = candidate.Port + } + } + } + require.NotZero(t, port, "controller must expose its actor-only runtime service port") + host, _, err := net.SplitHostPort(target) + require.NoError(t, err) + conn, err := grpc.NewClient(net.JoinHostPort(host, strconv.Itoa(int(port))), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, conn.Close()) }) + ctx, cancel := context.WithTimeout(t.Context(), time.Minute) + defer cancel() + _, err = runtimev1.NewMemoryServiceClient(conn).List(ctx, &runtimev1.MemoryServiceListRequest{}, grpc.WaitForReady(true)) + require.Equal(t, codes.Unauthenticated, status.Code(err), "runtime listener must be reachable and require authentication") + return conn +} diff --git a/helm/kagent/templates/_helpers.tpl b/helm/kagent/templates/_helpers.tpl index 8774839610..e76457dd19 100644 --- a/helm/kagent/templates/_helpers.tpl +++ b/helm/kagent/templates/_helpers.tpl @@ -439,3 +439,8 @@ tag wins over the component tag. {{- define "kagent.database.skipMigrations" -}} {{- if dig "database" "skipMigrations" false (.Values.config | default dict) -}}true{{- end -}} {{- end -}} + +{{/* Actor-only controller endpoint, separate from configured user authentication. */}} +{{- define "kagent.controllerRuntimeAuthority" -}} +{{- printf "%s-controller.%s.svc.%s:%d" (include "kagent.fullname" .) (include "kagent.namespace" .) (.Values.global.clusterDomain | default "cluster.local") (.Values.controller.service.ports.runtimePort | int) -}} +{{- end -}} diff --git a/helm/kagent/templates/controller-configmap.yaml b/helm/kagent/templates/controller-configmap.yaml index 822436ac66..665bda8e0a 100644 --- a/helm/kagent/templates/controller-configmap.yaml +++ b/helm/kagent/templates/controller-configmap.yaml @@ -1,5 +1,6 @@ {{- $namespace := include "kagent.namespace" . -}} {{- $apiURL := printf "http://%s" (include "kagent.controllerServiceAuthority" .) -}} +{{- $runtimeURL := printf "http://%s" (include "kagent.controllerRuntimeAuthority" .) -}} {{- $watch := list -}} {{- with include "kagent.watchNamespaces" . -}}{{- $watch = splitList "," . -}}{{- end -}} {{- $database := dict "readWrite" (dict "urlFile" "/etc/kagent/secrets/database-readwrite-url") -}} @@ -8,11 +9,11 @@ {{- end -}} {{- $base := dict "kubernetes" (dict "namespace" $namespace "watchNamespaces" $watch) - "server" (dict "bindAddress" (printf ":%v" .Values.controller.service.ports.targetPort)) + "server" (dict "bindAddress" (printf ":%v" .Values.controller.service.ports.targetPort) "runtimeBindAddress" (printf ":%v" .Values.controller.service.ports.runtimeTargetPort)) "database" $database "gateway" (dict "publicUrl" $apiURL) "push" (dict) - "runtimeDefaults" (dict "controlPlaneUrl" $apiURL "gatewayUrl" $apiURL) + "runtimeDefaults" (dict "controlPlaneUrl" $runtimeURL "gatewayUrl" $runtimeURL) -}} {{- $config := mergeOverwrite $base (deepCopy .Values.config) -}} {{- if and $config.sandboxes $config.sandboxes.guestImage -}} diff --git a/helm/kagent/templates/controller-deployment.yaml b/helm/kagent/templates/controller-deployment.yaml index 824f44efcd..f1ba81026f 100644 --- a/helm/kagent/templates/controller-deployment.yaml +++ b/helm/kagent/templates/controller-deployment.yaml @@ -161,6 +161,9 @@ spec: - name: http containerPort: {{ .Values.controller.service.ports.targetPort }} protocol: TCP + - name: runtime + containerPort: {{ .Values.controller.service.ports.runtimeTargetPort }} + protocol: TCP {{- if include "kagent.controller.metricsEnabled" . }} - name: metrics containerPort: {{ include "kagent.controller.metricsPort" . | int }} diff --git a/helm/kagent/templates/controller-service.yaml b/helm/kagent/templates/controller-service.yaml index a719f3b5a3..8e1dca585a 100644 --- a/helm/kagent/templates/controller-service.yaml +++ b/helm/kagent/templates/controller-service.yaml @@ -16,5 +16,9 @@ spec: targetPort: {{ .Values.controller.service.ports.targetPort }} protocol: TCP name: controller + - port: {{ .Values.controller.service.ports.runtimePort }} + targetPort: {{ .Values.controller.service.ports.runtimeTargetPort }} + protocol: TCP + name: runtime selector: {{- include "kagent.controller.selectorLabels" . | nindent 4 }} diff --git a/helm/kagent/tests/controller-configmap_test.yaml b/helm/kagent/tests/controller-configmap_test.yaml index 15a6c077a6..c2237ac7ce 100644 --- a/helm/kagent/tests/controller-configmap_test.yaml +++ b/helm/kagent/tests/controller-configmap_test.yaml @@ -36,10 +36,10 @@ tests: asserts: - matchRegex: path: data["controller.yaml"] - pattern: 'controlPlaneUrl: http://RELEASE-NAME-controller.NAMESPACE.svc.cluster.local:8083' + pattern: 'controlPlaneUrl: http://RELEASE-NAME-controller.NAMESPACE.svc.cluster.local:8084' - matchRegex: path: data["controller.yaml"] - pattern: 'gatewayUrl: http://RELEASE-NAME-controller.NAMESPACE.svc.cluster.local:8083' + pattern: 'gatewayUrl: http://RELEASE-NAME-controller.NAMESPACE.svc.cluster.local:8084' - it: supports a custom Kubernetes DNS domain template: controller-configmap.yaml @@ -48,7 +48,7 @@ tests: asserts: - matchRegex: path: data["controller.yaml"] - pattern: 'controlPlaneUrl: http://RELEASE-NAME-controller.NAMESPACE.svc.cluster.example:8083' + pattern: 'controlPlaneUrl: http://RELEASE-NAME-controller.NAMESPACE.svc.cluster.example:8084' - it: mounts one config document and secret files template: controller-deployment.yaml diff --git a/helm/kagent/tests/controller-deployment_test.yaml b/helm/kagent/tests/controller-deployment_test.yaml index da6802fb7f..67d009b77e 100644 --- a/helm/kagent/tests/controller-deployment_test.yaml +++ b/helm/kagent/tests/controller-deployment_test.yaml @@ -410,7 +410,7 @@ tests: asserts: - lengthEqual: path: spec.template.spec.containers[0].ports - count: 1 + count: 2 - notContains: path: spec.template.spec.containers[0].ports content: diff --git a/helm/kagent/tests/controller-service_test.yaml b/helm/kagent/tests/controller-service_test.yaml index b3ab1e7315..9945697924 100644 --- a/helm/kagent/tests/controller-service_test.yaml +++ b/helm/kagent/tests/controller-service_test.yaml @@ -83,3 +83,13 @@ tests: value: PrivateDNSName: kagent service.beta.kubernetes.io/aws-load-balancer-type: nlb-ip + + - it: should expose the actor-only runtime endpoint + asserts: + - equal: + path: spec.ports[1] + value: + name: runtime + port: 8084 + targetPort: 8084 + protocol: TCP diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 063fd80697..30f5f9053e 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -301,6 +301,8 @@ controller: ports: port: 8083 targetPort: 8083 + runtimePort: 8084 + runtimeTargetPort: 8084 annotations: {} # Kubernetes packaging for the controller's Prometheus listener. Its address # and TLS mode come from config.metrics. diff --git a/proto/kagent/config/v1/config.proto b/proto/kagent/config/v1/config.proto index c505883b57..592ff8b779 100644 --- a/proto/kagent/config/v1/config.proto +++ b/proto/kagent/config/v1/config.proto @@ -134,6 +134,8 @@ message ControllerServerConfig { optional string bind_address = 1 [(buf.validate.field).string.min_len = 1]; bool grpc_reflection = 2; AuthenticationConfig auth = 3; + // Actor-only listener. Every runtime call requires a Substrate Actor JWT. + optional string runtime_bind_address = 4 [(buf.validate.field).string.min_len = 1]; } message AuthenticationConfig { 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 cfaa4e6393..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 @@ -46,7 +46,7 @@ async def _session_id(self) -> str: async def _call(self, method, request): # Substrate replaces the placeholder with the calling Actor's JWT. # Credentials never become part of a runtime snapshot. - metadata = (("x-kagent-runtime-authorization", "Bearer placeholder"),) + 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 682f2eb8df..0b32748971 100644 --- a/python/packages/kagent-core/tests/test_task_store.py +++ b/python/packages/kagent-core/tests/test_task_store.py @@ -62,7 +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-runtime-authorization"] == "Bearer placeholder" + assert dict(context.invocation_metadata())["authorization"] == "Bearer placeholder" payload = request.SerializeToString(deterministic=True) if key in self.receipts: digest, version = self.receipts[key] From 2456f06d4101b2bca3a8cb18869ebfbcafeb167e Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Mon, 19 Oct 2026 20:35:21 +0000 Subject: [PATCH 04/11] fix(auth): preserve user access to memory management Signed-off-by: Eitan Yarmush --- docs/architecture/a2a-gateway.md | 6 +- go/core/internal/grpcserver/memory.go | 8 +- go/core/internal/grpcserver/memory_test.go | 101 ++++++++++++++++++ go/core/internal/grpcserver/policy.go | 2 + go/core/internal/service/memory/service.go | 20 +++- .../internal/service/memory/service_test.go | 33 ++++-- 6 files changed, 155 insertions(+), 15 deletions(-) create mode 100644 go/core/internal/grpcserver/memory_test.go diff --git a/docs/architecture/a2a-gateway.md b/docs/architecture/a2a-gateway.md index d471d9ec95..ff8918138e 100644 --- a/docs/architecture/a2a-gateway.md +++ b/docs/architecture/a2a-gateway.md @@ -213,7 +213,11 @@ 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. Public and share credentials cannot grant runtime access. The unsigned identity header is not accepted. +fields. Users can also list and delete their own memories through the user +listener. These 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. +Public and share credentials cannot grant runtime access. 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 diff --git a/go/core/internal/grpcserver/memory.go b/go/core/internal/grpcserver/memory.go index 16891c392f..9dc04b0b7f 100644 --- a/go/core/internal/grpcserver/memory.go +++ b/go/core/internal/grpcserver/memory.go @@ -82,8 +82,8 @@ func (s *memoryServer) Search(ctx context.Context, request *runtimev1.MemoryServ return &runtimev1.MemoryServiceSearchResponse{Memories: memories}, nil } -func (s *memoryServer) List(ctx context.Context, _ *runtimev1.MemoryServiceListRequest) (*runtimev1.MemoryServiceListResponse, error) { - results, err := s.service.List(ctx) +func (s *memoryServer) List(ctx context.Context, request *runtimev1.MemoryServiceListRequest) (*runtimev1.MemoryServiceListResponse, error) { + results, err := s.service.List(ctx, request.GetAgentName()) if err != nil { return nil, err } @@ -95,8 +95,8 @@ func (s *memoryServer) List(ctx context.Context, _ *runtimev1.MemoryServiceListR return &runtimev1.MemoryServiceListResponse{Memories: memories}, nil } -func (s *memoryServer) Delete(ctx context.Context, _ *runtimev1.MemoryServiceDeleteRequest) (*runtimev1.MemoryServiceDeleteResponse, error) { - if err := s.service.Delete(ctx); err != nil { +func (s *memoryServer) Delete(ctx context.Context, request *runtimev1.MemoryServiceDeleteRequest) (*runtimev1.MemoryServiceDeleteResponse, error) { + if err := s.service.Delete(ctx, request.GetAgentName()); err != nil { return nil, err } return &runtimev1.MemoryServiceDeleteResponse{Status: "deleted"}, 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..bffc18d236 --- /dev/null +++ b/go/core/internal/grpcserver/memory_test.go @@ -0,0 +1,101 @@ +package grpcserver + +import ( + "context" + "encoding/base64" + "net" + "slices" + "testing" + + "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" + + 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/runtimeauth" + memoryservice "github.com/kagent-dev/kagent/go/core/internal/service/memory" +) + +type userMemoryStore struct { + *database.Client + memories []database.Memory +} + +var _ memoryservice.Store = (*userMemoryStore)(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 TestMemoryUsersCanListAndDeleteOnlyTheirOwnData(t *testing.T) { + 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"}, + }} + listener := bufconn.Listen(DefaultMaxMessageSize) + server, err := New(Config{ + Listener: listener, + SystemService: testSystemService(), + Authenticator: runtimeauth.Users(authimpl.NewProxyAuthenticator("sub"), "https://actors.example"), + MemoryService: memoryservice.NewService(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", UserId: "bob"} + deleteRequest := &runtimev1.MemoryServiceDeleteRequest{AgentName: "team/agent", UserId: "bob"} + + for _, claims := range []string{"", `{"iss":"https://actors.example","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)) + } + // The configured proxy authenticates the bearer claims. Neither the request + // user_id nor a conflicting forwarded user header selects 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) +} diff --git a/go/core/internal/grpcserver/policy.go b/go/core/internal/grpcserver/policy.go index 824788e111..55d0dad52a 100644 --- a/go/core/internal/grpcserver/policy.go +++ b/go/core/internal/grpcserver/policy.go @@ -117,6 +117,8 @@ func DefaultMethodPolicies() MethodPolicies { 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 } diff --git a/go/core/internal/service/memory/service.go b/go/core/internal/service/memory/service.go index 8278ed221e..f2f3a7eda1 100644 --- a/go/core/internal/service/memory/service.go +++ b/go/core/internal/service/memory/service.go @@ -11,6 +11,7 @@ import ( "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 ( @@ -181,8 +182,8 @@ func (s *Service) Search(ctx context.Context, request SearchRequest) ([]SearchRe return response, nil } -func (s *Service) List(ctx context.Context) ([]database.Memory, error) { - agentName, userID, err := s.scope(ctx) +func (s *Service) List(ctx context.Context, agentName string) ([]database.Memory, error) { + agentName, userID, err := s.managementScope(ctx, agentName) if err != nil { return nil, err } @@ -199,8 +200,8 @@ func (s *Service) List(ctx context.Context) ([]database.Memory, error) { return memories, nil } -func (s *Service) Delete(ctx context.Context) error { - agentName, userID, err := s.scope(ctx) +func (s *Service) Delete(ctx context.Context, agentName string) error { + agentName, userID, err := s.managementScope(ctx, agentName) if err != nil { return err } @@ -216,6 +217,17 @@ func (s *Service) Delete(ctx context.Context) error { return nil } +// 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, agentName string) (string, string, error) { + if session, ok := auth.AuthSessionFrom(ctx); ok && session != nil { + if userID := session.Principal().User.ID; userID != "" { + return agentName, userID, nil + } + } + return s.scope(ctx) +} + // 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) (string, string, error) { diff --git a/go/core/internal/service/memory/service_test.go b/go/core/internal/service/memory/service_test.go index 52dd5efba5..d069c50e67 100644 --- a/go/core/internal/service/memory/service_test.go +++ b/go/core/internal/service/memory/service_test.go @@ -15,6 +15,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/runtimeauth" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" "github.com/kagent-dev/kagent/go/core/internal/substrate" @@ -126,10 +127,10 @@ func TestListDeleteAndStoreErrors(t *testing.T) { store := &memoryStore{listResult: []database.Memory{{ID: "memory-1"}}} service := NewService(store) - memories, err := service.List(memoryContext(t)) + memories, err := service.List(memoryContext(t), "") require.NoError(t, err) assert.Equal(t, "memory-1", memories[0].ID) - require.NoError(t, service.Delete(memoryContext(t))) + require.NoError(t, service.Delete(memoryContext(t), "")) assert.Equal(t, "team-a/agent", store.deletedAgent) assert.Equal(t, "user", store.deletedUser) @@ -182,10 +183,10 @@ func TestActorMemoryScopeIgnoresCallerIdentity(t *testing.T) { require.Equal(t, "team-a/agent", memory.AgentName) require.Equal(t, "user", memory.UserID) } - require.NoError(t, service.Delete(ctx)) + 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) + _, err = service.List(ctx, "other/agent") require.NoError(t, err) require.Equal(t, "team-a/agent", store.readAgent) require.Equal(t, "user", store.readUser) @@ -218,12 +219,32 @@ func TestMemoryRejectsUnboundActors(t *testing.T) { 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) + _, err = service.List(ctx, "other/agent") require.Equal(t, codes.PermissionDenied, status.Code(err)) - err = service.Delete(ctx) + 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) + 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)) +} From 4f209195d29a309053c3535cc077bcd1dc1d6363 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 20 Oct 2026 10:51:57 +0000 Subject: [PATCH 05/11] fix(auth): tolerate bounded Actor JWT clock skew Signed-off-by: Eitan Yarmush --- docs/architecture/a2a-gateway.md | 7 ++-- go/core/internal/substrate/actor_jwt.go | 8 +++-- go/core/internal/substrate/actor_jwt_test.go | 34 ++++++++++++++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/docs/architecture/a2a-gateway.md b/docs/architecture/a2a-gateway.md index ff8918138e..119c6b040f 100644 --- a/docs/architecture/a2a-gateway.md +++ b/docs/architecture/a2a-gateway.md @@ -205,9 +205,10 @@ protect access to that listener according to the selected user authentication mo 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, expiration and -not-before time, and requires an `actor.ate.dev` identity consistent with the -subject. Worker-for-actor tokens are not accepted. Signing keys refresh every +`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 diff --git a/go/core/internal/substrate/actor_jwt.go b/go/core/internal/substrate/actor_jwt.go index 0133b256b5..de36529027 100644 --- a/go/core/internal/substrate/actor_jwt.go +++ b/go/core/internal/substrate/actor_jwt.go @@ -16,7 +16,10 @@ import ( "github.com/kagent-dev/kagent/go/pkg/logging" ) -const runtimeJWTAudience = "kagent-runtime" +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. @@ -134,7 +137,8 @@ func (v *ActorJWTVerifier) Verify(ctx context.Context, raw string) (ActorIdentit } return publicKey, nil }, jwt.WithValidMethods([]string{"ES256", "RS256", "RS384", "RS512"}), - jwt.WithIssuer(v.issuer), jwt.WithAudience(runtimeJWTAudience), jwt.WithExpirationRequired(), jwt.WithIssuedAt()) + 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) } diff --git a/go/core/internal/substrate/actor_jwt_test.go b/go/core/internal/substrate/actor_jwt_test.go index 929ee61005..ed76b0dfc3 100644 --- a/go/core/internal/substrate/actor_jwt_test.go +++ b/go/core/internal/substrate/actor_jwt_test.go @@ -88,3 +88,37 @@ func TestActorJWTVerifierRequiresTrustedDiscovery(t *testing.T) { 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) + }) + } +} From 2ed70cdd1a6fa02a4d8910699b20ddd381497a31 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 20 Oct 2026 11:04:48 +0000 Subject: [PATCH 06/11] fix(auth): share the controller listener for users and actors Signed-off-by: Eitan Yarmush --- docs/architecture/a2a-gateway.md | 30 ++++--- .../architecture/oidc-proxy-authentication.md | 9 +- docs/architecture/process-configuration.md | 9 +- go/api/gen/kagent/config/v1/config.pb.go | 21 ++--- go/core/internal/grpcserver/interceptors.go | 28 ++++-- .../internal/grpcserver/interceptors_test.go | 3 +- go/core/internal/grpcserver/memory_test.go | 49 +++++++++-- go/core/internal/grpcserver/policy_test.go | 85 +++++++++++++++---- .../internal/grpcserver/task_store_test.go | 10 +-- go/core/internal/runtimeauth/users.go | 11 ++- go/core/internal/runtimeauth/users_test.go | 13 ++- go/core/pkg/app/app.go | 17 +--- go/core/pkg/config/defaults.go | 4 +- go/core/pkg/config/helm_test.go | 2 +- go/core/test/e2e/README.md | 10 +-- go/core/test/e2e/runtime_auth_test.go | 40 +-------- helm/kagent/templates/_helpers.tpl | 5 -- .../templates/controller-configmap.yaml | 5 +- .../templates/controller-deployment.yaml | 3 - helm/kagent/templates/controller-service.yaml | 4 - .../tests/controller-configmap_test.yaml | 6 +- .../tests/controller-deployment_test.yaml | 2 +- .../kagent/tests/controller-service_test.yaml | 12 +-- helm/kagent/values.yaml | 2 - proto/kagent/config/v1/config.proto | 2 - 25 files changed, 203 insertions(+), 179 deletions(-) diff --git a/docs/architecture/a2a-gateway.md b/docs/architecture/a2a-gateway.md index 119c6b040f..4ee5f33297 100644 --- a/docs/architecture/a2a-gateway.md +++ b/docs/architecture/a2a-gateway.md @@ -188,20 +188,26 @@ execution. Other harnesses retain their existing policy for parked tasks. All ha ## Runtime authority -Actor calls use the controller's runtime listener (`server.runtimeBindAddress`, -`:8084` by default). Every permitted RPC requires a Substrate-issued Actor JWT in +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. -The runtime method allowlist contains TaskStore and Memory only. User APIs, -A2A, MCP, reflection, and unconfigured methods are not available through that -listener. Actors do not inherit their session owner's general permissions. -The user listener retains configured authentication and rejects Actor JWTs as -user credentials. Insecure user authentication remains insecure by design; -protect access to that listener according to the selected user authentication mode. +The runtime method allowlist contains TaskStore and Memory only. Actor JWTs +cannot authorize user APIs, A2A, MCP, reflection, or unconfigured methods. Actors +do not inherit their session owner's general permissions. The user authenticator +identifies actor credentials without trusting their claims, then the gRPC +interceptor sends them to the Actor JWT verifier and enforces the allowlist. +Invalid actor credentials never fall back to user authentication. + +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 @@ -214,11 +220,11 @@ 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 through the user -listener. These calls use the requested agent key and the authenticated user ID, +fields. Users can also list and delete their own memories on the same endpoint. +These 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. -Public and share credentials cannot grant runtime access. The unsigned identity -header is not accepted. +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 diff --git a/docs/architecture/oidc-proxy-authentication.md b/docs/architecture/oidc-proxy-authentication.md index 3922baa6d1..db416cde6a 100644 --- a/docs/architecture/oidc-proxy-authentication.md +++ b/docs/architecture/oidc-proxy-authentication.md @@ -102,10 +102,11 @@ 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. Actor calls use the separate -runtime listener, which always verifies Actor JWTs and allows only TaskStore and -Memory. The user listener rejects Actor JWTs; the proxy's agent-header path -does not define authentication for runtime methods. See the +incoming authentication metadata to the configured provider. Actor calls use the +same listener, but the controller routes Actor JWTs to its dedicated verifier and +allows only TaskStore and Memory. 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/docs/architecture/process-configuration.md b/docs/architecture/process-configuration.md index 20206594eb..d18f84f2a3 100644 --- a/docs/architecture/process-configuration.md +++ b/docs/architecture/process-configuration.md @@ -69,7 +69,6 @@ kubernetes: leaderElection: true server: bindAddress: :8083 - runtimeBindAddress: :8084 auth: mode: insecure database: @@ -89,8 +88,8 @@ push: sessions: idleTtl: 604800s runtimeDefaults: - controlPlaneUrl: http://kagent-controller.kagent.svc:8084 - gatewayUrl: http://kagent-controller.kagent.svc:8084 + controlPlaneUrl: http://kagent-controller.kagent.svc:8083 + gatewayUrl: http://kagent-controller.kagent.svc:8083 logging: level: info ``` @@ -169,8 +168,8 @@ compiles `runtimeDefaults` into these payloads independently of its own settings ```json { "runtime": { - "control_plane_url": "http://kagent-controller.kagent.svc:8084", - "gateway_url": "http://kagent-controller.kagent.svc:8084", + "control_plane_url": "http://kagent-controller.kagent.svc:8083", + "gateway_url": "http://kagent-controller.kagent.svc:8083", "name": "researcher", "namespace": "kagent", "log_level": "info", diff --git a/go/api/gen/kagent/config/v1/config.pb.go b/go/api/gen/kagent/config/v1/config.pb.go index 08d44b2b29..fa0cfd3527 100644 --- a/go/api/gen/kagent/config/v1/config.pb.go +++ b/go/api/gen/kagent/config/v1/config.pb.go @@ -719,10 +719,8 @@ type ControllerServerConfig struct { BindAddress *string `protobuf:"bytes,1,opt,name=bind_address,json=bindAddress,proto3,oneof" json:"bind_address,omitempty"` GrpcReflection bool `protobuf:"varint,2,opt,name=grpc_reflection,json=grpcReflection,proto3" json:"grpc_reflection,omitempty"` Auth *AuthenticationConfig `protobuf:"bytes,3,opt,name=auth,proto3" json:"auth,omitempty"` - // Actor-only listener. Every runtime call requires a Substrate Actor JWT. - RuntimeBindAddress *string `protobuf:"bytes,4,opt,name=runtime_bind_address,json=runtimeBindAddress,proto3,oneof" json:"runtime_bind_address,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ControllerServerConfig) Reset() { @@ -776,13 +774,6 @@ func (x *ControllerServerConfig) GetAuth() *AuthenticationConfig { return nil } -func (x *ControllerServerConfig) GetRuntimeBindAddress() string { - if x != nil && x.RuntimeBindAddress != nil { - return *x.RuntimeBindAddress - } - return "" -} - type AuthenticationConfig struct { state protoimpl.MessageState `protogen:"open.v1"` Mode *string `protobuf:"bytes,1,opt,name=mode,proto3,oneof" json:"mode,omitempty"` @@ -2046,14 +2037,12 @@ const file_kagent_config_v1_config_proto_rawDesc = "" + "\x0fleader_election\x18\x03 \x01(\bH\x01R\x0eleaderElection\x88\x01\x01B\f\n" + "\n" + "_namespaceB\x12\n" + - "\x10_leader_election\"\x98\x02\n" + + "\x10_leader_election\"\xbf\x01\n" + "\x16ControllerServerConfig\x12/\n" + "\fbind_address\x18\x01 \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x00R\vbindAddress\x88\x01\x01\x12'\n" + "\x0fgrpc_reflection\x18\x02 \x01(\bR\x0egrpcReflection\x12:\n" + - "\x04auth\x18\x03 \x01(\v2&.kagent.config.v1.AuthenticationConfigR\x04auth\x12>\n" + - "\x14runtime_bind_address\x18\x04 \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x01R\x12runtimeBindAddress\x88\x01\x01B\x0f\n" + - "\r_bind_addressB\x17\n" + - "\x15_runtime_bind_address\"|\n" + + "\x04auth\x18\x03 \x01(\v2&.kagent.config.v1.AuthenticationConfigR\x04authB\x0f\n" + + "\r_bind_address\"|\n" + "\x14AuthenticationConfig\x127\n" + "\x04mode\x18\x01 \x01(\tB\x1e\xbaH\x1br\x19R\binsecureR\rtrusted-proxyH\x00R\x04mode\x88\x01\x01\x12\"\n" + "\ruser_id_claim\x18\x02 \x01(\tR\vuserIdClaimB\a\n" + diff --git a/go/core/internal/grpcserver/interceptors.go b/go/core/internal/grpcserver/interceptors.go index dc81a5a797..f729a06b33 100644 --- a/go/core/internal/grpcserver/interceptors.go +++ b/go/core/internal/grpcserver/interceptors.go @@ -18,6 +18,7 @@ import ( "google.golang.org/grpc/peer" "google.golang.org/grpc/status" + "github.com/kagent-dev/kagent/go/core/internal/runtimeauth" "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" @@ -56,24 +57,33 @@ func authenticate(ctx context.Context, fullMethod string, authenticator, runtime 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 + var session auth.Session + var err error + if access != auth.AccessRuntime && authenticator != nil { + session, err = authenticator.Authenticate(ctx, headers, url.Values{}) } - if authenticator == nil { - return ctx, status.Error(codes.Unauthenticated, "authentication is not configured") + actor := access == auth.AccessRuntime || errors.Is(err, runtimeauth.ErrActorCredentials) + if actor { + if runtimeAuthenticator == nil { + return ctx, status.Error(codes.Unauthenticated, "runtime authentication is not configured") + } + session, err = runtimeAuthenticator.Authenticate(ctx, headers, url.Values{}) + } else if access == auth.AccessPublic { + return ctx, nil } - - 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 actor { + if _, allowed := RuntimeMethodPolicies()[fullMethod]; !allowed { + return ctx, status.Error(codes.PermissionDenied, "actors cannot access this RPC") + } if headers.Get("X-Share-Token") != "" { return ctx, status.Error(codes.PermissionDenied, "share credentials cannot access runtime storage") } diff --git a/go/core/internal/grpcserver/interceptors_test.go b/go/core/internal/grpcserver/interceptors_test.go index 352d8742b5..ecea1ff02c 100644 --- a/go/core/internal/grpcserver/interceptors_test.go +++ b/go/core/internal/grpcserver/interceptors_test.go @@ -15,6 +15,7 @@ import ( "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" "github.com/kagent-dev/kagent/go/core/internal/database" "github.com/kagent-dev/kagent/go/core/internal/service/serviceerrors" pkgauth "github.com/kagent-dev/kagent/go/core/pkg/auth" @@ -217,7 +218,7 @@ func TestA2AShareAuthorizationIsDelegatedToGateway(t *testing.T) { } func TestRuntimeIdentityDoesNotAuthorizePublicAPI(t *testing.T) { - const runtimeMethod = "/test.TaskStore/Get" + const runtimeMethod = runtimev1.TaskStoreService_GetTask_FullMethodName policies := MethodPolicies{runtimeMethod: pkgauth.AccessRuntime, readMethod: pkgauth.AccessRead} ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("authorization", "Bearer signed-actor-token")) public := &testAuthenticator{err: errors.New("public credentials required")} diff --git a/go/core/internal/grpcserver/memory_test.go b/go/core/internal/grpcserver/memory_test.go index bffc18d236..d89e2b81c3 100644 --- a/go/core/internal/grpcserver/memory_test.go +++ b/go/core/internal/grpcserver/memory_test.go @@ -7,6 +7,7 @@ import ( "slices" "testing" + "github.com/google/uuid" "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -15,20 +16,31 @@ import ( "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" ) 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 { @@ -46,18 +58,26 @@ func (s *userMemoryStore) DeleteAgentMemory(_ context.Context, agentName, userID return nil } -func TestMemoryUsersCanListAndDeleteOnlyTheirOwnData(t *testing.T) { +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.Users(authimpl.NewProxyAuthenticator("sub"), "https://actors.example"), - MemoryService: memoryservice.NewService(store), + Listener: listener, + SystemService: testSystemService(), + Authenticator: runtimeauth.Users(authimpl.NewProxyAuthenticator("sub"), issuer.URL), + RuntimeAuthenticator: runtimeauth.NewAuthenticator(verifier), + MemoryService: memoryservice.NewService(store), }) require.NoError(t, err) ctx, cancel := context.WithCancel(t.Context()) @@ -72,7 +92,7 @@ func TestMemoryUsersCanListAndDeleteOnlyTheirOwnData(t *testing.T) { list := &runtimev1.MemoryServiceListRequest{AgentName: "team/agent", UserId: "bob"} deleteRequest := &runtimev1.MemoryServiceDeleteRequest{AgentName: "team/agent", UserId: "bob"} - for _, claims := range []string{"", `{"iss":"https://actors.example","sub":"alice"}`} { + 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")) @@ -82,11 +102,22 @@ func TestMemoryUsersCanListAndDeleteOnlyTheirOwnData(t *testing.T) { _, 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", UserId: "alice"} + 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.Equal(t, codes.PermissionDenied, status.Code(err)) + _, err = client.List(metadata.AppendToOutgoingContext(actor, "x-share-token", "share"), actorList) + require.Equal(t, codes.PermissionDenied, status.Code(err)) // The configured proxy authenticates the bearer claims. Neither the request // user_id nor a conflicting forwarded user header selects 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) + listed, err = client.List(user, list) require.NoError(t, err) require.Len(t, listed.Memories, 1) require.Equal(t, "own", listed.Memories[0].Id) @@ -98,4 +129,8 @@ func TestMemoryUsersCanListAndDeleteOnlyTheirOwnData(t *testing.T) { 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", UserId: "alice"}) + 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_test.go b/go/core/internal/grpcserver/policy_test.go index 9a3babe74e..e0da1a8736 100644 --- a/go/core/internal/grpcserver/policy_test.go +++ b/go/core/internal/grpcserver/policy_test.go @@ -2,8 +2,12 @@ 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" @@ -11,6 +15,10 @@ 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" + 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" ) @@ -100,24 +108,69 @@ func TestSandboxGuestPoliciesMatchTheirEffect(t *testing.T) { } } -func TestRuntimeEndpointDeniesUserAndUnconfiguredMethods(t *testing.T) { - actor := &testAuthenticator{session: &testSession{principal: pkgauth.Principal{Agent: pkgauth.Agent{ID: "actor"}}}} - for _, method := range []string{ - apiv1.SessionService_CreateSession_FullMethodName, - apiv1.AgentService_DeleteAgent_FullMethodName, - apiv1.SystemService_GetVersion_FullMethodName, - "/a2a.v1.A2AService/SendMessage", - "/extension.Service/Call", +func TestActorAndUserPoliciesOnSharedEndpoint(t *testing.T) { + issuer := jwttest.New(t) + verifier, err := substrate.NewActorJWTVerifier(t.Context(), issuer.URL, issuer.CAFile) + require.NoError(t, err) + actors := runtimeauth.NewAuthenticator(verifier) + sessionID := uuid.NewString() + 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 + }{ + {"insecure", &authimpl.InsecureAuthenticator{}, codes.OK}, + {"trusted proxy", authimpl.NewProxyAuthenticator("sub"), codes.Unauthenticated}, } { - t.Run(method, func(t *testing.T) { - _, err := authenticate(t.Context(), method, actor, actor, nil, RuntimeMethodPolicies()) + t.Run(mode.name, func(t *testing.T) { + users := runtimeauth.Users(mode.provider, 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.Unauthenticated}, + {"anonymous task denied", "", runtimev1.TaskStoreService_GetTask_FullMethodName, codes.Unauthenticated}, + {"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 denied", actor, apiv1.SessionService_CreateSession_FullMethodName, codes.PermissionDenied}, + {"actor public API denied", actor, apiv1.SystemService_GetVersion_FullMethodName, codes.PermissionDenied}, + {"actor A2A denied", actor, a2apb.A2AService_SendMessage_FullMethodName, codes.PermissionDenied}, + {"actor streaming A2A denied", actor, a2apb.A2AService_SendStreamingMessage_FullMethodName, codes.PermissionDenied}, + {"actor extension denied", actor, "/extension.Service/Call", codes.PermissionDenied}, + {"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) { + ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("authorization", test.token, "x-user-id", "alice")) + result, err := authenticate(ctx, test.method, users, actors, 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.Empty(t, identity.Principal().User.ID) + require.Equal(t, sessionID, identity.Principal().Agent.ID) + } + }) + } + shared := metadata.NewIncomingContext(t.Context(), metadata.Pairs("authorization", actor, "x-share-token", "share")) + _, err := authenticate(shared, runtimev1.MemoryService_List_FullMethodName, users, actors, nil, policies) require.Equal(t, codes.PermissionDenied, status.Code(err)) }) } - for _, method := range []string{runtimev1.TaskStoreService_GetTask_FullMethodName, runtimev1.MemoryService_List_FullMethodName} { - _, err := authenticate(t.Context(), method, actor, nil, nil, RuntimeMethodPolicies()) - require.Equal(t, codes.Unauthenticated, status.Code(err)) - _, err = authenticate(t.Context(), method, nil, actor, nil, RuntimeMethodPolicies()) - require.NoError(t, err) - } } diff --git a/go/core/internal/grpcserver/task_store_test.go b/go/core/internal/grpcserver/task_store_test.go index cace05b32f..77f9768227 100644 --- a/go/core/internal/grpcserver/task_store_test.go +++ b/go/core/internal/grpcserver/task_store_test.go @@ -125,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) { @@ -147,10 +147,10 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { memories := &runtimeMemoryStore{Client: store.Client} server, err := New(Config{ Listener: listener, SystemService: testSystemService(), - Authenticator: runtimeauth.NewAuthenticator(verifier), RuntimeAuthenticator: runtimeauth.NewAuthenticator(verifier), - MethodPolicies: RuntimeMethodPolicies(), - TaskStoreService: tasks, - MemoryService: memoryservice.NewService(memories), + Authenticator: runtimeauth.Users(&authimpl.InsecureAuthenticator{}, issuer.URL), + RuntimeAuthenticator: runtimeauth.NewAuthenticator(verifier), + TaskStoreService: tasks, + MemoryService: memoryservice.NewService(memories), }) require.NoError(t, err) serverCtx, stopServer := context.WithCancel(t.Context()) diff --git a/go/core/internal/runtimeauth/users.go b/go/core/internal/runtimeauth/users.go index 503601ce1e..40daa0d94e 100644 --- a/go/core/internal/runtimeauth/users.go +++ b/go/core/internal/runtimeauth/users.go @@ -2,7 +2,7 @@ package runtimeauth import ( "context" - "fmt" + "errors" "net/http" "net/url" "strings" @@ -19,6 +19,10 @@ type userAuthenticator struct { var _ auth.AuthProvider = (*userAuthenticator)(nil) +// ErrActorCredentials directs transports to the Actor JWT authenticator. +// It never establishes an identity or grants access on its own. +var ErrActorCredentials = errors.New("actor credentials require Actor JWT verification") + // Users preserves the configured user authentication mode while preventing // workload tokens from being interpreted as user credentials, including in // insecure or trusted-proxy mode. @@ -33,7 +37,8 @@ func (u *userAuthenticator) Authenticate(ctx context.Context, headers http.Heade continue } claims := jwt.MapClaims{} - // Unverified claims can only reject a request here; they never grant access. + // Unverified claims only exclude user authentication; actor identity must + // be verified separately. // Opaque user credentials remain the configured provider's responsibility. if _, _, err := jwt.NewParser().ParseUnverified(raw, claims); err != nil && len(claims) == 0 { continue @@ -41,7 +46,7 @@ func (u *userAuthenticator) Authenticate(ctx context.Context, headers http.Heade issuer, issuerErr := claims.GetIssuer() _, actor := claims["actor.ate.dev"] if actor || (issuerErr == nil && issuer == u.actorIssuer) { - return nil, fmt.Errorf("actor credentials require the runtime endpoint") + return nil, ErrActorCredentials } } return u.AuthProvider.Authenticate(ctx, headers, query) diff --git a/go/core/internal/runtimeauth/users_test.go b/go/core/internal/runtimeauth/users_test.go index 68c35d9bbd..a95f2b2f70 100644 --- a/go/core/internal/runtimeauth/users_test.go +++ b/go/core/internal/runtimeauth/users_test.go @@ -12,7 +12,6 @@ import ( 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" ) @@ -26,25 +25,23 @@ func TestActorCredentialsNeverAuthenticateAsUsers(t *testing.T) { } { headers := http.Header{"Authorization": {"Bearer " + issuer.Token(t, claims)}, "X-User-Id": {"admin"}} _, err := provider.Authenticate(t.Context(), headers, nil) - require.Error(t, err) + require.ErrorIs(t, err, runtimeauth.ErrActorCredentials) } user, err := provider.Authenticate(t.Context(), http.Header{"X-User-Id": {"alice"}}, nil) require.NoError(t, err) require.Equal(t, "alice", user.Principal().User.ID, "configured user auth remains in effect") } -func TestRuntimeHTTPRequiresActorCredentials(t *testing.T) { +func TestUserHTTPRejectsActorCredentials(t *testing.T) { issuer := jwttest.New(t) - verifier, err := substrate.NewActorJWTVerifier(t.Context(), issuer.URL, issuer.CAFile) - require.NoError(t, err) - handler := auth.AuthnMiddleware(runtimeauth.NewAuthenticator(verifier))(http.NotFoundHandler()) + handler := auth.AuthnMiddleware(runtimeauth.Users(&authimpl.InsecureAuthenticator{}, issuer.URL))(http.NotFoundHandler()) token := "Bearer " + issuer.Token(t, issuer.Claims("team-a", "session-"+uuid.NewString(), "actor-uid")) - for _, path := range []string{"/mcp", "/agents/team-a/agent", "/health"} { + for _, path := range []string{"/mcp", "/agents/team-a/agent"} { for _, test := range []struct { token string status int }{ - {"", http.StatusUnauthorized}, {"Bearer placeholder", http.StatusUnauthorized}, {token, http.StatusNotFound}, + {"", http.StatusNotFound}, {token, http.StatusUnauthorized}, } { request := httptest.NewRequest(http.MethodPost, path, nil) request.Header.Set("Authorization", test.token) diff --git a/go/core/pkg/app/app.go b/go/core/pkg/app/app.go index 7fc4126024..48ec89cecf 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -429,6 +429,7 @@ func Run(ctx context.Context, input *configv1.ControllerConfig, opts Options) er BindAddress: cfg.GetServer().GetBindAddress(), Reflection: cfg.GetServer().GetGrpcReflection(), Authenticator: authenticator, + RuntimeAuthenticator: runtimeauth.NewAuthenticator(actorVerifier), ShareStore: store, ModelService: models, ToolService: tools, @@ -452,26 +453,10 @@ func Run(ctx context.Context, input *configv1.ControllerConfig, opts Options) er return err } - actorAuthenticator := runtimeauth.NewAuthenticator(actorVerifier) - runtimeServer, err := grpcserver.New(grpcserver.Config{ - BindAddress: cfg.GetServer().GetRuntimeBindAddress(), - MethodPolicies: grpcserver.RuntimeMethodPolicies(), - Authenticator: actorAuthenticator, - RuntimeAuthenticator: actorAuthenticator, - SystemService: system, - MemoryService: memory, - TaskStoreService: runtimeTasks, - HTTPHandler: auth.AuthnMiddleware(actorAuthenticator)(http.NotFoundHandler()), - }) - if err != nil { - return err - } - group, ctx := errgroup.WithContext(ctx) group.Go(func() error { return runtime.Start(ctx) }) group.Go(func() error { return manager.Start(ctx) }) group.Go(func() error { return server.Start(ctx) }) - group.Go(func() error { return runtimeServer.Start(ctx) }) return group.Wait() } diff --git a/go/core/pkg/config/defaults.go b/go/core/pkg/config/defaults.go index 2c17a7c583..e5b2a7c265 100644 --- a/go/core/pkg/config/defaults.go +++ b/go/core/pkg/config/defaults.go @@ -34,7 +34,7 @@ func DefaultCLI(cfg *configv1.CLIConfig) { func DefaultController(cfg *configv1.ControllerConfig) { defaults(cfg, &configv1.ControllerConfig{ Kubernetes: &configv1.KubernetesConfig{Namespace: new("kagent"), LeaderElection: new(true)}, - Server: &configv1.ControllerServerConfig{BindAddress: new(":8083"), RuntimeBindAddress: new(":8084"), Auth: &configv1.AuthenticationConfig{Mode: new("insecure")}}, + 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"), 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)}, @@ -46,7 +46,7 @@ func DefaultController(cfg *configv1.ControllerConfig) { Metrics: &configv1.MetricsConfig{BindAddress: new("0")}, Logging: &configv1.LoggingConfig{Level: new("info")}, Telemetry: &configv1.TelemetryConfig{}, - RuntimeDefaults: &configv1.RuntimeDefaultsConfig{Cpu: new("1"), Memory: new("1Gi"), ControlPlaneUrl: new("http://kagent-controller.kagent:8084"), GatewayUrl: new("http://kagent-controller.kagent:8084"), Logging: &configv1.LoggingConfig{Level: new("info")}, Telemetry: &configv1.TelemetryConfig{}}, + RuntimeDefaults: &configv1.RuntimeDefaultsConfig{Cpu: new("1"), Memory: new("1Gi"), ControlPlaneUrl: new("http://kagent-controller.kagent:8083"), GatewayUrl: new("http://kagent-controller.kagent:8083"), Logging: &configv1.LoggingConfig{Level: new("info")}, Telemetry: &configv1.TelemetryConfig{}}, }) if cfg.Push.Issuer == nil { cfg.Push.Issuer = new(cfg.Gateway.GetPublicUrl()) diff --git a/go/core/pkg/config/helm_test.go b/go/core/pkg/config/helm_test.go index d1a253ab7c..b08e559953 100644 --- a/go/core/pkg/config/helm_test.go +++ b/go/core/pkg/config/helm_test.go @@ -69,7 +69,7 @@ func TestHelmControllerConfig(t *testing.T) { base, checksum, err := render("{}") require.NoError(t, err) require.Equal(t, "team", base.Kubernetes.GetNamespace()) - require.Equal(t, "http://demo-controller.team.svc.cluster.local:8084", base.RuntimeDefaults.GetControlPlaneUrl()) + require.Equal(t, "http://demo-controller.team.svc.cluster.local:8083", base.RuntimeDefaults.GetControlPlaneUrl()) require.NotEmpty(t, checksum) require.Equal(t, "kagent", base.Database.GetSchema()) require.Equal(t, "extensions", base.Database.GetVectorSchema()) diff --git a/go/core/test/e2e/README.md b/go/core/test/e2e/README.md index d0018a1fb1..58c1687504 100644 --- a/go/core/test/e2e/README.md +++ b/go/core/test/e2e/README.md @@ -62,11 +62,11 @@ The opaque BYO fixture has its own test because it does not consume managed agent configuration. `TestRuntimeActorJWTAuthentication` exercises real Substrate Actor JWT injection -for every harness. The chart exposes the actor-only runtime listener on its named -`runtime` service port (8084 by default). The test uses the controller's reachable -host with that service port to verify missing, forged, and uninjected credentials -are rejected for both TaskStore and Memory. It also checks that user APIs are -denied on the runtime listener. Controller and runtime images must match. +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: diff --git a/go/core/test/e2e/runtime_auth_test.go b/go/core/test/e2e/runtime_auth_test.go index d934ce8042..7ff8a3d155 100644 --- a/go/core/test/e2e/runtime_auth_test.go +++ b/go/core/test/e2e/runtime_auth_test.go @@ -1,24 +1,15 @@ package e2e_test import ( - "context" - "net" - "strconv" "testing" - "time" 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" "google.golang.org/grpc/codes" - "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" - corev1 "k8s.io/api/core/v1" - ctrlclient "sigs.k8s.io/controller-runtime/pkg/client" - apiv1 "github.com/kagent-dev/kagent/go/api/gen/kagent/api/v1" runtimev1 "github.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1" ) @@ -35,7 +26,7 @@ func TestRuntimeActorJWTAuthentication(t *testing.T) { 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 := runtimeControllerConn(t, target) + runtimeConn := newControllerConn(t, target) private := runtimev1.NewTaskStoreServiceClient(runtimeConn) memory := runtimev1.NewMemoryServiceClient(runtimeConn) request := &runtimev1.TaskStoreServiceGetTaskRequest{SessionId: fixture.sessionID, TaskId: string(task.ID)} @@ -52,36 +43,9 @@ func TestRuntimeActorJWTAuthentication(t *testing.T) { ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs(test.headers...)) _, err := private.GetTask(ctx, request) require.Equal(t, codes.Unauthenticated, status.Code(err)) - _, err = memory.List(ctx, &runtimev1.MemoryServiceListRequest{AgentName: "forged", UserId: "forged"}) + _, err = memory.Search(ctx, &runtimev1.MemoryServiceSearchRequest{AgentName: "forged", UserId: "forged"}) require.Equal(t, codes.Unauthenticated, status.Code(err)) }) } - _, err = apiv1.NewSessionServiceClient(runtimeConn).ListSessions(t.Context(), &apiv1.ListSessionsRequest{}) - require.Equal(t, codes.PermissionDenied, status.Code(err)) }) } - -func runtimeControllerConn(t *testing.T, target string) *grpc.ClientConn { - t.Helper() - services := &corev1.ServiceList{} - require.NoError(t, interactionKubeClient(t).List(t.Context(), services, ctrlclient.InNamespace("kagent"), ctrlclient.MatchingLabels{"app.kubernetes.io/component": "controller"})) - port := int32(0) - for _, service := range services.Items { - for _, candidate := range service.Spec.Ports { - if candidate.Name == "runtime" { - port = candidate.Port - } - } - } - require.NotZero(t, port, "controller must expose its actor-only runtime service port") - host, _, err := net.SplitHostPort(target) - require.NoError(t, err) - conn, err := grpc.NewClient(net.JoinHostPort(host, strconv.Itoa(int(port))), grpc.WithTransportCredentials(insecure.NewCredentials())) - require.NoError(t, err) - t.Cleanup(func() { require.NoError(t, conn.Close()) }) - ctx, cancel := context.WithTimeout(t.Context(), time.Minute) - defer cancel() - _, err = runtimev1.NewMemoryServiceClient(conn).List(ctx, &runtimev1.MemoryServiceListRequest{}, grpc.WaitForReady(true)) - require.Equal(t, codes.Unauthenticated, status.Code(err), "runtime listener must be reachable and require authentication") - return conn -} diff --git a/helm/kagent/templates/_helpers.tpl b/helm/kagent/templates/_helpers.tpl index e76457dd19..8774839610 100644 --- a/helm/kagent/templates/_helpers.tpl +++ b/helm/kagent/templates/_helpers.tpl @@ -439,8 +439,3 @@ tag wins over the component tag. {{- define "kagent.database.skipMigrations" -}} {{- if dig "database" "skipMigrations" false (.Values.config | default dict) -}}true{{- end -}} {{- end -}} - -{{/* Actor-only controller endpoint, separate from configured user authentication. */}} -{{- define "kagent.controllerRuntimeAuthority" -}} -{{- printf "%s-controller.%s.svc.%s:%d" (include "kagent.fullname" .) (include "kagent.namespace" .) (.Values.global.clusterDomain | default "cluster.local") (.Values.controller.service.ports.runtimePort | int) -}} -{{- end -}} diff --git a/helm/kagent/templates/controller-configmap.yaml b/helm/kagent/templates/controller-configmap.yaml index 665bda8e0a..822436ac66 100644 --- a/helm/kagent/templates/controller-configmap.yaml +++ b/helm/kagent/templates/controller-configmap.yaml @@ -1,6 +1,5 @@ {{- $namespace := include "kagent.namespace" . -}} {{- $apiURL := printf "http://%s" (include "kagent.controllerServiceAuthority" .) -}} -{{- $runtimeURL := printf "http://%s" (include "kagent.controllerRuntimeAuthority" .) -}} {{- $watch := list -}} {{- with include "kagent.watchNamespaces" . -}}{{- $watch = splitList "," . -}}{{- end -}} {{- $database := dict "readWrite" (dict "urlFile" "/etc/kagent/secrets/database-readwrite-url") -}} @@ -9,11 +8,11 @@ {{- end -}} {{- $base := dict "kubernetes" (dict "namespace" $namespace "watchNamespaces" $watch) - "server" (dict "bindAddress" (printf ":%v" .Values.controller.service.ports.targetPort) "runtimeBindAddress" (printf ":%v" .Values.controller.service.ports.runtimeTargetPort)) + "server" (dict "bindAddress" (printf ":%v" .Values.controller.service.ports.targetPort)) "database" $database "gateway" (dict "publicUrl" $apiURL) "push" (dict) - "runtimeDefaults" (dict "controlPlaneUrl" $runtimeURL "gatewayUrl" $runtimeURL) + "runtimeDefaults" (dict "controlPlaneUrl" $apiURL "gatewayUrl" $apiURL) -}} {{- $config := mergeOverwrite $base (deepCopy .Values.config) -}} {{- if and $config.sandboxes $config.sandboxes.guestImage -}} diff --git a/helm/kagent/templates/controller-deployment.yaml b/helm/kagent/templates/controller-deployment.yaml index f1ba81026f..824f44efcd 100644 --- a/helm/kagent/templates/controller-deployment.yaml +++ b/helm/kagent/templates/controller-deployment.yaml @@ -161,9 +161,6 @@ spec: - name: http containerPort: {{ .Values.controller.service.ports.targetPort }} protocol: TCP - - name: runtime - containerPort: {{ .Values.controller.service.ports.runtimeTargetPort }} - protocol: TCP {{- if include "kagent.controller.metricsEnabled" . }} - name: metrics containerPort: {{ include "kagent.controller.metricsPort" . | int }} diff --git a/helm/kagent/templates/controller-service.yaml b/helm/kagent/templates/controller-service.yaml index 8e1dca585a..a719f3b5a3 100644 --- a/helm/kagent/templates/controller-service.yaml +++ b/helm/kagent/templates/controller-service.yaml @@ -16,9 +16,5 @@ spec: targetPort: {{ .Values.controller.service.ports.targetPort }} protocol: TCP name: controller - - port: {{ .Values.controller.service.ports.runtimePort }} - targetPort: {{ .Values.controller.service.ports.runtimeTargetPort }} - protocol: TCP - name: runtime selector: {{- include "kagent.controller.selectorLabels" . | nindent 4 }} diff --git a/helm/kagent/tests/controller-configmap_test.yaml b/helm/kagent/tests/controller-configmap_test.yaml index c2237ac7ce..15a6c077a6 100644 --- a/helm/kagent/tests/controller-configmap_test.yaml +++ b/helm/kagent/tests/controller-configmap_test.yaml @@ -36,10 +36,10 @@ tests: asserts: - matchRegex: path: data["controller.yaml"] - pattern: 'controlPlaneUrl: http://RELEASE-NAME-controller.NAMESPACE.svc.cluster.local:8084' + pattern: 'controlPlaneUrl: http://RELEASE-NAME-controller.NAMESPACE.svc.cluster.local:8083' - matchRegex: path: data["controller.yaml"] - pattern: 'gatewayUrl: http://RELEASE-NAME-controller.NAMESPACE.svc.cluster.local:8084' + pattern: 'gatewayUrl: http://RELEASE-NAME-controller.NAMESPACE.svc.cluster.local:8083' - it: supports a custom Kubernetes DNS domain template: controller-configmap.yaml @@ -48,7 +48,7 @@ tests: asserts: - matchRegex: path: data["controller.yaml"] - pattern: 'controlPlaneUrl: http://RELEASE-NAME-controller.NAMESPACE.svc.cluster.example:8084' + pattern: 'controlPlaneUrl: http://RELEASE-NAME-controller.NAMESPACE.svc.cluster.example:8083' - it: mounts one config document and secret files template: controller-deployment.yaml diff --git a/helm/kagent/tests/controller-deployment_test.yaml b/helm/kagent/tests/controller-deployment_test.yaml index 67d009b77e..da6802fb7f 100644 --- a/helm/kagent/tests/controller-deployment_test.yaml +++ b/helm/kagent/tests/controller-deployment_test.yaml @@ -410,7 +410,7 @@ tests: asserts: - lengthEqual: path: spec.template.spec.containers[0].ports - count: 2 + count: 1 - notContains: path: spec.template.spec.containers[0].ports content: diff --git a/helm/kagent/tests/controller-service_test.yaml b/helm/kagent/tests/controller-service_test.yaml index 9945697924..b1873c4efb 100644 --- a/helm/kagent/tests/controller-service_test.yaml +++ b/helm/kagent/tests/controller-service_test.yaml @@ -84,12 +84,8 @@ tests: PrivateDNSName: kagent service.beta.kubernetes.io/aws-load-balancer-type: nlb-ip - - it: should expose the actor-only runtime endpoint + - it: should use one controller port for users and actors asserts: - - equal: - path: spec.ports[1] - value: - name: runtime - port: 8084 - targetPort: 8084 - protocol: TCP + - lengthEqual: + path: spec.ports + count: 1 diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 30f5f9053e..063fd80697 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -301,8 +301,6 @@ controller: ports: port: 8083 targetPort: 8083 - runtimePort: 8084 - runtimeTargetPort: 8084 annotations: {} # Kubernetes packaging for the controller's Prometheus listener. Its address # and TLS mode come from config.metrics. diff --git a/proto/kagent/config/v1/config.proto b/proto/kagent/config/v1/config.proto index 592ff8b779..c505883b57 100644 --- a/proto/kagent/config/v1/config.proto +++ b/proto/kagent/config/v1/config.proto @@ -134,8 +134,6 @@ message ControllerServerConfig { optional string bind_address = 1 [(buf.validate.field).string.min_len = 1]; bool grpc_reflection = 2; AuthenticationConfig auth = 3; - // Actor-only listener. Every runtime call requires a Substrate Actor JWT. - optional string runtime_bind_address = 4 [(buf.validate.field).string.min_len = 1]; } message AuthenticationConfig { From c6ba7b874cae349087e1254e5073ebbab1e3fbcb Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 20 Oct 2026 18:36:15 +0000 Subject: [PATCH 07/11] fix(auth): document memory scope and surface actor JWT failures Signed-off-by: Eitan Yarmush --- go/adk/cmd/main.go | 1 - go/adk/pkg/memory/kagent_service.go | 25 ++--- go/adk/pkg/memory/kagent_service_grpc_test.go | 25 ++--- go/api/gen/kagent/runtime/v1/memory.pb.go | 98 ++++++++++++------- .../gen/kagent/runtime/v1/memory_grpc.pb.go | 18 ++++ go/core/internal/grpcserver/memory.go | 18 ++-- go/core/internal/grpcserver/memory_test.go | 1 + .../internal/grpcserver/task_store_test.go | 2 +- go/core/internal/runtimeauth/auth.go | 2 +- go/core/test/e2e/runtime_auth_test.go | 2 +- proto/kagent/runtime/v1/memory.proto | 34 +++++-- .../kagent-adk/src/kagent/adk/_a2a.py | 1 - .../src/kagent/adk/_memory_service.py | 13 +-- .../unittests/test_memory_service_grpc.py | 11 +-- .../src/kagent/runtime/v1/memory_pb2.py | 68 +++++++------ .../src/kagent/runtime/v1/memory_pb2_grpc.py | 30 ++++-- 16 files changed, 201 insertions(+), 148 deletions(-) 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/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/api/gen/kagent/runtime/v1/memory.pb.go b/go/api/gen/kagent/runtime/v1/memory.pb.go index 28d7cd5671..6af9071f92 100644 --- a/go/api/gen/kagent/runtime/v1/memory.pb.go +++ b/go/api/gen/kagent/runtime/v1/memory.pb.go @@ -24,13 +24,19 @@ 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"` - TtlDays *int32 `protobuf:"varint,6,opt,name=ttl_days,json=ttlDays,proto3,oneof" json:"ttl_days,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + // Ignored. The agent is derived from the actor's persisted session. + // + // Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. + AgentName string `protobuf:"bytes,1,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` + // Ignored. The user is derived from the actor's persisted session owner. + // + // Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. + 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"` + TtlDays *int32 `protobuf:"varint,6,opt,name=ttl_days,json=ttlDays,proto3,oneof" json:"ttl_days,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -65,6 +71,7 @@ func (*SessionMemoryInput) Descriptor() ([]byte, []int) { return file_kagent_runtime_v1_memory_proto_rawDescGZIP(), []int{0} } +// Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. func (x *SessionMemoryInput) GetAgentName() string { if x != nil { return x.AgentName @@ -72,6 +79,7 @@ func (x *SessionMemoryInput) GetAgentName() string { return "" } +// Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. func (x *SessionMemoryInput) GetUserId() string { if x != nil { return x.UserId @@ -436,12 +444,18 @@ 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"` + state protoimpl.MessageState `protogen:"open.v1"` + // Ignored. The agent is derived from the actor's persisted session. + // + // Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. + AgentName string `protobuf:"bytes,1,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` + // Ignored. The user is derived from the actor's persisted session owner. + // + // Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. + 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"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -476,6 +490,7 @@ func (*MemoryServiceSearchRequest) Descriptor() ([]byte, []int) { return file_kagent_runtime_v1_memory_proto_rawDescGZIP(), []int{7} } +// Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. func (x *MemoryServiceSearchRequest) GetAgentName() string { if x != nil { return x.AgentName @@ -483,6 +498,7 @@ func (x *MemoryServiceSearchRequest) GetAgentName() string { return "" } +// Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. func (x *MemoryServiceSearchRequest) GetUserId() string { if x != nil { return x.UserId @@ -556,9 +572,16 @@ 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"` + // Ignored. User calls use the authenticated user; actor calls use the + // persisted session owner. Neither path can select another user. + // + // Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. + UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -600,6 +623,7 @@ func (x *MemoryServiceListRequest) GetAgentName() string { return "" } +// Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. func (x *MemoryServiceListRequest) GetUserId() string { if x != nil { return x.UserId @@ -652,9 +676,16 @@ 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"` + // Ignored. User calls use the authenticated user; actor calls use the + // persisted session owner. Neither path can select another user. + // + // Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. + UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -696,6 +727,7 @@ func (x *MemoryServiceDeleteRequest) GetAgentName() string { return "" } +// Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. func (x *MemoryServiceDeleteRequest) GetUserId() string { if x != nil { return x.UserId @@ -751,11 +783,11 @@ 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" + + "\x1ekagent/runtime/v1/memory.proto\x12\x11kagent.runtime.v1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xe8\x01\n" + + "\x12SessionMemoryInput\x12!\n" + "\n" + - "agent_name\x18\x01 \x01(\tR\tagentName\x12\x17\n" + - "\auser_id\x18\x02 \x01(\tR\x06userId\x12\x18\n" + + "agent_name\x18\x01 \x01(\tB\x02\x18\x01R\tagentName\x12\x1b\n" + + "\auser_id\x18\x02 \x01(\tB\x02\x18\x01R\x06userId\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" + @@ -783,11 +815,11 @@ 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" + + "\x05count\x18\x01 \x01(\x05R\x05count\"\xc9\x01\n" + + "\x1aMemoryServiceSearchRequest\x12!\n" + "\n" + - "agent_name\x18\x01 \x01(\tR\tagentName\x12\x17\n" + - "\auser_id\x18\x02 \x01(\tR\x06userId\x12\x16\n" + + "agent_name\x18\x01 \x01(\tB\x02\x18\x01R\tagentName\x12\x1b\n" + + "\auser_id\x18\x02 \x01(\tB\x02\x18\x01R\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\bminScore\x88\x01\x01B\b\n" + @@ -795,17 +827,17 @@ const file_kagent_runtime_v1_memory_proto_rawDesc = "" + "\n" + "_min_score\"`\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\"V\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\tagentName\x12\x1b\n" + + "\auser_id\x18\x02 \x01(\tB\x02\x18\x01R\x06userId\"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\"X\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\tagentName\x12\x1b\n" + + "\auser_id\x18\x02 \x01(\tB\x02\x18\x01R\x06userId\"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..e2b05b24ea 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; request identity fields +// cannot override that scope. List and Delete also accept user authentication. 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; request identity fields +// cannot override that scope. List and Delete also accept user authentication. 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/grpcserver/memory.go b/go/core/internal/grpcserver/memory.go index 9dc04b0b7f..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 @@ -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 index d89e2b81c3..f4cc6b2cf8 100644 --- a/go/core/internal/grpcserver/memory_test.go +++ b/go/core/internal/grpcserver/memory_test.go @@ -58,6 +58,7 @@ func (s *userMemoryStore) DeleteAgentMemory(_ context.Context, agentName, userID return nil } +//nolint:staticcheck // Forged legacy identity fields must not override authenticated memory scope. func TestMemoryUsersAndActorsShareEndpointWithSeparateScopes(t *testing.T) { id := uuid.NewString() store := &userMemoryStore{memories: []database.Memory{ diff --git a/go/core/internal/grpcserver/task_store_test.go b/go/core/internal/grpcserver/task_store_test.go index 77f9768227..8bc70e5a32 100644 --- a/go/core/internal/grpcserver/task_store_test.go +++ b/go/core/internal/grpcserver/task_store_test.go @@ -179,7 +179,7 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { _, err = private.GetTask(unsigned, read) require.Equal(t, codes.Unauthenticated, status.Code(err)) authenticated := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("authorization", runtimeToken)) - _, err = controller.MemoryService().List(authenticated, &runtimev1.MemoryServiceListRequest{AgentName: "forged/agent", UserId: "another-user"}) + _, err = controller.MemoryService().List(authenticated, &runtimev1.MemoryServiceListRequest{AgentName: "forged/agent", UserId: "another-user"}) //nolint:staticcheck // Legacy user_id must not override actor scope. require.NoError(t, err) require.Equal(t, session.Agent.Namespace+"/"+session.Agent.Name, memories.agentName) require.Equal(t, session.Creator, memories.userID) diff --git a/go/core/internal/runtimeauth/auth.go b/go/core/internal/runtimeauth/auth.go index 034e284415..28f80015e0 100644 --- a/go/core/internal/runtimeauth/auth.go +++ b/go/core/internal/runtimeauth/auth.go @@ -46,7 +46,7 @@ func (a *Authenticator) Authenticate(ctx context.Context, headers http.Header, _ } identity, err := a.verifier.Verify(ctx, token) if err != nil { - logging.FromContext(ctx).DebugContext(ctx, "runtime actor authentication failed", "error", err) + logging.FromContext(ctx).WarnContext(ctx, "runtime actor authentication failed", "error", err) return nil, err } id, ok := strings.CutPrefix(identity.ActorName, "session-") diff --git a/go/core/test/e2e/runtime_auth_test.go b/go/core/test/e2e/runtime_auth_test.go index 7ff8a3d155..2f043fe0ce 100644 --- a/go/core/test/e2e/runtime_auth_test.go +++ b/go/core/test/e2e/runtime_auth_test.go @@ -43,7 +43,7 @@ func TestRuntimeActorJWTAuthentication(t *testing.T) { ctx := metadata.NewOutgoingContext(t.Context(), metadata.Pairs(test.headers...)) _, err := private.GetTask(ctx, request) require.Equal(t, codes.Unauthenticated, status.Code(err)) - _, err = memory.Search(ctx, &runtimev1.MemoryServiceSearchRequest{AgentName: "forged", UserId: "forged"}) + _, err = memory.Search(ctx, &runtimev1.MemoryServiceSearchRequest{AgentName: "forged", UserId: "forged"}) //nolint:staticcheck // Legacy identity fields cannot authenticate a caller. require.Equal(t, codes.Unauthenticated, status.Code(err)) }) } diff --git a/proto/kagent/runtime/v1/memory.proto b/proto/kagent/runtime/v1/memory.proto index 5bf9abd2c4..4af658434a 100644 --- a/proto/kagent/runtime/v1/memory.proto +++ b/proto/kagent/runtime/v1/memory.proto @@ -7,17 +7,27 @@ 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; request identity fields +// cannot override that scope. List and Delete also accept user authentication. 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; + // Ignored. The agent is derived from the actor's persisted session. + string agent_name = 1 [deprecated = true]; + // Ignored. The user is derived from the actor's persisted session owner. + string user_id = 2 [deprecated = true]; string content = 3; repeated float vector = 4; google.protobuf.Struct metadata = 5; @@ -57,8 +67,10 @@ message MemoryServiceAddSessionBatchResponse { } message MemoryServiceSearchRequest { - string agent_name = 1; - string user_id = 2; + // Ignored. The agent is derived from the actor's persisted session. + string agent_name = 1 [deprecated = true]; + // Ignored. The user is derived from the actor's persisted session owner. + string user_id = 2 [deprecated = true]; repeated float vector = 3; optional int32 limit = 4; optional double min_score = 5; @@ -69,8 +81,13 @@ message MemoryServiceSearchResponse { } message MemoryServiceListRequest { + // 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; + // Ignored. User calls use the authenticated user; actor calls use the + // persisted session owner. Neither path can select another user. + string user_id = 2 [deprecated = true]; } message MemoryServiceListResponse { @@ -78,8 +95,13 @@ message MemoryServiceListResponse { } message MemoryServiceDeleteRequest { + // 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; + // Ignored. User calls use the authenticated user; actor calls use the + // persisted session owner. Neither path can select another user. + string user_id = 2 [deprecated = true]; } 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-proto/src/kagent/runtime/v1/memory_pb2.py b/python/packages/kagent-proto/src/kagent/runtime/v1/memory_pb2.py index 7b995e60fe..4f8f58a820 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\"\xe8\x01\n\x12SessionMemoryInput\x12!\n\nagent_name\x18\x01 \x01(\tB\x02\x18\x01R\tagentName\x12\x1b\n\x07user_id\x18\x02 \x01(\tB\x02\x18\x01R\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\"\xc9\x01\n\x1aMemoryServiceSearchRequest\x12!\n\nagent_name\x18\x01 \x01(\tB\x02\x18\x01R\tagentName\x12\x1b\n\x07user_id\x18\x02 \x01(\tB\x02\x18\x01R\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\"V\n\x18MemoryServiceListRequest\x12\x1d\n\nagent_name\x18\x01 \x01(\tR\tagentName\x12\x1b\n\x07user_id\x18\x02 \x01(\tB\x02\x18\x01R\x06userId\"Y\n\x19MemoryServiceListResponse\x12<\n\x08memories\x18\x01 \x03(\x0b\x32 .kagent.runtime.v1.MemorySummaryR\x08memories\"X\n\x1aMemoryServiceDeleteRequest\x12\x1d\n\nagent_name\x18\x01 \x01(\tR\tagentName\x12\x1b\n\x07user_id\x18\x02 \x01(\tB\x02\x18\x01R\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') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -34,32 +34,44 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'ZCgithub.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1;runtimev1' + _globals['_SESSIONMEMORYINPUT'].fields_by_name['agent_name']._loaded_options = None + _globals['_SESSIONMEMORYINPUT'].fields_by_name['agent_name']._serialized_options = b'\030\001' + _globals['_SESSIONMEMORYINPUT'].fields_by_name['user_id']._loaded_options = None + _globals['_SESSIONMEMORYINPUT'].fields_by_name['user_id']._serialized_options = b'\030\001' + _globals['_MEMORYSERVICESEARCHREQUEST'].fields_by_name['agent_name']._loaded_options = None + _globals['_MEMORYSERVICESEARCHREQUEST'].fields_by_name['agent_name']._serialized_options = b'\030\001' + _globals['_MEMORYSERVICESEARCHREQUEST'].fields_by_name['user_id']._loaded_options = None + _globals['_MEMORYSERVICESEARCHREQUEST'].fields_by_name['user_id']._serialized_options = b'\030\001' + _globals['_MEMORYSERVICELISTREQUEST'].fields_by_name['user_id']._loaded_options = None + _globals['_MEMORYSERVICELISTREQUEST'].fields_by_name['user_id']._serialized_options = b'\030\001' + _globals['_MEMORYSERVICEDELETEREQUEST'].fields_by_name['user_id']._loaded_options = None + _globals['_MEMORYSERVICEDELETEREQUEST'].fields_by_name['user_id']._serialized_options = b'\030\001' _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=349 + _globals['_MEMORYSEARCHRESULT']._serialized_start=352 + _globals['_MEMORYSEARCHRESULT']._serialized_end=548 + _globals['_MEMORYSUMMARY']._serialized_start=551 + _globals['_MEMORYSUMMARY']._serialized_end=761 + _globals['_MEMORYSERVICEADDSESSIONREQUEST']._serialized_start=763 + _globals['_MEMORYSERVICEADDSESSIONREQUEST']._serialized_end=858 + _globals['_MEMORYSERVICEADDSESSIONRESPONSE']._serialized_start=860 + _globals['_MEMORYSERVICEADDSESSIONRESPONSE']._serialized_end=909 + _globals['_MEMORYSERVICEADDSESSIONBATCHREQUEST']._serialized_start=911 + _globals['_MEMORYSERVICEADDSESSIONBATCHREQUEST']._serialized_end=1009 + _globals['_MEMORYSERVICEADDSESSIONBATCHRESPONSE']._serialized_start=1011 + _globals['_MEMORYSERVICEADDSESSIONBATCHRESPONSE']._serialized_end=1071 + _globals['_MEMORYSERVICESEARCHREQUEST']._serialized_start=1074 + _globals['_MEMORYSERVICESEARCHREQUEST']._serialized_end=1275 + _globals['_MEMORYSERVICESEARCHRESPONSE']._serialized_start=1277 + _globals['_MEMORYSERVICESEARCHRESPONSE']._serialized_end=1373 + _globals['_MEMORYSERVICELISTREQUEST']._serialized_start=1375 + _globals['_MEMORYSERVICELISTREQUEST']._serialized_end=1461 + _globals['_MEMORYSERVICELISTRESPONSE']._serialized_start=1463 + _globals['_MEMORYSERVICELISTRESPONSE']._serialized_end=1552 + _globals['_MEMORYSERVICEDELETEREQUEST']._serialized_start=1554 + _globals['_MEMORYSERVICEDELETEREQUEST']._serialized_end=1642 + _globals['_MEMORYSERVICEDELETERESPONSE']._serialized_start=1644 + _globals['_MEMORYSERVICEDELETERESPONSE']._serialized_end=1697 + _globals['_MEMORYSERVICE']._serialized_start=1700 + _globals['_MEMORYSERVICE']._serialized_end=2274 # @@protoc_insertion_point(module_scope) 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..468da6599b 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; request identity fields + cannot override that scope. List and Delete also accept user authentication. + """ 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; request identity fields + cannot override that scope. List and Delete also accept user authentication. + """ 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; request identity fields + cannot override that scope. List and Delete also accept user authentication. + """ @staticmethod def AddSession(request, From 389ab822fba43c456e8f15fe6eafa787b4840105 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 20 Oct 2026 18:53:01 +0000 Subject: [PATCH 08/11] feat(auth): share actor and user resource authorization Signed-off-by: Eitan Yarmush --- design/EP-1270-scoped-authorization.md | 4 + docs/architecture/README.md | 5 +- docs/architecture/a2a-gateway.md | 31 ++++- .../architecture/oidc-proxy-authentication.md | 5 +- .../internal/a2agateway/actor_auth_test.go | 68 ++++++++++ go/core/internal/a2agateway/routing_test.go | 2 +- go/core/internal/grpcserver/interceptors.go | 40 ++---- .../internal/grpcserver/interceptors_test.go | 38 ++---- go/core/internal/grpcserver/memory_test.go | 14 +- go/core/internal/grpcserver/policy.go | 10 +- go/core/internal/grpcserver/policy_test.go | 47 ++++--- go/core/internal/grpcserver/server.go | 5 +- .../internal/grpcserver/task_store_test.go | 30 +++-- go/core/internal/mcp/auth_test.go | 85 ++++++++++++ go/core/internal/mcp/tasks_test.go | 9 +- go/core/internal/runtimeauth/auth.go | 16 ++- go/core/internal/runtimeauth/authorizer.go | 70 ++++++++++ .../internal/runtimeauth/authorizer_test.go | 94 +++++++++++++ go/core/internal/runtimeauth/provider.go | 83 ++++++++++++ go/core/internal/runtimeauth/provider_test.go | 127 ++++++++++++++++++ go/core/internal/runtimeauth/users.go | 53 -------- go/core/internal/runtimeauth/users_test.go | 53 -------- go/core/internal/service/memory/service.go | 43 +++--- .../internal/service/memory/service_test.go | 34 ++++- go/core/internal/service/session/service.go | 4 + .../internal/service/session/service_test.go | 19 +++ go/core/internal/service/session/share.go | 3 + go/core/internal/service/system/service.go | 3 + .../internal/service/system/service_test.go | 16 ++- go/core/pkg/app/app.go | 6 +- go/core/pkg/auth/auth.go | 12 +- 31 files changed, 773 insertions(+), 256 deletions(-) create mode 100644 go/core/internal/a2agateway/actor_auth_test.go create mode 100644 go/core/internal/mcp/auth_test.go create mode 100644 go/core/internal/runtimeauth/authorizer.go create mode 100644 go/core/internal/runtimeauth/authorizer_test.go create mode 100644 go/core/internal/runtimeauth/provider.go create mode 100644 go/core/internal/runtimeauth/provider_test.go delete mode 100644 go/core/internal/runtimeauth/users.go delete mode 100644 go/core/internal/runtimeauth/users_test.go 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 4ee5f33297..40ccfcaf22 100644 --- a/docs/architecture/a2a-gateway.md +++ b/docs/architecture/a2a-gateway.md @@ -196,12 +196,27 @@ bound to the `kagent-runtime` audience. Other destinations receive no runtime JW and this rule does not grant network access to sandboxes that cannot already reach the control plane. -The runtime method allowlist contains TaskStore and Memory only. Actor JWTs -cannot authorize user APIs, A2A, MCP, reflection, or unconfigured methods. Actors -do not inherit their session owner's general permissions. The user authenticator -identifies actor credentials without trusting their claims, then the gRPC -interceptor sends them to the Actor JWT verifier and enforces the allowlist. -Invalid actor credentials never fall back to user authentication. +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 @@ -221,7 +236,9 @@ 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. -These calls use the requested agent key and the authenticated user ID, +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. diff --git a/docs/architecture/oidc-proxy-authentication.md b/docs/architecture/oidc-proxy-authentication.md index db416cde6a..9991a6aedd 100644 --- a/docs/architecture/oidc-proxy-authentication.md +++ b/docs/architecture/oidc-proxy-authentication.md @@ -103,8 +103,9 @@ 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. Actor calls use the -same listener, but the controller routes Actor JWTs to its dedicated verifier and -allows only TaskStore and Memory. Invalid actor credentials cannot fall back to +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. 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 f729a06b33..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" - "github.com/kagent-dev/kagent/go/core/internal/runtimeauth" "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" @@ -32,9 +31,9 @@ var forwardedMetadataKeys = map[string]string{ "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 } @@ -42,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 } @@ -52,7 +51,7 @@ 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") @@ -61,34 +60,17 @@ func authenticate(ctx context.Context, fullMethod string, authenticator, runtime if access == auth.AccessPublic && len(headers.Values("Authorization")) == 0 { return ctx, nil } - var session auth.Session - var err error - if access != auth.AccessRuntime && authenticator != nil { - session, err = authenticator.Authenticate(ctx, headers, url.Values{}) - } - actor := access == auth.AccessRuntime || errors.Is(err, runtimeauth.ErrActorCredentials) - if actor { - if runtimeAuthenticator == nil { - return ctx, status.Error(codes.Unauthenticated, "runtime authentication is not configured") - } - session, err = runtimeAuthenticator.Authenticate(ctx, headers, url.Values{}) - } else if access == auth.AccessPublic { - return ctx, nil + if authenticator == nil { + return ctx, status.Error(codes.Unauthenticated, "authentication is not configured") } + 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 actor { - if _, allowed := RuntimeMethodPolicies()[fullMethod]; !allowed { - return ctx, status.Error(codes.PermissionDenied, "actors cannot access this RPC") - } - 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 ecea1ff02c..5f031e1235 100644 --- a/go/core/internal/grpcserver/interceptors_test.go +++ b/go/core/internal/grpcserver/interceptors_test.go @@ -74,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 @@ -87,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 }, ) @@ -104,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) @@ -133,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) @@ -161,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") @@ -176,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 }, ) @@ -201,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) } @@ -217,25 +217,11 @@ func TestA2AShareAuthorizationIsDelegatedToGateway(t *testing.T) { } } -func TestRuntimeIdentityDoesNotAuthorizePublicAPI(t *testing.T) { - const runtimeMethod = runtimev1.TaskStoreService_GetTask_FullMethodName - policies := MethodPolicies{runtimeMethod: pkgauth.AccessRuntime, readMethod: pkgauth.AccessRead} - ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("authorization", "Bearer signed-actor-token")) - 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) - } - } - runtime := &testAuthenticator{session: &testSession{principal: pkgauth.Principal{Agent: pkgauth.Agent{ID: "actor"}}}} - _, err := authenticate(ctx, readMethod, public, runtime, nil, policies) - if status.Code(err) != codes.Unauthenticated { - t.Fatalf("runtime test mode authorized public API: %v", err) - } - _, err = authenticate(ctx, runtimeMethod, public, runtime, 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_test.go b/go/core/internal/grpcserver/memory_test.go index f4cc6b2cf8..21d3e1c0cc 100644 --- a/go/core/internal/grpcserver/memory_test.go +++ b/go/core/internal/grpcserver/memory_test.go @@ -24,6 +24,7 @@ import ( "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 { @@ -74,11 +75,10 @@ func TestMemoryUsersAndActorsShareEndpointWithSeparateScopes(t *testing.T) { require.NoError(t, err) listener := bufconn.Listen(DefaultMaxMessageSize) server, err := New(Config{ - Listener: listener, - SystemService: testSystemService(), - Authenticator: runtimeauth.Users(authimpl.NewProxyAuthenticator("sub"), issuer.URL), - RuntimeAuthenticator: runtimeauth.NewAuthenticator(verifier), - MemoryService: memoryservice.NewService(store), + 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()) @@ -111,9 +111,9 @@ func TestMemoryUsersAndActorsShareEndpointWithSeparateScopes(t *testing.T) { 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.Equal(t, codes.PermissionDenied, status.Code(err)) + require.NoError(t, err) _, err = client.List(metadata.AppendToOutgoingContext(actor, "x-share-token", "share"), actorList) - require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Equal(t, codes.Unauthenticated, status.Code(err)) // The configured proxy authenticates the bearer claims. Neither the request // user_id nor a conflicting forwarded user header selects another user's data. user := metadata.NewOutgoingContext(t.Context(), metadata.Pairs("authorization", diff --git a/go/core/internal/grpcserver/policy.go b/go/core/internal/grpcserver/policy.go index 55d0dad52a..5cf4214d6c 100644 --- a/go/core/internal/grpcserver/policy.go +++ b/go/core/internal/grpcserver/policy.go @@ -116,15 +116,15 @@ 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()) + maps.Copy(policies, runtimeMethodPolicies()) policies[runtimev1.MemoryService_List_FullMethodName] = auth.AccessRead policies[runtimev1.MemoryService_Delete_FullMethodName] = auth.AccessDelete return policies } -// RuntimeMethodPolicies is the explicit allowlist for actor calls. -// New public or extension methods never become callable by actors implicitly. -func RuntimeMethodPolicies() MethodPolicies { +// 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, @@ -134,7 +134,5 @@ func RuntimeMethodPolicies() MethodPolicies { runtimev1.MemoryService_AddSession_FullMethodName: auth.AccessRuntime, runtimev1.MemoryService_AddSessionBatch_FullMethodName: auth.AccessRuntime, runtimev1.MemoryService_Search_FullMethodName: auth.AccessRuntime, - runtimev1.MemoryService_List_FullMethodName: auth.AccessRuntime, - runtimev1.MemoryService_Delete_FullMethodName: auth.AccessRuntime, } } diff --git a/go/core/internal/grpcserver/policy_test.go b/go/core/internal/grpcserver/policy_test.go index e0da1a8736..51c3d3cf3f 100644 --- a/go/core/internal/grpcserver/policy_test.go +++ b/go/core/internal/grpcserver/policy_test.go @@ -82,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) } @@ -112,8 +112,8 @@ func TestActorAndUserPoliciesOnSharedEndpoint(t *testing.T) { issuer := jwttest.New(t) verifier, err := substrate.NewActorJWTVerifier(t.Context(), issuer.URL, issuer.CAFile) require.NoError(t, err) - actors := runtimeauth.NewAuthenticator(verifier) 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() @@ -123,15 +123,16 @@ func TestActorAndUserPoliciesOnSharedEndpoint(t *testing.T) { policies := DefaultMethodPolicies() policies["/extension.Service/Call"] = pkgauth.AccessRead for _, mode := range []struct { - name string - provider pkgauth.AuthProvider - anonymous codes.Code + name string + provider pkgauth.AuthProvider + anonymous codes.Code + runtimeCode codes.Code }{ - {"insecure", &authimpl.InsecureAuthenticator{}, codes.OK}, - {"trusted proxy", authimpl.NewProxyAuthenticator("sub"), codes.Unauthenticated}, + {"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.Users(mode.provider, issuer.URL) + users := runtimeauth.NewProvider(mode.provider, verifier, store, issuer.URL) for _, test := range []struct { name, token, method string want codes.Code @@ -141,36 +142,40 @@ func TestActorAndUserPoliciesOnSharedEndpoint(t *testing.T) { {"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.Unauthenticated}, - {"anonymous task denied", "", runtimev1.TaskStoreService_GetTask_FullMethodName, codes.Unauthenticated}, + {"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 denied", actor, apiv1.SessionService_CreateSession_FullMethodName, codes.PermissionDenied}, - {"actor public API denied", actor, apiv1.SystemService_GetVersion_FullMethodName, codes.PermissionDenied}, - {"actor A2A denied", actor, a2apb.A2AService_SendMessage_FullMethodName, codes.PermissionDenied}, - {"actor streaming A2A denied", actor, a2apb.A2AService_SendStreamingMessage_FullMethodName, codes.PermissionDenied}, - {"actor extension denied", actor, "/extension.Service/Call", codes.PermissionDenied}, + {"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) { - ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs("authorization", test.token, "x-user-id", "alice")) - result, err := authenticate(ctx, test.method, users, actors, nil, policies) + 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.Empty(t, identity.Principal().User.ID) - require.Equal(t, sessionID, identity.Principal().Agent.ID) + 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, actors, nil, policies) - require.Equal(t, codes.PermissionDenied, status.Code(err)) + _, 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 8bc70e5a32..3cd18c9548 100644 --- a/go/core/internal/grpcserver/task_store_test.go +++ b/go/core/internal/grpcserver/task_store_test.go @@ -145,12 +145,13 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { 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: runtimeauth.Users(&authimpl.InsecureAuthenticator{}, issuer.URL), - RuntimeAuthenticator: runtimeauth.NewAuthenticator(verifier), - TaskStoreService: tasks, - MemoryService: memoryservice.NewService(memories), + 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()) @@ -171,14 +172,23 @@ 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)) + 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.Unauthenticated, status.Code(err)) + 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", UserId: "another-user"}) //nolint:staticcheck // Legacy user_id must not override actor scope. require.NoError(t, err) require.Equal(t, session.Agent.Namespace+"/"+session.Agent.Name, memories.agentName) @@ -186,15 +196,15 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { _, 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.PermissionDenied}, - {"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) { token := issuer.Token(t, issuer.Claims(test.atespace, substrate.ActorName(id), test.actorUID)) 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 index 28f80015e0..66179781de 100644 --- a/go/core/internal/runtimeauth/auth.go +++ b/go/core/internal/runtimeauth/auth.go @@ -59,14 +59,22 @@ func (a *Authenticator) Authenticate(ctx context.Context, headers http.Header, _ return runtimeSession{sessionID: id, atespace: identity.Atespace, actorUID: identity.ActorUID}, nil } -func (*Authenticator) UpstreamAuth(*http.Request, auth.Session, auth.Principal) error { - return fmt.Errorf("runtime authentication cannot forward public credentials") +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 string } +type runtimeSession struct{ sessionID, atespace, actorUID, owner, agent string } func (s runtimeSession) Principal() auth.Principal { - return auth.Principal{Agent: auth.Agent{ID: s.sessionID}} + return auth.Principal{User: auth.User{ID: s.owner}, Agent: auth.Agent{ID: s.agent}, Actor: &auth.Actor{SessionID: s.sessionID}} } type SessionStore interface { 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/runtimeauth/users.go b/go/core/internal/runtimeauth/users.go deleted file mode 100644 index 40daa0d94e..0000000000 --- a/go/core/internal/runtimeauth/users.go +++ /dev/null @@ -1,53 +0,0 @@ -package runtimeauth - -import ( - "context" - "errors" - "net/http" - "net/url" - "strings" - - "github.com/golang-jwt/jwt/v5" - - "github.com/kagent-dev/kagent/go/core/pkg/auth" -) - -type userAuthenticator struct { - auth.AuthProvider - actorIssuer string -} - -var _ auth.AuthProvider = (*userAuthenticator)(nil) - -// ErrActorCredentials directs transports to the Actor JWT authenticator. -// It never establishes an identity or grants access on its own. -var ErrActorCredentials = errors.New("actor credentials require Actor JWT verification") - -// Users preserves the configured user authentication mode while preventing -// workload tokens from being interpreted as user credentials, including in -// insecure or trusted-proxy mode. -func Users(provider auth.AuthProvider, actorIssuer string) auth.AuthProvider { - return &userAuthenticator{AuthProvider: provider, actorIssuer: actorIssuer} -} - -func (u *userAuthenticator) Authenticate(ctx context.Context, headers http.Header, query url.Values) (auth.Session, error) { - 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 exclude user authentication; actor identity must - // be verified separately. - // 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 && issuer == u.actorIssuer) { - return nil, ErrActorCredentials - } - } - return u.AuthProvider.Authenticate(ctx, headers, query) -} diff --git a/go/core/internal/runtimeauth/users_test.go b/go/core/internal/runtimeauth/users_test.go deleted file mode 100644 index a95f2b2f70..0000000000 --- a/go/core/internal/runtimeauth/users_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package runtimeauth_test - -import ( - "net/http" - "net/http/httptest" - "testing" - - "github.com/golang-jwt/jwt/v5" - "github.com/google/uuid" - "github.com/stretchr/testify/require" - - 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/pkg/auth" -) - -func TestActorCredentialsNeverAuthenticateAsUsers(t *testing.T) { - issuer := jwttest.New(t) - provider := runtimeauth.Users(&authimpl.InsecureAuthenticator{}, issuer.URL) - for _, claims := range []jwt.MapClaims{ - issuer.Claims("team-a", "session-"+uuid.NewString(), "actor-uid"), - {"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.ErrorIs(t, err, runtimeauth.ErrActorCredentials) - } - user, err := provider.Authenticate(t.Context(), http.Header{"X-User-Id": {"alice"}}, nil) - require.NoError(t, err) - require.Equal(t, "alice", user.Principal().User.ID, "configured user auth remains in effect") -} - -func TestUserHTTPRejectsActorCredentials(t *testing.T) { - issuer := jwttest.New(t) - handler := auth.AuthnMiddleware(runtimeauth.Users(&authimpl.InsecureAuthenticator{}, issuer.URL))(http.NotFoundHandler()) - token := "Bearer " + issuer.Token(t, issuer.Claims("team-a", "session-"+uuid.NewString(), "actor-uid")) - for _, path := range []string{"/mcp", "/agents/team-a/agent"} { - for _, test := range []struct { - token string - status int - }{ - {"", http.StatusNotFound}, {token, http.StatusUnauthorized}, - } { - request := httptest.NewRequest(http.MethodPost, path, nil) - request.Header.Set("Authorization", test.token) - response := httptest.NewRecorder() - handler.ServeHTTP(response, request) - require.Equal(t, test.status, response.Code) - } - } -} diff --git a/go/core/internal/service/memory/service.go b/go/core/internal/service/memory/service.go index f2f3a7eda1..556825a9c0 100644 --- a/go/core/internal/service/memory/service.go +++ b/go/core/internal/service/memory/service.go @@ -63,12 +63,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) } @@ -76,7 +77,7 @@ func NewService(store Store, options ...Option) *Service { } func (s *Service) Add(ctx context.Context, input Input) (string, error) { - agentName, userID, err := s.scope(ctx) + agentName, userID, err := s.scope(ctx, auth.VerbCreate) if err != nil { return "", err } @@ -111,7 +112,7 @@ 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) + agentName, userID, err := s.scope(ctx, auth.VerbCreate) if err != nil { return 0, err } @@ -134,7 +135,7 @@ func (s *Service) AddBatch(ctx context.Context, inputs []Input) (int, error) { } func (s *Service) Search(ctx context.Context, request SearchRequest) ([]SearchResult, error) { - agentName, userID, err := s.scope(ctx) + agentName, userID, err := s.scope(ctx, auth.VerbGet) if err != nil { return nil, err } @@ -183,7 +184,7 @@ func (s *Service) Search(ctx context.Context, request SearchRequest) ([]SearchRe } func (s *Service) List(ctx context.Context, agentName string) ([]database.Memory, error) { - agentName, userID, err := s.managementScope(ctx, agentName) + agentName, userID, err := s.managementScope(ctx, auth.VerbList, agentName) if err != nil { return nil, err } @@ -201,7 +202,7 @@ func (s *Service) List(ctx context.Context, agentName string) ([]database.Memory } func (s *Service) Delete(ctx context.Context, agentName string) error { - agentName, userID, err := s.managementScope(ctx, agentName) + agentName, userID, err := s.managementScope(ctx, auth.VerbDelete, agentName) if err != nil { return err } @@ -219,18 +220,18 @@ func (s *Service) Delete(ctx context.Context, agentName string) 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, agentName string) (string, string, error) { +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 userID := session.Principal().User.ID; userID != "" { - return agentName, userID, 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) + 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) (string, string, error) { +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) } @@ -241,7 +242,19 @@ func (s *Service) scope(ctx context.Context) (string, string, error) { if session.GetAgent().GetNamespace() == "" || session.GetAgent().GetName() == "" || session.Creator == "" { return "", "", serviceerrors.NewPermissionDenied("actor has no memory scope", nil) } - return session.Agent.Namespace + "/" + session.Agent.Name, session.Creator, 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) (*database.Memory, error) { diff --git a/go/core/internal/service/memory/service_test.go b/go/core/internal/service/memory/service_test.go index d069c50e67..8e0e676109 100644 --- a/go/core/internal/service/memory/service_test.go +++ b/go/core/internal/service/memory/service_test.go @@ -66,7 +66,7 @@ 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(memoryContext(t), Input{ AgentName: "team-a/agent", @@ -83,7 +83,7 @@ func TestAddDefaultsMetadataAndTTL(t *testing.T) { } func TestAddAndBatchValidation(t *testing.T) { - service := NewService(&memoryStore{}) + service := NewService(&memoryStore{}, auth.NoopAuthorizer{}) _, err := service.Add(memoryContext(t), Input{AgentName: "team-a/agent", UserID: "user", Vector: vector(16, 1)}) assert.True(t, serviceerrors.IsCode(err, serviceerrors.CodeInvalidArgument)) @@ -107,7 +107,7 @@ 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(memoryContext(t), SearchRequest{ AgentName: "team-a/agent", @@ -125,7 +125,7 @@ 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(memoryContext(t), "") require.NoError(t, err) @@ -171,7 +171,7 @@ func memoryContext(t *testing.T) context.Context { func TestActorMemoryScopeIgnoresCallerIdentity(t *testing.T) { store := &memoryStore{} - service := NewService(store) + service := NewService(store, auth.NoopAuthorizer{}) ctx := memoryContext(t) input := Input{AgentName: "other/agent", UserID: "victim", Content: "scoped", Vector: vector(VectorDimension, 1)} _, err := service.Add(ctx, input) @@ -207,7 +207,7 @@ func TestMemoryRejectsUnboundActors(t *testing.T) { } { t.Run(test.name, func(t *testing.T) { store := &memoryStore{bindingErr: test.bindingErr} - service := NewService(store) + service := NewService(store, auth.NoopAuthorizer{}) ctx := t.Context() if test.authenticated { ctx = memoryContext(t) @@ -231,7 +231,7 @@ func TestMemoryRejectsUnboundActors(t *testing.T) { func TestUserMemoryManagementDoesNotRequireRuntime(t *testing.T) { store := &memoryStore{bindingErr: database.ErrNotFound} - service := NewService(store) + 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"} { @@ -248,3 +248,23 @@ func TestUserMemoryManagementDoesNotRequireRuntime(t *testing.T) { 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/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/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/pkg/app/app.go b/go/core/pkg/app/app.go index 48ec89cecf..37aedd4c07 100644 --- a/go/core/pkg/app/app.go +++ b/go/core/pkg/app/app.go @@ -326,13 +326,14 @@ func Run(ctx context.Context, input *configv1.ControllerConfig, opts Options) er } authenticator, authorizer := opts.resolve() - authenticator = runtimeauth.Users(authenticator, cfg.GetSubstrate().GetActorJwtIssuer()) + 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) @@ -429,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: runtimeauth.NewAuthenticator(actorVerifier), ShareStore: store, ModelService: models, ToolService: tools, diff --git a/go/core/pkg/auth/auth.go b/go/core/pkg/auth/auth.go index e8d419cad3..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 ( From 1f13893537d25b07fd067a25962bd73336b43348 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 20 Oct 2026 19:23:08 +0000 Subject: [PATCH 09/11] test(auth): align runtime E2E rejection codes with shared auth Signed-off-by: Eitan Yarmush --- go/core/test/e2e/runtime_auth_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/go/core/test/e2e/runtime_auth_test.go b/go/core/test/e2e/runtime_auth_test.go index 2f043fe0ce..8798fe4b6e 100644 --- a/go/core/test/e2e/runtime_auth_test.go +++ b/go/core/test/e2e/runtime_auth_test.go @@ -30,6 +30,8 @@ func TestRuntimeActorJWTAuthentication(t *testing.T) { 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 @@ -42,9 +44,9 @@ func TestRuntimeActorJWTAuthentication(t *testing.T) { 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.Unauthenticated, status.Code(err)) + require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) _, err = memory.Search(ctx, &runtimev1.MemoryServiceSearchRequest{AgentName: "forged", UserId: "forged"}) //nolint:staticcheck // Legacy identity fields cannot authenticate a caller. - require.Equal(t, codes.Unauthenticated, status.Code(err)) + require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) }) } }) From ac6aab37fe9e290a281242c15a25241274799664 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 20 Oct 2026 21:59:04 +0000 Subject: [PATCH 10/11] test(e2e): report failed egress control requests Signed-off-by: Eitan Yarmush --- go/core/test/e2e/egress_test.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 From 2f7e50e4a9caceea6536345888abadbc5ec734e0 Mon Sep 17 00:00:00 2001 From: Eitan Yarmush Date: Tue, 20 Oct 2026 22:48:22 +0000 Subject: [PATCH 11/11] refactor(memory): remove caller-supplied ownership fields Signed-off-by: Eitan Yarmush --- go/api/gen/kagent/runtime/v1/memory.pb.go | 126 ++++-------------- .../gen/kagent/runtime/v1/memory_grpc.pb.go | 8 +- go/core/internal/grpcserver/memory_test.go | 13 +- .../internal/grpcserver/task_store_test.go | 2 +- go/core/internal/service/memory/service.go | 49 +++---- .../internal/service/memory/service_test.go | 24 ++-- go/core/test/e2e/runtime_auth_test.go | 2 +- proto/buf.yaml | 5 + proto/kagent/runtime/v1/memory.proto | 28 ++-- .../src/kagent/runtime/v1/memory_pb2.py | 68 ++++------ .../src/kagent/runtime/v1/memory_pb2.pyi | 28 ++-- .../src/kagent/runtime/v1/memory_pb2_grpc.py | 12 +- .../kagent-proto/tests/test_memory_client.py | 2 +- 13 files changed, 121 insertions(+), 246 deletions(-) diff --git a/go/api/gen/kagent/runtime/v1/memory.pb.go b/go/api/gen/kagent/runtime/v1/memory.pb.go index 6af9071f92..de97eed615 100644 --- a/go/api/gen/kagent/runtime/v1/memory.pb.go +++ b/go/api/gen/kagent/runtime/v1/memory.pb.go @@ -24,19 +24,11 @@ const ( ) type SessionMemoryInput struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Ignored. The agent is derived from the actor's persisted session. - // - // Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. - AgentName string `protobuf:"bytes,1,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` - // Ignored. The user is derived from the actor's persisted session owner. - // - // Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. - 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"` - TtlDays *int32 `protobuf:"varint,6,opt,name=ttl_days,json=ttlDays,proto3,oneof" json:"ttl_days,omitempty"` + state protoimpl.MessageState `protogen:"open.v1"` + 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"` + TtlDays *int32 `protobuf:"varint,6,opt,name=ttl_days,json=ttlDays,proto3,oneof" json:"ttl_days,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -71,22 +63,6 @@ func (*SessionMemoryInput) Descriptor() ([]byte, []int) { return file_kagent_runtime_v1_memory_proto_rawDescGZIP(), []int{0} } -// Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. -func (x *SessionMemoryInput) GetAgentName() string { - if x != nil { - return x.AgentName - } - return "" -} - -// Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. -func (x *SessionMemoryInput) GetUserId() string { - if x != nil { - return x.UserId - } - return "" -} - func (x *SessionMemoryInput) GetContent() string { if x != nil { return x.Content @@ -444,18 +420,10 @@ func (x *MemoryServiceAddSessionBatchResponse) GetCount() int32 { } type MemoryServiceSearchRequest struct { - state protoimpl.MessageState `protogen:"open.v1"` - // Ignored. The agent is derived from the actor's persisted session. - // - // Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. - AgentName string `protobuf:"bytes,1,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` - // Ignored. The user is derived from the actor's persisted session owner. - // - // Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. - 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"` + state protoimpl.MessageState `protogen:"open.v1"` + 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"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -490,22 +458,6 @@ func (*MemoryServiceSearchRequest) Descriptor() ([]byte, []int) { return file_kagent_runtime_v1_memory_proto_rawDescGZIP(), []int{7} } -// Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. -func (x *MemoryServiceSearchRequest) GetAgentName() string { - if x != nil { - return x.AgentName - } - return "" -} - -// Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. -func (x *MemoryServiceSearchRequest) GetUserId() string { - if x != nil { - return x.UserId - } - return "" -} - func (x *MemoryServiceSearchRequest) GetVector() []float32 { if x != nil { return x.Vector @@ -576,12 +528,7 @@ type MemoryServiceListRequest struct { // 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"` - // Ignored. User calls use the authenticated user; actor calls use the - // persisted session owner. Neither path can select another user. - // - // Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. - UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + AgentName string `protobuf:"bytes,1,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -623,14 +570,6 @@ func (x *MemoryServiceListRequest) GetAgentName() string { return "" } -// Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. -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"` @@ -680,12 +619,7 @@ type MemoryServiceDeleteRequest struct { // 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"` - // Ignored. User calls use the authenticated user; actor calls use the - // persisted session owner. Neither path can select another user. - // - // Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. - UserId string `protobuf:"bytes,2,opt,name=user_id,json=userId,proto3" json:"user_id,omitempty"` + AgentName string `protobuf:"bytes,1,opt,name=agent_name,json=agentName,proto3" json:"agent_name,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -727,14 +661,6 @@ func (x *MemoryServiceDeleteRequest) GetAgentName() string { return "" } -// Deprecated: Marked as deprecated in kagent/runtime/v1/memory.proto. -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"` @@ -783,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\"\xe8\x01\n" + - "\x12SessionMemoryInput\x12!\n" + - "\n" + - "agent_name\x18\x01 \x01(\tB\x02\x18\x01R\tagentName\x12\x1b\n" + - "\auser_id\x18\x02 \x01(\tB\x02\x18\x01R\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" + @@ -815,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\"\xc9\x01\n" + - "\x1aMemoryServiceSearchRequest\x12!\n" + - "\n" + - "agent_name\x18\x01 \x01(\tB\x02\x18\x01R\tagentName\x12\x1b\n" + - "\auser_id\x18\x02 \x01(\tB\x02\x18\x01R\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\"V\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\x1b\n" + - "\auser_id\x18\x02 \x01(\tB\x02\x18\x01R\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\"X\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\x1b\n" + - "\auser_id\x18\x02 \x01(\tB\x02\x18\x01R\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 e2b05b24ea..f2a02810ef 100644 --- a/go/api/gen/kagent/runtime/v1/memory_grpc.pb.go +++ b/go/api/gen/kagent/runtime/v1/memory_grpc.pb.go @@ -31,8 +31,8 @@ const ( // 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; request identity fields -// cannot override that scope. List and Delete also accept user authentication. +// 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) @@ -109,8 +109,8 @@ func (c *memoryServiceClient) Delete(ctx context.Context, in *MemoryServiceDelet // 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; request identity fields -// cannot override that scope. List and Delete also accept user authentication. +// 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) diff --git a/go/core/internal/grpcserver/memory_test.go b/go/core/internal/grpcserver/memory_test.go index 21d3e1c0cc..d3bbc38e73 100644 --- a/go/core/internal/grpcserver/memory_test.go +++ b/go/core/internal/grpcserver/memory_test.go @@ -59,7 +59,6 @@ func (s *userMemoryStore) DeleteAgentMemory(_ context.Context, agentName, userID return nil } -//nolint:staticcheck // Forged legacy identity fields must not override authenticated memory scope. func TestMemoryUsersAndActorsShareEndpointWithSeparateScopes(t *testing.T) { id := uuid.NewString() store := &userMemoryStore{memories: []database.Memory{ @@ -90,8 +89,8 @@ func TestMemoryUsersAndActorsShareEndpointWithSeparateScopes(t *testing.T) { require.NoError(t, err) t.Cleanup(func() { require.NoError(t, conn.Close()) }) client := runtimev1.NewMemoryServiceClient(conn) - list := &runtimev1.MemoryServiceListRequest{AgentName: "team/agent", UserId: "bob"} - deleteRequest := &runtimev1.MemoryServiceDeleteRequest{AgentName: "team/agent", UserId: "bob"} + list := &runtimev1.MemoryServiceListRequest{AgentName: "team/agent"} + deleteRequest := &runtimev1.MemoryServiceDeleteRequest{AgentName: "team/agent"} for _, claims := range []string{"", `{"iss":"` + issuer.URL + `","sub":"alice"}`} { unauthenticated := t.Context() @@ -105,7 +104,7 @@ func TestMemoryUsersAndActorsShareEndpointWithSeparateScopes(t *testing.T) { } 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", UserId: "alice"} + actorList := &runtimev1.MemoryServiceListRequest{AgentName: "team/other"} listed, err := client.List(actor, actorList) require.NoError(t, err) require.Len(t, listed.Memories, 1) @@ -114,8 +113,8 @@ func TestMemoryUsersAndActorsShareEndpointWithSeparateScopes(t *testing.T) { 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. Neither the request - // user_id nor a conflicting forwarded user header selects another user's data. + // 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) @@ -130,7 +129,7 @@ func TestMemoryUsersAndActorsShareEndpointWithSeparateScopes(t *testing.T) { 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", UserId: "alice"}) + _, 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/task_store_test.go b/go/core/internal/grpcserver/task_store_test.go index 3cd18c9548..e3d66a7ca6 100644 --- a/go/core/internal/grpcserver/task_store_test.go +++ b/go/core/internal/grpcserver/task_store_test.go @@ -189,7 +189,7 @@ func TestRuntimeTaskStoreThroughGRPC(t *testing.T) { 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", UserId: "another-user"}) //nolint:staticcheck // Legacy user_id must not override actor scope. + _, 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) diff --git a/go/core/internal/service/memory/service.go b/go/core/internal/service/memory/service.go index 556825a9c0..f069e9aeff 100644 --- a/go/core/internal/service/memory/service.go +++ b/go/core/internal/service/memory/service.go @@ -30,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 { @@ -81,14 +77,13 @@ func (s *Service) Add(ctx context.Context, input Input) (string, error) { if err != nil { return "", err } - input.AgentName, input.UserID = agentName, userID - if err := validateInput(input, "Missing required fields (agent_name, user_id, vector)"); err != nil { + 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 } @@ -118,11 +113,10 @@ func (s *Service) AddBatch(ctx context.Context, inputs []Input) (int, error) { } memories := make([]*database.Memory, 0, len(inputs)) for _, input := range inputs { - input.AgentName, input.UserID = agentName, userID - 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 } @@ -139,10 +133,6 @@ func (s *Service) Search(ctx context.Context, request SearchRequest) ([]SearchRe if err != nil { return nil, err } - request.AgentName, request.UserID = agentName, userID - if request.AgentName == "" || request.UserID == "" || len(request.Vector) == 0 { - return nil, serviceerrors.NewInvalidArgument("Missing required fields (agent_name, user_id, vector)", nil) - } if err := validateVector(request.Vector); err != nil { return nil, err } @@ -154,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, ) @@ -257,7 +247,7 @@ func (s *Service) authorize(ctx context.Context, verb auth.Verb, agentName strin return nil } -func (s *Service) toMemory(input Input) (*database.Memory, error) { +func (s *Service) toMemory(input Input, agentName, userID string) (*database.Memory, error) { metadata := input.Metadata if len(metadata) == 0 { metadata = json.RawMessage("{}") @@ -270,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), @@ -279,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 8e0e676109..ee3ada8ad3 100644 --- a/go/core/internal/service/memory/service_test.go +++ b/go/core/internal/service/memory/service_test.go @@ -69,10 +69,8 @@ func TestAddDefaultsMetadataAndTTL(t *testing.T) { service := NewService(store, auth.NoopAuthorizer{}, WithClock(func() time.Time { return now })) id, err := service.Add(memoryContext(t), Input{ - AgentName: "team-a/agent", - UserID: "user", - Content: "remember this", - Vector: vector(VectorDimension, 0.25), + Content: "remember this", + Vector: vector(VectorDimension, 0.25), }) require.NoError(t, err) assert.Equal(t, "memory-1", id) @@ -85,11 +83,11 @@ func TestAddDefaultsMetadataAndTTL(t *testing.T) { func TestAddAndBatchValidation(t *testing.T) { service := NewService(&memoryStore{}, auth.NoopAuthorizer{}) - _, err := service.Add(memoryContext(t), Input{AgentName: "team-a/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(memoryContext(t), Input{AgentName: "team-a/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(memoryContext(t), nil) @@ -110,10 +108,8 @@ func TestSearchDefaultsFiltersAndNormalizesMetadata(t *testing.T) { service := NewService(store, auth.NoopAuthorizer{}) results, err := service.Search(memoryContext(t), SearchRequest{ - AgentName: "team-a/agent", - UserID: "user", - Vector: vector(VectorDimension, 0.25), - MinScore: 0.5, + Vector: vector(VectorDimension, 0.25), + MinScore: 0.5, }) require.NoError(t, err) assert.Equal(t, DefaultSearchLimit, store.searchLimit) @@ -135,7 +131,7 @@ func TestListDeleteAndStoreErrors(t *testing.T) { assert.Equal(t, "user", store.deletedUser) store.err = errors.New("database unavailable") - _, err = service.Search(memoryContext(t), SearchRequest{AgentName: "team-a/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") } @@ -169,11 +165,11 @@ func memoryContext(t *testing.T) context.Context { return auth.AuthSessionTo(t.Context(), session) } -func TestActorMemoryScopeIgnoresCallerIdentity(t *testing.T) { +func TestActorMemoryUsesSessionScope(t *testing.T) { store := &memoryStore{} service := NewService(store, auth.NoopAuthorizer{}) ctx := memoryContext(t) - input := Input{AgentName: "other/agent", UserID: "victim", Content: "scoped", Vector: vector(VectorDimension, 1)} + input := Input{Content: "scoped", Vector: vector(VectorDimension, 1)} _, err := service.Add(ctx, input) require.NoError(t, err) _, err = service.AddBatch(ctx, []Input{input}) @@ -190,7 +186,7 @@ func TestActorMemoryScopeIgnoresCallerIdentity(t *testing.T) { require.NoError(t, err) require.Equal(t, "team-a/agent", store.readAgent) require.Equal(t, "user", store.readUser) - _, err = service.Search(ctx, SearchRequest{AgentName: "other/agent", UserID: "victim", Vector: input.Vector}) + _, 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) diff --git a/go/core/test/e2e/runtime_auth_test.go b/go/core/test/e2e/runtime_auth_test.go index 8798fe4b6e..c15a5a4c74 100644 --- a/go/core/test/e2e/runtime_auth_test.go +++ b/go/core/test/e2e/runtime_auth_test.go @@ -45,7 +45,7 @@ func TestRuntimeActorJWTAuthentication(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{AgentName: "forged", UserId: "forged"}) //nolint:staticcheck // Legacy identity fields cannot authenticate a caller. + _, err = memory.Search(ctx, &runtimev1.MemoryServiceSearchRequest{}) require.Equal(t, codes.PermissionDenied, status.Code(err), "%v", err) }) } 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/runtime/v1/memory.proto b/proto/kagent/runtime/v1/memory.proto index 4af658434a..0651311102 100644 --- a/proto/kagent/runtime/v1/memory.proto +++ b/proto/kagent/runtime/v1/memory.proto @@ -8,8 +8,8 @@ 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; request identity fields -// cannot override that scope. List and Delete also accept user authentication. +// 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); @@ -24,10 +24,8 @@ service MemoryService { } message SessionMemoryInput { - // Ignored. The agent is derived from the actor's persisted session. - string agent_name = 1 [deprecated = true]; - // Ignored. The user is derived from the actor's persisted session owner. - string user_id = 2 [deprecated = true]; + reserved 1, 2; + reserved "agent_name", "user_id"; string content = 3; repeated float vector = 4; google.protobuf.Struct metadata = 5; @@ -67,10 +65,8 @@ message MemoryServiceAddSessionBatchResponse { } message MemoryServiceSearchRequest { - // Ignored. The agent is derived from the actor's persisted session. - string agent_name = 1 [deprecated = true]; - // Ignored. The user is derived from the actor's persisted session owner. - string user_id = 2 [deprecated = true]; + reserved 1, 2; + reserved "agent_name", "user_id"; repeated float vector = 3; optional int32 limit = 4; optional double min_score = 5; @@ -81,13 +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; - // Ignored. User calls use the authenticated user; actor calls use the - // persisted session owner. Neither path can select another user. - string user_id = 2 [deprecated = true]; } message MemoryServiceListResponse { @@ -95,13 +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; - // Ignored. User calls use the authenticated user; actor calls use the - // persisted session owner. Neither path can select another user. - string user_id = 2 [deprecated = true]; } message MemoryServiceDeleteResponse { 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 4f8f58a820..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\"\xe8\x01\n\x12SessionMemoryInput\x12!\n\nagent_name\x18\x01 \x01(\tB\x02\x18\x01R\tagentName\x12\x1b\n\x07user_id\x18\x02 \x01(\tB\x02\x18\x01R\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\"\xc9\x01\n\x1aMemoryServiceSearchRequest\x12!\n\nagent_name\x18\x01 \x01(\tB\x02\x18\x01R\tagentName\x12\x1b\n\x07user_id\x18\x02 \x01(\tB\x02\x18\x01R\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\"V\n\x18MemoryServiceListRequest\x12\x1d\n\nagent_name\x18\x01 \x01(\tR\tagentName\x12\x1b\n\x07user_id\x18\x02 \x01(\tB\x02\x18\x01R\x06userId\"Y\n\x19MemoryServiceListResponse\x12<\n\x08memories\x18\x01 \x03(\x0b\x32 .kagent.runtime.v1.MemorySummaryR\x08memories\"X\n\x1aMemoryServiceDeleteRequest\x12\x1d\n\nagent_name\x18\x01 \x01(\tR\tagentName\x12\x1b\n\x07user_id\x18\x02 \x01(\tB\x02\x18\x01R\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) @@ -34,44 +34,32 @@ if not _descriptor._USE_C_DESCRIPTORS: _globals['DESCRIPTOR']._loaded_options = None _globals['DESCRIPTOR']._serialized_options = b'ZCgithub.com/kagent-dev/kagent/go/api/gen/kagent/runtime/v1;runtimev1' - _globals['_SESSIONMEMORYINPUT'].fields_by_name['agent_name']._loaded_options = None - _globals['_SESSIONMEMORYINPUT'].fields_by_name['agent_name']._serialized_options = b'\030\001' - _globals['_SESSIONMEMORYINPUT'].fields_by_name['user_id']._loaded_options = None - _globals['_SESSIONMEMORYINPUT'].fields_by_name['user_id']._serialized_options = b'\030\001' - _globals['_MEMORYSERVICESEARCHREQUEST'].fields_by_name['agent_name']._loaded_options = None - _globals['_MEMORYSERVICESEARCHREQUEST'].fields_by_name['agent_name']._serialized_options = b'\030\001' - _globals['_MEMORYSERVICESEARCHREQUEST'].fields_by_name['user_id']._loaded_options = None - _globals['_MEMORYSERVICESEARCHREQUEST'].fields_by_name['user_id']._serialized_options = b'\030\001' - _globals['_MEMORYSERVICELISTREQUEST'].fields_by_name['user_id']._loaded_options = None - _globals['_MEMORYSERVICELISTREQUEST'].fields_by_name['user_id']._serialized_options = b'\030\001' - _globals['_MEMORYSERVICEDELETEREQUEST'].fields_by_name['user_id']._loaded_options = None - _globals['_MEMORYSERVICEDELETEREQUEST'].fields_by_name['user_id']._serialized_options = b'\030\001' _globals['_SESSIONMEMORYINPUT']._serialized_start=117 - _globals['_SESSIONMEMORYINPUT']._serialized_end=349 - _globals['_MEMORYSEARCHRESULT']._serialized_start=352 - _globals['_MEMORYSEARCHRESULT']._serialized_end=548 - _globals['_MEMORYSUMMARY']._serialized_start=551 - _globals['_MEMORYSUMMARY']._serialized_end=761 - _globals['_MEMORYSERVICEADDSESSIONREQUEST']._serialized_start=763 - _globals['_MEMORYSERVICEADDSESSIONREQUEST']._serialized_end=858 - _globals['_MEMORYSERVICEADDSESSIONRESPONSE']._serialized_start=860 - _globals['_MEMORYSERVICEADDSESSIONRESPONSE']._serialized_end=909 - _globals['_MEMORYSERVICEADDSESSIONBATCHREQUEST']._serialized_start=911 - _globals['_MEMORYSERVICEADDSESSIONBATCHREQUEST']._serialized_end=1009 - _globals['_MEMORYSERVICEADDSESSIONBATCHRESPONSE']._serialized_start=1011 - _globals['_MEMORYSERVICEADDSESSIONBATCHRESPONSE']._serialized_end=1071 - _globals['_MEMORYSERVICESEARCHREQUEST']._serialized_start=1074 - _globals['_MEMORYSERVICESEARCHREQUEST']._serialized_end=1275 - _globals['_MEMORYSERVICESEARCHRESPONSE']._serialized_start=1277 - _globals['_MEMORYSERVICESEARCHRESPONSE']._serialized_end=1373 - _globals['_MEMORYSERVICELISTREQUEST']._serialized_start=1375 - _globals['_MEMORYSERVICELISTREQUEST']._serialized_end=1461 - _globals['_MEMORYSERVICELISTRESPONSE']._serialized_start=1463 - _globals['_MEMORYSERVICELISTRESPONSE']._serialized_end=1552 - _globals['_MEMORYSERVICEDELETEREQUEST']._serialized_start=1554 - _globals['_MEMORYSERVICEDELETEREQUEST']._serialized_end=1642 - _globals['_MEMORYSERVICEDELETERESPONSE']._serialized_start=1644 - _globals['_MEMORYSERVICEDELETERESPONSE']._serialized_end=1697 - _globals['_MEMORYSERVICE']._serialized_start=1700 - _globals['_MEMORYSERVICE']._serialized_end=2274 + _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 468da6599b..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 @@ -7,8 +7,8 @@ class MemoryServiceStub: """Memory is scoped to an agent and a user. Actor calls derive both from the - persisted session bound to the verified Actor JWT; request identity fields - cannot override that scope. List and Delete also accept user authentication. + 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): @@ -46,8 +46,8 @@ def __init__(self, channel): class MemoryServiceServicer: """Memory is scoped to an agent and a user. Actor calls derive both from the - persisted session bound to the verified Actor JWT; request identity fields - cannot override that scope. List and Delete also accept user authentication. + 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): @@ -123,8 +123,8 @@ def add_MemoryServiceServicer_to_server(servicer, server): # This class is part of an EXPERIMENTAL API. class MemoryService: """Memory is scoped to an agent and a user. Actor calls derive both from the - persisted session bound to the verified Actor JWT; request identity fields - cannot override that scope. List and Delete also accept user authentication. + persisted session bound to the verified Actor JWT. List and Delete also + accept user authentication and use the authenticated user's identity. """ @staticmethod 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"),