Skip to content

Security model

The NexisOmni backend hardens two things at once: the auth material that proves who you are (signing keys, refresh cookies) and the authority that decides what you may do (verb-based permissions, branch scoping), plus the secrets that make database-per-tenant work. This page orients you across those pieces and points to the two decision records that define them in full: NexisOmni/docs/adr/0014-auth-and-secret-hardening.md and NexisOmni/docs/adr/0016-enterprise-security-model.md.

For how the two auth planes and the tenant resolution flow fit together, start with Auth and tenancy; this page is the security-hardening view of the same system.

Access tokens are signed with an RSA key pair using RS256. The signer holds the private key; the bearer handlers validate with the public key, so a party that only needs to check a token never holds a key that could mint one.

  • A JwtKeyRing owns the current signing key plus a set of validation keys, each selected by its kid header.
  • Rotation is additive. Roll by adding the new private key as the signer and keeping the previous public key in the validation set until the longest-lived token it signed has expired (access tokens live around 15 minutes), then drop it. No flag-day, no mass invalidation.
  • alg is pinned to RS256 on both bearer schemes, which closes the algorithm-confusion downgrade (an HS256 or none token can never be validated against the RSA public key).
  • Development and tests are zero-config. With no key configured, a non-production host generates an ephemeral in-process RSA key (kid = "dev"). Production must supply a key; a missing key throws at startup rather than silently falling back to an ephemeral one.

Each plane issues tokens under its own audience, and each bearer scheme accepts only its own. A token minted for one plane is rejected on the other. This split is unchanged by the key-ring work above and by the permission model below.

Plane Audience purpose Tenant-ID header
Admin (/admin/*) Platform operations against the central DB Never sent
Tenant (/identity/*, etc.) Operations inside one tenant’s own DB Sent on every request, including login

There is also a third, narrow audience for two-step login: a 2FA challenge token carries a dedicated nexisomni-2fa audience plus a purpose claim, so it can never validate as an access token.

The access token lives in memory on the client and is short-lived. The refresh token is delivered as an httpOnly, Secure cookie scoped to its plane, so page scripts cannot read it and it is confined to the plane that issued it.

Secrets at rest: connection strings and the data-protection key ring

Section titled “Secrets at rest: connection strings and the data-protection key ring”

Under database-per-tenant, the central DB’s Tenants.ConnectionString column is the highest-value secret in the system, because each row holds a working host, database, username, and password for one tenant DB. That column is encrypted at rest.

  • Encryption is applied through an EF Core value converter backed by an IDataProtector, so every write encrypts and every read decrypts with no call-site changes. The schema stays a plain text column, so no migration was needed.
  • The read side is tolerant: a value that fails to decrypt (for example a legacy plaintext row) is returned as-is, so enabling encryption never bricks an existing row.
  • Because the ciphertext is non-deterministic, there are no equality or filter queries on that column. Tenant deduplication uses the unique company-name index plus a physical database-existence probe instead.

Permissions resolved per request, never in the token

Section titled “Permissions resolved per request, never in the token”

Authority is a set of verb-based permissions (for example sales.process, inventory.transfer) granted to roles; a user’s effective permissions are the union over their roles. Roles are containers, not the authority itself.

  • Permissions are resolved server-side on every request, never baked into the JWT. A grant or revoke takes effect on the very next request, and the token stays small. This is the same out-of-token model the module entitlements use.
  • Endpoints gate through a dynamic policy that re-declares the full tenant-plane requirement set (scheme, plane claim, and the cross-tenant binding requirement) alongside the permission requirement, so a permission gate can never silently drop the tenant binding.
  • The client learns its effective permissions from GET /me/permissions for UI gating and offline caching; the server remains authoritative and re-checks on each request.
  • global.admin is a wildcard that short-circuits every permission and branch-scope check; in v1 the seeded Manager role holds it.

Users are bound to the branches they may operate through a join table with an IsPrimary home-branch flag, rather than a single branch column. This models area managers and floaters who cover some but not all stores.

  • Online requests are gated by an endpoint filter: without an assignment for the target branch the request is rejected with 403 branch_forbidden (global.admin bypasses).
  • Offline sync is branch-checked server-side too. The endpoint filter cannot see inside a sync batch envelope, so each item’s branch is re-verified when the batch is replayed. The offline channel is trusted for discount pre-authorization but not for actor identity or branch, which are re-verified on replay.

Both planes share a common identity configuration and several protective measures.

  • Lockout and password policy: a 15-minute lockout after 5 failed attempts (allowed for new users), with an upper, lower, digit, and minimum-length-8 password rule.
  • Soft deactivation: an inactive user resolves to zero permissions and no branch access immediately, because the per-request resolver short-circuits on the active flag rather than waiting for the token to expire. Deactivating a user also revokes their refresh tokens.
  • Two-factor authentication is authenticator-app (TOTP) based, so it needs no email. A password success for a 2FA-enabled user returns the short-lived challenge token described above, redeemed at the 2FA login step with a TOTP or recovery code. The challenge step honours the same lockout, so the second factor is not an unthrottled guessing surface, and there is no server-side 2FA state.

Security-governance changes are audited automatically. A SaveChanges interceptor writes an audit-log row, in the same transaction, for every tracked change to an auditable entity (today the role-permission and user-branch pivots), with sensitive properties such as the password hash redacted from the captured detail. The actor is resolved so that an explicitly set actor wins over the request principal, which keeps attribution correct during offline-sync replay and background jobs. Mutations that must be audited go through the change tracker rather than set-based ExecuteUpdate/ExecuteDelete, which would bypass the interceptor.

  • Auth and tenancy plumbing: Auth and tenancy.
  • Full decision records: NexisOmni/docs/adr/0014-auth-and-secret-hardening.md (RS256 key ring and connection-string encryption) and NexisOmni/docs/adr/0016-enterprise-security-model.md (permissions, branch scope, audit, lockout, and 2FA). The role tiers and email-based flows referenced there are refined by the later ADRs each one links to.