Skip to main content

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

FieldTypeDescription
idUUID v7Unique role identifier
namestringHuman-readable label (e.g. "Support Manager")
descriptionstringPurpose of the role
permission_idsstring[]Atomic permission strings assigned to the role
scopeenumtenant (default) or platform (Platform Owner only)
tenant_idUUIDOwning tenant; null for platform-scoped roles
created_byUUIDUser who created the role
created_atISO 8601Creation timestamp

Built-in Roles

Three roles are pre-created for every new tenant and cannot be deleted:

RolePermission setNotes
OwnerWildcard * — full access within the tenantOne Owner per tenant. Cannot be deleted, blocked, or stripped of the wildcard.
AdminAll system permissions except system.*Can manage users, roles, modules, and billing.
MemberRead-only on modules the user is assigned toDefault 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:

PatternGrants
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:

ChannelMechanismDepends on Kafka
Primary (async)auth.role.changed event → Gateway invalidates Valkey cacheYes
Fallback (sync)INCR permission_version:{tenantId} on every role change — checked on every requestNo

Permission revocation takes effect on the next request, regardless of whether Kafka is available.


Three-Layer Enforcement

LayerWhereWhat it does
UI ShellBrowserHides menu items and buttons. UX only — not a security boundary.
API GatewayNetwork edgehasPermission() on every authenticated request. Hard block. Returns 403.
PostgreSQL RLSDatabaseRow-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

ParameterDefaultOverride
Max roles per user50RBAC_MAX_ROLES_PER_USER
Max permissions per role1 000RBAC_MAX_PERMISSIONS_PER_ROLE
Max roles per tenant500RBAC_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

EventEffect
Module deactivatedPermissions remain active. Role builder shows group with "module disabled" badge.
Module soft-deletedPermissions archived. Role builder hides them. Roles show ⚠ stale permissions warning.
Archived permission + GatewayhasPermission() returns false even if role still contains it.
Module reinstalledPermissions automatically un-archived. Roles fully restored.