diff --git a/workspaces/boost/openspec/changes/platform-operations-deployment/specs/runtime-config/spec.md b/workspaces/boost/openspec/changes/platform-operations-deployment/specs/runtime-config/spec.md index e2fa9980c4..0fdd282403 100644 --- a/workspaces/boost/openspec/changes/platform-operations-deployment/specs/runtime-config/spec.md +++ b/workspaces/boost/openspec/changes/platform-operations-deployment/specs/runtime-config/spec.md @@ -101,15 +101,16 @@ The following new features MUST have runtime configuration fields as specified b |---|---|---| | `boost.skillsMarketplace.endpoint` | yaml-only | Skills catalog backend URL | -#### Scenario: Token exchange configuration +#### Scenario: Keycloak service-account auth configuration -- **WHEN** the admin configures per-user Kagenti auth +- **WHEN** the admin configures Kagenti authentication - **THEN** the following fields are available: | Field | Scope | Description | |---|---|---| - | `boost.kagenti.auth.tokenExchange.enabled` | yaml-only | Enable RFC 8693 token exchange | - | `boost.kagenti.auth.tokenExchange.audience` | yaml-only | Target audience for exchanged token | - | `boost.kagenti.auth.tokenExchange.userTokenHeader` | yaml-only | Header containing user OIDC token | + | `boost.kagenti.auth.tokenEndpoint` | yaml-only | Keycloak token endpoint URL | + | `boost.kagenti.auth.clientId` | yaml-only | OAuth2 client ID for service-account | + | `boost.kagenti.auth.clientSecret` | yaml-only | OAuth2 client secret (visibility: secret) | + | `boost.kagenti.auth.tokenExpiryBufferSeconds` | yaml-only | Seconds before expiry to refresh token (default: 60) | #### Scenario: Credential encryption diff --git a/workspaces/boost/openspec/changes/platform-operations-deployment/tasks.md b/workspaces/boost/openspec/changes/platform-operations-deployment/tasks.md index ba559b7bea..a4ee472ce2 100644 --- a/workspaces/boost/openspec/changes/platform-operations-deployment/tasks.md +++ b/workspaces/boost/openspec/changes/platform-operations-deployment/tasks.md @@ -19,7 +19,7 @@ - [ ] 2.2 Generate `config.d.ts` types from Zod schemas - [ ] 2.3 Validate all config writes (YAML and DB) via Zod `.parse()` — no hand-written validators - [ ] 2.4 Annotate each field with `configScope`: `yaml-only`, `db-overridable`, or `db-only` -- [ ] 2.5 Add Zod schemas for new config fields: agentApproval, skillsMarketplace, tokenExchange, DevSpaces credentials +- [ ] 2.5 Add Zod schemas for new config fields: agentApproval, skillsMarketplace, Keycloak service-account auth, DevSpaces credentials - [ ] 2.6 Admin UI shows only DB-overridable and DB-only fields - [ ] 2.7 Implement credential encryption for sensitive DB-stored values (DevSpaces tokens) - [ ] 2.8 Implement schema version tracking: store schema version alongside DB values, re-validate on startup diff --git a/workspaces/boost/openspec/changes/pluggable-ai-platform-architecture/design.md b/workspaces/boost/openspec/changes/pluggable-ai-platform-architecture/design.md index b87fa0b1c2..57c4778cd7 100644 --- a/workspaces/boost/openspec/changes/pluggable-ai-platform-architecture/design.md +++ b/workspaces/boost/openspec/changes/pluggable-ai-platform-architecture/design.md @@ -18,7 +18,7 @@ Boost implements the provider abstraction as modular RHDH dynamic plugins from t - Modifying chat interaction behavior or message rendering - Rewriting the ADK orchestration library - Creating catalog entities for models/agents (covered in agent-creation-discovery change) -- Per-user token exchange (covered in security-safety-governance change) +- Per-user token exchange (deferred — service-account auth adopted instead; see security-safety-governance change) ## Decisions diff --git a/workspaces/boost/openspec/changes/security-safety-governance/design.md b/workspaces/boost/openspec/changes/security-safety-governance/design.md index 88cd52b769..dd69d3edf6 100644 --- a/workspaces/boost/openspec/changes/security-safety-governance/design.md +++ b/workspaces/boost/openspec/changes/security-safety-governance/design.md @@ -4,13 +4,13 @@ Boost builds the security and governance layer with Backstage fine-grained permissions as the sole authorization mechanism from day one. The Augment reference prototype's governance system grew into a parallel authorization layer where authorization decisions bypassed `permissions.authorize()`. Boost avoids this entirely. -Boost also implements RFC 8693 token exchange for per-user identity delegation to Kagenti from the start, enabling per-user audit trails. +Boost also implements service-account Keycloak authentication for Kagenti via OAuth2 Client Credentials Grant, with user identity propagated via headers for audit trails. ## Goals - Implement 16 fine-grained Backstage permissions as the sole authorization mechanism - Add conditional permission rules for ownership (IS_OWNER), separation of duties (IS_NOT_CREATOR), and lifecycle stage gating (HAS_LIFECYCLE_STAGE) -- Implement RFC 8693 token exchange for per-user Kagenti identity +- Implement service-account Keycloak auth for Kagenti via OAuth2 Client Credentials Grant - Use `development-only-no-auth` as the only dev security mode name (no legacy aliases) - Add CSRF protection and credential encryption - Export all permissions from `boost-common` @@ -40,20 +40,20 @@ These rules are evaluated against loaded resources via `createConditionalDecisio The `IS_NOT_CREATOR` permission rule is the primary enforcement mechanism. A route-level guard remains as defense-in-depth (belt and suspenders). Both layers are active in `security.mode === 'full'`. -### Decision 4: Per-user token exchange is backend-only with graceful fallback +### Decision 4: Service-account auth with token caching and streaming support -`TokenExchangeManager` reads the user's OIDC token from a configurable request header (injected by auth proxy), exchanges it via RFC 8693, caches per-user, and deduplicates concurrent exchanges. All failures fall back silently to the shared service-account token — no request is ever blocked by token exchange issues. This is deliberately conservative: token exchange enhances audit trails and per-user authorization but must never degrade availability. +`KeycloakTokenManager` obtains service-account tokens via OAuth2 Client Credentials Grant against Keycloak's token endpoint. Tokens are cached with a configurable expiry buffer (default 60s), concurrent requests share a single in-flight Keycloak call, and `getTokenForStreaming(minLifetimeMs)` ensures minimum validity for SSE connections. On 401 errors, the cached token is cleared and a fresh token is fetched before retrying (at most once — a second 401 is propagated to the caller). User identity is propagated via `X-Backstage-User` header for audit purposes (informational, not for authentication). ### Decision 5: Separation of authorization concerns Three non-overlapping authorization layers: - **Backstage** governs: UI visibility, agent lifecycle governance, ownership, approval workflows, admin operations -- **Kagenti** governs: agent specs, tools, runtime operations — via per-user exchanged token when enabled +- **Kagenti** governs: agent specs, tools, runtime operations — via service-account token; user identity propagated via header for audit - **Kubernetes** governs: pod/deployment operations, namespace scoping, SPIRE mTLS ## Risks - **RBAC policy complexity:** 16 permissions with conditions is more complex than 2. Mitigated by sensible defaults — `boost.access` as top-level gate and `boost.admin` available for coarse control. -- **Token exchange reliability:** Keycloak availability becomes a dependency. Mitigated by graceful fallback to service-account token on any failure. +- **Keycloak availability:** Keycloak availability becomes a dependency for Kagenti auth. Mitigated by token caching with expiry buffer (requests continue with cached token even during brief Keycloak outages) and clear error reporting on token fetch failures. - **SonataFlow trust boundary:** Callbacks bypass self-approval prevention via header. Callback identity verification should be implemented to close this gap. diff --git a/workspaces/boost/openspec/changes/security-safety-governance/proposal.md b/workspaces/boost/openspec/changes/security-safety-governance/proposal.md index 6c1abe6e54..aa2b6f0a65 100644 --- a/workspaces/boost/openspec/changes/security-safety-governance/proposal.md +++ b/workspaces/boost/openspec/changes/security-safety-governance/proposal.md @@ -21,8 +21,9 @@ Enterprise AI platforms must treat security, safety, and governance as foundatio ### Identity & Authentication - RBAC via Keycloak OIDC + Backstage permissions (`boost.access`, `boost.admin` as top-level gates) -- RFC 8693 token exchange for per-user Kagenti identity delegation -- Graceful fallback to service-account token on any exchange failure +- Service-account Keycloak auth for Kagenti via OAuth2 Client Credentials Grant +- Token caching with expiry buffer, streaming support, and 401 retry with cache invalidation +- User identity propagation via `X-Backstage-User` header for audit trails - MCP 4-level auth chain - Kagenti SPIRE integration for infrastructure mTLS @@ -45,4 +46,4 @@ Enterprise AI platforms must treat security, safety, and governance as foundatio - `plugins/boost-common/src/permissions.ts` — 16 permission definitions with resource types - `plugins/boost-backend/src/middleware/security.ts` — `authorizeLifecycleAction` middleware - `plugins/boost-frontend/src/components/SecurityGate.tsx` — granular permission checks -- `plugins/boost-backend/src/services/TokenExchangeManager.ts` — RFC 8693 implementation +- `plugins/boost-backend-module-kagenti/src/client/KeycloakTokenManager.ts` — OAuth2 Client Credentials service-account auth diff --git a/workspaces/boost/openspec/changes/security-safety-governance/specs/access-control/spec.md b/workspaces/boost/openspec/changes/security-safety-governance/specs/access-control/spec.md index 327a6e51cd..e9727529dd 100644 --- a/workspaces/boost/openspec/changes/security-safety-governance/specs/access-control/spec.md +++ b/workspaces/boost/openspec/changes/security-safety-governance/specs/access-control/spec.md @@ -81,41 +81,55 @@ Inference responses are not stored on the server when ZDR is enabled. ## ADDED Requirements -### Requirement: Per-User Kagenti Identity via Token Exchange +### Requirement: Service-Account Kagenti Authentication via Keycloak -User identity MUST be delegated to Kagenti via RFC 8693 OAuth2 Token Exchange so agent operations are authorized per-user. +Kagenti requests MUST be authenticated using a dedicated service-account via OAuth2 Client Credentials Grant. User identity MUST be propagated via headers for audit purposes. -#### Scenario: Token exchange enabled +#### Scenario: Service-account token acquisition -- **WHEN** `boost.kagenti.auth.tokenExchange.enabled` is `true` -- **AND** a user's OIDC token is available via the configured header (default: `X-Forwarded-Access-Token`) -- **THEN** `TokenExchangeManager` exchanges the user's token for a Kagenti-scoped token via RFC 8693 -- **AND** the exchanged token is cached per-user with TTL from token expiry -- **AND** concurrent exchanges for the same user are deduplicated -- **AND** the per-user token is used for all Kagenti API calls on behalf of that user +- **WHEN** `boost.kagenti.auth.tokenEndpoint` is configured +- **AND** `boost.kagenti.auth.clientId` and `boost.kagenti.auth.clientSecret` are provided +- **THEN** `KeycloakTokenManager` obtains a service-account token via OAuth2 Client Credentials Grant +- **AND** the token is cached with a configurable expiry buffer (default: 60 seconds) +- **AND** concurrent token requests share a single in-flight Keycloak call +- **AND** the `Authorization: Bearer ` header is added to all Kagenti API calls -#### Scenario: Token exchange graceful fallback +#### Scenario: Streaming token lifecycle -- **WHEN** token exchange fails (missing header, Keycloak error, exchange failure, disabled config) -- **THEN** the system silently falls back to the shared service-account token -- **AND** no request is blocked due to token exchange failure -- **AND** the fallback is logged for debugging +- **WHEN** a streaming (SSE) request is initiated +- **THEN** `getTokenForStreaming(minLifetimeMs)` ensures the token has sufficient remaining validity +- **AND** if the token would expire during the stream, a fresh token is obtained before the request starts -#### Scenario: Token exchange configuration +#### Scenario: Token refresh on authentication failure -- **WHEN** token exchange is configured +- **WHEN** a Kagenti API call returns HTTP 401 +- **THEN** the cached token is immediately invalidated +- **AND** a fresh token is obtained from Keycloak +- **AND** the original request is retried with the new token +- **AND** the retry is attempted at most once — if the retried request also returns 401, the error is propagated to the caller + +#### Scenario: User identity propagation + +- **WHEN** a Kagenti API call is made on behalf of a user +- **THEN** the `X-Backstage-User` header carries the Backstage user entity ref (e.g., `user:default/jsmith`) +- **AND** this header is informational only — authentication is via the service-account token + +#### Scenario: Service-account auth configuration + +- **WHEN** Kagenti authentication is configured - **THEN** the following config is used: | Key | Default | Description | |---|---|---| - | `boost.kagenti.auth.tokenExchange.enabled` | `false` | Enable per-user token exchange | - | `boost.kagenti.auth.tokenExchange.audience` | — | Target audience for exchanged token | - | `boost.kagenti.auth.tokenExchange.userTokenHeader` | `X-Forwarded-Access-Token` | Header containing user's OIDC token | + | `boost.kagenti.auth.tokenEndpoint` | — | Keycloak token endpoint URL | + | `boost.kagenti.auth.clientId` | — | OAuth2 client ID for service-account | + | `boost.kagenti.auth.clientSecret` | — | OAuth2 client secret | + | `boost.kagenti.auth.tokenExpiryBufferSeconds` | `60` | Seconds before expiry to refresh token | #### Scenario: LlamaStack provider unaffected -- **WHEN** token exchange is configured -- **THEN** `ResponsesApiProvider` is not modified — `setUserContext` is optional and not implemented -- **AND** token exchange is Kagenti-specific +- **WHEN** Kagenti service-account auth is configured +- **THEN** `ResponsesApiProvider` is not modified — it uses a separate authentication path +- **AND** Keycloak service-account auth is Kagenti-specific ### Requirement: CSRF Protection diff --git a/workspaces/boost/openspec/changes/security-safety-governance/tasks.md b/workspaces/boost/openspec/changes/security-safety-governance/tasks.md index 27ceee8ed6..6178268280 100644 --- a/workspaces/boost/openspec/changes/security-safety-governance/tasks.md +++ b/workspaces/boost/openspec/changes/security-safety-governance/tasks.md @@ -43,15 +43,15 @@ - [ ] 6.4 Gate MCP panel with `boost.mcp.manage` - [ ] 6.5 Gate config panel with `boost.config.manage` -## 7. Token Exchange (P1) +## 7. Keycloak Service-Account Authentication (P1) -- [ ] 7.1 Create `TokenExchangeManager` implementing RFC 8693 exchange -- [ ] 7.2 Add per-user token caching with TTL from token expiry -- [ ] 7.3 Add concurrent exchange deduplication -- [ ] 7.4 Add graceful fallback to service-account token on all failures -- [ ] 7.5 Add config schema: `boost.kagenti.auth.tokenExchange.{enabled, audience, userTokenHeader}` -- [ ] 7.6 Integrate into `KagentiApiClient.requestCore()` — inject per-user token when available -- [ ] 7.7 Extract user OIDC token from configurable request header in route handlers +- [ ] 7.1 Create `KeycloakTokenManager` implementing OAuth2 Client Credentials Grant +- [ ] 7.2 Add token caching with configurable expiry buffer (default 60s) +- [ ] 7.3 Add concurrent token request deduplication (single in-flight Keycloak call) +- [ ] 7.4 Add `getTokenForStreaming(minLifetimeMs)` for SSE connection support +- [ ] 7.5 Add single 401 retry with cache invalidation and fresh token fetch +- [ ] 7.6 Add config schema: `boost.kagenti.auth.{tokenEndpoint, clientId, clientSecret, tokenExpiryBufferSeconds}` +- [ ] 7.7 Integrate into `KagentiApiClient.requestCore()` — add `Authorization: Bearer` and `X-Backstage-User` headers ## 8. CSRF and Credential Security (P2) @@ -70,7 +70,7 @@ - [ ] 10.2 Verify IS_OWNER blocks non-owner promote/delete/withdraw - [ ] 10.3 Verify IS_NOT_CREATOR blocks self-approval - [ ] 10.4 Verify `boost.admin` works as coarse-grained alternative to fine-grained permissions -- [ ] 10.5 Verify token exchange fallback: disabled config → service-account token -- [ ] 10.6 Verify token exchange fallback: Keycloak error → service-account token -- [ ] 10.7 Verify token exchange fallback: missing header → service-account token +- [ ] 10.5 Verify service-account token acquisition and caching with expiry buffer +- [ ] 10.6 Verify 401 retry: cache invalidation → fresh token → successful retry +- [ ] 10.7 Verify streaming token lifecycle: `getTokenForStreaming` returns token with sufficient validity - [ ] 10.8 Verify `none` is rejected with a clear error pointing to `development-only-no-auth` diff --git a/workspaces/boost/plugins/boost-backend/config.d.ts b/workspaces/boost/plugins/boost-backend/config.d.ts index ea62cb4d98..123283ff1a 100644 --- a/workspaces/boost/plugins/boost-backend/config.d.ts +++ b/workspaces/boost/plugins/boost-backend/config.d.ts @@ -97,26 +97,29 @@ export interface Config { /** Kagenti provider configuration. */ kagenti?: { - /** Authentication configuration. */ + /** Authentication configuration (OAuth2 Client Credentials Grant). */ auth?: { - /** RFC 8693 token exchange. */ - tokenExchange?: { - /** - * Enable RFC 8693 token exchange for Kagenti. - * @configScope yaml-only - */ - enabled?: boolean; - /** - * Target audience for exchanged token. - * @configScope yaml-only - */ - audience?: string; - /** - * Header containing user OIDC token. - * @configScope yaml-only - */ - userTokenHeader?: string; - }; + /** + * Keycloak token endpoint URL. + * @configScope yaml-only + */ + tokenEndpoint?: string; + /** + * OAuth2 client ID for service-account authentication. + * @configScope yaml-only + */ + clientId?: string; + /** + * OAuth2 client secret for service-account authentication. + * @visibility secret + * @configScope yaml-only + */ + clientSecret?: string; + /** + * Seconds before token expiry to trigger a refresh (default: 60). + * @configScope yaml-only + */ + tokenExpiryBufferSeconds?: number; }; }; diff --git a/workspaces/boost/plugins/boost-backend/report.api.md b/workspaces/boost/plugins/boost-backend/report.api.md index 2e96d31b4a..71fac2f422 100644 --- a/workspaces/boost/plugins/boost-backend/report.api.md +++ b/workspaces/boost/plugins/boost-backend/report.api.md @@ -112,7 +112,7 @@ export interface BackendApprovalStoreOptions { } // @public -export const BOOST_CONFIG_SCHEMA_VERSION = 1; +export const BOOST_CONFIG_SCHEMA_VERSION = 2; // @public export const boostAiProviderServiceFactory: ServiceFactory< @@ -170,19 +170,25 @@ export const boostConfigFields: { readonly configScope: ConfigScope; readonly description: string; }; - readonly 'boost.kagenti.auth.tokenExchange.enabled': { - readonly schema: z.ZodOptional; + readonly 'boost.kagenti.auth.tokenEndpoint': { + readonly schema: z.ZodOptional; readonly configScope: ConfigScope; readonly description: string; }; - readonly 'boost.kagenti.auth.tokenExchange.audience': { + readonly 'boost.kagenti.auth.clientId': { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; readonly description: string; }; - readonly 'boost.kagenti.auth.tokenExchange.userTokenHeader': { + readonly 'boost.kagenti.auth.clientSecret': { readonly schema: z.ZodOptional; readonly configScope: ConfigScope; + readonly sensitive: true; + readonly description: string; + }; + readonly 'boost.kagenti.auth.tokenExpiryBufferSeconds': { + readonly schema: z.ZodOptional; + readonly configScope: ConfigScope; readonly description: string; }; readonly 'boost.encryptionSecret': { diff --git a/workspaces/boost/plugins/boost-backend/src/config/schemas.test.ts b/workspaces/boost/plugins/boost-backend/src/config/schemas.test.ts index a7e17491a9..4892c7032c 100644 --- a/workspaces/boost/plugins/boost-backend/src/config/schemas.test.ts +++ b/workspaces/boost/plugins/boost-backend/src/config/schemas.test.ts @@ -36,7 +36,10 @@ describe('boostConfigFields', () => { expect(keys).toContain('boost.security.mode'); expect(keys).toContain('boost.features.agentCreation'); expect(keys).toContain('boost.agentApproval.mode'); - expect(keys).toContain('boost.kagenti.auth.tokenExchange.enabled'); + expect(keys).toContain('boost.kagenti.auth.tokenEndpoint'); + expect(keys).toContain('boost.kagenti.auth.clientId'); + expect(keys).toContain('boost.kagenti.auth.clientSecret'); + expect(keys).toContain('boost.kagenti.auth.tokenExpiryBufferSeconds'); expect(keys).toContain('boost.encryptionSecret'); expect(keys).toContain('boost.devSpaces.credentials'); }); @@ -105,6 +108,36 @@ describe('validateConfigValue', () => { it('validates model name requires non-empty string', () => { expect(() => validateConfigValue('boost.model.name', '')).toThrow(ZodError); }); + + it('validates tokenExpiryBufferSeconds accepts valid integers', () => { + expect( + validateConfigValue('boost.kagenti.auth.tokenExpiryBufferSeconds', 120), + ).toBe(120); + expect( + validateConfigValue('boost.kagenti.auth.tokenExpiryBufferSeconds', 0), + ).toBe(0); + }); + + it('rejects negative tokenExpiryBufferSeconds', () => { + expect(() => + validateConfigValue('boost.kagenti.auth.tokenExpiryBufferSeconds', -1), + ).toThrow(ZodError); + }); + + it('rejects non-integer tokenExpiryBufferSeconds', () => { + expect(() => + validateConfigValue('boost.kagenti.auth.tokenExpiryBufferSeconds', 30.5), + ).toThrow(ZodError); + }); + + it('accepts undefined tokenExpiryBufferSeconds (consumer applies default)', () => { + expect( + validateConfigValue( + 'boost.kagenti.auth.tokenExpiryBufferSeconds', + undefined, + ), + ).toBeUndefined(); + }); }); describe('isDbWritable', () => { @@ -118,9 +151,7 @@ describe('isDbWritable', () => { it('returns false for yaml-only fields', () => { expect(isDbWritable('boost.security.mode')).toBe(false); expect(isDbWritable('boost.agentApproval.sonataflow.endpoint')).toBe(false); - expect(isDbWritable('boost.kagenti.auth.tokenExchange.enabled')).toBe( - false, - ); + expect(isDbWritable('boost.kagenti.auth.tokenEndpoint')).toBe(false); expect(isDbWritable('boost.encryptionSecret')).toBe(false); }); }); @@ -129,6 +160,7 @@ describe('isSensitiveField', () => { it('returns true for sensitive fields', () => { expect(isSensitiveField('boost.encryptionSecret')).toBe(true); expect(isSensitiveField('boost.devSpaces.credentials')).toBe(true); + expect(isSensitiveField('boost.kagenti.auth.clientSecret')).toBe(true); }); it('returns false for non-sensitive fields', () => { diff --git a/workspaces/boost/plugins/boost-backend/src/config/schemas.ts b/workspaces/boost/plugins/boost-backend/src/config/schemas.ts index 5c3a85ca60..16c99502ee 100644 --- a/workspaces/boost/plugins/boost-backend/src/config/schemas.ts +++ b/workspaces/boost/plugins/boost-backend/src/config/schemas.ts @@ -52,7 +52,7 @@ export interface ConfigFieldMeta { * * @public */ -export const BOOST_CONFIG_SCHEMA_VERSION = 1; +export const BOOST_CONFIG_SCHEMA_VERSION = 2; // --------------------------------------------------------------------------- // Individual field schemas with metadata @@ -163,33 +163,46 @@ export const boostConfigFields = { 'URL of the external skills catalog backend service. ' + 'Boost proxies browse/filter requests to this endpoint.', }, - // -- Kagenti auth / token exchange -- - 'boost.kagenti.auth.tokenExchange.enabled': { - schema: z.boolean().optional().describe('Enable RFC 8693 token exchange'), + // -- Kagenti auth / OAuth2 Client Credentials -- + 'boost.kagenti.auth.tokenEndpoint': { + schema: z.string().url().optional().describe('Keycloak token endpoint URL'), configScope: 'yaml-only' as ConfigScope, description: - 'Enable RFC 8693 token exchange for Kagenti. When enabled, the ' + - 'user OIDC token is exchanged for a Kagenti-scoped token.', + 'Keycloak token endpoint URL for obtaining service-account tokens ' + + 'via OAuth2 Client Credentials Grant.', }, - 'boost.kagenti.auth.tokenExchange.audience': { + 'boost.kagenti.auth.clientId': { schema: z .string() .optional() - .describe('Target audience for exchanged token'), + .describe('OAuth2 client ID for service-account'), configScope: 'yaml-only' as ConfigScope, description: - 'Target audience claim for the exchanged token, typically the ' + - 'Kagenti service identifier in the identity provider.', + 'OAuth2 client ID for Keycloak service-account authentication ' + + 'to Kagenti.', }, - 'boost.kagenti.auth.tokenExchange.userTokenHeader': { + 'boost.kagenti.auth.clientSecret': { schema: z .string() .optional() - .describe('Request header containing the user OIDC token'), + .describe('OAuth2 client secret for service-account'), + configScope: 'yaml-only' as ConfigScope, + sensitive: true, + description: + 'OAuth2 client secret for Keycloak service-account authentication ' + + 'to Kagenti.', + }, + 'boost.kagenti.auth.tokenExpiryBufferSeconds': { + schema: z + .number() + .int() + .min(0) + .optional() + .describe('Seconds before token expiry to trigger refresh'), configScope: 'yaml-only' as ConfigScope, description: - 'Name of the HTTP request header that carries the user OIDC token ' + - 'for token exchange (e.g. "x-user-oidc-token").', + 'Number of seconds before token expiry to proactively refresh ' + + 'the cached Keycloak token (default: 60).', }, // -- Encryption -- diff --git a/workspaces/boost/specifications/boost-context.md b/workspaces/boost/specifications/boost-context.md index a1a0680412..a7c6c01ea3 100644 --- a/workspaces/boost/specifications/boost-context.md +++ b/workspaces/boost/specifications/boost-context.md @@ -23,7 +23,7 @@ The Augment plugin (in `redhat-developer/rhdh-plugins`, workspace `augment`) is - [Agent Creation & Discovery](prd/agent-creation-discovery.md) — agent gallery, 4 creation paths, MCP tools, skills marketplace integration, catalog entities (UC-4, UC-7 through UC-13) - [Pluggable AI Platform Architecture](prd/pluggable-ai-platform-architecture.md) — provider abstraction, streaming protocol, hot-swap, multi-agent orchestration, providers as RHDH dynamic plugins (UC-14, UC-16) - [Platform Operations & Deployment](prd/platform-operations-deployment.md) — deployment, runtime config, RAG pipelines, white-labeling, workspace package structure (UC-15, UC-17, UC-18, UC-21, UC-22) -- [Security, Safety & Governance](prd/security-safety-governance.md) — 16 fine-grained permissions, lifecycle governance, token exchange, safety shields, resilience (UC-19, UC-20, UC-23 through UC-25) +- [Security, Safety & Governance](prd/security-safety-governance.md) — 16 fine-grained permissions, lifecycle governance, service-account Keycloak auth, safety shields, resilience (UC-19, UC-20, UC-23 through UC-25) ## Workspace Structure @@ -101,11 +101,11 @@ Agent lifecycle uses the 4-stage model (Draft → Pending → Published → Arch _Augment lesson: Pivoted from 5-stage to 4-stage model mid-development, requiring `LEGACY_STAGE_MAP` and `normalizeLifecycleStage` compatibility layers._ -### 10. Per-User Identity Delegation +### 10. Service-Account Keycloak Authentication -Kagenti authentication uses RFC 8693 token exchange for per-user authorization from the start (when enabled). No shared service-account-only authentication presenting all users as the same identity. +Kagenti authentication uses OAuth2 Client Credentials Grant with a dedicated `KeycloakTokenManager` that handles token caching, expiry buffer, streaming-compatible token lifecycle, and 401 retry with cache invalidation. User identity is propagated via `X-Backstage-User` header for audit trail purposes. -_Augment lesson: All Kagenti requests used a shared service-account token. `X-Backstage-User` header was informational only. Per-user audit trail impossible at the provider level._ +_Augment lesson: The service-account approach with `KeycloakTokenManager` proved robust in production. Token caching with expiry buffer, concurrent request deduplication, and streaming support are the right patterns. Per-user RFC 8693 token exchange adds complexity without proportional benefit at this stage — service-account auth with user context propagation via headers is sufficient for audit trails._ ### 11. Testing from Day One diff --git a/workspaces/boost/specifications/prd/platform-operations-deployment.md b/workspaces/boost/specifications/prd/platform-operations-deployment.md index 373453fb67..27466c1bd1 100644 --- a/workspaces/boost/specifications/prd/platform-operations-deployment.md +++ b/workspaces/boost/specifications/prd/platform-operations-deployment.md @@ -186,7 +186,7 @@ All packages live at `rhdh-plugins/workspaces/boost/plugins/`: - **Token and Turn Caps:** Maximum output tokens, tool calls per turn, agent turns - **Chat Experience:** Featured agents, conversation starters (Kagenti) - **Appearance:** Logo, colors, theme presets -- **Kagenti Auth:** Token exchange configuration (`tokenExchange.enabled`, `audience`, `userTokenHeader`) +- **Kagenti Auth:** Service-account Keycloak configuration (`tokenEndpoint`, `clientId`, `clientSecret`, `tokenExpiryBufferSeconds`) - **DevSpaces:** Workspace configuration (credentials must be stored encrypted, not plaintext) **Admin onboarding:** `AdminOnboardingCard` provides guided setup steps on first admin visit. diff --git a/workspaces/boost/specifications/prd/security-safety-governance.md b/workspaces/boost/specifications/prd/security-safety-governance.md index 8dc7e32e2b..fa5191fb9c 100644 --- a/workspaces/boost/specifications/prd/security-safety-governance.md +++ b/workspaces/boost/specifications/prd/security-safety-governance.md @@ -3,7 +3,7 @@ **Product:** Boost — Agentic Developer Portal for Red Hat Developer Hub **Status:** Requirements for new implementation (informed by Augment reference prototype) **Date:** 2026-05-19 -**Updated:** 2026-06-02 — reframed for boost; 4-stage lifecycle, 16 fine-grained permissions, RFC 8693 token exchange, SonataFlow approval integration, Backstage RBAC as sole authorization layer +**Updated:** 2026-06-26 — reframed for boost; 4-stage lifecycle, 16 fine-grained permissions, service-account Keycloak auth (client credentials grant), SonataFlow approval integration, Backstage RBAC as sole authorization layer **Priority:** P0 (access control, security posture, governance) / P1 (safety shields, SSRF, resilience) **Provenance:** Requirements derived from Augment plugin analysis and three tech debt assessments (May 13, May 26, May 30 2026). See `specifications/boost-context.md` for project context. @@ -13,21 +13,21 @@ Agentic AI platforms operating in enterprise environments — especially regulated financial institutions — must treat security, safety, and governance as foundational capabilities, not optional add-ons. An AI agent that can call tools, read internal documentation, and take actions on live infrastructure without proper access control, content safety, and audit trails is a liability, not a product. -This PRD defines the enterprise trust model: multi-level access control with fine-grained Backstage RBAC, agent lifecycle governance with approval workflows, per-user identity delegation to AI providers, content safety shields, network security protections, data retention policies, and resilience patterns that ensure the platform is safe to deploy in production. +This PRD defines the enterprise trust model: multi-level access control with fine-grained Backstage RBAC, agent lifecycle governance with approval workflows, service-account Keycloak authentication for AI providers with user context propagation, content safety shields, network security protections, data retention policies, and resilience patterns that ensure the platform is safe to deploy in production. ## What This Product Does -Boost implements a three-tier security mode system, fine-grained role-based access control via Backstage RBAC (16 permissions across agent, tool, and infrastructure resource types), agent lifecycle governance (Draft → Pending → Published → Archived) with configurable approval workflows (built-in or SonataFlow-managed), per-user identity delegation to Kagenti via RFC 8693 token exchange, content safety shields on both inputs and outputs, SSRF protection on all backend HTTP paths, optional zero data retention mode, and resilience patterns. All authorization decisions use Backstage `permissions.authorize()` from day one — no parallel authorization systems in route handlers. The security posture is configurable per environment — from zero-auth development mode to full production lockdown with OAuth token propagation to both MCP servers and AI providers. +Boost implements a three-tier security mode system, fine-grained role-based access control via Backstage RBAC (16 permissions across agent, tool, and infrastructure resource types), agent lifecycle governance (Draft → Pending → Published → Archived) with configurable approval workflows (built-in or SonataFlow-managed), service-account Keycloak authentication for Kagenti via OAuth2 Client Credentials Grant with user context propagation, content safety shields on both inputs and outputs, SSRF protection on all backend HTTP paths, optional zero data retention mode, and resilience patterns. All authorization decisions use Backstage `permissions.authorize()` from day one — no parallel authorization systems in route handlers. The security posture is configurable per environment — from zero-auth development mode to full production lockdown with OAuth authentication to both MCP servers and AI providers. ## Who It's For ### Administrator -Selects security mode, configures RBAC policies with fine-grained permissions, manages agent lifecycle governance (approving/rejecting agent promotions), sets up MCP auth chains, enables safety shields, configures data retention mode, and manages network security. +Selects security mode, configures RBAC policies with fine-grained permissions, manages agent lifecycle governance (approving/rejecting agent promotions), configures Keycloak service-account credentials, sets up MCP auth chains, enables safety shields, configures data retention mode, and manages network security. ### Security Architect -Designs the security posture, evaluates the auth chain for tool connections, configures per-user token exchange for Kagenti, and configures SPIRE for infrastructure-level mTLS in Kagenti environments. +Designs the security posture, evaluates the auth chain for tool connections, configures Keycloak service-account authentication for Kagenti, and configures SPIRE for infrastructure-level mTLS in Kagenti environments. ### Agent Creator @@ -41,7 +41,7 @@ Creates agents and submits them for governance review. Subject to ownership-base - Fine-grained RBAC via Backstage permissions (16 permissions, 2 resource types, conditional rules) - Agent lifecycle governance: 4-stage model (Draft → Pending → Published → Archived) with approval workflows - SonataFlow integration for external approval orchestration -- Per-user Kagenti identity via RFC 8693 OAuth2 Token Exchange +- Service-account Kagenti authentication via OAuth2 Client Credentials Grant with user context propagation - Frontend SecurityGate with meaningful access-denied page - CSRF protection via `X-Backstage-Request` header - Content safety shields (input and output) with fail-open/fail-closed @@ -78,11 +78,11 @@ Security and governance UI surfaces (access-denied pages, approval queues, revie **Three security modes:** -| Mode | Frontend | Backend | Provider Auth | Use Case | -| -------------------------- | --------------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------- | ---------------------------------------- | -| `development-only-no-auth` | No gate — all users pass as guest | No RBAC, everyone is admin | Static token/TLS (if configured) | Development/demo only | -| `plugin-only` | SecurityGate wraps BoostPage | user-cookie, boost.access, admin allow-list, real user principal | Token/TLS to Llama Stack, Keycloak OAuth2 for Kagenti | Recommended for production | -| `full` | SecurityGate wraps BoostPage | Fine-grained RBAC (16 permissions), real user principal, mcpOAuth config | Token/TLS + MCP OAuth chain, Keycloak OAuth2 + per-user token exchange + SPIRE mTLS | Full production with identity delegation | +| Mode | Frontend | Backend | Provider Auth | Use Case | +| -------------------------- | --------------------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | ----------------------------------------- | +| `development-only-no-auth` | No gate — all users pass as guest | No RBAC, everyone is admin | Static token/TLS (if configured) | Development/demo only | +| `plugin-only` | SecurityGate wraps BoostPage | user-cookie, boost.access, admin allow-list, real user principal | Token/TLS to Llama Stack, Keycloak OAuth2 for Kagenti | Recommended for production | +| `full` | SecurityGate wraps BoostPage | Fine-grained RBAC (16 permissions), real user principal, mcpOAuth config | Token/TLS + MCP OAuth chain, Keycloak OAuth2 Client Credentials + SPIRE mTLS | Full production with service-account auth | **Note:** The legacy mode name `none` is deprecated; deployments should use `development-only-no-auth`. A prominent warning is logged if this mode is detected in a non-development environment. @@ -192,26 +192,25 @@ Draft → Pending → Published → Archived **Cascading delete:** `DELETE /agents/:id` detects the agent's source (kagenti, orchestration, workflow) and cascades cleanup across corresponding backend stores. -### 3. Per-User Identity Delegation — Kagenti Token Exchange (UC-20 extension) +### 3. Service-Account Keycloak Authentication — Kagenti (UC-20 extension) -**Goal:** Propagate the authenticated user's identity to Kagenti so that agent operations are authorized per-user, not via a shared service-account. - -**Current state:** Augment authenticates to Kagenti using a shared service-account token (Keycloak `client_credentials` grant). All requests appear as the same service identity regardless of which user initiated them. The `X-Backstage-User` header is informational only. - -**Target state:** RFC 8693 OAuth2 Token Exchange. The user's OIDC token (injected by an auth proxy such as oauth2-proxy or Keycloak Gatekeeper) is exchanged for a Kagenti-scoped token, enabling per-user authorization at the provider level. +**Goal:** Authenticate Boost to Kagenti via a dedicated Keycloak service-account using OAuth2 Client Credentials Grant, with user identity propagated via headers for audit purposes. **Architecture:** -- Backend-only implementation: OIDC token read from a configurable request header (default: `X-Forwarded-Access-Token`) -- `TokenExchangeManager` service: implements RFC 8693 exchange against Keycloak, with per-user token caching, concurrent request deduplication, and streaming-compatible token lifecycle -- Graceful fallback on all failures: token exchange failure, missing header, disabled config, or Keycloak error → silently falls back to shared service-account token (no request blocking) -- Configuration: `boost.kagenti.auth.tokenExchange.enabled` (default: false), `audience`, `userTokenHeader` -- `ResponsesApiProvider` (Llama Stack) is unaffected: `setUserContext` method is optional and not implemented +- `KeycloakTokenManager` service: obtains service-account tokens via OAuth2 Client Credentials Grant against Keycloak's token endpoint +- Token caching with configurable expiry buffer (default: 60 seconds) — re-fetches token before expiry to avoid mid-request token invalidation +- Concurrent request deduplication: multiple simultaneous token requests share a single in-flight Keycloak call +- Streaming-compatible token lifecycle: `getTokenForStreaming(minLifetimeMs)` ensures minimum token validity for long-running SSE connections +- 401 retry with cache invalidation: on authentication failure, the cached token is cleared and a fresh token is fetched before retrying +- User identity propagation: `X-Backstage-User` header carries the Backstage user entity ref for audit trails (informational, not for authentication) +- Configuration: `boost.kagenti.auth.tokenEndpoint`, `clientId`, `clientSecret`, `tokenExpiryBufferSeconds` +- `ResponsesApiProvider` (Llama Stack) is unaffected: separate authentication path **Separation of authorization concerns:** - **Backstage governs:** UI visibility, agent lifecycle governance (draft/pending/published), ownership, approval workflows, admin operations -- **Kagenti governs:** agent specs, tools, runtime operations — authorized via per-user exchanged token when enabled +- **Kagenti governs:** agent specs, tools, runtime operations — authorized via service-account token; user identity propagated via header for audit - **Kubernetes governs:** pod/deployment operations, namespace scoping, SPIRE mTLS ### 4. Safety Shields and Guardrails (UC-19) @@ -285,7 +284,7 @@ Agent Lifecycle Governance Provider Authentication ├── Llama Stack — static token/TLS → Token/TLS → Token/TLS + MCP OAuth chain -└── Kagenti — no auth → client_credentials → per-user token exchange (RFC 8693) + SPIRE mTLS +└── Kagenti — no auth → client_credentials (KeycloakTokenManager) + SPIRE mTLS Cross-Cutting Protections (all modes) ├── SsrfGuard — blocks SSRF on all HTTP paths @@ -297,7 +296,7 @@ Cross-Cutting Protections (all modes) - `middleware/security.ts`: security mode enforcement, `requirePluginAccess`, `authorizeLifecycleAction` - `permissions.ts`: 16 fine-grained permissions, 2 resource types, 3 conditional rules -- `TokenExchangeManager`: RFC 8693 per-user token exchange for Kagenti +- `KeycloakTokenManager`: OAuth2 Client Credentials service-account auth for Kagenti - `AgentApprovalWorkflowService`: SonataFlow integration - `services/SafetyService`: safety shield delegation - `services/McpAuthService`: 4-level auth chain @@ -312,7 +311,7 @@ Cross-Cutting Protections (all modes) | --------------------------------- | ------------------- | -------- | -------------------------------- | | Security Posture & Access Control | UC-20 | P0 | 8.1.1-8.1.3, 8.3.1, 8.4.1, 3.5.1 | | Agent Lifecycle Governance | UC-23, UC-24, UC-25 | P0 | (new) | -| Per-User Token Exchange | UC-20 (extension) | P1 | (new) | +| Service-Account Keycloak Auth | UC-20 (extension) | P1 | (new) | | Fine-Grained Permissions | UC-20 (extension) | P0 | (new) | | Safety Shields | UC-19 | P1 | 8.2.1-8.2.2 | | Resilience | (cross-cutting) | P1 | 8.5.1-8.5.2 | @@ -332,4 +331,4 @@ Success outcomes addressed: - Enterprise security, safety, and compliance are built in (UC-19, UC-20) - Sensitive operations stay under human control (UC-3, addressed in AI Chat PRD) - Agent lifecycle governed with approval workflows and separation of duties (UC-23, UC-24, UC-25) -- Per-user identity delegated to AI providers for audit trail (UC-20 extension) +- Service-account authentication to AI providers with user context propagation for audit trail (UC-20 extension) diff --git a/workspaces/boost/staged-issues.md b/workspaces/boost/staged-issues.md index 6542244e1f..bdbcf39805 100644 --- a/workspaces/boost/staged-issues.md +++ b/workspaces/boost/staged-issues.md @@ -365,7 +365,7 @@ From `openspec/changes/agent-creation-discovery/tasks.md` sections 1 and 2: --- -## boost-backend — Token exchange via RFC 8693 per-user identity delegation (issue 13 of 15) +## boost-backend — Service-account Keycloak authentication via OAuth2 Client Credentials (issue 13 of 15) https://github.com/redhat-developer/rhdh-plugins/issues/3309 @@ -374,24 +374,24 @@ https://github.com/redhat-developer/rhdh-plugins/issues/3309 **Labels:** `ready-to-code` **Depends on:** Issue 11 -Implement `TokenExchangeManager` for per-user Kagenti identity delegation via RFC 8693 OAuth2 Token Exchange, with graceful fallback to service-account token on all failures. +Implement `KeycloakTokenManager` for service-account Kagenti authentication via OAuth2 Client Credentials Grant, with token caching, streaming support, and 401 retry. ### Tasks From `openspec/changes/security-safety-governance/tasks.md` section 7: -- 7.1 Create `TokenExchangeManager` implementing RFC 8693 -- 7.2 Add per-user token caching with TTL from token expiry -- 7.3 Add concurrent exchange deduplication -- 7.4 Add graceful fallback to service-account token -- 7.5 Add config schema: `boost.kagenti.auth.tokenExchange.*` -- 7.6 Integrate into `KagentiApiClient.requestCore()` -- 7.7 Extract user OIDC token from configurable request header +- 7.1 Create `KeycloakTokenManager` implementing OAuth2 Client Credentials Grant +- 7.2 Add token caching with configurable expiry buffer (default 60s) +- 7.3 Add concurrent token request deduplication (single in-flight Keycloak call) +- 7.4 Add `getTokenForStreaming(minLifetimeMs)` for SSE connection support +- 7.5 Add single 401 retry with cache invalidation and fresh token fetch +- 7.6 Add config schema: `boost.kagenti.auth.{tokenEndpoint, clientId, clientSecret, tokenExpiryBufferSeconds}` +- 7.7 Integrate into `KagentiApiClient.requestCore()` — add `Authorization: Bearer` and `X-Backstage-User` headers ### Specifications -- `openspec/changes/security-safety-governance/specs/access-control/spec.md` — Token exchange scenarios -- `openspec/changes/security-safety-governance/design.md` — Decision 4 (backend-only with graceful fallback) +- `openspec/changes/security-safety-governance/specs/access-control/spec.md` — Service-account auth scenarios +- `openspec/changes/security-safety-governance/design.md` — Decision 4 (service-account auth with token caching and streaming support) ---