1. Docs
  2. Organizations

Organizations

Give each of your business customers a first-class tenant object: an organization with its own members, one role per member, and org context minted into every token.

Overview

An organization is a tenant object inside an Environment running the organizations access model. It represents one of your business customers, and its membership is the access model: a member holds exactly one role per organization, so the same identity can be an Admin at Acme and a Viewer at Globex. That per-membership role is the case environment-wide (flat) RBAC cannot express, and it is what this model exists for. Roles, permissions, memberships, and invitations all live in Canopy; your application keeps no role tables of its own.

One organization per customer account

The intended mapping is direct: when a customer signs up for your product, your backend creates their account row in your database and provisions one Canopy organization alongside it. The organization is the anchor for that customer's roles; your account row stays the source of truth for everything else (billing, settings, domain data). Store the organization id on your account row and the two never drift.

A signed-in session acts in exactly one organization at a time, named by the token's org_id claimMembership grants exactly one role, held inside that organization onlyAn identity can belong to many organizations, with a different role in eachYour app never trusts a request-supplied organization id: the verified token claim is the tenant context

Scope & Entering the Model

Organizations are scoped to an Environment, and the access model is per-Environment: an Environment runs flat, hierarchy, or organizations, never two at once. There is no separate enable call. Creating the first organization is what switches a flat Environment into the organizations model: the fixed two-level schema is written, the model flips, and the Console's Organizations pages appear. An Environment already in hierarchy mode refuses organization creates with a 409; revert it to flat first.

Organizations is a Pro plan feature. The gate applies to the management surface only: creating organizations, managing members, and sending invitations. The runtime the feature mints keeps working regardless of billing state. Tokens keep carrying org claims, permission evaluation keeps answering, and your users keep signing in. A billing lapse never breaks your production logins.

Provisioning from Your Signup Flow

Provision from your own signup flow with your secret API key. Create the organization, then add the signing-up user as its first member with your owner-equivalent role. The metadata field is yours; use it to carry your own account id so either system can find the other.

curl -X POST https://auth.canopy-io.com/api/v1/organizations \
  -H "X-API-Key: cnpy_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Corp",
    "metadata": { "account_id": "acct_8231" }
  }'
{
  "data": {
    "id": "7f3d2c1b-9a80-4e5f-b6a7-c8d9e0f1a2b3",
    "name": "Acme Corp",
    "slug": "acme-corp",
    "member_count": 0,
    "pending_invite_count": 0,
    "metadata": { "account_id": "acct_8231" }
  }
}

The slug is display-only, derived from the name at create and frozen afterwards. Address organizations by id everywhere.

Members & Invitations

Two paths put a person inside an organization, and both end in the same place: one role assignment held at that organization.

Add an existing identity

For a person who already exists in the Environment, add them directly. The role is required: a membership is a role held at the organization, so there is no roleless member to create.

curl -X POST https://auth.canopy-io.com/api/v1/organizations/{org_id}/members \
  -H "X-API-Key: cnpy_..." \
  -H "Content-Type: application/json" \
  -d '{
    "identity_id": "idn_...",
    "role_id": "rol_..."
  }'

Invite by email

For a person with no identity yet, send an organization invitation. It carries the role the recipient will hold; accepting creates their identity (if needed) and places them in the organization with that role in one step. Changing a member's role replaces it (PATCH .../members/:identityId), and removing a member revokes only that organization's role: the identity itself, and its other memberships, are untouched.

curl -X POST https://auth.canopy-io.com/api/v1/organizations/{org_id}/invites \
  -H "X-API-Key: cnpy_..." \
  -H "Content-Type: application/json" \
  -d '{
    "email": "dana@acme.com",
    "role_id": "rol_...",
    "first_name": "Dana",
    "last_name": "Dual"
  }'

Org Context in the Token

In an organizations-model Environment, identity access tokens carry two extra claims: org_id (the organization the session is acting in) and org_role (the name of the one role held there). They are minted together at login, resolved from the session's active organization, with the membership re-verified on every mint. An identity that belongs to no organization gets a token with neither claim: authentication succeeds, and every org-scoped question answers no.

{
  "sub": "idn_...",
  "type": "identity",
  "environment_id": "env_...",
  "org_id": "7f3d2c1b-9a80-4e5f-b6a7-c8d9e0f1a2b3",
  "org_role": "Admin",
  "exp": 1788460000
}

