# Research — Graph Contracts: LIST `$expand` Parity Fix

## Context
This feature fixes a capability mismatch between LIST vs GET Graph calls:
- `MicrosoftGraphClient::getPolicy()` already supports `$expand` (via query sanitization).
- `MicrosoftGraphClient::listPolicies()` currently does **not** forward `$expand` at all.

Downstream impact: `EntraAdminRolesReportService` lists `entraRoleAssignments` and then reads `principal.displayName`. The contract allowlist permits `principal`, but it is never requested on LIST today, so the report frequently falls back to `"Unknown"`.

## Decisions

### 1) `$expand` input normalization
**Decision**: Accept `expand` as either:
- comma-separated string (e.g. `"principal,roleDefinition($select=id,displayName)"`)
- array of strings (e.g. `["principal", "roleDefinition($select=id,displayName)"]`)

Normalization steps (applied consistently inside `GraphContractRegistry::sanitizeQuery()`):
- Split on **top-level commas only** for string input (commas inside balanced parentheses are not separators).
- Trim whitespace.
- Drop empty tokens.
- Dedupe while preserving first-seen order.

**Rationale**: This matches the spec clarifications and keeps all LIST/GET query-shape logic in the contract registry (single source of truth).

Note: For subquery/select-style expansions, prefer passing an array form (one token per expansion) to avoid ambiguity.

**Alternatives considered**:
- Normalize in `MicrosoftGraphClient` only: rejected because sanitization must remain centralized.

### 2) Capability allowlist enforcement
**Decision**: Keep exact-match allowlist behavior via `array_intersect()`.

**Rationale**: Prevents partial matches and blocks accidental subquery injection. Existing registry behavior is already exact-match.

### 3) Performance/safety caps for `$expand`
**Decision**: Apply a hard cap during normalization:
- `maxItems = 10` allowed expand tokens (keep first 10 allowed after allowlist)
- `maxTokenLen = 200` per token (tokens exceeding are treated as invalid and dropped)

**Rationale**:
- Contract allowlists are small in this repo (most policy types allow 0–2 expands).
- A small cap prevents pathological caller inputs from bloating URLs and logs.
- `200` aligns with the existing bounded-string pattern used in `InventoryMetaSanitizer::boundedStringList($values, 25, 200)`.

**Alternatives considered**:
- `maxItems = 25` to match `InventoryMetaSanitizer`: rejected as unnecessary for Graph query params.
- Only cap total string length: rejected because token count is easier to reason about and test.

### 4) Diagnostics when sanitize removes/truncates `$expand`
**Decision**: Emit a structured log record when:
- disallowed expand tokens are dropped
- tokens are dropped due to maxTokenLen
- allowed tokens are truncated due to maxItems

Logging behavior:
- Non-production: `Log::warning('Graph query sanitized', [...])`
- Production: `Log::debug('Graph query sanitized', [...])`

Context fields to include:
- `policy_type`
- `query_key` = `'$expand'`
- `requested` (normalized tokens)
- `allowed` (filtered tokens)
- `removed` (dropped tokens)
- `reason` (e.g. `disallowed`, `too_long`, `max_items`)

**Rationale**:
- The spec requires that misuse is detectable in non-production.
- Production should be low-noise.
- This matches existing patterns where services log structured `type`/`reason` context (e.g. inventory dependency extraction warnings).

**Alternatives considered**:
- Rely on `GraphResponse->warnings` only: rejected because `GraphLogger` logs warnings at info level, which can be noisy in production and doesn’t guarantee visibility in non-prod.
- Introduce a new event/telemetry system: rejected as out of scope for this fix.

## Testing approach (selected)
- Unit test `GraphContractRegistry::sanitizeQuery()` for `$expand` normalization:
  - comma-separated string is split
  - disallowed tokens dropped
  - max-items behavior keeps first N allowed
  - emits `Log::warning` in non-prod when removal occurs
- Unit test `MicrosoftGraphClient::listPolicies()` request shape using `Http::fake()` + `Http::assertSent()`:
  - when `expand` is provided and allowlisted, outbound URL includes `%24expand=...`
- Unit/feature test for `EntraAdminRolesReportService`:
  - ensures role assignment list call requests `expand=principal`
  - ensures payload uses `principal.displayName` when present
