Skip to content
Closed
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -81,41 +81,63 @@ 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 <token>` header is added to all Kagenti API calls

#### Scenario: Token exchange graceful fallback
#### Scenario: Kagenti REST API endpoints

- **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** the entity provider or `KagentiApiClient` calls the Kagenti API
- **THEN** it uses the `/api/v1/` REST API prefix — **not** `/a2a/` (which is the A2A protocol path, not the management API)
- **AND** agent discovery calls `GET /api/v1/agents?namespace={ns}` returning `{ items: AgentCard[] }`
- **AND** tool discovery calls `GET /api/v1/tools?namespace={ns}` returning `{ items: KagentiTool[] }`
- **AND** the `Authorization: Bearer <token>` header is included on every request

#### Scenario: Token exchange configuration
#### Scenario: Streaming token lifecycle

- **WHEN** token exchange is configured
- **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 refresh on authentication failure

- **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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 errorservice-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 invalidationfresh 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`
41 changes: 22 additions & 19 deletions workspaces/boost/plugins/boost-backend/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
};

Expand Down
16 changes: 11 additions & 5 deletions workspaces/boost/plugins/boost-backend/report.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<
Expand Down Expand Up @@ -170,19 +170,25 @@ export const boostConfigFields: {
readonly configScope: ConfigScope;
readonly description: string;
};
readonly 'boost.kagenti.auth.tokenExchange.enabled': {
readonly schema: z.ZodOptional<z.ZodBoolean>;
readonly 'boost.kagenti.auth.tokenEndpoint': {
readonly schema: z.ZodOptional<z.ZodString>;
readonly configScope: ConfigScope;
readonly description: string;
};
readonly 'boost.kagenti.auth.tokenExchange.audience': {
readonly 'boost.kagenti.auth.clientId': {
readonly schema: z.ZodOptional<z.ZodString>;
readonly configScope: ConfigScope;
readonly description: string;
};
readonly 'boost.kagenti.auth.tokenExchange.userTokenHeader': {
readonly 'boost.kagenti.auth.clientSecret': {
readonly schema: z.ZodOptional<z.ZodString>;
readonly configScope: ConfigScope;
readonly sensitive: true;
readonly description: string;
};
readonly 'boost.kagenti.auth.tokenExpiryBufferSeconds': {
readonly schema: z.ZodOptional<z.ZodNumber>;
readonly configScope: ConfigScope;
readonly description: string;
};
readonly 'boost.encryptionSecret': {
Expand Down
Loading
Loading