Skip to content
runlot

Organizations

Let your app's users belong to organizations, each with roles you define, and have the database enforce them.

A B2B app has customers that are companies, and people who belong to one or more of them. env.auth.orgs gives your app that shape: organizations, members with a role, invitations, and an active organization per session. The database reads it, so a row policy can say "only people in this organization, with this permission".

Declare your roles

Roles are yours. Name them in runlot.json, each with the permissions it holds. "*" means every permission.

runlot.json
{
  "auth": {
    "orgs": {
      "roles": {
        "owner": ["*"],
        "hr": ["payroll:read_all"],
        "member": []
      },
      "creatorRole": "owner"
    }
  }
}

runlot deploy sends the table, and your app's database installs it. What a permission means is up to your policies.

Without auth.orgs, there is one role, owner, holding *.

Create, invite, join

const me = await env.auth.user(request);
const org = await env.auth.orgs.create({ slug: "acme", name: "Acme Inc." }, me.id);

// The token is returned once. Send it in your own email.
const invite = await env.auth.orgs.invite(org.id, { email: "[email protected]", role: "hr" }, me.id);

// On your accept page, with Bob signed in:
await env.auth.orgs.accept(request, token);

An invitation is accepted only by a signed-in user whose verified email is the one it was sent to. It works once, and expires after 7 days unless you pass expiresAt.

MethodWhat it does
create({ slug, name }, userId)A new organization; userId gets creatorRole
list(userId)The organizations a user belongs to now, with their role
get(orgId) · members(orgId)One organization, and its current members
invite(orgId, { email, role, expiresAt?, memberExpiresAt? }, invitedBy)An invitation token
accept(request, token)The signed-in user joins
add(orgId, userId, role, { expiresAt? })Add an existing user directly
setRole(orgId, userId, role)Change a role
setExpiry(orgId, userId, at)Membership that ends on a date; null removes the end
remove(orgId, userId)Remove a member
switch(request, orgId) · active(request)The session's active organization

The last member with an owner role (a role holding "*") can't be removed, demoted or given an end date. Make someone else owner first; the call fails with last_owner.

The active organization

A session works in one organization at a time.

await env.auth.orgs.switch(request, org.id);
const active = await env.auth.orgs.active(request);
// { id, slug, name, role, expiresAt, permissions: ["*"] }

Only a current member can switch to an organization. Removing someone clears it from their sessions.

Enforce it in the database

With RLS on, three functions read the request's identity:

FunctionAnswers
runlot_auth.org_id()The session's active organization
runlot_auth.org_role()The user's role in it, read now
runlot_auth.can('<permission>')Whether that role holds the permission, or *

A tenant table is one line, and a permission another:

migrations/0002_payslips.sql
alter table payslips enable row level security;

create policy tenant on payslips
  using (org_id = runlot_auth.org_id());

create policy hr_reads_all on payslips for select
  using (org_id = runlot_auth.org_id() and runlot_auth.can('payroll:read_all'));

Always pair can() with the organization

can() answers for the active organization. A policy that checks only can('payroll:read_all') lets someone who is HR in their own organization read every organization's rows. Match org_id = runlot_auth.org_id() in the same policy.

The role is read from the membership table on every statement, not from the session. Demoting or removing someone, or reaching their expiresAt, takes effect on their next query, with nothing to sign out.

Locally

runlot dev doesn't simulate organizations: active() answers null, list() answers [], and the other methods fail with a message saying so. Deploy a preview to try them.

On this page