@nacre.work/api 0.5.8 → 0.7.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/server.js CHANGED
@@ -4,7 +4,7 @@ import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
4
4
  // DOM, so the global URL is not typed here.
5
5
  import { URL } from 'node:url';
6
6
  import { logger, MetadataError, MultipartError, multipartBoundary, parseMultipart, queryAudit, parseFilters, parseMetadata, PROTECTED_RESOURCE_PATH, JWKS_PATH, AUTHORIZATION_SERVER_PATH, AUTHORIZE_PATH, CODE_TTL_MS, REGISTER_PATH, TOKEN_PATH, authorizationServerMetadata, consentRedirect, generateClientId, generateCode, redirectAllowed, verifierMatches, ADMIN_PREFIX, adminRoutes, withAuditSinks, TooBusy, } from '@nacre.work/core';
7
- import { authenticate, rejectTenantOverride } from './auth.js';
7
+ import { administers, administersTenants, authenticate, rejectTenantOverride, } from './auth.js';
8
8
  import { badRequest, internal, notFound, Problem } from './errors.js';
9
9
  import { isConflict, isReplay } from './idempotency.js';
10
10
  import { limitHeaders } from './limits.js';
@@ -793,15 +793,17 @@ async function handle(req, res, options) {
793
793
  // ─────────────────────── the authorization server ───────────────────────
794
794
  //
795
795
  // Three endpoints, and one decision running through all of them: the token
796
- // this flow issues acts as a **service account**, never as the person who
797
- // signed in. A consent screen that hands an agent your authority collapses
798
- // "what may this agent read" into "what may you read", which is the
796
+ // this flow issues acts as whatever the *person* chose at consent — as them,
797
+ // or as an agent. Both are offered because they are different acts. A
798
+ // delegation reaches exactly what its person reaches and is recomputed every
799
+ // request; an agent is a principal with its own grants, and collapsing "what
800
+ // may this agent read" into "what may you read" would throw away the
799
801
  // distinction this whole product is built on.
800
802
  //
801
803
  // Unauthenticated by necessity — a client arrives here with no credential,
802
804
  // which is what it came to get. Authority is created at exactly one point:
803
805
  // `POST /v1/oauth/consent`, which is inside the authenticated surface and is
804
- // where a signed-in person picks the agent.
806
+ // where a signed-in person makes that choice.
805
807
  if (options.oauth !== undefined && (instance === REGISTER_PATH || instance === AUTHORIZE_PATH || instance === TOKEN_PATH)) {
806
808
  const oauth = options.oauth;
807
809
  // RFC 7591. Open, which is what an MCP client expects and what the RFC is
@@ -1959,7 +1961,7 @@ async function handle(req, res, options) {
1959
1961
  // about what a caller can tell apart — it is that a member must not
1960
1962
  // reach module code at all, and "the module happened to have no matching
1961
1963
  // route" is not a reason to be safe.
1962
- if (auth.role !== 'platform_admin' && auth.role !== 'org_admin') {
1964
+ if (!administersTenants(auth) && !administers(auth)) {
1963
1965
  const problem = notFound(instance, requestId);
1964
1966
  send(res, problem.status, problem.toJSON(), requestId);
1965
1967
  return;
@@ -2154,7 +2156,7 @@ async function handle(req, res, options) {
2154
2156
  // build; it is for the multi-tenancy module, which inherits this endpoint
2155
2157
  // and where a platform administrator spans tenants. Writing the rule now
2156
2158
  // costs three lines. Retrofitting it later means auditing every caller.
2157
- if (auth.role !== 'org_admin' && auth.role !== 'platform_admin') {
2159
+ if (!administers(auth) && !administersTenants(auth)) {
2158
2160
  const problem = notFound(instance, requestId);
2159
2161
  send(res, problem.status, problem.toJSON(), requestId);
2160
2162
  return;
@@ -2187,7 +2189,7 @@ async function handle(req, res, options) {
2187
2189
  // `administrativeOnly` is set here and never read from the request. A
2188
2190
  // caller cannot widen their own view by omitting a parameter, which is
2189
2191
  // the shape this would take if it were a query filter.
2190
- { ...query, administrativeOnly: auth.role === 'platform_admin' }, page);
2192
+ { ...query, administrativeOnly: administersTenants(auth) }, page);
2191
2193
  // Reading the log is itself an access worth recording. It is the one
2192
2194
  // action where leaving it out is self-serving: an administrator who can
2193
2195
  // read who-read-what without that read appearing is a hole in exactly the
@@ -2270,9 +2272,16 @@ async function handle(req, res, options) {
2270
2272
  id: c.id,
2271
2273
  client_id: c.clientId,
2272
2274
  client_name: c.clientName,
2273
- service_account_id: c.serviceAccountId,
2275
+ // What it acts as, named rather than inferred from which of the
2276
+ // two id fields came back — the screen has to tell a person "this
2277
+ // application acts as you" from "this application acts as an
2278
+ // agent", and those are different sentences.
2279
+ acts_as: c.subject.actsAs,
2280
+ service_account_id: c.subject.actsAs === 'service_account' ? c.subject.serviceAccountId : null,
2274
2281
  service_account_name: c.serviceAccountName,
2275
2282
  approved_by: c.approvedBy,
2283
+ // Empty means the delegation reaches everything its approver does.
2284
+ layers: c.layers,
2276
2285
  created_at: c.createdAt,
2277
2286
  last_refreshed_at: c.lastRefreshedAt,
2278
2287
  revoked_at: c.revokedAt,
@@ -2331,8 +2340,66 @@ async function handle(req, res, options) {
2331
2340
  const redirectUri = need('redirect_uri');
2332
2341
  const codeChallenge = need('code_challenge');
2333
2342
  const serviceAccountId = need('service_account_id');
2334
- if (clientId === undefined || redirectUri === undefined || codeChallenge === undefined || serviceAccountId === undefined) {
2335
- const problem = badRequest(instance, requestId, "'client_id', 'redirect_uri', 'code_challenge' and 'service_account_id' are required.");
2343
+ if (clientId === undefined || redirectUri === undefined || codeChallenge === undefined) {
2344
+ const problem = badRequest(instance, requestId, "'client_id', 'redirect_uri' and 'code_challenge' are required.");
2345
+ send(res, problem.status, problem.toJSON(), requestId);
2346
+ return;
2347
+ }
2348
+ // Naming an agent is what makes this the agent flow; naming nobody is a
2349
+ // delegation. Not a mode flag beside the field, because two ways to say
2350
+ // one thing is two ways for them to disagree — and this endpoint used to
2351
+ // *require* the agent, which is exactly why a member reached a screen
2352
+ // they could not complete.
2353
+ const delegating = serviceAccountId === undefined;
2354
+ // Never delegable. It spans tenants in the multi-tenancy module, so a
2355
+ // delegation of it would be an escalation out of the organization this
2356
+ // screen is scoped to — the same argument that already refuses minting
2357
+ // one from an org-scoped endpoint. Refused here *and* again at
2358
+ // validation: this is the reachable path, the other is the one that holds
2359
+ // if a token is ever minted some other way.
2360
+ if (delegating && administersTenants(auth)) {
2361
+ const problem = notFound(instance, requestId);
2362
+ send(res, problem.status, problem.toJSON(), requestId);
2363
+ return;
2364
+ }
2365
+ // The narrowing, which can only ever remove. Ids rather than slugs: this
2366
+ // is stored and read on the authentication path, and a slug is renameable
2367
+ // — a narrowing that follows a rename is one that silently changes what a
2368
+ // person approved.
2369
+ const narrowing = consent['layers'];
2370
+ if (narrowing !== undefined && !isStringArray(narrowing)) {
2371
+ const problem = badRequest(instance, requestId, "'layers' must be an array of layer ids.");
2372
+ send(res, problem.status, problem.toJSON(), requestId);
2373
+ return;
2374
+ }
2375
+ // The permission ceiling, which is the dimension a person reaches for
2376
+ // first. Validated here rather than left to the database, so a typo is a
2377
+ // 400 naming the field instead of a constraint violation as a 500.
2378
+ const ceiling = consent['permissions'];
2379
+ if (ceiling !== undefined && (!isStringArray(ceiling) || ceiling.some((p) => !PERMISSIONS.includes(p)))) {
2380
+ const problem = badRequest(instance, requestId, "'permissions' must be an array of 'read', 'write' or 'admin'.");
2381
+ send(res, problem.status, problem.toJSON(), requestId);
2382
+ return;
2383
+ }
2384
+ if (delegating && isStringArray(ceiling) && ceiling.length === 0) {
2385
+ // Empty is not "no ceiling", it is a delegation that can do nothing —
2386
+ // a restriction nobody meant to write, and the database refuses one
2387
+ // too. Omitting the field is how a caller says there is no ceiling.
2388
+ const problem = badRequest(instance, requestId, "'permissions' cannot be empty. Omit it for a delegation with no restriction.");
2389
+ send(res, problem.status, problem.toJSON(), requestId);
2390
+ return;
2391
+ }
2392
+ if (!delegating && ceiling !== undefined) {
2393
+ // An agent's reach is its grants, which are an administrator's to set.
2394
+ const problem = badRequest(instance, requestId, "'permissions' restricts a delegation, and this consent names an agent.");
2395
+ send(res, problem.status, problem.toJSON(), requestId);
2396
+ return;
2397
+ }
2398
+ if (!delegating && narrowing !== undefined && narrowing.length > 0) {
2399
+ // An agent's reach is its grants, and those are an administrator's to
2400
+ // set. Accepting a narrowing here and storing it against a connection
2401
+ // nothing reads it for would be a control that does nothing.
2402
+ const problem = badRequest(instance, requestId, "'layers' narrows a delegation, and this consent names an agent.");
2336
2403
  send(res, problem.status, problem.toJSON(), requestId);
2337
2404
  return;
2338
2405
  }
@@ -2351,23 +2418,45 @@ async function handle(req, res, options) {
2351
2418
  // consent theirs to give. `404` for anything else, because an agent they
2352
2419
  // cannot see and one that does not exist are the same answer here as
2353
2420
  // everywhere.
2354
- const visible = options.serviceAccounts === undefined
2355
- ? undefined
2356
- : (await options.serviceAccounts.list(auth, { limit: 200, after: undefined })).items.find((a) => a.id === serviceAccountId);
2357
- if (visible === undefined) {
2358
- const problem = notFound(instance, requestId);
2359
- send(res, problem.status, problem.toJSON(), requestId);
2360
- return;
2421
+ if (!delegating) {
2422
+ const visible = options.serviceAccounts === undefined
2423
+ ? undefined
2424
+ : (await options.serviceAccounts.list(auth, { limit: 200, after: undefined })).items.find((a) => a.id === serviceAccountId);
2425
+ if (visible === undefined) {
2426
+ const problem = notFound(instance, requestId);
2427
+ send(res, problem.status, problem.toJSON(), requestId);
2428
+ return;
2429
+ }
2430
+ }
2431
+ // Every named layer has to be one this caller can read.
2432
+ //
2433
+ // Not a security check — a narrowing can only remove, so a layer they
2434
+ // cannot reach would simply contribute nothing. It is here because the
2435
+ // alternative is a foreign key violation surfacing as a 500 on a typo,
2436
+ // and because a person approving a restriction should be told when the
2437
+ // restriction they wrote is not the one they meant. `404` for an unknown
2438
+ // id and for an unreadable one alike, on invariant I6.
2439
+ if (delegating && narrowing !== undefined && narrowing.length > 0) {
2440
+ const readable = options.layers === undefined
2441
+ ? []
2442
+ : (await options.layers.list(auth, { limit: 500, after: undefined })).items.map((l) => l.id);
2443
+ if (narrowing.some((id) => !readable.includes(id))) {
2444
+ const problem = notFound(instance, requestId);
2445
+ send(res, problem.status, problem.toJSON(), requestId);
2446
+ return;
2447
+ }
2361
2448
  }
2362
2449
  // The standing connection first, then the code that points at it. That
2363
2450
  // order matters: the code is exchanged for a refresh token hung on the
2364
2451
  // connection, and a code with no connection would mint access nobody can
2365
2452
  // ever take back — which is the gap this closes.
2366
- const consentId = await options.oauth.consents.record(auth, clientId, serviceAccountId);
2453
+ const subject = delegating
2454
+ ? { actsAs: 'user', userId: auth.principal.id }
2455
+ : { actsAs: 'service_account', serviceAccountId: serviceAccountId };
2456
+ const consentId = await options.oauth.consents.record(auth, clientId, subject, narrowing === undefined ? [] : narrowing, isStringArray(ceiling) ? ceiling : undefined);
2367
2457
  const code = generateCode();
2368
- await options.oauth.authorizations.approve(auth, {
2458
+ const common = {
2369
2459
  orgId: auth.orgId,
2370
- serviceAccountId,
2371
2460
  clientId,
2372
2461
  redirectUri,
2373
2462
  codeChallenge,
@@ -2375,7 +2464,13 @@ async function handle(req, res, options) {
2375
2464
  code,
2376
2465
  consentId,
2377
2466
  expiresAt: new Date(Date.now() + CODE_TTL_MS),
2378
- });
2467
+ };
2468
+ // Two calls rather than one with a union in it. The delegated shape
2469
+ // *requires* the connection id and the agent shape does not, and writing
2470
+ // that as one object hands the compiler a value it cannot place in either
2471
+ // arm — which is the same reason 0025 gave the database a CHECK per mode
2472
+ // instead of one that permits both halves.
2473
+ await options.oauth.authorizations.approve(auth, subject.actsAs === 'user' ? { ...common, subject } : { ...common, subject });
2379
2474
  // Recorded, because "why does this agent hold a token" is a question an
2380
2475
  // administrator will ask and the grant table cannot answer: the grants
2381
2476
  // were there before, and what changed is that a client was handed the
@@ -2385,7 +2480,20 @@ async function handle(req, res, options) {
2385
2480
  actor: `${auth.principal.type}:${auth.principal.id}`,
2386
2481
  action: 'oauth.consent',
2387
2482
  result: 'allow',
2388
- detail: { client_id: clientId, client_name: client.clientName, service_account_id: serviceAccountId },
2483
+ detail: {
2484
+ client_id: clientId,
2485
+ client_name: client.clientName,
2486
+ acts_as: subject.actsAs,
2487
+ ...(delegating
2488
+ ? {
2489
+ delegation_id: consentId,
2490
+ layers: narrowing ?? [],
2491
+ // Recorded because it is the answer to "why can this
2492
+ // application not delete anything", asked six months later.
2493
+ permissions: isStringArray(ceiling) ? ceiling : 'no ceiling',
2494
+ }
2495
+ : { service_account_id: serviceAccountId }),
2496
+ },
2389
2497
  requestId,
2390
2498
  });
2391
2499
  // The redirect is returned rather than performed: this is an API call
@@ -2405,7 +2513,7 @@ async function handle(req, res, options) {
2405
2513
  // in the organization rather than an object inside a workspace, and there
2406
2514
  // is no scope to check it against — someone holding admin on one layer
2407
2515
  // must not be able to mint credentials.
2408
- if (auth.role !== 'org_admin') {
2516
+ if (!administers(auth)) {
2409
2517
  const problem = notFound(instance, requestId);
2410
2518
  send(res, problem.status, problem.toJSON(), requestId);
2411
2519
  return;
@@ -2470,7 +2578,7 @@ async function handle(req, res, options) {
2470
2578
  }
2471
2579
  const accountMatch = /^\/v1\/service-accounts\/([^/]+)$/.exec(instance);
2472
2580
  if (req.method === 'DELETE' && accountMatch && options.serviceAccounts !== undefined) {
2473
- if (auth.role !== 'org_admin') {
2581
+ if (!administers(auth)) {
2474
2582
  const problem = notFound(instance, requestId);
2475
2583
  send(res, problem.status, problem.toJSON(), requestId);
2476
2584
  return;
@@ -2506,7 +2614,7 @@ async function handle(req, res, options) {
2506
2614
  const principalPath = /^\/v1\/(users|groups)(\/.*)?$/.exec(instance);
2507
2615
  if (principalPath !== null) {
2508
2616
  const port = principalPath[1] === 'users' ? options.users : options.groups;
2509
- if (port !== undefined && auth.role !== 'org_admin') {
2617
+ if (port !== undefined && !administers(auth)) {
2510
2618
  await options.audit.write({
2511
2619
  orgId: auth.orgId,
2512
2620
  actor: `${auth.principal.type}:${auth.principal.id}`,