Skip to content

ABAC, Guards, Groups & Invites

Relationship tuples decide who is related to what, but some access decisions only make sense at the moment of the request: the amount on an invoice, the time of day, whether the actor owns the resource. This chapter covers the layers Auther stacks on top of ReBAC to capture that context.

The six subsystems below are really one pipeline seen from different angles: ABAC adds runtime conditions to a relationship check, the guard layer is the ergonomic way application code calls that check, groups let one tuple cover many users, registration contexts and invites seed the initial tuples a new user starts with, escalation is how users earn tuples they weren’t given, and templates bundle common tuple sets so you grant them in one step. The common thread throughout is the access tuple — everything here either creates one, scopes one, or evaluates one.

ReBAC answers “is Alice an editor of this invoice?” — a yes/no read of stored relationships. But it cannot express rules like “admins can only refund invoices under $1,000” or “users can only edit resources they own,” because those depend on attributes (the invoice amount, who owns it) that are only known at request time. That is the difference between the two models: ReBAC decides from relationships, ABAC decides from attributes.

Auther uses ABAC to refine ReBAC rather than replace it. A relationship match still has to exist first; ABAC then runs a small Lua policy — a short script written by an operator, not the resource itself — to give that match a second opinion using the live request context.

There are two levels at which a policy can attach, and they compose predictably:

  1. Permission-level policy lives in the authorization model’s permission definition and applies to every check for that permission. It is the broad rule. Here the refund permission requires the admin relation and a sub-$1,000 amount:

    {
    "refund": {
    "relation": "admin",
    "policyEngine": "lua",
    "policy": "if context.resource.amount < 1000 then return true else return false end"
    }
    }
  2. Tuple-level condition is a Lua script attached to one specific access tuple, so it only fires when that exact tuple is the match. A tuple-level condition takes priority over the permission-level policy, letting you carve out a narrower rule for a single grant. Here Bob’s specific editor grant carries its own condition:

    -- Bob can edit invoices, but only while they are in draft status
    INSERT INTO access_tuples (entityType, entityId, relation, subjectType, subjectId, condition)
    VALUES ('client_abc:invoice', '*', 'editor', 'user', 'bob_id',
    'return context.resource.status == "draft"');

Every policy runs against a context object assembled in src/lib/auth/abac-context.ts. It exposes the resource being touched, the actor, the action, and the current time:

-- context.resource: the resource being accessed
context.resource.id -- "invoice_123"
context.resource.type -- "invoice"
context.resource.amount -- 500
context.resource.status -- "draft"
context.resource.owner_id -- "user_456"
context.resource.department -- "sales"
-- context.user: the actor performing the action
context.user.id -- "user_789"
context.user.name -- "Alice"
context.user.email -- "alice@example.com"
context.user.role -- "admin"
context.user.department -- "finance"
-- context.action: the permission being checked
context.action -- "refund"
-- context.timestamp: current time (ISO string)
context.timestamp -- "2025-01-15T10:30:00.000Z"

With that object in hand, policies stay short and readable. A few representative examples:

-- Only allow refunds under $1000
if context.resource.amount < 1000 then return true else return false end
-- Only allow editing your own resources
return context.resource.owner_id == context.user.id
-- Time-based: only during business hours (UTC)
local hour = tonumber(os.date("!%H"))
return hour >= 9 and hour < 17
-- Department-based: finance can refund anything, others are capped at $500
if context.user.department == "finance" then return true end
return context.resource.amount < 500

To make the flow concrete, trace the refund policy on one request. Alice (an admin) tries to refund a $500 invoice. ReBAC confirms she has the admin relation, then ABAC builds the context and runs the permission-level Lua:

-- context handed to the policy
context.resource.amount -- 500
context.user.role -- "admin"
context.action -- "refund"
-- the policy
if context.resource.amount < 1000 then return true else return false end
-- 500 < 1000 -> returns true -> ALLOW

Swap the invoice for a $5,000 one and the same script returns false — denied, with both the decision and the context snapshot landing in the audit log described below.

Because these scripts are written by operators rather than baked into the server, the engine has to treat them as untrusted code on the hot path. The policy engine (src/lib/auth/policy-engine.ts) and the pooled runtime (lua-engine-pool.ts) enforce hard limits so a slow or oversized script cannot stall a check or exhaust memory:

Safety measureValue
Execution timeout1 second
Max script size10 KB
Engine pool size20 engines
Engine TTL5 minutes
Max concurrent20
Burst supportYes (engines created beyond the pool are not reused)

Inside Auther’s own admin surface, you do not want to hand-write a permission check before every server action. src/lib/auth/platform-guard.ts wraps the permission engine in a small, ergonomic layer with two flavors.

For server actions, the throwing guards short-circuit unauthorized calls with a Permission denied error:

src/app/admin/actions/create-user.ts
import { guards } from "@/lib/auth/platform-guard";
// In a server action that creates a user:
export async function createUser(data: CreateUserInput) {
await guards.users.create(); // throws "Permission denied" if unauthorized
// ... create user logic
}

