@acarmisc/backstage-plugin-litellm-backend 0.17.6 → 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/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
- if (!check.ok) {
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
- if (!owningGroup) {
245
- res.status(403).json({
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
- const owns = await (0, provisioning_1.isUserMemberOfGroup)(check.userEntityRef, owningGroup, catalogClient, auth, logger);
251
- if (!owns) {
252
- res
253
- .status(403)
254
- .json({ error: `Access denied: team is owned by ${owningGroup}` });
255
- return null;
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
- return {
258
- teamId,
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)();
@@ -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,
@@ -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): Promise<{
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
  }
@@ -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
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acarmisc/backstage-plugin-litellm-backend",
3
- "version": "0.17.6",
3
+ "version": "0.18.0",
4
4
  "description": "The Backstage backend plugin for LiteLLM governance",
5
5
  "backstage": {
6
6
  "role": "backend-plugin",