Skip to content

Commit 24d5580

Browse files
Restrict Secret informer to bbr-managed labeled Secrets only
Signed-off-by: Abdallah Samara <abdallahsamabd@gmail.com>
1 parent f087210 commit 24d5580

13 files changed

Lines changed: 119 additions & 34 deletions

File tree

deploy/README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,30 @@
33
A chart to deploy payload-processing.
44

55
<!-- TODO we should pin to odh payload processing released tag -->
6+
7+
## RBAC and Secrets Access
8+
9+
The `apikey-injection` plugin watches Kubernetes Secrets to inject provider API
10+
keys (e.g. OpenAI, Anthropic) into outbound inference requests. Only Secrets
11+
labeled `inference.llm-d.ai/ipp-managed: "true"` are processed.
12+
13+
### Single-namespace mode (`multiNamespace: false`, the default)
14+
15+
A **Role** and **RoleBinding** are created in the release namespace. This matches
16+
[Kubernetes RBAC good practices](https://kubernetes.io/docs/concepts/security/rbac-good-practices/)
17+
(least privilege, prefer namespace-scoped bindings) and limits exposure called
18+
out in [#201](https://github.com/opendatahub-io/ai-gateway-payload-processing/issues/201).
19+
20+
### Multi-namespace mode (`multiNamespace: true`, opt-in)
21+
22+
A **ClusterRole** is created with `get`/`list`/`watch` on `secrets`. Kubernetes
23+
RBAC cannot scope `secrets` by label, so static analysis tools will still flag
24+
broad Secret access; only enable when ExternalModels and credential Secrets
25+
actually span namespaces.
26+
27+
Defense in depth on the workload:
28+
29+
- The Secret informer uses a **label selector** at list/watch time so only
30+
`ipp-managed` Secrets enter the cache (reduces blast radius if the process is
31+
compromised; it does not replace least-privilege RBAC). `Reconcile` still
32+
checks the label after `Get` before storing credentials.

deploy/payload-processing/templates/rbac.yaml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,11 @@ kind: ClusterRole
66
metadata:
77
name: {{ .Release.Name }}-externalmodels-reader
88
rules:
9-
# apikey-injection plugin: watches labeled Secrets
9+
# apikey-injection plugin: watches Secrets that carry the label
10+
# "inference.llm-d.ai/ipp-managed=true" to inject provider API keys.
11+
# A ClusterRole is required because Kubernetes RBAC does not support
12+
# label-based scoping; the apikey-injection Secret informer still applies a
13+
# label selector at list/watch time so only ipp-managed Secrets are cached.
1014
- apiGroups: [""]
1115
resources: ["secrets"]
1216
verbs: ["get", "list", "watch"]
@@ -35,7 +39,9 @@ metadata:
3539
name: {{ .Release.Name }}-externalmodels-reader
3640
namespace: {{ .Release.Namespace }}
3741
rules:
38-
# apikey-injection plugin: watches labeled Secrets
42+
# apikey-injection plugin: watches Secrets labeled
43+
# "inference.llm-d.ai/ipp-managed=true" in this namespace.
44+
# Scoped to a single namespace via Role (preferred over ClusterRole).
3945
- apiGroups: [""]
4046
resources: ["secrets"]
4147
verbs: ["get", "list", "watch"]

deploy/payload-processing/values.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ upstreamBbr:
99
pullPolicy: IfNotPresent
1010
port: 9004
1111
healthCheckPort: 9005
12-
multiNamespace: true
12+
# false = namespaced Role for secrets + externalmodels (default, least privilege; see #201).
13+
# true = ClusterRole when ExternalModels/credentials span multiple namespaces.
14+
multiNamespace: false
1315

1416
flags:
1517
# Log verbosity

docs/providers/anthropic.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ metadata:
6060
name: anthropic-api-key
6161
namespace: llm
6262
labels:
63-
inference.networking.k8s.io/bbr-managed: "true"
63+
inference.llm-d.ai/ipp-managed: "true"
6464
type: Opaque
6565
stringData:
6666
api-key: "sk-ant-api03-..."

docs/providers/azure-openai.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ metadata:
5252
name: azure-api-key
5353
namespace: llm
5454
labels:
55-
inference.networking.k8s.io/bbr-managed: "true"
55+
inference.llm-d.ai/ipp-managed: "true"
5656
type: Opaque
5757
stringData:
5858
api-key: "<AZURE_API_KEY>"

docs/providers/bedrock-openai.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ metadata:
6767
name: bedrock-api-key
6868
namespace: llm
6969
labels:
70-
inference.networking.k8s.io/bbr-managed: "true"
70+
inference.llm-d.ai/ipp-managed: "true"
7171
type: Opaque
7272
stringData:
7373
api-key: "<BEDROCK_API_KEY>"

docs/providers/openai.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ metadata:
4141
name: openai-api-key
4242
namespace: llm
4343
labels:
44-
inference.networking.k8s.io/bbr-managed: "true"
44+
inference.llm-d.ai/ipp-managed: "true"
4545
type: Opaque
4646
stringData:
4747
api-key: "sk-proj-..."

docs/providers/vertex-openai.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ metadata:
9393
name: vertex-api-key
9494
namespace: llm
9595
labels:
96-
inference.networking.k8s.io/bbr-managed: "true"
96+
inference.llm-d.ai/ipp-managed: "true"
9797
type: Opaque
9898
stringData:
9999
api-key: "ya29.a0..." # OAuth token from gcloud auth print-access-token

pkg/plugins/apikey-injection/plugin.go

Lines changed: 66 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,17 @@ import (
2020
"context"
2121
"encoding/json"
2222
"fmt"
23+
"time"
2324

2425
corev1 "k8s.io/api/core/v1"
26+
"k8s.io/apimachinery/pkg/labels"
27+
ctrl "sigs.k8s.io/controller-runtime"
2528
"sigs.k8s.io/controller-runtime/pkg/builder"
29+
"sigs.k8s.io/controller-runtime/pkg/cache"
2630
"sigs.k8s.io/controller-runtime/pkg/client"
31+
"sigs.k8s.io/controller-runtime/pkg/handler"
2732
"sigs.k8s.io/controller-runtime/pkg/log"
33+
"sigs.k8s.io/controller-runtime/pkg/source"
2834
"sigs.k8s.io/gateway-api-inference-extension/pkg/bbr/framework"
2935
errcommon "sigs.k8s.io/gateway-api-inference-extension/pkg/common/error"
3036
logutil "sigs.k8s.io/gateway-api-inference-extension/pkg/common/observability/logging"
@@ -45,23 +51,37 @@ var _ framework.RequestProcessor = &ApiKeyInjectionPlugin{}
4551

4652
// APIKeyInjectionFactory defines the factory function for ApiKeyInjectionPlugin.
4753
func APIKeyInjectionFactory(name string, _ json.RawMessage, handle framework.Handle) (framework.BBRPlugin, error) {
48-
plugin, err := NewAPIKeyInjectionPlugin(handle.ReconcilerBuilder, handle.ClientReader())
54+
plugin, err := NewAPIKeyInjectionPlugin(handle.Context(), handle.ReconcilerBuilder, handle.ClientReader())
4955
if err != nil {
5056
return nil, fmt.Errorf("failed to create plugin '%s' - %w", APIKeyInjectionPluginType, err)
5157
}
5258

5359
return plugin.WithName(name), nil
5460
}
5561

56-
// NewAPIKeyInjectionPlugin creates a new apiKeyInjectionPlugin and returns its pointer
57-
func NewAPIKeyInjectionPlugin(reconcilerBuilder func() *builder.Builder, clientReader client.Reader) (*ApiKeyInjectionPlugin, error) {
62+
// NewAPIKeyInjectionPlugin creates a new apiKeyInjectionPlugin and returns its pointer.
63+
// It sets up a label-filtered informer cache so only Secrets matching the
64+
// ipp-managed label are listed from the API server; Reconcile still checks the
65+
// label after Get before updating the store.
66+
func NewAPIKeyInjectionPlugin(ctx context.Context, reconcilerBuilder func() *builder.Builder, clientReader client.Reader) (*ApiKeyInjectionPlugin, error) {
5867
store := newSecretStore()
5968
reconciler := &secretReconciler{
6069
Reader: clientReader,
6170
store: store,
6271
}
6372

64-
if err := reconcilerBuilder().For(&corev1.Secret{}).WithEventFilter(managedLabelPredicate()).Complete(reconciler); err != nil {
73+
filteredCache, err := newFilteredSecretCache(ctx)
74+
if err != nil {
75+
return nil, err
76+
}
77+
78+
var secretObj client.Object = &corev1.Secret{}
79+
if err := reconcilerBuilder().
80+
Named("apikey-injection-secret-watcher").
81+
WatchesRawSource(
82+
source.Kind(filteredCache, secretObj, &handler.EnqueueRequestForObject{}),
83+
).
84+
Complete(reconciler); err != nil {
6585
return nil, fmt.Errorf("failed to register Secret reconciler for plugin '%s' - %w", APIKeyInjectionPluginType, err)
6686
}
6787

@@ -71,9 +91,9 @@ func NewAPIKeyInjectionPlugin(reconcilerBuilder func() *builder.Builder, clientR
7191
Name: APIKeyInjectionPluginType,
7292
},
7393
authHeadersGenerators: map[string]auth.AuthHeadersGenerator{
74-
provider.OpenAI: &auth.SimpleAuthGenerator{HeaderName: "Authorization", HeaderValuePrefix: "Bearer "},
75-
provider.Anthropic: &auth.SimpleAuthGenerator{HeaderName: "x-api-key"},
76-
provider.AzureOpenAI: &auth.SimpleAuthGenerator{HeaderName: "api-key"},
94+
provider.OpenAI: &auth.SimpleAuthGenerator{HeaderName: "Authorization", HeaderValuePrefix: "Bearer "},
95+
provider.Anthropic: &auth.SimpleAuthGenerator{HeaderName: "x-api-key"},
96+
provider.AzureOpenAI: &auth.SimpleAuthGenerator{HeaderName: "api-key"},
7797
// provider.Vertex uses the native GenerateContent API — not used in 3.4 ExternalModel flow.
7898
// provider.Vertex: &auth.SimpleAuthGenerator{HeaderName: "Authorization", HeaderValuePrefix: "Bearer "},
7999
provider.VertexOpenAI: &auth.SimpleAuthGenerator{HeaderName: "Authorization", HeaderValuePrefix: "Bearer "},
@@ -145,3 +165,42 @@ func (p *ApiKeyInjectionPlugin) ProcessRequest(ctx context.Context, cycleState *
145165
log.FromContext(ctx).V(logutil.VERBOSE).Info("auth headers injected", "provider", providerName)
146166
return nil
147167
}
168+
169+
// newFilteredSecretCache creates a controller-runtime cache that restricts the
170+
// Secret informer to only list/watch Secrets labeled with the managed label.
171+
// This is a defense-in-depth measure: even though the RBAC ClusterRole grants
172+
// broad Secret access (required for cross-namespace watches), the informer
173+
// never fetches or caches unrelated Secrets.
174+
func newFilteredSecretCache(ctx context.Context) (cache.Cache, error) {
175+
cfg, err := ctrl.GetConfig()
176+
if err != nil {
177+
return nil, fmt.Errorf("failed to get rest config for filtered Secret cache: %w", err)
178+
}
179+
180+
filteredCache, err := cache.New(cfg, cache.Options{
181+
ByObject: map[client.Object]cache.ByObject{
182+
&corev1.Secret{}: {
183+
Label: labels.SelectorFromSet(labels.Set{
184+
managedLabel: "true",
185+
}),
186+
},
187+
},
188+
})
189+
if err != nil {
190+
return nil, fmt.Errorf("failed to create filtered Secret cache: %w", err)
191+
}
192+
193+
go func() {
194+
if err := filteredCache.Start(ctx); err != nil {
195+
ctrl.Log.WithName(APIKeyInjectionPluginType).Error(err, "filtered Secret cache stopped unexpectedly")
196+
}
197+
}()
198+
199+
syncCtx, cancel := context.WithTimeout(ctx, 2*time.Minute)
200+
defer cancel()
201+
if !filteredCache.WaitForCacheSync(syncCtx) {
202+
return nil, fmt.Errorf("filtered Secret cache failed to sync within deadline")
203+
}
204+
205+
return filteredCache, nil
206+
}

pkg/plugins/apikey-injection/reconciler.go

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -24,34 +24,20 @@ import (
2424
"k8s.io/apimachinery/pkg/api/errors"
2525
ctrl "sigs.k8s.io/controller-runtime"
2626
"sigs.k8s.io/controller-runtime/pkg/client"
27-
"sigs.k8s.io/controller-runtime/pkg/event"
2827
"sigs.k8s.io/controller-runtime/pkg/log"
29-
"sigs.k8s.io/controller-runtime/pkg/predicate"
3028
)
3129

3230
const (
3331
// managedLabel selects Secrets managed by the apikey-injection plugin.
34-
// Only Secrets carrying this label are watched by the reconciler.
35-
managedLabel = "inference.networking.k8s.io/bbr-managed"
32+
// The Secret informer uses this as a list/watch label selector; Reconcile
33+
// also requires this label before caching Secret data.
34+
managedLabel = "inference.llm-d.ai/ipp-managed"
3635
)
3736

3837
func hasManagedLabel(object client.Object) bool {
3938
return object.GetLabels()[managedLabel] == "true"
4039
}
4140

42-
// managedLabelPredicate filters events to only Secrets labeled with
43-
// "inference.networking.k8s.io/bbr-managed" = "true".
44-
// For updates, it accepts the event when either the old or new object carries
45-
// the label so that label-removal is visible to the reconciler.
46-
func managedLabelPredicate() predicate.Predicate {
47-
return predicate.Funcs{
48-
CreateFunc: func(e event.CreateEvent) bool { return hasManagedLabel(e.Object) },
49-
UpdateFunc: func(e event.UpdateEvent) bool { return hasManagedLabel(e.ObjectOld) || hasManagedLabel(e.ObjectNew) },
50-
DeleteFunc: func(e event.DeleteEvent) bool { return hasManagedLabel(e.Object) },
51-
GenericFunc: func(e event.GenericEvent) bool { return hasManagedLabel(e.Object) },
52-
}
53-
}
54-
5541
// secretReconciler watches Secrets and updates the secretStore.
5642
type secretReconciler struct {
5743
client.Reader

0 commit comments

Comments
 (0)