For conditional UI — say, hiding a button a user can’t act on — the non-throwing checks return a boolean instead:

src/app/admin/users/page.tsx
import { canUser } from "@/lib/auth/platform-guard";
// In a server component:
const showCreateButton = await canUser.users.create(); // returns boolean

Both forms share the same set of guard namespaces: platform, users, webhooks, pipelines, clients, keys, groups, sessions, and apiKeys.

Granting permissions one user at a time does not scale, so Auther lets you organize users into groups (the user_group table) with memberships recorded in group_membership. The important design choice is that groups are first-class subjects in ReBAC — meaning a tuple can name a group in the exact slot where it would otherwise name a user, with no special-casing. So you grant the group once and add members freely:

-- The Engineering group can use client_123
(oauth_client, client_123, use, group, eng_group_id)
-- Alice is a member of Engineering
group_membership: (alice_id, eng_group_id)

When the engine resolves Alice’s permissions, the expandSubjects() method in PermissionService walks outward from her with a breadth-first traversal:

  1. Start with Alice (the user subject).

  2. Find Alice’s groups through the group_membership table (retained for legacy support).

  3. Find hierarchical memberships through tuples that use hierarchy: true relations.

  4. Repeat for each newly discovered group, which is what makes nested groups work.

The payoff is inheritance: if Engineering is a subgroup of Product, and Product has access to a client, then every Engineering member inherits that access without an extra tuple.

Most new users should not arrive with zero permissions and wait for an admin. Registration contexts solve the bootstrapping problem by attaching automatic grants to a sign-up flow.

A registration context (the registration_contexts table) describes who is signing up and what they should get. This one auto-grants the commenter relation to anyone who signs up through the blog client:

{
"slug": "blog-commenter",
"name": "Blog Commenter",
"clientId": "blog_client_id",
"grants": [
{ "entityTypeId": "model_123", "relation": "commenter" }
],
"allowedOrigins": ["https://blog.example.com"],
"allowedDomains": ["example.com"],
"enabled": true
}

When a user signs up or first authorizes through a client that has registration contexts configured, the matching permission tuples are created for them automatically. This is wired up by applyRegistrationContextGrants() in src/lib/pipelines/registration-grants.ts, backed by the RegistrationContextService in src/lib/services/registration-context-service.ts.

Contexts come in two kinds, distinguished by whether clientId is set:

  • Client contexts (clientId set) grant client-specific permissions and apply when a user authorizes through that client.
  • Platform contexts (clientId null) grant platform-level permissions and apply to every new user.

When access should be gated rather than open, platform invites (the platform_invites table) issue one-time, HMAC-signed tokens. A client redeems one by posting it back to the verification endpoint:

POST /api/auth/verify-invite
Content-Type: application/json
{ "token": "signed_token_here", "email": "alice@example.com" }

The endpoint runs the token through a strict sequence before it trusts anything:

  1. Validate the HMAC-SHA256 signature.

  2. Confirm the invite has not already been consumed and has not expired.

  3. Optionally check that the supplied email matches the invite’s locked email.

  4. Queue the associated registration-context grants for after the user is created.

  5. Also queue every enabled global platform context.

Once the user actually creates their account, the queued grants are applied automatically — so an invited user lands fully provisioned.

Not every permission can be granted up front. Auther lets users request access they don’t yet have, and the PermissionRequestService (src/lib/services/permission-request-service.ts) drives the full lifecycle.

When a user submits a request, the service runs a series of gates before deciding what to do with it:

  • Is there already a pending request for this? (Prevents duplicates.)
  • Is the permission marked selfRequestable in the permission_rules table? (If not, the request is rejected outright.)
  • Does the auto-reject Lua condition match? (If so, reject immediately.)
  • Does the auto-approve Lua condition match? (If so, approve immediately and create the tuple.)
  • Otherwise the request moves to require_approval, awaiting an admin.

From there an admin either approves the request — which creates the granting tuple — or rejects it with an optional note.

The per-relation behavior of all of this is configured by the permission_rules table. This rule makes viewer self-requestable and auto-approves anyone with an @example.com email, while everyone else falls through to admin review:

{
"clientId": null,
"relation": "viewer",
"selfRequestable": true,
"autoApproveCondition": "return context.user.email:match('@example.com$')",
"defaultAction": "require_approval",
"approverRelation": "admin"
}

Assigning the same cluster of relations to user after user is tedious and error-prone, so Auther bundles common permission sets into reusable policy templates (the policy_templates table). The PolicyTemplateService (src/lib/services/policy-template-service.ts) ships seven built-in system templates:

TemplatePermissions granted
Super Administratorplatform:super_admin plus all admin relations
Platform Administratorplatform:admin plus all admin/editor relations
User Managerusers:admin only
Webhook Operatorwebhooks:editor only
Pipeline Developerpipelines:editor only
Read-Only Accessall viewer relations
API Key Self-Serviceapi_keys viewer plus create permissions

Applying a template with applyTemplateToUser(templateId, userId) creates all the corresponding access tuples in one step, and administrators can define custom templates of their own when the built-ins don’t fit.