@acarmisc/backstage-plugin-litellm-backend 0.15.0 → 0.16.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.
Files changed (53) hide show
  1. package/config.d.ts +115 -22
  2. package/dist/bridge.d.ts +23 -16
  3. package/dist/bridge.js +51 -11
  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 +4248 -3375
  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 +130 -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
@@ -0,0 +1,196 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerObjectPermissionsRoutes = registerObjectPermissionsRoutes;
4
+ const backstage_plugin_litellm_common_1 = require("@acarmisc/backstage-plugin-litellm-common");
5
+ const teamAdmin_1 = require("../teamAdmin");
6
+ const teamBudgetVisibility_1 = require("../teamBudgetVisibility");
7
+ function registerObjectPermissionsRoutes(router, ctx) {
8
+ const { client, catalogClient, auth, permissions, logger, teamAdminCfg, teamBudgetVisibility, requireObjectPerms, sendTeamError, authorizeTeamSubresource, } = ctx;
9
+ // ── Knowledge-base (vector store) management ────────────────────────────
10
+ //
11
+ // Attaching a vector store to a team exposes its documents to every team
12
+ // key. Team admins may only reference stores that the operator has
13
+ // allowlisted in litellm.teamAdmin.allowedVectorStores — anything outside
14
+ // the allowlist is rejected, never silently dropped.
15
+ // The vector stores a team admin is allowed to attach (allowlist ∩ proxy).
16
+ router.get('/vector-stores', async (req, res) => {
17
+ if (!requireObjectPerms(res))
18
+ return;
19
+ if (!teamAdminCfg.group) {
20
+ res.status(500).json({ error: 'Team management is misconfigured (group is missing)' });
21
+ return;
22
+ }
23
+ const check = await (0, teamAdmin_1.assertTeamAdmin)({
24
+ req,
25
+ auth,
26
+ permissions,
27
+ catalogClient,
28
+ teamAdminGroup: teamAdminCfg.group,
29
+ permission: backstage_plugin_litellm_common_1.litellmTeamKnowledgebaseManagePermission,
30
+ logger,
31
+ });
32
+ if (!check.ok) {
33
+ res.status(check.status).json({ error: check.error });
34
+ return;
35
+ }
36
+ const allowed = new Set(teamAdminCfg.allowedVectorStores);
37
+ try {
38
+ const all = await client.listVectorStores();
39
+ res.json(all.filter(s => allowed.has(s.id) || (s.name && allowed.has(s.name))));
40
+ }
41
+ catch (err) {
42
+ sendTeamError(err, res);
43
+ }
44
+ });
45
+ // Replace the set of knowledge bases attached to a team the caller's group
46
+ // owns. Body: { vector_stores: string[] }. Every id must be in the
47
+ // allowlist. The other object_permission facets (mcp_servers) are preserved.
48
+ router.put('/teams/:teamId/knowledge-bases', async (req, res) => {
49
+ if (!requireObjectPerms(res))
50
+ return;
51
+ const authz = await authorizeTeamSubresource(req, res, backstage_plugin_litellm_common_1.litellmTeamKnowledgebaseManagePermission);
52
+ if (!authz)
53
+ return;
54
+ const { teamId, owningGroup, actor, team } = authz;
55
+ const requested = (req.body?.vector_stores ?? []);
56
+ if (!Array.isArray(requested) ||
57
+ !requested.every(v => typeof v === 'string')) {
58
+ res
59
+ .status(400)
60
+ .json({ error: 'vector_stores must be an array of strings' });
61
+ return;
62
+ }
63
+ const allowed = new Set(teamAdminCfg.allowedVectorStores);
64
+ const offenders = requested.filter(v => !allowed.has(v));
65
+ if (offenders.length) {
66
+ res.status(400).json({
67
+ error: `vector store(s) not in the allowed set for team admins: ${offenders.join(', ')}`,
68
+ });
69
+ return;
70
+ }
71
+ const objectPermission = {
72
+ ...(team.object_permission ?? {}),
73
+ vector_stores: requested,
74
+ };
75
+ try {
76
+ const updated = await client.updateTeam({
77
+ team_id: teamId,
78
+ object_permission: objectPermission,
79
+ // /team/update replaces metadata wholesale — re-send the merged
80
+ // object so owning_group / created_* survive and the change is
81
+ // attributed.
82
+ metadata: {
83
+ ...(team.metadata ?? {}),
84
+ updated_by_backstage_user: actor,
85
+ updated_at_iso: new Date().toISOString(),
86
+ },
87
+ });
88
+ logger.info('team.knowledgebase.set', {
89
+ actor,
90
+ teamId,
91
+ owningGroup,
92
+ vector_stores: requested,
93
+ });
94
+ res.json(teamBudgetVisibility.hideTeamBudgetForManagers
95
+ ? (0, teamBudgetVisibility_1.redactTeamBudget)(updated)
96
+ : updated);
97
+ }
98
+ catch (err) {
99
+ sendTeamError(err, res);
100
+ }
101
+ });
102
+ // ── MCP server management ──────────────────────────────────────────────
103
+ //
104
+ // Attaching an MCP server to a team grants every team key the ability to
105
+ // invoke that server's tools through the model. Same allowlist + hard-reject
106
+ // discipline as knowledge bases, plus a structured audit event on every
107
+ // change so attaches are always attributable.
108
+ router.get('/mcp-servers', async (req, res) => {
109
+ if (!requireObjectPerms(res))
110
+ return;
111
+ if (!teamAdminCfg.group) {
112
+ res.status(500).json({ error: 'Team management is misconfigured (group is missing)' });
113
+ return;
114
+ }
115
+ const check = await (0, teamAdmin_1.assertTeamAdmin)({
116
+ req,
117
+ auth,
118
+ permissions,
119
+ catalogClient,
120
+ teamAdminGroup: teamAdminCfg.group,
121
+ permission: backstage_plugin_litellm_common_1.litellmTeamMcpManagePermission,
122
+ logger,
123
+ });
124
+ if (!check.ok) {
125
+ res.status(check.status).json({ error: check.error });
126
+ return;
127
+ }
128
+ const allowed = new Set(teamAdminCfg.allowedMcpServers);
129
+ try {
130
+ const all = await client.listMcpServers();
131
+ res.json(all.filter(s => allowed.has(s.id) || (s.name && allowed.has(s.name))));
132
+ }
133
+ catch (err) {
134
+ sendTeamError(err, res);
135
+ }
136
+ });
137
+ // Replace the set of MCP servers attached to a team the caller's group owns.
138
+ // Body: { mcp_servers: string[] }. Every id must be allowlisted. The
139
+ // vector_stores facet of object_permission is preserved.
140
+ router.put('/teams/:teamId/mcp-servers', async (req, res) => {
141
+ if (!requireObjectPerms(res))
142
+ return;
143
+ const authz = await authorizeTeamSubresource(req, res, backstage_plugin_litellm_common_1.litellmTeamMcpManagePermission);
144
+ if (!authz)
145
+ return;
146
+ const { teamId, owningGroup, actor, team } = authz;
147
+ const requested = (req.body?.mcp_servers ?? []);
148
+ if (!Array.isArray(requested) ||
149
+ !requested.every(v => typeof v === 'string')) {
150
+ res
151
+ .status(400)
152
+ .json({ error: 'mcp_servers must be an array of strings' });
153
+ return;
154
+ }
155
+ const allowed = new Set(teamAdminCfg.allowedMcpServers);
156
+ const offenders = requested.filter(v => !allowed.has(v));
157
+ if (offenders.length) {
158
+ res.status(400).json({
159
+ error: `MCP server(s) not in the allowed set for team admins: ${offenders.join(', ')}`,
160
+ });
161
+ return;
162
+ }
163
+ const before = team.object_permission?.mcp_servers ?? [];
164
+ const objectPermission = {
165
+ ...(team.object_permission ?? {}),
166
+ mcp_servers: requested,
167
+ };
168
+ try {
169
+ const updated = await client.updateTeam({
170
+ team_id: teamId,
171
+ object_permission: objectPermission,
172
+ // /team/update replaces metadata wholesale — re-send the merged
173
+ // object so owning_group / created_* survive and the change is
174
+ // attributed.
175
+ metadata: {
176
+ ...(team.metadata ?? {}),
177
+ updated_by_backstage_user: actor,
178
+ updated_at_iso: new Date().toISOString(),
179
+ },
180
+ });
181
+ logger.info('team.mcp.set', {
182
+ actor,
183
+ teamId,
184
+ owningGroup,
185
+ before,
186
+ after: requested,
187
+ });
188
+ res.json(teamBudgetVisibility.hideTeamBudgetForManagers
189
+ ? (0, teamBudgetVisibility_1.redactTeamBudget)(updated)
190
+ : updated);
191
+ }
192
+ catch (err) {
193
+ sendTeamError(err, res);
194
+ }
195
+ });
196
+ }
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ import type { RouterContext } from './context';
3
+ export declare function registerOpencodeRoutes(router: Router, ctx: RouterContext): void;
@@ -0,0 +1,239 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerOpencodeRoutes = registerOpencodeRoutes;
4
+ const provisioning_1 = require("../provisioning");
5
+ const backstage_plugin_litellm_common_1 = require("@acarmisc/backstage-plugin-litellm-common");
6
+ const client_1 = require("../client");
7
+ const errors_1 = require("../errors");
8
+ /**
9
+ * Only loopback http://localhost:<port>/callback redirect URIs are accepted —
10
+ * the OpenCode plugin runs its callback listener on 127.0.0.1. This
11
+ * keeps the flow from being usable as an open redirect.
12
+ */
13
+ function isValidRedirectUri(uri) {
14
+ if (!uri)
15
+ return false;
16
+ try {
17
+ const url = new URL(uri);
18
+ return (url.protocol === 'http:' &&
19
+ (url.hostname === 'localhost' || url.hostname === '127.0.0.1') &&
20
+ url.pathname === '/callback');
21
+ }
22
+ catch {
23
+ return false;
24
+ }
25
+ }
26
+ function registerOpencodeRoutes(router, ctx) {
27
+ const { client, catalogClient, auth, logger, userIdDomain, provisioningEnabled, provisioningDefaults, roleConfigs, opencodeCfg, assertPermission, sendPermissionDenied, } = ctx;
28
+ // ── OpenCode SSO-connect ─────────────────────────────────────────────────
29
+ //
30
+ // Client side: the opencode-portal-auth plugin. Handshake contract (see
31
+ // that package's README):
32
+ //
33
+ // GET /opencode/connect?team=<id>&redirect_uri=http://localhost:<port>/callback
34
+ // → (SSO-authenticated) returns an HTML confirmation form (no state change)
35
+ //
36
+ // POST /opencode/connect with form data (team, redirect_uri)
37
+ // → validates all conditions, rotates or generates a key
38
+ // → 302 redirect to {redirect_uri}?key=<plaintext-key>
39
+ //
40
+ // Mounted only when litellm.opencode.enabled is true. Key creation is
41
+ // gated by the same litellmKeyCreatePermission as POST /keys/generate.
42
+ if (opencodeCfg.enabled) {
43
+ logger.info(`OpenCode connect endpoint enabled — duration=${opencodeCfg.keyDuration}, maxBudget=$${opencodeCfg.maxBudget}`);
44
+ // Helper to validate auth, permissions, redirect_uri, and team membership.
45
+ // Reused by both GET and POST.
46
+ // Returns null if validation fails (response already sent).
47
+ // Returns an object with validated values if all checks pass.
48
+ // Note: after successful validation, redirectUri is guaranteed to be a valid string.
49
+ const validateOpenCodeRequest = async (req, res) => {
50
+ const tokenEntityRef = await (0, provisioning_1.resolveUserId)(req, auth);
51
+ if (!tokenEntityRef) {
52
+ res.status(401).json({ error: 'Authentication required' });
53
+ return null;
54
+ }
55
+ // For GET, redirectUri is in query; for POST, it's in body.
56
+ const redirectUriParam = (req.query.redirect_uri || req.body.redirect_uri);
57
+ if (!isValidRedirectUri(redirectUriParam)) {
58
+ res.status(400).json({
59
+ error: 'Invalid redirect_uri — expected http://localhost:<port>/callback',
60
+ });
61
+ return null;
62
+ }
63
+ // After isValidRedirectUri check, we know redirectUriParam is a valid string (! to tell TS).
64
+ const redirectUri = redirectUriParam;
65
+ if (!(await assertPermission(req, backstage_plugin_litellm_common_1.litellmKeyCreatePermission))) {
66
+ sendPermissionDenied(res, backstage_plugin_litellm_common_1.litellmKeyCreatePermission);
67
+ return null;
68
+ }
69
+ const userId = (0, provisioning_1.toLiteLLMUserId)(tokenEntityRef, userIdDomain);
70
+ const userInfo = await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
71
+ // Optional team binding: must be one of the user's own teams.
72
+ const teamId = (req.query.team || req.body.team);
73
+ if (teamId) {
74
+ const userTeams = userInfo?.teams ?? [];
75
+ if (!userTeams.includes(teamId)) {
76
+ res.status(403).json({
77
+ error: 'Access denied: team is not one of your teams',
78
+ team: teamId,
79
+ });
80
+ return null;
81
+ }
82
+ }
83
+ else if (opencodeCfg.requireTeam) {
84
+ res.status(400).json({ error: 'A team is required for this connection' });
85
+ return null;
86
+ }
87
+ return { tokenEntityRef, userId, userInfo, teamId, redirectUri };
88
+ };
89
+ router.get('/opencode/connect', async (req, res) => {
90
+ try {
91
+ const validation = await validateOpenCodeRequest(req, res);
92
+ if (!validation)
93
+ return;
94
+ const { teamId, redirectUri } = validation;
95
+ // GET returns an HTML form without changing state (no key mint/rotate yet).
96
+ // The form allows the user to review the connection and submit to POST.
97
+ const teamDisplay = teamId ? ` for team ${escapeHtml(teamId)}` : '';
98
+ const html = `<!DOCTYPE html>
99
+ <html>
100
+ <head>
101
+ <meta charset="utf-8">
102
+ <meta name="viewport" content="width=device-width, initial-scale=1">
103
+ <title>OpenCode Connect</title>
104
+ <style>
105
+ body { font-family: system-ui, -apple-system, sans-serif; margin: 0; padding: 2rem; background: #f5f5f5; }
106
+ .container { max-width: 400px; margin: 0 auto; background: white; border-radius: 8px; padding: 2rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
107
+ h1 { margin-top: 0; color: #333; font-size: 1.5rem; }
108
+ p { color: #666; line-height: 1.5; }
109
+ button { background: #0066cc; color: white; border: none; padding: 0.75rem 1.5rem; border-radius: 4px; cursor: pointer; font-size: 1rem; }
110
+ button:hover { background: #0052a3; }
111
+ </style>
112
+ </head>
113
+ <body>
114
+ <div class="container">
115
+ <h1>Connect to OpenCode</h1>
116
+ <p>This will create or rotate your OpenCode API key${teamDisplay}.</p>
117
+ <form method="POST">
118
+ <input type="hidden" name="redirect_uri" value="${escapeHtml(redirectUri)}">
119
+ ${teamId ? `<input type="hidden" name="team" value="${escapeHtml(teamId)}">` : ''}
120
+ <button type="submit">Connect</button>
121
+ </form>
122
+ </div>
123
+ </body>
124
+ </html>`;
125
+ res.set('Cache-Control', 'no-store');
126
+ res.set('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; form-action 'self' http://localhost:* http://127.0.0.1:*");
127
+ res.set('Content-Type', 'text/html; charset=utf-8');
128
+ res.status(200).send(html);
129
+ }
130
+ catch (error) {
131
+ if (error instanceof provisioning_1.ProvisioningError) {
132
+ res.status(error.status).json(error.body);
133
+ return;
134
+ }
135
+ (0, errors_1.sendError)(res, error, logger, 'OpenCode connect GET');
136
+ }
137
+ });
138
+ router.post('/opencode/connect', async (req, res) => {
139
+ try {
140
+ const validation = await validateOpenCodeRequest(req, res);
141
+ if (!validation)
142
+ return;
143
+ const { tokenEntityRef, userId, teamId, redirectUri } = validation;
144
+ // Alias is stable per user+team so re-connecting rotates the same
145
+ // logical key slot. Check if an existing key exists for this slot.
146
+ const alias = teamId ? `opencode-${userId}-${teamId}` : `opencode-${userId}`;
147
+ const existing = await client
148
+ .listKeys(userId)
149
+ .catch(() => []);
150
+ // Find a healthy existing key (not blocked).
151
+ const reusable = existing.find(k => k.key_alias === alias && !k.blocked && k.user_id === userId);
152
+ // Mint a brand-new key bound to this user (and team) under the slot alias.
153
+ const mintNewKey = async () => {
154
+ const profile = await (0, provisioning_1.resolveUserProfile)(tokenEntityRef, catalogClient, auth, logger);
155
+ const result = await client.generateKey({
156
+ alias,
157
+ duration: opencodeCfg.keyDuration,
158
+ max_budget: opencodeCfg.maxBudget,
159
+ team_id: teamId,
160
+ metadata: {
161
+ ...opencodeCfg.metadata,
162
+ created_via: 'opencode-connect',
163
+ created_by_backstage_user: tokenEntityRef,
164
+ ...(profile.email && { created_by_email: profile.email }),
165
+ ...(teamId && { opencode_team: teamId }),
166
+ },
167
+ user_id: userId,
168
+ });
169
+ return result.key;
170
+ };
171
+ let key;
172
+ let rotated = false;
173
+ if (reusable?.token ?? reusable?.key) {
174
+ const keyHashOrId = (reusable.token ?? reusable.key);
175
+ try {
176
+ // Rotate the existing key in place via LiteLLM's key regeneration.
177
+ key = (await client.regenerateKey(keyHashOrId)).key;
178
+ rotated = true;
179
+ }
180
+ catch (err) {
181
+ // Key regeneration isn't available on every LiteLLM edition. Only
182
+ // "not supported / not found" answers fall back to replacing the
183
+ // key (delete + generate under the same alias); auth, upstream
184
+ // outages and everything else still fail the request.
185
+ // Open-source LiteLLM answers with an "Enterprise feature" error
186
+ // (a 500), so recognise that by message as well as by status.
187
+ const unsupported = err instanceof client_1.LiteLLMUpstreamError &&
188
+ ([400, 404, 405, 501].includes(err.status) ||
189
+ /enterprise|premium/i.test(err.message));
190
+ if (!unsupported)
191
+ throw err;
192
+ logger.warn('LiteLLM key regeneration unavailable; replacing the OpenCode key instead');
193
+ await client.deleteKeys({ keys: [keyHashOrId] });
194
+ key = await mintNewKey();
195
+ rotated = true;
196
+ }
197
+ }
198
+ else {
199
+ key = await mintNewKey();
200
+ }
201
+ if (!key) {
202
+ res.status(502).json({ error: 'LiteLLM returned no key material' });
203
+ return;
204
+ }
205
+ const url = new URL(redirectUri);
206
+ url.searchParams.set('key', key);
207
+ if (teamId)
208
+ url.searchParams.set('team', teamId);
209
+ logger.info('opencode.connect', {
210
+ userId,
211
+ team: teamId ?? null,
212
+ rotated,
213
+ });
214
+ res.redirect(302, url.href);
215
+ }
216
+ catch (error) {
217
+ if (error instanceof provisioning_1.ProvisioningError) {
218
+ res.status(error.status).json(error.body);
219
+ return;
220
+ }
221
+ (0, errors_1.sendError)(res, error, logger, 'OpenCode connect POST');
222
+ }
223
+ });
224
+ }
225
+ /**
226
+ * Escape HTML special characters in strings to prevent XSS.
227
+ * Used when interpolating team IDs and redirect URIs into the confirmation form.
228
+ */
229
+ function escapeHtml(text) {
230
+ const map = {
231
+ '&': '&amp;',
232
+ '<': '&lt;',
233
+ '>': '&gt;',
234
+ '"': '&quot;',
235
+ "'": '&#39;',
236
+ };
237
+ return text.replace(/[&<>"']/g, c => map[c]);
238
+ }
239
+ }
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ import type { RouterContext } from './context';
3
+ export declare function registerTeamUsageRoutes(router: Router, ctx: RouterContext): void;
@@ -0,0 +1,73 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.registerTeamUsageRoutes = registerTeamUsageRoutes;
4
+ const provisioning_1 = require("../provisioning");
5
+ const teamBudgetVisibility_1 = require("../teamBudgetVisibility");
6
+ const errors_1 = require("../errors");
7
+ const teamFetch_1 = require("../http/teamFetch");
8
+ const withUser_1 = require("./middleware/withUser");
9
+ function registerTeamUsageRoutes(router, ctx) {
10
+ const { client, catalogClient, auth, logger, provisioningEnabled, provisioningDefaults, roleConfigs, teamBudgetVisibility, } = ctx;
11
+ const requireUser = (0, withUser_1.createRequireUser)(ctx);
12
+ router.get('/teams/:teamId/usage', requireUser, async (req, res) => {
13
+ try {
14
+ const { teamId } = req.params;
15
+ const { start_date, end_date } = req.query;
16
+ if (!start_date || !end_date) {
17
+ res.status(400).json({ error: 'start_date and end_date are required' });
18
+ return;
19
+ }
20
+ // Authorize: member of the team OR a team manager
21
+ const tokenEntityRef = res.locals.tokenEntityRef;
22
+ const userId = res.locals.userId;
23
+ const userInfo = await (0, provisioning_1.getOrProvisionUser)(client, tokenEntityRef, userId, provisioningEnabled, provisioningDefaults, roleConfigs, catalogClient, auth, logger);
24
+ // Check membership: user must be in the team OR be a team manager
25
+ const isMember = userInfo?.teams?.includes(teamId) ?? false;
26
+ // A "manager" administers this specific team: team management is enabled
27
+ // and the caller belongs to the team's owning group (metadata.owning_group).
28
+ // Membership of the global team-admin group alone is not enough.
29
+ let isManager = false;
30
+ // Skip the extra lookup when it can't change the outcome: members always
31
+ // get through, and manager-ness only matters for members when the budget
32
+ // hiding flags differ between the two roles.
33
+ const managerCheckNeeded = ctx.teamMgmtEnabled &&
34
+ (!isMember ||
35
+ teamBudgetVisibility.hideTeamBudgetForMembers !==
36
+ teamBudgetVisibility.hideTeamBudgetForManagers);
37
+ if (managerCheckNeeded) {
38
+ try {
39
+ const team = await (0, teamFetch_1.withTeamFetchRetry)(() => client.getTeamInfo(teamId));
40
+ const owningGroup = typeof team.metadata?.owning_group === 'string' ? team.metadata.owning_group : undefined;
41
+ if (owningGroup) {
42
+ isManager = await (0, provisioning_1.isUserMemberOfGroup)(tokenEntityRef, owningGroup, catalogClient, auth, logger);
43
+ }
44
+ }
45
+ catch (err) {
46
+ const message = err instanceof Error ? err.message : String(err);
47
+ logger.warn(`Team-manager check failed: ${message}`);
48
+ }
49
+ }
50
+ if (!isMember && !isManager) {
51
+ res.status(404).json({ error: 'Team not found' });
52
+ return;
53
+ }
54
+ const usage = await client.getTeamUsage(teamId, start_date, end_date);
55
+ // Spend figures would let a client derive a hidden budget
56
+ // (budget = spend / pct), so they follow the same hiding flags.
57
+ // Which flag applies depends on whether the caller is a team manager;
58
+ // catalog failures fail closed to the member rule.
59
+ let hide = teamBudgetVisibility.hideTeamBudgetForMembers;
60
+ if (isManager) {
61
+ hide = teamBudgetVisibility.hideTeamBudgetForManagers;
62
+ }
63
+ res.json(hide ? (0, teamBudgetVisibility_1.redactTeamUsage)(usage) : usage);
64
+ }
65
+ catch (error) {
66
+ if (error instanceof provisioning_1.ProvisioningError) {
67
+ res.status(error.status).json(error.body);
68
+ return;
69
+ }
70
+ (0, errors_1.sendError)(res, error, logger, 'fetch team usage');
71
+ }
72
+ });
73
+ }
@@ -0,0 +1,3 @@
1
+ import { Router } from 'express';
2
+ import type { RouterContext } from './context';
3
+ export declare function registerTeamsRoutes(router: Router, ctx: RouterContext): void;