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.
{
"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.
| Method | What 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:
| Function | Answers |
|---|---|
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:
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.