Docs
Security architecture
This is the deep reference for a security team evaluating Waydock. The shorter /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.
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 thewaydock_approle, which isNOSUPERUSER, NOBYPASSRLS. A query with no org context set returns zero rows: it fails closed rather than leaking. Entered viarunInOrgContext/runInOrgSession, which set theapp.current_org_idandapp.current_user_idGUCs. (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_idandapp.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. AbasePrisma(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") basePrismais 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 owninguserId(andorganizationId); every such write must stamporganization_idexplicitly, because service paths carry no GUC and theWHEREclause is the only guard.- Two CI tripwires keep the ceiling intentional.
no-raw-cross-member-read.test.tsblocks unjustifiedbasePrisma.<provider>.<read>calls (add a justification or route throughreadAcrossOrg).per-member-rls-coverage.test.tsfails 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, fromCOLUMN_REGISTRY:Field Scope Purpose OAuth access / refresh tokens org oauth-tokenInbound provider webhook secrets (Fathom / Fireflies / Pocket) org provider-webhookTeams push renewal secrets org pushOutbound alert webhook URL and secret org webhookOrg-wide Mira BYOK key org org-byokPer-user Mira BYOK key user mira-byokTOTP seed user totpChannel chat window / held approval context user channel-conversation/channel-approvalOpt-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.sendscope; it can never be bundled into a preset. The freewrite:mail.send.selfscope 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 withrecipient_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, butclampCapbounds 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 legacyddmcp_prefix. The raw key is never stored: only its SHA-256 hash. Lookup is by hash, followed by a constant-timetimingSafeEqualcomparison 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. Acatalog_versionis 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 |