@canopy-io/node 0.9.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/CHANGELOG.md +27 -0
- package/README.md +53 -2
- package/dist/index.cjs +284 -53
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +316 -20
- package/dist/index.d.ts +316 -20
- package/dist/index.js +284 -54
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,32 @@
|
|
|
1
1
|
# @canopy-io/node
|
|
2
2
|
|
|
3
|
+
## 0.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 6cbddf8: `LocalAuthorizer` answers the list question, says why a check was denied, and hears every verdict.
|
|
8
|
+
|
|
9
|
+
- `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.
|
|
10
|
+
- `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.
|
|
11
|
+
- `permissionsAt(identityId, nodeId)`: every permission the identity holds at the node, by grant or inheritance; `permissionsAtMany(identityId, nodeIds)` answers several nodes from one read.
|
|
12
|
+
- `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.
|
|
13
|
+
- `onDecision` and `onDenied` options, fired after every verdict and never awaited; a hook that throws is ignored.
|
|
14
|
+
- `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.
|
|
15
|
+
|
|
16
|
+
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.
|
|
17
|
+
|
|
18
|
+
`@canopy-io/nestjs` accepts `onDecision` and `onDenied` in `CanopyModuleOptions` and passes them to the authorizer; the guard's own behaviour is unchanged.
|
|
19
|
+
|
|
20
|
+
## 0.10.0
|
|
21
|
+
|
|
22
|
+
### Minor Changes
|
|
23
|
+
|
|
24
|
+
- f2ee649: `LocalAuthorizer` is held to Canopy's own authorization rules. Its suite replays the conformance vectors Canopy publishes at `/conformance/authorization.json`, the same cases Canopy's API suite replays end to end, and `npm run generate` keeps the copy in step with the published one.
|
|
25
|
+
|
|
26
|
+
The grant roots (`GET /api/v1/identities/:id/grants`) and the hierarchy's parent edges (`GET /api/v1/nodes/parents`) now carry the `model_version` of the authorization model they describe. `LocalAuthorizer` refuses a version it does not know with `CanopyAuthorizerError` (`authorizer.model_version_unknown`), which `authorizationErrorStatus` and the NestJS guard answer as a `500` misconfiguration, rather than walking rows whose meaning has changed. It knows version 1. This release requires a Canopy that sends `model_version`; against one that does not, every check is refused, so upgrade after the API has it.
|
|
27
|
+
|
|
28
|
+
`@canopy-io/nestjs` picks up the new authorizer; its own options and behaviour are unchanged.
|
|
29
|
+
|
|
3
30
|
## 0.9.0
|
|
4
31
|
|
|
5
32
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -270,6 +270,53 @@ up to five minutes (`300_000`); a longer value throws when the authorizer is
|
|
|
270
270
|
created. Shortening it below the gap between a user's requests saves nothing:
|
|
271
271
|
every request then finds the cache expired and refetches.
|
|
272
272
|
|
|
273
|
+
Each answer carries a reason when it is no, from what the grant roots and the
|
|
274
|
+
tree can say: `permission_not_held`, `outside_grants`, `expired`,
|
|
275
|
+
`not_yet_effective`, or `node_missing` for a `node` check with no node. The
|
|
276
|
+
grant roots are read with their windows, so a grant that ends sooner than the
|
|
277
|
+
TTL is dropped at its boundary, not at the next expiry. `onDecision` and
|
|
278
|
+
`onDenied` hooks hear every verdict after it is reached, never awaited and
|
|
279
|
+
never able to change it, for audit trails and alerting without a fork of the
|
|
280
|
+
guard.
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
const authorizer = new LocalAuthorizer(canopy.client, {
|
|
284
|
+
onDenied: ({ query, decision }) =>
|
|
285
|
+
audit.record("denied", { ...query, reason: decision.reason }),
|
|
286
|
+
});
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
A list needs the other direction: which nodes may this identity see. The same
|
|
290
|
+
cache answers it, in the shape your query wants. `grantRoots` names the nodes
|
|
291
|
+
the permission is granted at, in effect now, minus the ones another root
|
|
292
|
+
already covers, and says whether one is the Environment's root (`envWide`:
|
|
293
|
+
every node qualifies, so no filter). `accessibleNodes` expands those roots
|
|
294
|
+
through their descendants into a `Set` for an `IN (...)` filter, capped at
|
|
295
|
+
`maxNodes` (10,000 by default, per authorizer or per call); past the cap it
|
|
296
|
+
hands back the roots with `nodes: null` and `complete: false`, so an
|
|
297
|
+
application with a closure table or a materialised path filters by "beneath a
|
|
298
|
+
root" instead. `permissionsAt` lists what the identity holds at one node, for
|
|
299
|
+
rendering a screen's controls, and `permissionsAtMany` answers several nodes
|
|
300
|
+
from one read, for the rows of a list.
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
const access = await authorizer.accessibleNodes(identityId, "reports.view");
|
|
304
|
+
|
|
305
|
+
if (access.envWide) {
|
|
306
|
+
return db.reports.findMany();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (!access.complete) {
|
|
310
|
+
// Too large for an IN (...) list: filter beneath access.roots instead.
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return db.reports.findMany({ where: { nodeId: { in: [...access.nodes] } } });
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
A list filtered this way is what to show; the write that follows still checks
|
|
317
|
+
its node. Canopy answers the same question fresh, for a process without the
|
|
318
|
+
authorizer, as `canopy.identities.accessibleNodes(id, { permission })`.
|
|
319
|
+
|
|
273
320
|
`canopy.permissions.evaluate` remains for a decision with no staleness at all.
|
|
274
321
|
|
|
275
322
|
`authorizer.invalidate(identityId)` drops one identity's cached grants, so
|
|
@@ -278,8 +325,12 @@ instances behind a load balancer, each keeps its own cache until its TTL runs
|
|
|
278
325
|
out or it is told to invalidate too.
|
|
279
326
|
|
|
280
327
|
A failed check (Canopy unreachable, a credential that cannot read the
|
|
281
|
-
hierarchy, an identity not in the Environment
|
|
282
|
-
|
|
328
|
+
hierarchy, an identity not in the Environment, or a Canopy serving an
|
|
329
|
+
authorization model this version does not know) throws rather than answering
|
|
330
|
+
`false`. The grant roots and the hierarchy each carry the `model_version` of
|
|
331
|
+
the rules they describe; an unknown one is refused with `CanopyAuthorizerError`
|
|
332
|
+
(`authorizer.model_version_unknown`) instead of being walked as if the rules
|
|
333
|
+
had not changed, and upgrading the package is the fix. `authorizationErrorStatus` turns the error into the response to send:
|
|
283
334
|
`503` when Canopy did not answer, `500` for a misconfiguration no retry fixes,
|
|
284
335
|
`403` for an identity Canopy does not know. The NestJS guard uses the same
|
|
285
336
|
mapping.
|
package/dist/index.cjs
CHANGED
|
@@ -604,10 +604,28 @@ var Identities = class {
|
|
|
604
604
|
* question at the node that matters. This is for showing a person what
|
|
605
605
|
* someone can do.
|
|
606
606
|
*/
|
|
607
|
-
permissions(id) {
|
|
607
|
+
permissions(id, query = {}) {
|
|
608
|
+
return this.client.request(
|
|
609
|
+
"GET",
|
|
610
|
+
`/api/v1/identities/${encodeURIComponent(id)}/permissions`,
|
|
611
|
+
{ query: { ...query } }
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
/**
|
|
615
|
+
* Where the identity may act with one permission, answered by Canopy for a
|
|
616
|
+
* list query: the minimal grant roots, and every node beneath them up to
|
|
617
|
+
* `max_nodes` (null past it, with `complete: false`), or `env_wide` when a
|
|
618
|
+
* root is the Environment's own root and no filter is needed.
|
|
619
|
+
*
|
|
620
|
+
* `LocalAuthorizer.accessibleNodes` answers the same question from its
|
|
621
|
+
* cache without a call; this is the fresh answer, and the one for a process
|
|
622
|
+
* that does not run the authorizer.
|
|
623
|
+
*/
|
|
624
|
+
accessibleNodes(id, query) {
|
|
608
625
|
return this.client.request(
|
|
609
626
|
"GET",
|
|
610
|
-
`/api/v1/identities/${encodeURIComponent(id)}/
|
|
627
|
+
`/api/v1/identities/${encodeURIComponent(id)}/accessible-nodes`,
|
|
628
|
+
{ query: { ...query } }
|
|
611
629
|
);
|
|
612
630
|
}
|
|
613
631
|
/**
|
|
@@ -1151,17 +1169,37 @@ var Canopy = class {
|
|
|
1151
1169
|
// src/authorizer.ts
|
|
1152
1170
|
var DEFAULT_TTL_MS = 6e4;
|
|
1153
1171
|
var MAX_TTL_MS = 3e5;
|
|
1172
|
+
var DEFAULT_MAX_NODES = 1e4;
|
|
1154
1173
|
var MAX_WALK_DEPTH = 256;
|
|
1155
1174
|
var PAGE_SIZE = 100;
|
|
1156
1175
|
var MAX_PAGES = 1e4;
|
|
1157
1176
|
var MAX_TREE_WALKS = 3;
|
|
1177
|
+
var KNOWN_MODEL_VERSIONS = /* @__PURE__ */ new Set([1]);
|
|
1178
|
+
function understood(page, path) {
|
|
1179
|
+
if (!KNOWN_MODEL_VERSIONS.has(page.model_version)) {
|
|
1180
|
+
throw new CanopyAuthorizerError(
|
|
1181
|
+
"authorizer.model_version_unknown",
|
|
1182
|
+
`${path} describes authorization model version ${page.model_version}, which this version of @canopy-io/node does not understand. It knows ${[...KNOWN_MODEL_VERSIONS].join(", ")}. Upgrade @canopy-io/node; until then this authorizer cannot decide, rather than guess.`
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
return page;
|
|
1186
|
+
}
|
|
1158
1187
|
var GRANTS_PATH = (identityId) => `/api/v1/identities/${encodeURIComponent(identityId)}/grants`;
|
|
1159
1188
|
var PARENTS_PATH = "/api/v1/nodes/parents";
|
|
1189
|
+
function inEffect(window, now) {
|
|
1190
|
+
return (window.from === null || window.from <= now) && (window.to === null || window.to > now);
|
|
1191
|
+
}
|
|
1192
|
+
function reasonAmong(windows, now) {
|
|
1193
|
+
return windows.some((window) => window.to !== null && window.to <= now) ? "expired" : "not_yet_effective";
|
|
1194
|
+
}
|
|
1160
1195
|
var LocalAuthorizer = class {
|
|
1161
1196
|
client;
|
|
1162
1197
|
ttlMs;
|
|
1198
|
+
maxNodes;
|
|
1163
1199
|
now;
|
|
1164
1200
|
readOptions;
|
|
1201
|
+
onDecision;
|
|
1202
|
+
onDenied;
|
|
1165
1203
|
grants = /* @__PURE__ */ new Map();
|
|
1166
1204
|
tree;
|
|
1167
1205
|
/**
|
|
@@ -1189,22 +1227,39 @@ var LocalAuthorizer = class {
|
|
|
1189
1227
|
`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.`
|
|
1190
1228
|
);
|
|
1191
1229
|
}
|
|
1230
|
+
this.maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
|
|
1231
|
+
if (!Number.isInteger(this.maxNodes) || this.maxNodes < 1) {
|
|
1232
|
+
throw new RangeError(
|
|
1233
|
+
`maxNodes must be a positive integer; got ${String(this.maxNodes)}.`
|
|
1234
|
+
);
|
|
1235
|
+
}
|
|
1192
1236
|
this.now = options.now ?? (() => Date.now());
|
|
1237
|
+
this.onDecision = options.onDecision;
|
|
1238
|
+
this.onDenied = options.onDenied;
|
|
1193
1239
|
this.readOptions = {
|
|
1194
1240
|
...options.timeoutMs === void 0 ? {} : { timeoutMs: options.timeoutMs },
|
|
1195
1241
|
...options.maxRetries === void 0 ? {} : { maxRetries: options.maxRetries, maxBackoffMs: options.timeoutMs }
|
|
1196
1242
|
};
|
|
1197
1243
|
}
|
|
1198
1244
|
/**
|
|
1199
|
-
* Whether the identity holds the permission.
|
|
1245
|
+
* Whether the identity holds the permission, and why not when it does not.
|
|
1200
1246
|
*
|
|
1201
1247
|
* Shaped like the API's own evaluate so a caller can swap one for the other.
|
|
1202
1248
|
* A `node` check with no node is a denial rather than an error: a request
|
|
1203
1249
|
* whose subject cannot be established is exactly the one that must not pass.
|
|
1204
1250
|
*/
|
|
1205
1251
|
async evaluate(query, options = {}) {
|
|
1252
|
+
const started = this.now();
|
|
1253
|
+
const decided = (decision) => {
|
|
1254
|
+
this.announce({
|
|
1255
|
+
query,
|
|
1256
|
+
decision,
|
|
1257
|
+
durationMs: Math.max(0, this.now() - started)
|
|
1258
|
+
});
|
|
1259
|
+
return decision;
|
|
1260
|
+
};
|
|
1206
1261
|
if (!options.signal) {
|
|
1207
|
-
return this.decide(query);
|
|
1262
|
+
return decided(await this.decide(query));
|
|
1208
1263
|
}
|
|
1209
1264
|
const signal = options.signal;
|
|
1210
1265
|
let onAbort;
|
|
@@ -1221,25 +1276,157 @@ var LocalAuthorizer = class {
|
|
|
1221
1276
|
onAbort = fail;
|
|
1222
1277
|
signal.addEventListener("abort", fail, { once: true });
|
|
1223
1278
|
});
|
|
1224
|
-
return Promise.race([this.decide(query), aborted]).finally(() => {
|
|
1279
|
+
return Promise.race([this.decide(query), aborted]).then(decided).finally(() => {
|
|
1225
1280
|
if (onAbort) {
|
|
1226
1281
|
signal.removeEventListener("abort", onAbort);
|
|
1227
1282
|
}
|
|
1228
1283
|
});
|
|
1229
1284
|
}
|
|
1285
|
+
/**
|
|
1286
|
+
* The nodes the identity holds the permission at, in effect now, reduced to
|
|
1287
|
+
* the ones no other such node already covers, and whether one of them is
|
|
1288
|
+
* the Environment's root. The roots alone are enough for an application
|
|
1289
|
+
* that keeps a closure table or a materialised path: it filters by "beneath
|
|
1290
|
+
* one of these" without an expanded set.
|
|
1291
|
+
*
|
|
1292
|
+
* Reads the tree, since minimality and `envWide` are questions about it; a
|
|
1293
|
+
* root the readable hierarchy does not contain raises
|
|
1294
|
+
* `authorizer.hierarchy_incomplete`, as a check at such a node does.
|
|
1295
|
+
*/
|
|
1296
|
+
async grantRoots(identityId, permission) {
|
|
1297
|
+
const now = this.now();
|
|
1298
|
+
const held = (await this.grantRootsFor(identityId)).get(permission) ?? [];
|
|
1299
|
+
const granted = new Set(
|
|
1300
|
+
held.filter((window) => inEffect(window, now)).map((w) => w.node)
|
|
1301
|
+
);
|
|
1302
|
+
if (granted.size === 0) {
|
|
1303
|
+
return { roots: [], envWide: false };
|
|
1304
|
+
}
|
|
1305
|
+
const { parents } = await this.treeEntry();
|
|
1306
|
+
const roots = [];
|
|
1307
|
+
let envWide = false;
|
|
1308
|
+
for (const node of granted) {
|
|
1309
|
+
if (!parents.has(node)) {
|
|
1310
|
+
throw this.hierarchyIncomplete(node);
|
|
1311
|
+
}
|
|
1312
|
+
if (parents.get(node) === null) {
|
|
1313
|
+
envWide = true;
|
|
1314
|
+
}
|
|
1315
|
+
let covered = false;
|
|
1316
|
+
let depth = 0;
|
|
1317
|
+
for (let current = parents.get(node); current && depth < MAX_WALK_DEPTH; current = parents.get(current), depth += 1) {
|
|
1318
|
+
if (granted.has(current)) {
|
|
1319
|
+
covered = true;
|
|
1320
|
+
break;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
if (!covered) {
|
|
1324
|
+
roots.push(node);
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
return { roots, envWide };
|
|
1328
|
+
}
|
|
1329
|
+
/**
|
|
1330
|
+
* Where the identity may act with the permission, as the set a list query
|
|
1331
|
+
* filters by: {@link grantRoots} expanded through their descendants, up to
|
|
1332
|
+
* `maxNodes` (the authorizer's default unless given here).
|
|
1333
|
+
*
|
|
1334
|
+
* Past the cap `nodes` is null and `complete` false: the set is too large
|
|
1335
|
+
* to be worth an `IN (...)` list, and the caller filters by `roots`
|
|
1336
|
+
* instead. An `envWide` answer carries no set either, since every node
|
|
1337
|
+
* qualifies and a filter would only slow the query down.
|
|
1338
|
+
*
|
|
1339
|
+
* Computed from the same cached grant roots and tree a check uses, so a
|
|
1340
|
+
* list filtered by it and the check on a write cannot disagree, and it goes
|
|
1341
|
+
* stale on the same `ttlMs`.
|
|
1342
|
+
*/
|
|
1343
|
+
async accessibleNodes(identityId, permission, options = {}) {
|
|
1344
|
+
const cap = options.maxNodes ?? this.maxNodes;
|
|
1345
|
+
const { roots, envWide } = await this.grantRoots(identityId, permission);
|
|
1346
|
+
if (envWide) {
|
|
1347
|
+
return { roots, envWide: true, nodes: null, complete: true };
|
|
1348
|
+
}
|
|
1349
|
+
if (roots.length === 0) {
|
|
1350
|
+
return { roots, envWide: false, nodes: /* @__PURE__ */ new Set(), complete: true };
|
|
1351
|
+
}
|
|
1352
|
+
const { children } = await this.treeEntry();
|
|
1353
|
+
const nodes = /* @__PURE__ */ new Set();
|
|
1354
|
+
const pending = [...roots];
|
|
1355
|
+
while (pending.length > 0) {
|
|
1356
|
+
const node = pending.pop();
|
|
1357
|
+
if (nodes.has(node)) {
|
|
1358
|
+
continue;
|
|
1359
|
+
}
|
|
1360
|
+
nodes.add(node);
|
|
1361
|
+
if (nodes.size > cap) {
|
|
1362
|
+
return { roots, envWide: false, nodes: null, complete: false };
|
|
1363
|
+
}
|
|
1364
|
+
for (const child of children.get(node) ?? []) {
|
|
1365
|
+
pending.push(child);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
return { roots, envWide: false, nodes, complete: true };
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* Every permission the identity holds at the node, by grant or by
|
|
1372
|
+
* inheritance: the list a screen renders its controls from, answered from
|
|
1373
|
+
* the same cached grant roots and tree a check uses.
|
|
1374
|
+
*/
|
|
1375
|
+
async permissionsAt(identityId, nodeId) {
|
|
1376
|
+
const answers = await this.permissionsAtMany(identityId, [nodeId]);
|
|
1377
|
+
return answers.get(nodeId) ?? [];
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* {@link permissionsAt} for several nodes at once, as node → permissions:
|
|
1381
|
+
* the rows of a list each with their own controls, answered from one read
|
|
1382
|
+
* of the grant roots and one walk per node.
|
|
1383
|
+
*/
|
|
1384
|
+
async permissionsAtMany(identityId, nodeIds) {
|
|
1385
|
+
const now = this.now();
|
|
1386
|
+
const roots = await this.grantRootsFor(identityId);
|
|
1387
|
+
const answers = /* @__PURE__ */ new Map();
|
|
1388
|
+
for (const nodeId of new Set(nodeIds)) {
|
|
1389
|
+
const path = await this.lineageOf(nodeId);
|
|
1390
|
+
const held = [];
|
|
1391
|
+
for (const [permission, windows] of roots) {
|
|
1392
|
+
if (windows.some(
|
|
1393
|
+
(window) => path.has(window.node) && inEffect(window, now)
|
|
1394
|
+
)) {
|
|
1395
|
+
held.push(permission);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
answers.set(nodeId, held.sort());
|
|
1399
|
+
}
|
|
1400
|
+
return answers;
|
|
1401
|
+
}
|
|
1230
1402
|
async decide(query) {
|
|
1231
|
-
const
|
|
1232
|
-
const
|
|
1233
|
-
|
|
1234
|
-
|
|
1403
|
+
const now = this.now();
|
|
1404
|
+
const held = (await this.grantRootsFor(query.identity_id)).get(
|
|
1405
|
+
query.permission
|
|
1406
|
+
);
|
|
1407
|
+
if (!held || held.length === 0) {
|
|
1408
|
+
return { allowed: false, reason: "permission_not_held" };
|
|
1235
1409
|
}
|
|
1236
1410
|
if ((query.scope ?? "node") === "app_wide") {
|
|
1237
|
-
return { allowed: true };
|
|
1411
|
+
return held.some((window) => inEffect(window, now)) ? { allowed: true, reason: null } : { allowed: false, reason: reasonAmong(held, now) };
|
|
1238
1412
|
}
|
|
1239
1413
|
if (!query.node_id) {
|
|
1240
|
-
return { allowed: false };
|
|
1414
|
+
return { allowed: false, reason: "node_missing" };
|
|
1241
1415
|
}
|
|
1242
|
-
|
|
1416
|
+
if (held.some(
|
|
1417
|
+
(window) => window.node === query.node_id && inEffect(window, now)
|
|
1418
|
+
)) {
|
|
1419
|
+
return { allowed: true, reason: null };
|
|
1420
|
+
}
|
|
1421
|
+
const path = await this.lineageOf(query.node_id);
|
|
1422
|
+
const reachable = held.filter((window) => path.has(window.node));
|
|
1423
|
+
if (reachable.some((window) => inEffect(window, now))) {
|
|
1424
|
+
return { allowed: true, reason: null };
|
|
1425
|
+
}
|
|
1426
|
+
if (reachable.length === 0) {
|
|
1427
|
+
return { allowed: false, reason: "outside_grants" };
|
|
1428
|
+
}
|
|
1429
|
+
return { allowed: false, reason: reasonAmong(reachable, now) };
|
|
1243
1430
|
}
|
|
1244
1431
|
/** Counters for observability — how much traffic the cache is actually saving. */
|
|
1245
1432
|
snapshot() {
|
|
@@ -1275,28 +1462,42 @@ var LocalAuthorizer = class {
|
|
|
1275
1462
|
this.grants.clear();
|
|
1276
1463
|
this.tree = void 0;
|
|
1277
1464
|
}
|
|
1278
|
-
/**
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1465
|
+
/** Fire the decision hooks, after the verdict and without waiting on them. */
|
|
1466
|
+
announce(event) {
|
|
1467
|
+
for (const hook of [
|
|
1468
|
+
this.onDecision,
|
|
1469
|
+
event.decision.allowed ? void 0 : this.onDenied
|
|
1470
|
+
]) {
|
|
1471
|
+
if (!hook) {
|
|
1472
|
+
continue;
|
|
1473
|
+
}
|
|
1474
|
+
try {
|
|
1475
|
+
hook(event);
|
|
1476
|
+
} catch {
|
|
1477
|
+
}
|
|
1282
1478
|
}
|
|
1283
|
-
|
|
1479
|
+
}
|
|
1480
|
+
/** The node and its ancestors, from the tree this client can read. */
|
|
1481
|
+
async lineageOf(nodeId) {
|
|
1482
|
+
const { parents } = await this.treeEntry();
|
|
1284
1483
|
if (!parents.has(nodeId)) {
|
|
1285
|
-
throw
|
|
1286
|
-
"authorizer.hierarchy_incomplete",
|
|
1287
|
-
`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.`
|
|
1288
|
-
);
|
|
1484
|
+
throw this.hierarchyIncomplete(nodeId);
|
|
1289
1485
|
}
|
|
1486
|
+
const path = /* @__PURE__ */ new Set([nodeId]);
|
|
1290
1487
|
let current = parents.get(nodeId);
|
|
1291
1488
|
let depth = 0;
|
|
1292
1489
|
while (current && depth < MAX_WALK_DEPTH) {
|
|
1293
|
-
|
|
1294
|
-
return true;
|
|
1295
|
-
}
|
|
1490
|
+
path.add(current);
|
|
1296
1491
|
current = parents.get(current);
|
|
1297
1492
|
depth += 1;
|
|
1298
1493
|
}
|
|
1299
|
-
return
|
|
1494
|
+
return path;
|
|
1495
|
+
}
|
|
1496
|
+
hierarchyIncomplete(nodeId) {
|
|
1497
|
+
return new CanopyAuthorizerError(
|
|
1498
|
+
"authorizer.hierarchy_incomplete",
|
|
1499
|
+
`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.`
|
|
1500
|
+
);
|
|
1300
1501
|
}
|
|
1301
1502
|
async grantRootsFor(identityId) {
|
|
1302
1503
|
const cached = this.grants.get(identityId);
|
|
@@ -1314,12 +1515,19 @@ var LocalAuthorizer = class {
|
|
|
1314
1515
|
return read;
|
|
1315
1516
|
}
|
|
1316
1517
|
/**
|
|
1317
|
-
* Every page of the identity's grant roots,
|
|
1518
|
+
* Every page of the identity's grant roots, with their windows, folded
|
|
1519
|
+
* into permission → roots.
|
|
1318
1520
|
*
|
|
1319
|
-
* Each row is one permission at one node, so a
|
|
1320
|
-
* nodes arrives as three rows, possibly on
|
|
1321
|
-
* the first page would under-report what
|
|
1322
|
-
* the safe direction, but a wrong answer
|
|
1521
|
+
* Each row is one permission at one node for one assignment, so a
|
|
1522
|
+
* permission granted at three nodes arrives as three rows, possibly on
|
|
1523
|
+
* different pages. Stopping after the first page would under-report what
|
|
1524
|
+
* the identity holds: a false deny, the safe direction, but a wrong answer
|
|
1525
|
+
* all the same.
|
|
1526
|
+
*
|
|
1527
|
+
* Rows outside their window are kept: they are what lets a denial say
|
|
1528
|
+
* `expired` or `not_yet_effective`, and the entry expires at the nearest
|
|
1529
|
+
* window boundary when that comes before the TTL, so a grant takes effect
|
|
1530
|
+
* or ends on time rather than up to `ttlMs` late.
|
|
1323
1531
|
*/
|
|
1324
1532
|
async fetchGrantRoots(identityId) {
|
|
1325
1533
|
this.stats.grantFetches += 1;
|
|
@@ -1329,30 +1537,42 @@ var LocalAuthorizer = class {
|
|
|
1329
1537
|
this.stats.grantPages += 1;
|
|
1330
1538
|
return this.client.request("GET", path, {
|
|
1331
1539
|
...this.readOptions,
|
|
1332
|
-
query
|
|
1333
|
-
});
|
|
1540
|
+
query: { ...query, include: "windows" }
|
|
1541
|
+
}).then((page) => understood(page, path));
|
|
1334
1542
|
},
|
|
1335
1543
|
{ page: 1, take: PAGE_SIZE },
|
|
1336
1544
|
{ maxPages: MAX_PAGES }
|
|
1337
1545
|
);
|
|
1338
1546
|
const roots = /* @__PURE__ */ new Map();
|
|
1547
|
+
const now = this.now();
|
|
1548
|
+
let boundary = Number.POSITIVE_INFINITY;
|
|
1339
1549
|
for await (const row of rows) {
|
|
1340
|
-
const
|
|
1341
|
-
|
|
1342
|
-
|
|
1550
|
+
const window = {
|
|
1551
|
+
node: row.node_id,
|
|
1552
|
+
from: row.effective_from ? Date.parse(row.effective_from) : null,
|
|
1553
|
+
to: row.effective_to ? Date.parse(row.effective_to) : null
|
|
1554
|
+
};
|
|
1555
|
+
for (const edge of [window.from, window.to]) {
|
|
1556
|
+
if (edge !== null && edge > now && edge < boundary) {
|
|
1557
|
+
boundary = edge;
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
const windows = roots.get(row.permission);
|
|
1561
|
+
if (windows) {
|
|
1562
|
+
windows.push(window);
|
|
1343
1563
|
} else {
|
|
1344
|
-
roots.set(row.permission,
|
|
1564
|
+
roots.set(row.permission, [window]);
|
|
1345
1565
|
}
|
|
1346
1566
|
}
|
|
1347
1567
|
this.grants.set(identityId, {
|
|
1348
1568
|
roots,
|
|
1349
|
-
expiresAt:
|
|
1569
|
+
expiresAt: Math.min(now + this.ttlMs, boundary)
|
|
1350
1570
|
});
|
|
1351
1571
|
return roots;
|
|
1352
1572
|
}
|
|
1353
|
-
async
|
|
1573
|
+
async treeEntry() {
|
|
1354
1574
|
if (this.tree && this.tree.expiresAt > this.now()) {
|
|
1355
|
-
return this.tree
|
|
1575
|
+
return this.tree;
|
|
1356
1576
|
}
|
|
1357
1577
|
if (this.pendingTree) {
|
|
1358
1578
|
return this.pendingTree;
|
|
@@ -1389,7 +1609,7 @@ var LocalAuthorizer = class {
|
|
|
1389
1609
|
if (held) {
|
|
1390
1610
|
this.stats.treeNotModified += 1;
|
|
1391
1611
|
this.tree = { ...held, expiresAt: this.now() + this.ttlMs };
|
|
1392
|
-
return
|
|
1612
|
+
return this.tree;
|
|
1393
1613
|
}
|
|
1394
1614
|
return this.keepTree([], null);
|
|
1395
1615
|
}
|
|
@@ -1410,12 +1630,15 @@ var LocalAuthorizer = class {
|
|
|
1410
1630
|
}
|
|
1411
1631
|
parentsFirstPage(etag) {
|
|
1412
1632
|
this.stats.treePages += 1;
|
|
1413
|
-
return this.client.requestConditional(
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1633
|
+
return this.client.requestConditional("GET", PARENTS_PATH, etag, {
|
|
1634
|
+
...this.readOptions,
|
|
1635
|
+
query: { page: 1, take: PAGE_SIZE }
|
|
1636
|
+
}).then((result) => {
|
|
1637
|
+
if (result.modified) {
|
|
1638
|
+
understood(result.data, PARENTS_PATH);
|
|
1639
|
+
}
|
|
1640
|
+
return result;
|
|
1641
|
+
});
|
|
1419
1642
|
}
|
|
1420
1643
|
/** The first page's edges followed by every later page's. */
|
|
1421
1644
|
async parentsRemainingPages(first) {
|
|
@@ -1429,7 +1652,7 @@ var LocalAuthorizer = class {
|
|
|
1429
1652
|
return this.client.request("GET", PARENTS_PATH, {
|
|
1430
1653
|
...this.readOptions,
|
|
1431
1654
|
query
|
|
1432
|
-
});
|
|
1655
|
+
}).then((page) => understood(page, PARENTS_PATH));
|
|
1433
1656
|
},
|
|
1434
1657
|
{ page: first.pagination.page + 1, take: PAGE_SIZE },
|
|
1435
1658
|
{ maxPages: MAX_PAGES - 1 }
|
|
@@ -1447,19 +1670,26 @@ var LocalAuthorizer = class {
|
|
|
1447
1670
|
ids.set(edge.id, edge.id);
|
|
1448
1671
|
}
|
|
1449
1672
|
const parents = /* @__PURE__ */ new Map();
|
|
1673
|
+
const children = /* @__PURE__ */ new Map();
|
|
1450
1674
|
for (const edge of edges) {
|
|
1451
|
-
const parent = edge.parent_node_id ??
|
|
1452
|
-
parents.set(
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1675
|
+
const parent = edge.parent_node_id === null || edge.parent_node_id === void 0 ? null : ids.get(edge.parent_node_id) ?? edge.parent_node_id;
|
|
1676
|
+
parents.set(edge.id, parent);
|
|
1677
|
+
if (parent !== null) {
|
|
1678
|
+
const siblings = children.get(parent);
|
|
1679
|
+
if (siblings) {
|
|
1680
|
+
siblings.push(edge.id);
|
|
1681
|
+
} else {
|
|
1682
|
+
children.set(parent, [edge.id]);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1456
1685
|
}
|
|
1457
1686
|
this.tree = {
|
|
1458
1687
|
parents,
|
|
1688
|
+
children,
|
|
1459
1689
|
etag,
|
|
1460
1690
|
expiresAt: this.now() + this.ttlMs
|
|
1461
1691
|
};
|
|
1462
|
-
return
|
|
1692
|
+
return this.tree;
|
|
1463
1693
|
}
|
|
1464
1694
|
};
|
|
1465
1695
|
function spansPages(first) {
|
|
@@ -1902,6 +2132,7 @@ exports.CanopyClient = CanopyClient;
|
|
|
1902
2132
|
exports.CanopyConnectionError = CanopyConnectionError;
|
|
1903
2133
|
exports.CanopyError = CanopyError;
|
|
1904
2134
|
exports.CanopyTokenError = CanopyTokenError;
|
|
2135
|
+
exports.DEFAULT_MAX_NODES = DEFAULT_MAX_NODES;
|
|
1905
2136
|
exports.Identities = Identities;
|
|
1906
2137
|
exports.LocalAuthorizer = LocalAuthorizer;
|
|
1907
2138
|
exports.MAX_TTL_MS = MAX_TTL_MS;
|