1. Docs
  2. Organizations

Organizations

Give each of your business customers a first-class tenant: an organization with its own members and the roles each holds there, its own authentication policy, and org context minted into every token. Organizations are a container switched on beside your access model, so flat or hierarchical access control keeps running inside them.

Overview

An organization is a tenant object inside an Environment. It represents one of your business customers. Membership is admission, kept as a record of its own, and a member's roles are ordinary assignments at the organization's node or beneath it: none, one, or several. So the same identity can be an Admin at Acme and a Viewer at Globex, and belong to Initech holding nothing yet. That per-organization role set is the case environment-wide (flat) RBAC cannot express, and it is what organizations exist for. Organizations are not an access model. They are a container you switch on beside the model, and the Environment keeps running flat or hierarchical access control inside it. Roles, permissions, memberships, invitations, and each organization's authentication policy 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 admits a person to the organization; the roles they hold there are assignments at its node or beneath, held inside that organization onlyAn identity can belong to many organizations, with different roles in eachEach organization can tighten the Environment's authentication policy and bind its own identity providerYour app never trusts a request-supplied organization id: the verified token claim is the tenant context

Enabling the Container

Organizations are scoped to an Environment and switched on per Environment with one call, independent of the access model. Enabling reserves the tier directly under the Environment's root for organizations: every organization is a node there, and nothing else may be created at that level. The Environment stays flat or hierarchy; nothing about the model changes. In the Console the switch is the Organizations card on the Access Control Overview; until it is on, the Organizations page says so and links back to it.

curl -X PUT https://auth.canopy-io.com/portal/v1/accounts/{account}/applications/{app}/environments/{env}/organizations-feature \
  -H "Authorization: Bearer <console token>" \
  -H "Content-Type: application/json" \
  -d '{ "enabled": true }'

Both models run inside the container. A flat Environment holds memberships and roles at the organization node only: a member's roles there apply across the organization. A hierarchy Environment hangs each organization's own tree beneath it, so roles can be scoped to a region or team inside the tenant (see the section on hierarchy below). Switching between the two later works the same way it does without organizations; the organizations and their memberships stay.

Enabling while a hierarchy already has nodes under the root is refused: revert the Environment to flat first. The Console does this in one guided step, telling you which nodes go.Creating an organization requires the container to be on; the API answers organization.not_enabled otherwise.Disabling requires an empty container and a flat Environment. Delete every organization first (one call does it), and revert a hierarchy to flat.

Organizations is a Pro plan feature. The gate applies to the management surface only: enabling the container, creating organizations, managing members and invitations, and setting policy. 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. Pass your own account id as external_id: it is unique per Environment, so a repeated create answers 409 organization.external_id_conflict instead of a second organization, and GET /api/v1/organizations?external_id=... finds the tenant without your having stored the organization id first. metadata stays free-form for anything else.

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",
    "external_id": "acct_8231"
  }'
{
  "data": {
    "id": "7f3d2c1b-9a80-4e5f-b6a7-c8d9e0f1a2b3",
    "name": "Acme Corp",
    "slug": "acme-corp",
    "external_id": "acct_8231",
    "member_count": 0,
    "pending_invite_count": 0,
    "metadata": {}
  }
}

The slug is display-only, derived from the name at create and frozen afterwards. Address organizations by id everywhere, and by external_id when all you have is your own account id. If the create succeeded but storing the id on your side failed, the same call again answers 409 and the lookup returns the organization: create-then-store is safe to retry.

First Tenant at Signup

The most common flow on a multi-tenant app: a person registers, and their first session should already act inside the organization created for them. The Environment can do this for you: in the Developer Console, under Tenant → Authentication → Self-signup, expand the Environment's row, turn on Create an organization on sign-up and choose the Role the creator receives (one that carries canopy:organization.manage, so the person can run their organization from the portal). From then on every POST /v1/identity/auth/register creates an organization named after the person (or the local part of their email), adds them as its first member with that role, and their first session after email verification already carries org_id and org_roles. The organization commits together with the identity, so a sign-up that cannot create one fails as a whole: a plan cap or a role deleted since it was chosen refuses the registration rather than leaving the person in no organization. Invited identities are untouched, and the switch cannot be turned on without a role. When you would rather name the tenant yourself, pass your own account id as external_id, or create it only after a step of your own, make the calls below from your backend instead, in this order. The examples use the backend-for-frontend shape, where your server holds the secret API key and receives refresh tokens in the body.

