@canopy-io/nestjs 0.2.0 → 0.2.2

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.
Files changed (3) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/README.md +6 -0
  3. package/package.json +8 -2
package/CHANGELOG.md ADDED
@@ -0,0 +1,171 @@
1
+ # @canopy-io/nestjs
2
+
3
+ ## 0.2.2
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [e9a9d26]
8
+ - @canopy-io/node@0.4.0
9
+
10
+ ## 0.2.1
11
+
12
+ ### Patch Changes
13
+
14
+ - b6b1dd0: Ship the changelog with the package, and create a GitHub Release per published
15
+ version.
16
+
17
+ The changelog was written on every release and reachable by nobody: `files`
18
+ omitted it, so npm showed none, and the release workflow pushed tags without
19
+ creating releases, so the Releases page sat empty. A consumer deciding whether
20
+ to take a bump had nothing to read. `CHANGELOG.md` is now in the tarball, each
21
+ README links it and notes that a pre-1.0 minor may break, and the workflow
22
+ opens a release whose body is that version's changelog section.
23
+
24
+ - Updated dependencies [b6b1dd0]
25
+ - @canopy-io/node@0.3.3
26
+
27
+ ## 0.2.0
28
+
29
+ ### Minor Changes
30
+
31
+ - 8ad7dd0: Organizations support. Identity tokens minted in an Environment with the
32
+ organizations container on carry `org_id` and `org_role`; both are now
33
+ declared on `CanopyTokenClaims`, and the new `orgContext(claims)` helper
34
+ returns the verified pair (or `null` for a token acting in no organization,
35
+ refusing a half-present pair). `@RequirePermission` gains `scope: "org"`: the
36
+ guard evaluates at the token's `org_id` node through the existing
37
+ `LocalAuthorizer` — an organization is a hierarchy node and a membership is a
38
+ role assignment at it — reading the claim off what `CanopyTokenGuard`
39
+ attached, overridable with the new `resolveOrg` module option. A caller
40
+ acting in no organization is denied without a network call.
41
+
42
+ ### Patch Changes
43
+
44
+ - e4ca3ef: Organizations as a container: `canopy.organizations` wraps the tenant API
45
+ (organizations, members, invitations, the per-organization sign-in policy,
46
+ and SSO connection binding), `CanopyTokenClaims` declares `amr`, types are
47
+ regenerated against the current spec, and the wording no longer describes
48
+ organizations as an access model. The published spec now carries 102 operations (was 84), all additive.
49
+ - Updated dependencies [8ad7dd0]
50
+ - Updated dependencies [e4ca3ef]
51
+ - @canopy-io/node@0.3.0
52
+
53
+ ## 0.1.2
54
+
55
+ ### Patch Changes
56
+
57
+ - b873daa: Fix type resolution for CommonJS consumers on `node16`/`nodenext`: the
58
+ `exports` map now declares per-condition `types`, pointing `require` at the
59
+ `index.d.cts` the build already emitted. Previously a `require()` that worked
60
+ at runtime was rejected by TypeScript (TS1479), forcing dynamic-import and
61
+ `resolution-mode` workarounds. Verified with `arethetypeswrong` across
62
+ node10, node16-CJS, node16-ESM and bundler resolution.
63
+ - Updated dependencies [b873daa]
64
+ - Updated dependencies [13ecf63]
65
+ - Updated dependencies [003761a]
66
+ - @canopy-io/node@0.2.0
67
+
68
+ ## 0.1.1
69
+
70
+ ### Patch Changes
71
+
72
+ - 89f0a46: Answer permission checks in-process instead of calling Canopy on every request.
73
+
74
+ **Behavior change — `CanopyGuard` no longer makes a network call per guarded request.** It asks a different question, once: rather than "may this identity act _here_", it reads _where_ the identity may act and answers everything after that locally.
75
+
76
+ Two things are held, split by what they depend on: the identity's grant roots, and one copy of your hierarchy shared by every identity in the process. A check walks up from the node in question looking for a grant root. Grant roots are never expanded through their descendants — a grant already means "this node and everything beneath it", and expanding would restate the hierarchy once per identity, largest for the near-root grants administrators hold.
77
+
78
+ ```ts
79
+ CanopyModule.forRoot({
80
+ apiKey: process.env.CANOPY_API_KEY,
81
+ authorizationTtlMs: 60_000, // the default
82
+ resolveIdentity: (request) => request.user?.sub,
83
+ });
84
+ ```
85
+
86
+ **Both are held for at most 60 seconds, which is the delay between an access change and it taking effect.** A revoked user can still act for up to a minute. The window covers everything that can alter an answer, including a moved node — reparenting changes what an inherited grant reaches even though no grant changed, so the hierarchy is revalidated on the same cadence and not a slower one.
87
+
88
+ Set `authorizationTtlMs: 0` to switch caching off and read fresh on every check. That reinstates a round trip per request, so reach for it knowingly. For a single high-value operation, ask the API directly through the injected client instead — `canopy.permissions.evaluate` is unchanged.
89
+
90
+ Do not shorten the window below the gap between a user's requests: every request then finds the cache expired and refetches, which is the per-request traffic this removes.
91
+
92
+ **`LocalAuthorizer`** (`@canopy-io/node`) is the same machinery without NestJS. **`requestConditional`** sends a validator you already hold and reports whether anything changed, so something expensive can be revalidated cheaply.
93
+
94
+ **Requires a credential that can read both halves.** A `full_access` API key already can; a scoped one needs `identity.view` and `hierarchy.view`. Without the latter the hierarchy endpoint answers an empty tree and a `200` — so a node-scoped check raises `CanopyAuthorizerError` rather than returning `false`. Not being able to see the hierarchy is not the same as the identity lacking the permission, and answering `false` there would deny every node-scoped request while looking entirely healthy.
95
+
96
+ `CanopyGuard` still fails closed at every step. A `404` with no error code — the API not serving the grants endpoint at all — is reported as a misconfiguration rather than a denial, because denying there would take out every guarded route at once while reading as ordinary policy.
97
+
98
+ - 89f0a46: Verify Canopy access tokens locally, and guard a route with the result.
99
+
100
+ `TokenVerifier` checks Canopy's RS256 signatures against the published key set: one JWKS fetch, then a signature check per request with no network at all. Authorization asks the API a question; authentication does not need to.
101
+
102
+ ```ts
103
+ const verifier = new TokenVerifier();
104
+ const claims = await verifier.verify(bearerToken);
105
+ ```
106
+
107
+ The traps are why this ships rather than being left to each caller. The algorithm is pinned, so `alg: none` and HMAC key-confusion cannot get in. Refetches triggered by an unknown `kid` are floored, so a forged header cannot amplify one inbound request into one outbound fetch — and the same floor applies when the key set is empty or stale, which is the case an issuer outage produces. The JWKS read is bounded by a deadline, because verification sits on the request path and an unbounded read there is an unbounded inbound request. A token carrying an `aud` is refused rather than ignored when no audience was configured. And a **pre-auth** token — genuine, correctly signed, issued before the user selected an Account — is refused, because treating one as a session is a privilege escalation.
108
+
109
+ `sub` and `type` are validated rather than asserted. They are declared on `CanopyTokenClaims` but arrive as parsed JSON, and a `string` that is actually `undefined` is worse than a rejected token because it looks like an answer.
110
+
111
+ **`CanopyTokenGuard`** (`@canopy-io/nestjs`) applies this to a route and attaches the claims, for applications with no auth layer in front:
112
+
113
+ ```ts
114
+ @UseGuards(CanopyTokenGuard, CanopyGuard)
115
+ @RequirePermission("documents.read")
116
+ findAll() {}
117
+ ```
118
+
119
+ Order matters — `CanopyGuard` reads what `CanopyTokenGuard` attached. Claims land on `request.canopyToken`, not `request.user`, which belongs to Passport. Skip the guard entirely if you already run your own JWT middleware; nothing else depends on it.
120
+
121
+ A rejected token is a `401` whatever the detail. The exception is `token.jwks_unavailable`, which answers **503**: not being able to verify is not the same as failing to verify, the caller's token may be perfectly good, and telling a client its token is stale during a key-server outage only aims a refresh storm at the thing that is already down.
122
+
123
+ - bbfe8e8: Bound the permission check so a slow or unreachable Canopy cannot hold a request open.
124
+
125
+ `CanopyGuard` evaluated with the client-wide defaults — a 30s per-attempt timeout and 2 retries — so a hung call could hold an inbound request for roughly 90s plus backoff, on the authorization path of every guarded route.
126
+
127
+ The guard now evaluates with a 5s per-attempt deadline (`evaluateTimeoutMs`) and 1 retry (`evaluateMaxRetries`), both configurable and both scoped to the guard — everything else through the injected client keeps the client-wide values. It also caps each wait between attempts at one deadline, because that wait is otherwise set by Canopy's `Retry-After` header and sits outside the deadline entirely: a `Retry-After: 120` on a 429 would have held the inbound request for two minutes regardless of any timeout configured here.
128
+
129
+ Worst case is now roughly `evaluateTimeoutMs × (2 × evaluateMaxRetries + 1)` — about 15s with the defaults, against unbounded before.
130
+
131
+ ```ts
132
+ CanopyModule.forRoot({
133
+ apiKey: process.env.CANOPY_API_KEY,
134
+ evaluateTimeoutMs: 2_000,
135
+ evaluateMaxRetries: 0,
136
+ resolveIdentity: (request) => request.user.sub,
137
+ });
138
+ ```
139
+
140
+ **A failed check now reports what actually failed.** Every Canopy error became `503 Service Unavailable`, so a rejected or unscoped API key — a permanent misconfiguration — was reported as a temporary outage, inviting retries that could never succeed and hiding the bug. Now: an unreachable, rate-limited or `5xx` Canopy is `503`; a rejected API key or a refused question is `500`; an identity Canopy has never heard of is `403`. Every branch still fails closed.
141
+
142
+ **A resolver that throws now denies instead of crashing.** `resolveIdentity: (request) => request.user.sub` — the shape the docs suggested — throws on an unauthenticated request, which surfaced as a `500` rather than the documented `403`. Both resolvers are now called defensively: a throw is treated as "could not resolve", the same denial as returning nothing. The documented examples use `request.user?.sub`.
143
+
144
+ **Registering the guard globally is documented properly.** Nest runs global guards before controller- and route-scoped ones, so a globally-registered `CanopyGuard` runs before route-level authentication has populated the request and every guarded route denies. The README now says to register your authentication guard globally ahead of it, or to apply both at the route.
145
+
146
+ The guard also aborts the check when the caller disconnects while it is in flight, and propagates that abort rather than reporting `503` — a caller hanging up is not Canopy being unavailable, and it is no longer logged as though it were. Disconnect is detected on the response, on a close arriving before anything was written.
147
+
148
+ This is resolved per adapter: Express hands back the Node response directly, Fastify hands back a `Reply` whose real response is on `.raw`. A transport with neither, such as GraphQL, runs to the deadline as before. Both adapters are covered by end-to-end tests that boot a real Nest application and hang up a real client mid-check.
149
+
150
+ - Updated dependencies [89f0a46]
151
+ - Updated dependencies [89f0a46]
152
+ - Updated dependencies [41241ca]
153
+ - @canopy-io/node@0.1.1
154
+
155
+ ## 0.1.0
156
+
157
+ ### Minor Changes
158
+
159
+ - 481ee14: Add `@canopy-io/nestjs`, the NestJS integration.
160
+
161
+ `CanopyModule.forRoot` registers one configured client and the guard that uses
162
+ it; `@RequirePermission("orders.refund")` on a route turns into a single
163
+ evaluation against Canopy. The `node` scope is the default, so a permission is
164
+ checked at the node the request touches and inherited from ancestors;
165
+ `app_wide` is opt-in because it answers a coarser question and must not guard a
166
+ node-owned resource.
167
+
168
+ The guard fails closed. An unresolvable identity, a node check with no node, a
169
+ denial, or an unreachable API all end the request, and an unreachable API is a
170
+ 503 rather than a 403 so "we could not decide" is never reported as "you are
171
+ not allowed".
package/README.md CHANGED
@@ -286,6 +286,12 @@ A hit costs nothing. A miss reads from Canopy on the request path and holds an i
286
286
 
