@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/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 catalogClient = new catalog_client_1.CatalogClient({ discoveryApi: discovery });
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;