Members and invites
Who can do what in a tenant, how people join (invites, open sign-up and approval), project grants and per-database overrides, and the access log.
A tenant is one team's workspace. Everyone in it is a member, and what a member can do is decided by two things: the tenant-admin flag, and grants on projects. Studio's Members page (last in the rail) shows and edits all of it; every action is also an API route, listed in Access per route.
Roles
Tenant admin is a flag on a member. A tenant admin sees every project, creates projects, manages members and invites, and counts as Admin with every Postgres role (the database owner included) on every project. A tenant always keeps at least one active tenant admin: demoting or removing the last one is refused with 409 last_tenant_admin, and an admin can't remove themselves. Demoting a tenant admin checks what they keep (their own grants, overrides and teams' grants, on every project and database) exactly as a grant is checked: if it would leave them a role they can only hold as Admin, the demotion is refused with 422 invalid_request naming the role, its project_id and database_id, and the team_id when a team's grant is why. Change that access first, then demote.
A grant gives a member a platform role on one project:
| Platform role | Can |
|---|---|
| Viewer | Read: projects, databases, branches, schemas, merges, the table editor (rows and structure, no changes), data keys' list. |
| Developer | Everything a Viewer can, plus: create branches; change unprotected branches (reset, restore, delete); run SQL as an allowed Postgres role; start and run schema merges (merges don't run as a Postgres role); create and block app users; save and run queries; create data keys bound to an allowed role. |
| Admin | Everything on the project: protected branches, connection strings and passwords, login roles, the Data API settings, grants and invites, and acting as the database owner. |
A grant also lists the Postgres roles the member may act as in the SQL editor and the console. See Roles and schema isolation. The rules, in short:
- Admin holds every role. An Admin on a project may act as the database owner and every other role of the project's branches, login roles included; the grant lists no roles (
GET /v1/projects/{id}/grantsandGET /v1/meanswer"all"). Roles named when granting Admin are checked, then dropped. To give an Admin fewer roles on one database, narrow it there (below). Developers and Viewers can't act as the owner, andowneris never listed in a grant. - The data API's roles only when named.
anonandauthenticatedaren't part of an Admin's every role: Act as offers them only to a member whose grant (or a narrowing) names them, Admins included. - A granted role includes its chain. If you grant
billing_adminand it canSET ROLE billing, the member may act as both. Studio shows the expanded set. - Some roles are Admin-only, whether you grant them, act as them or bind a data key to them: owner-level roles (superuser,
CREATEROLE,CREATEDB,REPLICATION,BYPASSRLS, or a path toneon_superuser), roles that can log in (LOGIN), reserved roles, and any role whose chain reaches one of those. A Developer can't be given such a role, and can't create a key for one. A role whose chain holdsADMINon another role is refused at grant time (422), whatever the grant's level. Permissions go onNOLOGINgroup roles, which is the Postgres convention. - Saved queries are shared within the project. Any Developer can read them; only the author or an Admin edits or deletes one.
Per-database overrides
A project grant applies to every database in the project. An override narrows it for one Chelabase database. Overrides are per Chelabase database only, never per Postgres database inside it: to restrict within a database, use Postgres privileges. In Studio, a project's Access tab has Per database, where each line offers Narrow (and Stop narrowing once narrowed). GET /v1/me lists the narrowed databases per project (projects[].databases). The effective grant is the project grant intersected with the override: the platform role is the lower of the two and the role set is the overlap. An override can never widen access, even after the project grant changes later. Narrowing an Admin to Developer takes the owner away in that database. An Admin's every role narrows like this: an override naming roles keeps only those there (with the owner while still Admin); an override down to Developer or Viewer without naming roles leaves no roles there. A tenant admin is never narrowed.
Teams
A team is a named set of members that holds grants of its own. Studio's Members › Teams tab lists them, and a team's page edits its members and its project access.
- Shape. A name (trimmed, 1 to 80 characters, unique in the tenant ignoring case; a taken name is 409
name_taken) and a description (up to 500 characters). A new team has no members and no grants. - Team grants have a project grant's shape: a platform role and Postgres roles, with the same rules (an Admin grant lists no roles,
owneris never listed, and roles that are owner-level,LOGINor reserved are refused below Admin). A team may be granted Admin. Teams carry no per-database overrides. - Live-linked. A member's effective access is their own grant on the project plus the grants of every team they belong to. Nothing is copied: add a member to a team, change the team's grant or remove the grant, and every member it reaches changes at once. Their SQL editor role, personal logins, realtime and proxy caches are reconciled as for any other grant change.
- Per-role levels. Each Postgres role keeps the highest level among the sources that name it, and acting as a role needs that role's level. Example: a member holds Developer with
billing_editorthrough their own grant and Viewer withbilling_readerthrough a team. They may run SQL asbilling_editor, butbilling_readeris held only at Viewer, so it never reaches the SQL editor or a personal login (those take roles held at Developer or above). The platform role is the best level among the sources. - Overrides narrow afterwards. The member's per-database overrides apply to the combined access, as they do to a single grant: the platform role is the lower of the two and the role set is the overlap.
- Admin from any source is Admin on the project, holding every Postgres role.
- Checked on the combined access. Every team write (grant, membership change) is checked for every affected member and database. A change that would leave any member holding a role the rules above refuse is rejected with 422, naming the member, the database and the team. Own-grant and override writes are checked on the combined access too. A write that loses a race with another is retried, then answered 409
conflict. - Visibility. Any member reads
GET /v1/teamsandGET /v1/teams/{team_id}. A tenant admin sees every project of a team; anyone else sees only the projects where they have access. A team's Postgres roles on a project are shown only to tenant admins and to Admins of that project; others getpg_roles: null. - Removing a member from the tenant takes them out of their teams in the same transaction, and the log's
member_removedevent records the teams they were in.
The routes (all tenant admin to write, any member to read; see Access per route):
| Route | Does |
|---|---|
GET /v1/teams | List teams with member counts and grants on visible projects |
POST /v1/teams | Create ({"name": "Billing", "description": "…"}), 201 |
GET /v1/teams/{team_id} | One team, with member_ids |
PATCH /v1/teams/{team_id} | Rename or change the description |
DELETE /v1/teams/{team_id} | Delete, 204; its members lose what it gave them |
PUT /v1/teams/{team_id}/grants/{project_id} | Give the team access to a project, body {"platform_role": "developer", "pg_roles": ["billing_editor"]} |
DELETE /v1/teams/{team_id}/grants/{project_id} | Take it away, 204 (also when there was none) |
PUT /v1/teams/{team_id}/members/{member_id} | Add a member, 200 (also when already in); a pending member is 409 |
DELETE /v1/teams/{team_id}/members/{member_id} | Remove a member, 204 (also when not in) |
GET /v1/members/{member_id}/teams | A member's teams, with each team's grants on the projects you can see |
GET /v1/projects/{id}/team-grants | Project Admin: the teams granted on a project, with their member_ids |
An unknown team, project or member, or one of another tenant, is 404. Membership is edited one member at a time. For example:
curl -X POST "$API/v1/teams" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" -d '{"name": "Billing"}'
curl -X PUT "$API/v1/teams/$TEAM/grants/$PROJECT" -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"platform_role": "developer", "pg_roles": ["billing_editor"]}'
curl -X PUT "$API/v1/teams/$TEAM/members/$MEMBER" -H "Authorization: Bearer $TOKEN"GET /v1/members reports, per project, whether a member's access is own, team or both (via). Team events (team_created, team_updated, team_deleted, team_grant_put, team_grant_deleted, team_member_added, team_member_removed) appear in the access log; a member's Activity (member_id filter) includes team events that reached them, and a project's log hides who else a team reaches.
Teams on invites and approvals
A tenant admin's invite (POST /v1/invites) and an approval (POST /v1/members/{id}/approve) accept team_ids: up to 50 teams of the tenant (duplicates dropped; an unknown or foreign id is 422 naming it). The memberships are written in the same transaction as the grants, and the grants and teams are checked together on the combined access. This holds for a new invitee, a person who already has an account and an existing member. The project invite (POST /v1/projects/{id}/invites) refuses team_ids in any form, [] and null included: 422 invalid_request, because only a tenant admin's invite attaches teams.
What a member without a grant sees
A member with no grants anywhere gets 403 no_access_yet on project routes, and Studio shows "You don't have access to a project yet" in place of the projects. A member with a grant on some project gets 404 not_found for projects they have no grant on, so a project's existence isn't revealed. A removed member is refused at once, even with a still-valid token.
How people join
Self-hosted mode has three ways in. Platform mode has no self-sign-up yet: see Self-hosted vs platform.
Invites
A tenant admin invites from Members › People (POST /v1/invites, with the grants to start from); a project Admin invites from the project's Access tab with Add people (POST /v1/projects/{id}/invites, one grant). The grants are stored up front and count once the invitee accepts.
- By email. The identity service mails a six-digit invite code (5 tries; the invite expires after 72 hours) and a link to Studio's
/accept-invite. The page names the inviter and the tenant (by its display name, falling back to its slug); the invitee types the code and sets their own password, which also verifies their email. The link itself carries no code. - By link, without SMTP. When no SMTP server is configured (
GET /v1/invites/deliverysays, with the reasonemail_not_configured), pick One-time link under "How they get the invite" (delivery: "link"). The response carriesinvite_urlonce, with the code in it; hand it over yourself. It is never stored or logged, and the response isCache-Control: no-store. A resend withdelivery: "link"replaces the earlier link. A link invite is refused with 409link_not_allowedfor a person who already has an account (the code would set their password): invite them by email instead. Asking for email delivery with no SMTP server is 409conflict. - Someone who already has an account (a password and a verified email, from another tenant or an earlier membership) gets a notice by email instead of a code. Its link carries only the invite's token; they accept by signing in with their own password, and nothing asks them to choose a new one. An existing member just gets the grants (
outcome: "existing_member"). - Describe. The
/accept-invitepage asksPOST /auth/v1/invitewith theuserandt(token) from the link for the inviter and the tenant's name. It answers only for an open, unexpired invite whose token matches; anything else is the same 404not_found. - An invited member is activated only by an open, unexpired invite. Accepting signs them in.
- Resend (in Studio, New invite link for a link invite) makes a new code and a new token, and the old link stops working; revoke invalidates the open invite and removes the never-activated user. An expired or revoked link answers 410
invite_expiredorinvite_revoked. If a step after the code check fails, 503invite_incompletecarries anaccept_ticketso the page can retry without the spent code. Invites and resends are rate limited per person (429rate_limited). - Re-inviting a person who was removed brings them back as
invited, never straight to active. Their account stays banned until they accept, so their password signs nobody in meanwhile; accepting with that password lifts the ban. Any inviter may do it, and the access log records who. - An inviter with no name is shown by their email, or as "An admin" without one.
Open sign-up
Off by default. A tenant admin turns it on on the Members page's Instance tab (PATCH /v1/instance/settings with open_signup). While it is on, the sign-in page offers Create account, and a person who signs up becomes a pending member: they have no session until an admin approves them: their sign-in answers pending, and Studio shows "Waiting for approval".
Approval
Pending sign-ups appear under Members › People › Requests, with a badge on Members. A tenant admin approves one with its first project access (POST /v1/members/{id}/approve) or declines it (POST /v1/members/{id}/decline: removed, and their account banned and signed out unless another tenant holds them). Both are logged. With open sign-up off, a sign-up is refused with 403 sign_up_closed. Once approved, they sign in as usual.
The fresh-install window. While a tenant has no member at all, the instance is unclaimed. The first sign-up needs the setup code that init-secrets printed (docker compose logs init-secrets; see Install to print it again), and that account claims the tenant admin. A sign-up with a missing or wrong code is refused (setup_code_invalid) and creates no account. If two people race for it with the code, only one claims the admin and the other is admitted as pending. Claim the instance before you expose Studio to anyone. After the claim, sign-up follows open sign-up as above. On a workbench stack (development), the bootstrap claims the instance for a dev member and keeps its password in .chelabase/stacks/<slug>/bootstrap/dev-member.json, a tenant-admin credential: keep it private.
Removing a member
Removing a member (DELETE /v1/members/{id}) deletes their grants and overrides, drops their Postgres roles on every branch of their projects, revokes their personal logins, and ends their sessions at once. Their account is banned unless another tenant still holds them, so their password signs nobody in until an invite brings them back. Anything they created in a run and left behind goes to the role they last acted as; if that is gone, it is dropped. It never goes to the owner.
The tenant's name
An admin gives the tenant a display name on the Members page (Tenant name, then Rename; PATCH /v1/tenant, up to 80 characters; empty clears it). The invite page and GET /v1/me use it and fall back to the slug.
The access log
Every access change writes one append-only event in the same transaction: invites, accepts, grants, overrides, admin changes, approvals, removals, open sign-up switches and bootstrap. Tenant admins read the whole log in Members › Activity (GET /v1/access-events, which answers {events, next_before}); a project Admin reads that project's events under Access › Access changes (GET /v1/projects/{id}/access-events). Events name the actor and the person they touched, and keep the before and after of the change, never secrets.