287
287
  The read itself is deliberately **not** cancelled: it may be shared with other requests that are still waiting on it, and it warms the cache either way.
288
288
 
289
+ ## Changelog
290
+
291
+ [CHANGELOG.md](./CHANGELOG.md) records every published version. The package is
292
+ pre-1.0: while the surface is still settling, a minor bump may carry a breaking
293
+ change, so read the entry before taking one.
294
+
289
295
  ## License
290
296
 
291
297
  MIT © Canopy Identity Inc.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canopy-io/nestjs",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Official NestJS integration for Canopy — hierarchical identity and access management.",
5
5
  "license": "MIT",
6
6
  "author": "Canopy Identity Inc.",
@@ -16,8 +16,13 @@
16
16
  "nestjs",
17
17
  "authorization",
18
18
  "rbac",
19
+ "hierarchical-rbac",
20
+ "access-control",
19
21
  "iam",
20
22
  "permissions",
23
+ "multi-tenant",
24
+ "b2b-saas",
25
+ "sso",
21
26
  "guard"
22
27
  ],
23
28
  "type": "module",
@@ -41,6 +46,7 @@
41
46
  "files": [
42
47
  "dist",
43
48
  "README.md",
49
+ "CHANGELOG.md",
44
50
  "LICENSE"
45
51
  ],
46
52
  "engines": {
@@ -56,7 +62,7 @@
56
62
  "test:watch": "vitest"
57
63
  },
58
64
  "dependencies": {
59
- "@canopy-io/node": "^0.3.0"
65
+ "@canopy-io/node": "^0.4.0"
60
66
  },
61
67
  "peerDependencies": {
62
68
  "@nestjs/common": "^10.0.0 || ^11.0.0",