Organizations and Roles

Your end users rarely act alone: they work for a company, belong to a team, share a workspace. Gate lets you model that without building the tables yourself.

  • An organization is a group of your end users. It lives in one project of yours.
  • A role is a named set of permissions, defined inside one organization: admin in Acme may do more than viewer in Acme, and Globex has roles of its own.
  • A member is an end user who holds one role in an organization.

A user can belong to several organizations, with a different role in each; inside one organization they hold one role.

Gate keeps this data and tells you, everywhere it tells you who a user is: in the signed token, in token introspection, in the session check, and in a list of its own. Gate does not enforce permissions: they are data your code (or your database policy) reads, and what a permission means is up to you. If you would rather not read and compare them yourself, ask Gate: may this user do this in this organization? is one call.

All routes below need an API key with the gate scope, and act on the project the key belongs to — a key can neither see nor change another project's organizations, even one of the same account.

Organizations

POST   /v1/gate/organizations
GET    /v1/gate/organizations
GET    /v1/gate/organizations/{org_id}
PATCH  /v1/gate/organizations/{org_id}
DELETE /v1/gate/organizations/{org_id}
curl -X POST https://api.vernesoft.com/v1/gate/organizations \
  -H 'Authorization: Bearer vrn_9f8a7...' \
  -H 'Content-Type: application/json' \
  -d '{ "name": "Acme" }'
{
  "id": "3b2f6f0e-8f0c-4a54-9f1d-0c7a5d1e2b10",
  "name": "Acme",
  "created_at": "2026-10-14T09:00:00Z",
  "updated_at": "2026-10-14T09:00:00Z"
}

name is free text for people, 1 to 120 characters, and not unique: two customers of yours can honestly both be called Acme. Deleting an organization deletes its roles and its memberships with it (204). The list is paged with limit (default 20, at most 100) and cursor:

{ "data": [ { "id": "…", "name": "Acme", "created_at": "…", "updated_at": "…" } ], "has_more": true, "next_cursor": "…" }

Roles

POST   /v1/gate/organizations/{org_id}/roles
GET    /v1/gate/organizations/{org_id}/roles
GET    /v1/gate/organizations/{org_id}/roles/{role_id}
PATCH  /v1/gate/organizations/{org_id}/roles/{role_id}
DELETE /v1/gate/organizations/{org_id}/roles/{role_id}
curl -X POST https://api.vernesoft.com/v1/gate/organizations/3b2f6f0e-8f0c-4a54-9f1d-0c7a5d1e2b10/roles \
  -H 'Authorization: Bearer vrn_9f8a7...' \
  -H 'Content-Type: application/json' \
  -d '{ "name": "admin", "permissions": ["invoices:read", "invoices:write", "members:invite"] }'
{
  "id": "5d0e9b4a-1c2f-4e6a-8d3b-7a9c2e4f6b81",
  "organization_id": "3b2f6f0e-8f0c-4a54-9f1d-0c7a5d1e2b10",
  "name": "admin",
  "permissions": ["invoices:read", "invoices:write", "members:invite"],
  "created_at": "2026-10-14T09:01:00Z",
  "updated_at": "2026-10-14T09:01:00Z"
}
  • name is an identifier, because it is what your code compares against and what a signed token carries: 1 to 64 characters, lowercase letters, digits, _ and -, starting with a letter or digit. A name that is not already in that form is refused, not quietly corrected. It is unique within the organization. An organization has at most 100 roles.
  • permissions are strings of lowercase segments joined by :, such as invoices:write, each at most 64 characters, at most 100 per role; duplicates collapse and the list comes back sorted. * and the nautilus: prefix are reserved. A role may have none.
  • A role's permissions can be changed (PATCH) at any time. Everyone who holds the role has the new permissions the next time they are read — there is nothing to update per member.
  • A role that someone holds cannot be deleted: the answer is 409 conflict and says how many members hold it. Move them to another role, or remove them, first.

Members

