@canopy-io/node 0.10.0 → 0.12.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/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # @canopy-io/node
2
2
 
3
+ ## 0.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 10d3583: Organization membership is admission, not a role. Identity tokens carry `org_roles` (the names of the roles held at the organization node, sorted, possibly empty) in place of `org_role`; `orgContext(claims)` returns `{ orgId, orgRoles }`, and refuses a token carrying one claim without the other. An empty `orgRoles` is a member holding no role at the organization node, so authorize by evaluating rather than by the list. The conformance bundle gains the `organization-membership` vector: a grant inside an organization counts only while the membership is active, which the grants `LocalAuthorizer` caches already reflect.
8
+
9
+ `organizations.changeMemberRole()` is gone with its route: a member's role at the organization is an assignment at the organization node, written through `assignments.create()`, `assignments.update()` and `assignments.delete()` with the organization node as `node_id`. `assignments.update()` takes `{ ifMatch }` and sends it as `If-Match`; a stale version answers `409 assignment.version_mismatch`. `organizations.removeMember()` deactivates the membership and revokes every role the member held in the organization. Member rows carry `status`, `source`, `joined_at` and `roles[]` in place of the single `role`.
10
+
11
+ ## 0.11.0
12
+
13
+ ### Minor Changes
14
+
15
+ - 6cbddf8: `LocalAuthorizer` answers the list question, says why a check was denied, and hears every verdict.
16
+
17
+ - `grantRoots(identityId, permission)`: the nodes the permission is granted at, in effect now, minus the ones another root already covers, with `envWide` when one is the Environment's root.
18
+ - `accessibleNodes(identityId, permission, { maxNodes })`: those roots expanded through their descendants into a `Set` for an `IN (...)` filter, from the same cached tree a check walks. Capped at `maxNodes` (10,000 by default, settable per authorizer or per call); past the cap `nodes` is null and `complete` false, and the roots filter a closure table or materialised path instead.
19
+ - `permissionsAt(identityId, nodeId)`: every permission the identity holds at the node, by grant or inheritance; `permissionsAtMany(identityId, nodeIds)` answers several nodes from one read.
20
+ - `evaluate` returns `{ allowed, reason }`. On a denial `reason` is `permission_not_held`, `outside_grants`, `expired`, `not_yet_effective` or `node_missing`. The grant roots are read with `include=windows`, so a grant that ends before the TTL is re-read at its boundary.
21
+ - `onDecision` and `onDenied` options, fired after every verdict and never awaited; a hook that throws is ignored.
22
+ - `canopy.identities.accessibleNodes(id, { permission, max_nodes })` wraps `GET /api/v1/identities/:id/accessible-nodes`, and `canopy.identities.permissions(id, { node_id })` takes the node.
23
+
24
+ This version reads `GET /api/v1/identities/:id/grants?include=windows`, which Canopy serves from the same release that adds the accessible-nodes endpoint; against an older API the read is refused and every check fails, so upgrade after the API has it.
25
+
26
+ `@canopy-io/nestjs` accepts `onDecision` and `onDenied` in `CanopyModuleOptions` and passes them to the authorizer; the guard's own behaviour is unchanged.
27
+
3
28
  ## 0.10.0
4
29
 
5
30
  ### Minor Changes
package/README.md CHANGED
@@ -143,8 +143,10 @@ throws with neither. Getting it wrong fails closed: tokens are rejected, never
143
143
  mistakenly accepted.
144
144
 
145
145
  With an Environment's **organizations** container on, an identity token also
146
- names the organization the session is acting in and the one role held there. Read the pair through `orgContext` rather than off the raw claims —
147
- Canopy mints the two together, and the helper refuses a half-present pair:
146
+ names the organization the session is acting in and the roles held at that
147
+ organization's node. Read the pair through `orgContext` rather than off the raw
148
+ claims — Canopy mints the two together, and the helper refuses a half-present
149
+ pair:
148
150
 