Register the identity (POST /v1/identity/auth/register, or your invite flow). With the switch above off, the session this returns carries no org_id: the person belongs to nothing yet.Create the organization with your account id as external_id and store the returned id on your account row.Add the person as its first member (POST .../organizations/{org_id}/members) with your owner-equivalent role, which carries canopy:organization.manage so they can run the organization from the portal. If the organization should connect its own directory without asking you, choose the roles its directory may grant at the same time (PUT .../organizations/{org_id}/directory/grantable-roles); until you do, its administrator is told to ask you.Move the session into the organization: POST /v1/identity/auth/switch-organization with the refresh token from step 1 and the new org_id. The access token it mints carries the organization's claims; use that one from here on. The organization has no policy yet, so this never raises an MFA challenge.

The switch is the step teams skip, which ships a session acting in no organization until the next login. A later login can name the organization to start in with the organization field instead. When every person in the Environment should belong to a tenant, turn on Require organization membership under Tenant → Authentication → Sign-in methods: a sign-in, refresh or SSO completion for someone in no organization then answers 403 auth.organization_membership_required until they are added to one, and removing someone's last membership ends their session at its next refresh. The Console shows how many current members that would refuse before you turn it on. The switch assumes the organization exists before a person's first sign-in, which is true of Create an organization on sign-up and of a backend that creates the organization straight after register with its API key. If your app creates the organization in an onboarding step that needs the person signed in, leave it off: their first sign-in would be refused before that step could run. GET /v1/identity/auth/config publishes both switches, so your sign-in and sign-up screens can say what will happen.

Members & Invitations

Two paths put a person inside an organization, and both end in the same place: an active membership there, with a role at the organization node when one was given.

Add an existing identity

For a person who already exists in the Environment, add them directly. role_id is optional: with it the member holds that role from the start, at the organization node or at the office or team node_id names, for the effective_from to effective_to window when you give one; without it they belong and hold nothing until a role is assigned, which is how a person who should act only at one office or team is added. Adding needs no consent from the person: any identity with an active membership in the Environment can be added by id, and the member row answers with their email and name. Adding someone who already belongs answers 409 organization.member_exists: give them the role through the assignment routes instead. That is why only you can do it, with your API key or from the Developer Console: an organization's own administrator, signed in, is refused with 403 organization.member_add_requires_invite and invites by email instead, so the person accepts before joining. A membership added for someone does not move where their next login starts: without an organization named, a login starts in the first organization they joined.

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": "7d3f9b2e-4c1a-4e8b-9f2d-1a6c5e0b8f34",
    "role_id": "4a9c1e7f-2b8d-4f3a-9c6e-5d1b8a2f7e09"
  }'

Invite by email

Invite by email when the person should agree to joining, and for anyone with no identity yet. An invitation names where its role is held, the organization node by default or an office or team beneath it with node_id, and optionally the effective_from to effective_to window; with no role_id it admits a member holding nothing, which only the organization node allows. Accepting creates whatever is missing in one step: the identity, its access to the Environment, the membership and the role. Someone who already has an identity in this Environment receives a request to join: accepting is their consent, proven by their existing password or by accepting while signed in, and an organization that requires single sign-on, or a person with a second factor enrolled, accepts only while signed in. Inviting someone who is already a member answers 409 organization.member_exists. Roles are changed, added and removed through the assignment routes (PATCH /api/v1/assignments/:id with the assignment id the member row lists; the organization's own routes under /api/v1/organizations/{org_id}/assignments take the organization node too), and removing a member (DELETE .../members/:identityId) deactivates the membership and every role held at the organization's nodes with it: the identity itself, and its other memberships, are untouched. The member row answers with status, source, joined_at and roles, every assignment held in the organization with its node and effective window; GET .../members?status=deactivated lists the people who left.

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": "4a9c1e7f-2b8d-4f3a-9c6e-5d1b8a2f7e09",
    "first_name": "Dana",
    "last_name": "Dual"
  }'

