@acarmisc/backstage-plugin-litellm-backend 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/config.d.ts CHANGED
@@ -390,6 +390,15 @@ export interface Config {
390
390
  * @default "abby-cli"
391
391
  */
392
392
  clientId?: string;
393
+
394
+ /**
395
+ * Email domains whose *verified* addresses may use the bridge. Defaults
396
+ * to `litellm.userIdDomain` when that is set. With neither configured
397
+ * the bridge rejects every caller. The LiteLLM user is then the same one
398
+ * the UI addresses (the Keycloak `preferred_username`, with
399
+ * `litellm.userIdDomain` applied when set) — the email is only the gate.
400
+ */
401
+ allowedEmailDomains?: string[];
393
402
  };
394
403
  };
395
404
  }
package/dist/bridge.d.ts CHANGED
@@ -25,6 +25,12 @@ export interface BridgeConfig {
25
25
  issuer?: string;
26
26
  /** OIDC public client the CLI uses; checked against azp / aud. */
27
27
  clientId: string;
28
+ /**
29
+ * Email domains whose verified addresses may use the bridge. Defaults to
30
+ * `litellm.userIdDomain` when that is set; with neither, the bridge rejects
31
+ * every caller (fail closed).
32
+ */
33
+ allowedEmailDomains: string[];
28
34
  }
29
35
  export declare function readBridgeConfig(config: Config): BridgeConfig;
30
36
  /** Thrown when the bridge is misconfigured (e.g. enabled without an issuer). */
