Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion kagenti-operator/internal/keycloak/audience.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
Expand All @@ -39,6 +40,7 @@ type clientScopeCreateRep struct {
}

type protocolMapperRep struct {
ID string `json:"id"`
Name string `json:"name"`
Protocol string `json:"protocol"`
ProtocolMapper string `json:"protocolMapper"`
Expand Down Expand Up @@ -202,9 +204,13 @@ func (a *Admin) ensureAudienceMapper(ctx context.Context, token, realm, scopeID,
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusCreated || resp.StatusCode == http.StatusConflict {
if resp.StatusCode == http.StatusCreated {
return nil
}
if resp.StatusCode == http.StatusConflict {
// Mapper already exists — check if its audience needs updating.
return a.updateAudienceMapperIfNeeded(ctx, token, realm, scopeID, scopeName, audience)
}
body, _ := io.ReadAll(resp.Body)
// Mapper may already exist — treat other errors as non-fatal (Python logs and continues).
if resp.StatusCode >= 400 {
Expand All @@ -213,6 +219,76 @@ func (a *Admin) ensureAudienceMapper(ctx context.Context, token, realm, scopeID,
return nil
}

// updateAudienceMapperIfNeeded fetches the existing mapper for the scope and updates
// its included.custom.audience if it differs from the desired value.
func (a *Admin) updateAudienceMapperIfNeeded(ctx context.Context, token, realm, scopeID, scopeName, audience string) error {
base := trimBaseURL(a.BaseURL)
endpoint := base + "/admin/realms/" + url.PathEscape(realm) + "/client-scopes/" + url.PathEscape(scopeID) + "/protocol-mappers/models"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)

resp, err := a.httpc().Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("keycloak list mappers: status %d: %s", resp.StatusCode, truncate(body, 256))
}

var mappers []protocolMapperRep
if err := json.Unmarshal(body, &mappers); err != nil {
return fmt.Errorf("keycloak list mappers decode: %w", err)
}

for i := range mappers {
if mappers[i].Name != scopeName || mappers[i].ProtocolMapper != "oidc-audience-mapper" {
Comment thread
akram marked this conversation as resolved.
continue
}
if mappers[i].Config == nil {
continue
}
if mappers[i].Config["included.custom.audience"] == audience {
return nil // already correct
}
Comment thread
akram marked this conversation as resolved.
// Update the mapper with the correct audience.
mappers[i].Config["included.custom.audience"] = audience
return a.putAudienceMapper(ctx, token, realm, scopeID, mappers[i])
}
slog.Debug("no matching audience mapper found for scope", "scope", scopeName, "scopeID", scopeID)
return nil
}

func (a *Admin) putAudienceMapper(ctx context.Context, token, realm, scopeID string, mapper protocolMapperRep) error {
payload, err := json.Marshal(mapper)
if err != nil {
return err
}
base := trimBaseURL(a.BaseURL)
endpoint := base + "/admin/realms/" + url.PathEscape(realm) + "/client-scopes/" + url.PathEscape(scopeID) + "/protocol-mappers/models/" + url.PathEscape(mapper.ID)
req, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")

resp, err := a.httpc().Do(req)
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusNoContent || (resp.StatusCode >= 200 && resp.StatusCode < 300) {
return nil
}
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("keycloak update audience mapper: status %d: %s", resp.StatusCode, truncate(body, 256))
}

func (a *Admin) putRealmDefaultDefaultClientScope(ctx context.Context, token, realm, scopeID string) error {
base := trimBaseURL(a.BaseURL)
endpoint := base + "/admin/realms/" + url.PathEscape(realm) + "/default-default-client-scopes/" + url.PathEscape(scopeID)
Expand Down
149 changes: 149 additions & 0 deletions kagenti-operator/internal/keycloak/audience_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package keycloak
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
Expand Down Expand Up @@ -72,6 +73,154 @@ func TestEnsureAudienceScope(t *testing.T) {
}
}

// TestEnsureAudienceScope_UpdatesStaleMapper verifies that when an audience scope mapper
// already exists with a different audience (e.g. short-form "ns/wl" instead of SPIFFE URI),
// ensureAudienceMapper detects the mismatch and updates it via PUT.
func TestEnsureAudienceScope_UpdatesStaleMapper(t *testing.T) {
var getMapperCalls, putMapperCalls int
var putMapperBody protocolMapperRep

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case path == testMasterRealmTokenPath:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "tok"})

// Scope already exists
case path == "/admin/realms/kagenti/client-scopes" && r.Method == http.MethodGet:
_ = json.NewEncoder(w).Encode([]clientScopeListItem{{ID: "scope-123", Name: "agent-ns-wl-aud"}})

// POST mapper returns 409 (already exists)
case strings.Contains(path, "/client-scopes/scope-123/protocol-mappers/models") && r.Method == http.MethodPost:
w.WriteHeader(http.StatusConflict)

// GET mappers — returns mapper with stale audience
case strings.Contains(path, "/client-scopes/scope-123/protocol-mappers/models") && r.Method == http.MethodGet:
getMapperCalls++
_ = json.NewEncoder(w).Encode([]protocolMapperRep{{
ID: "mapper-456",
Name: "agent-ns-wl-aud",
Protocol: "openid-connect",
ProtocolMapper: "oidc-audience-mapper",
Config: map[string]string{
"included.custom.audience": "ns/wl", // stale short-form
"id.token.claim": "false",
"access.token.claim": "true",
"userinfo.token.claim": "false",
},
}})

// PUT mapper — update with correct audience
case strings.Contains(path, "/client-scopes/scope-123/protocol-mappers/models/mapper-456") && r.Method == http.MethodPut:
putMapperCalls++
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &putMapperBody)
w.WriteHeader(http.StatusNoContent)

