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:
user_guid— unique user identifiertenant_id— internal tenant IDtenant_guid— used to resolve the correct signing keyemailpermissions— list of permission strings assigned to the user's roleentity_type— either"supplier"or"customer", derived from the user's entity at login time
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:
tenant_guidclaim inside the JWT — primary method for all authenticated requestsX-Tenantheader — 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:
POST /api/v1/auth/loginPOST /api/v1/auth/refresh
Paths that bypass middleware entirely (no tenant resolution):
/swagger,/docs,/css,/api/Data/GenerateToken
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:
- Returns
nullforAllscope — no filter applied - Returns the user's
UserEntityIdforSelfscope
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 this caller allowed to ask about an identity other than their own (
EntityId/UserId), or to select a join direction that itself requires elevation? Gated byAccessScope.Allor role membership (seeElevatedRolesbelow). - Join direction — which side of a join a query matches (has a row in the join table vs. doesn't). This is query semantics, not inherently an access-control concept — most joins (e.g. "products not already on this document") carry no elevation requirement at all and are just a plain property, never wired into this system. Only reference a join-direction property from
[AccessControlled]when selecting the negated direction for that specific join genuinely requires elevation (e.g.EntityProductAccess's "products this entity does NOT have access to").
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:
Subject(EntityIdorUserId) — which caller-identity value substitutes onAutoPopulate.Requirement(Required/Optional/AutoPopulate/Ignore) — how anullvalue is treated when the caller isn't elevated.Requiredrejects;Optionalproceeds withnull(no filter applied);AutoPopulatefills in the caller's own value and proceeds (falls back toRequired's reject if no caller value is available to substitute). A non-null value that doesn't match the caller is rejected regardless ofRequirement— except forIgnore, which bypasses every check entirely (see below).ElevatedRoles— roleCodevalues (seeAppConstants.Roles) that count as elevated for this specific property, independent ofAccessScope. E.g.HeadUsermay specify anyUserIdwhileStandardUsermay not, despite both havingAccessScope.Self.JoinDirectionProperty— optionalnameof()of a siblingJoinDirectionproperty on the same type (Linked/Unlinked— deliberately named to describe query semantics, not access levels). SelectingUnlinkedon it also counts as "this request is elevated," gated by the sameAccessScope.All/ElevatedRolescheck, independently of whether the id itself matches the caller.
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:
accessToken— short-lived JWTrefreshToken— long-lived token used with/auth/refreshentityType—"supplier"or"customer", derived from the user's entity
The GET /api/v1/auth/me endpoint returns the current user context from the live JWT:
tokenId— current session token identifieruserGuid— unique user identifierfirstName,lastNametenantId,tenantGuid,tenantNameappIduserEntityIdroleentityType—"supplier"or"customer"scope—"Self"or"All"permissions— list of permission stringsfeatures— tenant feature flags
Known Limitations / Pending Review
- Endpoints that require more than one permission (e.g. either
orders.readORreports.read) will need additional logic —[RequirePermission]currently checks a single permission. - Some endpoints may need different scope rules depending on the operation (e.g. a
Self-scoped user reading a shared resource). These should be reviewed as they arise. - Legacy API token fallback is still in place for backward compatibility and should be phased out once all clients are migrated to JWT.
- The auth layer is intentionally kept simple at this stage — complexity should be added in response to real requirements, not pre-emptively.