@canopy-io/node 0.10.0 → 0.11.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/index.js CHANGED
@@ -602,10 +602,28 @@ var Identities = class {
602
602
  * question at the node that matters. This is for showing a person what
603
603
  * someone can do.
604
604
  */
605
- permissions(id) {
605
+ permissions(id, query = {}) {
606
606
  return this.client.request(
607
607
  "GET",
608
- `/api/v1/identities/${encodeURIComponent(id)}/permissions`
608
+ `/api/v1/identities/${encodeURIComponent(id)}/permissions`,
609
+ { query: { ...query } }
610
+ );
611
+ }
612
+ /**
613
+ * Where the identity may act with one permission, answered by Canopy for a
614
+ * list query: the minimal grant roots, and every node beneath them up to
615
+ * `max_nodes` (null past it, with `complete: false`), or `env_wide` when a
616
+ * root is the Environment's own root and no filter is needed.
617
+ *
618
+ * `LocalAuthorizer.accessibleNodes` answers the same question from its
619
+ * cache without a call; this is the fresh answer, and the one for a process
620
+ * that does not run the authorizer.
621
+ */
622
+ accessibleNodes(id, query) {
623
+ return this.client.request(
624
+ "GET",
625
+ `/api/v1/identities/${encodeURIComponent(id)}/accessible-nodes`,
626
+ { query: { ...query } }
609
627
  );
610
628
  }
611
629
  /**
@@ -1149,6 +1167,7 @@ var Canopy = class {
1149
1167
  // src/authorizer.ts
1150
1168
  var DEFAULT_TTL_MS = 6e4;
1151
1169
  var MAX_TTL_MS = 3e5;
1170
+ var DEFAULT_MAX_NODES = 1e4;
1152
1171
  var MAX_WALK_DEPTH = 256;
1153
1172
  var PAGE_SIZE = 100;
1154
1173
  var MAX_PAGES = 1e4;
@@ -1165,11 +1184,20 @@ function understood(page, path) {
1165
1184
  }
1166
1185
  var GRANTS_PATH = (identityId) => `/api/v1/identities/${encodeURIComponent(identityId)}/grants`;
1167
1186
  var PARENTS_PATH = "/api/v1/nodes/parents";
1187
+ function inEffect(window, now) {
1188
+ return (window.from === null || window.from <= now) && (window.to === null || window.to > now);
1189
+ }
1190
+ function reasonAmong(windows, now) {
1191
+ return windows.some((window) => window.to !== null && window.to <= now) ? "expired" : "not_yet_effective";
1192
+ }
1168
1193
  var LocalAuthorizer = class {
1169
1194
  client;
1170
1195
  ttlMs;
1196
+ maxNodes;
1171
1197
  now;
1172
1198
  readOptions;
1199
+ onDecision;
1200
+ onDenied;
1173
1201
  grants = /* @__PURE__ */ new Map();
1174
1202
  tree;
1175
1203
  /**
@@ -1197,22 +1225,39 @@ var LocalAuthorizer = class {
1197
1225
  `ttlMs must be between 0 and ${MAX_TTL_MS} (five minutes); got ${String(this.ttlMs)}. It is how long a revoked user keeps access, so it is capped.`
1198
1226
  );
1199
1227
  }
1228
+ this.maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
1229
+ if (!Number.isInteger(this.maxNodes) || this.maxNodes < 1) {
1230
+ throw new RangeError(
1231
+ `maxNodes must be a positive integer; got ${String(this.maxNodes)}.`
1232
+ );
1233
+ }
1200
1234
  this.now = options.now ?? (() => Date.now());
1235
+ this.onDecision = options.onDecision;
1236
+ this.onDenied = options.onDenied;
1201
1237
  this.readOptions = {
1202
1238
  ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
1203
1239
  ...options.maxRetries === void 0 ? {} : { maxRetries: options.maxRetries, maxBackoffMs: options.timeoutMs }
1204
1240
  };
1205
1241
  }
1206
1242
  /**
1207
- * Whether the identity holds the permission.
1243
+ * Whether the identity holds the permission, and why not when it does not.
1208
1244
  *
1209
1245
  * Shaped like the API's own evaluate so a caller can swap one for the other.
1210
1246
  * A `node` check with no node is a denial rather than an error: a request
1211
1247
  * whose subject cannot be established is exactly the one that must not pass.
1212
1248
  */
1213
1249
  async evaluate(query, options = {}) {
1250
+ const started = this.now();
1251
+ const decided = (decision) => {
1252
+ this.announce({
1253
+ query,
1254
+ decision,
1255
+ durationMs: Math.max(0, this.now() - started)
1256
+ });
1257
+ return decision;
1258
+ };
1214
1259
  if (!options.signal) {
1215
- return this.decide(query);
1260
+ return decided(await this.decide(query));
1216
1261
  }
1217
1262
  const signal = options.signal;
1218
1263
  let onAbort;
@@ -1229,25 +1274,157 @@ var LocalAuthorizer = class {
1229
1274
  onAbort = fail;
1230
1275
  signal.addEventListener("abort", fail, { once: true });
1231
1276
  });
1232
- return Promise.race([this.decide(query), aborted]).finally(() => {
1277
+ return Promise.race([this.decide(query), aborted]).then(decided).finally(() => {
1233
1278
  if (onAbort) {
1234
1279
  signal.removeEventListener("abort", onAbort);
1235
1280
  }
1236
1281
  });
1237
1282
  }
1283
+ /**
1284
+ * The nodes the identity holds the permission at, in effect now, reduced to
1285
+ * the ones no other such node already covers, and whether one of them is
1286
+ * the Environment's root. The roots alone are enough for an application
1287
+ * that keeps a closure table or a materialised path: it filters by "beneath
1288
+ * one of these" without an expanded set.
1289
+ *
1290
+ * Reads the tree, since minimality and `envWide` are questions about it; a
1291
+ * root the readable hierarchy does not contain raises
1292
+ * `authorizer.hierarchy_incomplete`, as a check at such a node does.
1293
+ */
1294
+ async grantRoots(identityId, permission) {
1295
+ const now = this.now();
1296
+ const held = (await this.grantRootsFor(identityId)).get(permission) ?? [];
1297
+ const granted = new Set(
1298
+ held.filter((window) => inEffect(window, now)).map((w) => w.node)
1299
+ );
1300
+ if (granted.size === 0) {
1301
+ return { roots: [], envWide: false };
1302
+ }
1303
+ const { parents } = await this.treeEntry();
1304
+ const roots = [];
1305
+ let envWide = false;
1306
+ for (const node of granted) {
1307
+ if (!parents.has(node)) {
1308
+ throw this.hierarchyIncomplete(node);
1309
+ }
1310
+ if (parents.get(node) === null) {
1311
+ envWide = true;
1312
+ }
1313
+ let covered = false;
1314
+ let depth = 0;
1315
+ for (let current = parents.get(node); current && depth < MAX_WALK_DEPTH; current = parents.get(current), depth += 1) {
1316
+ if (granted.has(current)) {
1317
+ covered = true;
1318
+ break;
1319
+ }
1320
+ }
1321
+ if (!covered) {
1322
+ roots.push(node);
1323
+ }
1324
+ }
1325
+ return { roots, envWide };
1326
+ }
1327
+ /**
1328
+ * Where the identity may act with the permission, as the set a list query
1329
+ * filters by: {@link grantRoots} expanded through their descendants, up to
1330
+ * `maxNodes` (the authorizer's default unless given here).
1331
+ *
1332
+ * Past the cap `nodes` is null and `complete` false: the set is too large
1333
+ * to be worth an `IN (...)` list, and the caller filters by `roots`
1334
+ * instead. An `envWide` answer carries no set either, since every node
1335
+ * qualifies and a filter would only slow the query down.
1336
+ *
1337
+ * Computed from the same cached grant roots and tree a check uses, so a
1338
+ * list filtered by it and the check on a write cannot disagree, and it goes
1339
+ * stale on the same `ttlMs`.
1340
+ */
1341
+ async accessibleNodes(identityId, permission, options = {}) {
1342
+ const cap = options.maxNodes ?? this.maxNodes;
1343
+ const { roots, envWide } = await this.grantRoots(identityId, permission);
1344
+ if (envWide) {
1345
+ return { roots, envWide: true, nodes: null, complete: true };
1346
+ }
1347
+ if (roots.length === 0) {
1348
+ return { roots, envWide: false, nodes: /* @__PURE__ */ new Set(), complete: true };
1349
+ }
1350
+ const { children } = await this.treeEntry();
1351
+ const nodes = /* @__PURE__ */ new Set();
1352
+ const pending = [...roots];
1353
+ while (pending.length > 0) {
1354
+ const node = pending.pop();
1355
+ if (nodes.has(node)) {
1356
+ continue;
1357
+ }
1358
+ nodes.add(node);
1359
+ if (nodes.size > cap) {
1360
+ return { roots, envWide: false, nodes: null, complete: false };
1361
+ }
1362
+ for (const child of children.get(node) ?? []) {
1363
+ pending.push(child);
1364
+ }
1365
+ }
1366
+ return { roots, envWide: false, nodes, complete: true };
1367
+ }
1368
+ /**
1369
+ * Every permission the identity holds at the node, by grant or by
1370
+ * inheritance: the list a screen renders its controls from, answered from
1371
+ * the same cached grant roots and tree a check uses.
1372
+ */
1373
+ async permissionsAt(identityId, nodeId) {
1374
+ const answers = await this.permissionsAtMany(identityId, [nodeId]);
1375
+ return answers.get(nodeId) ?? [];
1376
+ }
1377
+ /**
1378
+ * {@link permissionsAt} for several nodes at once, as node → permissions:
1379
+ * the rows of a list each with their own controls, answered from one read
1380
+ * of the grant roots and one walk per node.
1381
+ */
1382
+ async permissionsAtMany(identityId, nodeIds) {
1383
+ const now = this.now();
1384
+ const roots = await this.grantRootsFor(identityId);
1385
+ const answers = /* @__PURE__ */ new Map();
1386
+ for (const nodeId of new Set(nodeIds)) {
1387
+ const path = await this.lineageOf(nodeId);
1388
+ const held = [];
1389
+ for (const [permission, windows] of roots) {
1390
+ if (windows.some(
1391
+ (window) => path.has(window.node) && inEffect(window, now)
1392
+ )) {
1393
+ held.push(permission);
1394
+ }
1395
+ }
1396
+ answers.set(nodeId, held.sort());
1397
+ }
1398
+ return answers;
1399
+ }
1238
1400
  async decide(query) {
1239
- const roots = await this.grantRootsFor(query.identity_id);
1240
- const granted = roots.get(query.permission);
1241
- if (!granted || granted.size === 0) {
1242
- return { allowed: false };
1401
+ const now = this.now();
1402
+ const held = (await this.grantRootsFor(query.identity_id)).get(
1403
+ query.permission
1404
+ );
1405
+ if (!held || held.length === 0) {
1406
+ return { allowed: false, reason: "permission_not_held" };
1243
1407
  }
1244
1408
  if ((query.scope ?? "node") === "app_wide") {
1245
- return { allowed: true };
1409
+ return held.some((window) => inEffect(window, now)) ? { allowed: true, reason: null } : { allowed: false, reason: reasonAmong(held, now) };
1246
1410
  }
1247
1411
  if (!query.node_id) {
1248
- return { allowed: false };
1412
+ return { allowed: false, reason: "node_missing" };
1413
+ }
1414
+ if (held.some(
1415
+ (window) => window.node === query.node_id && inEffect(window, now)
1416
+ )) {
1417
+ return { allowed: true, reason: null };
1418
+ }
1419
+ const path = await this.lineageOf(query.node_id);
1420
+ const reachable = held.filter((window) => path.has(window.node));
1421
+ if (reachable.some((window) => inEffect(window, now))) {
1422
+ return { allowed: true, reason: null };
1249
1423
  }
1250
- return { allowed: await this.holdsAtNode(granted, query.node_id) };
1424
+ if (reachable.length === 0) {
1425
+ return { allowed: false, reason: "outside_grants" };
1426
+ }
1427
+ return { allowed: false, reason: reasonAmong(reachable, now) };
1251
1428
  }
1252
1429
  /** Counters for observability — how much traffic the cache is actually saving. */
1253
1430
  snapshot() {
@@ -1283,28 +1460,42 @@ var LocalAuthorizer = class {
1283
1460
  this.grants.clear();
1284
1461
  this.tree = void 0;
1285
1462
  }
1286
- /** Climb from `nodeId` and look for a grant root among its ancestors. */
1287
- async holdsAtNode(granted, nodeId) {
1288
- if (granted.has(nodeId)) {
1289
- return true;
1463
+ /** Fire the decision hooks, after the verdict and without waiting on them. */
1464
+ announce(event) {
1465
+ for (const hook of [
1466
+ this.onDecision,
1467
+ event.decision.allowed ? void 0 : this.onDenied
1468
+ ]) {
1469
+ if (!hook) {
1470
+ continue;
1471
+ }
1472
+ try {
1473
+ hook(event);
1474
+ } catch {
1475
+ }
1290
1476
  }
1291
- const parents = await this.parents();
1477
+ }
1478
+ /** The node and its ancestors, from the tree this client can read. */
1479
+ async lineageOf(nodeId) {
1480
+ const { parents } = await this.treeEntry();
1292
1481
  if (!parents.has(nodeId)) {
1293
- throw new CanopyAuthorizerError(
1294
- "authorizer.hierarchy_incomplete",
1295
- `The hierarchy this client can read does not contain node "${nodeId}", so authorization at that node cannot be decided. A scoped API key needs the \`hierarchy.view\` scope to read the tree.`
1296
- );
1482
+ throw this.hierarchyIncomplete(nodeId);
1297
1483
  }
1484
+ const path = /* @__PURE__ */ new Set([nodeId]);
1298
1485
  let current = parents.get(nodeId);
1299
1486
  let depth = 0;
1300
1487
  while (current && depth < MAX_WALK_DEPTH) {
1301
- if (granted.has(current)) {
1302
- return true;
1303
- }
1488
+ path.add(current);
1304
1489
  current = parents.get(current);
1305
1490
  depth += 1;
1306
1491
  }
1307
- return false;
1492
+ return path;
1493
+ }
1494
+ hierarchyIncomplete(nodeId) {
1495
+ return new CanopyAuthorizerError(
1496
+ "authorizer.hierarchy_incomplete",
1497
+ `The hierarchy this client can read does not contain node "${nodeId}", so authorization at that node cannot be decided. A scoped API key needs the \`hierarchy.view\` scope to read the tree.`
1498
+ );
1308
1499
  }
1309
1500
  async grantRootsFor(identityId) {
1310
1501
  const cached = this.grants.get(identityId);
@@ -1322,12 +1513,19 @@ var LocalAuthorizer = class {
1322
1513
  return read;
1323
1514
  }
1324
1515
  /**
1325
- * Every page of the identity's grant roots, folded into permission → nodes.
1516
+ * Every page of the identity's grant roots, with their windows, folded
1517
+ * into permission → roots.
1518
+ *
1519
+ * Each row is one permission at one node for one assignment, so a
1520
+ * permission granted at three nodes arrives as three rows, possibly on
1521
+ * different pages. Stopping after the first page would under-report what
1522
+ * the identity holds: a false deny, the safe direction, but a wrong answer
1523
+ * all the same.
1326
1524
  *
1327
- * Each row is one permission at one node, so a permission granted at three
1328
- * nodes arrives as three rows, possibly on different pages. Stopping after
1329
- * the first page would under-report what the identity holds: a false deny,
1330
- * the safe direction, but a wrong answer all the same.
1525
+ * Rows outside their window are kept: they are what lets a denial say
1526
+ * `expired` or `not_yet_effective`, and the entry expires at the nearest
1527
+ * window boundary when that comes before the TTL, so a grant takes effect
1528
+ * or ends on time rather than up to `ttlMs` late.
1331
1529
  */
1332
1530
  async fetchGrantRoots(identityId) {
1333
1531
  this.stats.grantFetches += 1;
@@ -1337,30 +1535,42 @@ var LocalAuthorizer = class {
1337
1535
  this.stats.grantPages += 1;
1338
1536
  return this.client.request("GET", path, {
1339
1537
  ...this.readOptions,
1340
- query
1538
+ query: { ...query, include: "windows" }
1341
1539
  }).then((page) => understood(page, path));
1342
1540
  },
1343
1541
  { page: 1, take: PAGE_SIZE },
1344
1542
  { maxPages: MAX_PAGES }
1345
1543
  );
1346
1544
  const roots = /* @__PURE__ */ new Map();
1545
+ const now = this.now();
1546
+ let boundary = Number.POSITIVE_INFINITY;
1347
1547
  for await (const row of rows) {
1348
- const nodes = roots.get(row.permission);
1349
- if (nodes) {
1350
- nodes.add(row.node_id);
1548
+ const window = {
1549
+ node: row.node_id,
1550
+ from: row.effective_from ? Date.parse(row.effective_from) : null,
1551
+ to: row.effective_to ? Date.parse(row.effective_to) : null
1552
+ };
1553
+ for (const edge of [window.from, window.to]) {
1554
+ if (edge !== null && edge > now && edge < boundary) {
1555
+ boundary = edge;
1556
+ }
1557
+ }
1558
+ const windows = roots.get(row.permission);
1559
+ if (windows) {
1560
+ windows.push(window);
1351
1561
  } else {
1352
- roots.set(row.permission, /* @__PURE__ */ new Set([row.node_id]));
1562
+ roots.set(row.permission, [window]);
1353
1563
  }
1354
1564
  }
1355
1565
  this.grants.set(identityId, {
1356
1566
  roots,
1357
- expiresAt: this.now() + this.ttlMs
1567
+ expiresAt: Math.min(now + this.ttlMs, boundary)
1358
1568
  });
1359
1569
  return roots;
1360
1570
  }
1361
- async parents() {
1571
+ async treeEntry() {
1362
1572
  if (this.tree && this.tree.expiresAt > this.now()) {
1363
- return this.tree.parents;
1573
+ return this.tree;
1364
1574
  }
1365
1575
  if (this.pendingTree) {
1366
1576
  return this.pendingTree;
@@ -1397,7 +1607,7 @@ var LocalAuthorizer = class {
1397
1607
  if (held) {
1398
1608
  this.stats.treeNotModified += 1;
1399
1609
  this.tree = { ...held, expiresAt: this.now() + this.ttlMs };
1400
- return held.parents;
1610
+ return this.tree;
1401
1611
  }
1402
1612
  return this.keepTree([], null);
1403
1613
  }
@@ -1458,19 +1668,26 @@ var LocalAuthorizer = class {
1458
1668
  ids.set(edge.id, edge.id);
1459
1669
  }
1460
1670
  const parents = /* @__PURE__ */ new Map();
1671
+ const children = /* @__PURE__ */ new Map();
1461
1672
  for (const edge of edges) {
1462
- const parent = edge.parent_node_id ?? null;
1463
- parents.set(
1464
- edge.id,
1465
- parent === null ? null : ids.get(parent) ?? parent
1466
- );
1673
+ const parent = edge.parent_node_id === null || edge.parent_node_id === void 0 ? null : ids.get(edge.parent_node_id) ?? edge.parent_node_id;
1674
+ parents.set(edge.id, parent);
1675
+ if (parent !== null) {
1676
+ const siblings = children.get(parent);
1677
+ if (siblings) {
1678
+ siblings.push(edge.id);
1679
+ } else {
1680
+ children.set(parent, [edge.id]);
1681
+ }
1682
+ }
1467
1683
  }
1468
1684
  this.tree = {
1469
1685
  parents,
1686
+ children,
1470
1687
  etag,
1471
1688
  expiresAt: this.now() + this.ttlMs
1472
1689
  };
1473
- return parents;
1690
+ return this.tree;
1474
1691
  }
1475
1692
  };
1476
1693
  function spansPages(first) {
@@ -1906,6 +2123,6 @@ function isWebhookEvent(delivery, name) {
1906
2123
  return delivery.event === name;
1907
2124
  }
1908
2125
 
1909
- export { Assignments, Canopy, CanopyAuthorizerError, CanopyClient, CanopyConnectionError, CanopyError, CanopyTokenError, Identities, LocalAuthorizer, MAX_TTL_MS, Organizations, Paginator, Permissions, Roles, TokenVerifier, authorizationErrorStatus, isCanopyAuthorizerError, isCanopyConnectionError, isCanopyError, isCanopyTokenError, isCursorPagination, isWebhookEvent, orgContext, paginate, tokenErrorStatus, withConcurrency };
2126
+ export { Assignments, Canopy, CanopyAuthorizerError, CanopyClient, CanopyConnectionError, CanopyError, CanopyTokenError, DEFAULT_MAX_NODES, Identities, LocalAuthorizer, MAX_TTL_MS, Organizations, Paginator, Permissions, Roles, TokenVerifier, authorizationErrorStatus, isCanopyAuthorizerError, isCanopyConnectionError, isCanopyError, isCanopyTokenError, isCursorPagination, isWebhookEvent, orgContext, paginate, tokenErrorStatus, withConcurrency };
1910
2127
  //# sourceMappingURL=index.js.map
1911
2128
  //# sourceMappingURL=index.js.map