Org roles vs per-product grants
The org role is account-wide: it covers things like invites, SSO, billing and branding, and it decides who may administer other members. It does not enumerate products.
The member-administration procedures behind this screen (
admin.setGrant,
admin.updateMemberRole, admin.removeMember) accept owner and admin
only. Owners and admins also pass every grant gate implicitly, so their rows
reach every product regardless of what the grant matrix shows for them.
For everyone else, product access comes entirely from grants. A member with no
grant rows is in the organization and can reach no product console.
admin.listMembers returns userId, email, name, role and createdAt.
Identities are resolved through the public.user_identities RPC rather than
read directly from the auth schema. name is an empty string when the account
has no name recorded — the invite flow and password signup do not set one — so
the screen falls back to the email address.
The grant levels and which products accept them
admin.setGrant takes one modality and one role. Passing role: null
deletes the grant row, which the screen shows as No access.
These are the grantable products, with the console each one opens:
Expand the Product access cell on a member row to see and edit the full
matrix. The count on the button is the number of products with a grant row.
Writes are idempotent: if the submitted level equals the stored level,
setGrant returns without touching the row or writing an audit entry.
Otherwise it upserts on (org_id, user_id, modality) and records granted_by.
Access presets on the invite form
The invite form offers presets instead of an eleven-row grid, because the common cases are few. Each preset sets both the org role and the grant map; you can still adjust either afterwards, which flips the selector to Custom.
The screen always sends the
grants object explicitly, including when it is
empty. An omitted grants field means “older client, synthesise a grant set
from the role” on the server, which is not the same thing as the deliberate
empty set behind No access yet.
Guard rails: last owner, owner-only changes, no self-edits
The server enforces these, not the UI. They return errors even if a client calls the procedure directly.- No self-edits.
setGrantrejects auserIdequal to the caller withBAD_REQUEST— “Cannot change your own access — ask another admin”. This stops an admin quietly widening their own product access without a second pair of eyes.updateMemberRoleapplies the same rule to the org role, which also prevents accidental self-lockout. - Owner changes are owner-only. Only an owner may grant the
ownerrole or change a member who already holds it. - The last owner cannot be demoted.
updateMemberRolerefuses the change that would leave the org with no owner.removeMembermirrors the same checks. - The target must be a member.
setGrantlooks the user up inorg_memberfirst and returnsNOT_FOUND— “Member not in this org” — if there is no row. You cannot pre-grant products to someone who has not accepted an invite.
The invite lifecycle: send, resend, accept, expire, revoke
admin.inviteMember takes orgId, email, role and grants. The invite row
in org_invite carries id, email, role, grants, created_at,
expires_at and accepted_at.
admin.listInvites returns only rows where accepted_at is null, newest
first. So an invite leaves the Pending invites table as soon as it is
accepted — the member then appears in the Members table instead. The
Expires column renders the row’s expires_at; once that moment passes the
link no longer works and you send a fresh invite.
Inviting an address that is already a member does not create a second
invitation. The mutation returns already_member: true together with a
message, and no acceptance link. The screen surfaces the message as a notice
rather than falling into the “email not delivered, copy the link” path, which
would otherwise promise a link that never renders.
admin.revokeInvite takes orgId and inviteId. Revoking stops the invite
link from working. You can send a new invite to the same address at any time.
When an invite email is not delivered
The invite response includes amail_delivered flag:
mail_delivered: true— the invitation was emailed and nothing else is needed.mail_delivered: false— the invite exists but the message did not go out. The response then carriesaccept_url(ormagic_link), and the screen renders that URL so you can hand it to the invitee out of band.
Entitlements bound every grant
Grants only ever narrow access. Your plan’s entitlements and the product list your org is provisioned for still decide what the organization can reach at all. Grantingstreams to a member of an org without the Stream entitlement
gets them nothing — the grant row exists, but the gate above it still fails.
Two consequences worth remembering when you debug a “why can’t they see it”
report:
- If nobody in the org can open a product, the problem is the entitlement, not the grant.
- If one person cannot open a product that colleagues can, the problem is the grant — unless that colleague is an owner or admin, who reach everything regardless of the matrix.
Auditing membership changes
Grant changes write an audit entry throughrecordAudit:
Because a no-op write returns early, repeated saves of the same level do not
produce duplicate audit rows. The
granted_by column on org_member_grant
independently records who last set the surviving grant.
Procedures behind this screen
Related
- Authentication — API keys and relay tokens, which are scoped separately from membership