Skip to content

Commit 3c267b3

Browse files
jrhynessclaude
andcommitted
fix: address CodeRabbit PR review findings
Security fixes: - Add fail-closed validation for API keys without bound subscription - Reject API key requests missing x-maas-subscription header (403) - Fix tests to use bare subscription names matching production behavior Model listing improvements: - Include ownedBy in deduplication key to keep different MaaSModelRef resources separate even if they serve the same model - Update documentation to reflect new deduplication behavior - Update tests to expect separate entries for different MaaSModelRefs Controller fixes: - Use GetAPIReader instead of GetClient for pre-start cluster detection (cache not started yet, would return stale/missing data) - Add RBAC permission for config.openshift.io/authentications (get) - Deduplicate and sort aggregated policy lists to prevent spurious reconciles from non-deterministic Kubernetes List order - Fix malformed Rego syntax in auth-valid rule (use separate allow blocks) Test cleanup: - Remove unnecessary f-string prefixes from log.info calls (Ruff F541) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 1c8f061 commit 3c267b3

File tree

7 files changed

+115
-49
lines changed

7 files changed

+115
-49
lines changed

docs/content/configuration-and-management/model-listing-flow.md

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,16 +140,22 @@ All models in the response include a `subscriptions` array with metadata for eac
140140

141141
### Deduplication Behavior
142142

143-
When `X-MaaS-Return-All-Models: true` is used, models are deduplicated by `(id, url)` key:
143+
Models are deduplicated by `(id, url, ownedBy)` key:
144144

145-
- **Same id + same URL**: Single entry with subscriptions aggregated into the `subscriptions` array
146-
- **Same id + different URLs**: Separate entries (different model endpoints)
145+
- **Same id + same URL + same MaaSModelRef (ownedBy)**: Single entry with subscriptions aggregated into the `subscriptions` array
146+
- **Different id, URL, or MaaSModelRef**: Separate entries
147147

148-
**Example:**
149-
- Model `gpt-3.5` at URL `https://example.com/gpt-3.5` is accessible via subscriptions A and B
148+
**User token authentication** (multiple subscriptions):
149+
- Model `gpt-3.5` from MaaSModelRef `namespace-a/model-a` at URL `https://example.com/gpt-3.5` is accessible via subscriptions A and B
150150
- Result: One entry with `subscriptions: [{name: "A"}, {name: "B"}]`
151-
- Model `gpt-3.5` at URL `https://example.com/gpt-3.5-premium` is only in subscription B
152-
- Result: Separate entry with `subscriptions: [{name: "B"}]`
151+
- Model `gpt-3.5` from MaaSModelRef `namespace-b/model-b` at the same URL is only in subscription B
152+
- Result: Separate entry with `subscriptions: [{name: "B"}]` (different MaaSModelRef)
153+
- Model `gpt-3.5` at URL `https://example.com/gpt-3.5-premium` from `namespace-a/model-a` is only in subscription B
154+
- Result: Separate entry with `subscriptions: [{name: "B"}]` (different URL)
155+
156+
**API key authentication** (single subscription):
157+
- Deduplication handles edge cases where multiple MaaSModelRef resources point to the same model endpoint
158+
- Each unique MaaSModelRef resource appears as a separate entry
153159

154160
!!! tip "Subscription metadata fields"
155161
The `displayName` and `description` fields are read from the MaaSSubscription CRD's `spec.displayName` and `spec.description` fields. If these fields are not set in the CRD, they will be empty strings in the response.

maas-api/internal/api_keys/service.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"strings"
78
"time"
89

910
"github.com/google/uuid"
@@ -227,6 +228,17 @@ func (s *Service) ValidateAPIKey(ctx context.Context, key string) (*ValidationRe
227228
groups = []string{} // Return empty array if no groups stored
228229
}
229230

