# Security architecture

_Last updated: July 8, 2026_

This is the deep reference for a security team evaluating Waydock. The shorter [/security](/security) page states the outbound-safety contract in plain terms; this page documents the underlying architecture, cites the file that enforces each control, and ends with a checklist you can audit yourself. Everything here describes the product as it behaves today, enforced server-side. Where a guarantee is an intentional ceiling or a posture rather than a certification, it says so plainly.

> **For agents:** this page is available as Markdown at [/docs/security-architecture.md](/docs/security-architecture.md).

## The threat we are built against

Waydock connects AI agents to a user's inbox, meetings, and chats. The defining risk of that category is an agent being talked into an action by content it just read: the EchoLeak class of prompt injection (CVE-2025-32711, the zero-click Microsoft 365 Copilot exfiltration). Two of the controls below (provenance gating and the outbound-send contract) are direct structural answers to it; the rest contain the blast radius of a stolen key, a compromised co-member, or an over-broad grant.

## Multi-tenancy and isolation

- **Org is the tenant.** Every user gets a personal org on signup; teams are many-to-many memberships with roles OWNER, ADMIN, and MEMBER. Personal-to-team conversion is a one-way, OWNER-gated flow.
- **Postgres Row-Level Security on every org-scoped table.** The runtime app client (`appPrisma`) connects as the `waydock_app` role, which is `NOSUPERUSER, NOBYPASSRLS`. A query with no org context set returns zero rows: it fails closed rather than leaking. Entered via `runInOrgContext` / `runInOrgSession`, which set the `app.current_org_id` and `app.current_user_id` GUCs. (`packages/core/src/db.ts`)
- **Per-member isolation, and its honest ceiling.** Tables carrying per-user data carry a stricter RLS policy requiring both `app.current_org_id` and `app.current_user_id`, so one co-member cannot read another's mail, cards, tasks, transcripts, or Teams messages inside the same org. This isolation is **logical (RLS), not cryptographic**: co-members share a single per-org data-encryption key, so at-rest encryption isolates orgs, not members. A `basePrisma` (BYPASSRLS) query or a compromise of the org key would cross the member boundary. That is an intentional ceiling (the standard B2B-SaaS posture), not an accident, and we hold the line on it deliberately. (`apps/waydock/src/lib/security/per-member-tables.ts`, and the AGENTS.md note "Member isolation is logical (RLS), not cryptographic")
- **`basePrisma` is the one deliberate cross-member path.** The BYPASSRLS client is confined to pre-session code (auth callbacks, crons, platform admin) and fire-and-forget writes that run after a request transaction closes. Every such read must filter by the owning `userId` (and `organizationId`); every such write must stamp `organization_id` explicitly, because service paths carry no GUC and the `WHERE` clause is the only guard.
- **Two CI tripwires keep the ceiling intentional.** `no-raw-cross-member-read.test.ts` blocks unjustified `basePrisma.<provider>.<read>` calls (add a justification or route through `readAcrossOrg`). `per-member-rls-coverage.test.ts` fails until any new per-member table is added to the acknowledged list, and a live verifier (`scripts/verify-per-member-rls.ts`) then confirms the policy exists in the running database. The acknowledged list holds 28 tables today. (`apps/waydock/src/lib/__tests__/no-raw-cross-member-read.test.ts`, `apps/waydock/src/lib/__tests__/per-member-rls-coverage.test.ts`)

## Encryption at rest

- **Envelope encryption with AWS KMS.** A KMS master key wraps per-scope data-encryption keys (DEKs), cached briefly. Payloads are AES-256-GCM, stored as a six-part envelope `kms.v1.<keyVersion>.<iv>.<authTag>.<ciphertext>`. (`packages/core/src/kms-crypto.ts`)
- **Per-purpose DEKs (domain separation).** Each secret class has its own `purpose`, so compromising one DEK cannot decrypt another class. The fields under column-level KMS encryption, from `COLUMN_REGISTRY`:

  | Field | Scope | Purpose |
  | --- | --- | --- |
  | OAuth access / refresh tokens | org | `oauth-token` |
  | Inbound provider webhook secrets (Fathom / Fireflies / Pocket) | org | `provider-webhook` |
  | Teams push renewal secrets | org | `push` |
  | Outbound alert webhook URL and secret | org | `webhook` |
  | Org-wide Mira BYOK key | org | `org-byok` |
  | Per-user Mira BYOK key | user | `mira-byok` |
  | TOTP seed | user | `totp` |
  | Channel chat window / held approval context | user | `channel-conversation` / `channel-approval` |

  Opt-in bulk content (email bodies, meeting transcripts, Teams message bodies) is encrypted under the pre-existing org DEKs (`email-body`, `meeting-transcript`, `teams-message`) and is off by default; absent the opt-in, Waydock keeps metadata and snippets and reads mail live at query time.
