Skip to main content

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}
SegmentDescriptionExamples
moduleSlug of the owning module or kernel primitivecrm, hr, billing, users
resourceNoun representing the data resourcecontacts, deals, invoices, roles
actionOperation on the resourceread, 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:

  1. Exact match crm.contacts.read in the user's effective permission set.
  2. 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 fieldDescription
idUUID v7 — unique identifier
nameHuman-readable label (e.g. "Support Manager")
descriptionPurpose of the role
permission_idsArray of atomic permission strings assigned to the role
scopetenant (most roles) or platform (Platform Owner only)
tenant_idUUID of the owning tenant; null for platform-scoped roles
created_byUUID of the user who created the role
created_atISO 8601 timestamp

Built-in roles

The kernel pre-creates three roles for every new tenant:

RolePermission set
OwnerWildcard * — full access to everything within the tenant
AdminAll system permissions except system.*
MemberRead-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:

EventEffect on permissions
Module deactivated (DISABLE)Permissions remain active. Roles keep working. The role builder shows the group with a "module disabled" badge.
Module soft-deletedPermissions are marked archived. The role builder hides them. Roles that contain archived permissions display a ⚠ contains stale permissions warning.
Gateway + archived permissionhasPermission() for an archived key returns false, even if the role still contains it. The module is gone — the permission does nothing.
Module reinstalledPermissions 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)

FieldTypeDescription
subUUIDUser ID
tenant_idUUIDTenant scope
rolesstring[]Role names — resolved to permissions server-side
iat / expUnix timestampsIssued 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)
ParameterValue
Valkey keypermissions:{tenantId}:{sorted_roles_hash}
Cache TTL5 minutes
Local in-process cacheLRU, 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:

ChannelMechanism
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 operationsFor 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

ParameterValueOverride
Permissions per moduleUnlimited
Permission key max length128 characters
Roles per tenantUnlimited
Roles per user50
Permission cache TTL (Valkey)5 minutes
Local Gateway cache TTL60 secondsGATEWAY_LOCAL_PERMISSION_CACHE_TTL_SEC
Max JWT size8 KB