@backstage/backend-app-api 0.7.6-next.0 → 0.7.6-next.2

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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # @backstage/backend-app-api
2
2
 
3
+ ## 0.7.6-next.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+ - @backstage/cli-node@0.2.6-next.1
9
+ - @backstage/backend-plugin-api@0.6.19-next.2
10
+ - @backstage/backend-common@0.23.0-next.2
11
+ - @backstage/plugin-permission-node@0.7.30-next.2
12
+ - @backstage/backend-tasks@0.5.24-next.2
13
+ - @backstage/plugin-auth-node@0.4.14-next.2
14
+ - @backstage/config-loader@1.8.0
15
+ - @backstage/cli-common@0.1.13
16
+ - @backstage/config@1.2.0
17
+ - @backstage/errors@1.2.4
18
+ - @backstage/types@1.1.1
19
+
20
+ ## 0.7.6-next.1
21
+
22
+ ### Patch Changes
23
+
24
+ - 398b82a: Add support for JWKS tokens in ExternalTokenHandler.
25
+ - 9e63318: Added an optional `accessRestrictions` to external access service tokens and service principals in general, such that you can limit their access to certain plugins or permissions.
26
+ - Updated dependencies
27
+ - @backstage/backend-tasks@0.5.24-next.1
28
+ - @backstage/backend-plugin-api@0.6.19-next.1
29
+ - @backstage/plugin-permission-node@0.7.30-next.1
30
+ - @backstage/backend-common@0.23.0-next.1
31
+ - @backstage/cli-node@0.2.6-next.0
32
+ - @backstage/config-loader@1.8.0
33
+ - @backstage/plugin-auth-node@0.4.14-next.1
34
+
3
35
  ## 0.7.6-next.0
4
36
 
5
37
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@backstage/backend-app-api",
3
- "version": "0.7.6-next.0",
3
+ "version": "0.7.6-next.2",
4
4
  "main": "../dist/alpha.cjs.js",
5
5
  "types": "../dist/alpha.d.ts"
6
6
  }
package/config.d.ts CHANGED
@@ -88,6 +88,46 @@ export interface Config {
88
88
  */
89
89
  subject: string;
90
90
  };
91
+ /**
92
+ * Restricts what types of access that are permitted for this access
93
+ * method. If no access restrictions are given, it'll have unlimited
94
+ * access. This access restriction applies for the framework level;
95
+ * individual plugins may have their own access control mechanisms
96
+ * on top of this.
97
+ */
98
+ accessRestrictions?: Array<{
99
+ /**
100
+ * Permit access to make requests to this plugin.
101
+ *
102
+ * Can be further refined by setting additional fields below.
103
+ */
104
+ plugin: string;
105
+ /**
106
+ * If given, this method is limited to only performing actions
107
+ * with these named permissions in this plugin.
108
+ *
109
+ * Note that this only applies where permissions checks are
110
+ * enabled in the first place. Endpoints that are not protected by
111
+ * the permissions system at all, are not affected by this
112
+ * setting.
113
+ */
114
+ permission?: string | Array<string>;
115
+ /**
116
+ * If given, this method is limited to only performing actions
117
+ * whose permissions have these attributes.
118
+ *
119
+ * Note that this only applies where permissions checks are
120
+ * enabled in the first place. Endpoints that are not protected by
121
+ * the permissions system at all, are not affected by this
122
+ * setting.
123
+ */
124
+ permissionAttribute?: {
125
+ /**
126
+ * One of more of 'create', 'read', 'update', or 'delete'.
127
+ */
128
+ action?: string | Array<string>;
129
+ };
130
+ }>;
91
131
  }
92
132
  | {
93
133
  /**
@@ -130,6 +170,87 @@ export interface Config {
130
170
  */
131
171
  subject: string;
132
172
  };
