Skip to content

Commit 220b8ce

Browse files
committed
fix(keycloak): delete wrong-type mapper before recreating audience mapper (#358)
When a protocol mapper exists with the correct name but the wrong type (not oidc-audience-mapper), the POST returns 409 and updateAudienceMapperIfNeeded fails to find a matching mapper — entering an infinite error loop that blocks audience scope propagation. ## Root Cause The 409 Conflict from Keycloak means "a mapper with that name already exists." But updateAudienceMapperIfNeeded only looks for mappers matching BOTH Name == scopeName AND ProtocolMapper == "oidc-audience-mapper". When the existing mapper has the right name but wrong type, the loop skips it and falls through to "no matching audience mapper found." This also prevents verifyAudienceMapper (defense-in-depth from PR #350) from running, since getOrCreateAudienceClientScope returns early on the error — no self-healing is possible. ## Fix In updateAudienceMapperIfNeeded, after failing to find an oidc-audience-mapper, perform a second pass looking for any mapper with a matching name (regardless of type). If found, DELETE it via the Keycloak Admin API, then re-POST the correct oidc-audience-mapper. This is the minimal targeted fix — it handles the exact broken state (wrong-type name collision) without restructuring the flow. ## Observed Symptoms - Operator logs: "ensure audience mapper for existing scope ... no matching audience mapper found" repeating every few seconds - Agent tokens lack the correct audience claim - AuthBridge/Envoy rejects requests with 401 Unauthorized - Affects fresh installs with operator v0.2.0-rc.4 Fixes #358 Signed-off-by: cwiklik <cwiklik@users.noreply.github.com> Assisted-By: Claude (Anthropic AI) <noreply@anthropic.com> Signed-off-by: cwiklik <cwiklikj@gmail.com>
1 parent e8882b4 commit 220b8ce

2 files changed

Lines changed: 293 additions & 1 deletion

File tree

kagenti-operator/internal/keycloak/audience.go

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,22 @@ func (a *Admin) updateAudienceMapperIfNeeded(ctx context.Context, token, realm,
275275
mappers[i].Config["included.custom.audience"] = audience
276276
return a.putAudienceMapper(ctx, token, realm, scopeID, mappers[i])
277277
}
278-
return fmt.Errorf("no matching audience mapper found for scope %q (scopeID %s)", scopeName, scopeID)
278+
279+
// No oidc-audience-mapper found. A mapper with the same name but a different
280+
// protocolMapper type caused the 409 conflict. Delete it and recreate correctly.
281+
for i := range mappers {
282+
if mappers[i].Name != scopeName {
283+
continue
284+
}
285+
if err := a.deleteMapper(ctx, token, realm, scopeID, mappers[i].ID); err != nil {
286+
return fmt.Errorf("delete stale mapper %q (id %s): %w", scopeName, mappers[i].ID, err)
287+
}
288+
return a.ensureAudienceMapper(ctx, token, realm, scopeID, scopeName, audience)
289+
}
290+
291+
// Scope has no mappers at all despite the 409 — Keycloak phantom conflict.
292+
// Create the mapper directly via PUT-style POST (Keycloak may accept it on retry).
293+
return a.createAudienceMapperDirect(ctx, token, realm, scopeID, scopeName, audience)
279294
}
280295

