Membership administration

A customer writes in: the person who owned their account has left the company, and they are one seat short. The account cannot fix the first from inside — that is the point of the two-party transfer — and has no way to raise the second. Your support desk can, through the control plane, and every fix leaves a reason in the trail.

These are the control-plane routes under {base}/admin/organizations/:organizationId. Each is reached only by a registered connection holding the scope it names, and with no connection registered for the environment every one answers controlplane/not_connected, the control plane’s shipped, denying state.

RouteScopeWhat it does
PATCH {base}/admin/organizations/:organizationIdorganization:accounts:writeRename a tenant, give it a free short name, set its seat limit
POST …/membersorganization:members:writeAdd an existing user at a named role
PATCH …/members/:membershipIdorganization:members:writeChange a member’s role
DELETE …/members/:membershipIdorganization:members:writeRemove a member, and clear their selection
GET …/invitationsorganization:members:writeThe outstanding invitations, to fix one
POST …/invitations/:invitationId/resendorganization:members:writeSend one again with a fresh link, expired or not
DELETE …/invitations/:invitationIdorganization:members:writeRevoke one, so its link stops working
POST …/ownershiporganization:ownership:transferMake a member the owner now

Three reads sit beside them, under their own scopes: GET {base}/admin/organizations (organization:accounts:read), GET …/:organizationId/members (organization:members:read), and GET {base}/admin/roles (organization:members:read), the role catalog a roster write names. Suspending, deleting and restoring the account itself, and previewing who a suspension or a deletion would reach, are six more routes under two more scopes, in organization lifecycle. The reference has every one.

Every one takes a reason, and every one is audited

The reason is a required field of the JSON body — a DELETE included, because the control-plane token signs the body — and of the query on the invitation listing. It is at most 500 characters and may not contain an email address, because the trail refuses addresses; name a member or an invitation by its id.

Each act writes an organization/admin_* event with actorType: "control-plane", the token’s sub as the actor, the connection, the reason, the tenant, and the membership, invitation or organization it was done to. They are their own codes rather than the member codes with a different actor, so a reader auditing an account’s administrators never finds support’s break-glass removal among them. The reference lists them.

An organization id that names no account is a 404 on every route, the reads included. A client holding only the read scopes is refused 403 on all eight routes in the table.

Three write scopes, and none implies another

organization:members:write covers who is in an account: add, remove, change a role, and list, resend or revoke an invitation. organization:accounts:write covers what it is: its name, its short name, its seat limit. organization:ownership:transfer covers who owns it, and only the forced-ownership route exercises it. Matched exactly, with no prefix rule, and none implies a read.

The owning role moves through one route

Where your project passes an ownership pair, the role it confers is the owning role, and the roster routes never touch it:

  • an add at the owning role,
  • a role change to the owning role, or from it,
  • the removal of an owner,

each refused 403 controlplane/insufficient_scope, with params.ownershipRoute naming POST {base}/admin/organizations/:organizationId/ownership. That holds even on a connection granted organization:ownership:transfer. A control-plane token is minted for one scope and exercises that one, and a roster call’s token is minted for organization:members:write, so the connection’s other grants are not its authority.

The reason is the transfer by another door. Without it, a roster token could add a second owner and remove the first — no ownership-scoped call, no forced-ownership event, and no notice to the new owner. Ownership moves only through POST …/ownership.

The refusal is written to the trail as controlplane/call_denied, with refusedBy: "handler", like any other scope refusal, so a credential probing for the grant it lacks shows up rather than hiding behind the gate’s controlplane/call_allowed.

A project with no ownership pair has no owning role, so none of this applies and the forced-ownership route does not mount.

Two floors that hold for an operator too

The last owner stays. Demoting or removing the last holder of the owning role is refused 409 organization/last_owner, and that refusal is checked first, so it is the sentence an operator reads for the last owner. It names the ownership route in params.ownershipRoute as well. An account with no owner is the state this surface exists to get out of, so it never makes one.

The last administrator stays. Demoting or removing the last holder of your administrativePower is refused 409 organization/last_administrator, on the member surface’s own floor.

Both are carried in the write, so two concurrent removals cannot both land. So is the role each write was decided from: a member made the owner while their removal or role change was running is refused 409 core/conflict rather than removed or changed under a member’s rules.