With hierarchy on inside the container, a role assignment at a node beneath an organization is a scoped grant on top of the membership, and it requires the membership first: assigning someone at Acme's EU team before they are a member of Acme, or after their membership was deactivated, is refused with 409 organization.membership_required. The same refusal stops a single sign-on arrival placed beneath it and a grant moved there, whenever the membership is not active. An invitation is the exception: accepting one admits the person first, so an invitation may name an office or team directly. A grant at the organization node itself needs no step before it: it admits its holder. Removing the membership removes every role held in the organization with it, and an organization keeps its last administrator: the last active holder of canopy:organization.manage at the organization node can neither be removed nor have that role taken away (409 organization.last_admin).

Hierarchy Inside an Organization

With the hierarchy model on inside the container, every organization is the top of its own tree. The Environment's hierarchy_schema is rooted at the reserved canopy:organization type, and its levels (say Region, then Team) hang beneath each organization rather than beneath the Environment root. Depth is counted from the organization down, and a node can never be moved from one organization into another.

The Console's Hierarchy page shows one organization at a time, chosen from a picker; an organization's own Hierarchy tab shows the same tree.Inheritance works as it does anywhere in a hierarchy: a role granted at Acme's EU region reaches everything beneath EU, and a role granted at Acme itself reaches the whole tenant.The node API refuses a child of the Environment root, and refuses renaming, moving, or deleting an organization node: organizations are managed only through the organizations endpoints.Reverting the Environment to flat keeps the organizations and their memberships. Only the trees beneath them go, and assignments at those nodes are dropped rather than widened to the organization.

Org Context in the Token

With the container on, identity access tokens carry three extra claims: org_id (the organization the session is acting in) org_roles (the names of the roles held at its node, sorted; [] for a member who holds none there) and org_role_ids (the same roles' ids, in the same order). A name is unique only among the Environment's roles or among one organization's own, so compare by id when a role must be told apart across organizations. They are minted together at login, resolved from the session's active organization, with the membership re-verified on every mint. A login starts in the organization the request names in its organization field, or, without one, in the identity's only organization or the first one it joined. An identity that belongs to no organization gets a token with neither claim: authentication succeeds, and every org-scoped question answers no.

{
  "sub": "7d3f9b2e-4c1a-4e8b-9f2d-1a6c5e0b8f34",
  "type": "identity",
  "environment_id": "3c8e1f5a-6b2d-4a9e-b7c4-8d1f2e6a9b05",
  "org_id": "7f3d2c1b-9a80-4e5f-b6a7-c8d9e0f1a2b3",
  "org_roles": ["Admin"],
  "org_role_ids": ["5b2e8d1f-3c7a-4f9e-a6b4-0d8c2e9f1a73"],
  "exp": 1788460000
}

With @canopy-io/node, read the claims through orgContext(claims) after verifying the token. It returns { orgId, orgRoles, orgRoleIds } or null, and refuses a partial set, or names and ids that do not pair up, rather than letting one claim be read without the others. An empty orgRoles is a member holding no role at the organization node; authorize by evaluating, never by the list.

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

const verifier = new TokenVerifier({
  issuer: process.env.CANOPY_ISSUER,
  // Hosted Login tokens name your client; omit for the Direct API.
  audience: process.env.OAUTH_CLIENT_ID,
});
const claims = await verifier.verify(bearerToken);
const org = orgContext(claims);

if (org) {
  // acting inside org.orgId, holding org.orgRoles at its node
}

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. The list is paged, newest membership first, and takes q to search by name and sort_by=name.

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",
      "roles": [{ "id": "...", "name": "Admin" }],
      "administers": true, "portal_access": true },
    { "id": "...", "name": "Globex", "slug": "globex",
      "roles": [],
      "administers": false, "portal_access": false }
  ],
  "pagination": { "page": 1, "take": 20, "item_count": 2, "page_count": 1, "has_previous_page": false, "has_next_page": false }
}

Switch the active organization

