From 9ead7b380a331b50a4e38014a6877fdc2ec6ba13 Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Fri, 2 Oct 2026 09:08:52 +0200 Subject: [PATCH 1/3] feat(credprovider): a provider bounds how long the injector reuses its answer FetchSecretResponse.max_age: unset keeps the injector's default, zero makes it fetch the secret again for every request, and an injector never reuses an answer past its own default. Signed-off-by: QuentinBisson --- ...edential-provider-bounds-injector-reuse.md | 29 ++++++++++++++ pkg/proto/credproviderpb/credprovider.pb.go | 38 +++++++++++++------ pkg/proto/credproviderpb/credprovider.proto | 8 ++++ 3 files changed, 64 insertions(+), 11 deletions(-) create mode 100644 fork/credential-provider-bounds-injector-reuse.md diff --git a/fork/credential-provider-bounds-injector-reuse.md b/fork/credential-provider-bounds-injector-reuse.md new file mode 100644 index 0000000000..e05f9ace88 --- /dev/null +++ b/fork/credential-provider-bounds-injector-reuse.md @@ -0,0 +1,29 @@ +--- +id: credential-provider-bounds-injector-reuse +fork: substrate +theme: credential-injection +category: feature +status: carried +commits: + - 'feat(credprovider): a provider bounds how long the injector reuses its answer' + - "docs(credprovider): say how a provider bounds the gateway's credential cache" +upstream: + outcome: to-file + refs: [] + exit: upstream PR merged or declined +depends_on: [] +verified: 2026-10-24 +--- +# A credential provider bounds how long the injector reuses its answer + +## Change + +`FetchSecretResponse.max_age` in `pkg/proto/credproviderpb/credprovider.proto`: unset keeps the injector's default, zero fetches again for every request, and no injector reuses an answer past its own default. atenet's Go injector keeps no cache and already fetches every request. `docs/kubernetes-credential-provider.md` says how a provider shortens the gateway's credential cache. + +## Reason + +The egress gateway agentgateway runs caches a secret by actor and URI for a fixed 300 s, so a provider whose answer depends on who acts on the actor at the moment (kagent answering with the token of the person whose turn runs) had it injected after that turn, into another person's next turn on a shared instance included. + +## History + +`feat/credential-max-age`, [#110](https://github.com/giantswarm/substrate/pull/110) (draft). The gateway side, giantswarm/agentgateway-upstream#37, was closed unmerged on 2026-10-19, parked with the caller credential provider. diff --git a/pkg/proto/credproviderpb/credprovider.pb.go b/pkg/proto/credproviderpb/credprovider.pb.go index 22021fd174..1713e493cd 100644 --- a/pkg/proto/credproviderpb/credprovider.pb.go +++ b/pkg/proto/credproviderpb/credprovider.pb.go @@ -23,6 +23,7 @@ package credproviderpb import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" reflect "reflect" sync "sync" unsafe "unsafe" @@ -97,7 +98,12 @@ func (x *FetchSecretRequest) GetActorSpiffeId() string { type FetchSecretResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // The raw bytes contains the secret. - OpaqueBytes []byte `protobuf:"bytes,1,opt,name=opaque_bytes,json=opaqueBytes,proto3" json:"opaque_bytes,omitempty"` + OpaqueBytes []byte `protobuf:"bytes,1,opt,name=opaque_bytes,json=opaqueBytes,proto3" json:"opaque_bytes,omitempty"` + // How long the injector may reuse the secret for the same actor and URI. + // Unset keeps the injector's default; zero makes it fetch the secret again + // for every request. An injector never reuses a secret longer than its own + // default, whatever the provider answers. + MaxAge *durationpb.Duration `protobuf:"bytes,2,opt,name=max_age,json=maxAge,proto3" json:"max_age,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -139,16 +145,24 @@ func (x *FetchSecretResponse) GetOpaqueBytes() []byte { return nil } +func (x *FetchSecretResponse) GetMaxAge() *durationpb.Duration { + if x != nil { + return x.MaxAge + } + return nil +} + var File_credprovider_proto protoreflect.FileDescriptor const file_credprovider_proto_rawDesc = "" + "\n" + - "\x12credprovider.proto\x12\fcredprovider\"N\n" + + "\x12credprovider.proto\x12\fcredprovider\x1a\x1egoogle/protobuf/duration.proto\"N\n" + "\x12FetchSecretRequest\x12\x10\n" + "\x03uri\x18\x01 \x01(\tR\x03uri\x12&\n" + - "\x0factor_spiffe_id\x18\x02 \x01(\tR\ractorSpiffeId\"=\n" + + "\x0factor_spiffe_id\x18\x02 \x01(\tR\ractorSpiffeId\"q\n" + "\x13FetchSecretResponse\x12&\n" + - "\fopaque_bytes\x18\x01 \x01(\fB\x03\x80\x01\x01R\vopaqueBytes2j\n" + + "\fopaque_bytes\x18\x01 \x01(\fB\x03\x80\x01\x01R\vopaqueBytes\x122\n" + + "\amax_age\x18\x02 \x01(\v2\x19.google.protobuf.DurationR\x06maxAge2j\n" + "\x12CredentialProvider\x12T\n" + "\vFetchSecret\x12 .credprovider.FetchSecretRequest\x1a!.credprovider.FetchSecretResponse\"\x00B?Z=github.com/agent-substrate/substrate/pkg/proto/credproviderpbb\x06proto3" @@ -168,15 +182,17 @@ var file_credprovider_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_credprovider_proto_goTypes = []any{ (*FetchSecretRequest)(nil), // 0: credprovider.FetchSecretRequest (*FetchSecretResponse)(nil), // 1: credprovider.FetchSecretResponse + (*durationpb.Duration)(nil), // 2: google.protobuf.Duration } var file_credprovider_proto_depIdxs = []int32{ - 0, // 0: credprovider.CredentialProvider.FetchSecret:input_type -> credprovider.FetchSecretRequest - 1, // 1: credprovider.CredentialProvider.FetchSecret:output_type -> credprovider.FetchSecretResponse - 1, // [1:2] is the sub-list for method output_type - 0, // [0:1] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name + 2, // 0: credprovider.FetchSecretResponse.max_age:type_name -> google.protobuf.Duration + 0, // 1: credprovider.CredentialProvider.FetchSecret:input_type -> credprovider.FetchSecretRequest + 1, // 2: credprovider.CredentialProvider.FetchSecret:output_type -> credprovider.FetchSecretResponse + 2, // [2:3] is the sub-list for method output_type + 1, // [1:2] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name } func init() { file_credprovider_proto_init() } diff --git a/pkg/proto/credproviderpb/credprovider.proto b/pkg/proto/credproviderpb/credprovider.proto index 97029b5b7e..be623a2e3b 100644 --- a/pkg/proto/credproviderpb/credprovider.proto +++ b/pkg/proto/credproviderpb/credprovider.proto @@ -16,6 +16,8 @@ syntax = "proto3"; package credprovider; +import "google/protobuf/duration.proto"; + option go_package = "github.com/agent-substrate/substrate/pkg/proto/credproviderpb"; // CredentialProvider is the plugin API a secret backend implements so Substrate @@ -66,4 +68,10 @@ message FetchSecretRequest { message FetchSecretResponse { // The raw bytes contains the secret. bytes opaque_bytes = 1 [ debug_redact = true ]; + + // How long the injector may reuse the secret for the same actor and URI. + // Unset keeps the injector's default; zero makes it fetch the secret again + // for every request. An injector never reuses a secret longer than its own + // default, whatever the provider answers. + google.protobuf.Duration max_age = 2; } From bef191677b34c3b6e5a1c98b3bf148876613ad3b Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Fri, 2 Oct 2026 10:42:47 +0200 Subject: [PATCH 2/3] docs(credprovider): say how a provider bounds the gateway's credential cache The Kubernetes provider guide described the egress gateway's five-minute credential cache as fixed. FetchSecretResponse.max_age lets a provider shorten it, down to a fetch for every request. Signed-off-by: QuentinBisson --- docs/kubernetes-credential-provider.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/kubernetes-credential-provider.md b/docs/kubernetes-credential-provider.md index b4a54df5aa..239c74ee69 100644 --- a/docs/kubernetes-credential-provider.md +++ b/docs/kubernetes-credential-provider.md @@ -10,7 +10,10 @@ directly over mTLS to inject credentials into HTTP and intercepted HTTPS request entry in that Kubernetes Secret. The `default` locator and an explicit key are required. The provider reads Kubernetes on every fetch and never persists or logs values. AGW caches successful credentials per actor and URI for five minutes, so rotation -can take that long to reach injected requests. +can take that long to reach injected requests. A provider bounds that reuse with +`FetchSecretResponse.max_age`: zero fetches the credential again for every +request, and no answer is reused past the five minutes. This provider leaves it +unset. A credential that is minted rather than read, such as the Google access token a Vertex AI actor needs, is the business of a provider of its own under its own From ba452b4cc11ded34f5219585bfa56ae827e5d552 Mon Sep 17 00:00:00 2001 From: QuentinBisson Date: Sat, 24 Oct 2026 13:04:37 +0200 Subject: [PATCH 3/3] docs(upgrade): the 1.17 to 1.18 step names the credential provider's max_age Signed-off-by: QuentinBisson --- docs/upgrade.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/upgrade.md b/docs/upgrade.md index e8084c5b3a..3f2b5bb049 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -1409,6 +1409,10 @@ survive it. - **During the roll:** an actor image only a pull secret can read is pulled once its node's atelet runs 1.18; a 1.17 atelet still pulls it anonymously or through the providers, as before. +- **Credential providers:** `FetchSecretResponse` gains `max_age`, how long + the injector may reuse an answer. A provider of 1.17 never sets it and + keeps the injector's default; atenet's injector caches nothing, so it + fetches every request whichever version answers. - **Goldens:** kept. - **Reset:** none. **Conversations lost:** none.