Security review package (Architecture Review Board)
This document is the security review package for a customer organization’s Architecture Review Board evaluating a deployment of Find My Data, release 26.7.15.0 (2026-07-15). It consolidates the deployment model, trust boundaries, identity and access design, data protection, secrets handling, Microsoft 365 permission posture, ingress security, audit, AI governance, and supply-chain posture into one reviewable package, with every control traced to the file or test that implements it. It is a companion to the threat model and the Microsoft permissions manifest; the platform overview explains the product itself. Honesty invariant. Find My Data documentation never overstates its state. Every capability in this document is labeled with one of three statuses, and a gated capability is never described as simply working:GET /api/ready (packages/server/src/routes/health.ts) reports the current mode of every integration — mock, live, blocked, or disabled — so a reviewer can verify the running system’s claims directly.
1. System context and deployment model
1.1 Deployment shape
Find My Data is single-tenant and customer-hosted. The entire data plane — API, worker, database, evidence artifacts, audit trail — runs inside the customer’s own environment. There is no multi-tenant SaaS data plane.- One server process (
packages/server/src/main.ts) hosts the HTTP API (Hono) and an in-process durable worker loop over a SQLite-backed work queue (ADR-0002). The worker can also run as a separate process (bun run worker,packages/server/src/worker-main.ts) against the same database. - The web UI is a separate React SPA (
packages/web/) that talks only to the API. - Persistence is a single SQLite file (
FMD_DB_PATH, default./data/fmd.db) behind role interfaces (ADR-0003), plus a purpose-flagged artifact directory (FMD_ARTIFACT_DIR, default./data/artifacts). - Runtime is Bun 1.3.14; there is no Node.js dependency.
1.2 Azure reference deployment (infra/main.bicep)
The repository ships a resource-group-scoped Bicep template as the reference deployment. What it provisions (all verified in infra/main.bicep):
Honest caveats the ARB should weigh:
- The template is built but has not been deployed and validated live; STATUS.md records
az bicep buildvalidation as pending a CLI environment. - The template sets
FMD_AUTH_MODE=entra, but the Entra OIDC sign-in flow is not built (see §3.2) — interactive sign-in against a deployment of this template returns 501 today. The template is a hardened target shape, not a turnkey production system. - Key Vault
publicNetworkAccessisEnabledin the template, with an inline comment directing production deployments to tighten to Private Endpoints. Scale beyond single-writer SQLite (managed PostgreSQL, separate worker app) is documented, not templated.
1.3 SaaS control plane: licensing and fleet health only
The vendor control plane (“Find My Data Control”) never receives customer content. The customer-side implementation exists (packages/server/src/controlplane/):
- Entitlement (licensing): a signed token verified locally (
entitlement.ts) with an offline grace window — the product keeps running from the last valid entitlement if Control is unreachable. This build verifies with HMAC-SHA256 over a shared secret; production is documented as asymmetric verification against Control’s public key. The token carries licensing metadata only (plan, limits, validity window). - Fleet-health telemetry: a strictly allowlisted payload (§4.5) with a transparency preview and customer opt-out.
- Not built: the receiving Control endpoint itself does not exist. Nothing is transmitted anywhere today; the telemetry payload is assembled, validated, and previewable locally.
2. Trust boundaries and data flows
The authoritative boundary analysis, threat scenarios (T1–T16), and residual-risk table are in the threat model. Summary view (boundary numbers match the threat model’s §1.2): Key properties, each expanded in the sections below:3. Identity and access
3.1 Application identities (Microsoft side)
The live path uses separate Entra ID app registrations with independent credentials so that the component that touches untrusted content at volume (the scanner) never holds write capability (permissions manifest §1.1):
This separation gives blast-radius containment (a scanner compromise cannot mutate tenant state), independent rotation/revocation, and consent screens that map 1:1 to product capabilities.
3.2 User authentication
- Dev identity mode (
FMD_AUTH_MODE=dev, ADR-0004) is the exercised path: a persona picker over seeded fixture principals, a persistent UI banner, andx-fmd-auth-modeon every response. - Production lockout is enforced, not advisory (
packages/server/src/kernel/config.ts):FMD_ENV=productionwithFMD_AUTH_MODE=devis a hard startup failure (ConfigError, tested inkernel/config.test.ts), and production also refuses the default session secret at boot. Defense in depth: even if a dev-mode session cookie exists, production rejects it at request time (devSessionInProdinpackages/server/src/kernel/http.ts), and dev persona endpoints 404 outside development. - Entra OIDC is the production seam, designed in ADR-0004 but not built: the sign-in endpoint returns an explicit 501 (
packages/server/src/identity/auth-routes.ts), and no OIDC token-validation code exists yet. The schema is ready for it — external identities map to internal principals by a unique(issuer, subject)index (migration0001), never by email or display name./api/readyreportsblockedordisabled, never a falselive.
3.3 Session security
Implemented inpackages/server/src/identity/sessions.ts and packages/server/src/kernel/http.ts:
Residual (recorded in the threat model, T4): the HMAC key is a single env-provided secret with no rotation story in the slice (Key Vault in the Azure design is the rotation seam), and there is no step-up authentication for sensitive actions.
3.4 Authorization: deny-by-default capability model
Enforcement uses capabilities, not roles (packages/shared/src/capabilities.ts, packages/server/src/kernel/authz.ts). A request is allowed only when the session principal holds the required capability at a scope covering the resource; UI hiding is never enforcement.
- 41 capability strings in a closed namespace (e.g.
asset.evidence.excerpt.read,action.approve,audit.read). - 5 visible roles (
platform_admin,governance_admin,domain_owner,remediation_approver,auditor) map to(capability, scope)grants where scope istenantordomain. - Domain scoping is resolved at evaluation time against active
ownership_assignments(validity-windowed), not cached in the session. A domain-scoped grant with no domain in the check is permitted only for “list my things” calls, and callers must then filter bydomainScopeFilter()— a contract that was adversarially reviewed and hardened with regression tests (packages/server/src/testing/authz-fixes.test.ts). - Deliberate exclusions:
platform_adminholds noasset.evidence.*,review.*, oranalyst.*capabilities — operating the platform does not grant access to business content.remediation_approverapproves and cancels but cannot draft or submit;domain_ownerdrafts and submits but cannot approve (separation of duties, enforced per action with an auditedapproval_self_dealing_deniedevent,packages/server/src/actions/service.ts, tested inactions.test.ts). - Existence does not leak: cross-domain object probes return 404, not 403 (tested in
packages/server/src/review/review.test.ts). - Every authorization denial appends an
authorization_deniedaudit event (http.tsonError).
4. Data protection
4.1 Data-handling profiles
Each tenant carries adata_handling_profile (migration 0001): minimized_evidence (default, implemented and exercised), with metadata_only and enhanced_evidence as schema-ready values that are not implemented beyond configuration validation. The minimized-evidence profile is specified in ADR-0011; what is and is not persisted is enumerated in threat model §4.
4.2 Evidence access levels
Evidence access is tiered by capability, so seeing that an asset exists never implies seeing its content:asset.metadata.read— names, locations, versionsasset.evidence.summary.read— detector/keyphrase summariesasset.evidence.excerpt.read— bounded redacted excerpts (callers without it receiveexcerpts: null)asset.source.open— deep link to the source system
evidence_access audit category).
4.3 Bounded excerpts and preview redaction
- Owner-review excerpts are bounded to ≤ 240 characters each, ≤ 3 per asset version (
EXCERPT_MAX_CHARS,EXCERPT_MAX_COUNTinpackages/server/src/scan/pipeline.ts) and pass through digit masking (SSN-like patterns, currency amounts, dates) before storage. Honest caveat from the threat model: the redaction patterns are fixture-tuned (US formats) — a demonstration of the mechanism, not a production-grade PII redactor. - Teams/Exchange message previews are length-bounded and identifier-redacted before persistence (
packages/server/src/intelligence/redact.ts: SSN, card-like, and long digit runs masked); detector signals are kept, raw identifier values are not. This redaction was one of the five second-review findings and carries a regression test.
4.4 Log redaction (default-deny)
The logger (packages/server/src/kernel/log.ts) enforces redaction structurally: only allowlisted field keys (SAFE_KEYS) are emitted; string values must match a conservative opaque-token pattern (SAFE_VALUE, ^[A-Za-z0-9_.:/-]{1,120}$) or are replaced with [redacted]; unknown keys are dropped and counted; sizes are logged as bands (sizeBand). Names, paths, URLs, excerpts, prompts, tokens, hashes, and embeddings never appear in default logs — asserted by an end-to-end log-scan test (kernel/log.test.ts and the acceptance suite). Unhandled HTTP errors log only the error class name.
4.5 What never leaves the boundary
The only data ever destined for the vendor is the fleet-health payload (packages/server/src/controlplane/telemetry.ts), and it is guarded three ways:
- Strict allowlist schema:
FleetTelemetrySchemais a Zod.strict()object — any field not on the allowlist fails validation, so an over-sharing field can never be added by accident. The payload is validated before it is returned (buildFleetTelemetryfails closed). - Denylist tripwire:
SENSITIVE_FIELD_DENYLIST(substrings likename,path,excerpt,content,domain,asset,email,tenantid,secret,token) backs a test (controlplane.test.ts) so a future edit that adds a sensitive field name fails CI. - Coarse values only: the deployment id is a non-reversible hash of the internal tenant id; queue depth, dead letters, and scale are reported as bands, never exact figures; the only raw counts permitted are scanner counts (operational, not content).
4.6 Retention
Bounds are implemented (excerpt caps, bounded extracted text, purpose flags); retention durations are undecided and no scheduled deletion job exists —retention_class is recorded intent, not enforced behavior (threat model §4.3). ADR-0011 also documents an honest deviation: bounded extracted text (≤ 20,000 chars) is retained as a purpose-flagged artifact to enable source-free re-scoring, with envelope encryption and per-class TTLs as recorded follow-ups.
Every artifact read must declare a purpose, and absence of the flag means denial (ObjectStore.get, packages/server/src/kernel/objectstore.ts). Extracted-text artifacts carry classification_inference and owner_review flags only; no artifact in this build carries training or evaluation flags.
5. Secrets management
Residual (threat model T14): once live credentials exist, a leaked env var is a full credential in the local slice; managed identity + Key Vault in the Azure design is the mitigation, and a rotation/revocation runbook is a documented prerequisite for live tenant connection.
6. Microsoft 365 permissions
The full manifest — every permission, why it is needed, its least-privilege alternative, and its status — is the Microsoft permissions manifest. ARB-relevant posture:- Least privilege by default: the recommended read/scan grant is
Sites.Selected, which grants nothing until both admin consent and an explicit per-site grant exist.Files.Read.Allis the documented fallback only where selected models cannot cover a target. The live CDX pilot ran withSites.Read.All+Files.Read.Allfor speed and the manifest says so explicitly;Sites.Selectedremains the production recommendation. - Protected/metered APIs are treated as gates, not details: Purview label write (
assignSensitivityLabel) is a protected, metered API requiring a Microsoft enablement process; the product reportspurviewLabelWrite: blockedand transitions actions to a blocked state with a reason rather than simulating success. Teams message acquisition (getAllMessages) is likewise protected/licensed and separately gated. - Exchange scoping: the Exchange connector reads only explicitly listed mailboxes (
FMD_EXCHANGE_MAILBOXES), with Exchange Online Application Access Policy / RBAC-for-Applications scoping as the documented deployment requirement — never indiscriminate tenant-wide capture. - Honest observation semantics: permission observations carry a
completenessstate (complete | partial | not_observable); “no broad share observed” is never presented as “none exists”. Label state is reportedunknownwhen the label-read permission is absent — the CDX run did exactly this rather than guessing.
7. Webhook / ingress security
The Graph change-notification receivers (/api/webhooks/graph/notifications, /api/webhooks/graph/lifecycle in packages/server/src/notifications/routes.ts) are necessarily unauthenticated — Microsoft Graph posts to them. Trust is established as follows (packages/server/src/notifications/subscriptions.ts):
- Per-subscription secret:
clientStateis derived asHMAC-SHA256(sessionSecret, "fmd-sub:" + subscriptionId)and registered with Graph at subscription creation. It is never persisted raw — only its SHA-256 hash is stored (for audit display), and the receiver recomputes the expected value on demand. - Constant-time verification before any side effect: the receiver compares with
timingSafeEqual; nothing in the notification body is trusted until the comparison passes. The lifecycle receiver applies the same verification — an early bypass when the field was absent was found in adversarial review and fixed with a regression test (ADR-0014 §1). - No write on mismatch: a failed or unknown-subscription verification records a metric only — no database row is written, so an attacker who learns an external subscription id cannot amplify writes into the tenant. (This was itself a fixed review finding.)
- Validation handshake: subscription creation echoes Graph’s
validationTokenastext/plain, per the Graph contract. - Notifications are hints, never content: a verified notification only triggers reconciliation through the existing authenticated delta feed; deduplication bounds repeat hints.
notifications.test.ts); live Graph subscriptions additionally require FMD_FEATURE_CHANGE_NOTIFICATIONS=true and a public HTTPS FMD_WEBHOOK_PUBLIC_URL, and are an explicit BlockedError otherwise. A dev simulator drives the same receiver path; on CDX, a simulated notification triggered a real read-only delta reconcile.
8. Audit and tamper evidence
Implemented inpackages/server/src/kernel/audit.ts:
- Append-only, per-tenant hash chain: each
audit_eventsrow links to its predecessor viaprev_hashand a SHA-256 hash over the tenant id, sequence number, actor, category, action, target, outcome, safe details, and timestamp; the append runs in an immediate transaction so sequence numbers cannot interleave. - Category coverage: ten categories (
auth,config,governance,scan,evidence_access,review,analyst,action,model,operations) span the privileged surface — authorization denials, evidence/excerpt access, review decisions, Analyst queries, the full action lifecycle including self-approval denials, model releases, and configuration changes. Category coverage across the full product journey is asserted by test (acceptance suite; see STATUS.md). - Content-safe by contract:
details_safe_jsonholds only structured references (ids, versions, outcomes) — never excerpts, paths, prompts, or names. - Verification:
verifyAuditChainrecomputes the chain and returns the sequence number of the first broken link (kernel/audit.test.ts).
9. AI governance
- Provider-neutral gateway: every model call goes through
AiGateway(packages/server/src/aigateway/gateway.ts). The default — and only exercised — provider is a deterministic mock; Azure OpenAI and Anthropic adapters are built and config-gated. Selecting a live provider without complete configuration is an explicitBlockedError, never a silent fallback to a differently governed provider (ADR-0005). - Provenance for every generation: each call records a
generation_runsrow — task, provider, model, template version, and an input hash (never prompt bodies) — with a failure row recorded on error. - Versioned templates with injection guards: prompt templates are versioned (
packages/server/src/aigateway/templates.ts), and all untrusted input (document content, user questions) is wrapped in labeled<untrusted-data>delimiters with an explicit not-an-instruction rule (INJECTION_GUARD,renderUntrusted). - The real security boundary is outside the model: any plan the model produces is treated as untrusted output — Zod-validated against an allowlisted semantic schema, with tenant/domain predicates injected server-side that the plan cannot set or widen (
packages/server/src/analyst/executor.ts). Injection resistance is tested with hostile questions and hostile fixture documents (analyst/analyst.test.ts); the threat model is explicit that this is proven only against the mock provider and must be re-run before enabling live providers. - Human approval with separation of duties: AI taxonomy output is a draft requiring human approval (
governance.taxonomy.approve); model/ensemble releases require approval by a principal different from the proposer, enforced in code (packages/server/src/models/pipeline.ts), not just by capability split, and tested (models/pipeline.test.ts). Nothing material auto-applies.
10. Supply chain and SDLC
Deliberately minimal runtime dependency surface (verified against the workspacepackage.json files; bun.lock is committed):
Crypto, SQLite, and the test runner are Bun built-ins. Dev-only tooling:
typescript, oxlint, vite/@vitejs/plugin-react, type packages.
Verification gates (STATUS.md Verification record):
bun test packages/shared packages/server→ 196 pass / 0 fail across 30 test files, including the security/acceptance suite (IDOR/404, production-refuses-dev-auth at boot and request level, analyst injection, action idempotency/drift/self-approval, end-to-end log-redaction scan, audit chain verification).bun run typecheck(all three packages),bun run lint, andbun run buildare clean.- Two multi-agent adversarial review passes: the first (5 lenses) confirmed 26 findings on the base build, all fixed with regression tests (review findings); the second (3 lenses) on the roadmap features confirmed 5 findings — lifecycle-webhook clientState bypass, unauthenticated write-amplification, Teams external-attachment idempotency,
/api/readyblocked-vs-live honesty, message-preview identifier redaction — all fixed with regression tests (ADR-0014). - All fixture data is synthetic (fictional Meridian Grove Holdings); no real tenant data or credentials in the repository.
^ ranges (pinned only by the lockfile); there is no vulnerability scanning, SBOM generation, artifact signing, or CI provenance yet; Bun itself is trusted implicitly. These are recorded follow-ups, not claimed controls.
11. Residual risks and known limitations
Stated plainly, from STATUS.md and the threat model:- Single-writer SQLite. Fine at slice scale; the migration triggers and the PostgreSQL/Blob/Service Bus path are documented in capacity-model.md and ADR-0003. Authorization is application-layer over one shared file — there is no physical tenant separation and no row-level security.
- Entra OIDC sign-in is not built. Production boot enforces
FMD_AUTH_MODE=entra, but the code flow returns 501 and no OIDC token-validation implementation exists yet. A production deployment is not usable for interactive sign-in until this lands. - Cross-tenant isolation is enforced by convention, not adversarially exercised. Every table is tenant-keyed and repositories refuse unscoped queries, but only one tenant has ever been seeded (threat model T11).
- Audit is tamper-evident, not tamper-proof — no external anchoring of chain heads yet (T15).
- Retention sweeps are not implemented;
retention_classis recorded intent (ADR-0011). Bounded extracted text is retained by design, documented as a deviation. - Mock-tenant fixture state is in-memory — a server restart resets fixture mutations while the DB keeps observations (dev-only quirk; a delta scan reconciles).
- Activity signals are not enabled; risk coverage surfaces
not_enabledhonestly rather than guessing. - Teams/Exchange messages are not yet mapped to business-domain ownership, so their read views are admin/audit-scoped (deny-by-default) until that mapping exists.
- AI injection resistance is proven against the mock provider only; live providers require re-running the injection suite before enablement (T5).
- No TLS inside the slice — transport security is a deployment concern (Container Apps ingress terminates TLS in the reference deployment); no secret rotation, no step-up auth, no support-access model, no disaster-recovery targets defined.
- Live label write and live subscriptions remain gated on Microsoft protected-API enablement and a public webhook URL respectively; both report
blockedhonestly until then. - Minor functional gaps: finding status transitions are read-only in the UI; action execution runs synchronously in-request via the queue-compatible executor; one Analyst aggregate (
lastModifiedAtper group) returns null.
12. ARB checklist: control → implementation → evidence
Related reading: threat model · permissions manifest · platform overview · CDX test runbook · capacity model · architecture · STATUS.md · ADRs 0001–0014.