@@ -57,28 +63,41 @@ export declare class KeycloakJWTVerifier implements TokenVerifier {
57
63
  /** Builds the default verifier from config, or throws BridgeConfigError. */
58
64
  export declare function newDefaultVerifier(cfg: BridgeConfig): TokenVerifier;
59
65
  /**
60
- * Resolves the LiteLLM user_id from the verified claims with strict identity validation.
66
+ * How the bridge maps a verified token to a LiteLLM user.
67
+ * - `userIdDomain`: the same `litellm.userIdDomain` the UI uses to build ids.
68
+ * - `trustedEmailDomains`: email domains allowed to use the bridge; defaults to
69
+ * `[userIdDomain]` when that is set.
70
+ */
71
+ export interface BridgeIdentityOptions {
72
+ userIdDomain?: string;
73
+ trustedEmailDomains?: string[];
74
+ }
75
+ /**
76
+ * Resolves the LiteLLM user_id for a verified bridge token.
61
77
  *
62
- * PR-6 identity mapping:
63
- * - Prefer `email` with `email_verified === true` whose domain equals `userIdDomain`.
64
- * - If userIdDomain is unset, always reject unverified emails (guard against misconfig).
65
- * - Never strip an arbitrary domain from `preferred_username`.
66
- * - Rejects invalid/missing identity with 403.
78
+ * Two separate questions, answered separately:
79
+ * 1. Is this person allowed in? The token must carry a *verified* email whose
80
+ * domain is in the trusted list (`litellm.bridge.allowedEmailDomains`,
81
+ * defaulting to `litellm.userIdDomain`). With no trusted domain configured
82
+ * everyone is rejected (fail closed). Failure → 403.
83
+ * 2. Which LiteLLM user are they? The same one the UI addresses: the Backstage
84
+ * user entity name (the Keycloak `preferred_username`) run through the same
85
+ * `litellm.userIdDomain` rule the UI applies. Using the email local part
86
+ * here would address a different user whenever a Keycloak username differs
87
+ * from the email (and would provision a duplicate). The realm is the same
88
+ * IdP Backstage signs users in with, so its usernames are the entity names.
67
89
  *
68
- * @param claims Verified JWT claims from the bridge token
69
- * @param userIdDomain Expected email domain (e.g. "example.com"); if set, email must match
70
- * @returns The resolved LiteLLM user_id
71
90
  * @throws BridgeIdentityError (403) if identity validation fails
72
91
  */
73
- export declare function resolveBridgeUserId(claims: BridgeClaims, userIdDomain?: string): string;
92
+ export declare function resolveBridgeUserId(claims: BridgeClaims, identity?: string | BridgeIdentityOptions): string;
74
93
  /**
75
94
  * Ensures a LiteLLM user exists for the verified identity. If the user is
76
95
  * missing and provisioning is enabled, creates it from the JWT claims (email +
77
96
  * name); if provisioning is disabled, throws a 404 telling the caller to log
78
97
  * in to Backstage first (the UI is the primary provisioning entry point).
79
98
  */
80
- export declare function getOrProvisionUserFromClaims(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, userIdDomain?: string): Promise<UserInfo>;
99
+ export declare function getOrProvisionUserFromClaims(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, userIdDomain?: string | BridgeIdentityOptions): Promise<UserInfo>;
81
100
  /** Lists the caller's virtual keys (provisioning the user first if needed). */
82
- export declare function bridgeListKeys(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, userIdDomain?: string): Promise<VirtualKey[]>;
101
+ export declare function bridgeListKeys(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, userIdDomain?: string | BridgeIdentityOptions): Promise<VirtualKey[]>;
83
102
  /** Mints a new virtual key for the caller (provisioning the user first if needed). */
84
- export declare function bridgeGenerateKey(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, request: Partial<GenerateKeyRequest>, userIdDomain?: string): Promise<GenerateKeyResponse>;
103
+ export declare function bridgeGenerateKey(client: LiteLLMClient, claims: BridgeClaims, provisioningEnabled: boolean, provisioningDefaults: ProvisioningDefaults, logger: LoggerService, request: Partial<GenerateKeyRequest>, userIdDomain?: string | BridgeIdentityOptions): Promise<GenerateKeyResponse>;
package/dist/bridge.js CHANGED
@@ -28,7 +28,8 @@ function readBridgeConfig(config) {
28
28
  const enabled = config.getOptionalBoolean('litellm.bridge.enabled') ?? false;
29
29
  const issuer = config.getOptionalString('litellm.bridge.issuer');
30
30
  const clientId = config.getOptionalString('litellm.bridge.clientId') ?? 'abby-cli';
31
- return { enabled, issuer, clientId };
31
+ const allowedEmailDomains = (config.getOptionalStringArray('litellm.bridge.allowedEmailDomains') ?? []).map(d => d.trim().toLowerCase()).filter(Boolean);
32
+ return { enabled, issuer, clientId, allowedEmailDomains };
32
33
  }
33
34
  /** Thrown when the bridge is misconfigured (e.g. enabled without an issuer). */
34
35
  class BridgeConfigError extends Error {
@@ -110,26 +111,40 @@ function newDefaultVerifier(cfg) {
110
111
  }
111
112
  return new KeycloakJWTVerifier({ issuer: cfg.issuer, clientId: cfg.clientId });
112
113
  }
114
+ /** A bare string is the legacy form: `userIdDomain`, also the only trusted domain. */
115
+ function normalizeIdentityOptions(opts) {
116
+ const o = typeof opts === 'string' ? { userIdDomain: opts } : opts ?? {};
117
+ let domains = [];
118
+ if (o.trustedEmailDomains?.length) {
119
+ domains = o.trustedEmailDomains;
120
+ }
121
+ else if (o.userIdDomain) {
122
+ domains = [o.userIdDomain];
123
+ }
124
+ const trusted = domains.map(d => d.toLowerCase());
125
+ return { userIdDomain: o.userIdDomain, trusted };
126
+ }
113
127
  /**
114
- * Resolves the LiteLLM user_id from the verified claims with strict identity validation.
128
+ * Resolves the LiteLLM user_id for a verified bridge token.
115
129
  *
116
- * PR-6 identity mapping:
117
- * - Prefer `email` with `email_verified === true` whose domain equals `userIdDomain`.
118
- * - If userIdDomain is unset, always reject unverified emails (guard against misconfig).
119
- * - Never strip an arbitrary domain from `preferred_username`.
120
- * - Rejects invalid/missing identity with 403.
130
+ * Two separate questions, answered separately:
131
+ * 1. Is this person allowed in? The token must carry a *verified* email whose
132
+ * domain is in the trusted list (`litellm.bridge.allowedEmailDomains`,
133
+ * defaulting to `litellm.userIdDomain`). With no trusted domain configured
134
+ * everyone is rejected (fail closed). Failure → 403.
135
+ * 2. Which LiteLLM user are they? The same one the UI addresses: the Backstage
136
+ * user entity name (the Keycloak `preferred_username`) run through the same
137
+ * `litellm.userIdDomain` rule the UI applies. Using the email local part
138
+ * here would address a different user whenever a Keycloak username differs
139
+ * from the email (and would provision a duplicate). The realm is the same
140
+ * IdP Backstage signs users in with, so its usernames are the entity names.
121
141
  *
122
- * @param claims Verified JWT claims from the bridge token
123
- * @param userIdDomain Expected email domain (e.g. "example.com"); if set, email must match
124
- * @returns The resolved LiteLLM user_id
125
142
  * @throws BridgeIdentityError (403) if identity validation fails
126
143
  */
127
- function resolveBridgeUserId(claims, userIdDomain) {
128
- // The bridge maps identities by verified email only. Without a configured
129
- // domain we can't tell our users from another tenant's, and stripping the
130
- // domain would merge alice@a.com and alice@b.com — fail closed.
131
- if (!userIdDomain) {
132
- throw new BridgeIdentityError('litellm.userIdDomain must be configured to use the CLI bridge');
144
+ function resolveBridgeUserId(claims, identity) {
145
+ const { userIdDomain, trusted } = normalizeIdentityOptions(identity);
146
+ if (trusted.length === 0) {
147
+ throw new BridgeIdentityError('set litellm.bridge.allowedEmailDomains (or litellm.userIdDomain) to use the CLI bridge');
133
148
  }
134
149
  if (!claims.email) {
135
150
  throw new BridgeIdentityError('no email found in token');
@@ -141,11 +156,12 @@ function resolveBridgeUserId(claims, userIdDomain) {
141
156
  if (parts.length !== 2 || !parts[0]) {
142
157
  throw new BridgeIdentityError('malformed email in token');
143
158
  }
144
- const [localPart, domain] = parts;
145
- if (domain.toLowerCase() !== userIdDomain.toLowerCase()) {
159
+ const [emailLocalPart, domain] = parts;
160
+ if (!trusted.includes(domain.toLowerCase())) {
146
161
  throw new BridgeIdentityError(`email domain mismatch: "${domain}" is not allowed for this deployment`);
147
162
  }
148
- return (0, provisioning_1.toLiteLLMUserId)(localPart, userIdDomain);
163
+ const name = claims.preferred_username?.trim() || emailLocalPart;
164
+ return (0, provisioning_1.toLiteLLMUserId)(name, userIdDomain);
149
165
  }
150
166
  /**
151
167
  * Ensures a LiteLLM user exists for the verified identity. If the user is
package/dist/index.cjs.js CHANGED
@@ -10292,12 +10292,9 @@ function registerKeysRoutes(router, ctx) {
10292
10292
  try {
10293
10293
  const { keyId } = req.params;
10294
10294
  const { tokenEntityRef, key } = await authorizeKeyAction(req, keyId);
10295
- const isOwnerUnblockingOwnBlock = key.metadata?.blocked_by === tokenEntityRef;
10296
- if (!isOwnerUnblockingOwnBlock) {
10297
- if (!await assertPermission(req, import_backstage_plugin_litellm_common3.litellmKeyUnblockPermission)) {
10298
- sendPermissionDenied(res, import_backstage_plugin_litellm_common3.litellmKeyUnblockPermission);
10299
- return;
10300
- }
10295
+ if (!await assertPermission(req, import_backstage_plugin_litellm_common3.litellmKeyUnblockPermission)) {
10296
+ sendPermissionDenied(res, import_backstage_plugin_litellm_common3.litellmKeyUnblockPermission);
10297
+ return;
10301
10298
  }
10302
10299
  await client.unblockKey(keyId);
10303
10300
  const updatedMetadata = {
@@ -12616,7 +12613,8 @@ function readBridgeConfig(config) {
12616
12613
  const enabled = config.getOptionalBoolean("litellm.bridge.enabled") ?? false;
12617
12614
  const issuer = config.getOptionalString("litellm.bridge.issuer");
12618
12615
  const clientId = config.getOptionalString("litellm.bridge.clientId") ?? "abby-cli";
12619
- return { enabled, issuer, clientId };
12616
+ const allowedEmailDomains = (config.getOptionalStringArray("litellm.bridge.allowedEmailDomains") ?? []).map((d) => d.trim().toLowerCase()).filter(Boolean);
12617
+ return { enabled, issuer, clientId, allowedEmailDomains };
12620
12618
  }
12621
12619
  var BridgeConfigError = class extends Error {
12622
12620
  };
@@ -12686,10 +12684,22 @@ function newDefaultVerifier(cfg) {
12686
12684
  }
12687
12685
  return new KeycloakJWTVerifier({ issuer: cfg.issuer, clientId: cfg.clientId });
12688
12686
  }
12689
- function resolveBridgeUserId(claims, userIdDomain) {
12690
- if (!userIdDomain) {
12687
+ function normalizeIdentityOptions(opts) {
12688
+ const o = typeof opts === "string" ? { userIdDomain: opts } : opts ?? {};
12689
+ let domains = [];
12690
+ if (o.trustedEmailDomains?.length) {
12691
+ domains = o.trustedEmailDomains;
12692
+ } else if (o.userIdDomain) {
12693
+ domains = [o.userIdDomain];
12694
+ }
12695
+ const trusted = domains.map((d) => d.toLowerCase());
12696
+ return { userIdDomain: o.userIdDomain, trusted };
12697
+ }
12698
+ function resolveBridgeUserId(claims, identity) {
12699
+ const { userIdDomain, trusted } = normalizeIdentityOptions(identity);
12700
+ if (trusted.length === 0) {
12691
12701
  throw new BridgeIdentityError(
12692
- "litellm.userIdDomain must be configured to use the CLI bridge"
12702
+ "set litellm.bridge.allowedEmailDomains (or litellm.userIdDomain) to use the CLI bridge"
12693
12703
  );
12694
12704
  }
12695
12705
  if (!claims.email) {
@@ -12702,13 +12712,14 @@ function resolveBridgeUserId(claims, userIdDomain) {
12702
12712
  if (parts.length !== 2 || !parts[0]) {
12703
12713
  throw new BridgeIdentityError("malformed email in token");
12704
12714
  }
12705
- const [localPart, domain] = parts;
12706
- if (domain.toLowerCase() !== userIdDomain.toLowerCase()) {
12715
+ const [emailLocalPart, domain] = parts;
12716
+ if (!trusted.includes(domain.toLowerCase())) {
12707
12717
  throw new BridgeIdentityError(
12708
12718
  `email domain mismatch: "${domain}" is not allowed for this deployment`
12709
12719
  );
12710
12720
  }
12711
- return toLiteLLMUserId(localPart, userIdDomain);
12721
+ const name = claims.preferred_username?.trim() || emailLocalPart;
12722
+ return toLiteLLMUserId(name, userIdDomain);
12712
12723
  }
12713
12724
  async function getOrProvisionUserFromClaims(client, claims, provisioningEnabled, provisioningDefaults, logger, userIdDomain) {
12714
12725
  const userId = resolveBridgeUserId(claims, userIdDomain);
@@ -12794,6 +12805,10 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
12794
12805
  const { config } = bridgeOpts;
12795
12806
  const generateKeyInputSchema = (0, import_backstage_plugin_litellm_common7.createGenerateKeyInputSchema)(ctx.keyValidationConfig);
12796
12807
  const bridgeCfg = readBridgeConfig(config);
12808
+ const bridgeIdentity = {
12809
+ userIdDomain,
12810
+ trustedEmailDomains: bridgeCfg.allowedEmailDomains
12811
+ };
12797
12812
  let tokenVerifier = bridgeOpts.tokenVerifier;
12798
12813
  if (bridgeCfg.enabled && !tokenVerifier) {
12799
12814
  try {
@@ -12844,7 +12859,7 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
12844
12859
  provisioningEnabled,
12845
12860
  provisioningDefaults,
12846
12861
  logger,
12847
- userIdDomain
12862
+ bridgeIdentity
12848
12863
  );
12849
12864
  res.json(keys);
12850
12865
  } catch (error) {
@@ -12864,7 +12879,7 @@ function registerBridgeRoutes(router, ctx, bridgeOpts) {
12864
12879
  return;
12865
12880
  }
12866
12881
  const input = parseResult.data;
12867
- const userId = resolveBridgeUserId(claims, userIdDomain);
12882
+ const userId = resolveBridgeUserId(claims, bridgeIdentity);
12868
12883
  const keyCreateCtx = {
12869
12884
  client,
12870
12885
  config: {