Sites.Read.All + Files.Read.All (admin-consented on a dedicated read-only app registration).
Sites.Selected (below) remains the least-privilege production recommendation; the
broader Read.All pair was used only to stand up a working read-only pilot quickly.
Reading assigned sensitivity labels was not attempted (needs the protected
extractSensitivityLabels / InformationProtectionContent.Read.All), so the
connector reports label state unknown — honestly, never guessing. See ADR-0013.
All Microsoft Learn references were last checked on 2026-07-13. Re-verify API status, licensing,
protected/metered requirements, and national-cloud support before any live
deployment.
1. Identity model
1.1 Separate app registrations: read/scan vs. remediation
Find My Data’s live path uses (at minimum) two Entra ID app registrations with independent credentials:
Why two identities:
- Blast-radius containment. A compromise of the scanner — the component that touches untrusted content at volume — must not inherit any write capability against the customer tenant. The scan identity holds read-only permissions; only the remediation identity can mutate source state.
- Independent rotation and revocation. A customer can disable remediation (revoke one credential) without losing inventory/scan continuity, and vice versa.
- Auditable consent. Each registration carries a small, explainable permission set; the consent screens map 1:1 to product capabilities.
/subscriptions on the read resource (no permission beyond the
resource read the connector already holds), a public HTTPS notificationUrl
(FMD_WEBHOOK_PUBLIC_URL) that Graph must reach, and a per-subscription
clientState HMAC the receiver verifies before any side effect. Without the
public URL the live path is honestly blocked; a dev simulator exercises the
receiver + reconcile path. See packages/server/src/notifications/.
Secrets are referenced by opaque IDs (*_SECRET_REF), never stored as values
in configuration. The configuration layer (packages/server/src/kernel/config.ts)
validates presence; the intended production store is Azure Key Vault or
equivalent, with managed/workload identities preferred over client secrets.
1.2 Development-mode note
This build runs in dev identity mode by default (FMD_AUTH_MODE=dev,
ADR-0004): sign-in is a persona picker over seeded fixture principals, every
response carries x-fmd-auth-mode: dev, and the UI shows a persistent
development banner. No Microsoft identity is involved on the exercised path.
Two production protections are implemented and tested:
- Production lockout:
FMD_ENV=productionwithFMD_AUTH_MODE=devis a hard startup failure (ConfigError), and dev-session cookies are rejected in production even if present. - Immutable identity mapping: external identities map to internal
principals by
(issuer, subject), never by email or display name.
GET /api/auth/entra/login runs a real authorization-code flow
with PKCE (S256), state, and nonce, and GET /api/auth/entra/callback
validates the returned ID token — RS256 pinned (rejects alg:none/missing
kid), signature checked before any claim against the tenant JWKS, then
iss/aud/exp(+60 s skew)/nbf/nonce/sub/tid. Identity binds by the
immutable (issuer, subject) pair (first login matches the token oid to a
pre-provisioned principal), never by email; the delegated scopes are the
standard openid profile email. There is still no silent fallback to dev —
an unconfigured entra mode returns 501 not_configured and a dev-mode
deployment returns 404. /api/ready reports entraSignIn as live only
when the app registration, a resolvable client secret, and a redirect URI
are all present; blocked when entra mode is selected but unconfigured;
disabled in dev — never a misleading “live.” The whole flow is validated
offline against a self-signing fake IdP (14 tests) and passed adversarial
review with zero findings; the live tenant round-trip (redirect-URI
registration, real consent) remains an operator step. See
packages/server/src/identity/entra-oidc.ts and auth-routes.ts.
2. Microsoft Graph permission manifest (live path)
The read/scan permissions below (Sites.Read.All + Files.Read.All) were
consented and exercised read-only
against a live Microsoft CDX test tenant (ADR-0013).
Everything else remains unconsented and gated. The table lists
every permission, why it is needed, and its least-privilege alternative.
Type key: A = application permission, D = delegated permission.
Status key: mock = capability exercised via the deterministic mock
connector; gated = code path exists behind configuration, not validated
live; future = designed, not built.
2.0 Azure Storage data-plane RBAC (not Graph)
The Azure Storage connector authenticates with the same Entra app registration (or theFMD_AZURE_* override identity) against the storage data plane
(https://storage.azure.com audience) — these are Azure RBAC role
assignments, not Graph permissions, and you grant them per storage account
or per container/share, which is the least-privilege mechanism for storage:
The connector never enumerates the storage account: only the exact
account/name entries in the selection lists are probed and listed, the
selection entry is persisted per scope, and — like OneDrive — it is
re-checked at scan time, so removing an entry (or disabling the feature)
stops that container/share from being scanned even though its scope row
persists. Container public-access level is observed from the data plane
and surfaces anonymous exposure; management-plane RBAC role assignments are
not enumerated in this build, and permission observations are recorded with
completeness partial (blob) / not_observable (files) accordingly.
2.0b SMB / on-prem file shares (no Graph, no app credential)
The SMB connector reads operator-mounted shares through the deployment host’s filesystem — there is no protocol client and no application credential:
The mount’s account is the read identity: scope it to read-only on the
shares you list, and rotate/revoke it with normal AD tooling — Find My Data
never sees or stores its password. The walker never follows symlinks, never
reads outside the listed roots, and each root is re-checked at scan time
(de-selecting a share stops its scans immediately). NTFS/share ACLs are not
observable through a mount, so permission observations are recorded as
not_observable — never guessed; native ACL mapping (a Windows scanner host
reading security descriptors) is a designed, gated follow-up (ADR-0031).
2.0c SQL databases (Azure SQL / SQL Server; a DB login, not Graph)
The SQL connector authenticates with a database login the operator provides (via theFMD_SQL_CONNECTIONS_REF connection map) and the
operator-installed mssql driver — no Graph permission and no app secret held
by Find My Data:
The connector is strictly read-only (never DDL/DML), queries only the
databases in
FMD_SQL_DATABASES (re-checked at scan time), bracket-quotes
every catalog identifier and binds every value as a parameter (no SQL
injection surface), and bounds the sampled data: ≤5 values × ≤64 chars per
column over ≤100 columns, sampling only string-typed columns (numeric/date
values are never emitted), with the row count reported only as a magnitude
band — the exact count never becomes evidence. Grant the login the least
privilege that lets it read the catalog and SELECT the tables you want
profiled; a read-only login scoped to specific schemas is the recommended
posture. SQL’s native sensitivity classifications are not mapped to Purview
labels in v1 (unsupported); reading them is a gated follow-up (ADR-0032).
2.0d Google Drive (a service account, not Graph)
The Google Drive connector authenticates with a Google service account (JWT-bearer, read-onlydrive.readonly scope) the operator provides via
FMD_GDRIVE_SA_KEY_REF, optionally impersonating a user through domain-wide
delegation (FMD_GDRIVE_SUBJECT) — no Microsoft Graph permission involved:
The connector is read-only, only ever enumerates the drives you list
(re-checked at scan time), and the service-account private key is read by
reference and never logged. Drive ACLs are fully observable, so sharing is
mapped honestly (a publicly-shared file surfaces as anonymous exposure) with
completeness
complete. Native Google docs are exported to text for
classification. Google Drive labels (Label API) are not mapped in v1
(unsupported).
2.0e Box (a Box app, not Graph)
The Box connector authenticates with a Box app using the Client Credentials Grant (server-to-server; no user in the loop) the operator provides viaFMD_BOX_CLIENT_ID + FMD_BOX_CLIENT_SECRET_REF +
FMD_BOX_SUBJECT_ID — no Microsoft/Google permission involved:
The connector is read-only, only ever walks the folders you list (re-checked
at scan time), and the client secret is read by reference and never logged. Box
collaborations and shared links are observable, so sharing is mapped (an
open shared link surfaces as anonymous exposure) with completeness
partial
(inherited folder collaborations are not fully expanded per file — the
connector does not over-claim). Box classifications (metadata) are not
mapped to Purview labels in v1 (unsupported).
2.1 Files.Read.All vs. Sites.Selected
Files.Read.All grants read on every drive in the tenant; it is simple
but maximally broad. Sites.Selected grants nothing by itself — it requires
both Entra admin consent and an explicit per-site grant (via the
sites/{id}/permissions API or admin tooling) before any site is readable.
See Selected permissions in OneDrive and SharePoint.
Find My Data’s position:
- Default the read/scan identity to
Sites.Selectedand surface, per scope, the difference between “connector is consented” and “target is actually accessible.” The connector contract’sdiscoverScopes()exists precisely to validate reachable scopes rather than assume them. - Fall back to
Files.Read.Allonly where selected models cannot cover a required target (historically OneDrive personal drives; Microsoft has been extending selected-permission models —Files.SelectedOperations.Selectedand related — so re-verify coverage at implementation time and document any endpoint that forces the broader grant). - The mock connector models both SharePoint drives and a OneDrive scope so the scope-granting UX and per-scope accessibility states are exercised without a tenant.
- Implemented (ADR-0036, 26.7.16.14): setting
FMD_SP_SITES(comma-separated site path identifiers,host:/sites/Name) switches discovery to resolve only the listed sites (GET /sites/{id}per entry) — the tenant-widesearch=*enumeration is never issued, so least privilege holds by construction. Each discovered drive is tagged with its selection entry and re-checked at scan time, so removing a site fromFMD_SP_SITESstops its persisted drive being scanned (and drains it on re-register). Identifiers are charset- and dot-segment-validated (a../…traversal that would escape/sites/is rejected), and aFMD_SP_SITESthat is present but reduces to no valid entries fails closed at boot rather than silently reverting to the tenant-wide pilot. A granted site the app cannot yet reach (403/404) is skipped rather than failing the whole discovery. EmptyFMD_SP_SITESkeeps theSites.Read.Allbroad-read pilot for backward compatibility. Three adversarial-review defects (path-traversal, config fail-open, discovery-only gate) were fixed with regression tests.
2.2 driveItem delta
Initial inventory and incremental change tracking both use
driveItem delta.
Contract expectations implemented in this release (against the mock connector):
@odata.nextLink/@odata.deltaLinkare persisted as opaque cursors, exactly as returned, never parsed (connectors/types.ts; ADR-0008).410 Gone→ full resynchronization is a first-class result (ChangesResult.kind = "resync_required"), tested via the mock connector’s simulated 410.- Deletes, renames, and duplicate delivery are handled and tested via
scripted delta sequences (
scan.test.ts). - The eTag/cTag distinction (any-change vs. content-change version) drives the stage-cache invalidation matrix, so a rename or permission change does not re-fetch content.
2.3 Permission observation caveats
driveItem permissions returns direct and inherited grants, but the detail returned depends on the caller’s access. Find My Data therefore treats permission observations as evidence with explicit completeness, not ground truth:- Every permission observation carries a
completenessvalue —complete | partial | not_observable(packages/shared/src/domain-enums.ts) — and the acquiring authorization context is recorded on the observation envelope. - “No broad share observed” is never presented as “no broad share exists.” Downstream exposure/risk scoring consumes the completeness state.
- Broad audiences (anonymous links, organization links, tenant-wide groups) are stored as categories, not expanded into per-user edges.
2.4 extractSensitivityLabels and assignSensitivityLabel
Observation —
extractSensitivityLabels
refreshes label metadata from supported file content and has explicit failure
modes (notably encrypted content). The label observation result
records the method used (metadata | extract_api | fixture) and the label
state including unsupported and inaccessible; the exercised path is
fixture.
Remediation —
assignSensitivityLabel
is a protected, metered API. Practical consequences, all reflected in the
implementation:
- Enablement process: access must be requested from Microsoft (protected
API request/approval flow) before the tenant will honor calls; usage is
metered (billed). In this build the capability is behind
FMD_FEATURE_PURVIEW_LABEL_WRITE(defaultfalse) and/api/readyreportspurviewLabelWrite: blockeduntil enabled. - Justification requirements: label downgrade or removal requires a justification. The action state machine (ADR-0010) enforces justification plus approval by policy default for downgrade/removal, independent of the API requirement.
- Application-only limitations: some protected (encrypted) files cannot be
relabeled in application-only mode. The connector’s
previewLabelActionreturnssupported: falsewith limitations, and validation transitions the action to a blocked state with the reason (actions/service.ts). - Asynchrony and verification: the live operation is long-running; the state machine polls and then re-reads source state to verify — executor success text alone never marks an action succeeded.
- Marking caveat: Office-at-rest labeling does not imply desktop-client visual markings (headers/footers) were rendered; verification records label state, not rendering.
2.5 Label catalog listing (beta caveat)
The Graph label-list operation (security/informationProtection sensitivityLabels) is documented under/beta, which Microsoft states is not supported for
production applications. Per ADR-0008, the catalog is therefore an isolated
adapter (LabelCatalogSource) with three paths:
- Fixture import — default and exercised in this build.
- Customer-run
Get-Label(Security & Compliance PowerShell) CSV export/import — the recommended production path until a v1.0 API exists. - Future supported API — slot reserved.
(msTenantId, labelGuid). Names and hierarchy
are versioned display attributes; label names are never hard-coded and never
used as identity.
3. Throttling and Retry-After handling expectations
Per Microsoft Graph throttling guidance,
every live adapter must implement:
- Honor
Retry-Afteron 429/503 exactly; exponential backoff with jitter when the header is absent. - Retry classification: the durable job model (ADR-0007) already
distinguishes
throttledfromtransient,auth,unsupported,corrupt,policy_denied, andpermanent; throttled work re-queues with the provider-specified delay and does not burn retry budget the way permanent failures do. - Per-provider concurrency budgets and tenant/source fairness so one large scope cannot starve others; backpressure propagates upstream so acquisition never overwhelms extraction or persistence.
- Prefer delta over polling — the pipeline is delta-driven by design, which is the primary throttle-avoidance strategy.
- Request correlation — correlation IDs are part of the canonical observation envelope for diagnosing throttling with Microsoft support.
4. Change notifications
Microsoft Graph change notifications are built in this release (section 1.1;/api/ready reports changeNotifications
as mock in mock mode, and disabled/blocked/live in graph mode depending on
FMD_FEATURE_CHANGE_NOTIFICATIONS, resolvable Graph credentials, and
FMD_WEBHOOK_PUBLIC_URL — see section 5). Design position:
- Delta remains authoritative. Notifications are wake-up hints only; the pipeline processes changes exclusively from the delta feed. This means a deployment loses freshness, not correctness, when notifications are unavailable — every scope can still be reconciled by delta scans regardless.
- Subscription lifecycle is real operational surface: subscriptions expire and must be renewed ahead of expiry; lifecycle events must be consumed; duplicates must be deduplicated; silent/stale subscriptions and widening cursor lag need alarms; delivery latency varies by resource and can exceed the 15-minute processing target, which is why the internal SLO is defined from receipt or delta observation, not source event time.
- Deployment prerequisite: a publicly reachable, validated webhook endpoint (or supported Azure messaging delivery), which a local development deployment does not have.
5. Capability status (mirrors /api/ready)
GET /api/ready (packages/server/src/routes/health.ts) reports one honest
mode per capability: mock, live, blocked (configured/required but
prerequisites unmet — never silently faked), or disabled (not configured /
not built). Current build state and exact prerequisites to advance each:
The readiness payload never reports
live on a feature flag alone: live
additionally requires the relevant credentials to resolve (the *_REF env
var names a variable that actually holds a value); an enabled capability whose
credentials do not resolve reports blocked (routes/health.ts, tested in
routes/health.test.ts). Even so, do not set feature flags ahead of their
consent prerequisites: Graph rejects unconsented calls at run time (403), which
surfaces as failed work units and probe failures — never silent success.
Internal authorization (for contrast)
Independent of Microsoft permissions, all product APIs are governed by the product’s own capability-based authorization (kernel/authz.ts): role
grants are (capability, scope) tuples where scope is tenant-wide or
domain-scoped (e.g., an HR domain owner holds asset.read only within owned
domains), evaluated per request against the tenant context, with enforcement
tested. Remediation additionally enforces separation of duties (requester
cannot approve, ADR-0010). Every privileged operation lands in the per-tenant
hash-chained audit log. Data handling follows the minimized-evidence retention
profile (ADR-0011): bounded redacted excerpts, purpose-flagged artifacts, and
default-deny log redaction.