Skip to content

Commit e504e86

Browse files
[agent_builder] Package-owned agent management and access control (AB-001-006)
Adds the package-managed agent surface: - AB-001: AgentBuilderManagementSetup create/update/delete for package agents - AB-003/004: managed_by_package labels + readonly enforcement, honored through agent update paths and persisted through the agent type - AB-005: caller-request skill registry path; reap stale package-owned skills - AB-006: validate REPLACE_WITH_FLEET_AGENT_* against installed agents Extracted as a feature-scoped net-diff squash from the SDLC visibility-platform dogfood branch (excludes WF-009 structured-output drift and fleet agent-asset install steps, which belong to their own rungs). Verified on fresh origin/main: 0 type_check errors in agent_builder, authorization.test 10/10, action_utils.test 11/11.
1 parent 1d9392a commit e504e86

14 files changed

Lines changed: 373 additions & 17 deletions

File tree

x-pack/platform/packages/shared/agent-builder/agent-builder-common/agents/crud.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,24 @@ export type AgentCreateRequest = Omit<
2525
*/
2626
type?: string;
2727
access_control?: Pick<AgentAccessControl, 'access_mode'>;
28+
/**
29+
* AB-004: when true the agent is created readonly (package-managed).
30+
* Fleet package installs set this so UI edits warn/block, mirroring the
31+
* managed workflow pattern. Carried on update so package upgrades keep it.
32+
*/
33+
readonly?: boolean;
2834
};
2935

3036
export type AgentUpdateRequest = Partial<
3137
Pick<AgentDefinition, 'name' | 'description' | 'labels' | 'avatar_color' | 'avatar_symbol'>
3238
> & {
3339
access_control?: Pick<AgentAccessControl, 'access_mode'>;
3440
configuration?: Partial<AgentConfiguration>;
41+
/**
42+
* AB-004: package upgrades re-assert the managed flag; a reinstall must not
43+
* silently downgrade a package agent to an editable user agent.
44+
*/
45+
readonly?: boolean;
3546
};
3647

3748
export type AgentDeleteRequest = Pick<AgentDefinition, 'id'>;