173
+ /**
174
+ * Restricts what types of access that are permitted for this access
175
+ * method. If no access restrictions are given, it'll have unlimited
176
+ * access. This access restriction applies for the framework level;
177
+ * individual plugins may have their own access control mechanisms
178
+ * on top of this.
179
+ */
180
+ accessRestrictions?: Array<{
181
+ /**
182
+ * Permit access to make requests to this plugin.
183
+ *
184
+ * Can be further refined by setting additional fields below.
185
+ */
186
+ plugin: string;
187
+ /**
188
+ * If given, this method is limited to only performing actions
189
+ * with these named permissions in this plugin.
190
+ *
191
+ * Note that this only applies where permissions checks are
192
+ * enabled in the first place. Endpoints that are not protected by
193
+ * the permissions system at all, are not affected by this
194
+ * setting.
195
+ */
196
+ permission?: string | Array<string>;
197
+ /**
198
+ * If given, this method is limited to only performing actions
199
+ * whose permissions have these attributes.
200
+ *
201
+ * Note that this only applies where permissions checks are
202
+ * enabled in the first place. Endpoints that are not protected by
203
+ * the permissions system at all, are not affected by this
204
+ * setting.
205
+ */
206
+ permissionAttribute?: {
207
+ /**
208
+ * One of more of 'create', 'read', 'update', or 'delete'.
209
+ */
210
+ action?: string | Array<string>;
211
+ };
212
+ }>;
213
+ }
214
+ | {
215
+ /**
216
+ * This access method consists of a JWKS endpoint that can be used to
217
+ * verify JWT tokens.
218
+ *
219
+ * Callers generate JWT tokens via 3rd party tooling
220
+ * and pass them in the Authorization header:
221
+ *
222
+ * ```
223
+ * Authorization: Bearer eZv5o+fW3KnR3kVabMW4ZcDNLPl8nmMW
224
+ * ```
225
+ */
226
+ type: 'jwks';
227
+ options: {
228
+ /**
229
+ * The full URL of the JWKS endpoint.
230
+ */
231
+ url: string;
232
+ /**
233
+ * Sets the algorithm(s) that should be used to verify the JWT tokens.
234
+ * The passed JWTs must have been signed using one of the listed algorithms.
235
+ */
236
+ algorithm?: string | string[];
237
+ /**
238
+ * Sets the issuer(s) that should be used to verify the JWT tokens.
239
+ * Passed JWTs must have an `iss` claim which matches one of the specified issuers.
240
+ */
241
+ issuer?: string | string[];
242
+ /**
243
+ * Sets the audience(s) that should be used to verify the JWT tokens.
244
+ * The passed JWTs must have an "aud" claim that matches one of the audiences specified,
245
+ * or have no audience specified.
246
+ */
247
+ audience?: string | string[];
248
+ /**
249
+ * Sets an optional subject prefix. Passes the subject to called plugins.
250
+ * Useful for debugging and tracking purposes.
251
+ */
252
+ subjectPrefix?: string;
253
+ };
133
254
  }
134
255
  >;
135
256
  };
package/dist/index.cjs.js CHANGED
@@ -1697,14 +1697,15 @@ class DatabaseKeyStore {
1697
1697
  }
1698
1698
  }
1699
1699
 
1700
- function createCredentialsWithServicePrincipal(sub, token) {
1700
+ function createCredentialsWithServicePrincipal(sub, token, accessRestrictions) {
1701
1701
  return {
1702
1702
  $$type: "@backstage/BackstageCredentials",
1703
1703
  version: "v1",
1704
1704
  token,
1705
1705
  principal: {
1706
1706
  type: "service",
1707
- subject: sub
1707
+ subject: sub,
1708
+ accessRestrictions
1708
1709
  }
1709
1710
  };
1710
1711
  }
@@ -1783,7 +1784,11 @@ class DefaultAuthService {
1783
1784
  }
1784
1785
  const externalResult = await this.externalTokenHandler.verifyToken(token);
1785
1786
  if (externalResult) {
1786
- return createCredentialsWithServicePrincipal(externalResult.subject);
1787
+ return createCredentialsWithServicePrincipal(
1788
+ externalResult.subject,
1789
+ void 0,
1790
+ externalResult.accessRestrictions
1791
+ );
1787
1792
  }
1788
1793
  throw new errors.AuthenticationError("Illegal token");
1789
1794
  }