281296
func (a *Admin) putAudienceMapper(ctx context.Context, token, realm, scopeID string, mapper protocolMapperRep) error {
@@ -304,6 +319,72 @@ func (a *Admin) putAudienceMapper(ctx context.Context, token, realm, scopeID str
304319
return fmt.Errorf("keycloak update audience mapper: status %d: %s", resp.StatusCode, truncate(body, 256))
305320
}
306321

322+
// createAudienceMapperDirect creates the audience mapper without conflict handling.
323+
// Used when a prior 409 was spurious (scope's mapper list is empty) — retrying the
324+
// POST typically succeeds because the phantom conflict has cleared.
325+
func (a *Admin) createAudienceMapperDirect(ctx context.Context, token, realm, scopeID, scopeName, audience string) error {
326+
mapper := protocolMapperRep{
327+
Name: scopeName,
328+
Protocol: "openid-connect",
329+
ProtocolMapper: "oidc-audience-mapper",
330+
ConsentRequired: false,
331+
Config: map[string]string{
332+
"included.custom.audience": audience,
333+
"id.token.claim": "false",
334+
"access.token.claim": "true",
335+
"userinfo.token.claim": "false",
336+
},
337+
}
338+
payload, err := json.Marshal(mapper)
339+
if err != nil {
340+
return err
341+
}
342+
base := trimBaseURL(a.BaseURL)
343+
endpoint := base + "/admin/realms/" + url.PathEscape(realm) + "/client-scopes/" + url.PathEscape(scopeID) + "/protocol-mappers/models"
344+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(payload))
345+
if err != nil {
346+
return err
347+
}
348+
req.Header.Set("Authorization", "Bearer "+token)
349+
req.Header.Set("Content-Type", "application/json")
350+
351+
resp, err := a.httpc().Do(req)
352+
if err != nil {
353+
return err
354+
}
355+
defer func() { _ = resp.Body.Close() }()
356+
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusNoContent {
357+
return nil
358+
}
359+
if resp.StatusCode == http.StatusConflict {
360+
// Another concurrent reconcile created the mapper — treat as success.
361+
return nil
362+
}
363+
body, _ := io.ReadAll(resp.Body)
364+
return fmt.Errorf("keycloak create audience mapper (retry): status %d: %s", resp.StatusCode, truncate(body, 256))
365+
}
366+
367+
func (a *Admin) deleteMapper(ctx context.Context, token, realm, scopeID, mapperID string) error {
368+
base := trimBaseURL(a.BaseURL)
369+
endpoint := base + "/admin/realms/" + url.PathEscape(realm) + "/client-scopes/" +
370+
url.PathEscape(scopeID) + "/protocol-mappers/models/" + url.PathEscape(mapperID)
371+
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
372+
if err != nil {
373+
return err
374+
}
375+
req.Header.Set("Authorization", "Bearer "+token)
376+
resp, err := a.httpc().Do(req)
377+
if err != nil {
378+
return err
379+
}
380+
defer func() { _ = resp.Body.Close() }()
381+
if resp.StatusCode == http.StatusNoContent || resp.StatusCode == http.StatusNotFound {
382+
return nil
383+
}
384+
body, _ := io.ReadAll(resp.Body)
385+
return fmt.Errorf("keycloak delete mapper: status %d: %s", resp.StatusCode, truncate(body, 256))
386+
}
387+
307388
// verifyAudienceMapper is a defense-in-depth check that runs on every reconcile.
308389
// It GETs the mappers for a scope and ensures the oidc-audience-mapper exists with the
309390
// correct audience. If the mapper is missing (e.g. due to a prior transient failure),

kagenti-operator/internal/keycloak/audience_test.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,217 @@ func TestEnsureAudienceScope_VerifyRecreatesMissingMapper(t *testing.T) {
353353
}
354354
}
355355

