Repository navigation
Security
The API holds a fleet's complete inventory, so everything past the health probes is authenticated. This page describes the model as implemented.
Five ways to authenticate. They are tried in this order, and the first that resolves a principal wins.
| Credential | Header | Scopes |
|---|---|---|
| Internal secret | X-Internal-Secret |
All. The dashboard-BFF-to-API hop. |
| Easy Auth principal | X-MS-CLIENT-PRINCIPAL-ID |
All. Ignored unless TRUST_EASYAUTH_PRINCIPAL_HEADER is on. |
| API key | X-API-Key |
Only what the key was granted. |
| OIDC bearer token | Authorization: Bearer <jwt> |
Mapped from the IdP's roles. |
| Shared passphrase |
X-Client-Passphrase or X-API-PASSPHRASE
|
All. What the agents send. |
Legacy credentials — the passphrase, the internal secret, a trusted Easy Auth principal — carry every scope for backward compatibility. Only API keys and OIDC tokens are scope-limited. The intended direction is to move callers onto scoped keys and then retire the shared passphrase.
Comparison of the passphrase and internal secret uses hmac.compare_digest.
The passphrase loop compares every configured candidate without an early
break, so the time taken does not reveal which entry matched.
The deployed Windows agent sends both X-API-Key and X-Client-Passphrase
when both are configured. So when an API key or bearer token fails and
another credential accompanies it, the request falls through to that
credential rather than being rejected. A failed API key is only a hard 401
when it was the sole credential presented. Hard-failing on the key alone
caused a total ingestion outage once; this is the fix.
X-MS-CLIENT-PRINCIPAL-ID is only evidence of anything when a platform that
strips inbound X-MS-* headers and injects validated ones sits in front of
the app. Without that guarantee anyone can type the header, which would make
this branch a full-scope bypass. It is therefore ignored unless
TRUST_EASYAUTH_PRINCIPAL_HEADER is explicitly enabled, and presenting it
without the opt-in is logged.
Three least-privilege buckets, enforced by one gate after the principal is resolved:
| Scope | Grants |
|---|---|
read |
GET, HEAD, OPTIONS |
ingest |
POST |
admin |
PUT, PATCH, DELETE, and anything whose path contains /admin
|
The scope is derived from the request, not declared per route. Three consequences:
-
POST /api/v1/admin/usage-history/reset-baselineneedsadmin, notingest, because of the path. -
GET /api/v1/debug/databasedoes not contain/admin, so areadcredential reaches it. Bear that in mind when issuing read keys. -
PUT /api/v1/inventory-stateneedsadminby method, so the asset inventory integration that writes it needs an admin-scoped key or token.
Anonymous routes are an explicit allowlist, enforced by a test: the three
health probes and GET /api/v1/auth/config. The last describes how to
authenticate — the accepted headers and, when OIDC is on, the issuer and
audience — and carries nothing that is not already in a token's claims or a
401 body.
A scope rejection is recorded as an ingest failure with reason
insufficient_scope, so a misconfigured integration is visible rather than
silent.
Format rm_<id>_<secret>: a 12-hex-character public id and a 48-hex-character
secret. Only sha256(secret) is stored, compared with hmac.compare_digest.
The full key is returned exactly once at creation and cannot be recovered — a
lost key is revoked and replaced.
Revocation is a soft delete (active = false) so the audit trail survives.
Create and revoke both append to api_key_audit with the acting principal.
last_used is stamped best-effort on each successful authentication; a
failure there is logged and never breaks authentication.
The first keys are minted with the shared passphrase, which avoids a bootstrap problem.
Secretless federated SSO against any OpenID Connect provider. ReportMate stores nothing for this path: identity is proven by the provider's signature over a short-lived token, so there is nothing to rotate and nothing to leak.
The validation order matters:
- The unverified
issis read first and checked againstOIDC_ISSUERS. An untrusted issuer is rejected before any cryptography runs, and in a multi-issuer deployment this selects the right JWKS. - The signing key is resolved from the issuer's
/.well-known/openid-configuration(or a pinnedOIDC_JWKS_URI) by the token'skid, cached per issuer. - Signature, audience, issuer, and expiry are verified.
expis required. - Roles, groups, and
scp/scopeclaims are mapped to ReportMate scopes and unioned.
Only asymmetric algorithms are accepted — RS256, RS384, RS512, ES256,
ES384 by default. none and HS* are excluded on purpose, to prevent an
algorithm-confusion attack where a token is signed with the public key.
A valid token that maps to no scope is rejected, and the rejection is logged
with the principal. Set OIDC_DEFAULT_SCOPES only if you deliberately want
any authenticated token in the tenant to have a floor of access.
A client discovers the audience to request a token for from
GET /api/v1/auth/config, so nothing about the deployment needs to be stored
on the client.
Validation failures return None rather than raising, which keeps the reason
out of the response — details are logged server-side only — and lets an
accompanying credential be considered.
Two fixed-window buckets, both counted on every request:
| Bucket | Key | Default |
|---|---|---|
| Device |
X-Device-Serial header |
120/min |
| Address | First hop of X-Forwarded-For, else the connection address |
3000/min |
One address is not one device. An entire site leaves through the same public egress, so a single per-address bucket puts a whole fleet in one allowance. The device bucket fixes that; the address bucket remains as a flood shield, sized for a fleet rather than a machine.
The serial header is spoofable and is deliberately still counted against the address bucket. This limiter exists to survive a fleet stampede, not an adversary — the endpoint requires authentication regardless. Clients that do not send the header are governed by the address bucket alone.
A caller presenting the valid internal secret bypasses both, because the dashboard funnels every user through a handful of egress addresses and would otherwise starve real users.
Counting is in process memory per replica, so the fleet-wide ceiling scales with the replica count.
Any response of 500 or above has its detail replaced. Server-error detail can
carry exception text, SQL, connection strings, or stack fragments, so the
caller gets a generic label plus a reference id that matches the
X-Request-ID header and the server log line. Client errors below 500 are
intended for the caller and are preserved verbatim.
The unhandled-exception handler logs a full traceback server-side and returns the same masked shape.
Every query is parameterized. Table and column names that appear in f-strings
are validated against fixed allowlists first — the module tables, the valid
module names, the export column list. load_sql() rejects path traversal and
resolves the path against the sql/ directory before reading.
-
Log injection. The proxy-supplied
X-Updated-ByonPUT /api/v1/settingshas CR and LF stripped and is truncated to 255 characters before it is stored or logged. -
Rate-limit key growth. The
X-Device-Serialvalue is unauthenticated at the point it becomes a bucket key, so it is restricted to alphanumerics,-, and_, and truncated to 64 characters. -
Client address. Recorded from the first hop of
X-Forwarded-Forwhen present. Recording the ingress address is worse than recording nothing, because it reads as a real device. - Zip bombs. A compressed request body is inflated with a hard ceiling enforced during decompression, so a small crafted body cannot exhaust the container before a size check runs.
-
NUL code points. Stripped from ingest payloads because PostgreSQL
jsonbcannot store them, and the occurrence is recorded so the client defect stays visible.
Called out because they shape how you should deploy this:
- The shared passphrase is a full-access credential held by every device in the fleet. Anything holding it can read the whole inventory and reach every admin mutation. Treat scoped API keys as the target state.
- There is no per-user identity in this tier. Role enforcement for settings writes happens upstream in the dashboard's proxy layer; the API only checks that the caller is the internal service. OIDC narrows this by carrying an IdP principal, but the scope model is still coarse.
-
/api/v1/metricsrequires onlyread, and so does/api/v1/debug/database. -
DISABLE_AUTHexists. The startup guard makes it unusable in a production-like configuration, but it is still a switch that turns off all authentication.
Do not open a public issue. Contact the maintainers through the channel listed on reportmate.app.
AGPL-3.0 — a commercial license is available. reportmate.app