Roles, Rights & the Authorization Engine
The rules/roles/rights system: every authorization decision in a multi-tenant platform must be tenant-aware.
The rules/roles/rights system. Every authorization decision in a multi-tenant platform must be tenant-aware: not "is this user an admin?" but "is this user an admin in this tenant?" One forgotten scope is a data leak.
Model: tenant-scoped RBAC + ABAC overlay
- RBAC for the common case: a small set of roles grant permission bundles mapped to product concepts.
- ABAC for the cross-cutting attributes that RBAC alone can't express: tenant, classification (OPEN/secret), cell (wargame), case ownership, residency.
Every decision is (subject, action, resource, context) → allow/deny, evaluated by one engine (packages/auth) and enforced at the API before the handler. UI gates mirror it but are never the source of truth.
Two role spaces (keep them separate)
Tenant roles (data plane)
| Role | Bundle |
|---|---|
| Owner | manage org, billing, members, all surfaces |
| Admin | manage members + sources + settings; no billing |
| Analyst | read/write cases, reports (draft), run agents, explore |
| Viewer | read-only |
| Faculty (wargame) | author scenarios, umpire, approve adjudications, sign AAR |
Control-plane roles (owner console — product §17)
Owner / Ops / Finance / Support / Read-only — capabilities provision, decommission, billing, team, breakglass, release, suspend. These are insigz-staff roles, never customer roles, in a different policy domain.
Permission bundles (not atomic perms in the UI)
Define ~30 atomic permissions, but expose bundles that map to real product actions so admins reason about roles, not checkboxes:
case:read case:write case:close
report:draft report:publish // publish is privileged (Q8)
source:connect source:configure
agent:run watch:create proposal:decide
member:invite member:role billing:manage settings:writereport:publish, proposal:decide, member:role, billing:manage are privileged — gated + often step-up MFA.
Enforcement layers (defense in depth)
1. API policy engine — the authoritative allow/deny (before handler)
2. Postgres RLS — even if a query forgets a filter, the DB returns only permitted rows
3. Retrieval filters — RAG/vector queries inject tenant+classification (ai/31)
4. UI gates — disable/hide controls (UX only; mirrors layer 1)The backend is the source of truth; UI gates must mirror backend semantics, never replace them.
Three principal types (not two)
Authorization must distinguish three kinds of subject — the engine keys on subject.kind, not only subject.role:
- tenant user — a customer analyst/faculty; has a tenant
role+clearance+ optionalcell. - control-plane operator — insigz staff in the owner console (Owner/Ops/…); never has a tenant role.
- operator-in-tenant (break-glass) — an insigz operator acting inside a tenant via a break-glass token (
83). This is a distinct principal: it carries no tenant role, is read-only, and is explicitly denied write/export/publish regardless of any convention. Without modeling this,authorize()would either deny everything (no role) or need an undefined special case.
Policy engine shape
type Decision = { allow: boolean; reason: string };
function authorize(subject, action, resource, ctx): Decision {
if (subject.tid !== resource.tid) return deny('cross-tenant'); // ABAC: hard tenant boundary
// (3) break-glass operator-in-tenant: read-only, no role, hard write/export deny.
if (subject.kind === 'breakglass') {
if (WRITE_OR_EXPORT.has(action)) return deny('break-glass is read-only');
return READ_ACTIONS.has(action) ? allow() : deny('break-glass scope: read only');
}
if (!roleGrants(subject.role, action)) return deny('role lacks ' + action);
// clearance is an ORDERED level, not a set-membership: clearanceRank dominates classRank.
if (resource.classification && clearanceRank(subject.clearance) < classRank(resource.classification))
return deny('classification');
// cell: "no cell" must NOT mean "see everything" (mirrors the RLS predicate in 82).
if (resource.cell && resource.cell !== subject.cell && !subject.isFaculty) return deny('cell isolation');
if (PRIVILEGED.has(action) && !ctx.stepUpMfa) return deny('step-up required');
return allow();
}This mirrors RLS (82) exactly: clearance is an ordered dominance check (not clearance.includes(...)), and cell isolation never falls open on a null cell. Consider a policy DSL/engine (OPA/Cedar/Oso) so policies are testable, versioned, and decoupled from handlers. Every deny returns a reason.
Custom roles (going upmarket)
Enterprises will want custom roles ("Compliance Auditor"). Support role templates + tenant extensions: ship base templates, let a tenant clone+edit within a defined envelope, and provide a "compare roles / permission diff" view to prevent near-duplicate role sprawl. Keep enforcement server-side regardless.
SSO group → role mapping
Per tenant, map IdP groups/claims to roles (JIT at login or continuous SCIM sync). Decide overwrite-vs-merge with manually-assigned roles, and what happens when a group disappears (deprovision).
Audit
Every privileged action and every deny-that-mattered writes an audit row (actor, action, resource, decision, reason, ts). Break-glass is the most-flagged (product §17).
Gaps to close
- Choose policy engine (hand-rolled vs. OPA/Cedar/Oso) and freeze the atomic-permission list + bundles.
- Define custom-role envelope (what tenants may change) and the diff view.
- Specify classification/clearance values and how
cellclearance is issued in wargame. - Decide which actions require step-up MFA.