packages/server/src/app.ts; the authorization and error-envelope behavior is defined in packages/server/src/kernel/http.ts and packages/server/src/kernel/errors.ts. Role-to-capability grants are documented in the permissions manifest; the security rationale is in the threat model.
Conventions
- All endpoints live under
/api/. Requests and responses are JSON unless noted (the Graph webhook validation handshake respondstext/plain). - Request bodies are validated with Zod schemas; a failed parse returns
400 validation_failedwith per-field issues (see Error envelope). - An optional
x-fmd-correlationrequest header sets the correlation id for the request; otherwise one is generated. Responses carry anx-fmd-auth-modeheader (devorentra) — except thecsrf_token_invalidrejection, which is returned before the header is set. - List endpoints are bounded (e.g. assets 200 rows, findings 200, audit 200, scans 50, analyst history 25); there is no cursor pagination in this release.
Capability column legend
Authentication model
Session cookie
Authentication is a signed session cookie namedfmd_session (SESSION_COOKIE in kernel/http.ts): httpOnly, SameSite=Lax, Secure when FMD_ENV=production, path /. The cookie value is an HMAC-signed session id; the server resolves it to a session row and loads the authorization context (tenant, principal, roles, owned domains) per request. Client-supplied identity fields are never trusted.
Two auth modes exist (ADR-0004):
-
Dev mode (
FMD_AUTH_MODE=dev, development only). Login flow:GET /api/auth/dev/personas— list seeded principals with roles.POST /api/auth/dev/loginwith{ "principalId": "..." }— sets the cookie and returns{ ok, csrfToken, authMode }.
FMD_AUTH_MODE=devwhenFMD_ENV=production(kernel/config.ts), and the request middleware rejects any surviving dev-mode session at request time (kernel/http.ts). -
Entra mode (
FMD_AUTH_MODE=entra) — Microsoft Entra OIDC sign-in (ADR-0035), live-validated.GET /api/auth/entra/loginstarts an authorization-code + PKCE (S256) flow andGET /api/auth/entra/callbackcompletes it: the ID token’s signature is validated against the tenant JWKS before any claim is trusted, and identity binds by immutable issuer+subject, never email. When the app registration is not configured, login returns an explicit501 not_configured— never a silent fallback.
CSRF
Every mutating request (POST, PUT, PATCH, DELETE) made with a session must send the session’s CSRF token in the x-fmd-csrf header. The token is returned by POST /api/auth/dev/login and by GET /api/auth/me. A missing or wrong token returns 403 {"error":"csrf_token_invalid"}. Requests without a resolved session skip the CSRF check — this is what allows the unauthenticated Graph webhooks and the dev login itself to POST.
401 / 403 / 404 semantics
Authorization is capability-based, not role-based: handlers call
requireCapability(auth, "..."), and capabilities are granted per role at either tenant scope or domain scope (resolved against active ownership assignments). Domain-scoped list endpoints additionally apply domainScopeFilter so results are constrained to the caller’s owned domains. See the permissions manifest for the full role/capability matrix.
Error envelope
The central error handler (kernel/http.ts) maps typed errors (kernel/errors.ts) to a uniform envelope:
- Zod validation failures:
{ "error": "validation_failed", "issues": [{ "path": "field.path", "message": "..." }] }(400). - CSRF failures:
{ "error": "csrf_token_invalid" }(403, nomessage). - Unhandled errors:
{ "error": "internal_error", "correlationId": "..." }(500) — no stack or details leak.
Endpoint reference
94 endpoints across 15 route modules, in mount order frompackages/server/src/app.ts.
Health (routes/health.ts)
Liveness and honest readiness. No authentication.
GET /api/ready returns { status, dbOk, env, integrations } where each integration (connector, aiProvider, entraSignIn, purviewLabelRead, purviewLabelWrite, changeNotifications, teamsConnector, exchangeConnector, onedriveConnector, azureStorageConnector, smbConnector, sqlConnector, gdriveConnector, boxConnector) reports one of:
mock— the deterministic fixture path is in use,live— feature enabled and its secret reference actually resolves to a value,blocked— feature enabled but credentials do not resolve (never reported aslive),disabled— feature turned off.
blocked/disabled split: purviewLabelRead and purviewLabelWrite report blocked (never disabled) whenever they are not fully live — including when their feature flag is simply off — and entraSignIn reports live when the app registration and secret reference resolve, blocked when FMD_AUTH_MODE=entra but the registration is unconfigured, and disabled outside entra mode. changeNotifications additionally requires a public webhook URL (FMD_WEBHOOK_PUBLIC_URL) to report live. The six per-source connectors (onedriveConnector, azureStorageConnector, smbConnector, sqlConnector, gdriveConnector, boxConnector) additionally report blocked (never a silent empty scan) when enabled without an explicit selection, and blocked rather than disabled while de-selected scopes persist un-reconciled.
Auth (identity/auth-routes.ts)
Governance (governance/routes.ts)
Onboarding, taxonomy lifecycle (draft → human review → approval), and ownership assignment. AI-generated taxonomy objects are always drafts pending human review.
Scan (scan/routes.ts)
Connector registration and durable scan campaigns (ADR-0007, ADR-0008). The mock M365 connector is the default/CI path; the live Graph connector is implemented and has been validated read-only against a live Microsoft 365 tenant (ADR-0013).
Scope visibility: tenant-scoped readers (admin/auditor) see all scopes; a domain-scoped owner sees scopes governed by their domains or holding at least one asset classified into them.
Models (routes/models.ts)
Governed dataset curation and model-release lifecycle with separation of duties (approver ≠ proposer, enforced in models/pipeline.ts). This loop has been validated end-to-end against live Microsoft 365 data.
Review (review/routes.ts)
The “Is this yours?” owner review queue. Decisions create linked OwnerConfirmed assertions — the original inference is never destroyed — and propagate to cluster/container neighbors without auto-confirming. Decisions are revisable within limits.
Analyst (analyst/routes.ts)
Typed, scoped query answering over the search projection (ADR-0009). Plans — whether deterministic or AI-proposed — are validated identically, and tenant/domain predicates are injected server-side; a plan cannot widen its scope.
Actions (actions/routes.ts)
Approval-gated remediation state machine (ADR-0010): draft (with preview + justification) → submit → approve → execute → verify from source. internal_task runs fully in-product. purview_label_set / purview_label_remove are built and offline/mock-validated; the live Graph write path is feature-gated (FMD_FEATURE_PURVIEW_LABEL_WRITE + a separate remediation identity) and refuses honestly when unconfigured: the draft is recorded with status: "blocked" and a blockedReason, and submitting a blocked action returns 409 conflict (a live connector whose base Graph credentials do not resolve fails earlier with 422 blocked_capability). Live label writes have not been exercised against a real tenant.
Read models (routes/read-models.ts)
Owner-facing read surface. Every query is tenant-scoped; domain-scoped capabilities filter to owned domains. Evidence levels are tiered (metadata < summary < excerpt); excerpt reads require asset.evidence.excerpt.read and are audited.
Admin (admin/routes.ts)
Setup progress, runtime + tenant settings, and a connectivity probe. Secrets are never returned — only booleans derived from configuration references.
Notifications (notifications/routes.ts)
Graph change-notification lifecycle: built end-to-end and validated via the dev simulator, which drove a real read-only delta reconcile against a live Microsoft 365 tenant. Live Graph subscription delivery additionally requires FMD_FEATURE_CHANGE_NOTIFICATIONS=true and a public HTTPS receiver URL (FMD_WEBHOOK_PUBLIC_URL); /api/ready reports the mode honestly. A notification is treated as a hint that triggers delta reconciliation — notification content is never trusted.
Webhook trust model (both unauthenticated endpoints):
- Validation handshake — a request with a
?validationToken=query parameter is echoed back astext/plain 200(Graph’s subscription-creation check). - clientState HMAC — each subscription’s
clientStateis derived asHMAC-SHA256(sessionSecret, "fmd-sub:<subscriptionId>")(deriveClientState) and compared in constant time (constantTimeEquals). On the notifications receiver,ingestNotificationrejects any item whoseclientStatedoes not verify before any side effect, and deliberately does not persist rejected rows (an unauthenticated caller who learns a subscription id must not be able to amplify writes); a metric records the rejection. On the lifecycle receiver, the same constant-time check runs beforehandleLifecycleEvent— an absent or wrongclientStateis skipped, so an unauthenticated caller cannot force a reconcile or tamper with a subscription. - Verified notifications are deduplicated within a time window and enqueue a delta reconcile; the payload itself is never treated as content.
Teams (teams/routes.ts)
Teams connector surface — built and mock-validated; the live Graph adapter is feature-gated behind FMD_FEATURE_TEAMS_CONNECTOR (requires the protected ChannelMessage.Read.All permission) and has not been run live. Channel file attachments canonicalize to their backing SharePoint driveItem, so no duplicate asset identities are created. Read views require tenant-scoped operations.read because Teams messages are not yet mapped to business-domain ownership — deny-by-default until they are.
Exchange (exchange/routes.ts)
Exchange connector surface — built and mock-validated; live is feature-gated behind FMD_FEATURE_EXCHANGE_CONNECTOR (Mail.Read scoped to selected mailboxes via FMD_EXCHANGE_MAILBOXES) and has not been run live. An attachment is a copy: a content match to a SharePoint asset is recorded as similarity, not identity. Read views require tenant-scoped operations.read for the same deny-by-default reason as Teams.
Control plane (controlplane/routes.ts)
Signed licensing entitlement — tokens are verified locally, with an offline grace window; online activation and renewal contact Find My Data Control only when a Control URL is configured — and strictly allowlisted fleet-health telemetry with a transparency preview and customer opt-out. This module also defines the deployment’s PRODUCT_VERSION.
Support (support/routes.ts)
Consent-based support diagnostics. The bundle is generated inside your deployment and returned for preview only — nothing is sent from this endpoint. Uploading an approved bundle to a support case is a separate, deliberate action in the Account portal.
MCP server
Your deployment includes an MCP (Model Context Protocol) server so AI agents (Claude, IDE agents) can work with it under the same authorization the UI enforces. It is a thin adapter over the HTTP API above — no separate permission model. Run it inside your deployment:403 is
returned to the agent as a tool error. Configure the identity with
FMD_MCP_PRINCIPAL (dev auth) or FMD_MCP_SESSION_COOKIE + FMD_MCP_SESSION_CSRF
(a pre-established session).
Writes are proposal-only by design: an agent can draft a remediation or
start a read-only scan, but approving, executing, confirming, or writing labels
is deliberately not exposed — those remain human actions in the product.
Related documents
- Permissions manifest — roles, capability grants, and Microsoft Graph permission requirements
- Threat model — the security invariants these endpoints enforce
- Architecture — where the route modules sit in the system
- ADR-0003 — persistence model behind these APIs
- ADR-0004 — dev/Entra authentication design