@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 +9 -0
- package/dist/bridge.d.ts +32 -13
- package/dist/bridge.js +35 -19
- package/dist/index.cjs.js +30 -15
- package/dist/index.cjs.js.map +2 -2
- package/dist/routes/bridge.js +7 -2
- package/dist/routes/keys.js +9 -9
- package/package.json +1 -1
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
|
-
*
|
|
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
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
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,
|
|
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
|
-
|
|
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
|
|
128
|
+
* Resolves the LiteLLM user_id for a verified bridge token.
|
|
115
129
|
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
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,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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 [
|
|
145
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
10296
|
-
|
|
10297
|
-
|
|
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
|
-
|
|
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
|
|
12690
|
-
|
|
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.
|
|
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 [
|
|
12706
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
12882
|
+
const userId = resolveBridgeUserId(claims, bridgeIdentity);
|
|
12868
12883
|
const keyCreateCtx = {
|
|
12869
12884
|
client,
|
|
12870
12885
|
config: {
|