Switching is a refresh-token operation: Canopy re-verifies the membership, applies the target organization's authentication policy, moves the session, and mints a new access token whose org_id and org_roles 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. On hosted login the same move is one more authorization request: GET /oauth/authorize?organization=<id> while the browser holds Canopy's hosted session issues a code for that organization with no credential asked again, and refuses a non-member at your callback with access_denied.

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>"
  }'

When the target organization requires MFA and the session has not yet presented a factor, the switch answers requires_mfa_challenge: true with an mfa_challenge instead of tokens, exactly as login does. The session stays in its current organization until the challenge is verified; the session that verify mints starts in the target. A session that already carries mfa in its amr claim moves without a challenge.

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.

Per-Organization Authentication Policy

Each organization owns an authentication policy layered over the Environment's settings. The Environment is the floor and the organization can only tighten it: a value that would loosen the Environment is refused with organization.policy_loosens. Every field but require_sso is nullable, and null inherits the Environment; require_sso is on or off, off by default. This is the setting behind a control you offer your own customers: when Acme's owner turns on "Require MFA for our team" in your product, your backend writes it here and Canopy enforces it on Acme's sign-ins only, while Globex is untouched. Your staff can also set it in the Console when onboarding an enterprise customer whose contract calls for it.

mfa_required: require a second factor for this organization's members even where the Environment leaves MFA optional.mfa_factor_allowlist: narrow the factors members may use to a subset of the Environment's.mfa_after_sso: require stops single sign-on waiving a second factor where the Environment would trust the identity provider's own. It only matters when one is owed: MFA required, or a factor enrolled. A sign-in whose provider reports a second factor (the ID token's amr, or a SAML assertion's authentication context) is never challenged again, whatever this says.allowed_sign_in_methods: the ways in members may use, a non-empty subset of the methods the Environment offers (password unless the Environment turned it off, email_otp where it turned it on, and sso). An organization can only remove methods, never add one the Environment has off; null inherits them all. A sign-in by a method the organization ruled out is refused with organization.sign_in_method_not_allowed, naming the method and the allowed ones in error.data, before any credential is checked for an email whose domain routes to the organization and again when the session would start there. POST /v1/identity/auth/sso/resolve returns the effective list as allowed_methods, so your sign-in page offers only what will work.require_sso: refuse password and email-code sign-in for members, and for any email whose domain routes to a connection bound to this organization. The refusal is organization.sso_required, and SSO becomes the only method whatever allowed_sign_in_methods says. Turning it on returns the organization's ten SSO recovery codes once (sso_recovery_codes); an administrator redeems one at POST /v1/identity/auth/login/sso-recovery, or under Can't use single sign-on? in the hosted login page's single sign-on step, when the identity provider is down; the session still runs the organization's MFA requirement and starts in that organization. POST …/policy/sso-recovery-codes mints a new batch.

Read and write the policy on the organization. The response carries the organization's own values, the Environment baseline it tightens from (environment), and the layered result its members sign in under (effective). The version field backs If-Match. Both routes admit the developer's hierarchy.view / hierarchy.manage and the reserved canopy:organization.manage held at the organization node, the permission you attach to a membership role so a tenant's own admin can set the tenant's policy and nothing else's.

curl -X PATCH https://auth.canopy-io.com/api/v1/organizations/{org_id}/policy \
  -H "X-API-Key: cnpy_..." \
  -H "Content-Type: application/json" \
  -d '{ "mfa_required": true, "allowed_sign_in_methods": ["password", "sso"], "require_sso": false }'
{
  "data": {
    "organization_id": "7f3d2c1b-...",
    "mfa_required": true,
    "mfa_factor_allowlist": null,
    "mfa_after_sso": null,
    "allowed_sign_in_methods": ["password", "sso"],
    "require_sso": false,
    "environment": { "mfa_required": false, "mfa_factor_allowlist": ["totp", "webauthn", "email_otp"], "mfa_after_sso": "require", "allowed_sign_in_methods": ["password", "email_otp", "sso"], "require_sso": false },
    "effective":   { "mfa_required": true,  "mfa_factor_allowlist": ["totp", "webauthn", "email_otp"], "mfa_after_sso": "require", "allowed_sign_in_methods": ["password", "sso"], "require_sso": false },
    "sso_recovery_codes_generation": 0,
    "sso_recovery_codes_remaining": 0,
    "version": 1
  }
}

