@acarmisc/backstage-plugin-litellm-backend 0.15.0 → 0.16.1

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.
Files changed (53) hide show
  1. package/config.d.ts +124 -22
  2. package/dist/bridge.d.ts +43 -17
  3. package/dist/bridge.js +69 -13
  4. package/dist/client.d.ts +7 -2
  5. package/dist/client.js +35 -9
  6. package/dist/errors.d.ts +64 -0
  7. package/dist/errors.js +205 -0
  8. package/dist/http/respondTeam.d.ts +15 -0
  9. package/dist/http/respondTeam.js +27 -0
  10. package/dist/http/teamFetch.d.ts +14 -0
  11. package/dist/http/teamFetch.js +44 -0
  12. package/dist/index.cjs.js +4264 -3373
  13. package/dist/index.cjs.js.map +4 -4
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +15 -13
  16. package/dist/openapi.js +17 -0
  17. package/dist/plugin.js +2 -2
  18. package/dist/provisioning.d.ts +17 -11
  19. package/dist/provisioning.js +36 -22
  20. package/dist/router.d.ts +3 -4
  21. package/dist/router.js +188 -1349
  22. package/dist/routes/bridge.d.ts +8 -0
  23. package/dist/routes/bridge.js +135 -0
  24. package/dist/routes/config.d.ts +7 -0
  25. package/dist/routes/config.js +89 -0
  26. package/dist/routes/context.d.ts +55 -0
  27. package/dist/routes/context.js +2 -0
  28. package/dist/routes/keys.d.ts +8 -0
  29. package/dist/routes/keys.js +471 -0
  30. package/dist/routes/middleware/withUser.d.ts +18 -0
  31. package/dist/routes/middleware/withUser.js +41 -0
  32. package/dist/routes/objectPermissions.d.ts +3 -0
  33. package/dist/routes/objectPermissions.js +196 -0
  34. package/dist/routes/opencode.d.ts +3 -0
  35. package/dist/routes/opencode.js +239 -0
  36. package/dist/routes/teamUsage.d.ts +3 -0
  37. package/dist/routes/teamUsage.js +73 -0
  38. package/dist/routes/teams.d.ts +3 -0
  39. package/dist/routes/teams.js +367 -0
  40. package/dist/services/keyService.d.ts +61 -0
  41. package/dist/services/keyService.js +179 -0
  42. package/dist/services/modelAccess.d.ts +9 -0
  43. package/dist/services/modelAccess.js +30 -0
  44. package/dist/services/userInfoCache.d.ts +65 -0
  45. package/dist/services/userInfoCache.js +147 -0
  46. package/dist/teamAdmin.d.ts +2 -2
  47. package/dist/teamBudgetVisibility.d.ts +2 -1
  48. package/dist/teamBudgetVisibility.js +5 -5
  49. package/dist/types.cjs.js.map +1 -1
  50. package/dist/types.d.ts +8 -197
  51. package/package.json +6 -3
  52. package/dist/permissions.d.ts +0 -11
  53. package/dist/permissions.js +0 -57
package/dist/router.js CHANGED
@@ -37,88 +37,60 @@ exports.ProvisioningError = void 0;
37
37
  exports.createRouter = createRouter;
38
38
  const express_1 = __importStar(require("express"));
39
39
  const plugin_permission_common_1 = require("@backstage/plugin-permission-common");
40
+ const errors_1 = require("@backstage/errors");
40
41
  const catalog_client_1 = require("@backstage/catalog-client");
41
42
  const client_1 = require("./client");
42
- const openapi_1 = require("./openapi");
43
43
  const provisioning_1 = require("./provisioning");
44
44
  Object.defineProperty(exports, "ProvisioningError", { enumerable: true, get: function () { return provisioning_1.ProvisioningError; } });
45
- const bridge_1 = require("./bridge");
46
- const permissions_1 = require("./permissions");
45
+ const backstage_plugin_litellm_common_1 = require("@acarmisc/backstage-plugin-litellm-common");
47
46
  const teamAdmin_1 = require("./teamAdmin");
48
47
  const teamBudgetVisibility_1 = require("./teamBudgetVisibility");
49
48
  const opencode_1 = require("./opencode");