149
151
  ```ts
150
152
  import { orgContext } from "@canopy-io/node";
@@ -153,12 +155,20 @@ const org = orgContext(claims);
153
155
 
154
156
  if (org) {
155
157
  org.orgId; // the organization — also a hierarchy node id
156
- org.orgRole; // the single role held there, by name
158
+ org.orgRoles; // the roles held at its node, by name; [] for a member holding none there
157
159
  }
158
160
  ```
159
161
 
160
162
  `null` is an ordinary answer: every token from an Environment without the
161
- container on, and every identity that belongs to no organization yet.
163
+ container on, and every identity that belongs to no organization yet. An empty
164
+ `orgRoles` is not: membership is admission, and a member may hold roles only
165
+ beneath the organization. Authorize by evaluating, never by the list.
166
+
167
+ A grant inside an organization counts only while the membership is active.
168
+ The grants `LocalAuthorizer` caches already apply that: a deactivated
169
+ membership drops every grant inside the organization from the next read, so
170
+ the authorizer sees it within the cache TTL, and sooner on
171
+ `invalidate(identityId)` when an `organization.member.removed` webhook arrives.
162
172
 
163
173
  Tokens also carry `amr`, how the session was proven (`pwd`, `otp` or `sso`,
164
174
  plus `mfa` once a second factor was verified), so a backend can insist on
@@ -270,6 +280,53 @@ up to five minutes (`300_000`); a longer value throws when the authorizer is
270
280
  created. Shortening it below the gap between a user's requests saves nothing:
271
281
  every request then finds the cache expired and refetches.
272
282
 
283
+ Each answer carries a reason when it is no, from what the grant roots and the
284
+ tree can say: `permission_not_held`, `outside_grants`, `expired`,
285
+ `not_yet_effective`, or `node_missing` for a `node` check with no node. The
286
+ grant roots are read with their windows, so a grant that ends sooner than the
287
+ TTL is dropped at its boundary, not at the next expiry. `onDecision` and
288
+ `onDenied` hooks hear every verdict after it is reached, never awaited and
289
+ never able to change it, for audit trails and alerting without a fork of the
290
+ guard.
291
+
292
+ ```ts
293
+ const authorizer = new LocalAuthorizer(canopy.client, {
294
+ onDenied: ({ query, decision }) =>
295
+ audit.record("denied", { ...query, reason: decision.reason }),
296
+ });
297
+ ```
298
+
299
+ A list needs the other direction: which nodes may this identity see. The same
300
+ cache answers it, in the shape your query wants. `grantRoots` names the nodes
301
+ the permission is granted at, in effect now, minus the ones another root
302
+ already covers, and says whether one is the Environment's root (`envWide`:
303
+ every node qualifies, so no filter). `accessibleNodes` expands those roots
304
+ through their descendants into a `Set` for an `IN (...)` filter, capped at
305
+ `maxNodes` (10,000 by default, per authorizer or per call); past the cap it
306
+ hands back the roots with `nodes: null` and `complete: false`, so an
307
+ application with a closure table or a materialised path filters by "beneath a
308
+ root" instead. `permissionsAt` lists what the identity holds at one node, for
309
+ rendering a screen's controls, and `permissionsAtMany` answers several nodes
310
+ from one read, for the rows of a list.
311
+
312
+ ```ts
313
+ const access = await authorizer.accessibleNodes(identityId, "reports.view");
314
+
315
+ if (access.envWide) {
316
+ return db.reports.findMany();
317
+ }
318
+
319
+ if (!access.complete) {
320
+ // Too large for an IN (...) list: filter beneath access.roots instead.
321
+ }
322
+
323
+ return db.reports.findMany({ where: { nodeId: { in: [...access.nodes] } } });
324
+ ```
325
+
326
+ A list filtered this way is what to show; the write that follows still checks
327
+ its node. Canopy answers the same question fresh, for a process without the
328
+ authorizer, as `canopy.identities.accessibleNodes(id, { permission })`.
329
+
273
330
  `canopy.permissions.evaluate` remains for a decision with no staleness at all.
274
331
 
275
332
  `authorizer.invalidate(identityId)` drops one identity's cached grants, so
