Skip to content

Commit d197bd6

Browse files
fullsend-ai-coder[bot]gabemonteroclaude
authored
feat(#3300): agent lifecycle routes with permission integration (#3539)
* feat(#3300): agent lifecycle routes with permission integration Implement agent CRUD routes with 4-stage lifecycle (Draft → Pending → Published → Archived) and fine-grained permission integration via authorizeLifecycleAction. Changes: - boost-common: Add LifecycleStage type and AgentRecord interface for agent governance state - boost-backend: Add AgentLifecycleStore (DB-backed) for persisting agent governance records - boost-backend: Add lifecycle transition validation (isValidTransition, isDeletableStage) - boost-backend: Add agent routes with permission gating: - GET /agents (boost.agent.list) - PUT /agents/:id/register (boost.agent.register) - PUT /agents/:id/promote (boost.agent.promote) - PUT /agents/:id/approve (boost.agent.approve) - PUT /agents/:id/request-unpublish (boost.agent.unpublish) - PUT /agents/:id/withdraw (boost.agent.withdraw) - DELETE /agents/:id (boost.agent.delete) - Each route uses authorizeLifecycleAction middleware with admin fallback pattern - Cascading delete documented: store removes governance record; source-specific cleanup is caller responsibility - 34 new tests covering lifecycle transitions, route handlers, and permission integration Closes #3300 * fix(boost): address review findings for agent lifecycle routes - Widen authorizeLifecycleAction parameter from BasicPermission to Permission to support resource-scoped permissions (promote, approve, unpublish, withdraw, delete) - Add resource ref extraction from req.params.id for resource-scoped permission checks in the authorization middleware - Add agent ID validation (AGENT_ID_PATTERN) on all :id routes to prevent path traversal and invalid identifiers - Change duplicate agent registration from InputError to ConflictError (409) with DB-level PK violation handling in AgentLifecycleStore - Reject registration when user identity cannot be resolved instead of falling back to user:default/unknown - Check updateStage() return value in all transition routes to detect concurrent deletion during transitions - Add TSDoc to public interface members to fix API report warnings - Fix unresolved @link reference in AgentLifecycleStore - Add tests for invalid agent ID, missing user identity, and concurrent deletion during transition - Regenerate API reports Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Signed-off-by: gabemontero <gmontero@redhat.com> --------- Signed-off-by: gabemontero <gmontero@redhat.com> Co-authored-by: fullsend-code <278716306+fullsend-ai-coder[bot]@users.noreply.github.com> Co-authored-by: gabemontero <gmontero@redhat.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 112eb22 commit d197bd6

14 files changed

Lines changed: 1520 additions & 15 deletions

File tree

workspaces/boost/plugins/boost-backend/report.api.md

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,20 @@
44
55
```ts
66
import type { AgenticProvider } from '@red-hat-developer-hub/backstage-plugin-boost-common';
7+
import type { AgentRecord } from '@red-hat-developer-hub/backstage-plugin-boost-common';
78
import { BackendFeature } from '@backstage/backend-plugin-api';
8-
import { BasicPermission } from '@backstage/plugin-permission-common';
99
import type { CacheService } from '@backstage/backend-plugin-api';
1010
import type { DatabaseService } from '@backstage/backend-plugin-api';
1111
import type { HttpAuthService } from '@backstage/backend-plugin-api';
12+
import type { LifecycleStage } from '@red-hat-developer-hub/backstage-plugin-boost-common';
1213
import type { LoggerService } from '@backstage/backend-plugin-api';
14+
import { Permission } from '@backstage/plugin-permission-common';
1315
import type { PermissionsService } from '@backstage/backend-plugin-api';
1416
import type { ProviderDescriptor } from '@red-hat-developer-hub/backstage-plugin-boost-common';
1517
import type { Request as Request_2 } from 'express';
1618
import type { RequestHandler } from 'express';
1719
import type { RootConfigService } from '@backstage/backend-plugin-api';
20+
import { Router } from 'express';
1821
import { ServiceFactory } from '@backstage/backend-plugin-api';
1922
import { z } from 'zod';
2023

@@ -30,15 +33,45 @@ export class AdminConfigService {
3033

3134
// @public
3235
export interface AdminConfigServiceOptions {
33-
// (undocumented)
3436
database: DatabaseService;
35-
// (undocumented)
3637
logger: LoggerService;
3738
}
3839

40+
// @public
41+
export class AgentLifecycleStore {
42+
constructor(options: AgentLifecycleStoreOptions);
43+
delete(id: string): Promise<boolean>;
44+
get(id: string): Promise<AgentRecord | undefined>;
45+
list(): Promise<AgentRecord[]>;
46+
register(agent: {
47+
id: string;
48+
name: string;
49+
description?: string;
50+
createdBy: string;
51+
}): Promise<AgentRecord>;
52+
updateStage(
53+
id: string,
54+
stage: LifecycleStage,
55+
): Promise<AgentRecord | undefined>;
56+
}
57+
58+
// @public
59+
export interface AgentLifecycleStoreOptions {
60+
database: DatabaseService;
61+
logger: LoggerService;
62+
}
63+
64+
// @public
65+
export interface AgentRoutesOptions {
66+
httpAuth: HttpAuthService;
67+
logger: LoggerService;
68+
permissions: PermissionsService;
69+
store: AgentLifecycleStore;
70+
}
71+
3972
// @public
4073
export function authorizeLifecycleAction(
41-
permission: BasicPermission,
74+
permission: Permission,
4275
_resourceLoader: ResourceLoader,
4376
options: AuthorizeLifecycleActionOptions,
4477
): RequestHandler;
@@ -157,15 +190,27 @@ export type ConfigScope = 'yaml-only' | 'db-overridable' | 'db-only';
157190
// @public
158191
export function createAgentResourceLoader(): ResourceLoader;
159192

193+
// @public
194+
export function createAgentRoutes(options: AgentRoutesOptions): Router;
195+
160196
// @public
161197
export function createToolResourceLoader(): ResourceLoader;
162198

163199
// @public
164200
export function isDbWritable(key: BoostConfigKey): boolean;
165201

202+
// @public
203+
export function isDeletableStage(stage: LifecycleStage): boolean;
204+
166205
// @public
167206
export function isSensitiveField(key: BoostConfigKey): boolean;
168207

208+
// @public
209+
export function isValidTransition(
210+
from: LifecycleStage,
211+
to: LifecycleStage,
212+
): boolean;
213+
169214
// @public
170215
export class ProviderManager {
171216
getActiveProvider(): AgenticProvider;
@@ -194,13 +239,9 @@ export class RuntimeConfigResolver {
194239

195240
// @public
196241
export interface RuntimeConfigResolverOptions {
197-
// (undocumented)
198242
adminConfigService: AdminConfigService;
199-
// (undocumented)
200243
cache: CacheService;
201-
// (undocumented)
202244
config: RootConfigService;
203-
// (undocumented)
204245
logger: LoggerService;
205246
}
206247

Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import type {
18+
DatabaseService,
19+
LoggerService,
20+
} from '@backstage/backend-plugin-api';
21+
import { ConflictError } from '@backstage/errors';
22+
import type { Knex } from 'knex';
23+
import type {
24+
AgentRecord,
25+
LifecycleStage,
26+
} from '@red-hat-developer-hub/backstage-plugin-boost-common';
27+
28+
const TABLE_NAME = 'boost_agents';
29+
30+
/**
31+
* A single row in the `boost_agents` table.
32+
*
33+
* @internal
34+
*/
35+
interface AgentRow {
36+
id: string;
37+
name: string;
38+
description: string | null;
39+
lifecycle_stage: LifecycleStage;
40+
created_by: string;
41+
governance_registered: number; // SQLite boolean
42+
created_at: string;
43+
updated_at: string;
44+
}
45+
46+
/**
47+
* Options for creating an {@link AgentLifecycleStore}.
48+
*
49+
* @public
50+
*/
51+
export interface AgentLifecycleStoreOptions {
52+
/** The Backstage database service. */
53+
database: DatabaseService;
54+
/** The Backstage logger service. */
55+
logger: LoggerService;
56+
}
57+
58+
/**
59+
* Database-backed store for agent lifecycle governance records.
60+
*
61+
* Each agent is registered with an owner (`createdBy`) and enters
62+
* the `draft` lifecycle stage. The store supports lifecycle transitions
63+
* and cascading deletes.
64+
*
65+
* @public
66+
*/
67+
export class AgentLifecycleStore {
68+
private readonly logger: LoggerService;
69+
private knexPromise: Promise<Knex> | undefined;
70+
private readonly database: DatabaseService;
71+
72+
constructor(options: AgentLifecycleStoreOptions) {
73+
this.logger = options.logger.child({ service: 'AgentLifecycleStore' });
74+
this.database = options.database;
75+
}
76+
77+
/**
78+
* Get the Knex instance, creating the table on first access.
79+
*/
80+
private async getDb(): Promise<Knex> {
81+
if (!this.knexPromise) {
82+
this.knexPromise = (async () => {
83+
const knex = await this.database.getClient();
84+
await this.ensureTable(knex);
85+
return knex;
86+
})().catch(err => {
87+
this.knexPromise = undefined;
88+
throw err;
89+
});
90+
}
91+
return this.knexPromise;
92+
}
93+
94+
/**
95+
* Ensure the agents table exists.
96+
*/
97+
private async ensureTable(knex: Knex): Promise<void> {
98+
const exists = await knex.schema.hasTable(TABLE_NAME);
99+
if (!exists) {
100+
await knex.schema.createTable(TABLE_NAME, table => {
101+
table.string('id').primary().notNullable();
102+
table.string('name').notNullable();
103+
table.text('description').nullable();
104+
table.string('lifecycle_stage').notNullable().defaultTo('draft');
105+
table.string('created_by').notNullable();
106+
table.boolean('governance_registered').notNullable().defaultTo(true);
107+
table
108+
.timestamp('created_at', { useTz: true })
109+
.defaultTo(knex.fn.now())
110+
.notNullable();
111+
table
112+
.timestamp('updated_at', { useTz: true })
113+
.defaultTo(knex.fn.now())
114+
.notNullable();
115+
});
116+
this.logger.info(`Created ${TABLE_NAME} table`);
117+
}
118+
}
119+
120+
/**
121+
* Convert a database row to an `AgentRecord`.
122+
*/
123+
private rowToRecord(row: AgentRow): AgentRecord {
124+
return {
125+
id: row.id,
126+
name: row.name,
127+
description: row.description ?? undefined,
128+
lifecycleStage: row.lifecycle_stage,
129+
createdBy: row.created_by,
130+
governanceRegistered: Boolean(row.governance_registered),
131+
createdAt: row.created_at,
132+
updatedAt: row.updated_at,
133+
};
134+
}
135+
136+
/**
137+
* List all agent records.
138+
*
139+
* @returns All registered agents.
140+
*/
141+
async list(): Promise<AgentRecord[]> {
142+
const knex = await this.getDb();
143+
const rows = await knex<AgentRow>(TABLE_NAME)
144+
.select()
145+
.orderBy('created_at', 'desc');
146+
return rows.map(row => this.rowToRecord(row));
147+
}
148+
149+
/**
150+
* Get a single agent record by ID.
151+
*
152+
* @param id - The agent ID.
153+
* @returns The agent record, or `undefined` if not found.
154+
*/
155+
async get(id: string): Promise<AgentRecord | undefined> {
156+
const knex = await this.getDb();
157+
const row = await knex<AgentRow>(TABLE_NAME).where({ id }).first();
158+
return row ? this.rowToRecord(row) : undefined;
159+
}
160+
161+
/**
162+
* Register a new agent for governance. Enters the `draft` stage.
163+
*
164+
* @param agent - The agent to register.
165+
* @returns The created agent record.
166+
*/
167+
async register(agent: {
168+
id: string;
169+
name: string;
170+
description?: string;
171+
createdBy: string;
172+
}): Promise<AgentRecord> {
173+
const knex = await this.getDb();
174+
const now = knex.fn.now() as unknown as string;
175+
try {
176+
await knex<AgentRow>(TABLE_NAME).insert({
177+
id: agent.id,
178+
name: agent.name,
179+
description: agent.description ?? null,
180+
lifecycle_stage: 'draft',
181+
created_by: agent.createdBy,
182+
governance_registered: 1,
183+
created_at: now,
184+
updated_at: now,
185+
});
186+
} catch (err: unknown) {
187+
const message = err instanceof Error ? err.message : String(err);
188+
if (
189+
message.includes('UNIQUE') ||
190+
message.includes('duplicate') ||
191+
message.includes('conflict')
192+
) {
193+
throw new ConflictError(`Agent "${agent.id}" is already registered`);
194+
}
195+
throw err;
196+
}
197+
this.logger.info(`Agent registered: ${agent.id} by ${agent.createdBy}`);
198+
const record = await this.get(agent.id);
199+
return record!;
200+
}
201+
202+
/**
203+
* Update the lifecycle stage of an agent.
204+
*
205+
* @param id - The agent ID.
206+
* @param stage - The new lifecycle stage.
207+
* @returns The updated agent record, or `undefined` if not found.
208+
*/
209+
async updateStage(
210+
id: string,
211+
stage: LifecycleStage,
212+
): Promise<AgentRecord | undefined> {
213+
const knex = await this.getDb();
214+
const updated = await knex<AgentRow>(TABLE_NAME)
215+
.where({ id })
216+
.update({
217+
lifecycle_stage: stage,
218+
updated_at: knex.fn.now() as unknown as string,
219+
});
220+
if (updated === 0) {
221+
return undefined;
222+
}
223+
this.logger.info(`Agent ${id} transitioned to ${stage}`);
224+
return this.get(id);
225+
}
226+
227+
/**
228+
* Delete an agent record.
229+
*
230+
* Cascading delete behavior: the store removes the governance record.
231+
* Source-specific cleanup (kagenti, orchestration, workflow) is the
232+
* responsibility of the caller or a higher-level service that detects
233+
* the agent's source before invoking this method.
234+
*
235+
* @param id - The agent ID to delete.
236+
* @returns `true` if the agent was deleted, `false` if not found.
237+
*/
238+
async delete(id: string): Promise<boolean> {
239+
const knex = await this.getDb();
240+
const deleted = await knex<AgentRow>(TABLE_NAME).where({ id }).delete();
241+
if (deleted > 0) {
242+
this.logger.info(`Agent deleted: ${id}`);
243+
return true;
244+
}
245+
return false;
246+
}
247+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/*
2+
* Copyright Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
export {
18+
AgentLifecycleStore,
19+
type AgentLifecycleStoreOptions,
20+
} from './AgentLifecycleStore';
21+
export { isValidTransition, isDeletableStage } from './lifecycle';
22+
export { createAgentRoutes, type AgentRoutesOptions } from './routes';

0 commit comments

Comments
 (0)