Skip to content

Security and Identity

Sergio Hernandez edited this page Jul 24, 2026 · 1 revision

Security and Identity

Two services share this concern:

  • AuthorityServer — the OpenIddict-based OAuth 2.0 / OpenID Connect token server. It authenticates users, drivers and service clients, and issues the access tokens (audience trackhub_api) that every other service validates.
  • Security API — the owner of the authorization model: users, roles, policies, resources, actions, and the service-client permission allowlist. It answers the permission questions the other services ask on each request.

Related pages: Technology · User Permissions Overview · Database


Principal types

Every token carries a principal type, and [Authorize] gates on it.

Principal type Who Notes
User A portal or mobile user Carries account_id and a role claim
ServiceClient A backend service or partner integration Subject is the client id — not a GUID
Driver A driver signing in from the driver mobile app Authenticated against security.driver_credentials
PublicLink An anonymous holder of a share link Resolved per request from a public link grant

AuthorizeAttribute.PrincipalTypes defaults to "User,ServiceClient". An attribute that does not set it is not unrestricted. Driver and PublicLink principals must be named explicitly to reach a request, which is why only driver-facing surfaces carry PrincipalTypes = "…,Driver". Read the property's initializer before reasoning about "unset" behaviour.


Authentication flows

Authorization Code Flow with PKCE

Used by every public client — web_client, mobile_client, driver_mobile_client. The full sequence is diagrammed on Technology.

The login screen validates credentials against security.users (BCrypt-hashed passwords) and attaches the security identifier claim to the auth cookie; the authorize endpoint then forwards the claims into the access token.

Password policy: minimum length 8, with at least one uppercase letter, one lowercase letter and one digit.

Lockout: security.users.LoginAttempts tracks consecutive failures.

Client Credentials Flow

Used by backend services and partner integrations. Tokens carry sub, role=service, client_id, principal_type=ServiceClient and — when the client is tenant-bound — an account_id claim.

The account is not supplied by the caller. It is derived from the client's active, in-effect rows in security.service_client_permissions, so a partner credential cannot widen its own reach:

Client's effective grants account_id claim
At least one allowcrossaccount grant (the platform-internal identities: router_client, syncworker_client, security_client, geofence_client, trip_client) Not issued — the token stays unscoped, which is what a platform-wide grant matches against
Grants naming exactly one account (the partner / TMS case) Issued automatically for that account
Grants naming several accounts Requires the account_id request parameter (see below)
No account-bound grant Not issued

The optional account_id request parameter

A service client whose grants span more than one account is ambiguous, and the token endpoint will not guess a tenant — issuing an arbitrary one would silently point a partner at the wrong customer. Such a client names the tenant it wants on each token request:

curl -X POST https://<authority>/connect/token \
  -d grant_type=client_credentials \
  -d client_id=<partner_client> \
  -d client_secret=<secret> \
  -d scope=service_scope \
  -d account_id=<account-guid>

The parameter is optional and unnecessary for single-account and platform-internal clients, and it can only narrow, never widen: the requested account must match one of the client's own active grants. The endpoint answers invalid_request when the value is not a valid identifier, when the client holds no active grant for the requested account, or when a multi-account client omits it. That last case is a deliberate rejection, not a fault — add the parameter, or seed the client with a single account grant.

Provisioning a partner client: seed its rows in security.service_client_permissions with an accountid and without allowcrossaccount, then re-run db-init. A client seeded the platform way (allowcrossaccount = true) receives no account claim and is treated as a platform-internal identity.

Driver authentication

Drivers do not have portal user records. They sign in through driver_mobile_client against security.driver_credentials, and their devices are bound in security.driver_device_registrations. Driver-reachable requests must name Driver explicitly in PrincipalTypes.


The role claim

User access tokens carry a role claim. The AuthorityServer login resolves the user's most privileged role from the read-only security.roles / security.user_role mapping into the auth cookie, and the authorize endpoint forwards it into the access token. The refresh-token grant re-resolves the claim on every refresh, so staleness is bounded by one access-token lifetime.

Two things this claim is and is not:

  • It drives visibility scoping — group-based filtering, IsPrivileged checks, and role-addressed notification feed matching.
  • It never drives permission enforcement. That is always AuthorizationBehavior → Security authorizeUser. Security remains the role source of truth.

Users must sign in again to pick up a newly granted role, and before this claim existed ICurrentPrincipal.Role was null for every portal user — every IsPrivileged check silently fell back to non-privileged. If a visibility bug looks role-shaped, check whether the session predates the role grant.

This required no schema change: security.roles and security.user_role are pre-existing Security-owned tables that AuthorityServer now maps read-only, exactly as it already does for security.users, security.clients and security.driver_credentials.


Authorization decisions

AuthorizationBehavior reads the [Authorize(Resource, Action, PrincipalTypes)] attribute on the command or query and asks Security:

Principal Call What it checks
User authorizeUserone combined role + policy call, evaluated in-process in Security Either the role path or the policy path resolves the Resource + Action pair. An empty policy set means no policy restriction; roles remain the gate.
ServiceClient isValidServiceForResource The client name is registered in security.clients and a matching row exists in security.service_client_permissions (client, resource, action, scope, audience)

Both decisions are cached for 30 s in Common.Infrastructure.IdentityService (AppSettings:AuthorizationCacheSeconds; 0 disables). That is the same staleness window as account status and feature flags.

Security's identity queries pin the subject to the caller (IdentityCallerGuard): a user token may only ask about itself, and a service token only about its own client.

Request → Logging → Validation → Authorization → AccountScope → Caching → RateLimiting → Handler
                                       ↑
                            [Authorize] attribute,
                            resolved via IIdentityService

A denied request throws ForbiddenAccessException, which surfaces as GraphQL FORBIDDEN with requiredResource and requiredAction extensions naming exactly what was missing.


Service clients

A confidential client is only usable once both of these exist:

  1. the OAuth application in OpenIddict (seeded by the AuthorityServer ClientSeeder from config/clients.json);
  2. the client name in security.clients and its grants in security.service_client_permissions (seeded by the Security DBInitializer).

Missing the second step yields a clean FORBIDDEN from every call while the service itself reports healthy — a common cause of "the service is up but everything is denied" after adding a new module. See Inter-Service Communication for the current grant inventory.


Seeding rules that bite

  • Adding a resource-action row and granting it to a role are two separate steps. A new Actions.Custom pair needs both the ResourceAction seed and an entry in each role's matrix. ResourceActionRoleReader matches role names exactly, with no ParentRoleId walk — nothing is inherited. Missing the second step returns FORBIDDEN for every non-Administrator on an otherwise correctly seeded deployment.
  • SeedRoleResourceActionsAsync only ever ADDS rows. Removing a grant from the matrix does not remove it from an already-seeded database; retracted grants need explicit cleanup SQL.

Committed development credentials

Seeded OAuth client secrets (clients.json), sample appsettings, .env examples and DBInitializer seed data are intentionally committed, so the multi-service development environment can be stood up without manual secret plumbing. Production deployments override every one of them through environment variables — see TrackHub.Deployment/INSTALL.md.

This is a documented decision, not an oversight. What is not acceptable is a committed real production or third-party credential.


Cryptography

Data Protection
User and driver passwords BCrypt hashing (Common.Domain.Extensions.Cryptographic)
GPS provider credentials (app.credentials) Encrypted with a server certificate; decrypted only in the Router, only under the service identity
Webhook notification payloads HMAC-SHA256 signature in the X-TrackHub-Signature header
Public tracking links Opaque public link grant identifiers, revocable and expiring

Clone this wiki locally