- **AAD binding.** The GCM additional-authenticated-data is built entirely inside the crypto module from the field's identity: `("at-rest", scope, scopeId, model, rowId, column, purpose, generation)`, SHA-256 hashed. A ciphertext therefore cannot be transplanted to another row, another column of the same row, or another org, and a per-row generation counter defeats replay after rotation. (`buildFieldAad`, `packages/core/src/kms-crypto.ts`)
- **Fail-closed crypto.** Transient KMS errors (throttle, timeout) retry and never coerce a value to null or write a partial row; tamper, wrong context, or access-denied surface as integrity errors rather than silent plaintext.

## Provenance gating (the injection defense)

Every agent tool declares a boolean `trustedContentSafe`. When a read tool returns attacker-controllable content (an email body, a meeting transcript, a Teams message), its output is wrapped in an `<external_content trusted="false" source="...">` envelope. Once any tool result in the conversation came back wrapped, `filterToolsForContext` removes every `write` and `destructive` tool from the model's tool list for the rest of that turn: the model literally cannot propose `waydock_send_email`, a card delete, or a draft save after it has read untrusted text. Detection is conservative (a user pasting the literal marker over-filters, which is the safe direction), and the taint carries across requests via the marker embedded in prior tool-result blocks. (`filterToolsForContext` and `conversationHasUntrustedContent` in `apps/waydock/src/lib/mira/anthropic-tools.ts`; the `trustedContentSafe` contract in `apps/waydock/src/lib/agent-tools/types.ts`)

This runs on the same tool registry that backs both the MCP server and the in-app Mira assistant, so the guarantee holds for external agents and in-app users alike. It is defense-in-depth layered on top of the approval card, not a replacement for it.

## The outbound-send contract

Every send path (the morning-brief cron, the MCP send tool, any automation) routes through one chokepoint, `apps/waydock/src/lib/email-send.ts`, which applies these rules and writes a log row ahead of calling Gmail or Microsoft Graph:

- **Allowlist-only recipients.** An unlisted address fails closed with `recipient_not_allowed`. There is no AI override and no trusted-sender bypass. Verified-self addresses are the only allowlist exemption.
- **Wildcard-proof send scope.** Sending to third parties requires the literal `write:mail.send` scope; it can never be bundled into a preset. The free `write:mail.send.self` scope is a hard boundary enforced before the allowlist machinery even runs: a self key may only reach the user's own verified inboxes, and any non-self recipient is rejected with `recipient_not_self`.
- **New-recipient cooldown.** A newly added recipient is blocked for 60 seconds (`ENFORCED_NEW_RECIPIENT_COOLDOWN_SECONDS`), a single hard constant, not user-tunable, because a shorter cooldown is the risky direction.
- **Server-enforced caps, clamped to hard ceilings.** Defaults are 500 sends per rolling 24h total (`DEFAULT_DAILY_CAP`) and 20 per recipient (`DEFAULT_PER_RECIPIENT_DAILY_CAP`). Users may tune these, but `clampCap` bounds every stored and read value to `[1, ceiling]`, and the ceilings are hard code constants no setting can exceed: 2000 per day (`MAX_DAILY_CAP`) and 100 per recipient (`MAX_PER_RECIPIENT_DAILY_CAP`).
- **Thread-only replies.** A reply's recipients are limited to the intersection of the thread's participants and the allowlist, so "reply" cannot be used as cover to email someone new.
- **One kill switch.** "Allow outbound sending" (`sending_enabled`) turns off every path and is checked at the moment of send, so it kills in-flight intent too.
- **Invisible-character scrubbing.** Zero-width, bidi-override, word-joiner, isolate marks, BOM, and the U+E0000 tag block (a steganographic channel for hidden instructions) are stripped before send.
- **HTML is opt-in per recipient** and runs through a strict sanitizer that removes scripts, iframes, event handlers, and any URL scheme other than http(s), mailto, or tel.

No silent retries: a failed send still consumes a cap slot, which brakes injection storms. No CC, BCC, or attachments from an agent today.

## Authentication

