Roles & Permissions (RBAC)
SeptemCore RBAC is granular and attribute-based. Roles are
tenant-scoped collections of atomic permission strings. The API Gateway
enforces every permission check on every authenticated request — no
exceptions. Permissions are never stored in the JWT; only roles[]
are, and the Gateway resolves permissions server-side from a Valkey
cache on each call.
Role Model
| Field | Type | Description |
|---|---|---|
id | UUID v7 | Unique role identifier |
name | string | Human-readable label (e.g. "Support Manager") |
description | string | Purpose of the role |
permission_ids | string[] | Atomic permission strings assigned to the role |
scope | enum | tenant (default) or platform (Platform Owner only) |
tenant_id | UUID | Owning tenant; null for platform-scoped roles |
created_by | UUID | User who created the role |
created_at | ISO 8601 | Creation timestamp |
Built-in Roles
Three roles are pre-created for every new tenant and cannot be deleted:
| Role | Permission set | Notes |
|---|---|---|
| Owner | Wildcard * — full access within the tenant | One Owner per tenant. Cannot be deleted, blocked, or stripped of the wildcard. |
| Admin | All system permissions except system.* | Can manage users, roles, modules, and billing. |
| Member | Read-only on modules the user is assigned to | Default role for invited users. |
Platform Owner is a separate, platform-scoped role created only at
first deployment via kernel-cli setup. It is never issued through the
REST API.
System Permissions
Kernel-registered permissions are always available regardless of installed modules:
# Tenants
tenants.list | tenants.create | tenants.update | tenants.block | tenants.delete
# Modules
modules.list | modules.install | modules.configure | modules.uninstall
# Users (within tenant scope)
users.list | users.create | users.update | users.delete | users.impersonate
# Roles & access
roles.list | roles.create | roles.update | roles.delete | roles.assign
# Audit
audit.read | audit.export | audit.delete
# Billing
billing.view | billing.change | billing.export
# Settings
settings.read | settings.update
# Platform-only — not assignable to tenant roles
system.shutdown | system.backup | system.restore
system.* and platform.* prefixes are reserved. A module trying to
register permissions with these prefixes receives 400 Bad Request.
Custom Module Permissions
When a module is installed its permission_ids from module.manifest.json
are automatically registered in IAM via an idempotent upsert:
INSERT INTO permissions (key, module_id, description, created_at)
VALUES ($1, $2, $3, NOW())
ON CONFLICT (key) DO UPDATE
SET description = EXCLUDED.description,
updated_at = NOW();
Every permission key must start with the module slug. The Module Registry validates this at install time:
{
"name": "@acme/crm",
"permissions": [
"crm.contacts.read",
"crm.contacts.create",
"crm.contacts.update",
"crm.contacts.delete",
"crm.deals.manage",
"crm.reports.export"
]
}
Modules using autoApi (auto-generated CRUD endpoints) receive
auto-generated permissions without any manual declaration: for a model
contacts in module crm the kernel registers crm.contacts.read,
crm.contacts.create, crm.contacts.update, and crm.contacts.delete
automatically.
Wildcard Access
Single wildcard grants all permissions for the module or the entire tenant:
| Pattern | Grants |
|---|---|
crm.* | All CRM module permissions |
hr.* | All HR module permissions |
* | Everything (Owner only) |
Resolution: hasPermission('crm.contacts.read') checks exact match
first, then crm.*. Either match grants access. The Gateway skips the
Valkey lookup entirely when the user carries the Owner (*) role.
Role CRUD
Create role
POST https://api.septemcore.com/v1/roles
Authorization: Bearer <access_token>
Content-Type: application/json
{
"name": "Support Manager",
"description": "Can read contacts and create deals",
"permission_ids": [
"crm.contacts.read",
"crm.deals.manage"
]
}
Response 201 Created:
{
"id": "01j9p6kx2e00000000000000",
"name": "Support Manager",
"description": "Can read contacts and create deals",
"permission_ids": ["crm.contacts.read", "crm.deals.manage"],
"scope": "tenant",
"tenant_id": "01j9p3kz5f00000000000000",
"created_by": "01j9p3kx2e00000000000000",
"created_at": "2026-04-15T10:00:00Z"
}
List roles
GET https://api.septemcore.com/v1/roles
Authorization: Bearer <access_token>
Supports cursor pagination (?cursor=&limit=). Requires
roles.list permission.
Update role
PATCH https://api.septemcore.com/v1/roles/:id
Authorization: Bearer <access_token>
Content-Type: application/json
{
"permission_ids": [
"crm.contacts.read",
"crm.contacts.create",
"crm.deals.manage"
]
}
On update, IAM increments permission_version:{tenantId} in Valkey.
The Gateway detects the version mismatch on the next request and
force-reloads the role → permissions mapping. Permission changes take
effect within one request for all sessions.
Delete role
DELETE https://api.septemcore.com/v1/roles/:id
Authorization: Bearer <access_token>
Built-in roles (owner, admin, member) cannot be deleted — returns
403 Forbidden.
List all permissions
GET https://api.septemcore.com/v1/permissions
Authorization: Bearer <access_token>
Returns all registered permissions for the current tenant, grouped by module. Used to populate the role builder UI.
Assigning Roles to Users
Assign a role
POST https://api.septemcore.com/v1/users/:id/roles
Authorization: Bearer <access_token>
Content-Type: application/json
{
"role_id": "01j9p6kx2e00000000000000"
}
Revoke a role
DELETE https://api.septemcore.com/v1/users/:id/roles/:roleId
Authorization: Bearer <access_token>
Get effective permissions
Returns the merged permission set from all roles assigned to the user:
GET https://api.septemcore.com/v1/users/:id/permissions
Authorization: Bearer <access_token>
{
"user_id": "01j9p3kx2e00000000000000",
"roles": ["support-manager", "member"],
"permissions": [
"crm.contacts.read",
"crm.contacts.create",
"crm.deals.manage"
]
}
Permission Resolution Pipeline
1. Client sends request with JWT (contains roles[])
2. Gateway reads version counter: GET permission_version:{tenantId} (~0.1 ms)
├─ version matches local cache → use local LRU (TTL 60 s)
└─ mismatch or miss
→ GET permissions:{tenantId}:{roles_hash} from Valkey
├─ HIT (TTL 5 min) → use cached permissions
└─ MISS → gRPC ValidateToken + role→permission resolution
→ write to Valkey (TTL 5 min) + update version
3. hasPermission(required_permission)
├─ YES → forward request to upstream Go service
└─ NO → 403 Forbidden (RFC 9457)
Dual invalidation channels:
| Channel | Mechanism | Depends on Kafka |
|---|---|---|
| Primary (async) | auth.role.changed event → Gateway invalidates Valkey cache | Yes |
| Fallback (sync) | INCR permission_version:{tenantId} on every role change — checked on every request | No |
Permission revocation takes effect on the next request, regardless of whether Kafka is available.
Three-Layer Enforcement
| Layer | Where | What it does |
|---|---|---|
| UI Shell | Browser | Hides menu items and buttons. UX only — not a security boundary. |
| API Gateway | Network edge | hasPermission() on every authenticated request. Hard block. Returns 403. |
| PostgreSQL RLS | Database | Row-Level Security filters rows by tenant_id. Cannot be bypassed by application code. |
Layer 2 is the security boundary. A raw HTTP request constructed by an attacker still passes through the Gateway, which checks the JWT and resolves permissions before forwarding.
RBAC Limits
| Parameter | Default | Override |
|---|---|---|
| Max roles per user | 50 | RBAC_MAX_ROLES_PER_USER |
| Max permissions per role | 1 000 | RBAC_MAX_PERMISSIONS_PER_ROLE |
| Max roles per tenant | 500 | RBAC_MAX_ROLES_PER_TENANT |
Exceeding any limit returns 400 Bad Request:
{
"type": "https://api.septemcore.com/problems/rbac-limit-exceeded",
"status": 400,
"detail": "Role limit exceeded: tenant already has 500 roles."
}
SDK
import { useRBAC, requireRole } from '@platform/sdk-auth';
// Reactive hook — reads from the Zustand singleton; no network call per render
export function ExportButton() {
const { hasPermission } = useRBAC();
if (!hasPermission('crm.reports.export')) {
return null; // hide, not disable
}
return <button onClick={exportReport}>Export</button>;
}
// HOC — route-level guard
export const SettingsPage = requireRole('settings.update')(SettingsComponent);
// Manage roles programmatically (admin actions)
import { kernel } from '@platform/sdk-core';
// Create a role
const role = await kernel.auth().createRole({
name: 'Viewer',
permission_ids: ['crm.contacts.read', 'crm.deals.read'],
});
// Assign role to user
await kernel.auth().assignRole(userId, role.id);
// Get user's effective permissions
const { permissions } = await kernel.auth().getUserPermissions(userId);
Permission Lifecycle on Module Removal
| Event | Effect |
|---|---|
| Module deactivated | Permissions remain active. Role builder shows group with "module disabled" badge. |
| Module soft-deleted | Permissions archived. Role builder hides them. Roles show ⚠ stale permissions warning. |
| Archived permission + Gateway | hasPermission() returns false even if role still contains it. |
| Module reinstalled | Permissions automatically un-archived. Roles fully restored. |