231+
// Fail closed: reject keys with no bound subscription (CWE-284)
232+
// This prevents legacy keys, bad migrations, or manual writes with empty subscription
233+
// from bypassing the "subscription bound at mint" access control invariant
234+
if strings.TrimSpace(metadata.Subscription) == "" {
235+
s.logger.Warn("API key missing bound subscription", "key_id", metadata.ID)
236+
return &ValidationResult{
237+
Valid: false,
238+
Reason: "key has no subscription bound",
239+
}, nil
240+
}
241+
230242
// Success - return user identity and groups for Authorino
231243
return &ValidationResult{
232244
Valid: true,

maas-api/internal/handlers/models.go

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -188,11 +188,23 @@ func (h *ModelsHandler) ListLLMs(c *gin.Context) {
188188
// For API keys: Authorino injects this from auth.metadata.apiKeyValidation.subscription
189189
// For user tokens: This header is not present (Authorino doesn't inject it)
190190
requestedSubscription := strings.TrimSpace(c.GetHeader("x-maas-subscription"))
191+
isAPIKeyRequest := strings.HasPrefix(authHeader, "Bearer sk-oai-")
192+
193+
// Fail closed: API keys without a bound subscription must be rejected
194+
if isAPIKeyRequest && requestedSubscription == "" {
195+
h.logger.Debug("API key request missing bound subscription header")
196+
c.JSON(http.StatusForbidden, gin.H{
197+
"error": gin.H{
198+
"message": "API key has no subscription bound",
199+
"type": "permission_error",
200+
}})
201+
return
202+
}
191203

192204
// Determine behavior based on auth method:
193205
// - API key with subscription → filter by that subscription (requestedSubscription != "")
194206
// - User token → return all accessible models (requestedSubscription == "")
195-
returnAllModels := requestedSubscription == ""
207+
returnAllModels := !isAPIKeyRequest && requestedSubscription == ""
196208

197209
// Get user context for subscription selection
198210
var userContext *token.UserContext
@@ -264,11 +276,13 @@ func (h *ModelsHandler) ListLLMs(c *gin.Context) {
264276
}
265277
} else {
266278
// Filter models by subscription(s) and aggregate subscriptions
267-
// Deduplication key is (model ID, URL) - models with the same ID and URL are
268-
// the same instance and should have their subscriptions aggregated into an array.
279+
// Deduplication key is (model ID, URL, OwnedBy) - models with the same ID, URL, and
280+
// MaaSModelRef (namespace/name) are the same instance and should have their
281+
// subscriptions aggregated into an array.
269282
type modelKey struct {
270-
id string
271-
url string
283+
id string
284+
url string
285+
ownedBy string
272286
}
273287
modelsByKey := make(map[modelKey]*models.Model)
274288

@@ -300,12 +314,12 @@ func (h *ModelsHandler) ListLLMs(c *gin.Context) {
300314
Description: sub.Description,
301315
}
302316

303-
// Create key from model ID and URL
317+
// Create key from model ID, URL, and OwnedBy (namespace/name of MaaSModelRef)
304318
urlStr := ""
305319
if model.URL != nil {
306320
urlStr = model.URL.String()
307321
}
308-
key := modelKey{id: model.ID, url: urlStr}
322+
key := modelKey{id: model.ID, url: urlStr, ownedBy: model.OwnedBy}
309323

310324
if existingModel, exists := modelsByKey[key]; exists {
311325
// Model already exists - append subscription if not already present
@@ -327,7 +341,10 @@ func (h *ModelsHandler) ListLLMs(c *gin.Context) {
327341
if keys[i].id != keys[j].id {
328342
return keys[i].id < keys[j].id
329343
}
330-
return keys[i].url < keys[j].url
344+
if keys[i].url != keys[j].url {
345+
return keys[i].url < keys[j].url
346+
}
347+
return keys[i].ownedBy < keys[j].ownedBy
331348
})
332349
for _, k := range keys {
333350
modelList = append(modelList, *modelsByKey[k])

maas-api/internal/handlers/models_test.go

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -402,9 +402,9 @@ func TestListingModelsWithSubscriptionHeader(t *testing.T) {
402402
testLogger := logger.Development()
403403

404404
// Create mock servers that require specific subscription headers
405-
// Use qualified names (namespace/name) to match the format sent by the handler
406-
premiumModelServer := createMockModelServerWithSubscriptionCheck(t, "premium-model", "test-namespace/premium")
407-
freeModelServer := createMockModelServerWithSubscriptionCheck(t, "free-model", "test-namespace/free")
405+
// Use bare subscription names to match what Authorino injects from API key validation
406+
premiumModelServer := createMockModelServerWithSubscriptionCheck(t, "premium-model", "premium")
407+
freeModelServer := createMockModelServerWithSubscriptionCheck(t, "free-model", "free")
408408

409409
// Build MaaSModelRef unstructured list
410410
maasModelRefItems := []*unstructured.Unstructured{
@@ -581,10 +581,10 @@ func TestListModels_ReturnAllModels(t *testing.T) {
581581
testLogger := logger.Development()
582582

583583
// Create mock servers for models
584-
// Use qualified names (namespace/name) to match the format sent by the handler
585-
model1Server := createMockModelServerWithSubscriptionCheck(t, "model-1", "test-namespace/sub-a")
586-
model2Server := createMockModelServerWithSubscriptionCheck(t, "model-2", "test-namespace/sub-b")
587-
model3Server := createMockModelServerWithSubscriptionCheck(t, "model-3", "test-namespace/sub-a")
584+
// Use bare subscription names to match what Authorino injects from API key validation
585+
model1Server := createMockModelServerWithSubscriptionCheck(t, "model-1", "sub-a")
586+
model2Server := createMockModelServerWithSubscriptionCheck(t, "model-2", "sub-b")
587+
model3Server := createMockModelServerWithSubscriptionCheck(t, "model-3", "sub-a")
588588

589589
// Setup MaaSModelRef lister with three models
590590
lister := fakeMaaSModelRefLister{
@@ -1049,7 +1049,7 @@ func TestListModels_DifferentModelRefsWithSameURLAndModelID(t *testing.T) {
10491049
v1 := router.Group("/v1")
10501050
v1.GET("/models", tokenHandler.ExtractUserInfo(), modelsHandler.ListLLMs)
10511051

1052-
t.Run("different MaaSModelRefs with same URL and model ID are deduplicated", func(t *testing.T) {
1052+
t.Run("different MaaSModelRefs with same URL and model ID remain separate entries", func(t *testing.T) {
10531053
w := httptest.NewRecorder()
10541054
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "/v1/models", nil)
10551055
require.NoError(t, err)
@@ -1066,14 +1066,19 @@ func TestListModels_DifferentModelRefsWithSameURLAndModelID(t *testing.T) {
10661066
err = json.Unmarshal(w.Body.Bytes(), &response)
10671067
require.NoError(t, err)
10681068

1069-
// Should have 1 entry because same URL and model ID (deduplicated)
1070-
assert.Len(t, response.Data, 1, "Same URL and model ID should be deduplicated into single entry")
1069+
// Should have 2 entries because different MaaSModelRef resources (different ownedBy)
1070+
// even though they have the same URL and model ID
1071+
assert.Len(t, response.Data, 2, "Different MaaSModelRef resources should remain separate entries")
10711072

1072-
model := response.Data[0]
1073-
assert.Equal(t, "gpt-4", model.ID)
1074-
assert.Equal(t, sharedModelServer.URL, model.URL.String())
1075-
require.Len(t, model.Subscriptions, 1, "Model should have 1 subscription")
1076-
assert.Equal(t, "sub-a", model.Subscriptions[0].Name)
1073+
// Both should have model ID "gpt-4" and same URL but different ownedBy
1074+
for _, model := range response.Data {
1075+
assert.Equal(t, "gpt-4", model.ID)
1076+
assert.Equal(t, sharedModelServer.URL, model.URL.String())
1077+
require.Len(t, model.Subscriptions, 1, "Each model should have 1 subscription")
1078+
assert.Equal(t, "sub-a", model.Subscriptions[0].Name)
1079+
// OwnedBy should be either namespace-a/gpt-4-ref or namespace-b/gpt-4-another-ref
1080+
assert.Contains(t, []string{"namespace-a/gpt-4-ref", "namespace-b/gpt-4-another-ref"}, model.OwnedBy)
1081+
}
10771082
})
10781083
}
10791084

@@ -1082,9 +1087,9 @@ func TestListModels_DifferentModelRefsWithSameModelIDAndDifferentSubscriptions(t
10821087

10831088
// Create two mock servers that both return the same model ID "gpt-4"
10841089
// One accessible via sub-a, one via sub-b
1085-
// Use qualified names (namespace/name) to match the format sent by the handler
1086-
modelServerA := createMockModelServerWithSubscriptionCheck(t, "gpt-4", "test-namespace/sub-a")
1087-
modelServerB := createMockModelServerWithSubscriptionCheck(t, "gpt-4", "test-namespace/sub-b")
1090+
// Use bare subscription names to match what Authorino injects from API key validation
1091+
modelServerA := createMockModelServerWithSubscriptionCheck(t, "gpt-4", "sub-a")
1092+
modelServerB := createMockModelServerWithSubscriptionCheck(t, "gpt-4", "sub-b")
10881093

10891094
// Setup MaaSModelRef lister with two different MaaSModelRefs in different namespaces
10901095
lister := fakeMaaSModelRefLister{

maas-controller/cmd/manager/main.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,8 @@ func init() {
5454

5555
// getClusterServiceAccountIssuer fetches the cluster's service account issuer from OpenShift/ROSA configuration.
5656
// Returns empty string if not found or not running on OpenShift/ROSA.
57-
func getClusterServiceAccountIssuer(c client.Client) (string, error) {
57+
// Uses client.Reader (not client.Client) so it can be called before the manager cache starts.
58+
func getClusterServiceAccountIssuer(c client.Reader) (string, error) {
5859
// Try to fetch the OpenShift Authentication config resource
5960
// This works on OpenShift/ROSA but not on vanilla Kubernetes
6061
authConfig := &unstructured.Unstructured{}
@@ -128,8 +129,9 @@ func main() {
128129
}
129130

130131
// Auto-detect cluster audience from OpenShift/ROSA if using default value
132+
// Use GetAPIReader() instead of GetClient() because the cache hasn't started yet
131133
if clusterAudience == "https://kubernetes.default.svc" {
132-
if detectedAudience, err := getClusterServiceAccountIssuer(mgr.GetClient()); err == nil && detectedAudience != "" {
134+
if detectedAudience, err := getClusterServiceAccountIssuer(mgr.GetAPIReader()); err == nil && detectedAudience != "" {
133135
setupLog.Info("auto-detected cluster service account issuer", "audience", detectedAudience)
134136
clusterAudience = detectedAudience
135137
} else if err != nil {

maas-controller/pkg/controller/maas/maasauthpolicy_controller.go

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"encoding/json"
2222
"errors"
2323
"fmt"
24+
"sort"
2425
"strings"
2526

2627
"github.com/go-logr/logr"
@@ -59,13 +60,6 @@ type MaaSAuthPolicyReconciler struct {
5960
ClusterAudience string
6061
}
6162

62-
func (r *MaaSAuthPolicyReconciler) gatewayName() string {
63-
if r.GatewayName != "" {
64-
return r.GatewayName
65-
}
66-
return defaultGatewayName
67-
}
68-
6963
func (r *MaaSAuthPolicyReconciler) clusterAudience() string {
7064
if r.ClusterAudience != "" {
7165
return r.ClusterAudience
@@ -79,6 +73,7 @@ func (r *MaaSAuthPolicyReconciler) clusterAudience() string {
7973
//+kubebuilder:rbac:groups=maas.opendatahub.io,resources=maasmodelrefs,verbs=get;list;watch
8074
//+kubebuilder:rbac:groups=kuadrant.io,resources=authpolicies,verbs=get;list;watch;create;update;patch;delete
8175
//+kubebuilder:rbac:groups=gateway.networking.k8s.io,resources=httproutes,verbs=get;list;watch
76+
//+kubebuilder:rbac:groups=config.openshift.io,resources=authentications,verbs=get
8277

8378
// Reconcile is part of the main kubernetes reconciliation loop
8479
const maasAuthPolicyFinalizer = "maas.opendatahub.io/authpolicy-cleanup"
@@ -182,6 +177,12 @@ func (r *MaaSAuthPolicyReconciler) reconcileModelAuthPolicies(ctx context.Contex
182177
}
183178
}
184179

180+
// Deduplicate and sort to ensure stable output across reconciles
181+
// (Kubernetes List order is not guaranteed to be deterministic)
182+
policyNames = deduplicateAndSort(policyNames)
183+
allowedGroups = deduplicateAndSort(allowedGroups)
184+
allowedUsers = deduplicateAndSort(allowedUsers)
185+
185186
// Construct API URLs using configured namespace
186187
apiKeyValidationURL := fmt.Sprintf("https://maas-api.%s.svc.cluster.local:8443/internal/v1/api-keys/validate", r.MaaSAPINamespace)
187188
subscriptionSelectorURL := fmt.Sprintf("https://maas-api.%s.svc.cluster.local:8443/internal/v1/subscriptions/select", r.MaaSAPINamespace)
@@ -287,12 +288,14 @@ func (r *MaaSAuthPolicyReconciler) reconcileModelAuthPolicies(ctx context.Contex
287288
"metrics": false,
288289
"priority": int64(0),
289290
"opa": map[string]interface{}{
290-
"rego": `allow {
291-
# API key authentication: validate the key
291+
"rego": `# API key authentication: validate the key
292+
allow {
292293
object.get(input.auth.metadata, "apiKeyValidation", {})
293294
input.auth.metadata.apiKeyValidation.valid == true
294-
} {
295-
# Kubernetes token authentication: check identity exists
295+
}
296+
297+
# Kubernetes token authentication: check identity exists
298+
allow {
296299
object.get(input.auth.identity, "user", {}).username != ""
297300
}`,
298301
},
@@ -779,3 +782,24 @@ func (r *MaaSAuthPolicyReconciler) mapHTTPRouteToMaaSAuthPolicies(ctx context.Co
779782
}
780783
return requests
781784
}
785+
// deduplicateAndSort removes duplicates from a string slice and sorts it.
786+
// This ensures stable output across reconciles, preventing spurious updates
787+
// caused by non-deterministic Kubernetes List order.
788+
func deduplicateAndSort(items []string) []string {
789+
if len(items) == 0 {
790+
return items
791+
}
792+
// Use a map to deduplicate
793+
seen := make(map[string]bool, len(items))
794+
for _, item := range items {
795+
seen[item] = true
796+
}
797+
// Build deduplicated slice
798+
result := make([]string, 0, len(seen))
799+
for item := range seen {
800+
result = append(result, item)
801+
}
802+
// Sort for deterministic output
803+
sort.Strings(result)
804+
return result
805+
}

test/e2e/tests/test_models_endpoint.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -864,7 +864,7 @@ def test_different_modelrefs_same_model_id(self):
864864
log.info(f"📊 API Response: {len(models)} total model(s), {len(unique_ids)} unique ID(s)")
865865
log.info(f" Model IDs: {model_ids}")
866866
log.info(f" Unique IDs: {unique_ids}")
867-
log.info(f" Subscription had: 2 different modelRefs both serving 'facebook/opt-125m'")
867+
log.info(" Subscription had: 2 different modelRefs both serving 'facebook/opt-125m'")
868868

869869
# Both modelRefs serve the same model ID
870870
assert len(unique_ids) == 1, \
@@ -1511,7 +1511,7 @@ def test_api_key_with_deleted_subscription_403(self):
15111511
_wait_reconcile()
15121512

15131513
# Query with API key (gateway injects deleted subscription name)
1514-
log.info(f"Querying /v1/models with API key bound to deleted subscription")
1514+
log.info("Querying /v1/models with API key bound to deleted subscription")
15151515
r = requests.get(
15161516
f"{_maas_api_url()}/v1/models",
15171517
headers={
@@ -1572,7 +1572,7 @@ def test_api_key_with_inaccessible_subscription_403(self):
15721572
# User tries to query with their token but specifying the other user's subscription
15731573
# This simulates what would happen if an API key was bound to a subscription
15741574
# the user doesn't have access to
1575-
log.info(f"Querying /v1/models with user token and inaccessible subscription")
1575+
log.info("Querying /v1/models with user token and inaccessible subscription")
15761576
r = requests.get(
15771577
f"{_maas_api_url()}/v1/models",
15781578
headers={

0 commit comments

Comments
 (0)