@canopy-io/node 0.8.0 → 0.10.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 +25 -4
- package/dist/index.cjs +168 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +253 -130
- package/dist/index.d.ts +253 -130
- package/dist/index.js +168 -37
- 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.10.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
|
|
9
|
+
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.
|
|
10
|
+
|
|
11
|
+
`@canopy-io/nestjs` picks up the new authorizer; its own options and behaviour are unchanged.
|
|
12
|
+
|
|
13
|
+
## 0.9.0
|
|
14
|
+
|
|
15
|
+
### Minor Changes
|
|
16
|
+
|
|
17
|
+
- 436d639: Follows Canopy's paged hierarchy, grant, session and directory group reads. These endpoints now answer `{ items, pagination }`, at most 100 to a page, where they used to answer the whole collection.
|
|
18
|
+
|
|
19
|
+
`LocalAuthorizer`, and so `CanopyGuard`, reads every page of an identity's grant roots (now one row per permission and node) and every page of the hierarchy's parent edges, 100 at a time. Revalidation still costs one request: only the hierarchy's first page is sent with its `ETag`, and a `304` keeps the held copy. When the hierarchy has changed, every page is read again, and a rebuild over several pages is confirmed against the first page's `ETag` so a change made mid-read is not kept half-applied. `snapshot()` gains `grantPages` and `treePages`, the page requests behind `grantFetches` and `treeRequests`. Earlier versions deny every check against a Canopy that answers grants as one row per node, so upgrade before that API reaches you.
|
|
20
|
+
|
|
21
|
+
Caller shapes that changed:
|
|
22
|
+
|
|
23
|
+
- `identities.sessions(id, query?)` returns a `Paginator` instead of a promise of `{ items }`, and takes `page` and `take`. Use `for await`, `.all()` or `.first()`.
|
|
24
|
+
- `organizations.listDirectoryGroups(id, query?)` returns a `Paginator`, and takes `page`, `take`, `q` (display name or external id) and `sort_by` (`name`, the default, or `created`). The item type is exported as `OrganizationDirectoryGroupItem`.
|
|
25
|
+
- `permissions.evaluateBulk` takes between 1 and 100 checks; more is refused with a `400`.
|
|
26
|
+
- The generated types follow the API: `GET /api/v1/nodes` and `GET /api/v1/nodes/:id/tree` answer pages of flat nodes (`ScopedNodeResponseDto`, linked by `parent_node_id`) instead of a nested tree with its scope, `GET /api/v1/nodes/:id/children` is paged, and `IdentityGrantResponseDto` carries `node_id` instead of `nodes`. Several lists' `sort_by` is now typed to the columns each accepts.
|
|
27
|
+
|
|
28
|
+
`@canopy-io/nestjs` picks up the new authorizer; its own options and behaviour are unchanged.
|
|
29
|
+
|
|
3
30
|
## 0.8.0
|
|
4
31
|
|
|
5
32
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -46,11 +46,14 @@ const { allowed } = await canopy.permissions.evaluate({
|
|
|
46
46
|
node_id: nodeId,
|
|
47
47
|
});
|
|
48
48
|
|
|
49
|
-
// Pagination is handled for you, whichever style the endpoint uses.
|
|
49
|
+
// Pagination is handled for you, whichever style the endpoint uses. Every
|
|
50
|
+
// list method returns a Paginator: `for await`, `.all()` or `.first()`.
|
|
50
51
|
for await (const identity of canopy.identities.list({ take: 50 })) {
|
|
51
52
|
console.log(identity.email);
|
|
52
53
|
}
|
|
53
54
|
|
|
55
|
+
const newest = await canopy.identities.sessions(identityId).first();
|
|
56
|
+
|
|
54
57
|
// Errors carry a stable code, not just a message.
|
|
55
58
|
try {
|
|
56
59
|
await canopy.assignments.create({
|
|
@@ -67,6 +70,10 @@ try {
|
|
|
67
70
|
}
|
|
68
71
|
```
|
|
69
72
|
|
|
73
|
+
Lists answer 20 items a page by default and at most 100 (`take`).
|
|
74
|
+
`permissions.evaluateBulk` takes 1 to 100 checks in one request; more is
|
|
75
|
+
refused with a `400` and nothing is evaluated, so send a larger set in batches.
|
|
76
|
+
|
|
70
77
|
Anything without a typed wrapper is reachable the same way, with the same envelope handling and retry policy:
|
|
71
78
|
|
|
72
79
|
```ts
|
|
@@ -243,10 +250,20 @@ const { allowed } = await authorizer.evaluate({
|
|
|
243
250
|
|
|
244
251
|
It holds two things: the identity's grant roots, and one copy of your hierarchy
|
|
245
252
|
— read as parent edges from `GET /api/v1/nodes/parents` rather than the full
|
|
246
|
-
|
|
253
|
+
node list, which is 3.7x smaller — shared across every identity in the process. A check walks up from the node in
|
|
247
254
|
question looking for a grant root — a grant already means _this node and
|
|
248
255
|
everything beneath it_, so the roots are never expanded into their descendants.
|
|
249
256
|
|
|
257
|
+
Both reads are paged, and the authorizer reads every page, 100 rows at a time:
|
|
258
|
+
`GET /api/v1/identities/:id/grants` answers one row per permission and node
|
|
259
|
+
it was granted at, and the hierarchy arrives 100 parent edges to a page. Only
|
|
260
|
+
the hierarchy's first page is revalidated: its `ETag` covers the whole
|
|
261
|
+
hierarchy, so a `304` keeps the copy held without reading any other page. When
|
|
262
|
+
the hierarchy has changed, every page is read again and the copy rebuilt; a
|
|
263
|
+
rebuild over several pages is then confirmed against the first page's `ETag`,
|
|
264
|
+
and read again if the hierarchy changed while its pages were being read. At
|
|
265
|
+
50,000 nodes a rebuild is 501 requests, and a revalidation is one.
|
|
266
|
+
|
|
250
267
|
Both are held for at most 60 seconds, which is the delay between an access
|
|
251
268
|
change and it taking effect, including a moved node. Set `ttlMs` to change it,
|
|
252
269
|
up to five minutes (`300_000`); a longer value throws when the authorizer is
|
|
@@ -261,8 +278,12 @@ instances behind a load balancer, each keeps its own cache until its TTL runs
|
|
|
261
278
|
out or it is told to invalidate too.
|
|
262
279
|
|
|
263
280
|
A failed check (Canopy unreachable, a credential that cannot read the
|
|
264
|
-
hierarchy, an identity not in the Environment
|
|
265
|
-
|
|
281
|
+
hierarchy, an identity not in the Environment, or a Canopy serving an
|
|
282
|
+
authorization model this version does not know) throws rather than answering
|
|
283
|
+
`false`. The grant roots and the hierarchy each carry the `model_version` of
|
|
284
|
+
the rules they describe; an unknown one is refused with `CanopyAuthorizerError`
|
|
285
|
+
(`authorizer.model_version_unknown`) instead of being walked as if the rules
|
|
286
|
+
had not changed, and upgrading the package is the fix. `authorizationErrorStatus` turns the error into the response to send:
|
|
266
287
|
`503` when Canopy did not answer, `500` for a misconfiguration no retry fixes,
|
|
267
288
|
`403` for an identity Canopy does not know. The NestJS guard uses the same
|
|
268
289
|
mapping.
|
package/dist/index.cjs
CHANGED
|
@@ -615,11 +615,23 @@ var Identities = class {
|
|
|
615
615
|
* newest first: when and where it was signed in, when it expires, and the
|
|
616
616
|
* organization it is acting in. Read it back after a revoke to confirm,
|
|
617
617
|
* from your own side, that a sign-out took.
|
|
618
|
+
*
|
|
619
|
+
* Paginated (20 per page by default, `take` up to 100), so this returns a
|
|
620
|
+
* `Paginator`: iterate it for every session, or call `.first()` for the
|
|
621
|
+
* newest. The list takes `page` and `take` only; its order is fixed.
|
|
622
|
+
*
|
|
623
|
+
* ```ts
|
|
624
|
+
* for await (const session of canopy.identities.sessions(id)) { … }
|
|
625
|
+
* ```
|
|
618
626
|
*/
|
|
619
|
-
sessions(id) {
|
|
620
|
-
return
|
|
621
|
-
|
|
622
|
-
|
|
627
|
+
sessions(id, query = {}) {
|
|
628
|
+
return paginate(
|
|
629
|
+
(params) => this.client.request(
|
|
630
|
+
"GET",
|
|
631
|
+
`/api/v1/identities/${encodeURIComponent(id)}/sessions`,
|
|
632
|
+
{ query: params }
|
|
633
|
+
),
|
|
634
|
+
{ ...query }
|
|
623
635
|
);
|
|
624
636
|
}
|
|
625
637
|
/**
|
|
@@ -915,11 +927,25 @@ var Organizations = class {
|
|
|
915
927
|
`/api/v1/organizations/${enc(id)}/directory/activity`
|
|
916
928
|
);
|
|
917
929
|
}
|
|
918
|
-
/**
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
930
|
+
/**
|
|
931
|
+
* The groups the provider has pushed, each with its role mapping.
|
|
932
|
+
*
|
|
933
|
+
* Paginated (20 per page by default, `take` up to 100), so this returns a
|
|
934
|
+
* `Paginator`. Sorted by display name unless `sort_by: "created"`; `q`
|
|
935
|
+
* searches display names and external ids.
|
|
936
|
+
*
|
|
937
|
+
* ```ts
|
|
938
|
+
* for await (const group of canopy.organizations.listDirectoryGroups(id)) { … }
|
|
939
|
+
* ```
|
|
940
|
+
*/
|
|
941
|
+
listDirectoryGroups(id, query = {}) {
|
|
942
|
+
return paginate(
|
|
943
|
+
(params) => this.client.request(
|
|
944
|
+
"GET",
|
|
945
|
+
`/api/v1/organizations/${enc(id)}/directory/groups`,
|
|
946
|
+
{ query: params }
|
|
947
|
+
),
|
|
948
|
+
{ ...query }
|
|
923
949
|
);
|
|
924
950
|
}
|
|
925
951
|
/**
|
|
@@ -981,6 +1007,10 @@ var Permissions = class {
|
|
|
981
1007
|
*
|
|
982
1008
|
* Prefer this to a loop over `evaluate` when rendering a screen: the checks
|
|
983
1009
|
* are answered together instead of paying request latency for each.
|
|
1010
|
+
*
|
|
1011
|
+
* One request carries between 1 and 100 checks. More is refused with a
|
|
1012
|
+
* `400` (`CanopyError`, `statusCode` 400) and nothing is evaluated, so split
|
|
1013
|
+
* a larger set into batches of 100.
|
|
984
1014
|
*/
|
|
985
1015
|
evaluateBulk(input, options = {}) {
|
|
986
1016
|
return this.client.request("POST", "/api/v1/permissions/evaluate/bulk", {
|
|
@@ -1122,6 +1152,21 @@ var Canopy = class {
|
|
|
1122
1152
|
var DEFAULT_TTL_MS = 6e4;
|
|
1123
1153
|
var MAX_TTL_MS = 3e5;
|
|
1124
1154
|
var MAX_WALK_DEPTH = 256;
|
|
1155
|
+
var PAGE_SIZE = 100;
|
|
1156
|
+
var MAX_PAGES = 1e4;
|
|
1157
|
+
var MAX_TREE_WALKS = 3;
|
|
1158
|
+
var KNOWN_MODEL_VERSIONS = /* @__PURE__ */ new Set([1]);
|
|
1159
|
+
function understood(page, path) {
|
|
1160
|
+
if (!KNOWN_MODEL_VERSIONS.has(page.model_version)) {
|
|
1161
|
+
throw new CanopyAuthorizerError(
|
|
1162
|
+
"authorizer.model_version_unknown",
|
|
1163
|
+
`${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.`
|
|
1164
|
+
);
|
|
1165
|
+
}
|
|
1166
|
+
return page;
|
|
1167
|
+
}
|
|
1168
|
+
var GRANTS_PATH = (identityId) => `/api/v1/identities/${encodeURIComponent(identityId)}/grants`;
|
|
1169
|
+
var PARENTS_PATH = "/api/v1/nodes/parents";
|
|
1125
1170
|
var LocalAuthorizer = class {
|
|
1126
1171
|
client;
|
|
1127
1172
|
ttlMs;
|
|
@@ -1141,7 +1186,9 @@ var LocalAuthorizer = class {
|
|
|
1141
1186
|
pendingTree;
|
|
1142
1187
|
stats = {
|
|
1143
1188
|
grantFetches: 0,
|
|
1189
|
+
grantPages: 0,
|
|
1144
1190
|
treeRequests: 0,
|
|
1191
|
+
treePages: 0,
|
|
1145
1192
|
treeNotModified: 0
|
|
1146
1193
|
};
|
|
1147
1194
|
constructor(client, options = {}) {
|
|
@@ -1276,16 +1323,36 @@ var LocalAuthorizer = class {
|
|
|
1276
1323
|
this.pendingGrants.set(identityId, read);
|
|
1277
1324
|
return read;
|
|
1278
1325
|
}
|
|
1326
|
+
/**
|
|
1327
|
+
* Every page of the identity's grant roots, folded into permission → nodes.
|
|
1328
|
+
*
|
|
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.
|
|
1333
|
+
*/
|
|
1279
1334
|
async fetchGrantRoots(identityId) {
|
|
1280
1335
|
this.stats.grantFetches += 1;
|
|
1281
|
-
const
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1336
|
+
const path = GRANTS_PATH(identityId);
|
|
1337
|
+
const rows = paginate(
|
|
1338
|
+
(query) => {
|
|
1339
|
+
this.stats.grantPages += 1;
|
|
1340
|
+
return this.client.request("GET", path, {
|
|
1341
|
+
...this.readOptions,
|
|
1342
|
+
query
|
|
1343
|
+
}).then((page) => understood(page, path));
|
|
1344
|
+
},
|
|
1345
|
+
{ page: 1, take: PAGE_SIZE },
|
|
1346
|
+
{ maxPages: MAX_PAGES }
|
|
1285
1347
|
);
|
|
1286
1348
|
const roots = /* @__PURE__ */ new Map();
|
|
1287
|
-
for (const row of
|
|
1288
|
-
roots.
|
|
1349
|
+
for await (const row of rows) {
|
|
1350
|
+
const nodes = roots.get(row.permission);
|
|
1351
|
+
if (nodes) {
|
|
1352
|
+
nodes.add(row.node_id);
|
|
1353
|
+
} else {
|
|
1354
|
+
roots.set(row.permission, /* @__PURE__ */ new Set([row.node_id]));
|
|
1355
|
+
}
|
|
1289
1356
|
}
|
|
1290
1357
|
this.grants.set(identityId, {
|
|
1291
1358
|
roots,
|
|
@@ -1310,43 +1377,107 @@ var LocalAuthorizer = class {
|
|
|
1310
1377
|
* Revalidate rather than re-read. The hierarchy is the expensive half and
|
|
1311
1378
|
* the one that changes least, so the common case is a `304` and no transfer
|
|
1312
1379
|
* at all — the tree stays in memory and only its expiry moves.
|
|
1380
|
+
*
|
|
1381
|
+
* The edges come a page at a time, so only the first page is conditional:
|
|
1382
|
+
* its validator covers the whole hierarchy, and a `304` to it means no page
|
|
1383
|
+
* has changed. On a `200` the remaining pages are read plainly and the map
|
|
1384
|
+
* is rebuilt from every page.
|
|
1385
|
+
*
|
|
1386
|
+
* A walk over several pages is not one snapshot. A node added or removed
|
|
1387
|
+
* between two page reads shifts the rows under the offset, so a row can be
|
|
1388
|
+
* skipped, which would leave a node out of the map until the next refresh.
|
|
1389
|
+
* So the walk is confirmed by asking for the first page again with the
|
|
1390
|
+
* validator it came with: a `304` proves nothing changed while the pages
|
|
1391
|
+
* were read. A `200` is a fresh first page, and the walk starts again from
|
|
1392
|
+
* it, up to {@link MAX_TREE_WALKS} times.
|
|
1313
1393
|
*/
|
|
1314
1394
|
async fetchTree() {
|
|
1315
1395
|
this.stats.treeRequests += 1;
|
|
1316
1396
|
const held = this.tree;
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
this.
|
|
1325
|
-
this.tree = { ...held, expiresAt: this.now() + this.ttlMs };
|
|
1326
|
-
return held.parents;
|
|
1397
|
+
let first = await this.parentsFirstPage(held?.etag ?? void 0);
|
|
1398
|
+
if (!first.modified) {
|
|
1399
|
+
if (held) {
|
|
1400
|
+
this.stats.treeNotModified += 1;
|
|
1401
|
+
this.tree = { ...held, expiresAt: this.now() + this.ttlMs };
|
|
1402
|
+
return held.parents;
|
|
1403
|
+
}
|
|
1404
|
+
return this.keepTree([], null);
|
|
1327
1405
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
ids.set(edge.id, edge.id);
|
|
1406
|
+
for (let walk = 1; ; walk++) {
|
|
1407
|
+
const edges = await this.parentsRemainingPages(first.data);
|
|
1408
|
+
if (!spansPages(first.data) || first.etag === null) {
|
|
1409
|
+
return this.keepTree(edges, first.etag);
|
|
1333
1410
|
}
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
edge.id,
|
|
1338
|
-
parent === null ? null : ids.get(parent) ?? parent
|
|
1339
|
-
);
|
|
1411
|
+
const confirm = await this.parentsFirstPage(first.etag);
|
|
1412
|
+
if (!confirm.modified) {
|
|
1413
|
+
return this.keepTree(edges, first.etag);
|
|
1340
1414
|
}
|
|
1415
|
+
if (walk >= MAX_TREE_WALKS) {
|
|
1416
|
+
return this.keepTree(edges, null);
|
|
1417
|
+
}
|
|
1418
|
+
first = confirm;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
parentsFirstPage(etag) {
|
|
1422
|
+
this.stats.treePages += 1;
|
|
1423
|
+
return this.client.requestConditional("GET", PARENTS_PATH, etag, {
|
|
1424
|
+
...this.readOptions,
|
|
1425
|
+
query: { page: 1, take: PAGE_SIZE }
|
|
1426
|
+
}).then((result) => {
|
|
1427
|
+
if (result.modified) {
|
|
1428
|
+
understood(result.data, PARENTS_PATH);
|
|
1429
|
+
}
|
|
1430
|
+
return result;
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
/** The first page's edges followed by every later page's. */
|
|
1434
|
+
async parentsRemainingPages(first) {
|
|
1435
|
+
const edges = [...first.items];
|
|
1436
|
+
if (!spansPages(first)) {
|
|
1437
|
+
return edges;
|
|
1438
|
+
}
|
|
1439
|
+
const rest = paginate(
|
|
1440
|
+
(query) => {
|
|
1441
|
+
this.stats.treePages += 1;
|
|
1442
|
+
return this.client.request("GET", PARENTS_PATH, {
|
|
1443
|
+
...this.readOptions,
|
|
1444
|
+
query
|
|
1445
|
+
}).then((page) => understood(page, PARENTS_PATH));
|
|
1446
|
+
},
|
|
1447
|
+
{ page: first.pagination.page + 1, take: PAGE_SIZE },
|
|
1448
|
+
{ maxPages: MAX_PAGES - 1 }
|
|
1449
|
+
);
|
|
1450
|
+
for await (const page of rest.pages()) {
|
|
1451
|
+
for (const edge of page.items) {
|
|
1452
|
+
edges.push(edge);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
return edges;
|
|
1456
|
+
}
|
|
1457
|
+
keepTree(edges, etag) {
|
|
1458
|
+
const ids = /* @__PURE__ */ new Map();
|
|
1459
|
+
for (const edge of edges) {
|
|
1460
|
+
ids.set(edge.id, edge.id);
|
|
1461
|
+
}
|
|
1462
|
+
const parents = /* @__PURE__ */ new Map();
|
|
1463
|
+
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
|
+
);
|
|
1341
1469
|
}
|
|
1342
1470
|
this.tree = {
|
|
1343
1471
|
parents,
|
|
1344
|
-
etag
|
|
1472
|
+
etag,
|
|
1345
1473
|
expiresAt: this.now() + this.ttlMs
|
|
1346
1474
|
};
|
|
1347
1475
|
return parents;
|
|
1348
1476
|
}
|
|
1349
1477
|
};
|
|
1478
|
+
function spansPages(first) {
|
|
1479
|
+
return first.pagination.has_next_page && first.items.length > 0;
|
|
1480
|
+
}
|
|
1350
1481
|
|
|
1351
1482
|
// src/status.ts
|
|
1352
1483
|
function tokenErrorStatus(error) {
|