Permissions Model
SeptemCore uses granular, attribute-based RBAC. Every operation a user can perform is governed by an atomic permission string. Modules register their own permissions at install time, and the API Gateway enforces every check on every request — no exceptions.
Permission Format
All permission identifiers follow a three-part dot-separated structure:
{module}.{resource}.{action}
| Segment | Description | Examples |
|---|---|---|
module | Slug of the owning module or kernel primitive | crm, hr, billing, users |
resource | Noun representing the data resource | contacts, deals, invoices, roles |
action | Operation on the resource | read, create, update, delete, export |
Wildcard shorthand — a module slug followed by .* grants every
permission in that module:
crm.* → all CRM permissions
hr.* → all HR permissions
The Gateway resolves hasPermission('crm.contacts.read') by checking:
- Exact match
crm.contacts.readin the user's effective permission set. - Wildcard
crm.*— if present, the check passes immediately.
System Permissions (Kernel-Owned)
The kernel pre-registers a fixed set of system permissions. These are always available in the role builder 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 attempting
to register permissions with these prefixes receives
400 Bad Request — "Permission must start with module slug".
Custom Roles
Roles are tenant-scoped collections of permissions. Every tenant manages its own role catalogue independently — roles do not cross tenant boundaries.
| Role field | Description |
|---|---|
id | UUID v7 — unique identifier |
name | Human-readable label (e.g. "Support Manager") |
description | Purpose of the role |
permission_ids | Array of atomic permission strings assigned to the role |
scope | tenant (most roles) or platform (Platform Owner only) |
tenant_id | UUID of the owning tenant; null for platform-scoped roles |
created_by | UUID of the user who created the role |
created_at | ISO 8601 timestamp |
Built-in roles
The kernel pre-creates three roles for every new tenant:
| Role | Permission set |
|---|---|
| Owner | Wildcard * — full access to everything within the tenant |
| Admin | All system permissions except system.* |
| Member | Read-only access to modules the user is assigned to |
Built-in roles cannot be deleted, but their permission sets can be narrowed by a Platform Owner.
REST API — role management
POST https://api.septemcore.com/v1/roles
PATCH https://api.septemcore.com/v1/roles/:id
DELETE https://api.septemcore.com/v1/roles/:id
GET https://api.septemcore.com/v1/roles
GET https://api.septemcore.com/v1/permissions
POST https://api.septemcore.com/v1/users/:id/roles
DELETE https://api.septemcore.com/v1/users/:id/roles/:roleId
GET https://api.septemcore.com/v1/users/:id/permissions
Module Permission Registration
When a module is installed, it declares its custom permissions in
module.manifest.json under the permissions array:
{
"name": "@acme/crm",
"version": "1.0.0",
"permissions": [
"crm.contacts.read",
"crm.contacts.create",
"crm.contacts.update",
"crm.contacts.delete",
"crm.deals.read",
"crm.deals.manage",
"crm.reports.export"
]
}
The Module Registry validates the prefix on registration: every permission must start with the module's slug. On pass, the IAM service runs 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();
The same registerPermissions() call is safe to run multiple times and
from concurrent module instances — duplicate key errors are impossible
because each module owns its own prefix namespace.
Auto-registration via autoApi
Modules that use the dataApi manifest field to generate automatic CRUD
endpoints also receive auto-generated permissions without any manual
declaration. For a model named contacts in module crm, the kernel
automatically registers:
crm.contacts.read
crm.contacts.create
crm.contacts.update
crm.contacts.delete
These appear in the role builder under the module's group immediately after installation. No extra configuration is required.
Permission Lifecycle on Module Removal
Permissions are tightly coupled to the module's lifecycle:
| Event | Effect on permissions |
|---|---|
Module deactivated (DISABLE) | Permissions remain active. Roles keep working. The role builder shows the group with a "module disabled" badge. |
| Module soft-deleted | Permissions are marked archived. The role builder hides them. Roles that contain archived permissions display a ⚠ contains stale permissions warning. |
| Gateway + archived permission | hasPermission() for an archived key returns false, even if the role still contains it. The module is gone — the permission does nothing. |
| Module reinstalled | Permissions with matching keys are automatically un-archived. Roles are silently restored to full functionality. |
JWT and Permission Resolution
Permissions are NOT stored in the JWT. With hundreds of atomic
permissions across dozens of modules, embedding them would inflate the
token to 5–10 KB, breaking the 4 KB cookie limit and increasing per-request
bandwidth. Only roles[] are carried in the JWT — following the same
approach as Stripe, Auth0, and Okta.
JWT payload (security-relevant fields)
| Field | Type | Description |
|---|---|---|
sub | UUID | User ID |
tenant_id | UUID | Tenant scope |
roles | string[] | Role names — resolved to permissions server-side |
iat / exp | Unix timestamps | Issued at / expires in 15 min |
Server-side resolution pipeline
1. Client sends request with JWT (contains roles[])
2. Gateway extracts roles[] from JWT claims
3. Gateway reads Valkey: GET permission_version:{tenantId}
├─ version matches cached value → Cache HIT
│ └─ use cached permissions[]
└─ version mismatch or MISS → gRPC call to IAM service
└─ IAM resolves roles → permissions[]
→ write to Valkey (key: permissions:{tenantId}:{roles_hash})
TTL: 5 minutes, version stamped
4. Gateway: hasPermission(required_permission)
├─ YES → forward to upstream service
└─ NO → 403 Forbidden (RFC 9457: /problems/forbidden)
| Parameter | Value |
|---|---|
| Valkey key | permissions:{tenantId}:{sorted_roles_hash} |
| Cache TTL | 5 minutes |
| Local in-process cache | LRU, TTL 60 s (GATEWAY_LOCAL_PERMISSION_CACHE_TTL_SEC) |
Dual cache invalidation
Two independent paths ensure permissions are revoked instantly even if Kafka is unavailable:
| Channel | Mechanism |
|---|---|
| Primary (async) | Event Bus emits auth.role.changed → Gateway invalidates Valkey cache immediately. |
| Fallback (sync) | IAM increments INCR permission_version:{tenantId} in Critical Valkey on every role or permission change. Gateway reads this counter on every request (~0.1 ms). A version mismatch triggers a force-reload — independent of Kafka. |
| Bulk operations | For mass role assignments, IAM emits a single auth.roles.bulk_changed event and the Gateway debounces reloads within a 50 ms window to prevent thundering herd. |
Owner wildcard: If the user carries the Owner role, the Gateway detects
* and bypasses the Valkey lookup entirely.
Fail-closed: If both IAM and Valkey are unavailable and there is no
stale local cache entry (cold start), the Gateway returns
503 Service Unavailable rather than allowing the request through.
Three-Layer Enforcement
Permissions are enforced at three independent layers. Bypassing one does not grant access:
Layer 1 — UI Shell UI hides menu items and buttons (UX only, not security)
Layer 2 — API Gateway hasPermission() on every request (hard block, 403)
Layer 3 — PostgreSQL RLS Row-Level Security filters rows by tenant_id
Layer 2 is the security boundary. Even if a user constructs a raw HTTP
request manually, the Gateway checks the JWT, resolves permissions, and
returns 403 Forbidden if the permission is absent.
SDK — RBAC Hooks
import { useRBAC, requireRole } from '@platform/sdk-auth';
// Hook — read permission state reactively
export function ManageContactsButton() {
const { hasPermission } = useRBAC();
if (!hasPermission('crm.contacts.create')) {
return null; // hide, do not disable
}
return <button onClick={createContact}>Add contact</button>;
}
// Higher-order component guard for route-level protection
export const ManagePage = requireRole('crm.contacts.manage')(ContactsPage);
useRBAC() reads the permissions resolved on login and stored in the
Zustand singleton — no additional network request per render. If the role
changes server-side, the next token refresh propagates the new roles[]
and the Gateway version counter ensures the cache is re-fetched.
Limits
| Parameter | Value | Override |
|---|---|---|
| Permissions per module | Unlimited | — |
| Permission key max length | 128 characters | — |
| Roles per tenant | Unlimited | — |
| Roles per user | 50 | — |
| Permission cache TTL (Valkey) | 5 minutes | — |
| Local Gateway cache TTL | 60 seconds | GATEWAY_LOCAL_PERMISSION_CACHE_TTL_SEC |
| Max JWT size | 8 KB | — |