Skip to content

Response Minimization & Cross-Tenant Masking ​

How the backend keeps internal and cross-tenant data out of HTTP responses β€” and the one invariant that ties the two mechanisms together.

The two mechanisms ​

Every response that can carry a Mongoose document passes through up to two transforms:

  1. Minimization (DTO mappers) β€” toWorkspaceResponse, toConfigurationResponse, toCompanyResponse, toDraftDetailResponse (in each module's utils/). These are allowlist mappers: they project the document down to the client-facing fields, so server-only fields (AI prompts, __v, userId, parentWorkspaceId, embeddings, …) never serialize. This applies to every response of that type, own-tenant or not.

  2. Cross-tenant masking β€” applied only to documents returned to a foreign tenant (a company that received a share). Two functions:

    • WorkspaceService.maskWorkspaceForViewer β€” for a shared workspace, deletesrowSharing, memberAccess, sharedWithCompanies, sharedWithUsers, and filters rowSharing.hiddenFields columns out of tableHeader.
    • SharedAccessService.maskForViewer β€” for shared record rows (ParseResults), strips each foreign row's workspace hiddenFields (the per-workspace column mask).

A third piece, the MongooseLeakInterceptor (see the DTO-leak guard), is a dev/test tripwire that flags any response still carrying a raw doc (Document / __v / ObjectId _id). It's opt-in (DTO_GUARD_WARN / DTO_GUARD_THROW) and a prod no-op.

The invariant ​

A field that a mask can strip must be .optional() with NO .default([]), and its mapper must emit undefined (not []) when the document lacks it.

Minimization and masking run in sequence: the service masks the document, then the controller maps it to a DTO. If the mapper supplies a default for a field the mask just deleted, the default resurfaces it β€” leaking its presence (an empty array where the field should be absent) to a tenant who should never see it.

Wrong β€” the default defeats the mask ​

ts
// schema
memberAccess: z.array(MemberAccessSchema).optional().default([]),   // ❌ default forces presence

// mapper
memberAccess: (doc.memberAccess ?? []).map(toEntry),   // ❌ masked (undefined) β†’ []

A foreign viewer's masked workspace comes back with memberAccess: [] instead of the field being absent. The empty array is itself a cross-tenant signal, and it broke the workspace-discovery integration spec (expect(res.memberAccess).toBeUndefined()).

Right β€” absence survives the round-trip ​

ts
// schema
memberAccess: z.array(MemberAccessSchema).optional(),   // βœ… no default; output type allows undefined

// mapper
memberAccess: doc.memberAccess?.map(toEntry),   // βœ… masked β†’ undefined, owned β†’ the array

This works because an owned document has memberAccess: [] (Mongoose array default), while a masked one has it undefined (deleted). doc.field?.map(...) distinguishes the two: owned keeps its array, masked stays absent.

Where the pattern applies (and where it can't) ​

The bug can only occur where both are true: the entity is returned cross-tenant, and a mask deletes a fixed-name field the mapper defaults. An audit of all four mappers:

EntityCross-tenant masked?Vulnerable?
WorkspaceYes (maskWorkspaceForViewer)Was β€” memberAccess + sharedWithCompanies fixed. rowSharing/sharedWithUsers were never emitted; visibility/authorScopedVisibility are deliberately kept.
Records / ParseResultsYes (maskForViewer)No β€” the mask strips dynamic column keys (hiddenFields), which no static schema default can name. Rows are returned post-mask with no defaulting mapper. Structurally immune.
CompanyNoNo β€” parcels/regions/goals are intentional empty placeholders (clients use dedicated endpoints); they leak nothing even in a connected-company view.
ConfigurationNoNo β€” per-company, never masked. phoneNumberIds ?? [] is a benign default.
DraftNoNo β€” own-company; messages: [] is intentional content removal.

Takeaway: the vulnerability converges on one entity (workspace), not a class of them β€” because records mask dynamic keys and company/config/draft aren't masked at all.

Checklist for a new (or edited) response mapper ​

  1. Allowlist, don't denylist. Project the fields clients read; never spread the doc.
  2. Audit the clients first. grep the frontend/mobile for each field before dropping it β€” a field absent from the DTO type can still be read at runtime via a looser local type (this is how the workspace companyId drop slipped past type-checking).
  3. Stringify ids, drop __v. ObjectId β†’ string; never emit __v or hydrated subdocs.
  4. For any field a mask can strip: schema .optional() without .default(...), and mapper doc.field?.map(...) β€” never ?? [].
  5. Verify with the guard + a masking spec. Run the owning module's E2E with DTO_GUARD_THROW=true (leaks β†’ 500), and assert masked-viewer responses omit the stripped fields (see workspaces/workspace-discovery.integration.spec.ts).
  • Sharing / masking model β€” root CLAUDE.md β†’ Permissions, Company Connections & Sharing
  • Records vs References β€” records-vs-references.md