package/dist/index.cjs CHANGED
@@ -473,6 +473,14 @@ function paginate(fetchPage, initial = {}, options = {}) {
473
473
  return new Paginator(fetchPage, initial, options);
474
474
  }
475
475
 
476
+ // src/schema.ts
477
+ function withConcurrency(options = {}) {
478
+ if (options.ifMatch === void 0) {
479
+ return {};
480
+ }
481
+ return { headers: { "If-Match": options.ifMatch } };
482
+ }
483
+
476
484
  // src/resources/assignments.ts
477
485
  var Assignments = class {
478
486
  constructor(client) {
@@ -498,11 +506,16 @@ var Assignments = class {
498
506
  create(input) {
499
507
  return this.client.request("POST", "/api/v1/assignments", { body: input });
500
508
  }
501
- update(id, input) {
509
+ /**
510
+ * Change the role or the window of a grant. Pass the `version` the
511
+ * assignment was read with as `ifMatch`; a stale one answers 409
512
+ * `assignment.version_mismatch` rather than overwriting a newer change.
513
+ */
514
+ update(id, input, options = {}) {
502
515
  return this.client.request(
503
516
  "PATCH",
504
517
  `/api/v1/assignments/${encodeURIComponent(id)}`,
505
- { body: input }
518
+ { body: input, ...withConcurrency(options) }
506
519
  );
507
520
  }
508
521
  delete(id) {
@@ -604,10 +617,28 @@ var Identities = class {
604
617
  * question at the node that matters. This is for showing a person what
605
618
  * someone can do.
606
619
  */
607
- permissions(id) {
620
+ permissions(id, query = {}) {
621
+ return this.client.request(
622
+ "GET",
623
+ `/api/v1/identities/${encodeURIComponent(id)}/permissions`,
624
+ { query: { ...query } }
625
+ );
626
+ }
627
+ /**
628
+ * Where the identity may act with one permission, answered by Canopy for a
629
+ * list query: the minimal grant roots, and every node beneath them up to
630
+ * `max_nodes` (null past it, with `complete: false`), or `env_wide` when a
631
+ * root is the Environment's own root and no filter is needed.
632
+ *
633
+ * `LocalAuthorizer.accessibleNodes` answers the same question from its
634
+ * cache without a call; this is the fresh answer, and the one for a process
635
+ * that does not run the authorizer.
636
+ */
637
+ accessibleNodes(id, query) {
608
638
  return this.client.request(
609
639
  "GET",
610
- `/api/v1/identities/${encodeURIComponent(id)}/permissions`
640
+ `/api/v1/identities/${encodeURIComponent(id)}/accessible-nodes`,
641
+ { query: { ...query } }
611
642
  );
612
643
  }
613
644
  /**
@@ -659,14 +690,6 @@ var Identities = class {
659
690
  }
660
691
  };
661
692
 
662
- // src/schema.ts
663
- function withConcurrency(options = {}) {
664
- if (options.ifMatch === void 0) {
665
- return {};
666
- }
667
- return { headers: { "If-Match": options.ifMatch } };
668
- }
669
-
670
693
  // src/resources/organizations.ts
671
694
  var Organizations = class {
672
695
  constructor(client) {
@@ -716,7 +739,7 @@ var Organizations = class {
716
739
  deleteAll() {
717
740
  return this.client.request("DELETE", "/api/v1/organizations");
718
741
  }
719
- // ── Members: one role per member, held inside the organization only ──
742
+ // ── Members: admission to the organization, apart from the roles held in it ──
720
743
  listMembers(id, query = {}) {
721
744
  return paginate(
722
745
  (params) => this.client.request("GET", `/api/v1/organizations/${enc(id)}/members`, {
@@ -732,14 +755,12 @@ var Organizations = class {
732
755
  { body: input }
733
756
  );
734
757
  }
735
- changeMemberRole(id, identityId, input) {
736
- return this.client.request(
737
- "PATCH",
738
- `/api/v1/organizations/${enc(id)}/members/${enc(identityId)}`,
739
- { body: input }
740
- );
741
- }
742
- /** Revokes that organization's one role; the identity and its other memberships are untouched. */
758
+ /**
759
+ * Deactivates the membership and revokes every role the member holds at
760
+ * the organization's nodes; the identity and its other memberships are
761
+ * untouched. A role in the organization is changed through the
762
+ * organization's assignment routes, not here.
763
+ */
743
764
  removeMember(id, identityId) {
744
765
  return this.client.request(
745
766
  "DELETE",
@@ -1151,6 +1172,7 @@ var Canopy = class {
1151
1172
  // src/authorizer.ts
1152
1173
  var DEFAULT_TTL_MS = 6e4;
1153
1174
  var MAX_TTL_MS = 3e5;
1175
+ var DEFAULT_MAX_NODES = 1e4;
1154
1176
  var MAX_WALK_DEPTH = 256;
1155
1177
  var PAGE_SIZE = 100;
1156
1178
  var MAX_PAGES = 1e4;
@@ -1167,11 +1189,20 @@ function understood(page, path) {
1167
1189
  }
1168
1190
  var GRANTS_PATH = (identityId) => `/api/v1/identities/${encodeURIComponent(identityId)}/grants`;
1169
1191
  var PARENTS_PATH = "/api/v1/nodes/parents";
1192
+ function inEffect(window, now) {
1193
+ return (window.from === null || window.from <= now) && (window.to === null || window.to > now);
1194
+ }
1195
+ function reasonAmong(windows, now) {
1196
+ return windows.some((window) => window.to !== null && window.to <= now) ? "expired" : "not_yet_effective";
1197
+ }
1170
1198
  var LocalAuthorizer = class {
1171
1199
  client;
1172
1200
  ttlMs;
1201
+ maxNodes;
1173
1202
  now;
1174
1203
  readOptions;
1204
+ onDecision;
1205
+ onDenied;
1175
1206
  grants = /* @__PURE__ */ new Map();
1176
1207
  tree;
1177
1208
  /**
@@ -1199,22 +1230,39 @@ var LocalAuthorizer = class {
1199
1230
  `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.`
1200
1231
  );
1201
1232
  }
1233
+ this.maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
1234
+ if (!Number.isInteger(this.maxNodes) || this.maxNodes < 1) {
1235
+ throw new RangeError(
1236
+ `maxNodes must be a positive integer; got ${String(this.maxNodes)}.`
1237
+ );
1238
+ }
1202
1239
  this.now = options.now ?? (() => Date.now());
1240
+ this.onDecision = options.onDecision;
1241
+ this.onDenied = options.onDenied;
1203
1242
  this.readOptions = {
1204
1243
  ...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
1205
1244
  ...options.maxRetries === void 0 ? {} : { maxRetries: options.maxRetries, maxBackoffMs: options.timeoutMs }
1206
1245
  };
1207
1246
  }
1208
1247
  /**
1209
- * Whether the identity holds the permission.
1248
+ * Whether the identity holds the permission, and why not when it does not.
1210
1249
  *
1211
1250
  * Shaped like the API's own evaluate so a caller can swap one for the other.
1212
1251
  * A `node` check with no node is a denial rather than an error: a request
1213
1252
  * whose subject cannot be established is exactly the one that must not pass.
1214
1253
  */
1215
1254
  async evaluate(query, options = {}) {
1255
+ const started = this.now();
1256
+ const decided = (decision) => {
1257
+ this.announce({
1258
+ query,
1259
+ decision,
1260
+ durationMs: Math.max(0, this.now() - started)
1261
+ });
1262
+ return decision;
1263
+ };
1216
1264
  if (!options.signal) {
1217
- return this.decide(query);
1265
+ return decided(await this.decide(query));
1218
1266
  }
1219
1267
  const signal = options.signal;
1220
1268
  let onAbort;
@@ -1231,25 +1279,157 @@ var LocalAuthorizer = class {
1231
1279
  onAbort = fail;
1232
1280
  signal.addEventListener("abort", fail, { once: true });
1233
1281
  });
1234
- return Promise.race([this.decide(query), aborted]).finally(() => {
1282
+ return Promise.race([this.decide(query), aborted]).then(decided).finally(() => {
1235
1283
  if (onAbort) {
1236
1284
  signal.removeEventListener("abort", onAbort);
1237
1285
  }
1238
1286
  });
1239
1287
  }
1288
+ /**
1289
+ * The nodes the identity holds the permission at, in effect now, reduced to
1290
+ * the ones no other such node already covers, and whether one of them is
1291
+ * the Environment's root. The roots alone are enough for an application
1292
+ * that keeps a closure table or a materialised path: it filters by "beneath
1293
+ * one of these" without an expanded set.
1294
+ *
1295
+ * Reads the tree, since minimality and `envWide` are questions about it; a
1296
+ * root the readable hierarchy does not contain raises
1297
+ * `authorizer.hierarchy_incomplete`, as a check at such a node does.
1298
+ */
1299
+ async grantRoots(identityId, permission) {
1300
+ const now = this.now();
1301
+ const held = (await this.grantRootsFor(identityId)).get(permission) ?? [];
1302
+ const granted = new Set(
1303
+ held.filter((window) => inEffect(window, now)).map((w) => w.node)
1304
+ );
1305
+ if (granted.size === 0) {
1306
+ return { roots: [], envWide: false };
1307
+ }
1308
+ const { parents } = await this.treeEntry();
1309
+ const roots = [];
1310
+ let envWide = false;
1311
+ for (const node of granted) {
1312
+ if (!parents.has(node)) {
1313
+ throw this.hierarchyIncomplete(node);
1314
+ }
1315
+ if (parents.get(node) === null) {
1316
+ envWide = true;
1317
+ }
1318
+ let covered = false;
1319
+ let depth = 0;
1320
+ for (let current = parents.get(node); current && depth < MAX_WALK_DEPTH; current = parents.get(current), depth += 1) {
1321
+ if (granted.has(current)) {
1322
+ covered = true;
1323
+ break;
1324
+ }
1325
+ }
1326
+ if (!covered) {
1327
+ roots.push(node);
1328
+ }
1329
+ }
1330
+ return { roots, envWide };
1331
+ }
1332
+ /**
1333
+ * Where the identity may act with the permission, as the set a list query
1334
+ * filters by: {@link grantRoots} expanded through their descendants, up to
1335
+ * `maxNodes` (the authorizer's default unless given here).
1336
+ *
1337
+ * Past the cap `nodes` is null and `complete` false: the set is too large
1338
+ * to be worth an `IN (...)` list, and the caller filters by `roots`
1339
+ * instead. An `envWide` answer carries no set either, since every node
1340
+ * qualifies and a filter would only slow the query down.
1341
+ *
1342
+ * Computed from the same cached grant roots and tree a check uses, so a
1343
+ * list filtered by it and the check on a write cannot disagree, and it goes
1344
+ * stale on the same `ttlMs`.
1345
+ */
1346
+ async accessibleNodes(identityId, permission, options = {}) {
1347
+ const cap = options.maxNodes ?? this.maxNodes;
1348
+ const { roots, envWide } = await this.grantRoots(identityId, permission);
1349
+ if (envWide) {
1350
+ return { roots, envWide: true, nodes: null, complete: true };
1351
+ }
1352
+ if (roots.length === 0) {
1353
+ return { roots, envWide: false, nodes: /* @__PURE__ */ new Set(), complete: true };
1354
+ }
1355
+ const { children } = await this.treeEntry();
1356
+ const nodes = /* @__PURE__ */ new Set();
1357
+ const pending = [...roots];
1358
+ while (pending.length > 0) {
1359
+ const node = pending.pop();
1360
+ if (nodes.has(node)) {
1361
+ continue;
1362
+ }
1363
+ nodes.add(node);
1364
+ if (nodes.size > cap) {
1365
+ return { roots, envWide: false, nodes: null, complete: false };
1366
+ }
1367
+ for (const child of children.get(node) ?? []) {
1368
+ pending.push(child);
1369
+ }
1370
+ }
1371
+ return { roots, envWide: false, nodes, complete: true };
1372
+ }
1373
+ /**
1374
+ * Every permission the identity holds at the node, by grant or by
1375
+ * inheritance: the list a screen renders its controls from, answered from
1376
+ * the same cached grant roots and tree a check uses.
1377
+ */
1378
+ async permissionsAt(identityId, nodeId) {
1379
+ const answers = await this.permissionsAtMany(identityId, [nodeId]);
1380
+ return answers.get(nodeId) ?? [];
1381
+ }
1382
+ /**
1383
+ * {@link permissionsAt} for several nodes at once, as node → permissions:
1384
+ * the rows of a list each with their own controls, answered from one read
1385
+ * of the grant roots and one walk per node.
1386
+ */
1387
+ async permissionsAtMany(identityId, nodeIds) {
1388
+ const now = this.now();
1389
+ const roots = await this.grantRootsFor(identityId);
1390
+ const answers = /* @__PURE__ */ new Map();
1391
+ for (const nodeId of new Set(nodeIds)) {
1392
+ const path = await this.lineageOf(nodeId);
1393
+ const held = [];
1394
+ for (const [permission, windows] of roots) {
1395
+ if (windows.some(
1396
+ (window) => path.has(window.node) && inEffect(window, now)
1397
+ )) {
1398
+ held.push(permission);
1399
+ }
1400
+ }
1401
+ answers.set(nodeId, held.sort());
1402
+ }
1403
+ return answers;
1404
+ }
1240
1405
  async decide(query) {
1241
- const roots = await this.grantRootsFor(query.identity_id);
1242
- const granted = roots.get(query.permission);
1243
- if (!granted || granted.size === 0) {
1244
- return { allowed: false };
1406
+ const now = this.now();
1407
+ const held = (await this.grantRootsFor(query.identity_id)).get(
1408
+ query.permission
1409
+ );
1410
+ if (!held || held.length === 0) {
1411
+ return { allowed: false, reason: "permission_not_held" };
1245
1412
  }
1246
1413
  if ((query.scope ?? "node") === "app_wide") {
1247
- return { allowed: true };
1414
+ return held.some((window) => inEffect(window, now)) ? { allowed: true, reason: null } : { allowed: false, reason: reasonAmong(held, now) };
1248
1415
  }
1249
1416
  if (!query.node_id) {
1250
- return { allowed: false };
1417
+ return { allowed: false, reason: "node_missing" };
1418
+ }
1419
+ if (held.some(
1420
+ (window) => window.node === query.node_id && inEffect(window, now)
1421
+ )) {
1422
+ return { allowed: true, reason: null };
1423
+ }
1424
+ const path = await this.lineageOf(query.node_id);
1425
+ const reachable = held.filter((window) => path.has(window.node));
1426
+ if (reachable.some((window) => inEffect(window, now))) {
1427
+ return { allowed: true, reason: null };
1251
1428
  }
1252
- return { allowed: await this.holdsAtNode(granted, query.node_id) };
1429
+ if (reachable.length === 0) {
1430
+ return { allowed: false, reason: "outside_grants" };
1431
+ }
1432
+ return { allowed: false, reason: reasonAmong(reachable, now) };
1253
1433
  }
1254
1434
  /** Counters for observability — how much traffic the cache is actually saving. */
1255
1435
  snapshot() {
@@ -1285,28 +1465,42 @@ var LocalAuthorizer = class {
1285
1465
  this.grants.clear();
1286
1466
  this.tree = void 0;
1287
1467
  }
1288
- /** Climb from `nodeId` and look for a grant root among its ancestors. */
1289
- async holdsAtNode(granted, nodeId) {
1290
- if (granted.has(nodeId)) {
1291
- return true;
1468
+ /** Fire the decision hooks, after the verdict and without waiting on them. */
1469
+ announce(event) {
1470
+ for (const hook of [
1471
+ this.onDecision,
1472
+ event.decision.allowed ? void 0 : this.onDenied
1473
+ ]) {
1474
+ if (!hook) {
1475
+ continue;
1476
+ }
1477
+ try {
1478
+ hook(event);
1479
+ } catch {
1480
+ }
1292
1481
  }
1293
- const parents = await this.parents();
1482
+ }
1483
+ /** The node and its ancestors, from the tree this client can read. */
1484
+ async lineageOf(nodeId) {
1485
+ const { parents } = await this.treeEntry();
1294
1486
  if (!parents.has(nodeId)) {
1295
- throw new CanopyAuthorizerError(
1296
- "authorizer.hierarchy_incomplete",
1297
- `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.`
1298
- );
1487
+ throw this.hierarchyIncomplete(nodeId);
1299
1488
  }
1489
+ const path = /* @__PURE__ */ new Set([nodeId]);
1300
1490
  let current = parents.get(nodeId);
1301
1491
  let depth = 0;
1302
1492
  while (current && depth < MAX_WALK_DEPTH) {
1303
- if (granted.has(current)) {
1304
- return true;
1305
- }
1493
+ path.add(current);
1306
1494
  current = parents.get(current);
1307
1495
  depth += 1;
1308
1496
  }
1309
- return false;
1497
+ return path;
1498
+ }
1499
+ hierarchyIncomplete(nodeId) {
1500
+ return new CanopyAuthorizerError(
1501
+ "authorizer.hierarchy_incomplete",
1502
+ `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.`
1503
+ );
1310
1504
  }
1311
1505
  async grantRootsFor(identityId) {
1312
1506
  const cached = this.grants.get(identityId);
@@ -1324,12 +1518,19 @@ var LocalAuthorizer = class {
1324
1518
  return read;
1325
1519
  }
1326
1520
  /**
1327
- * Every page of the identity's grant roots, folded into permission → nodes.
1521
+ * Every page of the identity's grant roots, with their windows, folded
1522
+ * into permission → roots.
1328
1523
  *
1329
- * Each row is one permission at one node, so a permission granted at three
1330
- * nodes arrives as three rows, possibly on different pages. Stopping after
1331
- * the first page would under-report what the identity holds: a false deny,
1332
- * the safe direction, but a wrong answer all the same.
1524
+ * Each row is one permission at one node for one assignment, so a
1525
+ * permission granted at three nodes arrives as three rows, possibly on
1526
+ * different pages. Stopping after the first page would under-report what
1527
+ * the identity holds: a false deny, the safe direction, but a wrong answer
1528
+ * all the same.
1529
+ *
1530
+ * Rows outside their window are kept: they are what lets a denial say
1531
+ * `expired` or `not_yet_effective`, and the entry expires at the nearest
1532
+ * window boundary when that comes before the TTL, so a grant takes effect
1533
+ * or ends on time rather than up to `ttlMs` late.
1333
1534
  */
1334
1535
  async fetchGrantRoots(identityId) {
1335
1536
  this.stats.grantFetches += 1;
@@ -1339,30 +1540,42 @@ var LocalAuthorizer = class {
1339
1540
  this.stats.grantPages += 1;
1340
1541
  return this.client.request("GET", path, {
1341
1542
  ...this.readOptions,
1342
- query
1543
+ query: { ...query, include: "windows" }
1343
1544
  }).then((page) => understood(page, path));
1344
1545
  },
1345
1546
  { page: 1, take: PAGE_SIZE },
1346
1547
  { maxPages: MAX_PAGES }
1347
1548
  );
1348
1549
  const roots = /* @__PURE__ */ new Map();
1550
+ const now = this.now();
1551
+ let boundary = Number.POSITIVE_INFINITY;
1349
1552
  for await (const row of rows) {
1350
- const nodes = roots.get(row.permission);
1351
- if (nodes) {
1352
- nodes.add(row.node_id);
1553
+ const window = {
1554
+ node: row.node_id,
1555
+ from: row.effective_from ? Date.parse(row.effective_from) : null,
1556
+ to: row.effective_to ? Date.parse(row.effective_to) : null
1557
+ };
1558
+ for (const edge of [window.from, window.to]) {
1559
+ if (edge !== null && edge > now && edge < boundary) {
1560
+ boundary = edge;
1561
+ }
1562
+ }
1563
+ const windows = roots.get(row.permission);
1564
+ if (windows) {
1565
+ windows.push(window);
1353
1566
  } else {
1354
- roots.set(row.permission, /* @__PURE__ */ new Set([row.node_id]));
1567
+ roots.set(row.permission, [window]);
1355
1568
  }
1356
1569
  }
1357
1570
  this.grants.set(identityId, {
1358
1571
  roots,
1359
- expiresAt: this.now() + this.ttlMs
1572
+ expiresAt: Math.min(now + this.ttlMs, boundary)
1360
1573
  });
1361
1574
  return roots;
1362
1575
  }
1363
- async parents() {
1576
+ async treeEntry() {
1364
1577
  if (this.tree && this.tree.expiresAt > this.now()) {
1365
- return this.tree.parents;
1578
+ return this.tree;
1366
1579
  }
1367
1580
  if (this.pendingTree) {
1368
1581
  return this.pendingTree;
@@ -1399,7 +1612,7 @@ var LocalAuthorizer = class {
1399
1612
  if (held) {
1400
1613
  this.stats.treeNotModified += 1;
1401
1614
  this.tree = { ...held, expiresAt: this.now() + this.ttlMs };
1402
- return held.parents;
1615
+ return this.tree;
1403
1616
  }
1404
1617
  return this.keepTree([], null);
1405
1618
  }
@@ -1460,19 +1673,26 @@ var LocalAuthorizer = class {
1460
1673
  ids.set(edge.id, edge.id);
1461
1674
  }
1462
1675
  const parents = /* @__PURE__ */ new Map();
1676
+ const children = /* @__PURE__ */ new Map();
1463
1677
  for (const edge of edges) {
1464
- const parent = edge.parent_node_id ?? null;
1465
- parents.set(
1466
- edge.id,
1467
- parent === null ? null : ids.get(parent) ?? parent
1468
- );
1678
+ const parent = edge.parent_node_id === null || edge.parent_node_id === void 0 ? null : ids.get(edge.parent_node_id) ?? edge.parent_node_id;
1679
+ parents.set(edge.id, parent);
1680
+ if (parent !== null) {
1681
+ const siblings = children.get(parent);
1682
+ if (siblings) {
1683
+ siblings.push(edge.id);
1684
+ } else {
1685
+ children.set(parent, [edge.id]);
1686
+ }
1687
+ }
1469
1688
  }
1470
1689
  this.tree = {
1471
1690
  parents,
1691
+ children,
1472
1692
  etag,
1473
1693
  expiresAt: this.now() + this.ttlMs
1474
1694
  };
1475
- return parents;
1695
+ return this.tree;
1476
1696
  }
1477
1697
  };
1478
1698
  function spansPages(first) {
@@ -1554,11 +1774,11 @@ function resolveIssuer(options) {
1554
1774
  );
1555
1775
  }
1556
1776
  function orgContext(claims) {
1557
- const { org_id, org_role } = claims;
1558
- if (typeof org_id !== "string" || org_id === "" || typeof org_role !== "string" || org_role === "") {
1777
+ const { org_id, org_roles } = claims;
1778
+ if (typeof org_id !== "string" || org_id === "" || !Array.isArray(org_roles) || org_roles.some((role) => typeof role !== "string" || role === "")) {
1559
1779
  return null;
1560
1780
  }
1561
- return { orgId: org_id, orgRole: org_role };
1781
+ return { orgId: org_id, orgRoles: [...org_roles] };
1562
1782
  }
1563
1783
  function decodeBase64Url(value) {
1564
1784
  const padded = value.replace(/-/g, "+").replace(/_/g, "/");
@@ -1915,6 +2135,7 @@ exports.CanopyClient = CanopyClient;
1915
2135
  exports.CanopyConnectionError = CanopyConnectionError;
1916
2136
  exports.CanopyError = CanopyError;
1917
2137
  exports.CanopyTokenError = CanopyTokenError;
2138
+ exports.DEFAULT_MAX_NODES = DEFAULT_MAX_NODES;
1918
2139
  exports.Identities = Identities;
1919
2140
  exports.LocalAuthorizer = LocalAuthorizer;
1920
2141
  exports.MAX_TTL_MS = MAX_TTL_MS;