@acarmisc/backstage-plugin-litellm-backend 0.8.2 → 0.9.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/index.d.ts CHANGED
@@ -7,3 +7,4 @@ export { resolveUserId, resolveUserProfile, toLiteLLMUserId, getOrProvisionUser,
7
7
  export type { BackstageUserProfile } from './provisioning';
8
8
  export { KeycloakJWTVerifier, newDefaultVerifier, readBridgeConfig, resolveBridgeUserId, getOrProvisionUserFromClaims, bridgeListKeys, bridgeGenerateKey, } from './bridge';
9
9
  export type { BridgeClaims, TokenVerifier, BridgeConfig, KeycloakJWTVerifierOptions, } from './bridge';
10
+ export { litellmPermissions, litellmKeyCreatePermission, litellmKeyRevokePermission, litellmKeyManagePermission, litellmAuditReadPermission, } from './permissions';
package/dist/index.js CHANGED
@@ -14,7 +14,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
14
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.bridgeGenerateKey = exports.bridgeListKeys = exports.getOrProvisionUserFromClaims = exports.resolveBridgeUserId = exports.readBridgeConfig = exports.newDefaultVerifier = exports.KeycloakJWTVerifier = exports.ProvisioningError = exports.resolveUserRole = exports.applyRoleOverrides = exports.readRoleConfigs = exports.readProvisioningDefaults = exports.provisionUser = exports.getOrProvisionUser = exports.toLiteLLMUserId = exports.resolveUserProfile = exports.resolveUserId = exports.LiteLLMClient = exports.createRouter = exports.default = exports.litellmPlugin = void 0;
17
+ exports.litellmAuditReadPermission = exports.litellmKeyManagePermission = exports.litellmKeyRevokePermission = exports.litellmKeyCreatePermission = exports.litellmPermissions = exports.bridgeGenerateKey = exports.bridgeListKeys = exports.getOrProvisionUserFromClaims = exports.resolveBridgeUserId = exports.readBridgeConfig = exports.newDefaultVerifier = exports.KeycloakJWTVerifier = exports.ProvisioningError = exports.resolveUserRole = exports.applyRoleOverrides = exports.readRoleConfigs = exports.readProvisioningDefaults = exports.provisionUser = exports.getOrProvisionUser = exports.toLiteLLMUserId = exports.resolveUserProfile = exports.resolveUserId = exports.LiteLLMClient = exports.createRouter = exports.default = exports.litellmPlugin = void 0;
18
18
  var plugin_1 = require("./plugin");
19
19
  Object.defineProperty(exports, "litellmPlugin", { enumerable: true, get: function () { return plugin_1.litellmPlugin; } });
20
20
  var plugin_2 = require("./plugin");
@@ -43,3 +43,9 @@ Object.defineProperty(exports, "resolveBridgeUserId", { enumerable: true, get: f
43
43
  Object.defineProperty(exports, "getOrProvisionUserFromClaims", { enumerable: true, get: function () { return bridge_1.getOrProvisionUserFromClaims; } });
44
44
  Object.defineProperty(exports, "bridgeListKeys", { enumerable: true, get: function () { return bridge_1.bridgeListKeys; } });
45
45
  Object.defineProperty(exports, "bridgeGenerateKey", { enumerable: true, get: function () { return bridge_1.bridgeGenerateKey; } });
46
+ var permissions_1 = require("./permissions");
47
+ Object.defineProperty(exports, "litellmPermissions", { enumerable: true, get: function () { return permissions_1.litellmPermissions; } });
48
+ Object.defineProperty(exports, "litellmKeyCreatePermission", { enumerable: true, get: function () { return permissions_1.litellmKeyCreatePermission; } });
49
+ Object.defineProperty(exports, "litellmKeyRevokePermission", { enumerable: true, get: function () { return permissions_1.litellmKeyRevokePermission; } });
50
+ Object.defineProperty(exports, "litellmKeyManagePermission", { enumerable: true, get: function () { return permissions_1.litellmKeyManagePermission; } });
51
+ Object.defineProperty(exports, "litellmAuditReadPermission", { enumerable: true, get: function () { return permissions_1.litellmAuditReadPermission; } });
@@ -0,0 +1,5 @@
1
+ export declare const litellmKeyCreatePermission: import("@backstage/plugin-permission-common").BasicPermission;
2
+ export declare const litellmKeyRevokePermission: import("@backstage/plugin-permission-common").BasicPermission;
3
+ export declare const litellmKeyManagePermission: import("@backstage/plugin-permission-common").BasicPermission;
4
+ export declare const litellmAuditReadPermission: import("@backstage/plugin-permission-common").BasicPermission;
5
+ export declare const litellmPermissions: import("@backstage/plugin-permission-common").BasicPermission[];
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.litellmPermissions = exports.litellmAuditReadPermission = exports.litellmKeyManagePermission = exports.litellmKeyRevokePermission = exports.litellmKeyCreatePermission = void 0;
4
+ const plugin_permission_common_1 = require("@backstage/plugin-permission-common");
5
+ exports.litellmKeyCreatePermission = (0, plugin_permission_common_1.createPermission)({
6
+ name: 'litellm.key.create',
7
+ attributes: { action: 'create' },
8
+ });
9
+ exports.litellmKeyRevokePermission = (0, plugin_permission_common_1.createPermission)({
10
+ name: 'litellm.key.revoke',
11
+ attributes: { action: 'delete' },
12
+ });
13
+ exports.litellmKeyManagePermission = (0, plugin_permission_common_1.createPermission)({
14
+ name: 'litellm.key.manage',
15
+ attributes: { action: 'update' },
16
+ });
17
+ exports.litellmAuditReadPermission = (0, plugin_permission_common_1.createPermission)({
18
+ name: 'litellm.audit.read',
19
+ attributes: { action: 'read' },
20
+ });
21
+ exports.litellmPermissions = [
22
+ exports.litellmKeyCreatePermission,
23
+ exports.litellmKeyRevokePermission,
24
+ exports.litellmKeyManagePermission,
25
+ exports.litellmAuditReadPermission,
26
+ ];
package/dist/plugin.js CHANGED
@@ -4,6 +4,7 @@ exports.litellmPlugin = void 0;
4
4
  const backend_plugin_api_1 = require("@backstage/backend-plugin-api");
5
5
  const router_1 = require("./router");
6
6
  const bridge_1 = require("./bridge");
7
+ const permissions_1 = require("./permissions");
7
8
  exports.litellmPlugin = (0, backend_plugin_api_1.createBackendPlugin)({
8
9
  pluginId: 'litellm',
9
10
  register(reg) {
@@ -14,9 +15,18 @@ exports.litellmPlugin = (0, backend_plugin_api_1.createBackendPlugin)({
14
15
  logger: backend_plugin_api_1.coreServices.logger,
15
16
  auth: backend_plugin_api_1.coreServices.auth,
16
17
  discovery: backend_plugin_api_1.coreServices.discovery,
18
+ permissions: backend_plugin_api_1.coreServices.permissions,
19
+ permissionsRegistry: backend_plugin_api_1.coreServices.permissionsRegistry,
17
20
  },
18
- async init({ httpRouter, config, logger, auth, discovery }) {
19
- const router = await (0, router_1.createRouter)({ config, logger, auth, discovery });
21
+ async init({ httpRouter, config, logger, auth, discovery, permissions, permissionsRegistry, }) {
22
+ permissionsRegistry.addPermissions(permissions_1.litellmPermissions);
23
+ const router = await (0, router_1.createRouter)({
24
+ config,
25
+ logger,
26
+ auth,
27
+ discovery,
28
+ permissions,
29
+ });
20
30
  httpRouter.use(router);
21
31
  // The CLI bridge verifies raw Keycloak JWTs itself (it does NOT use a
22
32
  // Backstage-issued token). Exempt its routes from the default auth
@@ -1,5 +1,5 @@
1
1
  import { Config } from '@backstage/config';
2
- import { AuthService } from '@backstage/backend-plugin-api';
2
+ import { AuthService, BackstageCredentials } from '@backstage/backend-plugin-api';
3
3
  import { CatalogClient } from '@backstage/catalog-client';
4
4
  import { Request } from 'express';
5
5
  import { LiteLLMClient } from './client';
@@ -45,6 +45,11 @@ export declare function readProvisioningDefaults(config: Config): {
45
45
  * the request carries no user credential (service-to-service calls).
46
46
  */
47
47
  export declare function resolveUserId(req: Request, auth: AuthService): Promise<string | undefined>;
48
+ /**
49
+ * Like resolveUserId, but returns the raw BackstageCredentials object
50
+ * (rather than just the entity ref) for passing into PermissionsService.authorize().
51
+ */
52
+ export declare function resolveCredentials(req: Request, auth: AuthService): Promise<BackstageCredentials | undefined>;
48
53
  /**
49
54
  * Profile data extracted from a Backstage Catalog User entity, used to
50
55
  * populate user_email / user_alias on the LiteLLM record.
@@ -6,6 +6,7 @@ exports.readRoleConfigs = readRoleConfigs;
6
6
  exports.applyRoleOverrides = applyRoleOverrides;
7
7
  exports.readProvisioningDefaults = readProvisioningDefaults;
8
8
  exports.resolveUserId = resolveUserId;
9
+ exports.resolveCredentials = resolveCredentials;
9
10
  exports.resolveUserProfile = resolveUserProfile;
10
11
  exports.provisionUser = provisionUser;
11
12
  exports.getOrProvisionUser = getOrProvisionUser;
@@ -116,6 +117,21 @@ async function resolveUserId(req, auth) {
116
117
  }
117
118
  return undefined;
118
119
  }
120
+ /**
121
+ * Like resolveUserId, but returns the raw BackstageCredentials object
122
+ * (rather than just the entity ref) for passing into PermissionsService.authorize().
123
+ */
124
+ async function resolveCredentials(req, auth) {
125
+ const rawToken = req.headers.authorization?.slice(7);
126
+ if (!rawToken)
127
+ return undefined;
128
+ try {
129
+ return await auth.authenticate(rawToken);
130
+ }
131
+ catch {
132
+ return undefined;
133
+ }
134
+ }
119
135
  /**
120
136
  * Looks up the catalog User entity for the authenticated user and returns
121
137
  * the profile block. Returns an empty object when the user has no catalog
package/dist/router.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { Router } from 'express';
2
2
  import { Config } from '@backstage/config';
3
- import { AuthService, DiscoveryService } from '@backstage/backend-plugin-api';
3
+ import { AuthService, DiscoveryService, PermissionsService } from '@backstage/backend-plugin-api';
4
4
  import { LiteLLMClient } from './client';
5
5
  import { ProvisioningError } from './provisioning';
6
6
  import { TokenVerifier } from './bridge';
@@ -10,6 +10,7 @@ export interface RouterOptions {
10
10
  logger: any;
11
11
  auth: AuthService;
12
12
  discovery: DiscoveryService;
13
+ permissions: PermissionsService;
13
14
  /** Override the LiteLLM client (tests). Defaults to one built from config. */
14
15
  client?: LiteLLMClient;
15
16
  /** Override the bridge token verifier (tests). Defaults to a Keycloak JWKS verifier. */
package/dist/router.js CHANGED
@@ -36,14 +36,16 @@ 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");
45
47
  async function createRouter(options) {
46
- const { config, logger, auth, discovery } = options;
48
+ const { config, logger, auth, discovery, permissions } = options;
47
49
  const baseUrl = config.getString('litellm.baseUrl');
48
50
  const masterKey = config.getString('litellm.masterKey');
49
51
  const userIdDomain = config.getOptionalString('litellm.userIdDomain');
@@ -212,6 +214,27 @@ async function createRouter(options) {
212
214
  }
213
215
  return false;
214
216
  }
217
+ // ── Permission-framework guard ───────────────────────────────────────────
218
+ //
219
+ // Coarse-grained "is this identity allowed to do this at all" check, layered
220
+ // on top of (not replacing) the ownership guard above. With no permission
221
+ // policy installed (the default for a fresh Backstage instance) this always
222
+ // resolves ALLOW, so behavior is unchanged until an operator wires up
223
+ // @backstage-community/plugin-rbac or a custom PermissionPolicy.
224
+ async function assertPermission(req, permission) {
225
+ const credentials = await (0, provisioning_1.resolveCredentials)(req, auth);
226
+ if (!credentials)
227
+ return false;
228
+ const [decision] = await permissions.authorize([{ permission }], {
229
+ credentials,
230
+ });
231
+ return decision.result === plugin_permission_common_1.AuthorizeResult.ALLOW;
232
+ }
233
+ function sendPermissionDenied(res, permission) {
234
+ res.status(403).json({
235
+ error: `Access denied: missing permission "${permission.name}"`,
236
+ });
237
+ }
215
238
  router.post('/keys/generate', async (req, res) => {
216
239
  try {
217
240
  // Only alias is hard-required. max_budget is optional: a positive
@@ -236,6 +259,10 @@ async function createRouter(options) {
236
259
  });
237
260
  return;
238
261
  }
262
+ if (!(await assertPermission(req, permissions_1.litellmKeyCreatePermission))) {
263
+ sendPermissionDenied(res, permissions_1.litellmKeyCreatePermission);
264
+ return;
265
+ }
239
266
  const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
240
267
  const resolvedUserId = tokenEntityRef
241
268
  ? (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain)
@@ -314,6 +341,10 @@ async function createRouter(options) {
314
341
  res.status(400).json({ error: 'keyId is required' });
315
342
  return;
316
343
  }
344
+ if (!(await assertPermission(req, permissions_1.litellmKeyManagePermission))) {
345
+ sendPermissionDenied(res, permissions_1.litellmKeyManagePermission);
346
+ return;
347
+ }
317
348
  const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
318
349
  const request = { ...req.body, key: keyId };
319
350
  const result = await client.updateKey(request);
@@ -334,6 +365,10 @@ async function createRouter(options) {
334
365
  res.status(400).json({ error: 'keyId is required' });
335
366
  return;
336
367
  }
368
+ if (!(await assertPermission(req, permissions_1.litellmKeyRevokePermission))) {
369
+ sendPermissionDenied(res, permissions_1.litellmKeyRevokePermission);
370
+ return;
371
+ }
337
372
  const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
338
373
  await client.deleteKeys({ keys: [keyId] });
339
374
  logger.info({ action: 'key.delete', userId: tokenEntityRef ?? 'unknown', keyId });
@@ -349,6 +384,10 @@ async function createRouter(options) {
349
384
  router.post('/keys/:keyId/block', async (req, res) => {
350
385
  try {
351
386
  const { keyId } = req.params;
387
+ if (!(await assertPermission(req, permissions_1.litellmKeyManagePermission))) {
388
+ sendPermissionDenied(res, permissions_1.litellmKeyManagePermission);
389
+ return;
390
+ }
352
391
  const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
353
392
  await client.blockKey(keyId);
354
393
  logger.info({ action: 'key.block', userId: tokenEntityRef ?? 'unknown', keyId });
@@ -364,6 +403,10 @@ async function createRouter(options) {
364
403
  router.post('/keys/:keyId/unblock', async (req, res) => {
365
404
  try {
366
405
  const { keyId } = req.params;
406
+ if (!(await assertPermission(req, permissions_1.litellmKeyManagePermission))) {
407
+ sendPermissionDenied(res, permissions_1.litellmKeyManagePermission);
408
+ return;
409
+ }
367
410
  const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
368
411
  await client.unblockKey(keyId);
369
412
  logger.info({ action: 'key.unblock', userId: tokenEntityRef ?? 'unknown', keyId });
@@ -379,6 +422,10 @@ async function createRouter(options) {
379
422
  router.post('/keys/:keyId/reset_spend', async (req, res) => {
380
423
  try {
381
424
  const { keyId } = req.params;
425
+ if (!(await assertPermission(req, permissions_1.litellmKeyManagePermission))) {
426
+ sendPermissionDenied(res, permissions_1.litellmKeyManagePermission);
427
+ return;
428
+ }
382
429
  const { tokenEntityRef } = await authorizeKeyAction(req, keyId);
383
430
  await client.resetKeySpend(keyId);
384
431
  logger.info({ action: 'key.reset_spend', userId: tokenEntityRef ?? 'unknown', keyId });
@@ -406,6 +453,10 @@ async function createRouter(options) {
406
453
  res.status(403).json({ error: 'Access denied: not a member of the audit group' });
407
454
  return;
408
455
  }
456
+ if (!(await assertPermission(req, permissions_1.litellmAuditReadPermission))) {
457
+ sendPermissionDenied(res, permissions_1.litellmAuditReadPermission);
458
+ return;
459
+ }
409
460
  try {
410
461
  const { page, page_size, start_date, end_date, action, table_name, changed_by } = req.query;
411
462
  const result = await client.getAuditLogs({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acarmisc/backstage-plugin-litellm-backend",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "The Backstage backend plugin for LiteLLM governance",
5
5
  "backstage": {
6
6
  "role": "backend-plugin",
@@ -31,6 +31,7 @@
31
31
  "build": "node build.js && tsc -p tsconfig.json",
32
32
  "lint": "eslint src --ext .ts",
33
33
  "test": "tsc -p tsconfig.test.json && node --test dist-test/**/*.test.js dist-test/*.test.js",
34
+ "api-report": "npm run build && api-extractor run --local --verbose",
34
35
  "prepack": "npm run build",
35
36
  "postpack": ""
36
37
  },
@@ -39,16 +40,18 @@
39
40
  "@backstage/catalog-client": "^1.9.0",
40
41
  "@backstage/catalog-model": "^1.7.0",
41
42
  "@backstage/config": "^1.3.0",
43
+ "@backstage/plugin-permission-common": "^0.9.10",
42
44
  "@backstage/types": "^1.2.0",
43
45
  "express": "^4.18.2",
44
46
  "jose": "^5.9.6"
45
47
  },
46
48
  "devDependencies": {
47
49
  "@backstage/cli": "^0.36.2",
50
+ "@microsoft/api-extractor": "^7.59.0",
48
51
  "@types/express": "^4.17.25",
52
+ "@types/node": "^20.0.0",
49
53
  "esbuild": "^0.28.0",
50
- "typescript": "^5.9.3",
51
- "@types/node": "^20.0.0"
54
+ "typescript": "^5.9.3"
52
55
  },
53
56
  "configSchema": "config.d.ts"
54
57
  }