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 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. 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; }