// Realm default scope
case path == "/admin/realms/kagenti/default-default-client-scopes/scope-123" && r.Method == http.MethodPut:
w.WriteHeader(http.StatusNoContent)

default:
t.Fatalf("unexpected %s %s", r.Method, path)
}
}))
defer srv.Close()

a := Admin{BaseURL: srv.URL, HTTPClient: srv.Client()}
token, err := a.PasswordGrantToken(context.Background(), "u", "p")
if err != nil {
t.Fatal(err)
}

spiffeURI := "spiffe://example.org/ns/ns/sa/wl"
err = a.EnsureAudienceScope(context.Background(), token, AudienceParams{
Realm: "kagenti",
ClientName: "ns/wl",
AudienceClientID: spiffeURI,
AudienceScopeEnabled: true,
})
if err != nil {
t.Fatal(err)
}
if getMapperCalls != 1 {
t.Fatalf("expected 1 GET mapper call, got %d", getMapperCalls)
}
if putMapperCalls != 1 {
t.Fatalf("expected 1 PUT mapper call, got %d", putMapperCalls)
}
if putMapperBody.Config["included.custom.audience"] != spiffeURI {
t.Fatalf("expected audience %q, got %q", spiffeURI, putMapperBody.Config["included.custom.audience"])
}
}

// TestEnsureAudienceScope_SkipsUpdateWhenCorrect verifies that when the existing mapper
// already has the correct audience, no PUT is issued.
func TestEnsureAudienceScope_SkipsUpdateWhenCorrect(t *testing.T) {
var putMapperCalls int

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case path == testMasterRealmTokenPath:
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "tok"})

case path == "/admin/realms/kagenti/client-scopes" && r.Method == http.MethodGet:
_ = json.NewEncoder(w).Encode([]clientScopeListItem{{ID: "scope-123", Name: "agent-ns-wl-aud"}})

case strings.Contains(path, "/client-scopes/scope-123/protocol-mappers/models") && r.Method == http.MethodPost:
w.WriteHeader(http.StatusConflict)

case strings.Contains(path, "/client-scopes/scope-123/protocol-mappers/models") && r.Method == http.MethodGet:
spiffeURI := "spiffe://example.org/ns/ns/sa/wl"
_ = json.NewEncoder(w).Encode([]protocolMapperRep{{
ID: "mapper-456",
Name: "agent-ns-wl-aud",
Protocol: "openid-connect",
ProtocolMapper: "oidc-audience-mapper",
Config: map[string]string{
"included.custom.audience": spiffeURI, // already correct
"id.token.claim": "false",
"access.token.claim": "true",
"userinfo.token.claim": "false",
},
}})

case strings.Contains(path, "/protocol-mappers/models/mapper-456") && r.Method == http.MethodPut:
putMapperCalls++
w.WriteHeader(http.StatusNoContent)

case path == "/admin/realms/kagenti/default-default-client-scopes/scope-123" && r.Method == http.MethodPut:
w.WriteHeader(http.StatusNoContent)

default:
t.Fatalf("unexpected %s %s", r.Method, path)
}
}))
defer srv.Close()

a := Admin{BaseURL: srv.URL, HTTPClient: srv.Client()}
token, err := a.PasswordGrantToken(context.Background(), "u", "p")
if err != nil {
t.Fatal(err)
}

err = a.EnsureAudienceScope(context.Background(), token, AudienceParams{
Realm: "kagenti",
ClientName: "ns/wl",
AudienceClientID: "spiffe://example.org/ns/ns/sa/wl",
AudienceScopeEnabled: true,
})
if err != nil {
t.Fatal(err)
}
if putMapperCalls != 0 {
t.Fatalf("expected 0 PUT mapper calls (audience already correct), got %d", putMapperCalls)
}
}

func TestEnsureAudienceScope_Disabled(t *testing.T) {
a := Admin{}
err := a.EnsureAudienceScope(context.Background(), "t", AudienceParams{AudienceScopeEnabled: false})
Expand Down
Loading