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:
adminin Acme may do more thanviewerin 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"
}
nameis 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.permissionsare strings of lowercase segments joined by:, such asinvoices:write, each at most 64 characters, at most 100 per role; duplicates collapse and the list comes back sorted.*and thenautilus: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 conflictand 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_idmust 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 same404.- 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 withPATCH(role_id).DELETEremoves the membership only — the person stays your end user. - A membership can end by itself:
expires_at(PATCHit, or send it onPOST) and theexpire_rolesrule 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 — is400 invalid_payloadwith the reason, notallowed: 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:
| Where | Field | Scope | How fresh |
|---|---|---|---|
| Signed token | orgs (and orgs_truncated) | every project of your account; first 10 | as of when the token was made; valid up to 15 minutes |
POST /v1/gate/tokens/introspect, when subject is an end user's id | orgs (and orgs_truncated) | the key's project; first 100 | live |
POST /v1/passepartout/tokens/introspect | orgs (and orgs_truncated) | the key's project; first 100 | live |
GET /v1/gate/auth/session | orgs (and orgs_truncated) | the key's project; first 100 | live |
GET /v1/gate/identities/{id}/organizations | data | the key's project; paged | live |
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.