@canopy-io/nestjs

中文文档
0.9.1发布于 2026/11/07MITTypeScriptESM + CJS构建来源已验证近 30 天下载 2956

Official NestJS integration for Canopy — hierarchical identity and access management.

原始使用文档
文档目录15 节
  1. Install
  2. Features
  3. Register
  4. Guard a route
  5. If you register it globally
  6. Scope
  7. If Canopy issues your tokens
  8. The client
  9. Failing closed
  10. How the decision is reached
  11. What the credential needs
  12. What a revoked user can still do
  13. Bounding a cache miss
  14. Changelog
  15. License

@canopy-io/nestjs

Official NestJS integration for Canopy — hierarchical identity and access management for B2B SaaS.

Turns a permission check into a decorator on the route instead of a call in every handler.

Install

npm install @canopy-io/nestjs

Requires NestJS 10 or 11 and Node 18 or later. @canopy-io/node comes with it.

Features

  • One registration. CanopyModule.forRoot for a static key, forRootAsync when it comes from something injectable. Pass your own request type and the resolvers are typed against it.
  • @RequirePermission on the route. The check is a declaration on the handler rather than a call inside it. A route without it passes through untouched, so the guard can be registered globally and opted into.
  • CanopyGuard answers that declaration in your own process, at the node the request names — inheriting a role granted on a parent without you modelling the walk.
  • No network call per request. The guard holds the identity's grant roots and one shared copy of your hierarchy, refreshed at most once a minute, so a guarded route normally reaches no network at all.
  • Three scopes. node is the default and the strict question; app_wide asks only whether the identity holds the permission anywhere in the Environment; org asks the node question at the organization the caller's token is acting in.
  • CanopyTokenGuard verifies a Canopy-issued bearer token locally against the published signing keys and attaches the claims, for applications with no auth layer in front. Additive — skip it if you already run Passport or your own JWT middleware.
  • @AllowServiceTokens() admits a machine client's client_credentials token on the routes built for machines, authorized from the client's own grants; every other route keeps refusing it.
  • @AllowDelegatedTokens() admits a machine client acting for a person after a token exchange, authorized only where the person and the client both hold the permission.
  • @InjectCanopy() hands you the full @canopy-io/node client for everything the guard does not cover.
  • Fails closed, with the status telling you which failure it was. 403 for a denial, 503 for an undecidable one, 500 for a broken integration.
  • A bounded check. Its own evaluation timeout and retry count, separate from the client-wide ones, with the wait between attempts capped.
  • Aborts when the caller hangs up, on both Express and Fastify, rather than finishing a decision no one will read.

Register

import { CanopyModule } from "@canopy-io/nestjs";

@Module({
  imports: [
    CanopyModule.forRoot<AuthedRequest>({
      isGlobal: true,
      apiKey: process.env.CANOPY_API_KEY,
      verify: { environmentId: process.env.CANOPY_ENVIRONMENT_ID },
      resolveIdentity: (request) => request.user?.sub,
      resolveNode: (request) => request.params?.orgId,
    }),
  ],
})
export class AppModule {}

Register it once, in the root module. isGlobal: true makes its providers visible to the guards in your feature modules, where guarded controllers usually live; without it, a guard outside the registering module cannot resolve its token verifier and the application refuses to start. It defaults to false, as @nestjs/config does.

verify names the Environment whose tokens the module accepts (environmentId, or issuer on a custom auth domain or a self-hosted instance; a self-hosted instance can instead set CANOPY_BASE_URL for the process, which environmentId and the client both follow). It is required: the module builds its token verifier at startup.

The two resolvers are how the guard learns who is asking and where. Where those live is your application's business — a claim on a verified token, a route parameter, a tenant on the session — so you supply them rather than the library guessing. Pass your own request type to forRoot and both resolvers are typed against it.

When the key comes from something injectable, use forRootAsync:

CanopyModule.forRootAsync({
  isGlobal: true,
  imports: [ConfigModule],
  inject: [ConfigService],
  useFactory: (config: ConfigService) => ({
    apiKey: config.getOrThrow("CANOPY_API_KEY"),
    verify: { environmentId: config.getOrThrow("CANOPY_ENVIRONMENT_ID") },
    resolveIdentity: (request) => request.user?.sub,
    resolveNode: (request) => request.params?.orgId,
  }),
});

Guard a route

import { CanopyGuard, RequirePermission } from "@canopy-io/nestjs";

@Controller("orgs/:orgId/orders")
@UseGuards(CanopyGuard)
export class OrdersController {
  @Post(":orderId/refund")
  @RequirePermission("orders.refund")
  refund() {}
}

That answers whether the identity holds orders.refund at orgId, walking the node's lineage — so a role granted on a parent is inherited without you modelling it.

