diff --git a/components/control-plane/internal/auth/token_provider.go b/components/control-plane/internal/auth/token_provider.go index 4f9e93e5..a130d140 100644 --- a/components/control-plane/internal/auth/token_provider.go +++ b/components/control-plane/internal/auth/token_provider.go @@ -88,7 +88,10 @@ func (tp *TokenProvider) Token() (string, error) { tp.token = token // Refresh at 80% of TTL to avoid using an expired token. - tp.expiry = time.Now().Add(time.Duration(float64(expiresIn) * 0.8)) + ttl := time.Duration(expiresIn) * time.Second + refreshAfter := ttl * 8 / 10 + tp.expiry = time.Now().Add(refreshAfter) + log.Printf("INFO got OIDC access token for client %q; refresh in %s", tp.clientID, refreshAfter) return tp.token, nil } diff --git a/components/control-plane/internal/auth/token_provider_test.go b/components/control-plane/internal/auth/token_provider_test.go new file mode 100644 index 00000000..70872ddf --- /dev/null +++ b/components/control-plane/internal/auth/token_provider_test.go @@ -0,0 +1,169 @@ +package auth + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestTokenGrantLogContainsSafeRefreshDetails(t *testing.T) { + var output bytes.Buffer + priorWriter := log.Writer() + priorFlags := log.Flags() + priorPrefix := log.Prefix() + log.SetOutput(&output) + log.SetFlags(0) + log.SetPrefix("") + t.Cleanup(func() { + log.SetOutput(priorWriter) + log.SetFlags(priorFlags) + log.SetPrefix(priorPrefix) + }) + + var grants atomic.Int32 + server := newTokenServer(t, &grants) + provider := NewTokenProvider("https://issuer.invalid", "client-id\nforged-entry", "client-secret") + provider.SetTokenEndpoint(server.URL) + + if _, err := provider.Token(); err != nil { + t.Fatalf("Token() failed: %v", err) + } + + message := output.String() + if !strings.Contains(message, `client "client-id\nforged-entry"; refresh in 4m0s`) { + t.Fatalf("token grant log = %q, want quoted client ID and refresh interval", message) + } + for _, forbidden := range []string{"token-1", "client-secret", "\nforged-entry"} { + if strings.Contains(message, forbidden) { + t.Fatalf("token grant log contains unsafe value %q: %q", forbidden, message) + } + } +} + +func TestTokenReusesCachedToken(t *testing.T) { + t.Parallel() + + var grants atomic.Int32 + server := newTokenServer(t, &grants) + + provider := NewTokenProvider("https://issuer.invalid", "client-id", "client-secret") + provider.SetTokenEndpoint(server.URL) + + first, err := provider.Token() + if err != nil { + t.Fatalf("first Token() call failed: %v", err) + } + second, err := provider.Token() + if err != nil { + t.Fatalf("second Token() call failed: %v", err) + } + + if first != second { + t.Fatalf("Token() returned %q and %q; both calls must return the same token", first, second) + } + if got := grants.Load(); got != 1 { + t.Fatalf("token endpoint received %d grants; it must receive 1", got) + } +} + +func TestConcurrentTokenCallsShareCachedToken(t *testing.T) { + t.Parallel() + + var grants atomic.Int32 + server := newTokenServer(t, &grants) + + provider := NewTokenProvider("https://issuer.invalid", "client-id", "client-secret") + provider.SetTokenEndpoint(server.URL) + + const callCount = 16 + type result struct { + token string + err error + } + + start := make(chan struct{}) + results := make(chan result, callCount) + for range callCount { + go func() { + <-start + token, err := provider.Token() + results <- result{token: token, err: err} + }() + } + close(start) + + for range callCount { + result := <-results + if result.err != nil { + t.Fatalf("Token() failed: %v", result.err) + } + if result.token != "token-1" { + t.Fatalf("Token() returned %q; it must return %q", result.token, "token-1") + } + } + + if got := grants.Load(); got != 1 { + t.Fatalf("token endpoint received %d grants; it must receive 1", got) + } +} + +func TestTokenRefreshesAfterThreshold(t *testing.T) { + t.Parallel() + + var grants atomic.Int32 + server := newTokenServer(t, &grants) + + provider := NewTokenProvider("https://issuer.invalid", "client-id", "client-secret") + provider.SetTokenEndpoint(server.URL) + + requestStarted := time.Now() + first, err := provider.Token() + requestFinished := time.Now() + if err != nil { + t.Fatalf("first Token() call failed: %v", err) + } + + provider.mu.Lock() + refreshAt := provider.expiry + provider.expiry = time.Now().Add(-time.Second) + provider.mu.Unlock() + + const refreshDelay = 4 * time.Minute + if refreshAt.Before(requestStarted.Add(refreshDelay)) || refreshAt.After(requestFinished.Add(refreshDelay)) { + t.Fatalf("refresh time = %v; want 80 percent of a 300-second lifetime", refreshAt) + } + + second, err := provider.Token() + if err != nil { + t.Fatalf("second Token() call failed: %v", err) + } + if first != "token-1" || second != "token-2" { + t.Fatalf("Token() returned %q and %q; want %q and %q", first, second, "token-1", "token-2") + } + if got := grants.Load(); got != 2 { + t.Fatalf("token endpoint received %d grants; it must receive 2", got) + } +} + +func newTokenServer(t *testing.T, grants *atomic.Int32) *httptest.Server { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + grant := grants.Add(1) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(tokenResponse{ + AccessToken: fmt.Sprintf("token-%d", grant), + ExpiresIn: 300, + TokenType: "Bearer", + }) + })) + t.Cleanup(server.Close) + return server +} diff --git a/components/control-plane/internal/serviceaccountkeycloak/client.go b/components/control-plane/internal/serviceaccountkeycloak/client.go index 5776698e..efadecbf 100644 --- a/components/control-plane/internal/serviceaccountkeycloak/client.go +++ b/components/control-plane/internal/serviceaccountkeycloak/client.go @@ -31,6 +31,7 @@ const ( clientRefreshTokenAttribute = "client_credentials.use_refresh_token" deviceGrantAttribute = "oauth2.device.authorization.grant.enabled" cibaGrantAttribute = "oidc.ciba.grant.enabled" + builtInServiceAccountScope = "service_account" defaultAccessTokenLifetimeSecs = 300 ) @@ -276,7 +277,7 @@ func (c *Client) reconcileConverged(ctx context.Context, spec ServiceAccountSpec return false, nil } if len(client.RedirectURIs) > 0 || len(client.WebOrigins) > 0 || - len(client.DefaultClientScopes) > 0 || len(client.OptionalClientScopes) > 0 { + !defaultClientScopesConverged(client.DefaultClientScopes) || len(client.OptionalClientScopes) > 0 { return false, nil } lifetime := spec.AccessTokenLifetimeSeconds @@ -305,6 +306,13 @@ func (c *Client) reconcileConverged(ctx context.Context, spec ServiceAccountSpec return c.protocolMappersConverged(ctx, client.ID, spec.GatewayClientID) } +// defaultClientScopesConverged accepts the built-in scope that Keycloak adds +// when service accounts are enabled. The repair payload stays empty because +// Keycloak owns this scope. All other scopes are drift. +func defaultClientScopesConverged(scopes []string) bool { + return len(scopes) == 0 || (len(scopes) == 1 && scopes[0] == builtInServiceAccountScope) +} + // roleMappingSet is the shape Keycloak returns for both user role-mappings and // client scope-mappings. type roleMappingSet struct { diff --git a/components/control-plane/internal/serviceaccountkeycloak/client_test.go b/components/control-plane/internal/serviceaccountkeycloak/client_test.go index a5c7752c..0b1d08d1 100644 --- a/components/control-plane/internal/serviceaccountkeycloak/client_test.go +++ b/components/control-plane/internal/serviceaccountkeycloak/client_test.go @@ -473,6 +473,63 @@ func TestReconcileServiceAccountPerformsNoWritesWhenConverged(t *testing.T) { } } +func TestReconcileServiceAccountPerformsNoWritesWithBuiltInServiceAccountScope(t *testing.T) { + rep := convergedClientRepresentation() + rep["defaultClientScopes"] = []string{builtInServiceAccountScope} + var writes []string + server := httptest.NewServer(reconcileHandler(t, rep, convergedProtocolMappers(), &writes)) + t.Cleanup(server.Close) + client := NewClient(server.URL, "realm", "provisioner", "admin-secret") + if err := client.ReconcileServiceAccount(t.Context(), reconcileSpec(), "service-uuid", "service-subject", true); err != nil { + t.Fatalf("ReconcileServiceAccount() error = %v", err) + } + if len(writes) != 0 { + t.Fatalf("converged reconciliation performed writes: %v", writes) + } +} + +func TestUpdateRepresentationSendsEmptyClientScopeLists(t *testing.T) { + type updatePayload struct { + Enabled bool `json:"enabled"` + ServiceAccountsEnabled bool `json:"serviceAccountsEnabled"` + DefaultClientScopes []string `json:"defaultClientScopes"` + OptionalClientScopes []string `json:"optionalClientScopes"` + } + updates := make(chan updatePayload, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/realms/realm/protocol/openid-connect/token": + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "admin-token", "expires_in": 300}) + case r.URL.Path == "/admin/realms/realm/clients/service-uuid" && r.Method == http.MethodPut: + var payload updatePayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid update payload", http.StatusBadRequest) + return + } + updates <- payload + w.WriteHeader(http.StatusNoContent) + default: + http.Error(w, fmt.Sprintf("unexpected %s %s", r.Method, r.URL.Path), http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + + client := NewClient(server.URL, "realm", "provisioner", "admin-secret") + if err := client.updateRepresentation(t.Context(), "service-uuid", reconcileSpec(), false); err != nil { + t.Fatalf("updateRepresentation() error = %v", err) + } + payload := <-updates + if payload.Enabled || !payload.ServiceAccountsEnabled { + t.Fatalf("client state = enabled %t, service accounts enabled %t", payload.Enabled, payload.ServiceAccountsEnabled) + } + if payload.DefaultClientScopes == nil || len(payload.DefaultClientScopes) != 0 { + t.Fatalf("default client scopes = %v, want an explicit empty list", payload.DefaultClientScopes) + } + if payload.OptionalClientScopes == nil || len(payload.OptionalClientScopes) != 0 { + t.Fatalf("optional client scopes = %v, want an explicit empty list", payload.OptionalClientScopes) + } +} + func TestReconcileServiceAccountRepairsSecurityBroadeningDrift(t *testing.T) { // Each case flips exactly one security-relevant field on an otherwise // converged client. The zero-write predicate must reject every one of them so @@ -489,6 +546,15 @@ func TestReconcileServiceAccountRepairsSecurityBroadeningDrift(t *testing.T) { {name: "direct access grants enabled", mutate: func(rep map[string]any) { rep["directAccessGrantsEnabled"] = true }}, {name: "redirect origin added", mutate: func(rep map[string]any) { rep["redirectUris"] = []string{"https://attacker.example"} }}, {name: "default client scope injected", mutate: func(rep map[string]any) { rep["defaultClientScopes"] = []string{"rogue-scope"} }}, + {name: "default client scope added to built-in scope", mutate: func(rep map[string]any) { + rep["defaultClientScopes"] = []string{builtInServiceAccountScope, "rogue-scope"} + }}, + {name: "built-in default client scope duplicated", mutate: func(rep map[string]any) { + rep["defaultClientScopes"] = []string{builtInServiceAccountScope, builtInServiceAccountScope} + }}, + {name: "optional client scope injected", mutate: func(rep map[string]any) { + rep["optionalClientScopes"] = []string{"rogue-scope"} + }}, {name: "device grant enabled", mutate: func(rep map[string]any) { rep["attributes"].(map[string]string)[deviceGrantAttribute] = "true" }}, diff --git a/skills/RECONCILE.md b/skills/RECONCILE.md index 02a3bddd..64d75426 100644 --- a/skills/RECONCILE.md +++ b/skills/RECONCILE.md @@ -45,9 +45,9 @@ skills/ ## Reconciliation State -**Last analyzed**: 2026-08-27 (OpenShell Gateway Console GC-W1 complete) +**Last analyzed**: 2026-08-31 (Keycloak event-storm KC-ES-W1 complete) **Spec corpus**: 40 spec files; the coverage table tracks 32 analyzed feature/spec groups after adding OpenShell Gateway Console -**Codebase commit**: 9984ed0 (fix/console OpenShift Route ingress) +**Codebase commit**: working tree (Keycloak event-storm KC-ES-W1 complete) ### Coverage Summary @@ -68,11 +68,11 @@ skills/ | Platform - Sandbox Count | 1 | 6 | 6 | 0 | 0 | 0 | 100% | | Platform - Local Development | 1 | 25 | 23 | 0 | 1 | 1 | 96% | | Platform - E2E Testing | 1 | 8 | 8 | 0 | 0 | 0 | 100% | -| Platform - OIDC Integration | 1 | 6 | 5 | 1 | 0 | 0 | 92% | +| Platform - OIDC Integration | 1 | 7 | 6 | 1 | 0 | 0 | 93% | | Web Console - Architecture | 1 | 28 | 21 | 5 | 2 | 0 | 86% | | Security - RBAC Enforcement | 1 | 13 | 11 | 0 | 0 | 2 | 85% | | Standards | 13 | 0 | 0 | 0 | 0 | 0 | N/A | -| **TOTAL** | **32** | **224** | **175** | **18** | **26** | **5** | **82%** | +| **TOTAL** | **32** | **225** | **176** | **18** | **26** | **5** | **82%** | ### Spec Dependency Order @@ -133,13 +133,14 @@ Layer 7: web-console/architecture (depends on data-model, security, UI | SA-11 | Workspace membership is a separate grant | Present | - | `plugins/serviceAccounts/presenter.go`, `components/cli/pkg/serviceaccount/`, `packages/gateway-management-ui/src/service-accounts/` | SA-W1, W4, W5 | | SA-12 | Scopes are not configurable in version 1 | Present | - | `openapi.serviceAccounts.yaml`, `pkg/keycloak/service_accounts.go` | SA-W1, W3 | | SA-13 | Auditability and secret redaction | Present | - | `plugins/serviceAccounts/`, `pkg/keycloak/`, generated SDKs, `components/web-console/bff/`, `packages/gateway-management-ui/src/service-accounts/` | SA-W3, W5 | -| SA-14 | Reconciliation and drift repair | Present | Structural reconciliation intentionally does not fetch a delivered secret or mint a token; this follows the stronger secret rule and records the spec contradiction | `plugins/serviceAccounts/service.go`, `pkg/keycloak/service_accounts.go` | SA-W3 | +| SA-14 | Reconciliation and drift repair | Present | The control-plane convergence predicate accepts Keycloak's built-in `service_account` scope and rejects all other client scopes. Structural reconciliation intentionally does not fetch a delivered secret or mint a token; this follows the stronger secret rule and records the spec contradiction. | `components/control-plane/internal/serviceaccountkeycloak/client.go`, `plugins/serviceAccounts/service.go`, `pkg/keycloak/service_accounts.go` | KC-ES-W1 | | SA-15 | Verification coverage | Present | - | `pkg/keycloak/*_test.go`, `plugins/serviceAccounts/*_test.go`, `pkg/rbac/*_test.go`, `components/cli/pkg/serviceaccount/*_test.go`, `packages/gateway-management-ui/src/service-accounts/*_test.ts*`, `components/web-console/**/*test*` | SA-W1..W6 | **Scoped analysis notes:** - The nested API, generated SDKs, CLI, Keycloak lifecycle, reconciliation, cleanup barrier, and gateway-detail management UI now implement the resource's public behavior. - The SDK and CLI generators now project the nested service-account collection. Generated clients and commands remain reproducible from the OpenAPI contract. +- Keycloak 26.1 and later adds the built-in `service_account` default client scope. The control-plane convergence predicate accepts this provider-managed scope without a write and rejects all additional scopes. - The spec forbids retrieving or regenerating a delivered client secret during reconciliation, while the full-scope drift scenario asks reconciliation to issue and inspect another Client Credentials token. Creation can perform this token test because it still holds the new secret. Later reconciliation can verify and repair structural Keycloak state but cannot perform a new grant without violating the stronger one-time-secret rule. This remains a specification mismatch; reconciliation will not fetch the secret. - The BFF forwards `Cache-Control` and `Pragma`, preserving the one-time response's no-store policy end to end. @@ -331,6 +332,7 @@ Layer 7: web-console/architecture (depends on data-model, security, UI | OI-4 | BFF Browser Session Contract | Present | GET /auth/session with identity, roles, expiry; no tokens | `bff/src/auth.ts` | OIDC ✅ | | OI-5 | Kind OIDC Always-On | Present | OIDC enabled unconditionally in kind-up; KIND_ENABLE_OIDC removed | `scripts/kind/`, `Makefile` | OIDC ✅ | | OI-6 | Identity Provider Client Security | Partial | redirectUris restricted but port wildcard pattern not supported by Keycloak; needs explicit port URIs | `keycloak.yaml` | Follow-up | +| OI-7 | Control Plane Service Token Reuse | Present | - | `components/control-plane/internal/auth/token_provider.go`, `components/control-plane/internal/auth/token_provider_test.go` | KC-ES-W1 | ### rbac-enforcement.spec.md @@ -440,6 +442,21 @@ Layer 7: web-console/architecture (depends on data-model, security, UI ## Wave Plan +### KC-ES-W1: Stop the Keycloak event storm + +**Scope:** OI-7, SA-14 +**Dependency:** Existing control-plane token provider and service-account Keycloak reconciliation +**Status:** Complete + +1. Interpret the token response's `expires_in` value as seconds and keep the 80 percent refresh threshold. +2. Add a regression test that proves that repeated calls reuse one token. +3. Accept an empty default-scope list or one built-in `service_account` scope as converged. +4. Reject every other default scope and every optional scope as drift. +5. Add regression tests for the provider-managed scope and additional-scope drift. +6. Run control-plane tests, race tests, vet, build, alignment, and review checks. + +**KC-ES-W1 summary:** The token provider now interprets `expires_in` as seconds and refreshes after 80 percent of the token lifetime. Service-account reconciliation now accepts Keycloak's built-in `service_account` scope without a write and repairs every other client scope. Sequential, concurrent, threshold, no-write, drift, and update-payload tests cover the changes. The complete control-plane test suite, affected-package race tests, vet, lint, build, alignment scan, and independent review passed. + ### GC-W1: OpenShift Route support for the Gateway Console **Scope:** GC-1, GC-5, GC-7, GC-9 @@ -667,6 +684,8 @@ label-selected pod informer. | Date | Commit | Action | Coverage | Notes | |------|--------|--------|----------|-------| +| 2026-08-31 | working tree | Completed Keycloak event-storm KC-ES-W1 | 82% | Corrected the token lifetime unit, reused tokens until the 80 percent threshold, accepted the provider-managed service-account scope, rejected all other client scopes, and added regression tests. OI-7 and SA-14 are present. | +| 2026-08-31 | 9ac4354 | Keycloak event-storm scoped gap analysis | 82% | Found two partial requirements: the token cache uses nanoseconds for `expires_in`, and service-account convergence rejects Keycloak's built-in scope. Planned control-plane wave KC-ES-W1. | | 2026-08-27 | 9984ed0 | Completed Gateway Console GC-W1 | 82% | Added mode-selected Route exposure, admission readiness, lifecycle cleanup, custom-host RBAC, and tests. All nine console requirements are present. | | 2026-08-27 | 612b373 | Gateway Console scoped gap analysis | 81% | Added the console spec to the registry and found four partial requirements. Planned one control-plane wave for OpenShift Route exposure, readiness, cleanup, RBAC, and tests. | | 2026-08-03 | initial | Initial setup | 100% | Baseline with 6 Kinds fully implemented | diff --git a/specs/platform/oidc-integration.spec.md b/specs/platform/oidc-integration.spec.md index 231584ba..5b88742e 100644 --- a/specs/platform/oidc-integration.spec.md +++ b/specs/platform/oidc-integration.spec.md @@ -1,6 +1,6 @@ # Platform OIDC Integration -**Date:** 2026-08-14 +**Date:** 2026-08-31 **Status:** Draft **Related:** `openshell-gateway-oidc.spec.md` - per-gateway OIDC authentication; `../web-console/architecture.spec.md` - WEB-AUTH-01 through WEB-AUTH-03; `local-development.spec.md` - Kind cluster environment @@ -316,6 +316,23 @@ The API server SHALL support JWT validation against a configurable JWKS endpoint - WHEN a request is made to `/healthcheck` or `/api/hypershell/v1/openapi` - THEN the request SHALL be processed without JWT validation +### Requirement: Control Plane Service Token Reuse + +The control plane SHALL cache the access token that it gets with its Client Credentials grant. It SHALL interpret the token response's `expires_in` value as seconds. It SHALL reuse the token until 80 percent of its lifetime has passed. Concurrent calls SHALL use the same cached token. + +#### Scenario: Repeated gRPC calls reuse one token + +- GIVEN Keycloak returns a control-plane access token with `expires_in=300` +- WHEN the control plane makes two gRPC calls before 240 seconds have passed +- THEN it SHALL make one Client Credentials grant +- AND both gRPC calls SHALL use the same access token + +#### Scenario: The refresh threshold has passed + +- GIVEN the cached control-plane access token has passed 80 percent of its lifetime +- WHEN the control plane makes another authenticated gRPC call +- THEN it SHALL get a new access token before it makes the call + ### Requirement: BFF OIDC Authorization Code Flow The web console BFF SHALL implement OAuth 2.0 authorization code flow with PKCE when configured with an OIDC issuer. This fulfills WEB-AUTH-01. diff --git a/specs/platform/openshell-gateway-service-accounts.spec.md b/specs/platform/openshell-gateway-service-accounts.spec.md index a1f58d79..37433e48 100644 --- a/specs/platform/openshell-gateway-service-accounts.spec.md +++ b/specs/platform/openshell-gateway-service-accounts.spec.md @@ -1,6 +1,6 @@ # OpenShellGatewayServiceAccount (Client Secret) Specification -**Date:** 2026-08-21 +**Date:** 2026-08-31 **Status:** Draft **Tracks:** [HYPERSHELL-49](https://redhat.atlassian.net/browse/HYPERSHELL-49) - Service account provisioning via federated Keycloak **Parent:** `openshell-gateway-keycloak.spec.md` - per-gateway Keycloak clients and role mapping @@ -399,6 +399,8 @@ Client attributes SHALL contain the OpenShellGatewayServiceAccount ID, gateway I | Device Authorization Grant | disabled | | `fullScopeAllowed` | `false` | | redirect URIs / web origins | empty | +| default client scopes | empty, or only Keycloak's built-in `service_account` scope | +| optional client scopes | empty | | access-token lifetime override | `300` seconds by default. Never greater than `900` seconds. | The service-account client SHALL NOT receive these permissions: @@ -426,6 +428,8 @@ Keycloak includes a role only when both the service-account assignment and the s The service-account client SHALL have no scope mapping for another gateway. Realm defaults and client scopes SHALL NOT add another gateway audience or role. +Keycloak 26.1 and later automatically assigns the built-in `service_account` default client scope when `serviceAccountsEnabled` is true. HyperShell SHALL accept an empty default-scope list for older Keycloak versions. It SHALL also accept a list that contains only `service_account`. It SHALL treat any other default scope or any optional scope as drift. The built-in scope SHALL NOT cause a repair because Keycloak adds it again while service accounts are enabled. + ### Expected Access Token An access token for an administrator OpenShellGatewayServiceAccount has this security-relevant form: @@ -982,6 +986,22 @@ If Keycloak grants broader access, reconciliation SHALL remove the unexpected ro Reconciliation SHALL never regenerate or fetch a client secret. Such a change would invalidate the consumer's stored client credentials without delivering a replacement. +#### Scenario: Built-in service-account scope is converged + +- GIVEN a ready service-account client matches the required Keycloak state +- AND its only default client scope is Keycloak's built-in `service_account` scope +- WHEN HyperShell reconciles the OpenShellGatewayServiceAccount +- THEN it SHALL perform no Keycloak write +- AND it SHALL leave the client enabled + +#### Scenario: An additional client scope is drift + +- GIVEN a ready service-account client has the built-in `service_account` scope and another default client scope +- WHEN HyperShell reconciles the OpenShellGatewayServiceAccount +- THEN it SHALL disable the client before repair +- AND it SHALL remove the additional default client scope +- AND it SHALL preserve the built-in `service_account` scope that Keycloak manages + #### Scenario: Service-account client drifts to full scope - GIVEN an administrator manually changes a service-account client to `fullScopeAllowed=true`