@@ -2197,18 +2202,112 @@ class UserTokenHandler {
2197
2202
  }
2198
2203
  }
2199
2204
 
2205
+ function readAccessRestrictionsFromConfig(externalAccessEntryConfig) {
2206
+ const configs = externalAccessEntryConfig.getOptionalConfigArray("accessRestrictions") ?? [];
2207
+ const result = /* @__PURE__ */ new Map();
2208
+ for (const config of configs) {
2209
+ const validKeys = ["plugin", "permission", "permissionAttribute"];
2210
+ for (const key of config.keys()) {
2211
+ if (!validKeys.includes(key)) {
2212
+ const valid = validKeys.map((k) => `'${k}'`).join(", ");
2213
+ throw new Error(
2214
+ `Invalid key '${key}' in 'accessRestrictions' config, expected one of ${valid}`
2215
+ );
2216
+ }
2217
+ }
2218
+ const pluginId = config.getString("plugin");
2219
+ const permissionNames = readPermissionNames(config);
2220
+ const permissionAttributes = readPermissionAttributes(config);
2221
+ if (result.has(pluginId)) {
2222
+ throw new Error(
2223
+ `Attempted to declare 'accessRestrictions' twice for plugin '${pluginId}', which is not permitted`
2224
+ );
2225
+ }
2226
+ result.set(pluginId, {
2227
+ ...permissionNames ? { permissionNames } : {},
2228
+ ...permissionAttributes ? { permissionAttributes } : {}
2229
+ });
2230
+ }
2231
+ return result.size ? result : void 0;
2232
+ }
2233
+ function readStringOrStringArrayFromConfig(root, key, validValues) {
2234
+ if (!root.has(key)) {
2235
+ return void 0;
2236
+ }
2237
+ const rawValues = Array.isArray(root.get(key)) ? root.getStringArray(key) : [root.getString(key)];
2238
+ const values = [
2239
+ ...new Set(
2240
+ rawValues.map((v) => v.split(/[ ,]/)).flat().filter(Boolean)
2241
+ )
2242
+ ];
2243
+ if (!values.length) {
2244
+ return void 0;
2245
+ }
2246
+ if (validValues?.length) {
2247
+ for (const value of values) {
2248
+ if (!validValues.includes(value)) {
2249
+ const valid = validValues.map((k) => `'${k}'`).join(", ");
2250
+ throw new Error(
2251
+ `Invalid value '${value}' at '${key}' in 'permissionAttributes' config, valid values are ${valid}`
2252
+ );
2253
+ }
2254
+ }
2255
+ }
2256
+ return values;
2257
+ }
2258
+ function readPermissionNames(externalAccessEntryConfig) {
2259
+ return readStringOrStringArrayFromConfig(
2260
+ externalAccessEntryConfig,
2261
+ "permission"
2262
+ );
2263
+ }
2264
+ function readPermissionAttributes(externalAccessEntryConfig) {
2265
+ const config = externalAccessEntryConfig.getOptionalConfig(
2266
+ "permissionAttribute"
2267
+ );
2268
+ if (!config) {
2269
+ return void 0;
2270
+ }
2271
+ const validKeys = ["action"];
2272
+ for (const key of config.keys()) {
2273
+ if (!validKeys.includes(key)) {
2274
+ const valid = validKeys.map((k) => `'${k}'`).join(", ");
2275
+ throw new Error(
2276
+ `Invalid key '${key}' in 'permissionAttribute' config, expected ${valid}`
2277
+ );
2278
+ }
2279
+ }
2280
+ const action = readStringOrStringArrayFromConfig(config, "action", [
2281
+ "create",
2282
+ "read",
2283
+ "update",
2284
+ "delete"
2285
+ ]);
2286
+ const result = {
2287
+ ...action ? { action } : {}
2288
+ };
2289
+ return Object.keys(result).length ? result : void 0;
2290
+ }
2291
+
2200
2292
  class LegacyTokenHandler {
2201
- #entries = [];
2202
- add(options) {
2203
- this.#doAdd(options.getString("secret"), options.getString("subject"));
2293
+ #entries = new Array();
2294
+ add(config) {
2295
+ const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
2296
+ this.#doAdd(
2297
+ config.getString("options.secret"),
2298
+ config.getString("options.subject"),
2299
+ allAccessRestrictions
2300
+ );
2204
2301
  }
2205
2302
  // used only for the old backend.auth.keys array
2206
- addOld(options) {
2207
- this.#doAdd(options.getString("secret"), "external:backstage-plugin");
2303
+ addOld(config) {
2304
+ this.#doAdd(config.getString("secret"), "external:backstage-plugin");
2208
2305
  }
2209
- #doAdd(secret, subject) {
2306
+ #doAdd(secret, subject, allAccessRestrictions) {
2210
2307
  if (!secret.match(/^\S+$/)) {
2211
2308
  throw new Error("Illegal secret, must be a valid base64 string");
2309
+ } else if (!subject.match(/^\S+$/)) {
2310
+ throw new Error("Illegal subject, must be a set of non-space characters");
2212
2311
  }
2213
2312
  let key;
2214
2313
  try {
@@ -2216,10 +2315,18 @@ class LegacyTokenHandler {
2216
2315
  } catch {
2217
2316
  throw new Error("Illegal secret, must be a valid base64 string");
2218
2317
  }
2219
- if (!subject.match(/^\S+$/)) {
2220
- throw new Error("Illegal subject, must be a set of non-space characters");
2318
+ if (this.#entries.some((e) => e.key === key)) {
2319
+ throw new Error(
2320
+ "Legacy externalAccess token was declared more than once"
2321
+ );
2221
2322
  }
2222
- this.#entries.push({ key, subject });
2323
+ this.#entries.push({
2324
+ key,
2325
+ result: {
2326
+ subject,
2327
+ allAccessRestrictions
2328
+ }
2329
+ });
2223
2330
  }
2224
2331
  async verifyToken(token) {
2225
2332
  try {
@@ -2234,10 +2341,10 @@ class LegacyTokenHandler {
2234
2341
  } catch (e) {
2235
2342
  return void 0;
2236
2343
  }
2237
- for (const entry of this.#entries) {
2344
+ for (const { key, result } of this.#entries) {
2238
2345
  try {
2239
- await jose.jwtVerify(token, entry.key);
2240
- return { subject: entry.subject };
2346
+ await jose.jwtVerify(token, key);
2347
+ return result;
2241
2348
  } catch (e) {
2242
2349
  if (e.code !== "ERR_JWS_SIGNATURE_VERIFICATION_FAILED") {
2243
2350
  throw e;
@@ -2250,45 +2357,104 @@ class LegacyTokenHandler {
2250
2357
 
2251
2358
  const MIN_TOKEN_LENGTH = 8;
2252
2359
  class StaticTokenHandler {
2253
- #entries = [];
2254
- add(options) {
2255
- const token = options.getString("token");
2360
+ #entries = /* @__PURE__ */ new Map();
2361
+ add(config) {
2362
+ const token = config.getString("options.token");
2363
+ const subject = config.getString("options.subject");
2364
+ const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
2256
2365
  if (!token.match(/^\S+$/)) {
2257
2366
  throw new Error("Illegal token, must be a set of non-space characters");
2258
- }
2259
- if (token.length < MIN_TOKEN_LENGTH) {
2367
+ } else if (token.length < MIN_TOKEN_LENGTH) {
2260
2368
  throw new Error(
2261
2369
  `Illegal token, must be at least ${MIN_TOKEN_LENGTH} characters length`
2262
2370
  );
2263
- }
2264
- const subject = options.getString("subject");
2265
- if (!subject.match(/^\S+$/)) {
2371
+ } else if (!subject.match(/^\S+$/)) {
2266
2372
  throw new Error("Illegal subject, must be a set of non-space characters");
2373
+ } else if (this.#entries.has(token)) {
2374
+ throw new Error(
2375
+ "Static externalAccess token was declared more than once"
2376
+ );
2267
2377
  }
2268
- this.#entries.push({ token, subject });
2378
+ this.#entries.set(token, { subject, allAccessRestrictions });
2269
2379
  }
2270
2380
  async verifyToken(token) {
2271
- const entry = this.#entries.find((e) => e.token === token);
2272
- if (!entry) {
2273
- return void 0;
2381
+ return this.#entries.get(token);
2382
+ }
2383
+ }
2384
+
2385
+ class JWKSHandler {
2386
+ #entries = [];
2387
+ add(config) {
2388
+ if (!config.getString("options.url").match(/^\S+$/)) {
2389
+ throw new Error(
2390
+ "Illegal JWKS URL, must be a set of non-space characters"
2391
+ );
2274
2392
  }
2275
- return { subject: entry.subject };
2393
+ const algorithms = readStringOrStringArrayFromConfig(
2394
+ config,
2395
+ "options.algorithm"
2396
+ );
2397
+ const issuers = readStringOrStringArrayFromConfig(config, "options.issuer");
2398
+ const audiences = readStringOrStringArrayFromConfig(
2399
+ config,
2400
+ "options.audience"
2401
+ );
2402
+ const subjectPrefix = config.getOptionalString("options.subjectPrefix");
2403
+ const url = new URL(config.getString("options.url"));
2404
+ const jwks = jose.createRemoteJWKSet(url);
2405
+ const allAccessRestrictions = readAccessRestrictionsFromConfig(config);
2406
+ this.#entries.push({
2407
+ algorithms,
2408
+ audiences,
2409
+ issuers,
2410
+ jwks,
2411
+ subjectPrefix,
2412
+ url,
2413
+ allAccessRestrictions
2414
+ });
2415
+ }
2416
+ async verifyToken(token) {
2417
+ for (const entry of this.#entries) {
2418
+ try {
2419
+ const {
2420
+ payload: { sub }
2421
+ } = await jose.jwtVerify(token, entry.jwks, {
2422
+ algorithms: entry.algorithms,
2423
+ issuer: entry.issuers,
2424
+ audience: entry.audiences
2425
+ });
2426
+ if (sub) {
2427
+ const prefix = entry.subjectPrefix ? `external:${entry.subjectPrefix}:` : "external:";
2428
+ return {
2429
+ subject: `${prefix}${sub}`,
2430
+ allAccessRestrictions: entry.allAccessRestrictions
2431
+ };
2432
+ }
2433
+ } catch {
2434
+ continue;
2435
+ }
2436
+ }
2437
+ return void 0;
2276
2438
  }
2277
2439
  }
2278
2440
 
2279
2441
  const NEW_CONFIG_KEY = "backend.auth.externalAccess";
2280
2442
  const OLD_CONFIG_KEY = "backend.auth.keys";
2443
+ let loggedDeprecationWarning = false;
2281
2444
  class ExternalTokenHandler {
2282
- constructor(handlers) {
2445
+ constructor(ownPluginId, handlers) {
2446
+ this.ownPluginId = ownPluginId;
2283
2447
  this.handlers = handlers;
2284
2448
  }
2285
2449
  static create(options) {
2286
- const { config, logger } = options;
2450
+ const { ownPluginId, config, logger } = options;
2287
2451
  const staticHandler = new StaticTokenHandler();
2288
2452
  const legacyHandler = new LegacyTokenHandler();
2453
+ const jwksHandler = new JWKSHandler();
2289
2454
  const handlers = {
2290
2455
  static: staticHandler,
2291
- legacy: legacyHandler
2456
+ legacy: legacyHandler,
2457
+ jwks: jwksHandler
2292
2458
  };
2293
2459
  const handlerConfigs = config.getOptionalConfigArray(NEW_CONFIG_KEY) ?? [];
2294
2460
  for (const handlerConfig of handlerConfigs) {
@@ -2300,10 +2466,11 @@ class ExternalTokenHandler {
2300
2466
  `Unknown type '${type}' in ${NEW_CONFIG_KEY}, expected one of ${valid}`
2301
2467
  );
2302
2468
  }
2303
- handler.add(handlerConfig.getConfig("options"));
2469
+ handler.add(handlerConfig);
2304
2470
  }
2305
2471
  const legacyConfigs = config.getOptionalConfigArray(OLD_CONFIG_KEY) ?? [];
2306
- if (legacyConfigs.length) {
2472
+ if (legacyConfigs.length && !loggedDeprecationWarning) {
2473
+ loggedDeprecationWarning = true;
2307
2474
  logger.warn(
2308
2475
  `DEPRECATION WARNING: The ${OLD_CONFIG_KEY} config has been replaced by ${NEW_CONFIG_KEY}, see https://backstage.io/docs/auth/service-to-service-auth`
2309
2476
  );
@@ -2311,13 +2478,29 @@ class ExternalTokenHandler {
2311
2478
  for (const handlerConfig of legacyConfigs) {
2312
2479
  legacyHandler.addOld(handlerConfig);
2313
2480
  }
2314
- return new ExternalTokenHandler(Object.values(handlers));
2481
+ return new ExternalTokenHandler(ownPluginId, Object.values(handlers));
2315
2482
  }
2316
2483
  async verifyToken(token) {
2317
2484
  for (const handler of this.handlers) {
2318
2485
  const result = await handler.verifyToken(token);
2319
2486
  if (result) {
2320
- return result;
2487
+ const { allAccessRestrictions, ...rest } = result;
2488
+ if (allAccessRestrictions) {
2489
+ const accessRestrictions = allAccessRestrictions.get(
2490
+ this.ownPluginId
2491
+ );
2492
+ if (!accessRestrictions) {
2493
+ const valid = [...allAccessRestrictions.keys()].map((k) => `'${k}'`).join(", ");
2494
+ throw new errors.NotAllowedError(
2495
+ `This token's access is restricted to plugin(s) ${valid}`
2496
+ );
2497
+ }
2498
+ return {
2499
+ ...rest,
2500
+ accessRestrictions
2501
+ };
2502
+ }
2503
+ return rest;
2321
2504
  }
2322
2505
  }
2323
2506
  return void 0;
@@ -2338,16 +2521,7 @@ const authServiceFactory = backendPluginApi.createServiceFactory({
2338
2521
  // new auth services in the new backend system.
2339
2522
  tokenManager: backendPluginApi.coreServices.tokenManager
2340
2523
  },
2341
- async createRootContext({ config, logger }) {
2342
- const externalTokens = ExternalTokenHandler.create({
2343
- config,
2344
- logger
2345
- });
2346
- return {
2347
- externalTokens
2348
- };
2349
- },
2350
- async factory({ config, discovery, plugin, tokenManager, logger, database }, { externalTokens }) {
2524
+ async factory({ config, discovery, plugin, tokenManager, logger, database }) {
2351
2525
  const disableDefaultAuthPolicy = Boolean(
2352
2526
  config.getOptionalBoolean(
2353
2527
  "backend.auth.dangerouslyDisableDefaultAuthPolicy"
@@ -2367,6 +2541,11 @@ const authServiceFactory = backendPluginApi.createServiceFactory({
2367
2541
  publicKeyStore,
2368
2542
  discovery
2369
2543
  });
2544
+ const externalTokens = ExternalTokenHandler.create({
2545
+ ownPluginId: plugin.getId(),
2546
+ config,
2547
+ logger
2548
+ });
2370
2549
  return new DefaultAuthService(
2371
2550
  userTokens,
2372
2551
  pluginTokens,