insigz docs
Engineering

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)

RoleBundle
Ownermanage org, billing, members, all surfaces
Adminmanage members + sources + settings; no billing
Analystread/write cases, reports (draft), run agents, explore
Viewerread-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:write

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

  1. tenant user — a customer analyst/faculty; has a tenant role + clearance + optional cell.
  2. control-plane operator — insigz staff in the owner console (Owner/Ops/…); never has a tenant role.
  3. 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 cell clearance is issued in wargame.
  • Decide which actions require step-up MFA.