356+
// TestEnsureAudienceScope_DeletesWrongTypeMapper verifies that when a mapper with the
357+
// correct name exists but has the wrong protocolMapper type (not oidc-audience-mapper),
358+
// the operator deletes it and recreates the correct mapper. This is the fix for #358.
359+
func TestEnsureAudienceScope_DeletesWrongTypeMapper(t *testing.T) {
360+
var deleteMapperCalls, recreatePostCalls int
361+
spiffeURI := "spiffe://example.org/ns/ns/sa/wl"
362+
363+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
364+
path := r.URL.Path
365+
switch {
366+
case path == testMasterRealmTokenPath:
367+
w.Header().Set("Content-Type", "application/json")
368+
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "tok"})
369+
370+
// Scope already exists
371+
case path == "/admin/realms/kagenti/client-scopes" && r.Method == http.MethodGet:
372+
_ = json.NewEncoder(w).Encode([]clientScopeListItem{{ID: "scope-123", Name: "agent-ns-wl-aud"}})
373+
374+
// ensureAudienceMapper POST — 409 conflict (mapper name taken)
375+
case strings.Contains(path, "/client-scopes/scope-123/protocol-mappers/models") && r.Method == http.MethodPost:
376+
if deleteMapperCalls > 0 {
377+
// After delete, the POST succeeds
378+
recreatePostCalls++
379+
w.WriteHeader(http.StatusCreated)
380+
} else {
381+
w.WriteHeader(http.StatusConflict)
382+
}
383+
384+
// GET mappers — returns a mapper with wrong type (e.g. "oidc-hardcoded-claim-mapper")
385+
case strings.Contains(path, "/client-scopes/scope-123/protocol-mappers/models") && r.Method == http.MethodGet:
386+
if deleteMapperCalls > 0 {
387+
// After delete+recreate, verify sees the correct mapper
388+
_ = json.NewEncoder(w).Encode([]protocolMapperRep{{
389+
ID: "m-new", Name: "agent-ns-wl-aud", Protocol: "openid-connect",
390+
ProtocolMapper: "oidc-audience-mapper",
391+
Config: map[string]string{"included.custom.audience": spiffeURI},
392+
}})
393+
} else {
394+
_ = json.NewEncoder(w).Encode([]protocolMapperRep{{
395+
ID: "mapper-stale",
396+
Name: "agent-ns-wl-aud",
397+
Protocol: "openid-connect",
398+
ProtocolMapper: "oidc-hardcoded-claim-mapper", // WRONG TYPE
399+
Config: map[string]string{"claim.value": "something"},
400+
}})
401+
}
402+
403+
// DELETE the stale mapper
404+
case strings.Contains(path, "/protocol-mappers/models/mapper-stale") && r.Method == http.MethodDelete:
405+
deleteMapperCalls++
406+
w.WriteHeader(http.StatusNoContent)
407+
408+
// Realm default scope
409+
case path == "/admin/realms/kagenti/default-default-client-scopes/scope-123" && r.Method == http.MethodPut:
410+
w.WriteHeader(http.StatusNoContent)
411+
412+
default:
413+
t.Fatalf("unexpected %s %s", r.Method, path)
414+
}
415+
}))
416+
defer srv.Close()
417+
418+
a := Admin{BaseURL: srv.URL, HTTPClient: srv.Client()}
419+
token, err := a.PasswordGrantToken(context.Background(), "u", "p")
420+
if err != nil {
421+
t.Fatal(err)
422+
}
423+
424+
err = a.EnsureAudienceScope(context.Background(), token, AudienceParams{
425+
Realm: "kagenti",
426+
ClientName: "ns/wl",
427+
AudienceClientID: spiffeURI,
428+
AudienceScopeEnabled: true,
429+
})
430+
if err != nil {
431+
t.Fatal(err)
432+
}
433+
if deleteMapperCalls != 1 {
434+
t.Fatalf("expected 1 DELETE call for stale mapper, got %d", deleteMapperCalls)
435+
}
436+
if recreatePostCalls != 1 {
437+
t.Fatalf("expected 1 POST call to recreate mapper after delete, got %d", recreatePostCalls)
438+
}
439+
}
440+
441+
// TestEnsureAudienceScope_PhantomConflictRetry verifies that when the mapper POST
442+
// returns 409 but the scope's mapper list is empty (phantom Keycloak conflict),
443+
// the operator retries the POST directly and succeeds. This is the actual scenario
444+
// observed on Kind clusters where Keycloak reports a conflict for a non-existent mapper.
445+
func TestEnsureAudienceScope_PhantomConflictRetry(t *testing.T) {
446+
var postCalls int
447+
spiffeURI := "spiffe://example.org/ns/ns/sa/wl"
448+
449+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
450+
path := r.URL.Path
451+
switch {
452+
case path == testMasterRealmTokenPath:
453+
w.Header().Set("Content-Type", "application/json")
454+
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "tok"})
455+
456+
// Scope already exists
457+
case path == "/admin/realms/kagenti/client-scopes" && r.Method == http.MethodGet:
458+
_ = json.NewEncoder(w).Encode([]clientScopeListItem{{ID: "scope-123", Name: "agent-ns-wl-aud"}})
459+
460+
// POST mapper — first call returns 409 (phantom), subsequent calls succeed
461+
case strings.Contains(path, "/client-scopes/scope-123/protocol-mappers/models") && r.Method == http.MethodPost:
462+
postCalls++
463+
if postCalls == 1 {
464+
w.WriteHeader(http.StatusConflict)
465+
} else {
466+
w.WriteHeader(http.StatusCreated)
467+
}
468+
469+
// GET mappers — returns empty (the phantom: 409 but no actual mapper)
470+
case strings.Contains(path, "/client-scopes/scope-123/protocol-mappers/models") && r.Method == http.MethodGet:
471+
if postCalls >= 2 {
472+
// After retry POST succeeds, verify sees the mapper
473+
_ = json.NewEncoder(w).Encode([]protocolMapperRep{{
474+
ID: "m-new", Name: "agent-ns-wl-aud", Protocol: "openid-connect",
475+
ProtocolMapper: "oidc-audience-mapper",
476+
Config: map[string]string{"included.custom.audience": spiffeURI},
477+
}})
478+
} else {
479+
_ = json.NewEncoder(w).Encode([]protocolMapperRep{})
480+
}
481+
482+
// Realm default scope
483+
case path == "/admin/realms/kagenti/default-default-client-scopes/scope-123" && r.Method == http.MethodPut:
484+
w.WriteHeader(http.StatusNoContent)
485+
486+
default:
487+
t.Fatalf("unexpected %s %s", r.Method, path)
488+
}
489+
}))
490+
defer srv.Close()
491+
492+
a := Admin{BaseURL: srv.URL, HTTPClient: srv.Client()}
493+
token, err := a.PasswordGrantToken(context.Background(), "u", "p")
494+
if err != nil {
495+
t.Fatal(err)
496+
}
497+
498+
err = a.EnsureAudienceScope(context.Background(), token, AudienceParams{
499+
Realm: "kagenti",
500+
ClientName: "ns/wl",
501+
AudienceClientID: spiffeURI,
502+
AudienceScopeEnabled: true,
503+
})
504+
if err != nil {
505+
t.Fatal(err)
506+
}
507+
if postCalls != 2 {
508+
t.Fatalf("expected 2 POST calls (first 409, then retry succeeds), got %d", postCalls)
509+
}
510+
}
511+
512+
// TestEnsureAudienceScope_ConcurrentCreate409 verifies that when both the initial
513+
// and retry POST return 409 (concurrent reconcile created the mapper), the operator
514+
// treats it as success rather than entering an error loop.
515+
func TestEnsureAudienceScope_ConcurrentCreate409(t *testing.T) {
516+
spiffeURI := "spiffe://example.org/ns/ns/sa/wl"
517+
518+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
519+
path := r.URL.Path
520+
switch {
521+
case path == testMasterRealmTokenPath:
522+
w.Header().Set("Content-Type", "application/json")
523+
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "tok"})
524+
525+
case path == "/admin/realms/kagenti/client-scopes" && r.Method == http.MethodGet:
526+
_ = json.NewEncoder(w).Encode([]clientScopeListItem{{ID: "scope-123", Name: "agent-ns-wl-aud"}})
527+
528+
// All POSTs return 409 (concurrent reconcile already created it)
529+
case strings.Contains(path, "/client-scopes/scope-123/protocol-mappers/models") && r.Method == http.MethodPost:
530+
w.WriteHeader(http.StatusConflict)
531+
532+
// GET mappers — empty during updateAudienceMapperIfNeeded (race: mapper not yet visible)
533+
// but present during verifyAudienceMapper (transaction committed)
534+
case strings.Contains(path, "/client-scopes/scope-123/protocol-mappers/models") && r.Method == http.MethodGet:
535+
_ = json.NewEncoder(w).Encode([]protocolMapperRep{{
536+
ID: "m-concurrent", Name: "agent-ns-wl-aud", Protocol: "openid-connect",
537+
ProtocolMapper: "oidc-audience-mapper",
538+
Config: map[string]string{"included.custom.audience": spiffeURI},
539+
}})
540+
541+
case path == "/admin/realms/kagenti/default-default-client-scopes/scope-123" && r.Method == http.MethodPut:
542+
w.WriteHeader(http.StatusNoContent)
543+
544+
default:
545+
t.Fatalf("unexpected %s %s", r.Method, path)
546+
}
547+
}))
548+
defer srv.Close()
549+
550+
a := Admin{BaseURL: srv.URL, HTTPClient: srv.Client()}
551+
token, err := a.PasswordGrantToken(context.Background(), "u", "p")
552+
if err != nil {
553+
t.Fatal(err)
554+
}
555+
556+
err = a.EnsureAudienceScope(context.Background(), token, AudienceParams{
557+
Realm: "kagenti",
558+
ClientName: "ns/wl",
559+
AudienceClientID: spiffeURI,
560+
AudienceScopeEnabled: true,
561+
})
562+
if err != nil {
563+
t.Fatalf("expected success when concurrent reconcile created mapper, got: %v", err)
564+
}
565+
}
566+
356567
func TestEnsureAudienceScope_Disabled(t *testing.T) {
357568
a := Admin{}
358569
err := a.EnsureAudienceScope(context.Background(), "t", AudienceParams{AudienceScopeEnabled: false})

0 commit comments

Comments
 (0)