The walk happens in your process, not over the network. See How the decision is reached.

A route with no @RequirePermission passes through untouched, so the guard can be registered globally and opted into per route. A requirement on a handler wins over one on its controller.

If you register it globally

Nest runs global guards before controller- and route-scoped ones. So a globally-registered CanopyGuard runs before a route-level AuthGuard has put anything on the request, and resolveIdentity finds nothing — every guarded route answers 403.

Register your authentication guard globally too, ahead of this one:

providers: [
  { provide: APP_GUARD, useClass: JwtAuthGuard }, // must come first
  { provide: APP_GUARD, useClass: CanopyGuard },
];

Global guards run in registration order. If your authentication is route-level, apply CanopyGuard at the route as well — @UseGuards(JwtAuthGuard, CanopyGuard) — where left-to-right order holds.

Scope

The default is node, which is the strict question: does this identity hold the permission at this node.

@RequirePermission("reports.view", { scope: "app_wide" })

app_wide asks only whether the identity holds the permission anywhere in the Environment, and returns no effective node. It is right for deciding whether to show a menu item and wrong for guarding a resource that belongs to a node — which is why it is opt-in rather than the default.

@RequirePermission("invoices.view", { scope: "org" })

org is for Environments with the organizations container on, where a token carries the organization the session is acting in (org_id) and the roles held at its node (org_roles, possibly none). The guard reads org_id off the verified claims CanopyTokenGuard attached and evaluates at that node — an organization is a hierarchy node, and a member's roles are assignments at it or beneath it, so no resolveNode and no node in the route's path are needed. A caller acting in no organization is denied: an org-scoped route has no meaning outside one.

That includes your own staff. A staff token (a role at the Environment root, org_scope: "all") carries no org_id until the session enters an organization with switch-organization or the hosted organization= parameter, and the guard never takes the organization from the route instead. Entering is what the customer's organization sees in its staff activity and can refuse, so a route that let staff in by its path would skip both. Once inside, org_id is set and the check runs as for a member, against the roles staff hold at the root; orgContext(claims).staff tells your interface it is a staff session. For a menu that needs "anywhere in this organization", use holdsAnywhereUnder from @canopy-io/node, which is a read and never a guard.

If your own auth layer verifies tokens and parks the claims somewhere other than attachTokenAs, configure resolveOrg — and feed it only a value read off a verified token, because it decides which organization's grants answer the check.

If Canopy issues your tokens

resolveIdentity assumes something upstream already verified the caller. If you have no auth layer yet, CanopyTokenGuard is that layer: it verifies the bearer token against Canopy's published signing keys and attaches the claims, so resolveIdentity has something trustworthy to read.

CanopyModule.forRoot<AuthedRequest>({
  apiKey: process.env.CANOPY_API_KEY,
  verify: {
    environmentId: process.env.CANOPY_ENVIRONMENT_ID,
    audience: process.env.CANOPY_OAUTH_CLIENT_ID, // omit for Direct API
  },
  resolveIdentity: (request) => request.canopyToken?.sub,
});
@UseGuards(CanopyTokenGuard, CanopyGuard)
@RequirePermission("documents.read")
findAll() {}

Order matters. Nest runs guards left to right, and CanopyGuard reads what CanopyTokenGuard attaches. Reversed, every request is denied — the identity is resolved before anything has established one.

Verification is local: one fetch of the key set, then a signature check per request with no network at all. If Canopy cannot be reached when the keys are due for a refresh, the guard keeps verifying with the keys it holds.

The attached claims are CanopyTokenClaims, plus whatever custom claims the Environment maps onto the token. CanopyTokenRequest<TCustom> types a request the guard has run on with those named, each optional, so a handler reads request.canopyToken?.plan typed. A custom claim that is not stored, or did not fit the token (custom_claims_overflow), is absent: treat that as not set, never as a pass.

@Get("quarterly")
quarterly(@Req() request: CanopyTokenRequest<{ plan?: "free" | "pro" }>) {
  if (request.canopyToken?.plan !== "pro") {
    throw new ForbiddenException();
  }
}

Only identity tokens are accepted, the people who sign in to your application. A token Canopy signed for one of its own administrators or staff answers 401 unless you add its type to verify.allowedTypes.

A machine client's token (type: "service", from the client_credentials grant) answers 401 too, except on a route or controller that admits it. Its aud names your API, so set verify.serviceAudience to the audience your API answers to:

CanopyModule.forRoot<AuthedRequest>({
  apiKey: process.env.CANOPY_API_KEY,
  verify: {
    environmentId: process.env.CANOPY_ENVIRONMENT_ID,
    serviceAudience: "https://api.example.com",
  },
  resolveIdentity: (request) => request.canopyToken?.sub,
});
@UseGuards(CanopyTokenGuard, CanopyGuard)
@AllowServiceTokens()
@RequirePermission("orders.import", { scope: "org" })
@Post("import")
import() {}

