@acarmisc/backstage-plugin-litellm-backend 0.17.7 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config.d.ts +28 -0
- package/dist/index.cjs.js +162 -75
- package/dist/index.cjs.js.map +3 -3
- package/dist/router.js +56 -18
- package/dist/routes/config.js +2 -0
- package/dist/routes/context.d.ts +4 -1
- package/dist/routes/teams.js +43 -4
- package/dist/teamAdmin.d.ts +24 -0
- package/dist/teamAdmin.js +3 -0
- package/package.json +1 -1
package/dist/router.js
CHANGED
|
@@ -201,13 +201,19 @@ async function createRouter(options) {
|
|
|
201
201
|
sendTeamError: (err, res) => {
|
|
202
202
|
(0, errors_2.sendError)(res, err, logger, 'team operation');
|
|
203
203
|
},
|
|
204
|
-
authorizeTeamSubresource: async (req, res, permission) => {
|
|
204
|
+
authorizeTeamSubresource: async (req, res, permission, opts) => {
|
|
205
205
|
if (!ctx.requireTeamMgmt(res))
|
|
206
206
|
return null;
|
|
207
207
|
if (!teamAdminCfg.group) {
|
|
208
208
|
res.status(500).json({ error: 'Team management is misconfigured (group is missing)' });
|
|
209
209
|
return null;
|
|
210
210
|
}
|
|
211
|
+
if (teamAdminCfg.readOnly) {
|
|
212
|
+
res.status(403).json({
|
|
213
|
+
error: 'Team management is read-only (litellm.teamAdmin.readOnly): teams are managed outside Backstage',
|
|
214
|
+
});
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
211
217
|
const check = await (0, teamAdmin_1.assertTeamAdmin)({
|
|
212
218
|
req,
|
|
213
219
|
auth,
|
|
@@ -217,7 +223,11 @@ async function createRouter(options) {
|
|
|
217
223
|
permission,
|
|
218
224
|
logger,
|
|
219
225
|
});
|
|
220
|
-
|
|
226
|
+
// A caller outside the admin group may still qualify through their
|
|
227
|
+
// LiteLLM team role (litellm.teamAdmin.memberManagerRoles), so a 403
|
|
228
|
+
// here is deferred until that path has been tried.
|
|
229
|
+
const teamRoleEligible = !!opts?.allowTeamRole && teamAdminCfg.memberManagerRoles.length > 0;
|
|
230
|
+
if (!check.ok && !(check.status === 403 && teamRoleEligible)) {
|
|
221
231
|
res.status(check.status).json({ error: check.error });
|
|
222
232
|
return null;
|
|
223
233
|
}
|
|
@@ -241,25 +251,53 @@ async function createRouter(options) {
|
|
|
241
251
|
const owningGroup = typeof existing.metadata?.owning_group === 'string'
|
|
242
252
|
? existing.metadata.owning_group
|
|
243
253
|
: undefined;
|
|
244
|
-
|
|
245
|
-
|
|
254
|
+
// Group path: admin-group member whose group owns the team.
|
|
255
|
+
let denial;
|
|
256
|
+
if (!check.ok) {
|
|
257
|
+
denial = { status: check.status, error: check.error };
|
|
258
|
+
}
|
|
259
|
+
else if (!owningGroup) {
|
|
260
|
+
denial = {
|
|
261
|
+
status: 403,
|
|
246
262
|
error: 'This team is not managed by Backstage team admins and cannot be edited here',
|
|
247
|
-
}
|
|
248
|
-
return null;
|
|
263
|
+
};
|
|
249
264
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
265
|
+
else if (await (0, provisioning_1.isUserMemberOfGroup)(check.userEntityRef, owningGroup, catalogClient, auth, logger)) {
|
|
266
|
+
return {
|
|
267
|
+
teamId,
|
|
268
|
+
owningGroup,
|
|
269
|
+
actor: check.userEntityRef,
|
|
270
|
+
team: existing,
|
|
271
|
+
via: 'group',
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
denial = {
|
|
276
|
+
status: 403,
|
|
277
|
+
error: `Access denied: team is owned by ${owningGroup}`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
// Team-role path: the caller holds an allowed role in this very team.
|
|
281
|
+
// The permission framework still has the final say.
|
|
282
|
+
if (teamRoleEligible) {
|
|
283
|
+
const actor = await (0, provisioning_1.resolveUserId)(req, auth);
|
|
284
|
+
const litellmUserId = actor && (0, provisioning_1.toLiteLLMUserId)(actor, userIdDomain);
|
|
285
|
+
const role = existing.members_with_roles?.find(m => m.user_id === litellmUserId)?.role;
|
|
286
|
+
if (actor &&
|
|
287
|
+
role &&
|
|
288
|
+
teamAdminCfg.memberManagerRoles.includes(role) &&
|
|
289
|
+
(await ctx.assertPermission(req, permission))) {
|
|
290
|
+
return {
|
|
291
|
+
teamId,
|
|
292
|
+
owningGroup: owningGroup ?? '',
|
|
293
|
+
actor,
|
|
294
|
+
team: existing,
|
|
295
|
+
via: 'teamRole',
|
|
296
|
+
};
|
|
297
|
+
}
|
|
256
298
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
owningGroup,
|
|
260
|
-
actor: check.userEntityRef,
|
|
261
|
-
team: existing,
|
|
262
|
-
};
|
|
299
|
+
res.status(denial.status).json({ error: denial.error });
|
|
300
|
+
return null;
|
|
263
301
|
},
|
|
264
302
|
};
|
|
265
303
|
const router = (0, express_1.Router)();
|
package/dist/routes/config.js
CHANGED
|
@@ -26,6 +26,8 @@ function registerConfigRoutes(router, ctx) {
|
|
|
26
26
|
maxBudgetCeiling: ctx.teamAdminCfg.maxBudgetCeiling,
|
|
27
27
|
allowUnlimitedBudget: ctx.teamAdminCfg.allowUnlimitedBudget,
|
|
28
28
|
objectPermissionsEnabled: ctx.objectPermsEnabled,
|
|
29
|
+
readOnly: ctx.teamAdminCfg.readOnly,
|
|
30
|
+
memberManagerRoles: ctx.teamAdminCfg.memberManagerRoles,
|
|
29
31
|
},
|
|
30
32
|
display: {
|
|
31
33
|
hideTeamBudgetForMembers: ctx.teamBudgetVisibility.hideTeamBudgetForMembers,
|
package/dist/routes/context.d.ts
CHANGED
|
@@ -46,10 +46,13 @@ export interface RouterContext {
|
|
|
46
46
|
requireTeamMgmt(res: Response): boolean;
|
|
47
47
|
requireObjectPerms(res: Response): boolean;
|
|
48
48
|
sendTeamError(err: unknown, res: Response): void;
|
|
49
|
-
authorizeTeamSubresource(req: Request, res: Response, permission: BasicPermission
|
|
49
|
+
authorizeTeamSubresource(req: Request, res: Response, permission: BasicPermission, opts?: {
|
|
50
|
+
allowTeamRole?: boolean;
|
|
51
|
+
}): Promise<{
|
|
50
52
|
teamId: string;
|
|
51
53
|
owningGroup: string;
|
|
52
54
|
actor: string;
|
|
53
55
|
team: TeamInfo;
|
|
56
|
+
via: 'group' | 'teamRole';
|
|
54
57
|
} | null>;
|
|
55
58
|
}
|
package/dist/routes/teams.js
CHANGED
|
@@ -43,6 +43,12 @@ function registerTeamsRoutes(router, ctx) {
|
|
|
43
43
|
res.status(500).json({ error: 'Team management is misconfigured (group is missing)' });
|
|
44
44
|
return;
|
|
45
45
|
}
|
|
46
|
+
if (teamAdminCfg.readOnly) {
|
|
47
|
+
res.status(403).json({
|
|
48
|
+
error: 'Team management is read-only (litellm.teamAdmin.readOnly): teams are managed outside Backstage',
|
|
49
|
+
});
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
46
52
|
const check = await (0, teamAdmin_1.assertTeamAdmin)({
|
|
47
53
|
req,
|
|
48
54
|
auth,
|
|
@@ -56,6 +62,16 @@ function registerTeamsRoutes(router, ctx) {
|
|
|
56
62
|
res.status(check.status).json({ error: check.error });
|
|
57
63
|
return;
|
|
58
64
|
}
|
|
65
|
+
// createGroups narrows creation to a subset of the admin group.
|
|
66
|
+
if (teamAdminCfg.createGroups.length > 0) {
|
|
67
|
+
const memberships = await Promise.all(teamAdminCfg.createGroups.map(group => (0, provisioning_1.isUserMemberOfGroup)(check.userEntityRef, group, catalogClient, auth, logger)));
|
|
68
|
+
if (!memberships.some(Boolean)) {
|
|
69
|
+
res.status(403).json({
|
|
70
|
+
error: `Access denied: team creation is limited to ${teamAdminCfg.createGroups.join(', ')}`,
|
|
71
|
+
});
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
59
75
|
const v = (0, teamAdmin_1.validateTeamWriteInput)(req.body ?? {}, teamAdminCfg);
|
|
60
76
|
if (!v.ok) {
|
|
61
77
|
res.status(400).json({ error: v.error });
|
|
@@ -240,10 +256,10 @@ function registerTeamsRoutes(router, ctx) {
|
|
|
240
256
|
// provision them on the spot when auto-provisioning is enabled. Only the
|
|
241
257
|
// 'user' team role can be assigned from Backstage.
|
|
242
258
|
router.post('/teams/:teamId/members', async (req, res) => {
|
|
243
|
-
const authz = await authorizeTeamSubresource(req, res, backstage_plugin_litellm_common_1.litellmTeamMembersManagePermission);
|
|
259
|
+
const authz = await authorizeTeamSubresource(req, res, backstage_plugin_litellm_common_1.litellmTeamMembersManagePermission, { allowTeamRole: true });
|
|
244
260
|
if (!authz)
|
|
245
261
|
return;
|
|
246
|
-
const { teamId, owningGroup, actor } = authz;
|
|
262
|
+
const { teamId, owningGroup, actor, via } = authz;
|
|
247
263
|
const body = (req.body ?? {});
|
|
248
264
|
const userEntityRef = (body.userEntityRef ?? '').trim();
|
|
249
265
|
if (!userEntityRef) {
|
|
@@ -267,6 +283,12 @@ function registerTeamsRoutes(router, ctx) {
|
|
|
267
283
|
return;
|
|
268
284
|
}
|
|
269
285
|
maxBudgetInTeam = body.maxBudgetInTeam;
|
|
286
|
+
if (via === 'teamRole') {
|
|
287
|
+
res.status(403).json({
|
|
288
|
+
error: 'Team member managers cannot set member budgets',
|
|
289
|
+
});
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
270
292
|
}
|
|
271
293
|
// (a) the member must be a real User in the Backstage catalog
|
|
272
294
|
try {
|
|
@@ -318,6 +340,7 @@ function registerTeamsRoutes(router, ctx) {
|
|
|
318
340
|
teamId,
|
|
319
341
|
member: litellmUserId,
|
|
320
342
|
owningGroup,
|
|
343
|
+
via,
|
|
321
344
|
});
|
|
322
345
|
const updated = await (0, teamFetch_1.withTeamFetchRetry)(() => client.getTeamInfo(teamId));
|
|
323
346
|
res.json(teamBudgetVisibility.hideTeamBudgetForManagers
|
|
@@ -332,10 +355,10 @@ function registerTeamsRoutes(router, ctx) {
|
|
|
332
355
|
// from the `userEntityRef` query param (entity refs contain ':' and '/', so
|
|
333
356
|
// they can't be a path segment). No catalog re-check on removal.
|
|
334
357
|
router.delete('/teams/:teamId/members', async (req, res) => {
|
|
335
|
-
const authz = await authorizeTeamSubresource(req, res, backstage_plugin_litellm_common_1.litellmTeamMembersManagePermission);
|
|
358
|
+
const authz = await authorizeTeamSubresource(req, res, backstage_plugin_litellm_common_1.litellmTeamMembersManagePermission, { allowTeamRole: true });
|
|
336
359
|
if (!authz)
|
|
337
360
|
return;
|
|
338
|
-
const { teamId, owningGroup, actor } = authz;
|
|
361
|
+
const { teamId, owningGroup, actor, team, via } = authz;
|
|
339
362
|
const userEntityRef = String(req.query.userEntityRef ?? '').trim();
|
|
340
363
|
if (!userEntityRef) {
|
|
341
364
|
res
|
|
@@ -344,6 +367,21 @@ function registerTeamsRoutes(router, ctx) {
|
|
|
344
367
|
return;
|
|
345
368
|
}
|
|
346
369
|
const litellmUserId = (0, provisioning_1.toLiteLLMUserId)(userEntityRef, userIdDomain);
|
|
370
|
+
// Team-role managers change membership only: they cannot remove
|
|
371
|
+
// themselves nor a peer holding a manager role.
|
|
372
|
+
if (via === 'teamRole') {
|
|
373
|
+
if (litellmUserId === (0, provisioning_1.toLiteLLMUserId)(actor, userIdDomain)) {
|
|
374
|
+
res.status(400).json({ error: 'You cannot remove yourself from the team' });
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const role = team.members_with_roles?.find(m => m.user_id === litellmUserId)?.role;
|
|
378
|
+
if (role && teamAdminCfg.memberManagerRoles.includes(role)) {
|
|
379
|
+
res.status(403).json({
|
|
380
|
+
error: `Only platform team admins can remove a team ${role}`,
|
|
381
|
+
});
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
347
385
|
try {
|
|
348
386
|
await client.teamMemberDelete({
|
|
349
387
|
team_id: teamId,
|
|
@@ -354,6 +392,7 @@ function registerTeamsRoutes(router, ctx) {
|
|
|
354
392
|
teamId,
|
|
355
393
|
member: litellmUserId,
|
|
356
394
|
owningGroup,
|
|
395
|
+
via,
|
|
357
396
|
});
|
|
358
397
|
const updated = await (0, teamFetch_1.withTeamFetchRetry)(() => client.getTeamInfo(teamId));
|
|
359
398
|
res.json(teamBudgetVisibility.hideTeamBudgetForManagers
|
package/dist/teamAdmin.d.ts
CHANGED
|
@@ -61,6 +61,30 @@ export interface TeamAdminConfig {
|
|
|
61
61
|
* Allow an admin to delete a team (vs. only block/deactivate).
|
|
62
62
|
*/
|
|
63
63
|
allowTeamDelete: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* When true, all team write operations return 403 even for admins:
|
|
66
|
+
* POST /teams, PATCH /teams/:id, DELETE /teams/:id,
|
|
67
|
+
* POST/DELETE /teams/:id/members, and PUT objectPermissions routes.
|
|
68
|
+
* Reads (GET /teams, GET /teams/managed) keep working.
|
|
69
|
+
* Fail-closed: defaults to false (writes allowed).
|
|
70
|
+
*/
|
|
71
|
+
readOnly: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Backstage group refs that must include the caller for team creation.
|
|
74
|
+
* Empty => no group restriction, admin group check alone suffices.
|
|
75
|
+
* When non-empty, POST /teams additionally requires the caller to be
|
|
76
|
+
* a member of at least one of these groups, narrowing the permission.
|
|
77
|
+
* Fail-closed: defaults to [] (no extra restriction).
|
|
78
|
+
*/
|
|
79
|
+
createGroups: string[];
|
|
80
|
+
/**
|
|
81
|
+
* LiteLLM team roles whose holders may add/remove members of their own team.
|
|
82
|
+
* Empty => feature off, only admins can manage members.
|
|
83
|
+
* When non-empty, callers with one of these roles in members_with_roles
|
|
84
|
+
* can POST/DELETE /teams/:teamId/members without owning the team's group.
|
|
85
|
+
* Fail-closed: defaults to [] (off).
|
|
86
|
+
*/
|
|
87
|
+
memberManagerRoles: string[];
|
|
64
88
|
}
|
|
65
89
|
/**
|
|
66
90
|
* Whether team-management routes should be mounted.
|
package/dist/teamAdmin.js
CHANGED
|
@@ -112,6 +112,9 @@ function readTeamAdminConfig(config) {
|
|
|
112
112
|
allowedMcpServers: config.getOptionalStringArray('litellm.teamAdmin.allowedMcpServers') ?? [],
|
|
113
113
|
allowedMcpAccessGroups: config.getOptionalStringArray('litellm.teamAdmin.allowedMcpAccessGroups') ?? [],
|
|
114
114
|
allowTeamDelete: config.getOptionalBoolean('litellm.teamAdmin.allowTeamDelete') ?? false,
|
|
115
|
+
readOnly: config.getOptionalBoolean('litellm.teamAdmin.readOnly') ?? false,
|
|
116
|
+
createGroups: config.getOptionalStringArray('litellm.teamAdmin.createGroups') ?? [],
|
|
117
|
+
memberManagerRoles: config.getOptionalStringArray('litellm.teamAdmin.memberManagerRoles') ?? [],
|
|
115
118
|
};
|
|
116
119
|
}
|
|
117
120
|
function validateTeamWriteInput(input, cfg) {
|