With @canopy-io/node, read the pair through orgContext(claims) after verifying the token. It returns { orgId, orgRole } or null, and refuses a half-present pair rather than letting one claim be read without the other.

import { TokenVerifier, orgContext } from "@canopy-io/node";

const verifier = new TokenVerifier({ issuer: process.env.CANOPY_ISSUER });
const claims = await verifier.verify(bearerToken);
const org = orgContext(claims);

if (org) {
  // acting inside org.orgId as org.orgRole
}

Building the Org Switcher

A person who belongs to several organizations acts in one at a time and switches between them. Two endpoints power the switcher your frontend renders.

List the caller's memberships

curl https://auth.canopy-io.com/v1/identity/auth/organizations \
  -H "Authorization: Bearer <access token>"
{
  "items": [
    { "id": "...", "name": "Acme Corp", "slug": "acme-corp",
      "role": { "id": "...", "name": "Admin" } },
    { "id": "...", "name": "Globex", "slug": "globex",
      "role": { "id": "...", "name": "Viewer" } }
  ]
}

Switch the active organization

Switching is a refresh-token operation: Canopy re-verifies the membership, moves the session, and mints a new access token whose org_id and org_role name the new organization. Browsers on cookie delivery send the refresh cookie automatically; a backend-for-frontend passes refresh_token in the body alongside its secret API key. Asking to act in an organization the identity does not belong to is refused with a 403.

curl -X POST https://auth.canopy-io.com/v1/identity/auth/switch-organization \
  -H "X-API-Key: cnpy_..." \
  -H "Content-Type: application/json" \
  -d '{
    "refresh_token": "<refresh token>",
    "org_id": "<target organization id>"
  }'

Nothing about the person changed after a switch. Only the context their next requests are decided in did, which is exactly why the same endpoint can answer differently before and after.

Enforcing Per-Org Authorization

An organization is a hierarchy node underneath, and a membership is a role assignment at it. That means per-org authorization is the node question asked at the organization the token is acting in, and the SDK packages it as its own scope.

NestJS: scope "org"

With @canopy-io/nestjs, declare the permission with scope: "org". The guard reads org_id off the verified claims CanopyTokenGuard attached and evaluates at that node through the local authorizer, so the check normally costs no network call. A caller acting in no organization is denied without one either.

@UseGuards(CanopyTokenGuard, CanopyGuard)
@RequirePermission("invoice.approve", { scope: "org" })
@Post("invoices/:id/approve")
approve() {}

Plain HTTP: evaluate at the org node

Without the SDK, ask the evaluate endpoint the same question: node scope, with node_id set to the token's org_id. The two rules that keep tenancy honest: take the organization from the verified token, never from anything the caller sends, and treat a row belonging to a different organization as out of scope before any role is consulted.

curl -X POST https://auth.canopy-io.com/api/v1/permissions/evaluate \
  -H "X-API-Key: cnpy_..." \
  -H "Content-Type: application/json" \
  -d '{
    "identity_id": "idn_...",
    "permission": "invoice.approve",
    "scope": "node",
    "node_id": "<the token'\''s org_id>"
  }'

Worked example. Dana is Admin at Acme and Viewer at Globex; the Viewer role carries read permissions only. The same person, the same endpoints, different answers, decided entirely by which membership the token is exercising:

Acting in Acme: approve an Acme invoice → allowed (Admin); approve a Globex invoice → refused (out of scope by tenancy)Acting in Globex: approve anything → refused (Viewer); read the Globex report → allowedBelonging to no organization: every org-scoped question → refused; authentication itself still works

Lifecycle & Teardown

Each teardown removes exactly what it names, and identities always survive:

Remove a member: revokes that organization's one role. The identity, and its memberships elsewhere, are untouched.Revoke an invitation: the invite link stops working. The address can always be invited again.Delete an organization: removes the organization, all its memberships, and its pending invitations. Member identities survive and keep everything they hold elsewhere.Revert the Environment to flat: deletes every organization, membership, and pending organization invitation, and returns the Environment to environment-wide RBAC. Identities and the role catalog survive.

Tokens converge on their own: a removed member's next refresh mints a token without that organization's claims, within the access token's lifetime.

Next Step

The SDKs page covers token verification, the local authorizer, and the NestJS guard this page's enforcement section leans on.

Environment
API version
v1.0
On this page Was this page helpful?

Tell us how we can improve this guide.