There CanopyGuard authorizes the machine client from its own grants, read from the verified claims, never from anything the caller sends. The token's sub is the client's client_id, so resolveIdentity: (request) => request.canopyToken?.sub serves both kinds. A machine client pinned to an organization carries its org_id, so scope: "org" works the same way.

A machine client acting for a person presents a delegated token (see exchangeToken in @canopy-io/node), which answers 401 except on a route or controller carrying @AllowDelegatedTokens(). It is held to verify.serviceAudience too. There CanopyGuard evaluates the person (resolveIdentity still reads sub) and the client in act.sub, and allows only when both hold the permission:

@UseGuards(CanopyTokenGuard, CanopyGuard)
@AllowDelegatedTokens()
@RequirePermission("deals.read", { scope: "org" })
@Get()
list() {}

A rejected token is a 401 whatever the detail, with the specific code left on error.cause for your logs rather than handed to the caller — who has no use for the difference and should not be told which check failed. 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.

Claims land on request.canopyToken, not request.user — that one belongs to Passport, and quietly overwriting it would be somebody's difficult afternoon. Use attachTokenAs to put them elsewhere.

Skip this guard entirely if you already run Passport or your own JWT middleware. It is additive; nothing else in the package depends on it.

The client

For anything the guard does not cover, inject the client:

import { InjectCanopy, type Canopy } from "@canopy-io/nestjs";

@Injectable()
export class OrdersService {
  constructor(@InjectCanopy() private readonly canopy: Canopy) {}

  async assign(identityId: string, nodeId: string, roleId: string) {
    await this.canopy.assignments.create({
      identity_id: identityId,
      node_id: nodeId,
      role_id: roleId,
    });
  }
}

It is the same @canopy-io/node client, with its retry policy, pagination and typed errors.

Failing closed

There is no path through CanopyGuard that allows a request whose decision is unknown. Each of these ends the request:

  • no identity resolves, including when a resolver throws on an unauthenticated request — 403
  • a node check with no node, or with no resolveNode configured — 403
  • the identity holds the permission nowhere, or nowhere on this node's lineage — 403
  • Canopy has never heard of the identity (404) — 403
  • a cache miss cannot be filled because Canopy is unreachable, rate-limiting, or answers 5xx — 503, deliberately not 403, because "we could not decide" is not "you are not allowed"
  • Canopy rejects the API key (401/403), or refuses the read (other 4xx) — 500, because a misconfiguration is not a temporary condition and no retry will fix it
  • the hierarchy this credential can read does not contain the node — 500, naming the cause. Undecidable, not denied: answering 403 would take out every node-scoped route while looking like ordinary policy
  • the caller hung up mid-check — the abort is propagated, not turned into a 503, because nothing failed and no one is waiting for an answer

A held answer is never served past its window to paper over an outage. Once it expires, an unfillable read is a 503 — extending it silently would extend the revocation window with it, without anyone choosing to.

Everything except the caller hanging up is logged with its cause before the request is refused.

The 500 cases are worth alerting on: they mean the integration is broken rather than the service being slow. Reporting them as 503 would bury a bad API key under what looks like an outage.

How the decision is reached

The guard does not ask Canopy per request. It asks a different question, once, and answers every later check from the result.

Rather than "may this identity act here", it reads "where may this identity act" — the nodes each permission was granted at. Those grant roots are not expanded through their descendants, because a grant already means this node and everything beneath it; expanding would restate your hierarchy once per identity, and would be largest for the near-root grants your administrators hold.

So two things are held, split by what they depend on:

  • grant roots, per identity — small, a handful of assignments
  • your hierarchy, once per process — shared by every identity, since its shape does not depend on who is asking

and a check is a walk up from the node in question looking for a grant root. An app_wide check is simpler still: the permission appearing at all is the answer, and no hierarchy is needed.

Both are held for at most 60 seconds, which is therefore the delay between an access change and it taking effect. authorizationTtlMs changes it, up to five minutes (300_000); a longer value throws at startup. The hierarchy is revalidated rather than re-read — a conditional request for its first page that normally answers 304, so nothing transfers. Both reads are paged and read in full, 100 rows to a page; when the hierarchy has changed, every page of it is read again (see LocalAuthorizer for how a rebuild over several pages is kept consistent).

CanopyModule.forRoot({
  apiKey: process.env.CANOPY_API_KEY,
  verify: { environmentId: process.env.CANOPY_ENVIRONMENT_ID },
  authorizationTtlMs: 30_000,
  resolveIdentity: (request) => request.user?.sub,
});