The policy is decided at login for the organization the session starts in, and again at every switch for the organization it moves to, on the Direct API and on the hosted OAuth pages alike. A member with no factor who enters an organization that requires MFA lands in the Environment's grace window (mfa_enrollment_pending), the same way a newly required Environment treats them.

Binding an Identity Provider

An organization binds its own identity provider. The connection stays an Account-level object (create it, verify its email domain, and bind it to the Environment as the SSO page describes), and the organization binding says where sign-ins through it land: in this organization, joining as a member with the binding's role when the identity is provisioned just in time or is not yet a member. A connection binds to one organization per Environment.

curl -X POST https://auth.canopy-io.com/api/v1/organizations/{org_id}/sso-connections \
  -H "X-API-Key: cnpy_..." \
  -H "Content-Type: application/json" \
  -d '{
    "sso_connection_id": "<connection id>",
    "default_role_id": "<membership role id>"
  }'
The connection must be an end-user connection already bound to the organization's Environment; otherwise organization.sso_connection_not_linked.A connection already bound to another organization in the Environment is refused with organization.sso_connection_already_bound.A sign-in through a bound connection lands the identity in the organization and starts the session there, so the first token already carries the organization's claims and its policy applied.Domain verification still applies, and the domain is the organization's: routing and landing both need it verified by the organization with its DNS record. The connection carries no domain list of its own.With require_sso on the organization, a password or code sign-in for an email whose domain routes to the bound connection is refused before any credential is checked.A membership the binding creates emits organization.member.added to webhooks and the audit log with metadata.source set to sso_jit and the connection_id, and a first sign-in that creates the identity emits identity.created the same way; no assignment.created accompanies it, because no assignment call was made.

In the Console the binding lives on the organization's Policy tab; the connection's own page lists the organizations it is bound to.

Directory Sync per Organization

An organization's identity provider pushes its people into your product, and you decide what that provider may hand out. The roles are yours, so a tenant cannot grant itself more than you offered. The connection itself can be set up three ways: by the organization's administrator in their portal, by you in the Console, or by your backend with an API key. Until you name at least one role, the organization's administrator sees a page saying directory sync is not available to them and has nothing to connect.

curl -X PUT https://auth.canopy-io.com/api/v1/organizations/{org_id}/directory/grantable-roles \
  -H "X-API-Key: <api key>" \
  -H "Content-Type: application/json" \
  -d '{
    "role_ids": ["<member role id>", "<lead role id>"],
    "default_role_id": "<member role id>"
  }'
One role is the one people arrive with. Somebody the provider pushes becomes a member, holding this role at the organization node unless a mapped group gives them another. Exactly one role carries it, and a directory cannot exist for an organization that has none; a directory that loses its default still admits the people it pushes, who then hold nothing until it is named again.The list is the ceiling. A tenant maps its own groups to the roles you listed and to nothing else; anything further is refused with scim.role_not_grantable. The same list bounds the role a sign-in through the organization's SSO connection may join with, so one answer covers every arrival from the tenant's identity provider. Set it once for the Environment (idp_arrival_roles in its auth settings, or Authentication → Sign-in methods in the Console) and every organization without a list of its own inherits it, which is what opens directory sync for them; an organization's own list, when you set one, replaces it. The Environment's list takes the Environment's roles only; an organization's own list may also name that organization's own roles. The Environment's list refuses a withdrawal an inheriting directory still maps, and refuses to clear while an inheriting directory is live, naming the organizations. The node is the organization's own and is never the tenant's to choose, so a mapping cannot reach elsewhere in your hierarchy.Changes take effect now. Removing a role unmaps the groups naming it; their members stay in the organization, on the role of another mapped group they belong to or on the role people arrive with. Naming a different role people arrive with moves everybody who arrived onto it. Emptying an organization's list returns it to the Environment's list. With no Environment list, the list cannot be emptied while the directory is live: end the connection first. A role deactivated after it was mapped grants nothing new: a push that would grant it, or move somebody onto it, leaves that person as they were and applies the rest of the request.A directory admits only the organization's own people. A provider can add somebody whose email is on a domain the organization has verified, the rule its single sign-on is held to, or somebody who already belongs to the organization because they accepted an invitation. Anybody else is refused with a SCIM 400 invalidValue, the same answer whether or not the address has an account, so a push never makes another organization's person a member or reveals that they exist. Your own Environment-wide directory has no such boundary.Your webhooks hear about it. A person the provider pushes, a role a group mapping changes, and a person the provider removes fire the same organization.member.* and identity.* events as the same change made by hand, marked source: "scim", so a record you keep of members and roles stays right without polling.Each directory sees only its own people. Two organizations syncing into one Environment get separate credentials and separate base URLs, and neither provider can list, filter or fetch the other's people, not even the same human synced by both, who gets a different resource id from each.Any of the three can run the connection. Creating it, minting, rotating and revoking its tokens, and mapping groups work the same from /api/v1/organizations/{id}/directory/*, the Console, and the hosted portal, so you can onboard a customer yourself or leave it to them. The list of roles is the exception: it is yours alone, and the organization's administrator can read it but gets 403 changing it, even at their own organization.

