API Authentication & Authorization — JWT Overview

Status: Core logic implemented. Auth layer is functional but will require ongoing review as new endpoints are developed and edge cases (e.g. multi-permission requirements, mixed scope scenarios) are identified.


How JWT Authentication Works

Each request must include a valid JWT in the Authorization header:

Authorization: Bearer <token>

Tokens are issued on login and contain the following claims:

Tokens are signed with HS512 using a per-tenant IssuerSigningKey stored against the tenant record. This means token validation is tenant-scoped — a token signed for Tenant A cannot be validated against Tenant B's key.

Sessions are tracked server-side. On each request the middleware checks the session has not been revoked (cached for 5 minutes for performance).


Identifying the Tenant

The middleware resolves the tenant through the following priority order:

  1. tenant_guid claim inside the JWT — primary method for all authenticated requests
  2. X-Tenant header — UUID of the tenant, used when no JWT is present (e.g. anonymous endpoints like login/refresh)

For authenticated requests the tenant is resolved from the JWT itself — no extra headers are needed.

Example header for anonymous / pre-auth requests:

X-Tenant: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx

If X-Tenant is missing or the GUID does not match an active tenant, the request is rejected with 403.


Anonymous Access ([AllowAnonymous])

Endpoints decorated with [AllowAnonymous] do not require a Bearer token. However, the middleware still runs and requires X-Tenant to be present so the correct tenant context (database connection, app ID, etc.) is available to the endpoint.

Current anonymous endpoints:

Paths that bypass middleware entirely (no tenant resolution):


Permission System

Permissions are assigned to roles and stored as strings in the JWT. Endpoints declare required permissions using the [RequirePermission] attribute:

[RequirePermission(Permissions.Orders.Write)]

If the user's token does not contain the required permission the request returns 403 Forbidden.

Defined permissions:

Area Permissions
Orders orders.read, orders.write, orders.approve
Products products.read, products.write
Pricing pricing.read, pricing.write, pricing.override
Users users.read, users.write, users.manage
Inventory inventory.read, inventory.write
Documents documents.read, documents.download
Reports reports.read, reports.export
Settings settings.read, settings.write
Full full.read, full.manage

Permissions are role-based — loaded from the user's role at login and embedded in the JWT. Role permissions are cached for 10 minutes.


Access Scope

Every user has an AccessScope derived from their role:

Scope Behaviour
AccessScope.Self User can only read/write data belonging to their own entity
AccessScope.All User can access all entity data within the tenant (admin-level)

Queries are filtered automatically via the EntityFilter helper in BaseController:


Access Control Filter (AccessControlFilter)

A single global IActionFilter (Filters/AccessControlFilter.cs) is the one centralized place that enforces identity scoping against bound action arguments — both scalar parameters (e.g. long entityId route/query values) and properties on bound complex DTOs. It runs after model binding, before the action executes.

Two axes, kept deliberately separate

Elevation is a single unified gate regardless of which axis triggered it: specifying a non-own value, or selecting an elevation-gated negated join direction, both require the same authorization check.

1. [AccessControlled] — explicit configuration

[AccessControlled(AccessControlSubject.EntityId,
    Requirement = AccessRequirement.AutoPopulate,
    JoinDirectionProperty = nameof(EntityLinkage))]
public long? EntityId { get; set; }

public JoinDirection EntityLinkage { get; set; } = JoinDirection.Linked;

Decorates a property or scalar action parameter directly — works regardless of the property's actual name (no interface/wrapper-type glue needed). Configuration:

A DTO can carry as many independently-configured [AccessControlled] properties as it needs — there's no one-property-per-DTO limit, so e.g. an entity-scoped join and a document-scoped join on the same request are fully independent, each with its own Requirement/ElevatedRoles/JoinDirectionProperty.

Requirement = Ignore — full opt-out, not a convenience default