Shortening that window makes revocation take effect sooner and costs more calls. Below the gap between a user's requests it stops saving anything at all — every request finds the cache expired and refetches, which is the per-request traffic this exists to remove. Human-paced traffic has multi-second gaps, so a few seconds can cost full price for no benefit.

What the credential needs

A full_access API key needs nothing further. A scoped key must carry both of the permissions the guard reads with, because a scoped key is granted exactly the permission keys listed on it:

  • identity.view — to read an identity's grant roots.
  • hierarchy.view — to read the hierarchy the walk runs against, taken as parent edges (GET /api/v1/nodes/parents, every page) rather than the dashboard's node list.

Miss the first and every guarded route answers 500 naming the failed read. Miss the second and the tree comes back empty with a 200 — the API is answering "here is everything you may see", which is nothing. The guard does not treat that as an absence of grants: an incomplete hierarchy makes a node-scoped check undecidable, so it raises rather than denying. Silently denying there would take out every node-scoped route while every response still looked healthy.

app_wide checks need only identity.view; they never consult the hierarchy.

What a revoked user can still do

For up to the window, a request that should now be denied is allowed. That is the cost of not asking every time, and it is worth stating to whoever owns your access-review process rather than leaving it to be discovered.

The window covers every change that can alter an answer, including a moved node — reparenting changes what an inherited grant reaches even though no grant itself changed, which is why the hierarchy is revalidated on the same cadence and not a slower one.

Two ways out, depending on how much you need.

For a single high-value operation, ask the API directly through the injected client — the rest of your routes keep the cache:

const { allowed } = await this.canopy.permissions.evaluate({
  identity_id: identityId,
  permission: "payments.release",
  scope: "node",
  node_id: nodeId,
});

For an application that cannot tolerate a stale allow anywhere, set the window to zero. Every guarded request then reads fresh, which reinstates a round trip per request — the cost this design exists to remove, so choose it knowingly:

CanopyModule.forRoot({
  apiKey: process.env.CANOPY_API_KEY,
  verify: { environmentId: process.env.CANOPY_ENVIRONMENT_ID },
  authorizationTtlMs: 0,
  resolveIdentity: (request) => request.user?.sub,
});

Bounding a cache miss

A hit costs nothing. A miss reads from Canopy on the request path and holds an inbound request open, so it is bounded: a 5s per-attempt deadline rather than the client-wide 30s, and 1 retry rather than 2, with each wait between attempts capped at one deadline so a Retry-After header cannot hold the request past it. Both are settable with evaluateTimeoutMs and evaluateMaxRetries, and both apply to these reads alone — everything else through the injected client keeps the client-wide values.

Hanging up when the caller does. If the client disconnects while a miss is being filled, the guard ends that check rather than finishing a decision no one will read. It watches the response for a close that arrives before anything was written, on Express (where Nest returns the Node response) and on Fastify (where the real response is on reply.raw).

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.

Changelog

CHANGELOG.md records every published version. The package is pre-1.0: while the surface is still settling, a minor bump may carry a breaking change, so read the entry before taking one.

License

MIT © Canopy Identity Inc.

版本与发布记录

@canopy-io/nestjs 共发布 27 个版本,首次发布于 2026/07/28;过去 12 个月发布了 27 个版本。最新版本附带 npm provenance 构建来源证明,可追溯到公开的 CI 构建记录。

版本发布日期距今
0.9.1latest2026/11/072 天前
0.9.02026/11/063 天前
0.8.12026/11/053 天前
0.8.02026/11/054 天前
0.7.62026/11/045 天前
0.7.52026/11/017 天前

关于 @canopy-io/nestjs 的常见问题

@canopy-io/nestjs 是什么?

Official NestJS integration for Canopy — hierarchical identity and access management.

如何安装 @canopy-io/nestjs?

在项目目录运行 npm install @canopy-io/nestjs,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:@nestjs/core(^10.0.0 || ^11.0.0)、@nestjs/common(^10.0.0 || ^11.0.0)、reflect-metadata(^0.1.13 || ^0.2.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@canopy-io/nestjs 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@canopy-io/nestjs 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@canopy-io/nestjs 需要什么版本的 Node.js?

package.json 的 engines 字段要求 Node.js >=18。版本不符时 npm 默认只给出警告,开启 engine-strict 后会拒绝安装。

@canopy-io/nestjs 可以商用吗?

@canopy-io/nestjs 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@canopy-io/nestjs 的下载量有多少?

根据 npm 官方下载统计,@canopy-io/nestjs 在 2026/10/09 – 2026/11/07 这 30 天内被下载 2,956 次,日均约 99 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@canopy-io/nestjs 还在维护吗?

最新版本 0.9.1 发布于 2026/11/07(2 天前);至今共发布 27 个版本,其中过去 12 个月发布了 27 个;首次发布于 2026/07/28。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。