Owner-scoped pricing quote access
PR #6090 restricts Dealops 2 pricing quote visibility and mutations to quote owners, guarded by a dark-launched per-org feature flag.
A shared authorization layer for pricing quotes: canViewAllOrgQuotes, isOwnerOrAdmin, enforceQuoteAccess, and list filtering.
Quote list, detail, mutation, comparison, term-file, and export paths now apply owner scoping when the flag is enabled.
With restrictQuoteViewToOwner off, same-org quote access remains org-wide. Opportunities are explicitly kept org-wide.
Direct quote IDs, clone modal browsing, quote comparison, and export jobs no longer become cross-rep read paths once the flag is on.
1. Why this exists
The epic is about quote visibility only, not opportunity collaboration.
- Pricing quotes were mostly protected by
isAuthorized, which checks organization membership. - A same-org rep could reach another rep’s quote by direct ID in several read and mutation paths.
- The clone modal’s list endpoint could expose quote metadata across reps in the same org.
- Export and comparison flows accepted quote IDs that needed explicit per-quote authorization.
- Reps see and act on pricing quotes they own.
- Admins and DealOps internal staff keep org-wide quote visibility.
- Opportunities stay shared inside the org so reps can collaborate on a common deal.
- The entire behavior is dark-launched behind
restrictQuoteViewToOwner.
2. What changes
The diff centralizes the decision, then fans it out to every quote-pulling endpoint.
restrictQuoteViewToOwner defaults off and is evaluated per organization.canViewAllOrgQuotes grants org-wide quote view only to ADMIN and internal staff.enforceQuoteAccess chooses owner-or-admin vs org-only based on the flag.exportPricingQuote re-checks access using a ctx-less guard for defense in depth.Behavior matrix
canViewAllOrgQuotes.isAuthorized.isAuthorized before owner checks.Meaningful files
packages/feature-flags/flags.ts
adds dark flag
The allow-list is empty, so merge behavior remains unchanged until an org is opted in.
apps/server/src/utils/users.ts
admin predicate
apps/server/src/trpc/utils/guards.ts
quote guards
This avoids endpoint-specific definitions of “owner scoped” and “admin bypass.”
pricingQuote/list.tsmetadata listpricingQuote/get.tsdetailpricingQuote/update.tsmutationpricingQuote/delete.tsmutationpricingQuote/duplicate.tsclonegetComQuoteByIds.tscomparisonexport.tsdispatchexportPricingQuote.tsworkertermFileAttachment.tsterm filesRepository owner filter
PricingQuoteRepository.findAllMetadataByOrganization now accepts an optional userId.
where: { organizationId }
where: { organizationId, userId }
The repository test pins the security-critical Prisma shape so a router stub cannot hide a missing userId filter.
3. How it works
There are two authorization shapes: throw for quote-by-id, filter for quote lists.
canViewAllOrgQuotes(user) is true only for:
Role.ADMINRole.DEALOPS_INTERNAL_STAFF
It deliberately excludes VIEW_ONLY, regular reps, and internal-staff-as-user impersonation.
enforceQuoteAccess(ctx, quote) reads the flag for ctx.user.organizationId.
flag ON → isOwnerOrAdmin(ctx, quote)
flag OFF → isAuthorized(ctx, quote)
filterQuotesByAccess(ctx, quotes) drops non-owned rows only when both conditions are true:
- flag is on
- caller cannot view all org quotes
Endpoint coverage
| Endpoint / path | New protection | Why it matters |
|---|---|---|
pricingQuotes.list |
Passes userId to metadata repository for non-admins when flag is on. |
Closes clone-modal browse visibility across reps. |
findByOpportunity |
Runs org check, then filterQuotesByAccess. |
A shared opportunity can stay visible without showing every rep’s quotes on it. |
get, getDefaultFeatures, getParsedUseCases |
Replace isAuthorized with enforceQuoteAccess. |
Blocks direct-link or direct-ID reads of non-owned quotes. |
update, delete, selectPricebook, reseedForDateChange |
Gate mutations with enforceQuoteAccess. |
Prevents same-org non-owners from changing quote state when the flag is on. |
duplicate |
Source quote is owner-scoped; target opportunity always gets org-boundary validation. | Prevents cloning another rep’s quote and blocks cross-tenant target writes even with the flag off. |
getComQuoteByIds |
Fetches requested quote IDs and authorizes each before comparison generation. | Prevents comparison from becoming a quote-content read bypass. |
export + exportPricingQuote |
Handler checks each requested ID; worker re-checks with enforceQuoteAccessForUser. |
Prevents unauthorized export dispatch and protects against future dispatch paths. |
uploadTermFile, deleteTermFile, getTermFileStatus |
validateQuoteOwnership now takes ctx and selects userId. |
Term-file reads and writes follow the same quote owner policy. |
Duplicate has two separate checks
Uses owner-scoped access when the flag is on.
restrictToOwner
? isOwnerOrAdmin(ctx, sourceQuote)
: isAuthorized(ctx, sourceQuote)
A rep cannot clone another rep’s quote once owner restriction is enabled.
Always uses org-level isAuthorized, independent of the flag.
const target = await opportunityRepo.findById(opportunityV2Id)
isAuthorized(ctx, target)
This blocks cross-org clones while preserving same-org opportunity collaboration.
Tests added and extended
4. What it doesn't change
- Opportunities are not owner-scoped.
opportunityV2.get,getCrm, andupdatekeep org-level access. - No default production behavior change.
restrictQuoteViewToOwnerships off with an empty enabled-org set. - No client toggle yet. The PR notes this is intended to become an org-settings toggle later.
- No schema migration. The implementation uses existing
pricingQuote.userIdownership. - No REST surface. This is server-side Dealops 2 tRPC and worker enforcement.
- No owner scoping for public/share workflows. Public share links and comparison share controllers are deliberately left at their existing boundary.
Deliberately left org-level
quoteProposals.createquoteProposals.updateapprovals.submit
pricingQuoteShareControllerPricingQuotesComparisonpublic share links
These are workflow or sharing surfaces where quote owner-scoping would be the wrong access model. The tRPC handlers remain the gate for normal authenticated quote access.
5. Risks / rollback / open questions
restrictQuoteViewToOwner off for affected orgs. The guard wrapper falls back to pre-epic org-level isAuthorized behavior, while duplicate’s target opportunity tenant-boundary check still runs.
When enabled, workflows that implicitly relied on cross-rep quote access may start returning FORBIDDEN or filtered lists.
The PR narrows quotes intentionally, but rollout should start with an org that has validated rep ownership semantics.
The flag is per-org and defaults off. Admins and internal staff retain an org-wide escape hatch for support and operations.
Move the flag from code allow-list to an org-settings toggle, as called out in the PR description.
enforceQuoteAccess or an intentional exception, and that list-like paths filter instead of throwing.