-
Notifications
You must be signed in to change notification settings - Fork 1
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
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.PrincipalTypesdefaults to"User,ServiceClient". An attribute that does not set it is not unrestricted.DriverandPublicLinkprincipals must be named explicitly to reach a request, which is why only driver-facing surfaces carryPrincipalTypes = "…,Driver". Read the property's initializer before reasoning about "unset" behaviour.
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.
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 |
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_permissionswith anaccountidand withoutallowcrossaccount, then re-rundb-init. A client seeded the platform way (allowcrossaccount = true) receives no account claim and is treated as a platform-internal identity.
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.
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,
IsPrivilegedchecks, and role-addressed notification feed matching. -
It never drives permission enforcement. That is always
AuthorizationBehavior→ SecurityauthorizeUser. 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.Rolewas null for every portal user — everyIsPrivilegedcheck 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.
AuthorizationBehavior reads the [Authorize(Resource, Action, PrincipalTypes)] attribute on the command or query and asks Security:
| Principal | Call | What it checks |
|---|---|---|
User |
authorizeUser — one 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.
A confidential client is only usable once both of these exist:
- the OAuth application in OpenIddict (seeded by the AuthorityServer
ClientSeederfromconfig/clients.json); - the client name in
security.clientsand its grants insecurity.service_client_permissions(seeded by the SecurityDBInitializer).
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.
-
Adding a resource-action row and granting it to a role are two separate steps. A new
Actions.Custompair needs both theResourceActionseed and an entry in each role's matrix.ResourceActionRoleReadermatches role names exactly, with noParentRoleIdwalk — nothing is inherited. Missing the second step returnsFORBIDDENfor every non-Administrator on an otherwise correctly seeded deployment. -
SeedRoleResourceActionsAsynconly ever ADDS rows. Removing a grant from the matrix does not remove it from an already-seeded database; retracted grants need explicit cleanup SQL.
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.
| 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 |
Platform
- Architecture
- Technology
- Inter-Service Communication
- Database
- Security and Identity
- User Permissions Overview
Services
- Manager
- Telemetry
- Router
- Adding a Provider
- Geofencing
- Trip Management
- Reporting
- Common Library
- Frontend
Engineering
User docs
- User Guide (ships in the app)