- **Per-user bearer keys, hashed at rest.** MCP keys carry the current `wdmcp_` prefix or the legacy `ddmcp_` prefix. The raw key is never stored: only its SHA-256 hash. Lookup is by hash, followed by a constant-time `timingSafeEqual` comparison over a second SHA-256 of both sides. Keys with NULL scopes (the retired wildcard shape) are refused outright, fail-closed. (`apps/waydock/src/lib/mcp-auth.ts`)
- **Per-key controls.** Each key checks: revocation (`revoked_at`), expiry (`expires_at`), an optional IP CIDR allowlist (`allowed_cidrs`), and a per-key tool denylist. Every required scope must be present literally; there is no wildcard expansion. A `catalog_version` is tracked per key so a bump to the tool catalog can require re-consent. (`apps/waydock/src/lib/mcp-auth.ts`, `apps/waydock/src/lib/mcp-server-meta.ts`)
- **OAuth with hardened state.** PKCE (S256), an OIDC nonce, and a CSRF value stored in an HttpOnly cookie and mirrored into the HMAC-signed state; the callback requires the cookie to match, defeating stolen-state replay. 10-minute TTL. (`apps/waydock/src/lib/oauth-state.ts`)
- **Least-privilege provider scopes**, tiered base / drafts / send, disableable per account without re-running OAuth. (`packages/core/src/oauth-scopes.ts`)
- **MFA and step-up.** Passkeys (primary), TOTP, and backup codes; a 15-minute step-up (sudo) window gates sensitive actions such as disabling MFA. Sessions are sealed iron-session cookies validated against `user_sessions`; revocation is final (`revokedAt`).

## Audit

Four durable logs, all queryable, all written by the same code paths the UI uses:

- **Auth audit** (`auth_audit_logs`): logins, OAuth connect/disconnect, MFA, key lifecycle, step-up. Identifiers are HMAC-peppered.
- **MCP audit** (`mcp_api_key_audit_logs`): every agent tool call (key, agent name, tool, outcome, latency, geo).
- **Org audit** (`organization_audit_events`): invites, role changes, member removal, org rename, convert-to-team.
- **Outbound calls** (`mcp_outbound_calls`): every third-party API call Waydock makes on a user's behalf, captured by an audited fetch wrapper and attributable to the driving MCP key.

Because the app UI and the MCP endpoint write to the same store, revoking a key once stops both in the same moment.

## AI data flow

Model calls go direct to the provider (Anthropic). There is no AI gateway, inference proxy, or middleman over user mail and chat content (no Vercel AI Gateway, OpenRouter, Helicone, or LiteLLM). The stated posture is fewer hops over user data. The in-app Mira assistant is ephemeral and per-seat, and user data is not used to train models.

## Compliance posture

This is a posture statement, not a certification. Waydock does not currently hold a SOC 2 (or equivalent) attestation. The technical controls are strong (RLS, KMS column encryption, MFA, step-up admin, unified audit, scoped OAuth, the outbound-send contract) and CI gates enforce typecheck, tests, lint, admin-purity, a dependency audit, secret scanning, and SHA-pinned GitHub Actions. The remaining delta to a SOC 2 attestation is governance and evidence (formal policies, vendor inventory, incident-response and DR test evidence), not missing controls. A recommended first scope is SOC 2 Type I, Security. See `apps/waydock/docs/compliance/soc2-readiness.md`.

## What to verify yourself

Each guarantee maps to the file that enforces it. Paths are relative to the repository root.

| Guarantee | Enforcing file |
| --- | --- |
| App role cannot bypass RLS; no-org query returns zero rows | `packages/core/src/db.ts` |
| Per-member RLS requires org AND user_id (logical, not cryptographic) | `apps/waydock/src/lib/security/per-member-tables.ts` |
| CI blocks unjustified cross-member `basePrisma` reads | `apps/waydock/src/lib/__tests__/no-raw-cross-member-read.test.ts` |
| New per-member tables must join the RLS sweep | `apps/waydock/src/lib/__tests__/per-member-rls-coverage.test.ts` |
| AES-256-GCM envelope, per-purpose DEKs, AAD binding | `packages/core/src/kms-crypto.ts` |
| Provenance gating removes write tools after untrusted content | `apps/waydock/src/lib/mira/anthropic-tools.ts` |
| Tool trust contract (`trustedContentSafe`) | `apps/waydock/src/lib/agent-tools/types.ts` |
| Allowlist, caps (2000/day, 100/recipient ceilings), cooldown, kill switch, self-send boundary, invisible-char scrub | `apps/waydock/src/lib/email-send.ts` |
| Bearer keys SHA-256 hashed, constant-time compare, CIDR / denylist / expiry / revocation | `apps/waydock/src/lib/mcp-auth.ts` |
| OAuth PKCE + OIDC nonce + CSRF-cookie-bound state | `apps/waydock/src/lib/oauth-state.ts` |
| Tiered, per-account-disableable provider scopes | `packages/core/src/oauth-scopes.ts` |
| Audit tables (auth, MCP, org, outbound) | `apps/waydock/prisma/schema.prisma` (`auth_audit_logs`, `mcp_api_key_audit_logs`, `organization_audit_events`); `apps/waydock/src/lib/outbound-audit.ts` |
