> ## Documentation Index
> Fetch the complete documentation index at: https://docs.findmydata.io/llms.txt
> Use this file to discover all available pages before exploring further.

# ADR 0036 sites selected least privilege

# ADR-0036: Sites.Selected — least-privilege SharePoint site resolution

**Status:** Accepted · 2026-07-17 — implemented in 26.7.16.14

## Context

ADR-0013 validated the live Graph connector read-only against the CDX tenant
using the application permissions **`Sites.Read.All` + `Files.Read.All`** — a
tenant-wide read grant, chosen only to stand up a working pilot quickly. Both
ADR-0013 and the permissions manifest recorded the intended production posture:
**`Sites.Selected`**, which grants nothing by itself and reads only the specific
SharePoint sites an admin has explicitly granted the app. Phase 1 makes that the
real code path so a production deployment can run at least privilege instead of
tenant-wide read.

## Decisions

### 1. An explicit site list drives resolution — no tenant-wide enumeration

When `FMD_SP_SITES` lists one or more sites, the connector's `allSites()`
resolves **only those sites** (`selectedSites()`), one `GET /sites/{id}` per
entry — it never issues the tenant-wide `GET /sites?search=*` enumeration. This
is the `Sites.Selected` posture: with that permission the search endpoint
returns nothing useful anyway, and more importantly the code **cannot** enumerate
beyond the granted list because that call path is not taken. When `FMD_SP_SITES`
is empty, discovery falls back to `search=*` (the `Sites.Read.All` read pilot),
unchanged — additive and backward compatible.

### 2. Path-form identifiers, charset- AND traversal-validated

Each `FMD_SP_SITES` entry is a site **path** identifier — `host:/sites/Name` (or
a bare host for the root). The composite `{host},{collectionId},{siteId}` id
Graph also accepts is deliberately **not** used in the list: it contains commas,
and `FMD_SP_SITES` is comma-separated, so a composite id would be shredded into
broken fragments. The path form addresses the same site and has no commas.