x-pack/platform/packages/shared/agent-builder/agent-builder-common/agents/definition.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ export interface AgentDefinition {
8282
* Optional labels used to organize or filter agents
8383
*/
8484
labels?: string[];
85+
/** True when the agent is managed by a package and must not be edited by users. */
8586
/**
8687
* Optional avatar eui icon for built-in agents
8788
*/

x-pack/platform/packages/shared/agent-builder/agent-builder-server/plugin_contract.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@
77

88
import type { ZodObject } from '@kbn/zod/v4';
99
import type { KibanaRequest } from '@kbn/core-http-server';
10-
import type { AgentCreateRequest, ConversationTemplate } from '@kbn/agent-builder-common';
10+
import type {
11+
AgentCreateRequest,
12+
ConversationTemplate,
13+
PersistedSkillCreateRequest,
14+
} from '@kbn/agent-builder-common';
1115
import type { ConversationPublicClient } from './conversations';
1216
import type { StaticToolRegistration, ToolRegistry } from './tools';
1317
import type { AttachmentTypeDefinition } from './attachments';
@@ -238,11 +242,36 @@ export interface TopSnippetsConfig {
238242
/**
239243
* Setup contract of the agentBuilder plugin.
240244
*/
245+
246+
/**
247+
* Internal management API for package-owned persisted agents and skills
248+
* (e.g. Fleet package install/uninstall).
249+
*/
250+
export interface AgentBuilderManagementSetup {
251+
/** Read a single agent, or null when it does not exist. Used to enrich Fleet asset listings. */
252+
getAgent(agentId: string, request: KibanaRequest): Promise<unknown>;
253+
createOrUpdateAgent(params: AgentCreateRequest, request: KibanaRequest): Promise<unknown>;
254+
deletePackageManagedAgent(agentId: string, spaceId: string): Promise<boolean>;
255+
createOrUpdateSkill(params: PersistedSkillCreateRequest, request: KibanaRequest): Promise<unknown>;
256+
deletePackageManagedSkill(skillId: string, spaceId: string): Promise<boolean>;
257+
/**
258+
* List the skills a package owns, so install can reap the ones the current
259+
* archive no longer produces. Package-managed skills are readonly, so an
260+
* orphan left behind by an id-scheme change is otherwise undeletable.
261+
*/
262+
listPackageManagedSkills(
263+
pluginId: string,
264+
spaceId: string
265+
): Promise<Array<{ id: string; plugin_id?: string }>>;
266+
}
267+
241268
export interface AgentBuilderPluginSetup {
242269
/**
243270
* Agents setup contract, which can be used to register built-in agents.
244271
*/
245272
agents: AgentsSetup;
273+
/** Internal management API for programmatic agent/skill CRUD (Fleet package install). */
274+
management: AgentBuilderManagementSetup;
246275
/**
247276
* Tools setup contract, which can be used to register built-in tools.
248277
*/

x-pack/platform/plugins/shared/agent_builder/common/agents.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ export type AgentCreateRequest = Omit<
2828
*/
2929
type?: string;
3030
access_control?: Pick<AgentAccessControl, 'access_mode'>;
31+
/**
32+
* AB-004: when true the agent is created readonly (package-managed).
33+
* Fleet package installs set this so UI edits warn/block.
34+
*/
35+
readonly?: boolean;
3136
};
3237

3338
export type AgentUpdateRequest = Partial<
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0; you may not use this file except in compliance with the Elastic License
5+
* 2.0.
6+
*/
7+
8+
import { CoreSetup, KibanaRequest, Logger } from '@kbn/core/server';
9+
import type {
10+
AgentCreateRequest,
11+
AgentUpdateRequest,
12+
PersistedSkillCreateRequest,
13+
} from '@kbn/agent-builder-common';
14+
import { skillIndexName } from '../services/skills/persisted/client/storage';
15+
16+
import { agentsIndexName } from '../services/agents/persisted/client/storage';
17+
import type { AgentBuilderPluginStart, AgentBuilderStartDependencies } from '../types';
18+
19+
export class AgentBuilderManagementApi {
20+
constructor(
21+
private readonly getStartServices: CoreSetup<
22+
AgentBuilderStartDependencies,
23+
AgentBuilderPluginStart
24+
>['getStartServices'],
25+
private readonly logger: Logger
26+
) {}
27+
28+
private async getAgentsService() {
29+
const [, , pluginStart] = await this.getStartServices();
30+
if (!pluginStart?.agents) {
31+
throw new Error('agentBuilder plugin is not available');
32+
}
33+
return pluginStart.agents;
34+
}
35+
36+
public async getAgent(agentId: string, request: KibanaRequest) {
37+
const agents = await this.getAgentsService();
38+
const registry = await agents.getRegistry({ request });
39+
if (!(await registry.has(agentId))) {
40+
return null;
41+
}
42+
return registry.get(agentId);
43+
}
44+
45+
public async createOrUpdateAgent(params: AgentCreateRequest, request: KibanaRequest) {
46+
const agents = await this.getAgentsService();
47+
const registry = await agents.getRegistry({ request });
48+
49+
if (await registry.has(params.id)) {
50+
const update: AgentUpdateRequest = {
51+
name: params.name,
52+
description: params.description,
53+
labels: params.labels,
54+
// AB-004: carry the managed flag on upgrades too, otherwise a reinstall
55+
// silently downgrades a package agent to an editable user agent.
56+
readonly: params.readonly,
57+
avatar_color: params.avatar_color,
58+
avatar_symbol: params.avatar_symbol,
59+
configuration: params.configuration,
60+
};
61+
return registry.update(params.id, update);
62+
}
63+
64+
return registry.create(params); // AB-004: params may carry readonly=true (fleet package agent)
65+
}
66+
67+
public async deleteAgent(agentId: string, request: KibanaRequest): Promise<boolean> {
68+
const agents = await this.getAgentsService();
69+
const registry = await agents.getRegistry({ request });
70+
return registry.delete({ id: agentId });
71+
}
72+
73+
/**
74+
* System-level delete for Fleet package uninstall (no end-user request context).
75+
*/
76+
public async deletePackageManagedAgent(agentId: string, spaceId: string): Promise<boolean> {
77+
const [coreStart] = await this.getStartServices();
78+
const esClient = coreStart.elasticsearch.client.asInternalUser;
79+
80+
try {
81+
const searchResult = await esClient.search<{ id: string; space: string }>({
82+
index: agentsIndexName,
83+
query: {
84+
bool: {
85+
filter: [{ term: { id: agentId } }, { term: { space: spaceId } }],
86+
},
87+
},
88+
size: 1,
89+
});
90+
91+
const documentId = searchResult.hits.hits[0]?._id;
92+
if (!documentId) {
93+
return false;
94+
}
95+
96+
const deleteResponse = await esClient.delete({
97+
index: agentsIndexName,
98+
id: documentId,
99+
});
100+
return deleteResponse.result === 'deleted';
101+
} catch (error) {
102+
this.logger.warn(
103+
`Failed to delete package-managed agent ${agentId} in space ${spaceId}: ${
104+
(error as Error).message
105+
}`
106+
);
107+
return false;
108+
}
109+
}
110+
111+
/**
112+
* AB-005: create or update a package-managed persisted skill
113+
* (system context; plugin_id makes it readonly in the UI).
114+
*/
115+
public async createOrUpdateSkill(params: PersistedSkillCreateRequest, request: KibanaRequest) {
116+
const [, , pluginStart] = await this.getStartServices();
117+
if (!pluginStart?.skills) {
118+
throw new Error('agentBuilder skills service is not available');
119+
}
120+
// Use the caller request: the skills registry runs an ES privilege check,
121+
// which fails with a synthetic credential-less request.
122+
const registry = await pluginStart.skills.getRegistry({ request });
123+
if (!(await registry.has(params.id))) {
124+
return registry.create(params);
125+
}
126+
// The registry blocks direct updates of plugin-managed skills (a guard meant for
127+
// end users). Package reinstall/upgrade must still refresh its own skill, so write
128+
// the document through the internal client instead.
129+
const [coreStart] = await this.getStartServices();
130+
const esClient = coreStart.elasticsearch.client.asInternalUser;
131+
const existing = await esClient.search<Record<string, unknown>>({
132+
index: skillIndexName,
133+
query: { term: { id: params.id } },
134+
size: 1,
135+
});
136+
const hit = existing.hits.hits[0];
137+
if (!hit?._id) {
138+
return registry.create(params);
139+
}
140+
const updatedDocument = {
141+
...(hit._source ?? {}),
142+
name: params.name,
143+
description: params.description,
144+
content: params.content,
145+
tool_ids: params.tool_ids,
146+
updated_at: new Date().toISOString(),
147+
};
148+
await esClient.index({ index: skillIndexName, id: hit._id, document: updatedDocument, refresh: true });
149+
return registry.get(params.id);
150+
}
151+
152+
/**
153+
* AB-005: system-level delete for Fleet package uninstall.
154+
* The user-context registry blocks deletes of plugin-managed skills,
155+
* so bypass it with an internal ES query by id + space.
156+
*/
157+
public async deletePackageManagedSkill(skillId: string, spaceId: string): Promise<boolean> {
158+
const [coreStart] = await this.getStartServices();
159+
const esClient = coreStart.elasticsearch.client.asInternalUser;
160+
try {
161+
const searchResult = await esClient.search<{ id: string; space: string }>({
162+
index: skillIndexName,
163+
query: {
164+
bool: {
165+
filter: [{ term: { id: skillId } }, { term: { space: spaceId } }],
166+
},
167+
},
168+
size: 1,
169+
});
170+
const documentId = searchResult.hits.hits[0]?._id;
171+
if (!documentId) {
172+
return false;
173+
}
174+
const deleteResponse = await esClient.delete({ index: skillIndexName, id: documentId });
175+
return deleteResponse.result === 'deleted';
176+
} catch (error) {
177+
this.logger.warn(
178+
`Failed to delete package-managed skill ${skillId} in space ${spaceId}: ${
179+
(error as Error).message
180+
}`
181+
);
182+
return false;
183+
}
184+
}
185+
186+
/**
187+
* AB-005: list the skills owned by a Fleet package in a space.
188+
*
189+
* Package-managed skills are readonly, so neither the user nor package
190+
* uninstall can remove one whose id is absent from the package asset refs.
191+
* Install uses this to reap its own stale skills; it reads through the
192+
* internal client because the user-context registry hides plugin-managed
193+
* skills by default.
194+
*/
195+
public async listPackageManagedSkills(
196+
pluginId: string,
197+
spaceId: string
198+
): Promise<Array<{ id: string; plugin_id?: string }>> {
199+
const [coreStart] = await this.getStartServices();
200+
const esClient = coreStart.elasticsearch.client.asInternalUser;
201+
try {
202+
const searchResult = await esClient.search<{ id: string; plugin_id?: string }>({
203+
index: skillIndexName,
204+
query: {
205+
bool: {
206+
filter: [{ term: { plugin_id: pluginId } }, { term: { space: spaceId } }],
207+
},
208+
},
209+
size: 1000,
210+
});
211+
return searchResult.hits.hits
212+
.map((hit) => hit._source)
213+
.filter((source): source is { id: string; plugin_id?: string } => Boolean(source?.id));
214+
} catch (error) {
215+
this.logger.warn(
216+
`Failed to list package-managed skills for ${pluginId} in space ${spaceId}: ${
217+
(error as Error).message
218+
}`
219+
);
220+
return [];
221+
}
222+
}
223+
}

x-pack/platform/plugins/shared/agent_builder/server/mocks.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ const createSetupContractMock = (): AgentBuilderPluginSetupMock => {
2626
tools: {
2727
register: jest.fn(),
2828
},
29+
management: {
30+
getAgent: jest.fn(),
31+
createOrUpdateAgent: jest.fn(),
32+
deletePackageManagedAgent: jest.fn(),
33+
createOrUpdateSkill: jest.fn(),
34+
deletePackageManagedSkill: jest.fn(),
35+
listPackageManagedSkills: jest.fn().mockResolvedValue([]),
36+
},
2937
attachments: {
3038
registerType: jest.fn(),
3139
},

x-pack/platform/plugins/shared/agent_builder/server/plugin.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
} from '@kbn/core/server';
1515
import type { Logger } from '@kbn/logging';
1616
import { AGENT_BUILDER_EXPERIMENTAL_FEATURES_SETTING_ID } from '@kbn/management-settings-ids';
17+
import { AgentBuilderManagementApi } from './api/agent_builder_management_api';
1718
import type { UsageCounter } from '@kbn/usage-collection-plugin/server';
1819
import type { HomeServerPluginSetup } from '@kbn/home-plugin/server';
1920
import {
@@ -278,6 +279,9 @@ export class AgentBuilderPlugin
278279
),
279280
},
280281
topSnippets: this.config.topSnippets,
282+
// AB-004/AB-005: Fleet package installs create/update package-managed
283+
// agents and skills through this system-level management surface.
284+
management: new AgentBuilderManagementApi(coreSetup.getStartServices, this.logger),
281285
};
282286
}
283287

