Auth and RBAC
Identity, the role and capability model, route guards, and tenant isolation.
Two layers: identity vs. authorization
Unphish v2 separates identity (who you are) from authorization (what you can do):
- Identity is owned by Authentik. Authentik holds passwords, MFA secrets, recovery flows, SSO federation.
- Authorization is owned by the Unphish application. The app database holds memberships, roles, capabilities, and scope restrictions.
This separation matters: rotating a password, resetting MFA, or federating to a customer SSO provider is an Authentik operation. Granting a user access to a client, promoting them to admin, or revoking their analyst role is a Unphish-app operation.
Identity (Authentik)
Authentik is the OIDC provider. Configuration:
- Separate Authentik application / provider pair per environment (local, staging, production).
- Redirect URI:
${NEXT_PUBLIC_APP_URL}/api/auth/callback/authentik. - Logout / end-session return URL:
${NEXT_PUBLIC_APP_URL}/signin. - Issuer: no trailing slash; includes the app slug, e.g.,
https://auth.example.com/application/o/unphish. - Required scopes:
openid,email,profile. Authentik group claims may be captured as identity metadata, but app roles come from app-owned tables. - Branded login, recovery, MFA, and email templates.
- SMTP, optional MFA policy, admin recovery, backups, monitoring, and TLS configured on the Authentik host.
Authentik runs outside Vercel with its own Postgres / Redis / TLS / SMTP / backups / monitoring / MFA policy. This is non-negotiable; Authentik is not a Vercel function.
App-owned authorization
App tables hold all authorization state:
public.users— Authentik subject (sub) plus profile (email, name, image, last login, disabled state, system_role).public.user_identities— provider mappings; idempotent upsert by email on login.public.user_sessions— product session created after OIDC callback. Signed cookie; HttpOnly; Secure in production.public.organizationsandpublic.clients— tenant containers.public.user_organizations— membership in an organization with a role.public.user_client_access— fine-grained client access (optional; if missing, user can see all org clients).public.user_brand_access— brand-level grants (e.g., a reviewer can approve only Brand A and Brand B).public.team_members— mirror of org/role for dashboard team views.public.team_invitations— app-owned invite tokens (14-day expiry, sent via Postmark).public.audit_log(oractivity_log) — auth-sensitive events.
What app tables explicitly do not hold:
- Password hashes.
- MFA secrets.
- OAuth client secrets.
- Recovery tokens.
There is no "credential account" table in the app database. A SQL injection on the app database does not yield credentials.
Roles
The platform uses normalized, lowercase role identifiers in storage and APIs:
| Role | Scope | Capabilities |
|---|---|---|
owner | Organization or partner | Full tenant control: billing, configuration, team, destructive actions. |
admin | Organization, partner, or client | Manage users, clients, policies, integrations, reports, and most operational settings. |
analyst | Partner or client operations | Review, triage, enrich, enforce, verify, escalate, and close cases. |
viewer | Partner or client | Read dashboards, cases, reports, intelligence, and audit records allowed by scope. |
client_approver | Client | Approve or reject enforcement requests; provide requested information. |
api | Client or integration | Programmatic access limited by API key scopes. |
Legacy v1 labels (Admin, Analyst, Viewer, Senior Analyst) are mapped to canonical roles before persistence. Senior Analyst maps to analyst unless we deliberately introduce a future role.
System role: separate from organization role
In addition to organization role, every user has a system_role:
system_role | Meaning |
|---|---|
user | Customer or partner user. No platform-level access. |
staff | Unphish staff. Hub access for read-only operational visibility. |
admin | Platform operator (Engram or Unphish admin). Hub write access, secrets, audit, support preview. |
system_role is distinct from organization role. A user can be admin of their organization and user at the system level (typical for a partner or client admin). Conversely, a user can be analyst in the platform organization and admin at the system level (an Engram engineer). The two values combine in route guards.
Internal-only auto-promotion
Users invited with internal email domains (@unphish.com, @engram.org, @engram.com by default) and an organization role of admin or owner are automatically promoted to system_role: admin. Partner and client admins do not auto-promote.
Capabilities
For finer-grained authorization than role-based, the platform uses named capabilities:
| Capability | What it gates |
|---|---|
hub.view | Hub access. |
client.onboard | Create / edit clients. |
impersonation.start | Support preview. |
provider_secrets.manage | Hub secrets writes. |
enforcement.submit_live | Production enforcement (gated separately from staging). |
workbench.view | Workbench access. |
workbench.mutate_staging | Staging sandbox mutations. |
delivery_board.manage | Customer-visible Kanban / requests. |
status.manage | External status page. |
architecture.manage | Architecture diagram. |
Capabilities are derived from system_role plus organization role plus explicit grants. The combination keeps route guards readable: the dashboard checks analyst or higher in the active organization; Hub checks system_role >= staff; live enforcement checks enforcement.submit_live.
Route guards
Each route family enforces guards consistently at the layout level:
| Route | Guard |
|---|---|
/hub/* | system_role >= staff; sections may have additional capability requirements. |
/admin/* | system_role >= staff plus admin capabilities. |
/dashboard/* | analyst or higher in the active organization. |
/partner/* | Partner membership (organization is a partner; user has role in that organization). |
/client/* | Client membership; further filtered by brand access where configured. |
/workbench/* | system_role >= staff plus workbench.view. |
/api/* | Authenticated session or valid API key with appropriate scope. |
Unauthenticated requests redirect to /signin. Authenticated-but-unauthorized requests render an explicit 403 with no data leak; they do not redirect to /dashboard and silently scrub.
Tenant isolation
Tenant isolation is structural:
- Every tenant-scoped record carries
organization_id. - Every client-scoped record additionally carries
client_id. - Query filters always include the active organization (and client where applicable) at the data-access layer.
- API keys are scoped to their issuing client/organization; their requests are filtered the same way.
- Cross-tenant access is impossible by construction. There is no global "see all cases" query path.
Staff/global access is the only exception, and it is explicit:
- An Engram or Unphish admin can use support preview (impersonation) to act inside a target tenant's scope.
- Support preview is gated by the
impersonation.startcapability. - Preview sessions show a persistent banner with the real actor, target user, and time remaining.
- Every action in a preview is audited under both the real actor and the target.
- Preview sessions have a max 60-minute expiry and require an explicit reason.
There is no "act as customer" path that does not produce an audit trail.
API keys
Programmatic access uses API keys:
- Issued per
api_application(per client or organization). - Scoped to specific resource families (cases, evidence, enforcements, watchlist, reports).
- Rate-limited.
- Revocable; revocation is immediate.
- Last-used timestamp tracked.
- Created via the appropriate admin surface; the secret is shown once and never retrievable again. If lost, issue a new one.
API keys do not have personal-user identity; their requests are tagged with the application and scope they belong to. Audit entries record the application, not a synthetic user.
SSO federation
Clients can configure SSO via verified email domains. Authentik handles the federation; the app records:
- The verified domain.
- The provider (Auth0, Okta, Microsoft Entra ID, Google, custom OIDC, custom SAML).
- The verification state (pending, verified, disabled).
- The activation timestamp.
- Audit history.
Domain verification typically requires a DNS record (TXT) before SSO is enabled. Unverified domains cannot route logins.
MFA
MFA is enforced at the Authentik level. Organizations and clients can require MFA via Authentik policies; the application surfaces the requirement but does not enforce it directly. Users without MFA enrollment cannot complete sign-in when their organization requires it.
v1 TOTP secrets are not migrated. Users re-enroll MFA in Authentik after migration.
Session model
- Sign-in completes at the Authentik OIDC callback (
/api/auth/callback/authentik). - The app creates a
user_sessionsrow and issues a signed cookie. - Cookies are HttpOnly and Secure (in production), with a configured expiry.
- Sign-out terminates the session row, clears the cookie, and routes the user to Authentik's end-session URL, which returns them to
/signin. - Revoked memberships, sessions, and API keys cannot access protected routes — checked at every request.
What deliberately does not exist
- Local password authentication. No "username + password against the app database" path. All authentication routes through Authentik.
- A fallback auth path. If Authentik is unavailable, sign-in is unavailable. Readiness goes red and operators are paged.
- A role slider for previewing other personas. v1 had this on Hub. v2 has support preview, which is capability-gated, banner-visible, time-bounded, and audited.