Add a member

POST …/members with { userId, role, reason }, and overrideSeatLimit when you mean it. An add is a membership, not an invitation. The user must already have an account: an unknown userId is a 404. Somebody already in the account is refused 409 organization/already_member, with params.role naming what they hold. It answers 201 with the new member.

The role may be any role the catalog declares except the owning one, unassignable ones included. A role the catalog does not declare is refused, listing the ones it does. Offer the catalog, not the roster. GET {base}/admin/roles answers { roles, owningRole }: every role the catalog declares, in its order, and the role your ownership pair confers, or null where the project wires none. It is read from config, so a role nobody holds yet is in it. A client offers roles without owningRole, so it never offers a role these routes refuse.

Change a role, or remove somebody

PATCH …/members/:membershipId with { role, reason } changes it to any declared role but the owning one, and never from the owning one either. DELETE …/members/:membershipId with { reason } ends the membership and the member’s acting selection of the account in the same write. A membership id from another tenant is a 404.

Force ownership

POST …/ownership with { membershipId, previousOwner, reason }. It moves ownership now, with no nomination and no acceptance — the break-glass path for an owner who has gone, which no member route can repair by design.

The new owner must already be in the account; add them first if not. previousOwner is required, because the two answers are different stories about the person who left:

  • "keep" demotes whoever held the role to the one your pair demotesTo.
  • "remove" ends their membership and their selection.

Any standing offer of ownership is spent in the same write, so an offer a previous owner made cannot be accepted to take the account back from the new owner. The whole move is one batch, conditional on the nominee still being a member: a nominee who leaves while it runs takes nothing with them, the batch changes nothing, and the route answers 404. Forcing it onto somebody who already owns the account is 409 core/conflict.

It answers with newHolderMembershipId, previousHolderMembershipIds and previousOwner. The act is recorded as organization/admin_ownership_forced at warning, and each previous holder gets an event of their own naming them, with the same reason: organization/admin_member_removed for "remove", organization/admin_member_role_changed for "keep", each with forcedOwnershipTo set to the new holder’s membership.

Hold a seat limit

PATCH {base}/admin/organizations/:organizationId with { seatLimit, reason } stores a limit on the account, from 1 to 1,000,000; null lifts it. An operator’s add that would exceed it is refused 409 organization/seat_limit_reached, naming the limit in params.seatLimit, unless the request sets overrideSeatLimit: true — then it lands, and the event records overrodeSeatLimit: true.

An invitation accepted against a full account is refused the same way, and the offer stays pending until a seat frees. Lowering the limit below the roster removes nobody; it refuses the next add.

The same route renames the account and moves its short name. Changing the short name is an operator’s act alone — no member route does it — and one another account holds is refused 409 organization/slug_taken, naming that account. At least one of name, slug and seatLimit has to be in the body.

Fix an invitation

GET …/invitations?reason=… lists the outstanding ones. It sits under the write scope, because the list is addresses and it is read to fix one, and the read is audited with its reason and a count — never the rows.

POST …/invitations/:invitationId/resend with { reason } issues a fresh link, expired or not, and the old one stops working. The mail names the person who first invited them, because it is their offer. An invitation whose role the catalog no longer assigns cannot be renewed, and is refused organization/invitation_invalid. DELETE …/invitations/:invitationId with { reason } revokes one, so its link stops working.

Who is told

A member an operator added or removed, and a new owner, each get a notice through email, in their own language: organizationMemberAdded, organizationMemberRemoved and organizationOwnershipTransferred. A previous owner removed by a forced transfer is told they were removed. The operator’s reason is never in the mail — it is written for whoever answers for the account, and it can say things about the person that are not a sentence to send them.

Each notice is switchable off:

organization({
  roles,
  ownership: { confers: "owner", demotesTo: "admin" },
  // All three send by default.
  operatorEmails: { memberAdded: true, memberRemoved: true, ownershipTransferred: true },
}),

A notice that cannot be sent never undoes the act. With no email composed nothing is sent and nothing is refused, and the audit event records mailed: null; an enqueue that fails is reported the same way.

What it does not do

Invite on an operator’s behalf. An add is direct, for somebody who already has an account. An operator who wants an invitation sent resends one that exists.

ESC