Your own Environment-wide sync is unaffected and stays on the Console's Directory Sync page: it is a directory too, belonging to no organization, and its activity feed and a tenant's never show each other's events.

Letting Customers Administer Themselves

Two reserved permissions decide who may run an organization from the outside. canopy:organization.manage, held at the organization's node, administers the whole organization: its settings, its sign-in policy, its members. canopy:access.manage, held at any node inside it, administers access at that node and every node beneath it, and nothing beside it: an office administrator manages their office and its teams and is refused at a sibling office. Holding canopy:organization.manage at the organization node implies the access key everywhere in it. Grant either to one of your roles, and anyone holding that role administers what the key reaches, calling the organization endpoints with their own session token instead of your API key, which is what lets a customer's admin act as themselves rather than through your backend. Delegate Administration walks through both keys.

Name your admin role

Both keys are system permissions created in every Environment that has the organizations container on, so there is nothing to register. Add canopy:organization.manage to whichever role means "runs their own organization" in your product, and canopy:access.manage to the one that means "runs their own office", once per Environment. In the Developer Console they sit under Canopy capabilities on the Roles page, above your own permissions and tinted apart from them, because granting it hands authority to people who never sign in to the Console. The same grant over the API looks like this, and note the write replaces the role's whole set, so send its existing keys alongside it.

curl -X PUT https://auth.canopy-io.com/portal/v1/accounts/{account}/applications/{app}/environments/{env}/roles/{role_id}/permissions \
  -H "Authorization: Bearer <admin token>" \
  -H "Content-Type: application/json" \
  -d '{
    "permission_keys": ["canopy:organization.manage", "<its existing permission keys>"]
  }'

Give each organization its first admin

An organization's members cannot grant themselves this, so the first one comes from you: assign the admin role when you provision the organization, or invite that person with it. After that the organization is self-sufficient: its admins can promote a colleague or hand over entirely, and the last one cannot be removed or demoted.

Calling as the customer's admin

The organization endpoints accept an identity access token as well as an API key. With a token, every route checks the reserved permission at that organization, so an admin of Acme reads and writes Acme and is refused everywhere else. The one thing they read but cannot write is the list of roles their directory may grant, which stays yours. Only the reserved key opens these routes to a session: Canopy's own management keys, the ones an API key holds as scopes, are never satisfied by a permission you define, so a permission you define under one of those names, such as your own identity.manage, grants nothing: neither these routes, evaluate, the grants the SDK authorizes from, nor the permissions claim count it. Give your own permissions names of their own. One write stays yours: an organization's administrator can reset a member's second factor only while that member belongs to no other organization and signs in to no other Environment of your Account, because a factor is held once per identity and the reset would reach everywhere the person signs in; for a shared member the request answers 409 organization.member_mfa_shared and the reset comes from your own administrators. GET /v1/identity/auth/organizations reports administers (the whole organization) and portal_access (anything inside it) per membership, which is how a session knows what it may open; GET /api/v1/organizations/{org_id}/me then says what, down to the node. Delegate Administration lists the reads an admin screen is built from.