50
- const teamCreateInFlight = new Map();
51
- /** Backoff schedule for `withTeamFetchRetry` — 3 retries over ~1.3s total. */
52
- const TEAM_FETCH_RETRY_DELAYS_MS = [100, 300, 900];
53
- /**
54
- * Only 5xx (and network/timeout) failures are treated as transient — a 4xx
55
- * like "team not found" or "forbidden" is a deterministic answer from the
56
- * upstream and retrying it would just add latency for the same result.
57
- */
58
- function isRetryableTeamFetchError(err) {
59
- if (err instanceof client_1.LiteLLMUpstreamError) {
60
- return err.status >= 500;
61
- }
62
- return true;
63
- }
64
- /**
65
- * Retries a LiteLLM team-info fetch with the given backoff schedule.
66
- *
67
- * LiteLLM proxies are commonly run behind multiple replicas; a request can
68
- * land on a replica that hasn't yet caught up with a very recent write
69
- * (team create/update, membership change), or on one that is transiently
70
- * unhealthy. Both surface as a failed `getTeamInfo` call that would
71
- * otherwise be swallowed (e.g. GET /teams silently drops the team from the
72
- * response) or bubble up as a spurious 500/404 right after a write this
73
- * same request just made. A short retry smooths over that window without
74
- * masking a genuine, persistent failure.
75
- */
76
- async function withTeamFetchRetry(fn, delaysMs = TEAM_FETCH_RETRY_DELAYS_MS) {
77
- for (let attempt = 0;; attempt++) {
78
- try {
79
- return await fn();
80
- }
81
- catch (err) {
82
- if (attempt >= delaysMs.length || !isRetryableTeamFetchError(err)) {
83
- throw err;
84
- }
85
- await new Promise(resolve => setTimeout(resolve, delaysMs[attempt]));
86
- }
87
- }
88
- }
89
- /**
90
- * Only loopback http://localhost:<port>/callback redirect URIs are accepted —
91
- * the OpenCode plugin runs its callback listener on 127.0.0.1. This
92
- * keeps the flow from being usable as an open redirect.
93
- */
94
- function isValidRedirectUri(uri) {
95
- if (!uri)
96
- return false;
97
- try {
98
- const url = new URL(uri);
99
- return (url.protocol === 'http:' &&
100
- (url.hostname === 'localhost' || url.hostname === '127.0.0.1') &&
101
- url.pathname === '/callback');
102
- }
103
- catch {
104
- return false;
105
- }
106
- }
49
+ const errors_2 = require("./errors");
50
+ const withUser_1 = require("./routes/middleware/withUser");
51
+ const userInfoCache_1 = require("./services/userInfoCache");
52
+ const config_1 = require("./routes/config");
53
+ const keys_1 = require("./routes/keys");
54
+ const opencode_2 = require("./routes/opencode");
55
+ const teams_1 = require("./routes/teams");
56
+ const objectPermissions_1 = require("./routes/objectPermissions");
57
+ const teamUsage_1 = require("./routes/teamUsage");
58
+ const bridge_1 = require("./routes/bridge");
59
+ const teamFetch_1 = require("./http/teamFetch");
107
60
  async function createRouter(options) {
108
61
  const { config, logger, auth, discovery, permissions } = options;
109
62
  const baseUrl = config.getString('litellm.baseUrl');
110
63
  const masterKey = config.getString('litellm.masterKey');
111
64
  const userIdDomain = config.getOptionalString('litellm.userIdDomain');
112
65
  // Publicly reachable LiteLLM proxy URL, used to build ready-to-paste
113
- // snippets in the frontend. Falls back to baseUrl (the internal URL the
114
- // backend calls) when the operator has not configured a public one.
115
- const publicBaseUrl = config.getOptionalString('litellm.publicBaseUrl') ?? baseUrl;
116
- const client = options.client ?? new client_1.LiteLLMClient({ baseUrl, masterKey });
66
+ // snippets in the frontend. Only exposed if explicitly configured.
67
+ // When unset, returned as null to the FE.
68
+ const publicBaseUrl = config.getOptionalString('litellm.publicBaseUrl') ?? null;
69
+ // Support contact for unprovisioned users (optional).
70
+ const supportContact = config.getOptionalString('litellm.supportContact');
71
+ const baseClient = options.client ?? new client_1.LiteLLMClient({ baseUrl, masterKey });
72
+ // Wrap client with userInfoCache. TTL is configurable (default 10s, 0 disables).
73
+ const userInfoCacheTtlSeconds = config.getOptionalNumber('litellm.cache.userInfoTtlSeconds') ?? 10;
74
+ const client = userInfoCacheTtlSeconds > 0
75
+ ? (0, userInfoCache_1.withUserInfoCache)(baseClient, { ttlMs: userInfoCacheTtlSeconds * 1000 })
76
+ : baseClient;
117
77
  const { enabled: provisioningEnabled, defaults: provisioningDefaults } = (0, provisioning_1.readProvisioningDefaults)(config);
118
78
  const roleConfigs = (0, provisioning_1.readRoleConfigs)(config);
119
79
  const auditGroup = config.getOptionalString('litellm.audit.group');
120
80
  const allowUnlimitedBudget = config.getOptionalBoolean('litellm.keyGeneration.allowUnlimitedBudget') ?? false;
121
81
  const teamRequired = config.getOptionalBoolean('litellm.keyGeneration.teamRequired') ?? true;
82
+ const allowOwnerResetSpend = config.getOptionalBoolean('litellm.keys.allowOwnerResetSpend') ?? false;
83
+ // Key validation ceilings (PR-2)
84
+ const keyMaxBudget = config.getOptionalNumber('litellm.keys.maxBudget') ?? 100;
85
+ const keyMaxTpm = config.getOptionalNumber('litellm.keys.maxTpm') ?? 100000;
86
+ const keyMaxRpm = config.getOptionalNumber('litellm.keys.maxRpm') ?? 1000;
87
+ const keyAllowedDurations = config.getOptionalStringArray('litellm.keys.allowedDurations') ?? backstage_plugin_litellm_common_1.DEFAULT_KEY_DURATIONS;
88
+ const keyValidationConfig = {
89
+ maxBudget: keyMaxBudget,
90
+ maxTpm: keyMaxTpm,
91
+ maxRpm: keyMaxRpm,
92
+ allowedDurations: keyAllowedDurations,
93
+ };
122
94
  const teamMgmtEnabled = (0, teamAdmin_1.isTeamManagementEnabled)(config);
123
95
  const objectPermsEnabled = (0, teamAdmin_1.isObjectPermissionsEnabled)(config);
124
96
  const teamAdminCfg = (0, teamAdmin_1.readTeamAdminConfig)(config);
@@ -137,1312 +109,179 @@ async function createRouter(options) {
137
109
  teamBudgetVisibility.hideTeamBudgetForManagers) {
138
110
  logger.info(`LiteLLM team budget hiding enabled — members: ${teamBudgetVisibility.hideTeamBudgetForMembers}, managers: ${teamBudgetVisibility.hideTeamBudgetForManagers}`);
139
111
  }
140
- const router = (0, express_1.Router)();
141
- // JSON body parser. Without this, every POST/PUT endpoint sees an empty
142
- // req.body. Backstage's httpRouter does not apply a body parser at the
143
- // plugin-router level, so each plugin must attach its own.
144
- router.use(express_1.default.json());
145
- router.get('/health', (_req, res) => {
146
- res.json({ status: 'ok', provisioning: provisioningEnabled });
147
- });
148
- // Exposes the public LiteLLM proxy URL so the frontend can build
149
- // ready-to-paste curl / OpenAI-SDK snippets for freshly generated keys.
150
- router.get('/config', (_req, res) => {
151
- res.json({
152
- baseUrl: publicBaseUrl,
153
- keyGeneration: { allowUnlimitedBudget, teamRequired },
154
- opencode: { enabled: opencodeCfg.enabled },
155
- teamManagement: {
156
- enabled: teamMgmtEnabled,
157
- maxBudgetCeiling: teamAdminCfg.maxBudgetCeiling,
158
- allowUnlimitedBudget: teamAdminCfg.allowUnlimitedBudget,
159
- objectPermissionsEnabled: objectPermsEnabled,
160
- },
161
- display: {
162
- hideTeamBudgetForMembers: teamBudgetVisibility.hideTeamBudgetForMembers,
163
- hideTeamBudgetForManagers: teamBudgetVisibility.hideTeamBudgetForManagers,
164
- },
165
- });
166
- });
167
- // Self-hosted OpenAPI 3.1 contract — lets integrators read a spec instead
168
- // of router.ts. No swagger-ui dependency; serve the JSON and point external
169
- // renderers (Stoplight, Swagger UI hosted elsewhere) at this endpoint.
170
- router.get('/openapi.json', (_req, res) => {
171
- res.json(openapi_1.openApiSpec);
172
- });
173
- // Provisioning dry-run: resolves which role a Backstage group maps to and
174
- // echoes the effective defaults, collapsing the config → deploy → test loop
175
- // into one request. Admin-gated by the audit group (same RBAC as /audit).
176
- router.get('/provisioning/preview', async (req, res) => {
177
- if (!auditGroup) {
178
- res.status(403).json({ error: 'Preview is not configured (litellm.audit.group not set)' });
179
- return;
180
- }
181
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
182
- if (!tokenEntityRef) {
183
- res.status(401).json({ error: 'Authentication required' });
184
- return;
185
- }
186
- const allowed = await (0, provisioning_1.isUserMemberOfGroup)(tokenEntityRef, auditGroup, catalogClient, auth, logger);
187
- if (!allowed) {
188
- res.status(403).json({ error: 'Access denied: not a member of the audit group' });
189
- return;
190
- }
191
- const group = req.query.group?.trim();
192
- if (!group) {
193
- res.status(400).json({ error: 'group query parameter is required (e.g. group=group:default/ai-platform)' });
194
- return;
195
- }
196
- if (!roleConfigs.length) {
197
- res.json({
198
- group,
199
- matched_role: null,
200
- effective_defaults: provisioningDefaults,
201
- note: 'No litellm.provisioning.roles configured — every group receives the base defaults.',
202
- });
203
- return;
204
- }
205
- try {
206
- const matched = roleConfigs.find(rc => rc.group === group);
207
- const effective = matched
208
- ? (0, provisioning_1.applyRoleOverrides)(provisioningDefaults, matched)
209
- : provisioningDefaults;
210
- res.json({
211
- group,
212
- matched_role: matched?.group ?? null,
213
- effective_defaults: effective,
214
- });
215
- }
216
- catch (error) {
217
- logger.error('Failed to resolve provisioning preview', error);
218
- res.status(500).json({ error: error.message });
219
- }
220
- });
221
- router.get('/user/info', async (req, res) => {
222
- try {
223
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
224
- const userId = tokenEntityRef
225
- ? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
226
- : req.query.user_id;
227
- const userInfo = await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
228
- const canViewAudit = auditGroup && tokenEntityRef
229
- ? await (0, provisioning_1.isUserMemberOfGroup)(tokenEntityRef, auditGroup, catalogClient, auth, logger)
230
- : false;
231
- res.json({ ...userInfo, can_view_audit: canViewAudit });
232
- }
233
- catch (error) {
234
- if (error instanceof provisioning_1.ProvisioningError) {
235
- res.status(error.status).json(error.body);
236
- return;
237
- }
238
- logger.error('Failed to fetch user info', error);
239
- res.status(500).json({ error: error.message });
240
- }
241
- });
242
- router.get('/keys', async (req, res) => {
243
- try {
244
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
245
- const userId = tokenEntityRef
246
- ? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
247
- : req.query.user_id;
248
- await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
249
- const keys = await client.listKeys(userId);
250
- res.json(keys);
251
- }
252
- catch (error) {
253
- if (error instanceof provisioning_1.ProvisioningError) {
254
- res.status(error.status).json(error.body);
255
- return;
112
+ // Build shared RouterContext with config and helpers
113
+ const ctx = {
114
+ client,
115
+ catalogClient,
116
+ auth,
117
+ permissions,
118
+ logger,
119
+ baseUrl,
120
+ publicBaseUrl,
121
+ supportContact,
122
+ userIdDomain,
123
+ provisioningEnabled,
124
+ provisioningDefaults,
125
+ roleConfigs,
126
+ auditGroup,
127
+ allowUnlimitedBudget,
128
+ teamRequired,
129
+ allowOwnerResetSpend,
130
+ keyValidationConfig,
131
+ teamMgmtEnabled,
132
+ objectPermsEnabled,
133
+ teamAdminCfg,
134
+ teamBudgetVisibility,
135
+ opencodeCfg,
136
+ // Helper functions - defined inline below this object
137
+ authorizeKeyAction: async (req, keyId) => {
138
+ // Express middleware should have populated req.res.locals with user info
139
+ if (!req.res || !req.res.locals.tokenEntityRef || !req.res.locals.userId) {
140
+ throw new errors_1.NotAllowedError('User identity not found in request');
141
+ }
142
+ const tokenEntityRef = req.res.locals.tokenEntityRef;
143
+ const userId = req.res.locals.userId;
144
+ const ownKeys = await client.listKeys(userId);
145
+ const key = ownKeys.find(k => (k.token ?? k.key) === keyId);
146
+ if (!key) {
147
+ throw new errors_1.NotAllowedError('Access denied: key does not belong to the caller');
148
+ }
149
+ return { tokenEntityRef, userId, key };
150
+ },
151
+ sendOwnershipError: (err, res) => {
152
+ // Handle NotAllowedError from authorizeKeyAction
153
+ if (err instanceof errors_1.NotAllowedError) {
154
+ res.status(403).json({ error: err.message });
155
+ return true;
156
+ }
157
+ // Fallback for legacy errors with status and body
158
+ if (err && typeof err === 'object' && 'status' in err && 'body' in err) {
159
+ const errObj = err;
160
+ if (typeof errObj.status === 'number' && errObj.body) {
161
+ res.status(errObj.status).json(errObj.body);
162
+ return true;
163
+ }
256
164
  }
257
- logger.error('Failed to list keys', error);
258
- res.status(500).json({ error: error.message });
259
- }
260
- });
261
- // ── Key ownership guard ──────────────────────────────────────────────────
262
- //
263
- // Every key-mutation route below runs under the LiteLLM master key, so
264
- // without an explicit check any authenticated Backstage user could act on
265
- // any key whose token they learn (audit logs expose truncated tokens, and
266
- // anyone who has held the raw key knows it). We fetch the caller's own key
267
- // list and 403 if the target token isn't in it.
268
- //
269
- // Returns the caller's LiteLLM user_id (when resolvable) so handlers can
270
- // stamp it in logs without re-deriving it.
271
- async function authorizeKeyAction(req, keyId) {
272
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
273
- const userId = tokenEntityRef
274
- ? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
275
- : req.query.user_id;
276
- if (!userId) {
277
- throw Object.assign(new Error('Cannot verify key ownership without an authenticated user'), {
278
- status: 403,
279
- body: { error: 'Cannot verify key ownership without an authenticated user' },
165
+ return false;
166
+ },
167
+ assertPermission: async (req, permission) => {
168
+ const credentials = await (0, provisioning_1.resolveCredentials)(req, auth);
169
+ if (!credentials)
170
+ return false;
171
+ const [decision] = await permissions.authorize([{ permission }], {
172
+ credentials,
280
173
  });
281
- }
282
- const ownKeys = await client.listKeys(userId);
283
- const owns = ownKeys.some(k => (k.token ?? k.key) === keyId);
284
- if (!owns) {
285
- throw Object.assign(new Error('Access denied: key does not belong to the caller'), {
286
- status: 403,
287
- body: { error: 'Access denied: key does not belong to the caller' },
174
+ return decision.result === plugin_permission_common_1.AuthorizeResult.ALLOW;
175
+ },
176
+ sendPermissionDenied: (res, permission) => {
177
+ res.status(403).json({
178
+ error: `Access denied: missing permission "${permission.name}"`,
288
179
  });
289
- }
290
- return { tokenEntityRef, userId };
291
- }
292
- // Normalize the ownership-guard rejection shape into a response.
293
- function sendOwnershipError(err, res) {
294
- if (err && typeof err.status === 'number' && err.body) {
295
- res.status(err.status).json(err.body);
296
- return true;
297
- }
298
- return false;
299
- }
300
- // ── Permission-framework guard ───────────────────────────────────────────
301
- //
302
- // Coarse-grained "is this identity allowed to do this at all" check, layered
303
- // on top of (not replacing) the ownership guard above. With no permission
304
- // policy installed (the default for a fresh Backstage instance) this always
305
- // resolves ALLOW, so behavior is unchanged until an operator wires up
306
- // @backstage-community/plugin-rbac or a custom PermissionPolicy.
307
- async function assertPermission(req, permission) {
308
- const credentials = await (0, provisioning_1.resolveCredentials)(req, auth);
309
- if (!credentials)
310
- return false;
311
- const [decision] = await permissions.authorize([{ permission }], {
312
- credentials,
313
- });
314
- return decision.result === plugin_permission_common_1.AuthorizeResult.ALLOW;
315
- }
316
- function sendPermissionDenied(res, permission) {
317
- res.status(403).json({
318
- error: `Access denied: missing permission "${permission.name}"`,
319
- });
320
- }
321
- router.post('/keys/generate', async (req, res) => {
322
- try {
323
- // Only alias is hard-required. max_budget is optional: a positive
324
- // number caps spend, while null/undefined means "unlimited" (LiteLLM
325
- // supports null). An empty models array is intentional — in LiteLLM
326
- // `models: []` means "all models the user can access" which is the
327
- // desired default. Forcing a selection up front is too restrictive for
328
- // the common case.
329
- const body = (req.body ?? {});
330
- const missing = [];
331
- if (!body.alias?.trim())
332
- missing.push('alias');
333
- if (body.max_budget !== null &&
334
- body.max_budget !== undefined &&
335
- (typeof body.max_budget !== 'number' || body.max_budget <= 0)) {
336
- missing.push('max_budget (positive number, or null for unlimited)');
337
- }
338
- if (missing.length) {
339
- res.status(400).json({
340
- error: 'Missing required fields',
341
- hint: `Required: ${missing.join(', ')}`,
342
- });
343
- return;
344
- }
345
- if (!(await assertPermission(req, permissions_1.litellmKeyCreatePermission))) {
346
- sendPermissionDenied(res, permissions_1.litellmKeyCreatePermission);
347
- return;
348
- }
349
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
350
- const resolvedUserId = tokenEntityRef
351
- ? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
352
- : undefined;
353
- if (resolvedUserId) {
354
- await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, resolvedUserId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
355
- }
356
- // Stamp ownership into LiteLLM key metadata. LiteLLM's native
357
- // `created_by` column is only populated when the caller authenticates
358
- // via JWT/SSO; we always call with the master key, so that column
359
- // stays null. Enriching `metadata` makes the owner identity visible
360
- // in LiteLLM's UI and queryable via API.
361
- const profile = tokenEntityRef
362
- ? await (0, provisioning_1.resolveUserProfile)(tokenEntityRef, catalogClient, auth, logger)
363
- : {};
364
- const enrichedMetadata = {
365
- ...(body.metadata ?? {}),
366
- created_by_backstage_user: tokenEntityRef ?? 'unknown',
367
- ...(profile.email && { created_by_email: profile.email }),
368
- ...(profile.displayName && {
369
- created_by_display_name: profile.displayName,
370
- }),
371
- created_via: 'backstage',
372
- created_at_iso: new Date().toISOString(),
373
- };
374
- const request = {
375
- ...body,
376
- metadata: enrichedMetadata,
377
- ...(resolvedUserId && { user_id: resolvedUserId }),
378
- };
379
- const result = await client.generateKey(request);
380
- logger.info({ action: 'key.generate', userId: resolvedUserId ?? 'unknown', keyAlias: body.alias });
381
- res.json(result);
382
- }
383
- catch (error) {
384
- if (error instanceof provisioning_1.ProvisioningError) {
385
- res.status(error.status).json(error.body);
386
- return;
387
- }
388
- // LiteLLM's enterprise team-key hook silently overrides the requested
389
- // `duration` with the team's `metadata.team_member_key_duration` when a
390
- // team_id is set, before parsing it. A malformed value there 500s with
391
- // this exact message regardless of what we sent — surface that instead
392
- // of the opaque passthrough so it's actionable from the LiteLLM side.
393
- if (typeof error.message === 'string' &&
394
- error.message.includes('Invalid duration format') &&
395
- req.body?.team_id) {
396
- res.status(502).json({
397
- error: 'LiteLLM rejected the key duration for this team. The team has a ' +
398
- '"Team Member Key Duration" set in LiteLLM that is not in a valid ' +
399
- '<number><unit> format (e.g. "30d") and overrides whatever duration ' +
400
- 'is requested. Fix or clear it in LiteLLM under Teams → this team → ' +
401
- 'Team Settings, then retry.',
402
- teamId: req.body.team_id,
403
- });
404
- return;
405
- }
406
- // Preserve upstream LiteLLM status + structured error (e.g. a 400 with
407
- // param "key_alias" for a duplicate alias) so the frontend can
408
- // distinguish "alias taken" from a genuine server error.
409
- if (error instanceof client_1.LiteLLMUpstreamError) {
410
- res.status(error.status).json({
411
- error: error.message,
412
- ...(error.param ? { param: error.param } : {}),
180
+ },
181
+ requireTeamMgmt: (res) => {
182
+ if (!teamMgmtEnabled) {
183
+ res.status(403).json({
184
+ error: 'Team management is disabled (requires permission.enabled and litellm.teamAdmin.group)',
413
185
  });
414
- return;
186
+ return false;
415
187
  }
416
- logger.error('Failed to generate key', error);
417
- res.status(500).json({ error: error.message });
418
- }
419
- });
420
- // ── OpenCode SSO-connect ─────────────────────────────────────────────────
421
- //
422
- // Client side: the opencode-portal-auth plugin. Handshake contract (see
423
- // that package's README):
424
- //
425
- // GET /opencode/connect?team=<id>&redirect_uri=http://localhost:<port>/callback
426
- // → (SSO-authenticated) generate/reuse a virtual key bound to the
427
- // user and optional team → 302 {redirect_uri}?key=<api-key>
428
- //
429
- // Mounted only when litellm.opencode.enabled is true. Key creation is
430
- // gated by the same litellmKeyCreatePermission as POST /keys/generate.
431
- if (opencodeCfg.enabled) {
432
- logger.info(`OpenCode connect endpoint enabled — duration=${opencodeCfg.keyDuration}, maxBudget=$${opencodeCfg.maxBudget}`);
433
- router.get('/opencode/connect', async (req, res) => {
434
- try {
435
- // The request comes from the browser (user session), so SSO
436
- // auth applies exactly like for any other UI route.
437
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
438
- if (!tokenEntityRef) {
439
- res.status(401).json({ error: 'Authentication required' });
440
- return;
441
- }
442
- const redirectUri = req.query.redirect_uri;
443
- if (!isValidRedirectUri(redirectUri)) {
444
- res.status(400).json({
445
- error: 'Invalid redirect_uri — expected http://localhost:<port>/callback',
446
- });
447
- return;
448
- }
449
- if (!(await assertPermission(req, permissions_1.litellmKeyCreatePermission))) {
450
- sendPermissionDenied(res, permissions_1.litellmKeyCreatePermission);
451
- return;
452
- }
453
- const userId = (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain);
454
- const userInfo = await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
455
- // Optional team binding: must be one of the user's own teams so
456
- // the connect flow can never mint a key billed to someone else.
457
- const teamId = req.query.team;
458
- if (teamId) {
459
- const userTeams = userInfo?.teams ?? [];
460
- if (!userTeams.includes(teamId)) {
461
- res.status(403).json({
462
- error: 'Access denied: team is not one of your teams',
463
- team: teamId,
464
- });
465
- return;
466
- }
467
- }
468
- else if (opencodeCfg.requireTeam) {
469
- res.status(400).json({ error: 'A team is required for this connection' });
470
- return;
471
- }
472
- // Alias is stable per user+team so re-connecting rotates the same
473
- // logical key slot instead of accumulating duplicates. LiteLLM
474
- // rejects duplicate aliases, so on conflict we look up the existing
475
- // key and reuse it (the user gets a working connection either way).
476
- const alias = teamId
477
- ? `opencode-${userId}-${teamId}`
478
- : `opencode-${userId}`;
479
- const existing = await client
480
- .listKeys(userId)
481
- .catch(() => []);
482
- // Reuse a healthy existing key for this slot rather than failing
483
- // on the alias collision LiteLLM would raise. Blocked keys are
484
- // skipped so a blocked slot can be recovered by re-connecting.
485
- const reusable = existing.find(k => k.key_alias === alias &&
486
- !k.blocked &&
487
- k.user_id === userId);
488
- let key;
489
- if (reusable?.token ?? reusable?.key) {
490
- key = reusable.token ?? reusable.key;
491
- }
492
- else {
493
- const profile = await (0, provisioning_1.resolveUserProfile)(tokenEntityRef, catalogClient, auth, logger);
494
- const result = await client.generateKey({
495
- alias,
496
- duration: opencodeCfg.keyDuration,
497
- max_budget: opencodeCfg.maxBudget,
498
- team_id: teamId,
499
- metadata: {
500
- ...opencodeCfg.metadata,
501
- created_via: 'opencode-connect',
502
- created_by_backstage_user: tokenEntityRef,
503
- ...(profile.email && { created_by_email: profile.email }),
504
- ...(teamId && { opencode_team: teamId }),
505
- },
506
- user_id: userId,
507
- });
508
- if (!result.key) {
509
- res.status(502).json({ error: 'LiteLLM returned no key material' });
510
- return;
511
- }
512
- key = result.key;
513
- }
514
- const url = new URL(redirectUri);
515
- url.searchParams.set('key', key);
516
- if (teamId)
517
- url.searchParams.set('team', teamId);
518
- logger.info({
519
- action: 'opencode.connect',
520
- userId,
521
- team: teamId ?? null,
522
- reused: Boolean(reusable?.token ?? reusable?.key),
188
+ return true;
189
+ },
190
+ requireObjectPerms: (res) => {
191
+ if (!ctx.requireTeamMgmt(res))
192
+ return false;
193
+ if (!objectPermsEnabled) {
194
+ res.status(403).json({
195
+ error: 'Knowledge-base / MCP management is disabled (set litellm.teamAdmin.objectPermissions.enabled: true, with a permission policy and the allowlists in place)',
523
196
  });
524
- res.redirect(302, url.href);
197
+ return false;
525
198
  }
526
- catch (error) {
527
- if (error instanceof provisioning_1.ProvisioningError) {
528
- res.status(error.status).json(error.body);
529
- return;
530
- }
531
- logger.error('OpenCode connect failed', error);
532
- res.status(500).json({ error: error.message });
533
- }
534
- });
535
- }
536
- router.post('/keys/:keyId/update', async (req, res) => {
537
- try {
538
- const { keyId } = req.params;
539
- if (!keyId) {
540
- res.status(400).json({ error: 'keyId is required' });
541
- return;
542
- }
543
- if (!(await assertPermission(req, permissions_1.litellmKeyManagePermission))) {
544
- sendPermissionDenied(res, permissions_1.litellmKeyManagePermission);
545
- return;
546
- }
547
- const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
548
- const request = { ...req.body, key: keyId };
549
- const result = await client.updateKey(request);
550
- logger.info({ action: 'key.update', userId: tokenEntityRef ?? 'unknown', keyId });
551
- res.json(result);
552
- }
553
- catch (error) {
554
- if (sendOwnershipError(error, res))
555
- return;
556
- logger.error('Failed to update key', error);
557
- res.status(500).json({ error: error.message });
558
- }
559
- });
560
- router.delete('/keys/:keyId', async (req, res) => {
561
- try {
562
- const { keyId } = req.params;
563
- if (!keyId) {
564
- res.status(400).json({ error: 'keyId is required' });
565
- return;
566
- }
567
- if (!(await assertPermission(req, permissions_1.litellmKeyRevokePermission))) {
568
- sendPermissionDenied(res, permissions_1.litellmKeyRevokePermission);
569
- return;
570
- }
571
- const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
572
- await client.deleteKeys({ keys: [keyId] });
573
- logger.info({ action: 'key.delete', userId: tokenEntityRef ?? 'unknown', keyId });
574
- res.json({ success: true });
575
- }
576
- catch (error) {
577
- if (sendOwnershipError(error, res))
578
- return;
579
- logger.error('Failed to delete key', error);
580
- res.status(500).json({ error: error.message });
581
- }
582
- });
583
- router.post('/keys/:keyId/block', async (req, res) => {
584
- try {
585
- const { keyId } = req.params;
586
- if (!(await assertPermission(req, permissions_1.litellmKeyManagePermission))) {
587
- sendPermissionDenied(res, permissions_1.litellmKeyManagePermission);
588
- return;
589
- }
590
- const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
591
- await client.blockKey(keyId);
592
- logger.info({ action: 'key.block', userId: tokenEntityRef ?? 'unknown', keyId });
593
- res.json({ success: true });
594
- }
595
- catch (error) {
596
- if (sendOwnershipError(error, res))
597
- return;
598
- logger.error('Failed to block key', error);
599
- res.status(500).json({ error: error.message });
600
- }
601
- });
602
- router.post('/keys/:keyId/unblock', async (req, res) => {
603
- try {
604
- const { keyId } = req.params;
605
- if (!(await assertPermission(req, permissions_1.litellmKeyManagePermission))) {
606
- sendPermissionDenied(res, permissions_1.litellmKeyManagePermission);
607
- return;
608
- }
609
- const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
610
- await client.unblockKey(keyId);
611
- logger.info({ action: 'key.unblock', userId: tokenEntityRef ?? 'unknown', keyId });
612
- res.json({ success: true });
613
- }
614
- catch (error) {
615
- if (sendOwnershipError(error, res))
616
- return;
617
- logger.error('Failed to unblock key', error);
618
- res.status(500).json({ error: error.message });
619
- }
620
- });
621
- router.post('/keys/:keyId/reset_spend', async (req, res) => {
622
- try {
623
- const { keyId } = req.params;
624
- if (!(await assertPermission(req, permissions_1.litellmKeyManagePermission))) {
625
- sendPermissionDenied(res, permissions_1.litellmKeyManagePermission);
626
- return;
199
+ return true;
200
+ },
201
+ sendTeamError: (err, res) => {
202
+ (0, errors_2.sendError)(res, err, logger, 'team operation');
203
+ },
204
+ authorizeTeamSubresource: async (req, res, permission) => {
205
+ if (!ctx.requireTeamMgmt(res))
206
+ return null;
207
+ if (!teamAdminCfg.group) {
208
+ res.status(500).json({ error: 'Team management is misconfigured (group is missing)' });
209
+ return null;
627
210
  }
628
- const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
629
- await client.resetKeySpend(keyId);
630
- logger.info({ action: 'key.reset_spend', userId: tokenEntityRef ?? 'unknown', keyId });
631
- res.json({ success: true });
632
- }
633
- catch (error) {
634
- if (sendOwnershipError(error, res))
635
- return;
636
- logger.error('Failed to reset key spend', error);
637
- res.status(500).json({ error: error.message });
638
- }
639
- });
640
- router.get('/audit', async (req, res) => {
641
- if (!auditGroup) {
642
- res.status(403).json({ error: 'Audit log is not configured (litellm.audit.group not set)' });
643
- return;
644
- }
645
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
646
- if (!tokenEntityRef) {
647
- res.status(401).json({ error: 'Authentication required' });
648
- return;
649
- }
650
- const allowed = await (0, provisioning_1.isUserMemberOfGroup)(tokenEntityRef, auditGroup, catalogClient, auth, logger);
651
- if (!allowed) {
652
- res.status(403).json({ error: 'Access denied: not a member of the audit group' });
653
- return;
654
- }
655
- if (!(await assertPermission(req, permissions_1.litellmAuditReadPermission))) {
656
- sendPermissionDenied(res, permissions_1.litellmAuditReadPermission);
657
- return;
658
- }
659
- try {
660
- const { page, page_size, start_date, end_date, action, table_name, changed_by } = req.query;
661
- const result = await client.getAuditLogs({
662
- page: page ? Number(page) : undefined,
663
- page_size: page_size ? Number(page_size) : 25,
664
- start_date,
665
- end_date,
666
- action,
667
- table_name,
668
- changed_by,
211
+ const check = await (0, teamAdmin_1.assertTeamAdmin)({
212
+ req,
213
+ auth,
214
+ permissions,
215
+ catalogClient,
216
+ teamAdminGroup: teamAdminCfg.group,
217
+ permission,
218
+ logger,
669
219
  });
670
- res.json(result);
671
- }
672
- catch (error) {
673
- logger.error('Failed to fetch audit logs', error);
674
- res.status(500).json({ error: error.message });
675
- }
676
- });
677
- router.get('/models', async (_req, res) => {
678
- try {
679
- const models = await client.listModels();
680
- res.json(models);
681
- }
682
- catch (error) {
683
- logger.error('Failed to list models', error);
684
- res.status(500).json({ error: error.message });
685
- }
686
- });
687
- router.get('/teams', async (req, res) => {
688
- try {
689
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
690
- const userId = tokenEntityRef
691
- ? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
692
- : req.query.user_id;
693
- const userInfo = await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
694
- if (!userInfo?.teams?.length) {
695
- res.json([]);
696
- return;
220
+ if (!check.ok) {
221
+ res.status(check.status).json({ error: check.error });
222
+ return null;
697
223
  }
698
- const teams = await Promise.all(userInfo.teams.map(teamId => withTeamFetchRetry(() => client.getTeamInfo(teamId)).catch(err => {
699
- logger.warn(`Failed to fetch team ${teamId} after retries: ${err.message}`);
224
+ const { teamId } = req.params;
225
+ if (!teamId) {
226
+ res.status(400).json({ error: 'teamId is required' });
700
227
  return null;
701
- })));
702
- const list = teams.filter(Boolean);
703
- // Member surface: strip dollar amounts when the operator hides them
704
- // from members. Managers who need the numbers use /teams/managed.
705
- res.json(teamBudgetVisibility.hideTeamBudgetForMembers
706
- ? list.map(teamBudgetVisibility_1.redactTeamBudget)
707
- : list);
708
- }
709
- catch (error) {
710
- if (error instanceof provisioning_1.ProvisioningError) {
711
- res.status(error.status).json(error.body);
712
- return;
713
228
  }
714
- logger.error('Failed to fetch teams', error);
715
- res.status(500).json({ error: error.message });
716
- }
717
- });
718
- function requireTeamMgmt(res) {
719
- if (!teamMgmtEnabled) {
720
- res.status(403).json({
721
- error: 'Team management is disabled (requires permission.enabled and litellm.teamAdmin.group)',
722
- });
723
- return false;
724
- }
725
- return true;
726
- }
727
- function requireObjectPerms(res) {
728
- if (!requireTeamMgmt(res))
729
- return false;
730
- if (!objectPermsEnabled) {
731
- res.status(403).json({
732
- error: 'Knowledge-base / MCP management is disabled (set litellm.teamAdmin.objectPermissions.enabled: true, with a permission policy and the allowlists in place)',
733
- });
734
- return false;
735
- }
736
- return true;
737
- }
738
- function sendTeamError(err, res) {
739
- if (err instanceof client_1.LiteLLMUpstreamError) {
740
- res.status(err.status).json({
741
- error: err.message,
742
- ...(err.param ? { param: err.param } : {}),
743
- });
744
- }
745
- else {
746
- logger.error('Team operation failed', err);
747
- res.status(500).json({ error: err.message });
748
- }
749
- }
750
- router.post('/teams', async (req, res) => {
751
- if (!requireTeamMgmt(res))
752
- return;
753
- const check = await (0, teamAdmin_1.assertTeamAdmin)({
754
- req,
755
- auth,
756
- permissions,
757
- catalogClient,
758
- teamAdminGroup: teamAdminCfg.group,
759
- permission: permissions_1.litellmTeamCreatePermission,
760
- logger,
761
- });
762
- if (!check.ok) {
763
- res.status(check.status).json({ error: check.error });
764
- return;
765
- }
766
- const v = (0, teamAdmin_1.validateTeamWriteInput)(req.body ?? {}, teamAdminCfg);
767
- if (!v.ok) {
768
- res.status(400).json({ error: v.error });
769
- return;
770
- }
771
- const key = v.value.team_alias.trim().toLowerCase();
772
- const pending = teamCreateInFlight.get(key);
773
- if (pending) {
774
- logger.info(`Team creation already in flight for ${key} — joining`);
229
+ let existing;
775
230
  try {
776
- const result = await pending;
777
- res.json(result);
231
+ existing = await (0, teamFetch_1.withTeamFetchRetry)(() => client.getTeamInfo(teamId));
778
232
  }
779
233
  catch (err) {
780
- sendTeamError(err, res);
781
- }
782
- return;
783
- }
784
- const payload = {
785
- team_alias: v.value.team_alias,
786
- models: v.value.models,
787
- ...(v.value.max_budget !== undefined && { max_budget: v.value.max_budget }),
788
- ...(v.value.budget_duration && { budget_duration: v.value.budget_duration }),
789
- ...(v.value.tpm_limit !== undefined && { tpm_limit: v.value.tpm_limit }),
790
- ...(v.value.rpm_limit !== undefined && { rpm_limit: v.value.rpm_limit }),
791
- metadata: {
792
- owning_group: teamAdminCfg.group,
793
- created_by_backstage_user: check.userEntityRef,
794
- created_via: 'backstage',
795
- created_at_iso: new Date().toISOString(),
796
- },
797
- };
798
- const createPromise = (async () => {
799
- const result = await client.createTeam(payload);
800
- logger.info({
801
- action: 'team.create',
802
- actor: check.userEntityRef,
803
- teamAlias: v.value.team_alias,
804
- owningGroup: teamAdminCfg.group,
805
- });
806
- return result;
807
- })();
808
- teamCreateInFlight.set(key, createPromise);
809
- try {
810
- const result = await createPromise;
811
- // Manager surface: redact dollars when hidden even from managers.
812
- res.json(teamBudgetVisibility.hideTeamBudgetForManagers
813
- ? (0, teamBudgetVisibility_1.redactTeamBudget)(result)
814
- : result);
815
- }
816
- catch (err) {
817
- sendTeamError(err, res);
818
- }
819
- finally {
820
- teamCreateInFlight.delete(key);
821
- }
822
- });
823
- router.patch('/teams/:teamId', async (req, res) => {
824
- const authz = await authorizeTeamSubresource(req, res, permissions_1.litellmTeamManagePermission);
825
- if (!authz)
826
- return;
827
- const { teamId, owningGroup, actor, team: existing } = authz;
828
- const v = (0, teamAdmin_1.validateTeamPatchInput)(req.body ?? {}, teamAdminCfg);
829
- if (!v.ok) {
830
- res.status(400).json({ error: v.error });
831
- return;
832
- }
833
- // Optimistic-concurrency guard (opt-in): when the client sends the
834
- // `expectedUpdatedAtIso` it based its edit on, reject if the stored value
835
- // has moved on — a concurrent edit landed in between. Callers that don't
836
- // send it are unaffected (last-writer-wins, as before).
837
- const expected = (req.body ?? {}).expectedUpdatedAtIso;
838
- if (expected !== undefined &&
839
- expected !== existing.metadata?.updated_at_iso) {
840
- res.status(409).json({
841
- error: 'This team was modified since you loaded it. Reload and re-apply your change.',
842
- });
843
- return;
844
- }
845
- const payload = {
846
- team_id: teamId,
847
- ...v.value,
848
- metadata: {
849
- ...(existing.metadata ?? {}),
850
- updated_by_backstage_user: actor,
851
- updated_at_iso: new Date().toISOString(),
852
- },
853
- };
854
- try {
855
- const r = await client.updateTeam(payload);
856
- logger.info({
857
- action: 'team.update',
858
- actor,
859
- teamId,
860
- owningGroup,
861
- });
862
- res.json(teamBudgetVisibility.hideTeamBudgetForManagers
863
- ? (0, teamBudgetVisibility_1.redactTeamBudget)(r)
864
- : r);
865
- }
866
- catch (err) {
867
- sendTeamError(err, res);
868
- }
869
- });
870
- // Delete a team the caller's group owns. Gated by litellm.teamAdmin.allowTeamDelete
871
- // (default false — deletion is destructive: it orphans keys and revokes access
872
- // for every member). Refuses when the team id is referenced by provisioning
873
- // config unless ?force=true. Prefer blocking a team over deleting it.
874
- router.delete('/teams/:teamId', async (req, res) => {
875
- const authz = await authorizeTeamSubresource(req, res, permissions_1.litellmTeamDeletePermission);
876
- if (!authz)
877
- return;
878
- const { teamId, owningGroup, actor } = authz;
879
- if (!teamAdminCfg.allowTeamDelete) {
880
- res.status(403).json({
881
- error: 'Team deletion is disabled (set litellm.teamAdmin.allowTeamDelete: true). Block the team instead.',
882
- });
883
- return;
884
- }
885
- const provisioningRefs = [
886
- ...provisioningDefaults.teams,
887
- ...roleConfigs.flatMap(r => r.teams ?? []),
888
- ];
889
- const force = String(req.query.force ?? '') === 'true';
890
- if (provisioningRefs.includes(teamId) && !force) {
891
- res.status(409).json({
892
- error: `Team ${teamId} is referenced by litellm.provisioning config; deleting it will break user provisioning. Re-send with ?force=true to override.`,
893
- });
894
- return;
895
- }
896
- try {
897
- await client.deleteTeam(teamId);
898
- logger.info({ action: 'team.delete', actor, teamId, owningGroup, force });
899
- res.json({ success: true });
900
- }
901
- catch (err) {
902
- sendTeamError(err, res);
903
- }
904
- });
905
- // Scoped listing for team admins: returns ONLY the teams whose
906
- // `metadata.owning_group` equals the configured admin group, so a team admin
907
- // can see and edit teams their group owns even when they are not a member.
908
- // This deliberately never returns a global team list. Registered before any
909
- // `/teams/:param` route so the literal path is not shadowed.
910
- router.get('/teams/managed', async (req, res) => {
911
- if (!requireTeamMgmt(res))
912
- return;
913
- const check = await (0, teamAdmin_1.assertTeamAdmin)({
914
- req,
915
- auth,
916
- permissions,
917
- catalogClient,
918
- teamAdminGroup: teamAdminCfg.group,
919
- permission: permissions_1.litellmTeamManagePermission,
920
- logger,
921
- });
922
- if (!check.ok) {
923
- res.status(check.status).json({ error: check.error });
924
- return;
925
- }
926
- try {
927
- const all = await client.listTeams();
928
- // TODO(multi-group): when litellm.teamAdmin.group becomes a list, resolve
929
- // the caller's group set and filter with includes().
930
- const owned = all.filter(t => typeof t.metadata?.owning_group === 'string' &&
931
- t.metadata.owning_group === teamAdminCfg.group);
932
- // Manager surface: strip dollar amounts when hidden even from managers.
933
- res.json(teamBudgetVisibility.hideTeamBudgetForManagers
934
- ? owned.map(teamBudgetVisibility_1.redactTeamBudget)
935
- : owned);
936
- }
937
- catch (err) {
938
- sendTeamError(err, res);
939
- }
940
- });
941
- // ── Team member routes ──────────────────────────────────────────────────
942
- //
943
- // Shared preamble: team-mgmt enabled → assertTeamAdmin(members permission) →
944
- // load the team and enforce the owning-group object guard (same as PATCH
945
- // /teams/:id). Returns the resolved teamId + owningGroup, or null when a
946
- // response has already been sent.
947
- // Shared preamble for a team sub-resource mutation (members, knowledge bases,
948
- // MCP servers): team-mgmt enabled → assertTeamAdmin(<the given permission>) →
949
- // load the team and enforce the owning-group object guard. Returns the
950
- // resolved teamId + owningGroup + actor, or null when a response was sent.
951
- async function authorizeTeamSubresource(req, res, permission) {
952
- if (!requireTeamMgmt(res))
953
- return null;
954
- const check = await (0, teamAdmin_1.assertTeamAdmin)({
955
- req,
956
- auth,
957
- permissions,
958
- catalogClient,
959
- teamAdminGroup: teamAdminCfg.group,
960
- permission,
961
- logger,
962
- });
963
- if (!check.ok) {
964
- res.status(check.status).json({ error: check.error });
965
- return null;
966
- }
967
- const { teamId } = req.params;
968
- if (!teamId) {
969
- res.status(400).json({ error: 'teamId is required' });
970
- return null;
971
- }
972
- let existing;
973
- try {
974
- existing = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
975
- }
976
- catch (err) {
977
- if (err instanceof client_1.LiteLLMUpstreamError && err.status === 404) {
978
- res.status(404).json({ error: 'Team not found' });
234
+ if (err instanceof client_1.LiteLLMUpstreamError && err.status === 404) {
235
+ res.status(404).json({ error: 'Team not found' });
236
+ return null;
237
+ }
238
+ ctx.sendTeamError(err, res);
979
239
  return null;
980
240
  }
981
- sendTeamError(err, res);
982
- return null;
983
- }
984
- const owningGroup = typeof existing.metadata?.owning_group === 'string'
985
- ? existing.metadata.owning_group
986
- : undefined;
987
- if (!owningGroup) {
988
- res.status(403).json({
989
- error: 'This team is not managed by Backstage team admins and cannot be edited here',
990
- });
991
- return null;
992
- }
993
- const owns = await (0, provisioning_1.isUserMemberOfGroup)(check.userEntityRef, owningGroup, catalogClient, auth, logger);
994
- if (!owns) {
995
- res
996
- .status(403)
997
- .json({ error: `Access denied: team is owned by ${owningGroup}` });
998
- return null;
999
- }
1000
- return { teamId, owningGroup, actor: check.userEntityRef, team: existing };
1001
- }
1002
- // Add a member to a team the caller's group owns. The member must (a) resolve
1003
- // to a real User in the Backstage catalog and (b) exist in LiteLLM — we
1004
- // provision them on the spot when auto-provisioning is enabled. Only the
1005
- // 'user' team role can be assigned from Backstage.
1006
- router.post('/teams/:teamId/members', async (req, res) => {
1007
- const authz = await authorizeTeamSubresource(req, res, permissions_1.litellmTeamMembersManagePermission);
1008
- if (!authz)
1009
- return;
1010
- const { teamId, owningGroup, actor } = authz;
1011
- const body = (req.body ?? {});
1012
- const userEntityRef = (body.userEntityRef ?? '').trim();
1013
- if (!userEntityRef) {
1014
- res.status(400).json({ error: 'userEntityRef is required' });
1015
- return;
1016
- }
1017
- if (body.role !== undefined && body.role !== 'user') {
1018
- res.status(400).json({
1019
- error: "only the 'user' team role can be assigned from Backstage",
1020
- });
1021
- return;
1022
- }
1023
- let maxBudgetInTeam;
1024
- if (body.maxBudgetInTeam !== undefined) {
1025
- if (typeof body.maxBudgetInTeam !== 'number' ||
1026
- !Number.isFinite(body.maxBudgetInTeam) ||
1027
- body.maxBudgetInTeam <= 0) {
1028
- res
1029
- .status(400)
1030
- .json({ error: 'maxBudgetInTeam must be a positive number' });
1031
- return;
1032
- }
1033
- maxBudgetInTeam = body.maxBudgetInTeam;
1034
- }
1035
- // (a) the member must be a real User in the Backstage catalog
1036
- try {
1037
- const { token } = await auth.getPluginRequestToken({
1038
- onBehalfOf: await auth.getOwnServiceCredentials(),
1039
- targetPluginId: 'catalog',
1040
- });
1041
- const entity = await catalogClient.getEntityByRef(userEntityRef, {
1042
- token,
1043
- });
1044
- if (!entity || entity.kind !== 'User') {
1045
- res.status(400).json({
1046
- error: `${userEntityRef} is not a User in the Backstage catalog`,
241
+ const owningGroup = typeof existing.metadata?.owning_group === 'string'
242
+ ? existing.metadata.owning_group
243
+ : undefined;
244
+ if (!owningGroup) {
245
+ res.status(403).json({
246
+ error: 'This team is not managed by Backstage team admins and cannot be edited here',
1047
247
  });
1048
- return;
248
+ return null;
1049
249
  }
1050
- }
1051
- catch (err) {
1052
- logger.warn(`Catalog lookup failed for ${userEntityRef}: ${err.message}`);
1053
- res.status(400).json({
1054
- error: `Could not verify ${userEntityRef} in the Backstage catalog`,
1055
- });
1056
- return;
1057
- }
1058
- // (b) the member must exist in LiteLLM — provision on the spot if enabled
1059
- const litellmUserId = (0, provisioning_1.toLiteLLMUserId)(userEntityRef, userIdDomain);
1060
- try {
1061
- await (0, provisioning_1.getOrProvisionUser)(client, userEntityRef, litellmUserId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
1062
- }
1063
- catch (err) {
1064
- if (err instanceof provisioning_1.ProvisioningError) {
1065
- res.status(err.status).json(err.body);
1066
- return;
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;
1067
256
  }
1068
- throw err;
1069
- }
1070
- try {
1071
- await client.teamMemberAdd({
1072
- team_id: teamId,
1073
- user_id: litellmUserId,
1074
- role: 'user',
1075
- ...(maxBudgetInTeam !== undefined && {
1076
- max_budget_in_team: maxBudgetInTeam,
1077
- }),
1078
- });
1079
- logger.info({
1080
- action: 'team.member.add',
1081
- actor,
1082
- teamId,
1083
- member: litellmUserId,
1084
- owningGroup,
1085
- });
1086
- const updated = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
1087
- res.json(teamBudgetVisibility.hideTeamBudgetForManagers
1088
- ? (0, teamBudgetVisibility_1.redactTeamBudget)(updated)
1089
- : updated);
1090
- }
1091
- catch (err) {
1092
- sendTeamError(err, res);
1093
- }
1094
- });
1095
- // Remove a member from a team the caller's group owns. The member ref comes
1096
- // from the `userEntityRef` query param (entity refs contain ':' and '/', so
1097
- // they can't be a path segment). No catalog re-check on removal.
1098
- router.delete('/teams/:teamId/members', async (req, res) => {
1099
- const authz = await authorizeTeamSubresource(req, res, permissions_1.litellmTeamMembersManagePermission);
1100
- if (!authz)
1101
- return;
1102
- const { teamId, owningGroup, actor } = authz;
1103
- const userEntityRef = String(req.query.userEntityRef ?? '').trim();
1104
- if (!userEntityRef) {
1105
- res
1106
- .status(400)
1107
- .json({ error: 'userEntityRef query parameter is required' });
1108
- return;
1109
- }
1110
- const litellmUserId = (0, provisioning_1.toLiteLLMUserId)(userEntityRef, userIdDomain);
1111
- try {
1112
- await client.teamMemberDelete({
1113
- team_id: teamId,
1114
- user_id: litellmUserId,
1115
- });
1116
- logger.info({
1117
- action: 'team.member.remove',
1118
- actor,
257
+ return {
1119
258
  teamId,
1120
- member: litellmUserId,
1121
259
  owningGroup,
1122
- });
1123
- const updated = await withTeamFetchRetry(() => client.getTeamInfo(teamId));
1124
- res.json(teamBudgetVisibility.hideTeamBudgetForManagers
1125
- ? (0, teamBudgetVisibility_1.redactTeamBudget)(updated)
1126
- : updated);
1127
- }
1128
- catch (err) {
1129
- sendTeamError(err, res);
1130
- }
1131
- });
1132
- // ── Knowledge-base (vector store) management ────────────────────────────
1133
- //
1134
- // Attaching a vector store to a team exposes its documents to every team
1135
- // key. Team admins may only reference stores that the operator has
1136
- // allowlisted in litellm.teamAdmin.allowedVectorStores — anything outside
1137
- // the allowlist is rejected, never silently dropped.
1138
- // The vector stores a team admin is allowed to attach (allowlist ∩ proxy).
1139
- router.get('/vector-stores', async (req, res) => {
1140
- if (!requireObjectPerms(res))
1141
- return;
1142
- const check = await (0, teamAdmin_1.assertTeamAdmin)({
1143
- req,
1144
- auth,
1145
- permissions,
1146
- catalogClient,
1147
- teamAdminGroup: teamAdminCfg.group,
1148
- permission: permissions_1.litellmTeamKnowledgebaseManagePermission,
1149
- logger,
1150
- });
1151
- if (!check.ok) {
1152
- res.status(check.status).json({ error: check.error });
1153
- return;
1154
- }
1155
- const allowed = new Set(teamAdminCfg.allowedVectorStores);
1156
- try {
1157
- const all = await client.listVectorStores();
1158
- res.json(all.filter(s => allowed.has(s.id) || (s.name && allowed.has(s.name))));
1159
- }
1160
- catch (err) {
1161
- sendTeamError(err, res);
1162
- }
1163
- });
1164
- // Replace the set of knowledge bases attached to a team the caller's group
1165
- // owns. Body: { vector_stores: string[] }. Every id must be in the
1166
- // allowlist. The other object_permission facets (mcp_servers) are preserved.
1167
- router.put('/teams/:teamId/knowledge-bases', async (req, res) => {
1168
- if (!requireObjectPerms(res))
1169
- return;
1170
- const authz = await authorizeTeamSubresource(req, res, permissions_1.litellmTeamKnowledgebaseManagePermission);
1171
- if (!authz)
1172
- return;
1173
- const { teamId, owningGroup, actor, team } = authz;
1174
- const requested = (req.body?.vector_stores ?? []);
1175
- if (!Array.isArray(requested) ||
1176
- !requested.every(v => typeof v === 'string')) {
1177
- res
1178
- .status(400)
1179
- .json({ error: 'vector_stores must be an array of strings' });
1180
- return;
1181
- }
1182
- const allowed = new Set(teamAdminCfg.allowedVectorStores);
1183
- const offenders = requested.filter(v => !allowed.has(v));
1184
- if (offenders.length) {
1185
- res.status(400).json({
1186
- error: `vector store(s) not in the allowed set for team admins: ${offenders.join(', ')}`,
1187
- });
1188
- return;
1189
- }
1190
- const objectPermission = {
1191
- ...(team.object_permission ?? {}),
1192
- vector_stores: requested,
1193
- };
1194
- try {
1195
- const updated = await client.updateTeam({
1196
- team_id: teamId,
1197
- object_permission: objectPermission,
1198
- // /team/update replaces metadata wholesale — re-send the merged
1199
- // object so owning_group / created_* survive and the change is
1200
- // attributed.
1201
- metadata: {
1202
- ...(team.metadata ?? {}),
1203
- updated_by_backstage_user: actor,
1204
- updated_at_iso: new Date().toISOString(),
1205
- },
1206
- });
1207
- logger.info({
1208
- action: 'team.knowledgebase.set',
1209
- actor,
1210
- teamId,
1211
- owningGroup,
1212
- vector_stores: requested,
1213
- });
1214
- res.json(teamBudgetVisibility.hideTeamBudgetForManagers
1215
- ? (0, teamBudgetVisibility_1.redactTeamBudget)(updated)
1216
- : updated);
1217
- }
1218
- catch (err) {
1219
- sendTeamError(err, res);
1220
- }
1221
- });
1222
- // ── MCP server management ──────────────────────────────────────────────
1223
- //
1224
- // Attaching an MCP server to a team grants every team key the ability to
1225
- // invoke that server's tools through the model. Same allowlist + hard-reject
1226
- // discipline as knowledge bases, plus a structured audit event on every
1227
- // change so attaches are always attributable.
1228
- router.get('/mcp-servers', async (req, res) => {
1229
- if (!requireObjectPerms(res))
1230
- return;
1231
- const check = await (0, teamAdmin_1.assertTeamAdmin)({
1232
- req,
1233
- auth,
1234
- permissions,
1235
- catalogClient,
1236
- teamAdminGroup: teamAdminCfg.group,
1237
- permission: permissions_1.litellmTeamMcpManagePermission,
1238
- logger,
1239
- });
1240
- if (!check.ok) {
1241
- res.status(check.status).json({ error: check.error });
1242
- return;
1243
- }
1244
- const allowed = new Set(teamAdminCfg.allowedMcpServers);
1245
- try {
1246
- const all = await client.listMcpServers();
1247
- res.json(all.filter(s => allowed.has(s.id) || (s.name && allowed.has(s.name))));
1248
- }
1249
- catch (err) {
1250
- sendTeamError(err, res);
1251
- }
1252
- });
1253
- // Replace the set of MCP servers attached to a team the caller's group owns.
1254
- // Body: { mcp_servers: string[] }. Every id must be allowlisted. The
1255
- // vector_stores facet of object_permission is preserved.
1256
- router.put('/teams/:teamId/mcp-servers', async (req, res) => {
1257
- if (!requireObjectPerms(res))
1258
- return;
1259
- const authz = await authorizeTeamSubresource(req, res, permissions_1.litellmTeamMcpManagePermission);
1260
- if (!authz)
1261
- return;
1262
- const { teamId, owningGroup, actor, team } = authz;
1263
- const requested = (req.body?.mcp_servers ?? []);
1264
- if (!Array.isArray(requested) ||
1265
- !requested.every(v => typeof v === 'string')) {
1266
- res
1267
- .status(400)
1268
- .json({ error: 'mcp_servers must be an array of strings' });
1269
- return;
1270
- }
1271
- const allowed = new Set(teamAdminCfg.allowedMcpServers);
1272
- const offenders = requested.filter(v => !allowed.has(v));
1273
- if (offenders.length) {
1274
- res.status(400).json({
1275
- error: `MCP server(s) not in the allowed set for team admins: ${offenders.join(', ')}`,
1276
- });
1277
- return;
1278
- }
1279
- const before = team.object_permission?.mcp_servers ?? [];
1280
- const objectPermission = {
1281
- ...(team.object_permission ?? {}),
1282
- mcp_servers: requested,
1283
- };
1284
- try {
1285
- const updated = await client.updateTeam({
1286
- team_id: teamId,
1287
- object_permission: objectPermission,
1288
- // /team/update replaces metadata wholesale — re-send the merged
1289
- // object so owning_group / created_* survive and the change is
1290
- // attributed.
1291
- metadata: {
1292
- ...(team.metadata ?? {}),
1293
- updated_by_backstage_user: actor,
1294
- updated_at_iso: new Date().toISOString(),
1295
- },
1296
- });
1297
- logger.info({
1298
- action: 'team.mcp.set',
1299
- actor,
1300
- teamId,
1301
- owningGroup,
1302
- before,
1303
- after: requested,
1304
- });
1305
- res.json(teamBudgetVisibility.hideTeamBudgetForManagers
1306
- ? (0, teamBudgetVisibility_1.redactTeamBudget)(updated)
1307
- : updated);
1308
- }
1309
- catch (err) {
1310
- sendTeamError(err, res);
1311
- }
1312
- });
1313
- router.get('/teams/:teamId/usage', async (req, res) => {
1314
- try {
1315
- const { teamId } = req.params;
1316
- const { start_date, end_date } = req.query;
1317
- if (!start_date || !end_date) {
1318
- res.status(400).json({ error: 'start_date and end_date are required' });
1319
- return;
1320
- }
1321
- const usage = await client.getTeamUsage(teamId, start_date, end_date);
1322
- // Spend figures would let a client derive a hidden budget
1323
- // (budget = spend / pct), so they follow the same hiding flags.
1324
- // Which flag applies depends on whether the caller is a team manager;
1325
- // catalog failures fail closed to the member rule.
1326
- let hide = teamBudgetVisibility.hideTeamBudgetForMembers;
1327
- if (teamAdminCfg.group) {
1328
- try {
1329
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
1330
- const isManager = !!tokenEntityRef &&
1331
- (await (0, provisioning_1.isUserMemberOfGroup)(tokenEntityRef, teamAdminCfg.group, catalogClient, auth, logger));
1332
- hide = isManager
1333
- ? teamBudgetVisibility.hideTeamBudgetForManagers
1334
- : teamBudgetVisibility.hideTeamBudgetForMembers;
1335
- }
1336
- catch (err) {
1337
- logger.warn(`Team-manager check failed, hiding team spend: ${err.message}`);
1338
- hide = teamBudgetVisibility.hideTeamBudgetForMembers;
1339
- }
1340
- }
1341
- res.json(hide ? (0, teamBudgetVisibility_1.redactTeamUsage)(usage) : usage);
1342
- }
1343
- catch (error) {
1344
- logger.error('Failed to fetch team usage', error);
1345
- res.status(500).json({ error: error.message });
1346
- }
1347
- });
1348
- router.get('/usage', async (req, res) => {
1349
- try {
1350
- const { start_date, end_date } = req.query;
1351
- if (!start_date || !end_date) {
1352
- res.status(400).json({ error: 'start_date and end_date are required' });
1353
- return;
1354
- }
1355
- const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
1356
- const userId = tokenEntityRef
1357
- ? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
1358
- : req.query.user_id;
1359
- if (userId) {
1360
- await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
1361
- }
1362
- const usage = await client.getUsage(start_date, end_date, userId);
1363
- res.json(usage);
1364
- }
1365
- catch (error) {
1366
- if (error instanceof provisioning_1.ProvisioningError) {
1367
- res.status(error.status).json(error.body);
1368
- return;
1369
- }
1370
- logger.error('Failed to fetch usage', error);
1371
- res.status(500).json({ error: error.message });
1372
- }
1373
- });
1374
- // ---------------------------------------------------------------------------
1375
- // Bridge endpoints (CLI / Abby)
1376
- //
1377
- // Authenticated by a Keycloak access token (JWKS-verified), NOT by Backstage's
1378
- // own auth. Lets CLI clients list/mint virtual keys without holding the master
1379
- // key. Gated by litellm.bridge.enabled; the verifier needs litellm.bridge.issuer.
1380
- const bridgeCfg = (0, bridge_1.readBridgeConfig)(config);
1381
- let tokenVerifier = options.tokenVerifier;
1382
- if (bridgeCfg.enabled && !tokenVerifier) {
1383
- try {
1384
- tokenVerifier = (0, bridge_1.newDefaultVerifier)(bridgeCfg);
1385
- }
1386
- catch (e) {
1387
- logger.error(`LiteLLM bridge enabled but misconfigured: ${e.message}`);
1388
- }
1389
- }
1390
- if (bridgeCfg.enabled && tokenVerifier) {
1391
- const requireClaims = async (req) => {
1392
- const header = req.headers.authorization ?? '';
1393
- const token = header.startsWith('Bearer ') ? header.slice(7) : '';
1394
- if (!token)
1395
- throw new bridge_1.BridgeAuthError('missing Bearer token');
1396
- return tokenVerifier.verify(token);
1397
- };
1398
- const handleBridgeError = (error, res) => {
1399
- if (error instanceof bridge_1.BridgeAuthError) {
1400
- res.status(401).json({ error: 'unauthorized', hint: error.message });
1401
- return;
1402
- }
1403
- if (error instanceof provisioning_1.ProvisioningError) {
1404
- res.status(error.status).json(error.body);
1405
- return;
1406
- }
1407
- logger.error('Bridge request failed', error);
1408
- res.status(500).json({ error: error.message });
1409
- };
1410
- router.get('/bridge/health', (_req, res) => {
1411
- res.json({ status: 'ok', bridge: true, clientId: bridgeCfg.clientId });
1412
- });
1413
- router.get('/bridge/keys', async (req, res) => {
1414
- try {
1415
- const claims = await requireClaims(req);
1416
- const keys = await (0, bridge_1.bridgeListKeys)(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain);
1417
- res.json(keys);
1418
- }
1419
- catch (error) {
1420
- handleBridgeError(error, res);
1421
- }
1422
- });
1423
- router.post('/bridge/keys', async (req, res) => {
1424
- try {
1425
- const claims = await requireClaims(req);
1426
- const result = await (0, bridge_1.bridgeGenerateKey)(client, claims, provisioningEnabled, provisioningDefaults, logger, (req.body ?? {}), userIdDomain);
1427
- res.json(result);
1428
- }
1429
- catch (error) {
1430
- handleBridgeError(error, res);
1431
- }
1432
- });
1433
- router.get('/bridge/models', async (req, res) => {
1434
- try {
1435
- await requireClaims(req); // authenticate only
1436
- const models = await client.listModels();
1437
- res.json(models);
1438
- }
1439
- catch (error) {
1440
- handleBridgeError(error, res);
1441
- }
1442
- });
1443
- }
1444
- else if (bridgeCfg.enabled) {
1445
- logger.warn('litellm.bridge.enabled is true but no verifier could be built — bridge endpoints not mounted');
1446
- }
260
+ actor: check.userEntityRef,
261
+ team: existing,
262
+ };
263
+ },
264
+ };
265
+ const router = (0, express_1.Router)();
266
+ // JSON body parser. Without this, every POST/PUT endpoint sees an empty
267
+ // req.body. Backstage's httpRouter does not apply a body parser at the
268
+ // plugin-router level, so each plugin must attach its own.
269
+ router.use(express_1.default.json());
270
+ // URL-encoded form parser for POST /opencode/connect (confirmation form).
271
+ router.use(express_1.default.urlencoded({ extended: false }));
272
+ // User-scoped routes act on the caller's own LiteLLM identity. That identity
273
+ // comes ONLY from a verified Backstage user principal — never from the query
274
+ // string or body — so service/external principals (and anonymous callers)
275
+ // are rejected instead of being allowed to name an arbitrary user_id.
276
+ const requireUser = (0, withUser_1.createRequireUser)(ctx);
277
+ router.use(['/user/info', '/keys', '/usage'], requireUser);
278
+ // Register config routes (/health, /config, /openapi.json, /provisioning/preview)
279
+ (0, config_1.registerConfigRoutes)(router, ctx);
280
+ (0, keys_1.registerKeysRoutes)(router, ctx);
281
+ (0, opencode_2.registerOpencodeRoutes)(router, ctx);
282
+ (0, teams_1.registerTeamsRoutes)(router, ctx);
283
+ (0, objectPermissions_1.registerObjectPermissionsRoutes)(router, ctx);
284
+ (0, teamUsage_1.registerTeamUsageRoutes)(router, ctx);
285
+ (0, bridge_1.registerBridgeRoutes)(router, ctx, { config, tokenVerifier: options.tokenVerifier });
1447
286
  return router;
1448
287
  }