@acarmisc/backstage-plugin-litellm-backend 0.8.2 → 0.10.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 +101 -0
- package/dist/client.d.ts +61 -1
- package/dist/client.js +137 -1
- package/dist/index.cjs.js +8987 -22
- package/dist/index.cjs.js.map +4 -4
- package/dist/index.d.ts +3 -0
- package/dist/index.js +19 -1
- package/dist/permissions.d.ts +11 -0
- package/dist/permissions.js +57 -0
- package/dist/plugin.js +12 -2
- package/dist/provisioning.d.ts +6 -1
- package/dist/provisioning.js +16 -0
- package/dist/router.d.ts +5 -1
- package/dist/router.js +646 -2
- package/dist/teamAdmin.d.ts +150 -0
- package/dist/teamAdmin.js +235 -0
- package/dist/types.cjs.js.map +1 -1
- package/dist/types.d.ts +34 -0
- package/package.json +6 -3
package/dist/router.js
CHANGED
|
@@ -36,14 +36,18 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
36
36
|
exports.ProvisioningError = void 0;
|
|
37
37
|
exports.createRouter = createRouter;
|
|
38
38
|
const express_1 = __importStar(require("express"));
|
|
39
|
+
const plugin_permission_common_1 = require("@backstage/plugin-permission-common");
|
|
39
40
|
const catalog_client_1 = require("@backstage/catalog-client");
|
|
40
41
|
const client_1 = require("./client");
|
|
41
42
|
const openapi_1 = require("./openapi");
|
|
42
43
|
const provisioning_1 = require("./provisioning");
|
|
43
44
|
Object.defineProperty(exports, "ProvisioningError", { enumerable: true, get: function () { return provisioning_1.ProvisioningError; } });
|
|
44
45
|
const bridge_1 = require("./bridge");
|
|
46
|
+
const permissions_1 = require("./permissions");
|
|
47
|
+
const teamAdmin_1 = require("./teamAdmin");
|
|
48
|
+
const teamCreateInFlight = new Map();
|
|
45
49
|
async function createRouter(options) {
|
|
46
|
-
const { config, logger, auth, discovery } = options;
|
|
50
|
+
const { config, logger, auth, discovery, permissions } = options;
|
|
47
51
|
const baseUrl = config.getString('litellm.baseUrl');
|
|
48
52
|
const masterKey = config.getString('litellm.masterKey');
|
|
49
53
|
const userIdDomain = config.getOptionalString('litellm.userIdDomain');
|
|
@@ -57,12 +61,18 @@ async function createRouter(options) {
|
|
|
57
61
|
const auditGroup = config.getOptionalString('litellm.audit.group');
|
|
58
62
|
const allowUnlimitedBudget = config.getOptionalBoolean('litellm.keyGeneration.allowUnlimitedBudget') ?? false;
|
|
59
63
|
const teamRequired = config.getOptionalBoolean('litellm.keyGeneration.teamRequired') ?? true;
|
|
60
|
-
const
|
|
64
|
+
const teamMgmtEnabled = (0, teamAdmin_1.isTeamManagementEnabled)(config);
|
|
65
|
+
const objectPermsEnabled = (0, teamAdmin_1.isObjectPermissionsEnabled)(config);
|
|
66
|
+
const teamAdminCfg = (0, teamAdmin_1.readTeamAdminConfig)(config);
|
|
67
|
+
const catalogClient = options.catalogClient ?? new catalog_client_1.CatalogClient({ discoveryApi: discovery });
|
|
61
68
|
if (provisioningEnabled) {
|
|
62
69
|
logger.info(`LiteLLM auto-provisioning enabled — defaults: budget=$${provisioningDefaults.maxBudget}/${provisioningDefaults.budgetDuration}, models=${provisioningDefaults.models.length
|
|
63
70
|
? provisioningDefaults.models.join(',')
|
|
64
71
|
: 'all'}, teams=[${provisioningDefaults.teams.join(',')}]`);
|
|
65
72
|
}
|
|
73
|
+
if (config.getOptional('litellm.teamAdmin') && !teamMgmtEnabled) {
|
|
74
|
+
logger.warn('litellm.teamAdmin is configured but team management is disabled — set permission.enabled: true and litellm.teamAdmin.group to enable it.');
|
|
75
|
+
}
|
|
66
76
|
const router = (0, express_1.Router)();
|
|
67
77
|
// JSON body parser. Without this, every POST/PUT endpoint sees an empty
|
|
68
78
|
// req.body. Backstage's httpRouter does not apply a body parser at the
|
|
@@ -77,6 +87,12 @@ async function createRouter(options) {
|
|
|
77
87
|
res.json({
|
|
78
88
|
baseUrl: publicBaseUrl,
|
|
79
89
|
keyGeneration: { allowUnlimitedBudget, teamRequired },
|
|
90
|
+
teamManagement: {
|
|
91
|
+
enabled: teamMgmtEnabled,
|
|
92
|
+
maxBudgetCeiling: teamAdminCfg.maxBudgetCeiling ?? null,
|
|
93
|
+
allowUnlimitedBudget: teamAdminCfg.allowUnlimitedBudget,
|
|
94
|
+
objectPermissionsEnabled: objectPermsEnabled,
|
|
95
|
+
},
|
|
80
96
|
});
|
|
81
97
|
});
|
|
82
98
|
// Self-hosted OpenAPI 3.1 contract — lets integrators read a spec instead
|
|
@@ -212,6 +228,27 @@ async function createRouter(options) {
|
|
|
212
228
|
}
|
|
213
229
|
return false;
|
|
214
230
|
}
|
|
231
|
+
// ── Permission-framework guard ───────────────────────────────────────────
|
|
232
|
+
//
|
|
233
|
+
// Coarse-grained "is this identity allowed to do this at all" check, layered
|
|
234
|
+
// on top of (not replacing) the ownership guard above. With no permission
|
|
235
|
+
// policy installed (the default for a fresh Backstage instance) this always
|
|
236
|
+
// resolves ALLOW, so behavior is unchanged until an operator wires up
|
|
237
|
+
// @backstage-community/plugin-rbac or a custom PermissionPolicy.
|
|
238
|
+
async function assertPermission(req, permission) {
|
|
239
|
+
const credentials = await (0, provisioning_1.resolveCredentials)(req, auth);
|
|
240
|
+
if (!credentials)
|
|
241
|
+
return false;
|
|
242
|
+
const [decision] = await permissions.authorize([{ permission }], {
|
|
243
|
+
credentials,
|
|
244
|
+
});
|
|
245
|
+
return decision.result === plugin_permission_common_1.AuthorizeResult.ALLOW;
|
|
246
|
+
}
|
|
247
|
+
function sendPermissionDenied(res, permission) {
|
|
248
|
+
res.status(403).json({
|
|
249
|
+
error: `Access denied: missing permission "${permission.name}"`,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
215
252
|
router.post('/keys/generate', async (req, res) => {
|
|
216
253
|
try {
|
|
217
254
|
// Only alias is hard-required. max_budget is optional: a positive
|
|
@@ -236,6 +273,10 @@ async function createRouter(options) {
|
|
|
236
273
|
});
|
|
237
274
|
return;
|
|
238
275
|
}
|
|
276
|
+
if (!(await assertPermission(req, permissions_1.litellmKeyCreatePermission))) {
|
|
277
|
+
sendPermissionDenied(res, permissions_1.litellmKeyCreatePermission);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
239
280
|
const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
|
|
240
281
|
const resolvedUserId = tokenEntityRef
|
|
241
282
|
? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
|
|
@@ -314,6 +355,10 @@ async function createRouter(options) {
|
|
|
314
355
|
res.status(400).json({ error: 'keyId is required' });
|
|
315
356
|
return;
|
|
316
357
|
}
|
|
358
|
+
if (!(await assertPermission(req, permissions_1.litellmKeyManagePermission))) {
|
|
359
|
+
sendPermissionDenied(res, permissions_1.litellmKeyManagePermission);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
317
362
|
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
318
363
|
const request = { ...req.body, key: keyId };
|
|
319
364
|
const result = await client.updateKey(request);
|
|
@@ -334,6 +379,10 @@ async function createRouter(options) {
|
|
|
334
379
|
res.status(400).json({ error: 'keyId is required' });
|
|
335
380
|
return;
|
|
336
381
|
}
|
|
382
|
+
if (!(await assertPermission(req, permissions_1.litellmKeyRevokePermission))) {
|
|
383
|
+
sendPermissionDenied(res, permissions_1.litellmKeyRevokePermission);
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
337
386
|
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
338
387
|
await client.deleteKeys({ keys: [keyId] });
|
|
339
388
|
logger.info({ action: 'key.delete', userId: tokenEntityRef ?? 'unknown', keyId });
|
|
@@ -349,6 +398,10 @@ async function createRouter(options) {
|
|
|
349
398
|
router.post('/keys/:keyId/block', async (req, res) => {
|
|
350
399
|
try {
|
|
351
400
|
const { keyId } = req.params;
|
|
401
|
+
if (!(await assertPermission(req, permissions_1.litellmKeyManagePermission))) {
|
|
402
|
+
sendPermissionDenied(res, permissions_1.litellmKeyManagePermission);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
352
405
|
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
353
406
|
await client.blockKey(keyId);
|
|
354
407
|
logger.info({ action: 'key.block', userId: tokenEntityRef ?? 'unknown', keyId });
|
|
@@ -364,6 +417,10 @@ async function createRouter(options) {
|
|
|
364
417
|
router.post('/keys/:keyId/unblock', async (req, res) => {
|
|
365
418
|
try {
|
|
366
419
|
const { keyId } = req.params;
|
|
420
|
+
if (!(await assertPermission(req, permissions_1.litellmKeyManagePermission))) {
|
|
421
|
+
sendPermissionDenied(res, permissions_1.litellmKeyManagePermission);
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
367
424
|
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
368
425
|
await client.unblockKey(keyId);
|
|
369
426
|
logger.info({ action: 'key.unblock', userId: tokenEntityRef ?? 'unknown', keyId });
|
|
@@ -379,6 +436,10 @@ async function createRouter(options) {
|
|
|
379
436
|
router.post('/keys/:keyId/reset_spend', async (req, res) => {
|
|
380
437
|
try {
|
|
381
438
|
const { keyId } = req.params;
|
|
439
|
+
if (!(await assertPermission(req, permissions_1.litellmKeyManagePermission))) {
|
|
440
|
+
sendPermissionDenied(res, permissions_1.litellmKeyManagePermission);
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
382
443
|
const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
|
|
383
444
|
await client.resetKeySpend(keyId);
|
|
384
445
|
logger.info({ action: 'key.reset_spend', userId: tokenEntityRef ?? 'unknown', keyId });
|
|
@@ -406,6 +467,10 @@ async function createRouter(options) {
|
|
|
406
467
|
res.status(403).json({ error: 'Access denied: not a member of the audit group' });
|
|
407
468
|
return;
|
|
408
469
|
}
|
|
470
|
+
if (!(await assertPermission(req, permissions_1.litellmAuditReadPermission))) {
|
|
471
|
+
sendPermissionDenied(res, permissions_1.litellmAuditReadPermission);
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
409
474
|
try {
|
|
410
475
|
const { page, page_size, start_date, end_date, action, table_name, changed_by } = req.query;
|
|
411
476
|
const result = await client.getAuditLogs({
|
|
@@ -460,6 +525,585 @@ async function createRouter(options) {
|
|
|
460
525
|
res.status(500).json({ error: error.message });
|
|
461
526
|
}
|
|
462
527
|
});
|
|
528
|
+
function requireTeamMgmt(res) {
|
|
529
|
+
if (!teamMgmtEnabled) {
|
|
530
|
+
res.status(403).json({
|
|
531
|
+
error: 'Team management is disabled (requires permission.enabled and litellm.teamAdmin.group)',
|
|
532
|
+
});
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
function requireObjectPerms(res) {
|
|
538
|
+
if (!requireTeamMgmt(res))
|
|
539
|
+
return false;
|
|
540
|
+
if (!objectPermsEnabled) {
|
|
541
|
+
res.status(403).json({
|
|
542
|
+
error: 'Knowledge-base / MCP management is disabled (set litellm.teamAdmin.objectPermissions.enabled: true, with a permission policy and the allowlists in place)',
|
|
543
|
+
});
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
function sendTeamError(err, res) {
|
|
549
|
+
if (err instanceof client_1.LiteLLMUpstreamError) {
|
|
550
|
+
res.status(err.status).json({
|
|
551
|
+
error: err.message,
|
|
552
|
+
...(err.param ? { param: err.param } : {}),
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
else {
|
|
556
|
+
logger.error('Team operation failed', err);
|
|
557
|
+
res.status(500).json({ error: err.message });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
router.post('/teams', async (req, res) => {
|
|
561
|
+
if (!requireTeamMgmt(res))
|
|
562
|
+
return;
|
|
563
|
+
const check = await (0, teamAdmin_1.assertTeamAdmin)({
|
|
564
|
+
req,
|
|
565
|
+
auth,
|
|
566
|
+
permissions,
|
|
567
|
+
catalogClient,
|
|
568
|
+
teamAdminGroup: teamAdminCfg.group,
|
|
569
|
+
permission: permissions_1.litellmTeamCreatePermission,
|
|
570
|
+
logger,
|
|
571
|
+
});
|
|
572
|
+
if (!check.ok) {
|
|
573
|
+
res.status(check.status).json({ error: check.error });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const v = (0, teamAdmin_1.validateTeamWriteInput)(req.body ?? {}, teamAdminCfg);
|
|
577
|
+
if (!v.ok) {
|
|
578
|
+
res.status(400).json({ error: v.error });
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const key = v.value.team_alias.trim().toLowerCase();
|
|
582
|
+
const pending = teamCreateInFlight.get(key);
|
|
583
|
+
if (pending) {
|
|
584
|
+
logger.info(`Team creation already in flight for ${key} — joining`);
|
|
585
|
+
try {
|
|
586
|
+
const result = await pending;
|
|
587
|
+
res.json(result);
|
|
588
|
+
}
|
|
589
|
+
catch (err) {
|
|
590
|
+
sendTeamError(err, res);
|
|
591
|
+
}
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const payload = {
|
|
595
|
+
team_alias: v.value.team_alias,
|
|
596
|
+
models: v.value.models,
|
|
597
|
+
...(v.value.max_budget !== undefined && { max_budget: v.value.max_budget }),
|
|
598
|
+
...(v.value.budget_duration && { budget_duration: v.value.budget_duration }),
|
|
599
|
+
...(v.value.tpm_limit !== undefined && { tpm_limit: v.value.tpm_limit }),
|
|
600
|
+
...(v.value.rpm_limit !== undefined && { rpm_limit: v.value.rpm_limit }),
|
|
601
|
+
metadata: {
|
|
602
|
+
owning_group: teamAdminCfg.group,
|
|
603
|
+
created_by_backstage_user: check.userEntityRef,
|
|
604
|
+
created_via: 'backstage',
|
|
605
|
+
created_at_iso: new Date().toISOString(),
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
const createPromise = (async () => {
|
|
609
|
+
const result = await client.createTeam(payload);
|
|
610
|
+
logger.info({
|
|
611
|
+
action: 'team.create',
|
|
612
|
+
actor: check.userEntityRef,
|
|
613
|
+
teamAlias: v.value.team_alias,
|
|
614
|
+
owningGroup: teamAdminCfg.group,
|
|
615
|
+
});
|
|
616
|
+
return result;
|
|
617
|
+
})();
|
|
618
|
+
teamCreateInFlight.set(key, createPromise);
|
|
619
|
+
try {
|
|
620
|
+
const result = await createPromise;
|
|
621
|
+
res.json(result);
|
|
622
|
+
}
|
|
623
|
+
catch (err) {
|
|
624
|
+
sendTeamError(err, res);
|
|
625
|
+
}
|
|
626
|
+
finally {
|
|
627
|
+
teamCreateInFlight.delete(key);
|
|
628
|
+
}
|
|
629
|
+
});
|
|
630
|
+
router.patch('/teams/:teamId', async (req, res) => {
|
|
631
|
+
const authz = await authorizeTeamSubresource(req, res, permissions_1.litellmTeamManagePermission);
|
|
632
|
+
if (!authz)
|
|
633
|
+
return;
|
|
634
|
+
const { teamId, owningGroup, actor, team: existing } = authz;
|
|
635
|
+
const v = (0, teamAdmin_1.validateTeamPatchInput)(req.body ?? {}, teamAdminCfg);
|
|
636
|
+
if (!v.ok) {
|
|
637
|
+
res.status(400).json({ error: v.error });
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
// Optimistic-concurrency guard (opt-in): when the client sends the
|
|
641
|
+
// `expectedUpdatedAtIso` it based its edit on, reject if the stored value
|
|
642
|
+
// has moved on — a concurrent edit landed in between. Callers that don't
|
|
643
|
+
// send it are unaffected (last-writer-wins, as before).
|
|
644
|
+
const expected = (req.body ?? {}).expectedUpdatedAtIso;
|
|
645
|
+
if (expected !== undefined &&
|
|
646
|
+
expected !== existing.metadata?.updated_at_iso) {
|
|
647
|
+
res.status(409).json({
|
|
648
|
+
error: 'This team was modified since you loaded it. Reload and re-apply your change.',
|
|
649
|
+
});
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const payload = {
|
|
653
|
+
team_id: teamId,
|
|
654
|
+
...v.value,
|
|
655
|
+
metadata: {
|
|
656
|
+
...(existing.metadata ?? {}),
|
|
657
|
+
updated_by_backstage_user: actor,
|
|
658
|
+
updated_at_iso: new Date().toISOString(),
|
|
659
|
+
},
|
|
660
|
+
};
|
|
661
|
+
try {
|
|
662
|
+
const r = await client.updateTeam(payload);
|
|
663
|
+
logger.info({
|
|
664
|
+
action: 'team.update',
|
|
665
|
+
actor,
|
|
666
|
+
teamId,
|
|
667
|
+
owningGroup,
|
|
668
|
+
});
|
|
669
|
+
res.json(r);
|
|
670
|
+
}
|
|
671
|
+
catch (err) {
|
|
672
|
+
sendTeamError(err, res);
|
|
673
|
+
}
|
|
674
|
+
});
|
|
675
|
+
// Delete a team the caller's group owns. Gated by litellm.teamAdmin.allowTeamDelete
|
|
676
|
+
// (default false — deletion is destructive: it orphans keys and revokes access
|
|
677
|
+
// for every member). Refuses when the team id is referenced by provisioning
|
|
678
|
+
// config unless ?force=true. Prefer blocking a team over deleting it.
|
|
679
|
+
router.delete('/teams/:teamId', async (req, res) => {
|
|
680
|
+
const authz = await authorizeTeamSubresource(req, res, permissions_1.litellmTeamDeletePermission);
|
|
681
|
+
if (!authz)
|
|
682
|
+
return;
|
|
683
|
+
const { teamId, owningGroup, actor } = authz;
|
|
684
|
+
if (!teamAdminCfg.allowTeamDelete) {
|
|
685
|
+
res.status(403).json({
|
|
686
|
+
error: 'Team deletion is disabled (set litellm.teamAdmin.allowTeamDelete: true). Block the team instead.',
|
|
687
|
+
});
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
const provisioningRefs = [
|
|
691
|
+
...provisioningDefaults.teams,
|
|
692
|
+
...roleConfigs.flatMap(r => r.teams ?? []),
|
|
693
|
+
];
|
|
694
|
+
const force = String(req.query.force ?? '') === 'true';
|
|
695
|
+
if (provisioningRefs.includes(teamId) && !force) {
|
|
696
|
+
res.status(409).json({
|
|
697
|
+
error: `Team ${teamId} is referenced by litellm.provisioning config; deleting it will break user provisioning. Re-send with ?force=true to override.`,
|
|
698
|
+
});
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
try {
|
|
702
|
+
await client.deleteTeam(teamId);
|
|
703
|
+
logger.info({ action: 'team.delete', actor, teamId, owningGroup, force });
|
|
704
|
+
res.json({ success: true });
|
|
705
|
+
}
|
|
706
|
+
catch (err) {
|
|
707
|
+
sendTeamError(err, res);
|
|
708
|
+
}
|
|
709
|
+
});
|
|
710
|
+
// Scoped listing for team admins: returns ONLY the teams whose
|
|
711
|
+
// `metadata.owning_group` equals the configured admin group, so a team admin
|
|
712
|
+
// can see and edit teams their group owns even when they are not a member.
|
|
713
|
+
// This deliberately never returns a global team list. Registered before any
|
|
714
|
+
// `/teams/:param` route so the literal path is not shadowed.
|
|
715
|
+
router.get('/teams/managed', async (req, res) => {
|
|
716
|
+
if (!requireTeamMgmt(res))
|
|
717
|
+
return;
|
|
718
|
+
const check = await (0, teamAdmin_1.assertTeamAdmin)({
|
|
719
|
+
req,
|
|
720
|
+
auth,
|
|
721
|
+
permissions,
|
|
722
|
+
catalogClient,
|
|
723
|
+
teamAdminGroup: teamAdminCfg.group,
|
|
724
|
+
permission: permissions_1.litellmTeamManagePermission,
|
|
725
|
+
logger,
|
|
726
|
+
});
|
|
727
|
+
if (!check.ok) {
|
|
728
|
+
res.status(check.status).json({ error: check.error });
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
try {
|
|
732
|
+
const all = await client.listTeams();
|
|
733
|
+
// TODO(multi-group): when litellm.teamAdmin.group becomes a list, resolve
|
|
734
|
+
// the caller's group set and filter with includes().
|
|
735
|
+
const owned = all.filter(t => typeof t.metadata?.owning_group === 'string' &&
|
|
736
|
+
t.metadata.owning_group === teamAdminCfg.group);
|
|
737
|
+
res.json(owned);
|
|
738
|
+
}
|
|
739
|
+
catch (err) {
|
|
740
|
+
sendTeamError(err, res);
|
|
741
|
+
}
|
|
742
|
+
});
|
|
743
|
+
// ── Team member routes ──────────────────────────────────────────────────
|
|
744
|
+
//
|
|
745
|
+
// Shared preamble: team-mgmt enabled → assertTeamAdmin(members permission) →
|
|
746
|
+
// load the team and enforce the owning-group object guard (same as PATCH
|
|
747
|
+
// /teams/:id). Returns the resolved teamId + owningGroup, or null when a
|
|
748
|
+
// response has already been sent.
|
|
749
|
+
// Shared preamble for a team sub-resource mutation (members, knowledge bases,
|
|
750
|
+
// MCP servers): team-mgmt enabled → assertTeamAdmin(<the given permission>) →
|
|
751
|
+
// load the team and enforce the owning-group object guard. Returns the
|
|
752
|
+
// resolved teamId + owningGroup + actor, or null when a response was sent.
|
|
753
|
+
async function authorizeTeamSubresource(req, res, permission) {
|
|
754
|
+
if (!requireTeamMgmt(res))
|
|
755
|
+
return null;
|
|
756
|
+
const check = await (0, teamAdmin_1.assertTeamAdmin)({
|
|
757
|
+
req,
|
|
758
|
+
auth,
|
|
759
|
+
permissions,
|
|
760
|
+
catalogClient,
|
|
761
|
+
teamAdminGroup: teamAdminCfg.group,
|
|
762
|
+
permission,
|
|
763
|
+
logger,
|
|
764
|
+
});
|
|
765
|
+
if (!check.ok) {
|
|
766
|
+
res.status(check.status).json({ error: check.error });
|
|
767
|
+
return null;
|
|
768
|
+
}
|
|
769
|
+
const { teamId } = req.params;
|
|
770
|
+
if (!teamId) {
|
|
771
|
+
res.status(400).json({ error: 'teamId is required' });
|
|
772
|
+
return null;
|
|
773
|
+
}
|
|
774
|
+
let existing;
|
|
775
|
+
try {
|
|
776
|
+
existing = await client.getTeamInfo(teamId);
|
|
777
|
+
}
|
|
778
|
+
catch (err) {
|
|
779
|
+
if (err instanceof client_1.LiteLLMUpstreamError && err.status === 404) {
|
|
780
|
+
res.status(404).json({ error: 'Team not found' });
|
|
781
|
+
return null;
|
|
782
|
+
}
|
|
783
|
+
sendTeamError(err, res);
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
const owningGroup = typeof existing.metadata?.owning_group === 'string'
|
|
787
|
+
? existing.metadata.owning_group
|
|
788
|
+
: undefined;
|
|
789
|
+
if (!owningGroup) {
|
|
790
|
+
res.status(403).json({
|
|
791
|
+
error: 'This team is not managed by Backstage team admins and cannot be edited here',
|
|
792
|
+
});
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
const owns = await (0, provisioning_1.isUserMemberOfGroup)(check.userEntityRef, owningGroup, catalogClient, auth, logger);
|
|
796
|
+
if (!owns) {
|
|
797
|
+
res
|
|
798
|
+
.status(403)
|
|
799
|
+
.json({ error: `Access denied: team is owned by ${owningGroup}` });
|
|
800
|
+
return null;
|
|
801
|
+
}
|
|
802
|
+
return { teamId, owningGroup, actor: check.userEntityRef, team: existing };
|
|
803
|
+
}
|
|
804
|
+
// Add a member to a team the caller's group owns. The member must (a) resolve
|
|
805
|
+
// to a real User in the Backstage catalog and (b) exist in LiteLLM — we
|
|
806
|
+
// provision them on the spot when auto-provisioning is enabled. Only the
|
|
807
|
+
// 'user' team role can be assigned from Backstage.
|
|
808
|
+
router.post('/teams/:teamId/members', async (req, res) => {
|
|
809
|
+
const authz = await authorizeTeamSubresource(req, res, permissions_1.litellmTeamMembersManagePermission);
|
|
810
|
+
if (!authz)
|
|
811
|
+
return;
|
|
812
|
+
const { teamId, owningGroup, actor } = authz;
|
|
813
|
+
const body = (req.body ?? {});
|
|
814
|
+
const userEntityRef = (body.userEntityRef ?? '').trim();
|
|
815
|
+
if (!userEntityRef) {
|
|
816
|
+
res.status(400).json({ error: 'userEntityRef is required' });
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
if (body.role !== undefined && body.role !== 'user') {
|
|
820
|
+
res.status(400).json({
|
|
821
|
+
error: "only the 'user' team role can be assigned from Backstage",
|
|
822
|
+
});
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
let maxBudgetInTeam;
|
|
826
|
+
if (body.maxBudgetInTeam !== undefined) {
|
|
827
|
+
if (typeof body.maxBudgetInTeam !== 'number' ||
|
|
828
|
+
!Number.isFinite(body.maxBudgetInTeam) ||
|
|
829
|
+
body.maxBudgetInTeam <= 0) {
|
|
830
|
+
res
|
|
831
|
+
.status(400)
|
|
832
|
+
.json({ error: 'maxBudgetInTeam must be a positive number' });
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
maxBudgetInTeam = body.maxBudgetInTeam;
|
|
836
|
+
}
|
|
837
|
+
// (a) the member must be a real User in the Backstage catalog
|
|
838
|
+
try {
|
|
839
|
+
const { token } = await auth.getPluginRequestToken({
|
|
840
|
+
onBehalfOf: await auth.getOwnServiceCredentials(),
|
|
841
|
+
targetPluginId: 'catalog',
|
|
842
|
+
});
|
|
843
|
+
const entity = await catalogClient.getEntityByRef(userEntityRef, {
|
|
844
|
+
token,
|
|
845
|
+
});
|
|
846
|
+
if (!entity || entity.kind !== 'User') {
|
|
847
|
+
res.status(400).json({
|
|
848
|
+
error: `${userEntityRef} is not a User in the Backstage catalog`,
|
|
849
|
+
});
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
catch (err) {
|
|
854
|
+
logger.warn(`Catalog lookup failed for ${userEntityRef}: ${err.message}`);
|
|
855
|
+
res.status(400).json({
|
|
856
|
+
error: `Could not verify ${userEntityRef} in the Backstage catalog`,
|
|
857
|
+
});
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
// (b) the member must exist in LiteLLM — provision on the spot if enabled
|
|
861
|
+
const litellmUserId = (0, provisioning_1.toLiteLLMUserId)(userEntityRef, userIdDomain);
|
|
862
|
+
try {
|
|
863
|
+
await (0, provisioning_1.getOrProvisionUser)(client, userEntityRef, litellmUserId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
|
|
864
|
+
}
|
|
865
|
+
catch (err) {
|
|
866
|
+
if (err instanceof provisioning_1.ProvisioningError) {
|
|
867
|
+
res.status(err.status).json(err.body);
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
throw err;
|
|
871
|
+
}
|
|
872
|
+
try {
|
|
873
|
+
await client.teamMemberAdd({
|
|
874
|
+
team_id: teamId,
|
|
875
|
+
user_id: litellmUserId,
|
|
876
|
+
role: 'user',
|
|
877
|
+
...(maxBudgetInTeam !== undefined && {
|
|
878
|
+
max_budget_in_team: maxBudgetInTeam,
|
|
879
|
+
}),
|
|
880
|
+
});
|
|
881
|
+
logger.info({
|
|
882
|
+
action: 'team.member.add',
|
|
883
|
+
actor,
|
|
884
|
+
teamId,
|
|
885
|
+
member: litellmUserId,
|
|
886
|
+
owningGroup,
|
|
887
|
+
});
|
|
888
|
+
const updated = await client.getTeamInfo(teamId);
|
|
889
|
+
res.json(updated);
|
|
890
|
+
}
|
|
891
|
+
catch (err) {
|
|
892
|
+
sendTeamError(err, res);
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
// Remove a member from a team the caller's group owns. The member ref comes
|
|
896
|
+
// from the `userEntityRef` query param (entity refs contain ':' and '/', so
|
|
897
|
+
// they can't be a path segment). No catalog re-check on removal.
|
|
898
|
+
router.delete('/teams/:teamId/members', async (req, res) => {
|
|
899
|
+
const authz = await authorizeTeamSubresource(req, res, permissions_1.litellmTeamMembersManagePermission);
|
|
900
|
+
if (!authz)
|
|
901
|
+
return;
|
|
902
|
+
const { teamId, owningGroup, actor } = authz;
|
|
903
|
+
const userEntityRef = String(req.query.userEntityRef ?? '').trim();
|
|
904
|
+
if (!userEntityRef) {
|
|
905
|
+
res
|
|
906
|
+
.status(400)
|
|
907
|
+
.json({ error: 'userEntityRef query parameter is required' });
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const litellmUserId = (0, provisioning_1.toLiteLLMUserId)(userEntityRef, userIdDomain);
|
|
911
|
+
try {
|
|
912
|
+
await client.teamMemberDelete({
|
|
913
|
+
team_id: teamId,
|
|
914
|
+
user_id: litellmUserId,
|
|
915
|
+
});
|
|
916
|
+
logger.info({
|
|
917
|
+
action: 'team.member.remove',
|
|
918
|
+
actor,
|
|
919
|
+
teamId,
|
|
920
|
+
member: litellmUserId,
|
|
921
|
+
owningGroup,
|
|
922
|
+
});
|
|
923
|
+
const updated = await client.getTeamInfo(teamId);
|
|
924
|
+
res.json(updated);
|
|
925
|
+
}
|
|
926
|
+
catch (err) {
|
|
927
|
+
sendTeamError(err, res);
|
|
928
|
+
}
|
|
929
|
+
});
|
|
930
|
+
// ── Knowledge-base (vector store) management ────────────────────────────
|
|
931
|
+
//
|
|
932
|
+
// Attaching a vector store to a team exposes its documents to every team
|
|
933
|
+
// key. Team admins may only reference stores that the operator has
|
|
934
|
+
// allowlisted in litellm.teamAdmin.allowedVectorStores — anything outside
|
|
935
|
+
// the allowlist is rejected, never silently dropped.
|
|
936
|
+
// The vector stores a team admin is allowed to attach (allowlist ∩ proxy).
|
|
937
|
+
router.get('/vector-stores', async (req, res) => {
|
|
938
|
+
if (!requireObjectPerms(res))
|
|
939
|
+
return;
|
|
940
|
+
const check = await (0, teamAdmin_1.assertTeamAdmin)({
|
|
941
|
+
req,
|
|
942
|
+
auth,
|
|
943
|
+
permissions,
|
|
944
|
+
catalogClient,
|
|
945
|
+
teamAdminGroup: teamAdminCfg.group,
|
|
946
|
+
permission: permissions_1.litellmTeamKnowledgebaseManagePermission,
|
|
947
|
+
logger,
|
|
948
|
+
});
|
|
949
|
+
if (!check.ok) {
|
|
950
|
+
res.status(check.status).json({ error: check.error });
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
const allowed = new Set(teamAdminCfg.allowedVectorStores);
|
|
954
|
+
try {
|
|
955
|
+
const all = await client.listVectorStores();
|
|
956
|
+
res.json(all.filter(s => allowed.has(s.id) || (s.name && allowed.has(s.name))));
|
|
957
|
+
}
|
|
958
|
+
catch (err) {
|
|
959
|
+
sendTeamError(err, res);
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
// Replace the set of knowledge bases attached to a team the caller's group
|
|
963
|
+
// owns. Body: { vector_stores: string[] }. Every id must be in the
|
|
964
|
+
// allowlist. The other object_permission facets (mcp_servers) are preserved.
|
|
965
|
+
router.put('/teams/:teamId/knowledge-bases', async (req, res) => {
|
|
966
|
+
if (!requireObjectPerms(res))
|
|
967
|
+
return;
|
|
968
|
+
const authz = await authorizeTeamSubresource(req, res, permissions_1.litellmTeamKnowledgebaseManagePermission);
|
|
969
|
+
if (!authz)
|
|
970
|
+
return;
|
|
971
|
+
const { teamId, owningGroup, actor, team } = authz;
|
|
972
|
+
const requested = (req.body?.vector_stores ?? []);
|
|
973
|
+
if (!Array.isArray(requested) ||
|
|
974
|
+
!requested.every(v => typeof v === 'string')) {
|
|
975
|
+
res
|
|
976
|
+
.status(400)
|
|
977
|
+
.json({ error: 'vector_stores must be an array of strings' });
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
const allowed = new Set(teamAdminCfg.allowedVectorStores);
|
|
981
|
+
const offenders = requested.filter(v => !allowed.has(v));
|
|
982
|
+
if (offenders.length) {
|
|
983
|
+
res.status(400).json({
|
|
984
|
+
error: `vector store(s) not in the allowed set for team admins: ${offenders.join(', ')}`,
|
|
985
|
+
});
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
const objectPermission = {
|
|
989
|
+
...(team.object_permission ?? {}),
|
|
990
|
+
vector_stores: requested,
|
|
991
|
+
};
|
|
992
|
+
try {
|
|
993
|
+
const updated = await client.updateTeam({
|
|
994
|
+
team_id: teamId,
|
|
995
|
+
object_permission: objectPermission,
|
|
996
|
+
// /team/update replaces metadata wholesale — re-send the merged
|
|
997
|
+
// object so owning_group / created_* survive and the change is
|
|
998
|
+
// attributed.
|
|
999
|
+
metadata: {
|
|
1000
|
+
...(team.metadata ?? {}),
|
|
1001
|
+
updated_by_backstage_user: actor,
|
|
1002
|
+
updated_at_iso: new Date().toISOString(),
|
|
1003
|
+
},
|
|
1004
|
+
});
|
|
1005
|
+
logger.info({
|
|
1006
|
+
action: 'team.knowledgebase.set',
|
|
1007
|
+
actor,
|
|
1008
|
+
teamId,
|
|
1009
|
+
owningGroup,
|
|
1010
|
+
vector_stores: requested,
|
|
1011
|
+
});
|
|
1012
|
+
res.json(updated);
|
|
1013
|
+
}
|
|
1014
|
+
catch (err) {
|
|
1015
|
+
sendTeamError(err, res);
|
|
1016
|
+
}
|
|
1017
|
+
});
|
|
1018
|
+
// ── MCP server management ──────────────────────────────────────────────
|
|
1019
|
+
//
|
|
1020
|
+
// Attaching an MCP server to a team grants every team key the ability to
|
|
1021
|
+
// invoke that server's tools through the model. Same allowlist + hard-reject
|
|
1022
|
+
// discipline as knowledge bases, plus a structured audit event on every
|
|
1023
|
+
// change so attaches are always attributable.
|
|
1024
|
+
router.get('/mcp-servers', async (req, res) => {
|
|
1025
|
+
if (!requireObjectPerms(res))
|
|
1026
|
+
return;
|
|
1027
|
+
const check = await (0, teamAdmin_1.assertTeamAdmin)({
|
|
1028
|
+
req,
|
|
1029
|
+
auth,
|
|
1030
|
+
permissions,
|
|
1031
|
+
catalogClient,
|
|
1032
|
+
teamAdminGroup: teamAdminCfg.group,
|
|
1033
|
+
permission: permissions_1.litellmTeamMcpManagePermission,
|
|
1034
|
+
logger,
|
|
1035
|
+
});
|
|
1036
|
+
if (!check.ok) {
|
|
1037
|
+
res.status(check.status).json({ error: check.error });
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
const allowed = new Set(teamAdminCfg.allowedMcpServers);
|
|
1041
|
+
try {
|
|
1042
|
+
const all = await client.listMcpServers();
|
|
1043
|
+
res.json(all.filter(s => allowed.has(s.id) || (s.name && allowed.has(s.name))));
|
|
1044
|
+
}
|
|
1045
|
+
catch (err) {
|
|
1046
|
+
sendTeamError(err, res);
|
|
1047
|
+
}
|
|
1048
|
+
});
|
|
1049
|
+
// Replace the set of MCP servers attached to a team the caller's group owns.
|
|
1050
|
+
// Body: { mcp_servers: string[] }. Every id must be allowlisted. The
|
|
1051
|
+
// vector_stores facet of object_permission is preserved.
|
|
1052
|
+
router.put('/teams/:teamId/mcp-servers', async (req, res) => {
|
|
1053
|
+
if (!requireObjectPerms(res))
|
|
1054
|
+
return;
|
|
1055
|
+
const authz = await authorizeTeamSubresource(req, res, permissions_1.litellmTeamMcpManagePermission);
|
|
1056
|
+
if (!authz)
|
|
1057
|
+
return;
|
|
1058
|
+
const { teamId, owningGroup, actor, team } = authz;
|
|
1059
|
+
const requested = (req.body?.mcp_servers ?? []);
|
|
1060
|
+
if (!Array.isArray(requested) ||
|
|
1061
|
+
!requested.every(v => typeof v === 'string')) {
|
|
1062
|
+
res
|
|
1063
|
+
.status(400)
|
|
1064
|
+
.json({ error: 'mcp_servers must be an array of strings' });
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
const allowed = new Set(teamAdminCfg.allowedMcpServers);
|
|
1068
|
+
const offenders = requested.filter(v => !allowed.has(v));
|
|
1069
|
+
if (offenders.length) {
|
|
1070
|
+
res.status(400).json({
|
|
1071
|
+
error: `MCP server(s) not in the allowed set for team admins: ${offenders.join(', ')}`,
|
|
1072
|
+
});
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
const before = team.object_permission?.mcp_servers ?? [];
|
|
1076
|
+
const objectPermission = {
|
|
1077
|
+
...(team.object_permission ?? {}),
|
|
1078
|
+
mcp_servers: requested,
|
|
1079
|
+
};
|
|
1080
|
+
try {
|
|
1081
|
+
const updated = await client.updateTeam({
|
|
1082
|
+
team_id: teamId,
|
|
1083
|
+
object_permission: objectPermission,
|
|
1084
|
+
// /team/update replaces metadata wholesale — re-send the merged
|
|
1085
|
+
// object so owning_group / created_* survive and the change is
|
|
1086
|
+
// attributed.
|
|
1087
|
+
metadata: {
|
|
1088
|
+
...(team.metadata ?? {}),
|
|
1089
|
+
updated_by_backstage_user: actor,
|
|
1090
|
+
updated_at_iso: new Date().toISOString(),
|
|
1091
|
+
},
|
|
1092
|
+
});
|
|
1093
|
+
logger.info({
|
|
1094
|
+
action: 'team.mcp.set',
|
|
1095
|
+
actor,
|
|
1096
|
+
teamId,
|
|
1097
|
+
owningGroup,
|
|
1098
|
+
before,
|
|
1099
|
+
after: requested,
|
|
1100
|
+
});
|
|
1101
|
+
res.json(updated);
|
|
1102
|
+
}
|
|
1103
|
+
catch (err) {
|
|
1104
|
+
sendTeamError(err, res);
|
|
1105
|
+
}
|
|
1106
|
+
});
|
|
463
1107
|
router.get('/teams/:teamId/usage', async (req, res) => {
|
|
464
1108
|
try {
|
|
465
1109
|
const { teamId } = req.params;
|