Documentation

Authorization

AdminUpdated Sep 11, 2026

Authorization

Authentication proves who someone is. Authorization decides what they may do. Atlas gives you two tools for it, and most apps use both:

  • Roles & permissions (RBAC) — coarse, per-organization. "Admins can manage billing; members can't." A handful of roles that apply across an org.

  • Fine-grained access (FGA) — per-object, relationship-based (Google-Zanzibar / OpenFGA style). "Alice can edit document 123; Bob can only view it." Scales to millions of per-object grants and inheritance.

One rule governs both: the server is the boundary. Client helpers decide what a user sees; only a check on the request decides what they can do. Every example below that gates UI has a matching server check.

1. Permission keys and role keys

Keys are the contract — they travel in the session token and get hardcoded into your authorization checks, so their shape is fixed and they are immutable once created. The two shapes differ on purpose:

  • A role key is org:<name> — e.g. org:admin, org:member. It names who someone is.

  • A permission key is org:<resource>:<action> — e.g. org:billing:manage. It names what they may do.

So <Protect role="org:admin"> and <Protect permission="org:billing:manage"> both work — the arity of the key tells you which you're checking. The sys_ prefix is reserved for Atlas's built-in permissions (e.g. org:sys_memberships:manage).

2. Roles & permissions in the dashboard

Under Authorization → Roles & permissions, every instance is seeded with two system roles — org:admin (holds every permission) and org:member (read-only) — which you can't delete but can relabel. Add your own roles and permissions; the "Add permission" field offers a menu of conventional keys so you pick a well-shaped one instead of guessing. Group-to-role grants let your IdP drive role assignment through SCIM.

3. What's in the session token

When a user has an active organization, their role and the union of its permissions (direct and any granted through a group) are minted into the session JWT:

{
  "sub": "user_2a…",
  "org_id": "org_9f…",
  "org_role": "org:admin",
  "org_permissions": ["org:sys_memberships:manage", "org:billing:manage"]
}

Your code reads authorization straight off the token — no call back to Atlas on the hot path. A permission change takes effect within one token lifetime (the same bound as session revocation).

4. Check on the server (the real boundary)

Verify the session, then gate the action. The verified result carries has() and protect() bound to the claims — has() returns a boolean, protect() throws ForbiddenError (map it to 401/403):

import { AtlasBackend } from '@atlas/backend';

const atlas = new AtlasBackend({
  jwksUrl: 'https://accounts.yourapp.com/.well-known/jwks.json',
  issuer: 'https://accounts.yourapp.com',
});

const auth = await atlas.authenticateRequest(req);
if (!auth.ok) return res.status(401).end();

// boolean form — full condition set
if (auth.has({ permission: 'org:billing:manage' })) { /* … */ }
if (auth.has({ anyPermission: ['org:billing:manage', 'org:billing:read'] })) { /* … */ }

// assertion form — throws ForbiddenError when unmet
auth.protect({ role: 'org:admin' });

In Next.js, auth() exposes the same has() / protect() in server components and route handlers:

import { auth } from '@/atlas'; // your createAuthHelper() instance

export async function POST(request: Request) {
  const a = await auth(request);
  a.protect({ permission: 'org:billing:manage' }); // 403 if not held
  // …do the privileged work
}

5. Gate UI on the client

In React, render conditionally with <Protect> or useAuth().has(). This is a rendering aid, not a security boundary — always keep the matching server check from step 4.

import { Protect, useAuth } from '@atlas/react';

<Protect permission="org:billing:manage" fallback={<Locked />}>
  <BillingSettings />
</Protect>

// or imperatively
const { has } = useAuth();
if (has({ anyPermission: ['org:billing:manage', 'org:billing:read'] })) { /* … */ }

@atlas/react-native exposes the same useAuth().has(condition), and @atlas/js ships a framework-agnostic has(claims, condition) for anything else. All of them evaluate the identical condition — a check written for one surface behaves the same everywhere.

6. Manage roles from your backend

Everything on the Roles screen is also a REST API, so provisioning can be automated:

import { createAtlasClient } from '@atlas/backend';
const atlas = createAtlasClient({ secretKey: 'sk_live_…' });

await atlas.roles.create({
  key: 'org:auditor',
  name: 'Auditor',
  permissions: ['org:logs:read', 'org:billing:read'],
});

// Retire a role that people still hold — move its members first, atomically:
await atlas.roles.delete('role_123', { reassignTo: 'role_member' });

// Custom permissions
await atlas.permissions.create({ key: 'org:reports:export', name: 'Export reports' });
await atlas.permissions.delete('perm_456'); // 409s if a role still grants it

7. Fine-grained access (FGA)

When "who can touch this specific object" outgrows roles, reach for FGA. You define a model (types and relations), write tuples (facts like "alice is an editor of doc:1"), and check a relation before allowing an action. It supports inheritance ("editors of a folder can edit its documents"), groups, and public wildcards.

const atlas = createAtlasClient({ secretKey: 'sk_live_…' });

// One-time: create a store + model (types & relations), then bind the store:
const { id } = await atlas.fga.stores.create({ name: 'app' });
const fga = atlas.fga.store(id);

// Write relationship tuples as facts change:
await fga.write({ writes: { tuple_keys: [
  { user: 'user:alice', relation: 'editor', object: 'doc:1' },
  { user: 'group:eng#member', relation: 'viewer', object: 'doc:1' },
] } });

// Check on the request path:
const { allowed } = await fga.check({ user: 'user:alice', relation: 'editor', object: 'doc:1' });

// Answer many at once (e.g. filtering a list) in one round trip:
const batch = await fga.batchCheck({ checks: [
  { user: 'user:alice', relation: 'viewer', object: 'doc:1', correlation_id: '1' },
  { user: 'user:alice', relation: 'viewer', object: 'doc:2', correlation_id: '2' },
] });

// Or ask which objects a user can reach:
const { objects } = await fga.listObjects({ user: 'user:alice', relation: 'viewer', type: 'doc' });

The model is OpenFGA-compatible, so a model authored in the OpenFGA playground ports straight in. Writes are validated against the model's allowed user types, so a typo'd tuple that would silently never match is rejected up front.

8. Which one do I use?

  • A small, fixed set of roles that apply across an organization → Roles & permissions.

  • Permissions that vary per object, per relationship, or need inheritance → FGA.

  • Both together is normal: RBAC for "is this person an org admin," FGA for "can this person open record #98472."

Was this page helpful?