POST   /v1/gate/organizations/{org_id}/members
GET    /v1/gate/organizations/{org_id}/members
PATCH  /v1/gate/organizations/{org_id}/members/{identity_id}
DELETE /v1/gate/organizations/{org_id}/members/{identity_id}
curl -X POST https://api.vernesoft.com/v1/gate/organizations/3b2f6f0e-8f0c-4a54-9f1d-0c7a5d1e2b10/members \
  -H 'Authorization: Bearer vrn_9f8a7...' \
  -H 'Content-Type: application/json' \
  -d '{ "identity_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479", "role_id": "5d0e9b4a-1c2f-4e6a-8d3b-7a9c2e4f6b81" }'
{
  "organization_id": "3b2f6f0e-8f0c-4a54-9f1d-0c7a5d1e2b10",
  "identity_id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
  "role_id": "5d0e9b4a-1c2f-4e6a-8d3b-7a9c2e4f6b81",
  "expires_at": null,
  "created_at": "2026-10-14T09:02:00Z",
  "updated_at": "2026-10-14T09:02:00Z"
}
  • identity_id must be one of your end users (an identity you created, or one that signed up through your key). Anything else — another account's user, one of your own team's accounts, an id that does not exist — is the same 404.
  • The role must belong to that organization; a role of another organization is refused.
  • Adding someone who is already a member is 409; change their role with PATCH (role_id). DELETE removes the membership only — the person stays your end user.
  • A membership can end by itself: expires_at (PATCH it, or send it on POST) and the expire_roles rule remove it when the date passes.
  • Deleting an end user removes their memberships.

Every change of a role or a membership is in the audit log.

A user's organizations

GET /v1/gate/identities/{identity_id}/organizations

The organizations of your project that one of your end users belongs to, each with the role the user holds there and its permissions, oldest membership first. It is live and paged (limit up to 100, cursor). This is the complete list that the orgs_truncated flag in a token or an introspection points to.

curl 'https://api.vernesoft.com/v1/gate/identities/f47ac10b-58cc-4372-a567-0e02b2c3d479/organizations?limit=50' \
  -H 'Authorization: Bearer vrn_9f8a7...'
{
  "data": [
    { "id": "3b2f6f0e-8f0c-4a54-9f1d-0c7a5d1e2b10", "role": "admin", "permissions": ["invoices:read", "invoices:write", "members:invite"] }
  ],
  "has_more": false,
  "next_cursor": null
}

A user of another account is 403, as everywhere under identities/{id}. A user who belongs to nothing gets "data": [].

Checking a permission

POST /v1/gate/authorize

May this user do this in this organization? One call, answered from the user's role and its permissions as they are at the moment you ask. It is the Authorize Action endpoint with an organization as the resource: subject is the end user's id, action is the permission, resource is organization: and the organization's id.

curl -X POST https://api.vernesoft.com/v1/gate/authorize \
  -H 'Authorization: Bearer vrn_9f8a7...' \
  -H 'Content-Type: application/json' \
  -d '{
    "subject": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
    "action": "invoices:write",
    "resource": "organization:3b2f6f0e-8f0c-4a54-9f1d-0c7a5d1e2b10"
  }'
{
  "allowed": true,
  "decision_id": "dec_9f8a7c1b2d3e",
  "reason": "member_with_permission",
  "role": "admin"
}

reason says why: member_with_permission, member_without_permission, not_a_member, subject_inactive (a member who is switched off) or no_such_organization. role is there whenever the user is a member.

  • The permission is matched exactly against the permissions of the role the user holds in that organization. There are no wildcards.
  • It is the key's project: an organization of another project or another account is answered no_such_organization, exactly as one that does not exist.
  • A permission no role could hold — a wildcard, upper case, the reserved nautilus: prefix — is 400 invalid_payload with the reason, not allowed: false.
  • It is a read: nothing is written to the audit log, and it is not billed.

Use it instead of reading orgs yourself when you want the answer to be exactly as fresh as the database: a signed token's orgs is as old as the token.

Where else the user's organizations appear

The same element — { "id", "role", "permissions" } — is the unit everywhere:

WhereFieldScopeHow fresh
Signed tokenorgs (and orgs_truncated)every project of your account; first 10as of when the token was made; valid up to 15 minutes
POST /v1/gate/tokens/introspect, when subject is an end user's idorgs (and orgs_truncated)the key's project; first 100live
POST /v1/passepartout/tokens/introspectorgs (and orgs_truncated)the key's project; first 100live
GET /v1/gate/auth/sessionorgs (and orgs_truncated)the key's project; first 100live
GET /v1/gate/identities/{id}/organizationsdatathe key's project; pagedlive

A token that is not the signed one never carries a stale list: introspection and the session check read the memberships at the moment you ask.