Skip to content
Open
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
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,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 <token>` 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

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
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Comment thread
gabemontero marked this conversation as resolved.
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');
});
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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);
});
});
Expand All @@ -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', () => {
Expand Down
Loading
Loading