FleetlessFleetlessdocs
Reference/Identity

Identity — what an app developer needs to know

This is not the console’s screens. It is what someone building on Fleetless — a client app, a server-side integration — must know about the identities the platform hands them, the rules that are easy to get backwards, and what a rate_limited refusal obliges a client to do. This page states what the platform does; the SDK reference shows how to call it.

One user pool per org

Fleetless once had two separate identity spaces — developers who configure the platform and end users who use the apps built on it. They are now one pool. A person in an organisation is a single user row, in exactly one group, with one email address that is unique across the platform. What that person may do is decided by where they are placed, not by which of two account types they hold:

  • Console access is membership of the org’s one Org Admins group — the only group that grants it. An app’s ordinary users live in other groups and never see the console.
  • App access is explicit. A user can log into an app only after an org admin assigns them to it and picks a role. The app must belong to the user’s group. There is no ambient “every user of the org can reach every app.”
  • Server keys are the one non-person credential: a per-app secret with the app’s full permission set, for your own backend, automation or CI. Never handed to a client.

A token still carries a kind (developer, end_user, or server_key), but that names which surface the session was minted for — the console (/api/auth/*, Org Admins only) or a named app (/api/client/*, backed by an assignment) — not a separate account. The same person, if they are an org admin and hold an app assignment, authenticates to each surface on its own route. Org Admins additionally may sign into any app in the org through an impersonation step (as a role, or as a specific non-admin user of the app’s group) — see Impersonation below.

An address is unique across the whole platform, not merely within one org. An email names at most one user row, and that row sits in exactly one organisation and one group — so a login resolves against that single row (see the password section), and somebody who needs access in two organisations needs two addresses.

Password lifecycle

Two ways a user’s password changes, and they behave differently on purpose. Each surface has its own route — the console pair under /api/auth/, the app pair under /api/client/ — but the rules below are the same for both.

Changing a known password (POST /api/auth/password/change for the console, POST /api/client/password/change for an app session; both authenticated): requires the current password, not just a valid session. A stolen session token is not enough to take over the account — the caller has to prove they still know the password. On success the route answers a fresh sessionTokens pair, and every other session of that user is revoked; the session that made the change survives only because it is handed new tokens, not because the old ones were spared. Store what it returns — the credentials you called it with are now revoked too.

Recovering a forgotten password (POST /api/auth/password/reset, then .../reset/confirm, both unauthenticated): a request by email produces a link; the token is single-use and expires. Spending it sets the new password and revokes every session of that user — not “every other” this time, because whoever is confirming a reset by definition does not hold a session worth preserving. A token_spent refusal from the confirm step means either the token was already used or it expired; the platform deliberately does not distinguish the two, because telling them apart would tell a stranger whether a token had ever existed.

One address is one account. users.email is unique across the platform, so an address resolves to at most one user: a reset request mails a single link bound to that user, and a login verifies the password against that one row. An address that names no account still gets the same 202 and the same empty body, for the enumeration reason below — the route never says whether an address exists.

Console access: the Org Admins group, tiers, invitations

Console access is membership of the org’s Org Admins group — one per org, renamable but never deletable. Within that group a member carries a tier: Owner (everything, including org deletion, org settings, promoting another member to owner, and — later — billing) or Developer (robots, apps, users, groups, assignments; no destructive org-level actions). The tier is meaningful only inside the Org Admins group; a user in any other group has no console tier because they have no console access at all.

Getting in is invitation-only. POST /api/org/users/invitations creates an invitation for an address; accepting it (POST /api/org/users/invitations/accept, unauthenticated) creates the login — sign in next. There is one invitation flow for every user now — admin or not — which replaces the old per-app invitation and per-app self-registration routes. Accepting refuses an address that is already claimed anywhere on the platform (email_taken), not only one claimed in this org.

If a route refuses because the caller’s tier is too low, it answers tier_required with { required, actual }not forbidden. forbidden stays what it always was: a refusal that says nothing about whether the target exists. tier_required says something different — “your tier does not permit this,” which a developer can act on (ask an Owner) — and it is careful to say nothing about the target either. Don’t conflate the two: tier_required is about the caller’s Org Admins tier (Owner/Developer); it has nothing to do with a user’s app role (observe/operate/custom, the role × service matrix), which is enforced separately once inside an app. A caller can be refused by either mechanism for unrelated reasons, and the error code is how you tell which one happened.

The last owner is neither removable nor demotable — the org cannot lock itself out of ownership.

Removing a user (DELETE /api/org/users/:id) is not a row deletion: every session that user is holding stops working, immediately, not just at its next refresh. Moving a user to another group, or changing which group an app belongs to, cascades to the now-invalid app assignments — but only behind an explicit confirmation that previews the count first (GET /api/org/users/:id/usage?group_id= for a user move, GET /api/apps/:id/group-usage?group_id= for an app re-link — the blast-radius preview).

Self-registration is gone

Earlier versions of Fleetless let an app owner turn on per-app self-registration: a public POST /api/client/register endpoint gated by a domain filter, putting strangers into that app’s own user pool. The org-central identity redesign removed it with no successor. There are no per-app user pools any more, so there is nothing to self-register into, and these routes now answer as removed by design: POST /api/client/register, /register/confirm, and GET|PUT /api/apps/:id/self-registration.

A group grows in exactly two ways now:

  • By invitation — an org admin invites an address into a group (above).
  • By OIDC just-in-time provisioning — a group may carry one OIDC provider, and an authenticated user your IdP vouches for is created into the group on first login. See Federated login and JIT provisioning below.

If you built against POST /api/client/register, there is no drop-in replacement: users are provisioned centrally by an org admin, or by your IdP through JIT, not by your app signing them up.

Federated login and JIT provisioning

A group can federate its logins to your own OpenID Connect identity provider. An org admin configures it per group — PUT /api/org/groups/:id/oidc-provider (issuer, client id, a write-only client secret, scopes, and the JIT grants below); GET reads it back without the secret, DELETE removes it. The Org Admins group can never carry a provider — console login is always the Fleetless password provider, and the route refuses the attempt.

Fleetless is the authorization server your app talks to; toward your IdP it is a relying party. Your app never speaks to the IdP directly. It runs the ordinary OAuth flow against Fleetless (/oauth/authorize); when the user’s group federates, Fleetless redirects the browser out to your IdP, and the IdP returns to Fleetless’s own callback (/oauth/idp-callback). Fleetless validates the id_token end to end — issuer, audience, expiry, the nonce it planted, and the signature against the IdP’s published JWKS — before it will mint anything. A token that fails any of those checks logs nobody in.

Provisioning is just-in-time, and it never links to an existing account by email. When the validated (issuer, subject) names a user Fleetless has seen before, that user is logged in. When it is new and the group has JIT enabled, the user is created in one transaction — the user row, the federated identity, and the app assignments the provider’s JIT grants name (each grant is an app in this group plus a role on that app). Two guards are worth building against:

  • An email collision is refused, not merged. If the asserted email already belongs to a different account — in this org or any other, email being unique across the platform — the login is refused (email_collision) rather than silently attached to that account. Federation is never a way to take over an existing user by claiming their address.
  • No grant for the app being entered is a clean refusal. The user may be provisioned (with whatever other grants applied), but if the provider names no role for this app the login into it is forbidden — a created user with no route into the app they tried, not a half-open session.

If the group has no provider, or has one with JIT disabled, an unknown (issuer, subject) is turned away (jit_disabled); nothing is created. A claims_incomplete refusal means the IdP returned a token missing something Fleetless needs (a subject, an email) to provision safely.

Impersonation — an org admin signing into an app

An org admin does not hold an app assignment of their own; they live in the Org Admins group, which no app belongs to. So when an org admin runs an app’s login flow, Fleetless does not drop them into a blank session — it redirects them to a server-rendered interstitial (/oauth/impersonate) and asks them to choose how to enter:

  • As a role — a preview. The admin runs the app as one of its roles, with no target user; the chosen role is authoritative because the admin holds no assignment of their own. Use this to see what a role sees.
  • As a specific user — “see exactly what they see.” The target must be a member of the app’s group and must already hold an assignment for the app (there is nothing to impersonate otherwise). The session runs with that user’s own assignment and role.

Two rules are load-bearing:

  • No admin impersonates another admin. The user list offered is the app group’s assigned members, and org admins are never in an app’s group — so they cannot appear. The choice is also re-checked on submit; an attempt to name another org admin is refused.
  • The real admin is always attributed. A token minted through impersonation carries an act claim — the impersonating admin’s user id — alongside the effective identity it is acting as. Every mutating action that session takes is audited at the source as “Admin A as user B” (or “as role X”), never as the target acting alone. Impersonation is a labelled act, not a disguise.

Impersonation is browser-only: it exists to let an admin use an app through its real login screen. There is no headless POST /api/client/login path into it — a direct client login by an org admin is refused, because the choice of whom to act as has to be made, and made in the authenticated browser that proved it is the admin.

Password reset — the rule everyone gets backwards

POST /api/auth/password/reset answers identically for a known and an unknown address. Always the same status, the same body, and it must not even be measurably faster or slower for one case than the other. This is unauthenticated by necessity, and it is the one place in the platform where the platform's usual silence about existence is not a preference but the entire point: any observable difference between "we emailed a reset link" and "no such account" is an account-enumeration oracle.

Any UI or client that renders “no such account” from this call has reintroduced that oracle. The correct client behaviour for every response from this route is the same message: “if that address has an account, a reset link was sent.” There is no other honest sentence to show.

mailStatus — what each value licenses you to claim

Every place the platform reports on a mail it tried to send — invitations, password resets — reports one of three values, not a boolean:

  • sent — the SMTP server accepted the message. This is not “delivered,” and no document (including this one) may imply it is: no sender can promise delivery, only that a mail server took responsibility for the attempt.
  • not_configured — no SMTP is set up for this deployment. This is a normal, expected state, not a failure (the invitation design: the link is always the primary path, mail is the second). Do not render this as an error to a developer or an end user; the flow is complete and usable without it.
  • failed — SMTP was configured, was tried, and the server refused the message or was unreachable. This is the one value worth someone’s attention — configuration is right, delivery is not.

The three states need genuinely different reactions from whoever reads them: not_configured says “go set up SMTP if you want mail at all,” failed says “go look at why the mail server you already have said no.” Collapsing them back into a single boolean is exactly the defect this shape replaces (a boolean could not tell the two apart, so it always guessed the reassuring one).

rate_limited — what it obliges a client to do

Every unauthenticated route that costs the platform something to answer — login, refresh, invitation acceptance, password reset — is rate limited, refused before any password is verified. A refusal carries exactly one number: rateLimitDetails.retry_after_ms.

The obligation on the client is to wait until then, and not retry before it. Nothing else is a valid response to rate_limited:

  • Do not retry immediately. The refusal is cheap precisely because nothing expensive ran; retrying in a loop turns your own client into the attack this exists to stop, against your own users’ accounts.
  • Do not surface it to your end user as a generic error. It is not “the server is broken” or “your password is wrong” — it is “wait retry_after_ms and try again,” and a client that shows anything else is lying to its user about what to do next.
  • Do not try to infer the limit, the window, or how many attempts remain. The response deliberately carries none of that — only the one number that changes an honest caller’s behaviour. There is nothing else in the shape to read.