# Organizations and roles How WAMP models accounts, organizations, memberships, invitations, roles, and the fourteen organization permissions that authorize every management call. Source: https://docs.vampikez.fun/identity/organizations/ Every authority decision in WAMP resolves through an organization. This page gives you the model — accounts, organizations, memberships, roles, permissions — and the exact permission each management call requires, so you can tell in advance whether a call will answer `403 permission_denied`. The organization is the tenant. A user is not "in WAMP"; a user is a **member of an organization**, and the membership is what carries authority. Tokens, API keys, app installations, and AI usage all attach to an organization, never to a bare user. ## Accounts A WAMP account is one human: `id`, `email`, `name`, an optional avatar. There is no self-service creation or public registration route. The only path to a new account is **accepting an organization invitation**. The invitation carries both the account creation grant and the first membership, and the two commit in one transaction — you never end up with an account that belongs to nothing. Email addresses are never verified. The `email_verified` claim exists in OIDC tokens and is hardcoded `false`; no verification flow exists anywhere in the platform. ## Organizations | Field | Notes | |---|---| | `id` | UUID. This is what every management route takes as `:orgId` | | `slug` | Short unique handle | | `name` | Display name, 1–120 characters | | `kind` | Organization category, `BUSINESS` by default | | `status` | `ACTIVE` or `SUSPENDED`. Every call into a suspended organization fails `403 organization_suspended` | Organizations are created through an operator bootstrap path, not by signed-in users. A signed-in user with no membership anywhere cannot make themselves an organization; they have to be invited into one. List the organizations you belong to: ```bash curl -s "$API/api/organizations" -H "Authorization: Bearer $USER_ACCESS_TOKEN" ``` ```json { "success": true, "organizations": [ { "id": "8c1b…", "slug": "acme", "name": "Acme", "kind": "BUSINESS", "status": "ACTIVE", "currentMembership": { "id": "3d90…", "status": "ACTIVE", "roles": [{ "id": "…", "key": "admin", "name": "Admin", "permissions": ["…"], "isOwner": false, "isDefault": false }], "permissions": ["organization.profile.read", "…"] } } ] } ``` If the credential you present is already bound to one organization, this listing is filtered to that organization, and any `:orgId` in a path that does not match it answers `403 organization_context_mismatch`. That guard is why you cannot use a token minted for one tenant to read another. ## Memberships | Field | Notes | |---|---| | `id` | The `membership_id` that appears in OIDC tokens | | `status` | `ACTIVE`, `SUSPENDED`, or `REMOVED` | | `user` | `id`, `email`, `name`, `avatarUrl` | | `roles` | The full role objects assigned to this membership | | `joinedAt`, `suspendedAt`, `removedAt` | Lifecycle timestamps | Only an `ACTIVE` membership grants anything. Authority is re-read from the database inside the transaction of every mutation, so suspending a member takes effect on their next call — there is no cache to wait out, and no session to invalidate separately. ## Roles are data, not an enum A role is a row owned by the organization, not a fixed platform enum: | Field | Notes | |---|---| | `key` | ≤64 characters, unique in the organization | | `name` | ≤80 characters | | `description` | ≤240 characters, nullable | | `permissions` | Array of permission ids, ≤100 entries. Unrecognized strings are dropped rather than rejected | | `isOwner` | Protected. An owner role's authority comes from this flag, not from its permission list | | `isDefault` | The role an invitation uses when it names none | Three roles are seeded when an organization is created: | `key` | `permissions` | `isOwner` | `isDefault` | |---|---|---|---| | `owner` | empty — authority comes from the flag | `true` | `false` | | `admin` | all 14 organization permissions **except** `organization.ownership.manage` | `false` | `false` | | `member` | `organization.profile.read`, `organization.members.read`, `organization.applications.read`, `organization.products.read` | `false` | `true` | You can edit non-owner roles, add your own roles, and delete unused non-owner roles. Owner roles are protected from this update/delete API. Two rules constrain role assignment and permissions: - **You cannot grant what you do not hold.** Creating or editing a role whose permissions exceed your own answers `403 permission_denied`. This is the privilege-escalation guard, and it applies to the role's resulting effective permissions, not just the diff you sent. - **Owner roles need `organization.ownership.manage`.** Assigning or removing a role with `isOwner: true` requires that permission, which `admin` deliberately does not have. An organization also refuses to drop its last active owner (`409 final_owner_required`). An owner receives all 14 permissions regardless of what their role lists. ## The fourteen organization permissions These ids are a stable contract; handlers authorize against the id and never against a role key or display name. | Permission | What holding it permits | |---|---| | `organization.profile.read` | Read the organization record | | `organization.profile.manage` | Rename the organization | | `organization.members.read` | List members with their roles | | `organization.members.invite` | Create, list, rotate, and revoke invitations | | `organization.members.manage` | Change a member's status and role assignments; remove a member | | `organization.ownership.manage` | Assign or remove owner roles, grant a role that itself carries this permission, and revoke an invitation that carries an owner role | | `organization.roles.read` | List roles and the permission catalogue | | `organization.roles.manage` | Create, edit, and delete roles | | `organization.applications.read` | List the organization's apps | | `organization.applications.manage` | Create apps; register and revoke app keys; manage OAuth clients | | `organization.integrations.read` | Read server integrations | | `organization.integrations.manage` | Manage server integrations | | `organization.products.read` | List installed apps | | `organization.products.manage` | Change an installation's member access; revoke an installation | ## Two namespaces in one array The `permissions` array you get back mixes **two different kinds of string**, and treating them as one namespace will mislead you: 1. **Organization permissions** — the 14 `organization.*` ids above. Granted by putting them in a role. 2. **Resource capabilities** — capability ids belonging to a resource server, such as `wamp.ai.invoke` (the metered AI proxy) and the resource-defined `wamp.cloud.*` capabilities. These are **not** organization permissions. A capability only enters the array once the organization holds a live self-grant on an installed product that offers it. Adding `wamp.ai.invoke` to a role's permission list does nothing on its own — the filter drops any capability the organization is not entitled to, so the string silently disappears from effective authority. Conversely, revoking the installation removes the capability from every member immediately, even before the string is cleaned out of stored roles. The `wamp.cloud.*` capability set is documented with the product that defines it, at [docs.cloud.vampikez.fun](https://docs.cloud.vampikez.fun). Capability ids are seeded rows rather than a code enum, so treat any list of them as belonging to a specific resource server rather than to the platform. ## Invitations An invitation is a single-use bearer token. The raw token is returned once, at creation, and only its SHA-256 hash is stored — a lost invite link cannot be recovered, only rotated. Create one: ```bash curl -X POST "$API/api/organizations/8c1b…/invitations" \ -H "Authorization: Bearer $USER_ACCESS_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"email":"person@example.com","roleIds":["3f2a…"],"expiresInDays":7}' ``` ```json { "success": true, "invitation": { "id": "b41c…", "organization": { "id": "8c1b…", "slug": "acme", "name": "Acme", "status": "ACTIVE" }, "email": "person@example.com", "status": "PENDING", "expiresAt": "2026-08-18T09:00:00.000Z", "invitedByUserId": "…", "acceptedByUserId": null, "acceptedAt": null, "revokedAt": null, "createdAt": "2026-08-11T09:00:00.000Z", "updatedAt": "2026-08-11T09:00:00.000Z", "roles": [{ "id": "3f2a…", "key": "member", "name": "Member", "permissions": ["…"], "isOwner": false, "isDefault": true }] }, "acceptUrl": "https://account.example.com/invite#" } ``` Request fields: `email` (lowercased, ≤320 characters), optional `roleIds` (1–20 role UUIDs; falls back to the organization's `isDefault` role, and fails with `409 default_role_required` if there is none), optional `expiresInDays` (1–30, default 7 days). The raw token sits in the URL **fragment** of `acceptUrl`. Browsers never send a fragment to servers, proxies, referrers, or access logs, which is what keeps the token out of infrastructure you do not control. Preserve that shape if you re-send the link yourself. Statuses are `PENDING`, `ACCEPTED`, `REVOKED`, and `EXPIRED`. ### Redeeming one Both redemption endpoints take the token **in the request body**, never in the URL. ```bash # Anyone holding the token can inspect it. No authentication. curl -X POST "$API/api/invitations/resolve" \ -H 'Content-Type: application/json' -d '{"token":""}' ``` The response adds one field the invitation record does not carry: `accountExists`, a boolean saying whether a WAMP account already exists for the invited email. That is what lets an interface choose between "set a password and join" and "sign in and join". Resolving an expired invitation also transitions it to `EXPIRED` as a side effect. ```bash # Accepting requires an authenticated session, and the emails must match. curl -X POST "$API/api/invitations/accept" \ -H "Authorization: Bearer $USER_ACCESS_TOKEN" \ -H 'Content-Type: application/json' -d '{"token":""}' # {"success":true,"organizationId":"8c1b…","membershipId":"3d90…"} ``` If the invited email has no account yet, the token is instead redeemed by registering — the recipient sets a name and a password of at least 10 characters, and the account, membership, and invitation consumption commit together. That endpoint is same-origin-restricted to the Account Center host, so it is driven by opening the invite link in a browser rather than by your code. Rotating an invitation issues a new token and invalidates the old link (`POST /api/organizations/:orgId/invitations/:invitationId/rotate`, optional `expiresInDays`). Revoking it (`DELETE …/invitations/:invitationId`) ends it outright; revoking an invitation that carries an owner role additionally requires `organization.ownership.manage`. | Situation | Status | Body | |---|---|---| | Unknown or already-consumed token on resolve | `404` | `{"success": false, "error": "invitation_not_found"}` | | Past `expiresAt` | `410` | `{"success": false, "error": "invitation_expired"}` | | Signed-in email differs from the invited email | `403` | `{"success": false, "error": "invitation_email_mismatch"}` | | Already accepted or revoked | `409` | `{"success": false, "error": "invitation_not_pending"}` | | A pending invitation for that email already exists | `409` | `{"success": false, "error": "invitation_already_pending"}` | | The email already has an account and a membership | `409` | `{"success": false, "error": "invitation_email_taken"}` | ## Product access When an app is installed in an organization, the installation — not the app — carries the access rules. | Field | Notes | |---|---| | `id` | The `installation_id` that appears in tokens | | `app` | `id`, `slug`, `name`, `status` | | `status`, `revokedAt` | An installation must be `ACTIVE` with no `revokedAt` to grant anything | | `accessMode` | `ALL_MEMBERS`, or `ASSIGNED_MEMBERS` for a named subset | | `assignedMembershipIds` | The named subset, when `accessMode` is `ASSIGNED_MEMBERS` | ```bash # Requires organization.products.read curl -s "$API/api/organizations/8c1b…/products" -H "Authorization: Bearer $USER_ACCESS_TOKEN" # Requires organization.products.manage curl -X PATCH "$API/api/organizations/8c1b…/products//access" \ -H "Authorization: Bearer $USER_ACCESS_TOKEN" -H 'Content-Type: application/json' \ -d '{"accessMode":"ASSIGNED_MEMBERS","membershipIds":["3d90…"]}' ``` `accessMode` interacts directly with sign-in: a user whose membership is not in `assignedMembershipIds` cannot select that organization at the consent screen, and an existing grant for them stops resolving. Revoking the installation (`DELETE …/products/:installationId`, `organization.products.manage`) invalidates every token that depended on it at the next refresh. ## Management endpoints All of these take a WAMP user session as `Authorization: Bearer ` and, if the credential is bound to an organization, require `:orgId` to be that organization. | Method and path | Permission required | |---|---| | `GET /api/organizations` | Membership only | | `GET /api/organizations/:orgId` | `organization.profile.read` | | `PATCH /api/organizations/:orgId` | `organization.profile.manage` | | `GET /api/organizations/:orgId/members` | `organization.members.read` | | `PATCH /api/organizations/:orgId/members/:membershipId` | `organization.members.manage` | | `DELETE /api/organizations/:orgId/members/:membershipId` | `organization.members.manage` | | `GET /api/organizations/:orgId/roles` | `organization.roles.read` | | `POST /api/organizations/:orgId/roles` | `organization.roles.manage` | | `PATCH /api/organizations/:orgId/roles/:roleId` | `organization.roles.manage` | | `DELETE /api/organizations/:orgId/roles/:roleId` | `organization.roles.manage` | | `GET /api/organizations/:orgId/invitations` | `organization.members.invite` | | `POST /api/organizations/:orgId/invitations` | `organization.members.invite` | | `POST /api/organizations/:orgId/invitations/:invitationId/rotate` | `organization.members.invite` | | `DELETE /api/organizations/:orgId/invitations/:invitationId` | `organization.members.invite` | | `GET /api/organizations/:orgId/products` | `organization.products.read` | | `PATCH /api/organizations/:orgId/products/:installationId/access` | `organization.products.manage` | | `DELETE /api/organizations/:orgId/products/:installationId` | `organization.products.manage` | | `POST /api/invitations/resolve` | None | | `POST /api/invitations/accept` | Any authenticated user | `PATCH …/members/:membershipId` takes `{"status": "ACTIVE" | "SUSPENDED"}`, `{"roleIds": [...]}`, or both; `DELETE` on the same path sets the membership to `REMOVED` and answers `204`. Role creation takes `{key, name, description?, permissions, isDefault?}`; the update form is the same object minus `key`, partial, and must not be empty. Listing invitations returns only `PENDING` ones, and it is gated on `organization.members.invite` rather than `organization.members.read` — reading the member list does not include reading who has been invited. ## Error codes on this plane Bodies are `{"success": false, "error": ""}`. | Code | Status | Meaning | |---|---|---| | `organization_not_found` | 404 | No active membership for you in that organization. It is deliberately indistinguishable from "does not exist" | | `organization_suspended` | 403 | The organization is suspended | | `permission_denied` | 403 | You hold an active membership but not the required permission, or you tried to grant more than you hold | | `organization_context_mismatch` | 403 | Your credential is bound to a different organization than the path | | `member_not_found` | 404 | Unknown membership id in this organization | | `member_removed` | 409 | The membership is `REMOVED` | | `invalid_member_transition` | 409 | The requested status change is not legal from the current one | | `final_owner_required` | 409 | The change would leave the organization with no active owner | | `role_not_found` | 404 | Unknown role id | | `role_key_taken` | 409 | Another role in this organization already uses that `key` | | `invalid_role_key` | 400 | The `key` is not acceptable | | `invalid_permissions` | 400 | The permission list is not acceptable | | `protected_role` | 409 | The role is protected — an owner role cannot be edited or deleted this way | | `default_role_required` | 409 | An invitation named no roles and the organization has no default role | | `role_in_use` | 409 | The role is still assigned to a membership | | `invalid_invitation_expiry` | 400 | `expiresAt` is not in the future | | `installation_not_found` | 404 | Unknown installation in this organization | | `invalid_installation_access` | 400 | The access-mode change is not acceptable | | `internal_error` | 500 | Unexpected failure | Invitation-specific codes are in the table under [Redeeming one](#redeeming-one).