@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 +25 -0
- package/README.md +61 -4
- package/dist/index.cjs +288 -67
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1819 -277
- package/dist/index.d.ts +1819 -277
- package/dist/index.js +288 -68
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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
|
|
147
|
-
|
|
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.
|
|
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
|
-
|
|
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)}/
|
|
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:
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
|
1242
|
-
const
|
|
1243
|
-
|
|
1244
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1304
|
-
return true;
|
|
1305
|
-
}
|
|
1493
|
+
path.add(current);
|
|
1306
1494
|
current = parents.get(current);
|
|
1307
1495
|
depth += 1;
|
|
1308
1496
|
}
|
|
1309
|
-
return
|
|
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,
|
|
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
|
|
1330
|
-
* nodes arrives as three rows, possibly on
|
|
1331
|
-
* the first page would under-report what
|
|
1332
|
-
* the safe direction, but a wrong answer
|
|
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
|
|
1351
|
-
|
|
1352
|
-
|
|
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,
|
|
1567
|
+
roots.set(row.permission, [window]);
|
|
1355
1568
|
}
|
|
1356
1569
|
}
|
|
1357
1570
|
this.grants.set(identityId, {
|
|
1358
1571
|
roots,
|
|
1359
|
-
expiresAt:
|
|
1572
|
+
expiresAt: Math.min(now + this.ttlMs, boundary)
|
|
1360
1573
|
});
|
|
1361
1574
|
return roots;
|
|
1362
1575
|
}
|
|
1363
|
-
async
|
|
1576
|
+
async treeEntry() {
|
|
1364
1577
|
if (this.tree && this.tree.expiresAt > this.now()) {
|
|
1365
|
-
return this.tree
|
|
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
|
|
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 ??
|
|
1465
|
-
parents.set(
|
|
1466
|
-
|
|
1467
|
-
|
|
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
|
|
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,
|
|
1558
|
-
if (typeof org_id !== "string" || org_id === "" || typeof
|
|
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,
|
|
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;
|