Skip to main content

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 build validation 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 publicNetworkAccess is Enabled in 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, and x-fmd-auth-mode on every response.
  • Production lockout is enforced, not advisory (packages/server/src/kernel/config.ts): FMD_ENV=production with FMD_AUTH_MODE=dev is a hard startup failure (ConfigError, tested in kernel/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 (devSessionInProd in packages/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 (migration 0001), never by email or display name. /api/ready reports blocked or disabled, never a false live.

3.3 Session security

Implemented in packages/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 is tenant or domain.
  • 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 by domainScopeFilter() — a contract that was adversarially reviewed and hardened with regression tests (packages/server/src/testing/authz-fixes.test.ts).
  • Deliberate exclusions: platform_admin holds no asset.evidence.*, review.*, or analyst.* capabilities — operating the platform does not grant access to business content. remediation_approver approves and cancels but cannot draft or submit; domain_owner drafts and submits but cannot approve (separation of duties, enforced per action with an audited approval_self_dealing_denied event, packages/server/src/actions/service.ts, tested in actions.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_denied audit event (http.ts onError).

4. Data protection

4.1 Data-handling profiles

Each tenant carries a data_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:
  1. asset.metadata.read — names, locations, versions
  2. asset.evidence.summary.read — detector/keyphrase summaries
  3. asset.evidence.excerpt.read — bounded redacted excerpts (callers without it receive excerpts: null)
  4. asset.source.open — deep link to the source system
Excerpt reads are additionally domain-scoped and audited (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_COUNT in packages/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:
  1. Strict allowlist schema: FleetTelemetrySchema is 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 (buildFleetTelemetry fails closed).
  2. Denylist tripwire: SENSITIVE_FIELD_DENYLIST (substrings like name, 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.
  3. 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).
And to restate §1.3: since no Control endpoint exists, nothing is transmitted at all in this release; the payload is inspectable via the transparency preview and is opt-out.

4.6 Retention

Bounds are implemented (excerpt caps, bounded extracted text, purpose flags); retention durations are undecided and no scheduled deletion job existsretention_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.All is the documented fallback only where selected models cannot cover a target. The live CDX pilot ran with Sites.Read.All + Files.Read.All for speed and the manifest says so explicitly; Sites.Selected remains 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 reports purviewLabelWrite: blocked and 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 completeness state (complete | partial | not_observable); “no broad share observed” is never presented as “none exists”. Label state is reported unknown when 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: clientState is derived as HMAC-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 validationToken as text/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.
Status honesty: the receiver, lifecycle handling, renewal sweep, and reconcile path are built and tested (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 in packages/server/src/kernel/audit.ts:
  • Append-only, per-tenant hash chain: each audit_events row links to its predecessor via prev_hash and 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_json holds only structured references (ids, versions, outcomes) — never excerpts, paths, prompts, or names.
  • Verification: verifyAuditChain recomputes the chain and returns the sequence number of the first broken link (kernel/audit.test.ts).
Honest limitation (threat model T15): the chain detects tampering; it does not prevent it. An attacker with direct database write access can rewrite the chain from genesis. External anchoring of chain heads (write-once store or customer SIEM) is the documented follow-up, not built.

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 explicit BlockedError, never a silent fallback to a differently governed provider (ADR-0005).
  • Provenance for every generation: each call records a generation_runs row — 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 workspace package.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/server196 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, and bun run build are 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/ready blocked-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.
Honest gaps (threat model T13): dependency versions use ^ 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:
  1. 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.
  2. 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.
  3. 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).
  4. Audit is tamper-evident, not tamper-proof — no external anchoring of chain heads yet (T15).
  5. Retention sweeps are not implemented; retention_class is recorded intent (ADR-0011). Bounded extracted text is retained by design, documented as a deviation.
  6. 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).
  7. Activity signals are not enabled; risk coverage surfaces not_enabled honestly rather than guessing.
  8. 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.
  9. AI injection resistance is proven against the mock provider only; live providers require re-running the injection suite before enablement (T5).
  10. 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.
  11. Live label write and live subscriptions remain gated on Microsoft protected-API enablement and a public webhook URL respectively; both report blocked honestly until then.
  12. 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 (lastModifiedAt per 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 00010014.