curl https://auth.canopy-io.com/api/v1/organizations/{org_id}/members \
  -H "Authorization: Bearer <identity access token>"

# Roles you may assign inside this organization, for a picker
curl https://auth.canopy-io.com/api/v1/organizations/{org_id}/roles \
  -H "Authorization: Bearer <identity access token>"

# What the caller administers here, down to the node
curl https://auth.canopy-io.com/api/v1/organizations/{org_id}/me \
  -H "Authorization: Bearer <identity access token>"

What an administrator may hand out

An identity may grant a role only when it holds, at the target node or above, every permission the role carries, the reserved keys included. So an office administrator hands out the roles their office uses and never the one that runs the organization, and nobody promotes themselves: a write outside that line answers 403 assignment.grant_ceiling, on assignments, member role changes and invitations alike. The organization's own administrator, holding canopy:organization.manage at the organization node, is not bounded inside it, so a self-sufficient organization stays one. Console users and your API key are not bounded; the Workspace keeps its own allowlist for its administrators. Delegate Administration shows the escalation it prevents.

Single sign-on in one call. POST /api/v1/organizations/{id}/sso-connection creates the connection, links it to the Environment, and binds it to the organization together. GET, PATCH and DELETE work on the same path, with POST .../activate and POST .../disable for its lifecycle.The provider's side. GET /api/v1/organizations/{id}/sso-connection/service-provider returns the entity id, ACS URL or redirect URI to paste into the identity provider.Reading the provider's metadata. POST /api/v1/organizations/{id}/sso-connection/import-metadata takes a metadata_url or a pasted metadata_xml and fills the SAML issuer, sign-in URL and signing certificate from it, so those three never have to be transcribed. An imported URL is kept on the connection as saml_metadata_url; a pasted document clears it. SAML connections only; OpenID Connect reads its discovery URL instead.Domains. Claim, verify and remove under /api/v1/organizations/{id}/domains. Verification is a DNS TXT record, done once, by us; relay the challenge and the result rather than running your own.Directory sync. Create the connection, mint, rotate and revoke its tokens, and map pushed groups under /api/v1/organizations/{id}/directory. A token comes back once, with the full SCIM base URL to paste beside it. The groups map only to roles on .../directory/grantable-roles, which an admin's token can read and your API key sets.A connection you bound stays yours. One created through this route is marked the organization's, and the Console refuses to move it elsewhere. A connection you bound from the Console reads here as owned_by_organization: false and refuses writes with 409.The last admin is protected. Removing or demoting the only holder of the reserved key whose membership is in effect is refused with 409 organization.last_admin, so an organization cannot lock itself out.

Everything above is the same surface the hosted admin portal calls. Build against it when you want these controls inside your own product; send customers to the portal when you would rather not build them at all.

The Organization Admin Portal

Each organization's own administrators run it from a hosted portal: who belongs, who holds a role at each place inside it, the authentication policy, single sign-on, and directory sync. The portal wears your Environment's branding and signs people in with their identity in your product. Admission is a reserved permission held through a role in that organization: canopy:organization.manage at the organization opens every page, and canopy:access.manage anywhere inside it opens the Access page alone. Link to it from your product, or open it from the Console's organization detail.