`encodeSiteId()` is the URL chokepoint: it strips a leading slash and asserts the
charset `^[A-Za-z0-9.,:/_-]+$` (rejecting spaces, `?`, `#`, `%` — so
query-string and percent-encoding injection can't reach a Graph URL). The
charset must permit `.` and `/` for the path form, so it is necessary but **not
sufficient**: a `../drives/{id}/root/children` entry passes the charset, and the
WHATWG URL parser would then normalize `/sites/../drives/…` into `/drives/…`,
escaping the `/sites/` segment to reach a non-granted endpoint. So `encodeSiteId`
**also rejects any `.`/`..` dot-segment**, keeping every request the connector
makes inside `/sites/{id}`. The same charset + dot-segment rule is enforced at
config load (a Zod `.refine`), so a malformed or traversal-shaped entry fails at
**boot**, not at first scan. *(Adversarial-review finding: the charset alone let
`..` escape the segment — fixed here.)*

### 3. A granted site the app cannot reach is skipped, not fatal

`selectedSites()` skips a site that returns `403`/`404` (grant not propagated, or
removed) and continues resolving the rest; any other status throws. A single
un-provisioned site degrades to "that site isn't scanned" rather than failing
discovery for the whole deployment — the same partial-availability posture the
other connectors take.

### 4. De-selection is enforced at SCAN time, not only at discovery

A selection that is checked only at discovery is a trap (the OneDrive review,
ADR-0029, caught the same one): removing a site from `FMD_SP_SITES` wouldn't stop
its already-persisted drive from being scanned. So `sp_drive` is wired into the
**same** scan-time machinery as every other selection-gated kind:

* Each drive discovered under a selection carries its **exact `FMD_SP_SITES`
  entry** as `ownerExternalId`. Broad-pilot (`search=*`) drives and mock
  fixtures carry a **null** owner and stay exempt.
* On **re-discovery the owner is refreshed** (`persistDiscoveredScope`), not
  frozen: a drive first seen under the broad pilot (owner null) and later
  resolved via a `Sites.Selected` entry has its owner **upgraded** to that entry
  (and back to null on the reverse switch), so the gate enforces the current
  selection. A plain `INSERT … ON CONFLICT DO NOTHING` would have kept the
  first-seen null owner and left such a drive gate-exempt forever — a
  re-review caught exactly this hole.
* **A null-owner `sp_drive` is blocked at the gate whenever the deployment is in
  Sites.Selected mode** (`spSelectionActive` = live connector + non-empty
  `FMD_SP_SITES`). This is the definitive enforcement: a drive on a site the
  operator **excluded** is never re-discovered, so the owner-refresh can't reach
  it and it keeps its pilot-era null owner — but in Sites.Selected mode a null
  owner is no longer a free pass, so it is refused (fail closed) at scan time via
  **every** path (manual, autonomous change-notification, CLI), immediately on a
  config tightening and without waiting for a re-connect. Broad-pilot (empty
  selection) and mock scopes keep the null-owner exemption. A second re-review
  caught that the owner-refresh alone missed these never-re-discovered excluded
  drives; this closes it.
* `sp_drive` is added to `GATED_KINDS`: at the `createScanCampaign` chokepoint (the
  manual `/api/scans` path, the autonomous change-notification path, and the CLI
  all funnel through it), an owned `sp_drive` scope is scannable only while its
  site entry is still in the current selection. A de-selected site's scope →
  `BlockedError`; emptying `FMD_SP_SITES` entirely leaves prior owned scopes
  **fail-closed** (blocked) until reconcile.
* `sp_drive` is added to the connect-time reconcile (`selectionByKind`), so a
  re-register **drains** de-selected sites' scopes and **resurrects** re-added
  ones — reconciling against the current selection, not against discovery, so a
  transient outage doesn't kill a still-selected site.

*(Adversarial-review finding: `sp_drive` had none of this wiring — fixed here.)*

### 5. A degenerate selection fails closed, never falls open

`FMD_SP_SITES` present but reducing to zero valid entries after the comma-split
and trim (e.g. `" "`, `","`, or a `"${SITES},"` templating mistake with `SITES`
unset) must **not** silently fall back to the tenant-wide `search=*` pilot —
that would quietly widen scope to the whole tenant. Config load **refuses to
boot** (`ConfigError`) in that case. An empty string is the documented "off"
value and still falls back to the broad-read pilot. *(Adversarial-review
finding: whitespace/comma-only degraded to a full-tenant sweep — fixed here.)*

## Consequences

* A production SharePoint scan can run on `Sites.Selected`: only the listed
  sites are resolved, only owned scopes are scannable, and de-selecting a site
  stops it being scanned at the gate (immediately) and drains it on re-register.
  Least privilege holds by **construction** (the enumeration call is not made)
  and is **enforced at scan time**, not merely configured.
* `Sites.Read.All` remains supported for the broad-read pilot (empty
  `FMD_SP_SITES`), so nothing regresses for the CDX validation path; broad-pilot
  scopes (null owner) stay exempt from the selection gate.
* OneDrive personal drives are still reached via the OneDrive connector
  (`Files.Read.All` + explicit `FMD_ONEDRIVE_USERS`, ADR-0029) — `Sites.Selected`
  does not cover personal drives, and that split is unchanged.

## Configuration

| Env var        | Purpose                                                                                                                                                                                                                                                                                      |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FMD_SP_SITES` | Comma-separated SharePoint site **path** identifiers to resolve, each `{host}:/sites/{name}` (or a bare host for the root). Empty ⇒ `search=*` broad-read pilot. Charset- and dot-segment-validated at boot. A value present but reducing to no valid entries refuses to boot (fail closed). |

## Verification

`connectors/graph/sites-selected.test.ts` + `kernel/config.test.ts` (offline):
with `FMD_SP_SITES` set, discovery resolves **only** the listed sites, tags each
scope with its selection entry, and the `search=*` endpoint is **never** called
(asserted on the observed request list); a `403`/`404` site is skipped while the
others resolve; empty `FMD_SP_SITES` falls back to `search=*` with **null-owner**
scopes; `encodeSiteId` accepts the path form and rejects spaces, `%`-escapes, and
`..`/`.` dot-segments (path-traversal); the scan-time gate makes a selected
site's drive scannable, a de-selected site's drive `BlockedError`, an
emptied-selection owned drive fail-closed, a null-owner drive **exempt in
broad-pilot/mock mode but blocked in Sites.Selected mode**, an owned drive with
no gate fail-safe, and the broad-pilot→Sites.Selected owner **upgrade** via
`persistDiscoveredScope`; and config **refuses to boot** on a whitespace/comma-only
or `..`-bearing `FMD_SP_SITES`. Full suite 512 green. **Three adversarial review
rounds (find → verify-fixes → verify-again) each surfaced a real defect —
(1) path-traversal, config fail-open, discovery-only de-selection; (2) owner
frozen at null on the pilot→Selected transition; (3) excluded (never-re-discovered)
pilot drives still gate-exempt — every one fixed above with regression tests
before shipping.**
