RBAC as Code
Define your resources, roles, and permissions in a TypeScript config file and sync them to AuthGate with a single CLI command. RBAC as code means your RBAC configuration is version-controlled, reviewable in pull requests, and deployable through your existing CI/CD pipeline.
Installation
npm install @auth-gate/rbac
Quick start
1. Generate a starter config
npx @auth-gate/rbac init
This creates an authgate.rbac.ts file in your project root with a sample resource and role definition.
2. Edit the config
Open authgate.rbac.ts and define your resources, roles, and grants. See Config format below.
3. Set environment variables
AUTHGATE_API_KEY=ag_...
AUTHGATE_URL=https://...
4. Preview changes
npx @auth-gate/rbac sync
Runs a dry-run and prints a diff of what will be created, updated, or archived. No changes are applied.
5. Apply changes
npx @auth-gate/rbac sync --apply
Executes the diff against AuthGate.
Config format
Use defineRbac() to declare your resources and roles:
authgate.rbac.ts
import { defineRbac } from "@auth-gate/rbac/config";
export const rbac = defineRbac({
resources: {
documents: { actions: ["read", "write", "delete", "share"] },
billing: { actions: ["read", "manage"] },
members: { actions: ["invite", "remove", "update_role"] },
},
roles: {
admin: {
name: "Administrator",
grants: {
documents: { read: true, write: true, delete: true, share: true },
billing: { read: true, manage: true },
members: { invite: true, remove: true, update_role: true },
},
},
editor: {
name: "Editor",
grants: {
documents: { read: true, write: true },
billing: { read: true },
},
},
viewer: {
name: "Viewer",
grants: {
documents: { read: true },
},
},
},
});
// Default export for CLI compatibility
export default rbac;
Key points about the config:
- Resource keys (
documents,billing,members) are stable identifiers used internally. Each resource declares its available actions as a string array. - Role keys (
admin,editor,viewer) are stable identifiers. If you need to rename a key after syncing, use renamedFrom to preserve existing member assignments. - Grants map resources to their permitted actions. Each grant value is
truefor boolean access (Phase 1). Actions not listed in a role's grants are denied by default. - Routing the config through
defineRbac()is what enables type-safe RBAC across your app — the same object writtenas constwithout it degrades every permission tostring. The export name is not load-bearing: the CLI accepts a default export, a namedrbacexport, or the module itself.
Wildcard grants
Use "*" as a resource key or action key in grants to avoid listing every permission explicitly. Wildcards are expanded at defineRbac() time against your declared resources and actions, so the resulting grants object is always fully explicit — including the copy rbac sync uploads. A role's grants are therefore fixed by the resources declared now; adding a resource later does not silently widen it.
An action named under the "*" resource applies to every resource that declares it and is skipped for those that do not, so "*": { read: true } is safe alongside a resource with no read action. An action no resource declares is a typo and is rejected.
All resources, all actions
authgate.rbac.ts
export default defineRbac({
resources: {
documents: { actions: ["read", "write", "delete"] },
billing: { actions: ["read", "manage"] },
},
roles: {
superadmin: {
name: "Super Admin",
grants: { "*": { "*": true } },
},
},
});
This expands to:
grants: {
documents: { read: true, write: true, delete: true },
billing: { read: true, manage: true },
}
One action across all resources
auditor: {
name: "Auditor",
grants: { "*": { read: true } },
},
Expands read: true to every resource that declares a read action. Resources without a read action are skipped.
All actions on one resource
doc_admin: {
name: "Doc Admin",
grants: { documents: { "*": true } },
},
Expands to all actions declared on documents (read, write, delete).
Layering: specific overrides wildcard
When a role combines wildcard and specific grants, specifics win:
mostly_reader: {
name: "Mostly Reader",
grants: {
"*": { read: true }, // base: read on everything
documents: { write: true }, // override: also write on documents
},
},
Expands to:
grants: {
documents: { read: true, write: true },
billing: { read: true },
}
The layering order is:
- Wildcard resource grants (
"*") provide the base - Specific resource grants override the base
- Within a resource, specific action values override a wildcard action (
"*")
Wildcards work with all grant value types — true, scope strings, and conditional grants ({ when: "..." }). TypeScript accepts "*" in both key positions. Note that rbac.roles.<role>.grants then reflects the expanded shape, with each declared resource optional, since what a wildcard resolves to depends on which resources declare the action.
Type-safe RBAC
defineRbac() returns a typed object that carries your resource, role, and action keys as literal types. Pass it to factory functions to get full IDE autocomplete everywhere — like tRPC, but for access control.
React hooks
lib/rbac.ts
import { createRbacHooks } from "@auth-gate/react";
import { rbac } from "../authgate.rbac";
export const { useRbac, RbacGate } = createRbacHooks(rbac);
Now all resource/role/action parameters are typed:
const { can, is } = useRbac(orgId);
can(rbac.permissions.documents.write); // the string "documents:write"
is(rbac.roles.admin); // the role object; is() also accepts "admin"
<RbacGate orgId={orgId} permission={rbac.permissions.billing.manage}>
<BillingSettings />
</RbacGate>
Server helpers (Next.js)
import { createRbacHelpers } from "@auth-gate/nextjs";
import { rbac } from "../authgate.rbac";
const helpers = createRbacHelpers({
rbac,
baseUrl: process.env.AUTHGATE_URL!,
apiKey: process.env.AUTHGATE_API_KEY!,
});
await helpers.checkPermission(userId, orgId, rbac.permissions.documents.write); // full autocomplete ↗
await helpers.checkRole(userId, orgId, rbac.roles.admin); // full autocomplete ↗
createRbacHooks(rbac) is the only way to obtain the RBAC hooks — there is no standalone useRbac export from @auth-gate/react. The factory is what constrains permission and role arguments to the keys in your config.
Environment variables
| Variable | Description |
|---|---|
AUTHGATE_API_KEY | Your project API key (ag_...) |
AUTHGATE_URL | Base URL of your AuthGate instance (AUTHGATE_BASE_URL is also accepted) |
CLI commands
| Command | Description |
|---|---|
npx @auth-gate/rbac sync | Dry-run — print the diff without applying |
npx @auth-gate/rbac sync --apply | Apply the diff to AuthGate |
npx @auth-gate/rbac sync --apply --force | Apply including archiving roles with assigned members |
npx @auth-gate/rbac sync --strict | Treat warnings as errors (recommended for CI/CD) |
npx @auth-gate/rbac init | Generate a starter authgate.rbac.ts config |
npx @auth-gate/rbac check | Validate config locally (no server round-trip) |
Without --apply, sync is always safe to run. Use it in pull request checks to surface role changes for review before they reach production. Add --strict in CI/CD to fail the check when the plan archives a role that still has members assigned.
How sync works
When you run npx @auth-gate/rbac sync, the CLI:
- Loads and validates your
authgate.rbac.tsconfig - Fetches the current resource and role state from the AuthGate server
- Computes a diff: which resources and roles to create, update, or archive
- In dry-run mode: prints the diff to stdout and exits
- With
--apply: executes the changes against the AuthGate database
Resources and roles are matched by their config key (admin, editor, etc.). If a key is present in the config but not on the server, it is created. If it exists on both sides, it is updated. If it exists on the server but is absent from the config, it is archived.
Archiving a role with assigned members requires the --force flag. Without it, sync --apply aborts with a non-zero exit and applies nothing at all — including the creates and updates in the same run. It does not skip the archive and continue. Re-run with --force once you have confirmed the members can lose the role.
Renaming roles
If you need to change a role's config key (e.g., admin to org_admin), use renamedFrom to tell the sync engine that this is a rename rather than an archive-and-create:
authgate.rbac.ts
import { defineRbac } from "@auth-gate/rbac/config";
export default defineRbac({
resources: {
documents: { actions: ["read", "write", "delete", "share"] },
},
roles: {
org_admin: {
name: "Organization Admin",
renamedFrom: "admin",
grants: {
documents: { read: true, write: true, delete: true, share: true },
},
},
},
});
When the sync engine sees renamedFrom: "admin", it updates the existing role's config key from "admin" to "org_admin" instead of archiving one and creating the other. All existing member assignments are preserved — the underlying role ID stays the same.
The old key is stored in a previousConfigKeys array, so chain renames (admin -> org_admin -> organization_admin) work correctly across multiple syncs.
renamedFrom is a one-time directive. After the rename is applied, you can remove it from the config. If you leave it in, the sync engine will warn that no matching role was found and proceed normally.
Role inheritance
Roles can inherit permissions from other roles using the inherits field. This avoids duplication and makes the permission hierarchy explicit:
authgate.rbac.ts
import { defineRbac } from "@auth-gate/rbac/config";
export default defineRbac({
resources: {
documents: { actions: ["read", "write", "delete", "share"] },
billing: { actions: ["read", "manage"] },
members: { actions: ["invite", "remove", "update_role"] },
},
roles: {
viewer: {
name: "Viewer",
grants: {
documents: { read: true },
},
},
editor: {
name: "Editor",
inherits: ["viewer"],
grants: {
documents: { write: true },
},
},
admin: {
name: "Admin",
inherits: ["editor"],
grants: {
documents: { delete: true, share: true },
billing: { read: true, manage: true },
members: { invite: true, remove: true, update_role: true },
},
},
},
});
Inheritance follows an additive-only model: a child role receives all permissions from its parents plus its own grants. In the example above:
- viewer can
documents:read - editor inherits from viewer, so it can
documents:read+documents:write - admin inherits from editor (and transitively from viewer), so it gets all document permissions plus its own
billingandmembersgrants
Permissions are never removed through inheritance — a child role always has at least the permissions of its parents.
Coexistence with the dashboard
Config-managed roles and dashboard-managed roles can coexist in the same project:
- Roles synced from
authgate.rbac.tsappear as read-only in the AuthGate dashboard. You cannot edit them through the UI — all changes must go through the config file. - Roles created directly in the dashboard are unaffected by
syncand remain fully editable in the UI. - Both types of roles are available for assignment and appear in the organization roles API.
Project-level roles
Role definitions in your defineRbac() config are scope-agnostic — the same role can be assigned at the organization level or directly to a user at the project level. You don't need to change your config to support project-level roles.
See Roles & Permissions — Project-level roles for assignment and permission merging details.
The type-safe helpers work with both modes:
// Without org — project-level permissions only
const { can, is } = await ac.getUserRbac(userId);
can(rbac.permissions.billing.manage); // checks project role
// With org — merged permissions
const { can, is } = await ac.getUserRbac(userId, orgId);
can(rbac.permissions.billing.manage); // project role ✓
can(rbac.permissions.documents.write); // org role ✓
CI/CD integration
Add role syncing to your deployment pipeline so RBAC configuration is always in sync with your code:
.github/workflows/deploy.yml
- name: Sync RBAC roles
run: npx @auth-gate/rbac sync --apply
env:
AUTHGATE_API_KEY: ${{ secrets.AUTHGATE_API_KEY }}
AUTHGATE_URL: ${{ secrets.AUTHGATE_URL }}
Run the dry-run with --strict in pull request checks to catch warnings early:
.github/workflows/pr.yml
- name: Preview RBAC role changes
run: npx @auth-gate/rbac sync --strict
env:
AUTHGATE_API_KEY: ${{ secrets.AUTHGATE_API_KEY }}
AUTHGATE_URL: ${{ secrets.AUTHGATE_URL }}
The dry-run exits with a non-zero code if validation fails. With --strict, a plan that would archive a role with members assigned also exits non-zero, so that case is caught before reaching production. --strict keys off that condition specifically; it is not a general warnings-as-errors switch.