https://canopy-io.com/org/{publishable_key}/{organization_slug}
The publishable key names the Environment; the organization slug names the tenant. One organization per visit. Someone who administers more than one picks it on Choose an organization, which lists every organization they administer or administer access inside; someone who administers none lands on You don't administer an organization here.The navigation follows what the reader administers: Overview; People and Access, with Members and Access; and Sign-In, with Authentication, Single Sign-On and Directory Sync. An office or team administrator sees only Access, where they grant, change and remove roles at the places they administer. Delegate Administration walks through the page.Sign-in follows the Environment's methods: single sign-on when the email's domain routes to a provider, otherwise a password or an emailed code, then the second factor the Environment or the organization requires, answered with a passkey, an authenticator code, an emailed code or a recovery code, whichever the administrator has. A passkey is bound to the domain it was enrolled on, so one enrolled in your product under its own domain is not offered on the portal; the other methods are.The portal only adds to what you set for the Environment: an organization can require MFA or single sign-on on top of your floor, never below it.Single sign-on set up there belongs to the organization: the connection is created at your Account and bound to that organization, and the Console cannot move it elsewhere.Directory sync set up there is that organization's own: its provider reaches only its own people, adds only people on the organization's verified domains or already in the organization, and the roles it may grant are the ones you listed.An administrator can reset a member's second factor from Members when a device is lost: every enrolled factor, recovery code and trusted device goes, and the member enrols afresh at their next sign-in.The last administrator cannot be removed or demoted (organization.last_admin). Only a role in effect counts: an administrator whose role at the organization has expired or not yet begun does not count toward it.

The portal is a client of the same APIs. Every request carries the identity's token and is answered by /api/v1/organizations/:id/* under the tenant admin rule, so what it can do is exactly what the two reserved permissions allow.

Enforcing Per-Org Authorization

An organization is a hierarchy node underneath, and a member's roles are assignments at that node or beneath 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. An assignment inside an organization grants only while the membership is active, on the server and in the grants the SDK caches: a check against a deactivated membership answers ORGANIZATION_MEMBERSHIP_INACTIVE, and the grants read lists nothing held there.

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": "7d3f9b2e-4c1a-4e8b-9f2d-1a6c5e0b8f34",
    "permission": "invoice.approve",
    "scope": "node",
    "node_id": "<org_id from the token>"
  }'

Two SDK details for a backend that is not the one verifying the token. resolveOrg, a CanopyModule option, tells the guard where to read the acting organization from when the claims are not on the request under attachTokenAs: a backend-for-frontend that parked them elsewhere, or an API-key surface. Feed it only a value read off a verified token, since it decides which organization's grants answer. orgContext(claims) from @canopy-io/node reads the pair safely, and the amr claim tells you how the session was proven (pwd, otp, or sso, plus mfa once a factor was verified), so a sensitive action can insist on mfa before proceeding.

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

Root assignments reach every organization

Evaluation walks a node's lineage with no special case for organizations, and organizations sit directly under the Environment root. So a role assigned at the root is held in every organization: a person with Support at the root can act in Acme and Globex alike, and an org-scoped check answers yes for them in both. That is the place for your own staff. It also means a customer's environment-wide role from before the container was on is a cross-tenant grant until you move that person into memberships: create the memberships first, then remove the root assignment. Removing it touches nothing beneath an organization. A membership, and any scoped grant beneath the organization, reaches only that tenant.

Lifecycle & Teardown

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

Remove a member: deactivates the membership and revokes every role held at the organization's nodes. The identity, and its memberships elsewhere, are untouched, and the membership row stays listed under status=deactivated; adding the person again brings it back.After a member is removed: their current access token keeps its claims until it expires (the Environment's access-token lifetime, 15 minutes by default), and evaluation refuses org-scoped checks for them immediately, since it reads memberships live. Leaving ends every grant inside the organization on every path: the roles go with the membership, and anything that would stand on a deactivated membership counts for nothing until the person is admitted again. Their next refresh mints a token with no organization claims, even if they belong to other organizations. Switching into the removed organization answers 403 organization.not_a_member; switching into another they still belong to works.Revoke an invitation: the invite link stops working. The address can always be invited again.Delete an organization: removes the organization, all its memberships, its pending invitations, its policy, its SSO binding and any connection it owns, its email-domain claims, its directory, and the tree beneath it. Member identities survive and keep everything they hold elsewhere.Delete every organization: DELETE /api/v1/organizations empties the container in one call, the step before switching it off.Disable the container: with the container empty and the Environment flat, the same switch that enabled it turns it off. Identities and the role catalog survive.

Tokens converge on their own: within the access token's lifetime, every removed or changed membership is reflected in the claims the next refresh mints, and a session acting nowhere lists its remaining memberships with GET /v1/identity/auth/organizations and switches into one.

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.