Chelabase

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 roleCan
ViewerRead: projects, databases, branches, schemas, merges, the table editor (rows and structure, no changes), data keys' list.
DeveloperEverything 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.
AdminEverything 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}/grants and GET /v1/me answer "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, and owner is never listed in a grant.
  • The data API's roles only when named. anon and authenticated aren'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_admin and it can SET 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 to neon_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 holds ADMIN on another role is refused at grant time (422), whatever the grant's level. Permissions go on NOLOGIN group 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, owner is never listed, and roles that are owner-level, LOGIN or 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_editor through their own grant and Viewer with billing_reader through a team. They may run SQL as billing_editor, but billing_reader is 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/teams and GET /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 get pg_roles: null.
  • Removing a member from the tenant takes them out of their teams in the same transaction, and the log's member_removed event records the teams they were in.

The routes (all tenant admin to write, any member to read; see Access per route):

RouteDoes
GET /v1/teamsList teams with member counts and grants on visible projects
POST /v1/teamsCreate ({"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}/teamsA member's teams, with each team's grants on the projects you can see
GET /v1/projects/{id}/team-grantsProject 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/delivery says, with the reason email_not_configured), pick One-time link under "How they get the invite" (delivery: "link"). The response carries invite_url once, with the code in it; hand it over yourself. It is never stored or logged, and the response is Cache-Control: no-store. A resend with delivery: "link" replaces the earlier link. A link invite is refused with 409 link_not_allowed for 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 409 conflict.
  • 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-invite page asks POST /auth/v1/invite with the user and t (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 404 not_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_expired or invite_revoked. If a step after the code check fails, 503 invite_incomplete carries an accept_ticket so the page can retry without the spent code. Invites and resends are rate limited per person (429 rate_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.

On this page