x-pack/platform/plugins/shared/agent_builder/server/services/agents/access_control/authorization.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,13 +58,31 @@ describe('agent access-control authorization', () => {
5858
expect(
5959
isAgentOwner({
6060
owner: { id: 'owner-id', username: 'alice' },
61-
currentUser: { username: 'alice' },
61+
currentUser: { id: 'different-id', username: 'alice' },
6262
})
6363
).toBe(false);
64+
// Profile-uid owners never accept a username-only current user.
6465
expect(
6566
isAgentOwner({
6667
owner: { id: 'owner-id', username: 'alice' },
67-
currentUser: { id: 'different-id', username: 'alice' },
68+
currentUser: { username: 'alice' },
69+
})
70+
).toBe(false);
71+
});
72+
73+
it('accepts the realm-encoded username when the current user id is unresolvable', () => {
74+
// Kibana authc can omit authentication_realm, leaving currentUser.id undefined. The owner
75+
// must still be recognised, but only via the username encoded in its own realm id.
76+
expect(
77+
isAgentOwner({
78+
owner: { id: 'realm:["reserved","reserved","elastic"]', username: 'elastic' },
79+
currentUser: { username: 'elastic' },
80+
})
81+
).toBe(true);
82+
expect(
83+
isAgentOwner({
84+
owner: { id: 'realm:["reserved","reserved","elastic"]', username: 'elastic' },
85+
currentUser: { username: 'mallory' },
6886
})
6987
).toBe(false);
7088
});

0 commit comments

Comments
 (0)