Ignore skips all scrutiny for that property: no null check, no mismatch check, and no JoinDirectionProperty elevation check either — the value passes through completely untouched regardless of caller scope. It's for a property that genuinely is EntityId/UserId-shaped (worth declaring via [AccessControlled] for self-documentation) but represents write-payload data on a database-backed entity rather than part of a query's scoping definition — e.g. DocumentRequest.UserId (assigned salesperson/owner). The correct design there is that it should be set once at creation from the creator's own id and never change again; building that properly was out of scope for now, so enforcement is deliberately turned off rather than left at a Requirement that doesn't match how the field is actually meant to work. This is a conscious, documented gap, not a forgotten one — the naming-convention fallback below never selects Ignore on its own; it's only reachable via an explicit attribute.

Ignore vs. [AccessControlIgnore] (below) — they solve different problems. Requirement = Ignore still declares the property's Subject explicitly (it is an EntityId/UserId, we know, we're choosing not to enforce it). [AccessControlIgnore] is for a property that was never an identity concern in the first place and shouldn't be considered at all.

2. Naming-convention fallback (the safety net)

Any bound property or scalar parameter literally named EntityId/EntityID/UserId/UserID (case-insensitive) and typed long/long?, with no [AccessControlled] attribute at all, automatically gets a strict default profile (Requirement = Required, no elevated roles, no join-direction pairing). This is deliberately a safety net, not a convenience — a forgotten property still gets protected rather than silently falling through with zero enforcement. The fallback never synthesizes Ignore — that leniency is opt-in only, via an explicit attribute.

This is exact-name matching only, not suffix-based. A codebase scan before this was built found concrete, shipped counterexamples that suffix matching would have broken: EntityModel.AccountManagerUserId (bound as [FromBody] on live customer/supplier create/update endpoints — "which staff member manages this account," unrelated to caller identity) and DocumentLineRequest/DocumentUpdate's EntityUserId ("entity user the line is assigned to" — which user at the target entity, not the caller). Both would be false positives under suffix matching; exact-name matching doesn't touch either.

Use [AccessControlIgnore] to opt a property/parameter out of the convention fallback for the rare case where the name coincidentally matches but it isn't a caller-identity concern at all.

Caching

Attribute discovery and convention matching are reflected once per Type (for complex object properties) or once per ParameterInfo (for scalar action parameters) and cached for the process lifetime — never re-reflected per request.

Response shape

A rejected request returns 403 Forbidden with { error: "FORBIDDEN", reason: "..." } — a request that isn't entitled to what it asked for is rejected outright, never silently rewritten to something else.


Auth Flow Summary

Request
  │
  ├─ Public path? ──► Skip middleware entirely
  │
  ├─ [AllowAnonymous]? ──► Require X-Tenant header, resolve tenant only (no user auth)
  │
  └─ Authenticated path
       │
       ├─ Extract tenant_guid from JWT
       ├─ Lookup IssuerSigningKey from TenantDomain
       ├─ Validate JWT signature + claims
       ├─ Check session not revoked
       ├─ Load UserId + UserEntityId + AccessScope from DB
       ├─ Extract entity_type claim → set context.Items["UserEntityType"]
       └─ Set context → proceed to controller
                            │
                            ├─ [RequirePermission] → 403 if missing
                            └─ AccessControlFilter → 403 on [AccessControlled]/convention-
                                                       fallback violations (EntityId/UserId
                                                       scoping), or auto-populate on AutoPopulate

Rate Limiting

The API applies rate limiting per client IP address to protect against abuse and brute-force attacks.

Policy Applies to Limit
Global All endpoints 500 requests / minute (sliding window)
Login POST /api/v1/auth/login 5 attempts / 15 minutes
AccountWrite register, reset-password, confirm 10 attempts / 15 minutes
GenerateToken GET /api/Data/GenerateToken 5 requests / minute

When a rate limit is exceeded the API returns 429 Too Many Requests.

For authentication endpoints (/auth/login, /auth/register, /auth/reset-password, /auth/confirm) an additional 2-second delay is applied on rejection to further deter brute-force attempts.

The Login policy is partitioned per IP and email address — a single attacker cannot lock out multiple accounts simultaneously at scale.


Login Response Fields

On successful login (POST /api/v1/auth/login and POST /api/v1/auth/refresh) the server returns a LoginResponse:

The GET /api/v1/auth/me endpoint returns the current user context from the live JWT:


Known Limitations / Pending Review