This is the feature that turns Polaris from "a login library" into "the backbone of a SaaS." The whole mental model fits in a single sentence:
Identity is global. Authority is scoped to an organization.
You are one user. You might own your own company's org, be an admin in a client's org, and be a regular member in a third. Same account, three different sets of powers. The relationship looks like this:
User >--< Membership >--< Organization
|
roles >-- permissions (per-org; system roles when org is null)Every response below is captured from a live run.
Create an Organization
Create an org and you become its owner automatically:
curl -s https://api.example.com/v1/orgs \
-H 'Authorization: Bearer eyJ0eXAiOiJKV1Qi...' \
-H 'Content-Type: application/json' \
-d '{"name":"Analytical Engines Ltd"}'{ "data": { "id": "019ee088-0802-7a1a-b6a9-29b90bb13ac4", "name": "Analytical Engines Ltd", "slug": "analytical-engines-ltd", "role": "owner" } }If you do not supply a slug, Polaris derives one from the organization name. When that org is created, Polaris clones three role templates into it: owner, admin, and member. They are real, editable rows scoped to your organization, not hard-coded global constants. The owner holds every org-scoped permission. The admin holds everything except deleting the org. The member gets read-only basics. There is also a global superadmin for platform operators that sits above all orgs and is never listed under any single org.
Scope Your Session to the Org
Creating an org does not automatically scope your current token at it. You switch:
curl -s https://api.example.com/v1/auth/switch-org \
-H 'Authorization: Bearer eyJ0eXAiOiJKV1Qi...' \
-H 'Content-Type: application/json' \
-d '{"organization_id":"019ee088-0802-7a1a-b6a9-29b90bb13ac4"}'The response is a fresh access token whose claims now carry the org and the resolved roles. Decoded, the relevant part looks like this:
{
"sub": "019ee088-0581-...",
"org": "019ee088-0802-7a1a-b6a9-29b90bb13ac4",
"roles": ["owner"],
"mfa": false,
"amr": ["pwd"]
}Switch-org preserves mfa, amr, and auth_time from the session, so a step-up you did earlier survives the switch.
The Permission Catalog
Permissions are a fixed, readable set of resource.action keys. GET /permissions returns the whole catalog as {"key","description"} pairs. Here are the twelve org-scoped keys:
You can also define your own custom roles in an org with any subset of these keys, and a host module can contribute its own permission keys (covered in part 5, coming soon).
Declaring a Permission on an Endpoint
So how does an endpoint say "you need org.read to do this"? It declares a constant. That is the entire ceremony. Here is the real read-organization domain:
final class ReadOrganizationDomain extends OrganizationDomain
{
public const array REQUIRES_PERMISSIONS = [PermissionCatalog::ORG_READ];
public function __construct(private readonly RepositoryInterface $organizations)
{
}
public function __invoke(InputCollection $input): PayloadInterface
{
$token = $this->token($input);
if ($token === null) {
return $this->unauthorized();
}
$organizationId = (string) $input->get('id');
if ($organizationId === '') {
return $this->notFound('The organization does not exist.');
}
if ($this->deniesActiveOrg($input, $token, $organizationId)) {
return $this->forbidden('That organization is not your active organization.');
}
$organization = $this->organizations->find($organizationId);
if (!$organization instanceof Organization) {
return $this->notFound('The organization does not exist.');
}
return $this->respond(200, [
'data' => [
'id' => $organization->id,
'name' => $organization->name,
'slug' => $organization->slug,
'status' => $organization->status,
],
]);
}
}An AuthorizationMiddleware reads the REQUIRES_PERMISSIONS constant before the domain ever runs. If the caller lacks the required permission, it short-circuits with a 403. If a domain declares nothing, the middleware is a no-op for it. No annotation magic, no route-config drift. The requirement lives right next to the code that needs it.
The Part I Will Not Shut up About: Authority Comes from the Database
When that middleware checks your permission, it does not trust the roles claim out of your token. It re-resolves your authority from the database for your active org, every single time. The check itself is delegated to a small Gate:
final readonly class Gate
{
public function __construct(private PermissionResolver $permissions)
{
}
public function authorize(TokenInterface $token, string ...$permissions): void
{
if (!$this->allows($token, ...$permissions)) {
throw new AuthorizationException('You do not have permission to perform this action.');
}
}
public function allows(TokenInterface $token, string ...$permissions): bool
{
return $this->allowsAuthority($this->authority($token), ...$permissions);
}
public function authority(TokenInterface $token): ResolvedAuthority
{
$organization = $token->getMetadata('org');
return $this->permissions->resolve(
(string) $token->getMetadata('sub'),
is_string($organization) ? $organization : null,
);
}
}Why hit the database when the token already lists the roles? Because tokens live for fifteen minutes and reality does not wait. If you revoke someone's admin role, you want that to bite on their very next request, not whenever their access token happens to expire. The claims are a hint for resource servers that want a cheap optimistic check. Polaris itself never trusts them when making an authority decision. The middleware uses allows() for the declarative edge check; domain services call authorize() for row-level checks deep in the business logic.
Inviting People
Inviting a member is a permissioned action (members.invite):
curl -s https://api.example.com/v1/orgs/019ee088-0802-.../invites \
-H 'Authorization: Bearer eyJ0eXAiOiJKV1Qi...' \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","role_slugs":["admin"]}'{
"data": {
"id": "019ee08a-0349-754d-8f92-88af1d704094",
"email": "[email protected]",
"role_slugs": ["admin"],
"expires_at": "2026-06-26T15:40:01+00:00"
}
}The invitation token is single-use, expiring (seven days by default), and bound to the invitee's email. It is delivered by email through an event listener and is never returned by the API, so it cannot accidentally leak through your access logs. When Charles accepts at POST /auth/invites/accept, his account email has to match the invited address or he gets a 403.
The Guardrails That Keep a SAAS Safe
This is where multi-tenant systems quietly grow security holes, so Polaris enforces a set of invariants, all from database-resolved authority, never from token claims:
- No privilege escalation. You can only grant roles and permissions you yourself hold. An admin cannot mint a role more powerful than admin. Superadmin is the only exemption.
- Owners are protected. Only an owner (or superadmin) can modify, suspend, or remove another owner.
- Last-owner protection. The final active owner of an org cannot be demoted, suspended, or removed. This one is absolute, returning
409 conflict. Not even an owner or a superadmin can orphan an organization. I added this after picturing the support ticket that starts with "I accidentally removed myself and now nobody can administer the company account." - Cross-tenant isolation. Every
/orgs/{id}/...route checks that the path's org is your active org. A token scoped to org A cannot touch org B, full stop.
There is even a small privacy touch in the member list. The email addresses of invited and suspended members come back as null unless you also hold members.invite. Active members' emails are always visible. Little things like that are the difference between something that’s "technically RBAC" and something that’s actually been thought through.
Recap
Organizations with cloned, editable role templates. A flat, readable permission catalog. Declarative permission guards that live next to the code. A Gate that always re-checks authority against the database. Single-use, email-bound invitations. And a set of hard invariants (no escalation, owners protected, last-owner protection, cross-tenant isolation) that you get for free.
The full RBAC model, including custom roles and host-contributed permission keys, is in the reference docs at polaris.univeros.io.
Next up: part 5, Bring your own Twilio, SES, and listeners (coming soon), where we wire real delivery providers and hook into the PSR-14 event stream.
The Polaris series
- Meet Polaris
- Logins that do not leak
- Real MFA in an afternoon
- One user, many orgs: multi-tenant RBAC (you are here)
- Bring your own Twilio, SES, and listeners (coming soon)
Build captivating apps and sophisticated B2B platforms
Stunning solutions for web, mobile, or cross-platform applications.
Learn more.jpg)