From 42f403a7edb51a95688c9320e3cb6e49e709e412 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Mon, 22 Jun 2026 13:36:00 +0700 Subject: [PATCH] task-l.3 --- .../task-l3-full-crm-scope-enforcement.md | 37 + docs/implementation/technical-debt.md | 32 + .../crm-access-enforcement-inventory.md | 41 ++ docs/security/crm-authorization-boundaries.md | 84 +++ package.json | 1 + plans/task-l.3.md | 663 ++++++++++++++++++ scripts/security/verify-crm-access.ts | 83 +++ src/app/api/crm/dashboard/export/route.ts | 35 +- src/app/api/crm/dashboard/route.ts | 19 +- .../document-artifacts/[id]/download/route.ts | 36 +- .../crm/quotations/[id]/approved-pdf/route.ts | 49 +- .../quotations/[id]/document-data/route.ts | 25 +- .../quotations/[id]/document-preview/route.ts | 25 +- .../crm/quotations/[id]/pdf-download/route.ts | 25 +- .../crm/quotations/[id]/pdf-preview/route.ts | 25 +- src/app/api/crm/quotations/[id]/route.ts | 38 +- src/app/api/crm/quotations/route.ts | 19 +- src/features/crm/dashboard/server/service.ts | 5 +- src/features/crm/quotations/server/service.ts | 101 ++- src/features/crm/security/server/service.ts | 111 +++ .../foundation/approval/server/service.ts | 14 +- src/lib/auth/crm-access.ts | 13 + src/lib/auth/session.ts | 12 +- 23 files changed, 1425 insertions(+), 68 deletions(-) create mode 100644 docs/implementation/task-l3-full-crm-scope-enforcement.md create mode 100644 docs/security/crm-access-enforcement-inventory.md create mode 100644 docs/security/crm-authorization-boundaries.md create mode 100644 plans/task-l.3.md create mode 100644 scripts/security/verify-crm-access.ts create mode 100644 src/features/crm/security/server/service.ts diff --git a/docs/implementation/task-l3-full-crm-scope-enforcement.md b/docs/implementation/task-l3-full-crm-scope-enforcement.md new file mode 100644 index 0000000..0c64b30 --- /dev/null +++ b/docs/implementation/task-l3-full-crm-scope-enforcement.md @@ -0,0 +1,37 @@ +# Task L.3: Full CRM Scope Enforcement + +## Summary + +Task L.3 starts hardening CRM security around resolved CRM access instead of relying on route-level permissions or legacy role strings alone. + +## Implemented in this pass + +- Added centralized CRM security helpers in `src/features/crm/security/server/service.ts` +- Added `canViewQuotationPricing()` guard for pricing-bearing quotation outputs +- Applied pricing enforcement to: + - document data + - document preview + - PDF preview + - PDF download + - approved PDF + - approved quotation artifact download +- Switched dashboard revenue visibility to pricing visibility instead of broad quotation-read semantics +- Blocked revenue dashboard export when pricing visibility is missing +- Updated quotation list/detail/create/update/delete routes to pass resolved CRM scope context +- Added quotation service-side branch/product/own-scope enforcement +- Updated approval step actor validation to use resolved CRM role unions +- Added `crm_security_access` audit events for pricing-denied flows +- Added security inventory and boundary documentation +- Added `scripts/security/verify-crm-access.ts` + +## Remaining gaps + +- Customers and contacts still need full ownership-scope enforcement +- Approval list/detail visibility is still broader than the final resolved-quotation scope +- Team-scope semantics remain approximate until the domain has a first-class team/subordinate model +- Runtime seeded scenario verification is still needed beyond the current static regression script + +## Verification + +- `npx tsc --noEmit` +- `node --experimental-strip-types scripts/security/verify-crm-access.ts` diff --git a/docs/implementation/technical-debt.md b/docs/implementation/technical-debt.md index d456c83..279f125 100644 --- a/docs/implementation/technical-debt.md +++ b/docs/implementation/technical-debt.md @@ -398,3 +398,35 @@ Future: - decide which non-revenue widgets should fully honor project-party role filtering - add a unified filtered reporting projection if cross-widget party-role filtering becomes a hard requirement + +## After Task L.3 + +### Customer and contact ownership enforcement + +Current: +Customer/contact modules are still more organization-wide than quotation security and need the same resolved ownership model applied consistently. + +Future: + +- add resolved CRM access context to every customer/contact service path +- enforce shared-contact visibility without falling back to blanket organization reads + +### Approval visibility scoping + +Current: +Approval step handling now resolves multi-role actors correctly, but approval list/detail visibility is still broader than final quotation-scoped security. + +Future: + +- scope approval requests by accessible entity visibility, not organization membership alone +- align dashboard approval widgets and approval detail pages with the same rule + +### Team-scope fidelity + +Current: +Resolved CRM access supports `team`, but the domain still lacks a first-class subordinate/team graph for precise enforcement. + +Future: + +- define the canonical team relationship source +- replace current coarse team handling with explicit team membership or manager hierarchy logic diff --git a/docs/security/crm-access-enforcement-inventory.md b/docs/security/crm-access-enforcement-inventory.md new file mode 100644 index 0000000..5b530d7 --- /dev/null +++ b/docs/security/crm-access-enforcement-inventory.md @@ -0,0 +1,41 @@ +# CRM Access Enforcement Inventory + +## Status Key + +- `Protected`: route already passes through resolved CRM access or explicit permission/scope enforcement +- `Partially Protected`: route requires auth/permission but still has legacy or coarse organization-wide behavior +- `Legacy Protected`: route depends on role string or legacy membership behavior instead of resolved union access +- `Not Protected`: no meaningful CRM scope enforcement beyond organization lookup + +## Inventory + +| Area | Route / Module | Status | Notes | +| --- | --- | --- | --- | +| Customers | `/api/crm/customers` | `Partially Protected` | Permission-gated, but service is still mostly organization-wide and not yet ownership-scoped | +| Contacts | `/api/crm/customers/[id]/contacts` | `Partially Protected` | Bound to customer org, but no dedicated resolved ownership/contact-sharing policy yet | +| Leads / Enquiries | `/api/crm/enquiries` and child routes | `Partially Protected` | Uses branch/product/own-scope logic already, but still passes legacy `businessRole` fields through route/service seams | +| Quotations | `/api/crm/quotations` and `[id]` | `Protected` | List/detail/create/update/delete now pass resolved CRM scope context and enforce branch/product/own visibility | +| Quotation pricing surfaces | document data, preview, PDF preview/download, approved PDF | `Protected` | Pricing-bearing outputs now require pricing visibility and emit `crm_security_access` audit events when denied | +| Document artifacts | `/api/crm/document-artifacts/[id]/download` | `Protected` | Approved quotation artifacts now require artifact permission plus pricing visibility guard | +| Approvals | `/api/crm/approvals`, approve/reject/return | `Legacy Protected` | Permission-gated; step actor resolution now uses resolved CRM access union, but request listing is still organization-wide | +| Dashboard | `/api/crm/dashboard` | `Protected` | Route uses resolved context; revenue widgets are hidden unless quotation pricing is visible | +| Dashboard export | `/api/crm/dashboard/export` | `Protected` | Revenue export explicitly blocked without pricing visibility | +| CRM settings: roles | `/api/crm/settings/roles*` | `Partially Protected` | Permission-gated, but inventory still needs full pass for every mutation route | +| CRM settings: user assignments | `/api/crm/settings/user-role-assignments*` | `Protected` | Explicit permission-gated through CRM role-assignment permissions | +| CRM settings: approval workflows | `/api/crm/settings/approval-workflows*` | `Partially Protected` | Permission-gated at route layer; deeper union-based workflow visibility still pending | +| CRM settings: document templates / sequences | `/api/crm/settings/document-templates*`, `/document-sequences*` | `Partially Protected` | Permission-gated, but not yet audited against the full L.3 export/artifact matrix | +| Master options | `/api/foundation/master-options` | `Partially Protected` | Admin-oriented and permission-gated, but outside resolved CRM scope model | + +## High-Risk Findings + +1. Customer/contact services still need full ownership-scope enforcement rather than organization-only reads. +2. Approval request listing and detail visibility are still broader than the final resolved-ownership model. +3. Enquiry services already enforce meaningful scope, but several route files still pass legacy role-shaped context instead of a dedicated security context object. +4. Team-scope semantics are still coarse because the current model does not yet carry a first-class subordinate/team graph. + +## Follow-up Focus + +- Finish customer/contact ownership enforcement. +- Convert approval list/detail visibility from organization scope to resolved quotation visibility. +- Normalize remaining enquiry routes onto the same security-context builder used by quotations and dashboard. +- Add runtime scenario tests once seeded multi-role fixtures are available. diff --git a/docs/security/crm-authorization-boundaries.md b/docs/security/crm-authorization-boundaries.md new file mode 100644 index 0000000..0ee5a0b --- /dev/null +++ b/docs/security/crm-authorization-boundaries.md @@ -0,0 +1,84 @@ +# CRM Authorization Boundaries + +## Source of Truth + +Resolved CRM access is the source of truth for CRM authorization. + +The effective model is: + +1. Authenticated user +2. Active organization membership +3. CRM role assignments +4. Resolved permission union +5. Resolved branch scope +6. Resolved product scope +7. Resolved ownership scope +8. Resolved approval authority + +`memberships.businessRole` remains a compatibility fallback only inside the resolver path. + +## Enforcement Layers + +### Route permission gates + +Every CRM route must first require organization access plus at least one explicit permission. + +### Service-level scope enforcement + +Sensitive list/detail/mutation services must additionally enforce: + +- branch scope +- product scope +- ownership visibility + +This is required because UI filtering is not a security boundary. + +### Pricing visibility + +Pricing-bearing outputs require quotation pricing visibility: + +- quotation PDF preview/download +- approved PDF +- document preview/data +- revenue dashboard/export +- approved artifact download + +Denied pricing access is logged as `crm_security_access` with action `pricing_hidden`. + +### Approval authority + +Approval step handling must use resolved CRM role unions instead of a single legacy business-role string. + +## Current Ownership Model + +- `own`: creator or assigned owner/salesman +- `team`: currently treated as broader-than-own, but still requires future team graph formalization +- `organization`: organization-wide inside active tenant +- `monitor`: read-oriented role with limited commercial visibility + +## Artifact Boundary + +Artifact permission alone is not enough for approved quotation PDFs. + +The caller must satisfy: + +1. artifact download permission +2. quotation pricing visibility when the artifact is an approved quotation PDF + +## Dashboard Boundary + +Dashboard access is split: + +- base dashboard visibility: `crm.dashboard.read` +- revenue/commercial visibility: quotation pricing visibility +- approval widgets: approval read permission +- export visibility: same scope as dashboard, with extra revenue export restriction + +## Audit Events + +Security-sensitive denials are recorded under entity type `crm_security_access` with actions: + +- `access_denied` +- `scope_violation` +- `permission_violation` +- `pricing_hidden` diff --git a/package.json b/package.json index f1cf201..71f96fb 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js", "verify:task-h1": "node scripts/verify-task-h1.js", "verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js", + "verify:crm-access": "node --experimental-strip-types scripts/security/verify-crm-access.ts", "audit:pdf:inventory": "node --experimental-strip-types scripts/audit-pdf-template-inventory.ts", "audit:pdf:coverage": "node --experimental-strip-types scripts/audit-pdf-mapping-coverage.ts", "audit:pdf:runtime": "node --experimental-strip-types scripts/audit-pdf-runtime-payload.ts", diff --git a/plans/task-l.3.md b/plans/task-l.3.md new file mode 100644 index 0000000..6461891 --- /dev/null +++ b/plans/task-l.3.md @@ -0,0 +1,663 @@ +# Task L.3: Full CRM Scope Enforcement & Security Validation + +## Objective + +Complete CRM authorization by enforcing resolved CRM access consistently across all CRM modules. + +After Task L.3: + +```txt +Resolved CRM Access +``` + +becomes the single source of truth for: + +```txt +Visibility +Ownership +Branch Scope +Product Scope +Approval Authority +Pricing Visibility +Dashboard Access +Settings Access +``` + +No CRM module may rely solely on UI filtering or legacy membership role behavior. + +--- + +# Background + +Completed: + +```txt +Task L +Task L.1 +Task L.2 +``` + +Current state: + +```txt +CRM Role Profiles +CRM User Role Assignments +Effective Access Resolver +User Management Integration +``` + +Remaining risk: + +Some modules may still: + +```txt +filter in UI only +use organization visibility +ignore branch scope +ignore product scope +ignore ownership scope +``` + +Task L.3 closes those gaps. + +--- + +# Scope L3.1 Access Enforcement Inventory + +Create a complete inventory of CRM endpoints. + +Audit: + +```txt +Customers +Contacts +Leads +Enquiries +Quotations +Approvals +Dashboard +Document Artifacts +CRM Settings +``` + +For each endpoint classify: + +```txt +Protected +Partially Protected +Not Protected +Legacy Protected +``` + +Output: + +```txt +docs/security/crm-access-enforcement-inventory.md +``` + +--- + +# Scope L3.2 Resolver Adoption Audit + +Verify every CRM route uses: + +```txt +resolveCrmAccess() +``` + +or equivalent centralized helper. + +Remove direct dependency on: + +```txt +membership.businessRole +membership.role +manual permission arrays +``` + +except compatibility fallback inside resolver. + +--- + +# Scope L3.3 Customer Enforcement + +Apply: + +```txt +Branch Scope +Product Scope +Ownership Scope +``` + +Rules: + +Sales: + +```txt +own customers only +``` + +Manager: + +```txt +team customers +``` + +Admin: + +```txt +organization customers +``` + +CRM Admin: + +```txt +all CRM customers +``` + +Server-side enforced. + +--- + +# Scope L3.4 Contact Enforcement + +Apply same rules. + +Preserve: + +```txt +Contact Sharing +``` + +Rules: + +Shared contacts remain visible. + +Ownership rules still respected. + +--- + +# Scope L3.5 Lead Enforcement + +Apply: + +```txt +Lead Scope +``` + +Marketing: + +```txt +view monitoring surfaces +assign leads +view follow-ups +``` + +Sales: + +```txt +assigned leads only +``` + +Manager: + +```txt +team leads +``` + +Enforce: + +```txt +branch scope +product scope +ownership +``` + +--- + +# Scope L3.6 Enquiry Enforcement + +Apply: + +```txt +lead -> enquiry pipeline rules +``` + +Sales: + +```txt +assigned enquiries only +``` + +Manager: + +```txt +team enquiries +``` + +Marketing: + +```txt +monitor only +``` + +Must not edit sales-owned enquiries unless permission exists. + +--- + +# Scope L3.7 Quotation Enforcement + +Highest priority. + +Rules: + +Sales: + +```txt +own quotations +``` + +Manager: + +```txt +team quotations +``` + +CRM Admin: + +```txt +all quotations +``` + +Marketing: + +```txt +cannot view pricing +cannot edit quotations +``` + +Support: + +```txt +can edit only if permission granted +``` + +--- + +# Scope L3.8 Pricing Visibility Enforcement + +Add centralized helper: + +```txt +canViewQuotationPricing() +``` + +Required for: + +```txt +Quotation Detail +Quotation List +Dashboard Revenue +Exports +PDF Preview +PDF Download +Approved PDF +``` + +If denied: + +Hide: + +```txt +subtotal +discount +tax +total +currency values +``` + +Server-side. + +Not UI-only. + +--- + +# Scope L3.9 Approval Enforcement + +Verify: + +```txt +approval authority +``` + +comes from: + +```txt +resolved CRM access +``` + +not role string. + +Rules: + +Only users with matching authority level may: + +```txt +approve +reject +request changes +``` + +--- + +# Scope L3.10 Dashboard Enforcement + +Apply resolved scope to: + +```txt +Lead KPI +Enquiry KPI +Revenue KPI +Approval KPI +Follow-up KPI +Sales Ranking +Exports +``` + +Rules: + +Revenue only visible with: + +```txt +crm.quotation.price.read +``` + +Sales Ranking: + +```txt +branch/product filtered +``` + +Exports: + +```txt +same scope as dashboard +``` + +--- + +# Scope L3.11 Report / Export Enforcement + +Audit: + +```txt +CSV +Excel +Dashboard Export +Revenue Export +Sales Ranking Export +``` + +Rules: + +Export must never return records not visible in UI. + +Server-side filtering required. + +--- + +# Scope L3.12 Document Artifact Enforcement + +Protect: + +```txt +Approved PDF +Artifacts +Downloads +``` + +Rules: + +User may download only if: + +```txt +can access quotation +AND +has artifact permission +``` + +No direct artifact bypass. + +--- + +# Scope L3.13 CRM Settings Enforcement + +Protect: + +```txt +Roles +User Assignments +Templates +Approval Workflows +Document Sequences +Master Options +``` + +Permissions: + +```txt +crm.role.* +crm.role.assignment.* +crm.document_template.* +crm.approval.workflow.* +crm.document_sequence.* +crm.master_option.* +``` + +No implicit admin access. + +--- + +# Scope L3.14 Security Regression Suite + +Add: + +```txt +scripts/security/verify-crm-access.ts +``` + +Verify: + +### Marketing + +```txt +cannot see quotation prices +cannot export revenue +cannot approve quotation +``` + +### Sales + +```txt +cannot see other sales records +``` + +### Sales Manager + +```txt +can see team records only +``` + +### CRM Admin + +```txt +can manage settings +``` + +### Mixed Roles + +```txt +permissions union correctly +scope union correctly +``` + +--- + +# Scope L3.15 Security Audit Logging + +Audit entity: + +```txt +crm_security_access +``` + +Log: + +```txt +access_denied +scope_violation +permission_violation +pricing_hidden +``` + +Purpose: + +Security troubleshooting. + +--- + +# Scope L3.16 Documentation + +Create: + +```txt +docs/security/crm-authorization-boundaries.md +docs/implementation/task-l3-full-crm-scope-enforcement.md +``` + +Document: + +```txt +ownership model +scope model +pricing visibility model +approval authority model +dashboard visibility model +artifact visibility model +``` + +--- + +# Verification Matrix + +## Marketing + +Expected: + +```txt +Lead Monitoring +Follow-ups +Assignments + +No Pricing +No Revenue +No Approval +``` + +--- + +## Sales + +Expected: + +```txt +Own Records +Own Quotations +Own Follow-ups +``` + +--- + +## Sales Manager + +Expected: + +```txt +Team Visibility +Approval Access +``` + +--- + +## CRM Admin + +Expected: + +```txt +Settings +Roles +Templates +Sequences +``` + +--- + +## Sales + Manager + +Expected: + +```txt +Union Permissions +Union Scope +``` + +--- + +## Marketing + Sales Support + +Expected: + +```txt +Lead Access +Quotation Support + +No Revenue +``` + +--- + +# Deliverables + +1. Full CRM Scope Enforcement +2. Pricing Visibility Layer +3. Dashboard Security Layer +4. Artifact Security Layer +5. Approval Authority Enforcement +6. Security Verification Suite +7. Security Audit Logging +8. Authorization Boundary Documentation + +--- + +# Definition of Done + +Task L.3 is complete when: + +* all CRM routes use resolved CRM access +* branch scope enforced +* product scope enforced +* ownership scope enforced +* pricing visibility enforced +* dashboard security enforced +* artifact security enforced +* approval authority enforced +* exports respect visibility +* regression suite passes +* authorization boundaries documented + +Result: + +```txt +Authorization Foundation = CLOSED +CRM Security Boundary = PRODUCTION READY +``` diff --git a/scripts/security/verify-crm-access.ts b/scripts/security/verify-crm-access.ts new file mode 100644 index 0000000..1958cbf --- /dev/null +++ b/scripts/security/verify-crm-access.ts @@ -0,0 +1,83 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +type Check = { + name: string; + file: string; + patterns: string[]; +}; + +const checks: Check[] = [ + { + name: 'dashboard route uses CRM security context', + file: 'src/app/api/crm/dashboard/route.ts', + patterns: ['buildCrmSecurityContext', 'getCrmDashboardData('] + }, + { + name: 'dashboard export blocks revenue export without pricing visibility', + file: 'src/app/api/crm/dashboard/export/route.ts', + patterns: ['canViewQuotationPricing', "section === 'revenue-analytics'"] + }, + { + name: 'quotation list route passes resolved scope context', + file: 'src/app/api/crm/quotations/route.ts', + patterns: ['buildCrmSecurityContext', 'listQuotations(organization.id', 'accessContext'] + }, + { + name: 'quotation detail route passes resolved scope context', + file: 'src/app/api/crm/quotations/[id]/route.ts', + patterns: ['buildCrmSecurityContext', 'getQuotationDetail(id, organization.id, accessContext)'] + }, + { + name: 'quotation PDF preview is pricing-gated', + file: 'src/app/api/crm/quotations/[id]/pdf-preview/route.ts', + patterns: ['canViewQuotationPricing', "action: 'pricing_hidden'"] + }, + { + name: 'quotation PDF download is pricing-gated', + file: 'src/app/api/crm/quotations/[id]/pdf-download/route.ts', + patterns: ['canViewQuotationPricing', "action: 'pricing_hidden'"] + }, + { + name: 'approved PDF route is pricing-gated', + file: 'src/app/api/crm/quotations/[id]/approved-pdf/route.ts', + patterns: ['canViewQuotationPricing', "action: 'pricing_hidden'"] + }, + { + name: 'artifact download route protects approved quotation artifacts', + file: 'src/app/api/crm/document-artifacts/[id]/download/route.ts', + patterns: ['getArtifact', "artifact.artifactType === 'approved_pdf'", 'canViewQuotationPricing'] + }, + { + name: 'approval step actor resolution uses resolved CRM access', + file: 'src/features/foundation/approval/server/service.ts', + patterns: ['resolveCrmMembershipAccess', 'resolvedAccess.businessRoles.includes(roleCode)'] + }, + { + name: 'quotation service applies scoped record access', + file: 'src/features/crm/quotations/server/service.ts', + patterns: ['canAccessScopedCrmRecord', 'buildQuotationAccessScopedFilters', 'Forbidden branch scope'] + } +]; + +let hasFailure = false; + +for (const check of checks) { + const absolutePath = resolve(process.cwd(), check.file); + const content = readFileSync(absolutePath, 'utf8'); + const missing = check.patterns.filter((pattern) => !content.includes(pattern)); + + if (missing.length > 0) { + hasFailure = true; + console.error(`FAIL ${check.name}`); + console.error(` File: ${check.file}`); + console.error(` Missing: ${missing.join(', ')}`); + continue; + } + + console.info(`PASS ${check.name}`); +} + +if (hasFailure) { + process.exit(1); +} diff --git a/src/app/api/crm/dashboard/export/route.ts b/src/app/api/crm/dashboard/export/route.ts index 323f7d0..f2d6d66 100644 --- a/src/app/api/crm/dashboard/export/route.ts +++ b/src/app/api/crm/dashboard/export/route.ts @@ -1,6 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { auditAction } from '@/features/foundation/audit-log/service'; import { getCrmDashboardData } from '@/features/crm/dashboard/server/service'; +import { + auditCrmSecurityEvent, + buildCrmSecurityContext, + canViewQuotationPricing +} from '@/features/crm/security/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -34,19 +39,27 @@ export async function GET(request: NextRequest) { const searchParams = request.nextUrl.searchParams; const section = searchParams.get('section') ?? 'sales-ranking'; const format = searchParams.get('format') ?? 'csv'; - const data = await getCrmDashboardData( - { + const securityContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + + if (section === 'revenue-analytics' && !canViewQuotationPricing(securityContext)) { + await auditCrmSecurityEvent({ organizationId: organization.id, userId: session.user.id, - membershipRole: membership.role, - businessRole: membership.businessRole, - permissions: membership.permissions, - branchScopeIds: membership.branchScopeIds ?? [], - productTypeScopeIds: membership.productTypeScopeIds ?? [], - ownershipScope: membership.ownershipScope ?? 'organization', - branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', - productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' - }, + action: 'permission_violation', + entityType: 'crm_dashboard_export', + entityId: section, + reason: 'Revenue export requires quotation pricing visibility', + metadata: { format } + }); + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + + const data = await getCrmDashboardData( + securityContext, { dateFrom: searchParams.get('dateFrom'), dateTo: searchParams.get('dateTo'), diff --git a/src/app/api/crm/dashboard/route.ts b/src/app/api/crm/dashboard/route.ts index 2248046..881ecef 100644 --- a/src/app/api/crm/dashboard/route.ts +++ b/src/app/api/crm/dashboard/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { getCrmDashboardData } from '@/features/crm/dashboard/server/service'; +import { buildCrmSecurityContext } from '@/features/crm/security/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -9,19 +10,13 @@ export async function GET(request: NextRequest) { permission: PERMISSIONS.crmDashboardRead }); const searchParams = request.nextUrl.searchParams; + const securityContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); const data = await getCrmDashboardData( - { - organizationId: organization.id, - userId: session.user.id, - membershipRole: membership.role, - businessRole: membership.businessRole, - permissions: membership.permissions, - branchScopeIds: membership.branchScopeIds ?? [], - productTypeScopeIds: membership.productTypeScopeIds ?? [], - ownershipScope: membership.ownershipScope ?? 'organization', - branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', - productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' - }, + securityContext, { dateFrom: searchParams.get('dateFrom'), dateTo: searchParams.get('dateTo'), diff --git a/src/app/api/crm/document-artifacts/[id]/download/route.ts b/src/app/api/crm/document-artifacts/[id]/download/route.ts index 189a3f2..50a1bd0 100644 --- a/src/app/api/crm/document-artifacts/[id]/download/route.ts +++ b/src/app/api/crm/document-artifacts/[id]/download/route.ts @@ -1,5 +1,13 @@ import { NextRequest, NextResponse } from 'next/server'; -import { downloadArtifact } from '@/features/foundation/document-artifact/server/service'; +import { + downloadArtifact, + getArtifact +} from '@/features/foundation/document-artifact/server/service'; +import { + auditCrmSecurityEvent, + buildCrmSecurityContext, + canViewQuotationPricing +} from '@/features/crm/security/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -10,9 +18,33 @@ type Params = { export async function GET(_request: NextRequest, { params }: Params) { try { const { id } = await params; - const { organization, session } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmDocumentArtifactDownload }); + const securityContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const artifact = await getArtifact(id, organization.id); + + if ( + artifact.entityType === 'quotation' && + artifact.artifactType === 'approved_pdf' && + !canViewQuotationPricing(securityContext) + ) { + await auditCrmSecurityEvent({ + organizationId: organization.id, + userId: session.user.id, + action: 'pricing_hidden', + entityType: artifact.entityType, + entityId: artifact.entityId, + reason: 'Approved quotation artifact download requires pricing visibility', + metadata: { artifactId: artifact.id } + }); + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + const { object } = await downloadArtifact({ artifactId: id, organizationId: organization.id, diff --git a/src/app/api/crm/quotations/[id]/approved-pdf/route.ts b/src/app/api/crm/quotations/[id]/approved-pdf/route.ts index e4edbb5..d4dc7ed 100644 --- a/src/app/api/crm/quotations/[id]/approved-pdf/route.ts +++ b/src/app/api/crm/quotations/[id]/approved-pdf/route.ts @@ -1,5 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { auditAction } from '@/features/foundation/audit-log/service'; +import { + auditCrmSecurityEvent, + buildCrmSecurityContext, + canViewQuotationPricing +} from '@/features/crm/security/server/service'; import { generateApprovedQuotationPdf, getStoredApprovedQuotationPdf @@ -15,9 +20,27 @@ type Params = { export async function GET(_request: NextRequest, { params }: Params) { try { const { id } = await params; - const { organization, session } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmQuotationPdfDownload }); + const securityContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + + if (!canViewQuotationPricing(securityContext)) { + await auditCrmSecurityEvent({ + organizationId: organization.id, + userId: session.user.id, + action: 'pricing_hidden', + entityType: 'crm_quotation', + entityId: id, + reason: 'Approved quotation PDF download requires pricing visibility' + }); + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + const pdf = await getStoredApprovedQuotationPdf(id, organization.id, { userId: session.user.id, auditDownload: true @@ -40,12 +63,30 @@ export async function GET(_request: NextRequest, { params }: Params) { export async function POST(_request: NextRequest, { params }: Params) { try { const { id } = await params; - const { organization, session } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmQuotationPdfGenerateApproved }); - const before = await getQuotationDetail(id, organization.id); + const securityContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + + if (!canViewQuotationPricing(securityContext)) { + await auditCrmSecurityEvent({ + organizationId: organization.id, + userId: session.user.id, + action: 'pricing_hidden', + entityType: 'crm_quotation', + entityId: id, + reason: 'Approved quotation PDF generation requires pricing visibility' + }); + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + + const before = await getQuotationDetail(id, organization.id, securityContext); const pdf = await generateApprovedQuotationPdf(id, organization.id, session.user.id); - const after = await getQuotationDetail(id, organization.id); + const after = await getQuotationDetail(id, organization.id, securityContext); await auditAction({ organizationId: organization.id, diff --git a/src/app/api/crm/quotations/[id]/document-data/route.ts b/src/app/api/crm/quotations/[id]/document-data/route.ts index 3ab30d5..c68595c 100644 --- a/src/app/api/crm/quotations/[id]/document-data/route.ts +++ b/src/app/api/crm/quotations/[id]/document-data/route.ts @@ -1,5 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { buildQuotationDocumentData } from '@/features/crm/quotations/document/server/service'; +import { + auditCrmSecurityEvent, + buildCrmSecurityContext, + canViewQuotationPricing +} from '@/features/crm/security/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -10,9 +15,27 @@ type Params = { export async function GET(_request: NextRequest, { params }: Params) { try { const { id } = await params; - const { organization } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmQuotationDocumentPreview }); + const securityContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + + if (!canViewQuotationPricing(securityContext)) { + await auditCrmSecurityEvent({ + organizationId: organization.id, + userId: session.user.id, + action: 'pricing_hidden', + entityType: 'crm_quotation', + entityId: id, + reason: 'Quotation document data requires pricing visibility' + }); + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + const documentData = await buildQuotationDocumentData(id, organization.id); return NextResponse.json({ diff --git a/src/app/api/crm/quotations/[id]/document-preview/route.ts b/src/app/api/crm/quotations/[id]/document-preview/route.ts index 7093d6f..4dac186 100644 --- a/src/app/api/crm/quotations/[id]/document-preview/route.ts +++ b/src/app/api/crm/quotations/[id]/document-preview/route.ts @@ -1,5 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { getQuotationDocumentPreviewData } from '@/features/crm/quotations/document/server/service'; +import { + auditCrmSecurityEvent, + buildCrmSecurityContext, + canViewQuotationPricing +} from '@/features/crm/security/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -10,9 +15,27 @@ type Params = { export async function GET(_request: NextRequest, { params }: Params) { try { const { id } = await params; - const { organization } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmQuotationDocumentPreview }); + const securityContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + + if (!canViewQuotationPricing(securityContext)) { + await auditCrmSecurityEvent({ + organizationId: organization.id, + userId: session.user.id, + action: 'pricing_hidden', + entityType: 'crm_quotation', + entityId: id, + reason: 'Quotation document preview requires pricing visibility' + }); + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + const preview = await getQuotationDocumentPreviewData(id, organization.id); return NextResponse.json({ diff --git a/src/app/api/crm/quotations/[id]/pdf-download/route.ts b/src/app/api/crm/quotations/[id]/pdf-download/route.ts index d9c80ca..d178d20 100644 --- a/src/app/api/crm/quotations/[id]/pdf-download/route.ts +++ b/src/app/api/crm/quotations/[id]/pdf-download/route.ts @@ -1,5 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { generateQuotationPdf } from '@/features/foundation/pdf-generator/server/service'; +import { + auditCrmSecurityEvent, + buildCrmSecurityContext, + canViewQuotationPricing +} from '@/features/crm/security/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -10,9 +15,27 @@ type Params = { export async function GET(_request: NextRequest, { params }: Params) { try { const { id } = await params; - const { organization } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmQuotationPdfDownload }); + const securityContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + + if (!canViewQuotationPricing(securityContext)) { + await auditCrmSecurityEvent({ + organizationId: organization.id, + userId: session.user.id, + action: 'pricing_hidden', + entityType: 'crm_quotation', + entityId: id, + reason: 'Quotation PDF download requires pricing visibility' + }); + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + const pdf = await generateQuotationPdf(id, organization.id); return new NextResponse(pdf.buffer, { diff --git a/src/app/api/crm/quotations/[id]/pdf-preview/route.ts b/src/app/api/crm/quotations/[id]/pdf-preview/route.ts index 3b392a2..8275d6c 100644 --- a/src/app/api/crm/quotations/[id]/pdf-preview/route.ts +++ b/src/app/api/crm/quotations/[id]/pdf-preview/route.ts @@ -1,5 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { generateQuotationPreviewPdf } from '@/features/foundation/pdf-generator/server/service'; +import { + auditCrmSecurityEvent, + buildCrmSecurityContext, + canViewQuotationPricing +} from '@/features/crm/security/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -10,9 +15,27 @@ type Params = { export async function GET(_request: NextRequest, { params }: Params) { try { const { id } = await params; - const { organization } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmQuotationPdfPreview }); + const securityContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + + if (!canViewQuotationPricing(securityContext)) { + await auditCrmSecurityEvent({ + organizationId: organization.id, + userId: session.user.id, + action: 'pricing_hidden', + entityType: 'crm_quotation', + entityId: id, + reason: 'Quotation PDF preview requires pricing visibility' + }); + return NextResponse.json({ message: 'Forbidden' }, { status: 403 }); + } + const pdf = await generateQuotationPreviewPdf(id, organization.id); return new NextResponse(pdf.buffer, { diff --git a/src/app/api/crm/quotations/[id]/route.ts b/src/app/api/crm/quotations/[id]/route.ts index bbe417b..7629783 100644 --- a/src/app/api/crm/quotations/[id]/route.ts +++ b/src/app/api/crm/quotations/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; +import { buildCrmSecurityContext } from '@/features/crm/security/server/service'; import { getQuotationActivity, getQuotationDetail, @@ -18,11 +19,16 @@ type Params = { export async function GET(_request: NextRequest, { params }: Params) { try { const { id } = await params; - const { organization } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmQuotationRead }); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); const [quotation, activity] = await Promise.all([ - getQuotationDetail(id, organization.id), + getQuotationDetail(id, organization.id, accessContext), getQuotationActivity(id, organization.id) ]); @@ -49,12 +55,23 @@ export async function GET(_request: NextRequest, { params }: Params) { export async function PATCH(request: NextRequest, { params }: Params) { try { const { id } = await params; - const { organization, session } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmQuotationUpdate }); const payload = quotationSchema.parse(await request.json()); - const before = await getQuotationDetail(id, organization.id); - const updated = await updateQuotation(id, organization.id, session.user.id, payload); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const before = await getQuotationDetail(id, organization.id, accessContext); + const updated = await updateQuotation( + id, + organization.id, + session.user.id, + payload, + accessContext + ); await auditUpdate({ organizationId: organization.id, @@ -94,11 +111,16 @@ export async function PATCH(request: NextRequest, { params }: Params) { export async function DELETE(_request: NextRequest, { params }: Params) { try { const { id } = await params; - const { organization, session } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmQuotationDelete }); - const before = await getQuotationDetail(id, organization.id); - const updated = await softDeleteQuotation(id, organization.id, session.user.id); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const before = await getQuotationDetail(id, organization.id, accessContext); + const updated = await softDeleteQuotation(id, organization.id, session.user.id, accessContext); await auditDelete({ organizationId: organization.id, diff --git a/src/app/api/crm/quotations/route.ts b/src/app/api/crm/quotations/route.ts index 68f8408..45edfe1 100644 --- a/src/app/api/crm/quotations/route.ts +++ b/src/app/api/crm/quotations/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { auditCreate } from '@/features/foundation/audit-log/service'; +import { buildCrmSecurityContext } from '@/features/crm/security/server/service'; import { createQuotation, listQuotations } from '@/features/crm/quotations/server/service'; import { quotationSchema } from '@/features/crm/quotations/schemas/quotation.schema'; import { PERMISSIONS } from '@/lib/auth/rbac'; @@ -8,7 +9,7 @@ import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; export async function GET(request: NextRequest) { try { - const { organization } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmQuotationRead }); const { searchParams } = request.nextUrl; @@ -22,6 +23,11 @@ export async function GET(request: NextRequest) { const enquiry = searchParams.get('enquiry') ?? undefined; const hot = searchParams.get('hot') ?? undefined; const sort = searchParams.get('sort') ?? undefined; + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); const result = await listQuotations(organization.id, { page, limit, @@ -33,7 +39,7 @@ export async function GET(request: NextRequest) { enquiry, hot, sort - }); + }, accessContext); const offset = (page - 1) * limit; return NextResponse.json({ @@ -60,11 +66,16 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { try { - const { organization, session } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmQuotationCreate }); const payload = quotationSchema.parse(await request.json()); - const created = await createQuotation(organization.id, session.user.id, payload); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const created = await createQuotation(organization.id, session.user.id, payload, accessContext); await auditCreate({ organizationId: organization.id, diff --git a/src/features/crm/dashboard/server/service.ts b/src/features/crm/dashboard/server/service.ts index 2ee1b2b..fbd51d7 100644 --- a/src/features/crm/dashboard/server/service.ts +++ b/src/features/crm/dashboard/server/service.ts @@ -23,6 +23,7 @@ import { db } from '@/lib/db'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { getUserBranches } from '@/features/foundation/branch-scope/service'; import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access'; +import { canViewQuotationPricing } from '@/features/crm/security/server/service'; import { listApprovalRequests } from '@/features/foundation/approval/server/service'; import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; import { @@ -113,9 +114,7 @@ function average(values: number[]) { } function canViewCommercialData(context: DashboardContext) { - return ( - context.membershipRole === 'admin' || context.permissions.includes(PERMISSIONS.crmQuotationRead) - ); + return context.membershipRole === 'admin' || canViewQuotationPricing(context); } function canViewApprovalData(context: DashboardContext) { diff --git a/src/features/crm/quotations/server/service.ts b/src/features/crm/quotations/server/service.ts index 1f5ec64..41cad10 100644 --- a/src/features/crm/quotations/server/service.ts +++ b/src/features/crm/quotations/server/service.ts @@ -16,8 +16,13 @@ import { } from '@/db/schema'; import { db } from '@/lib/db'; import { AuthError } from '@/lib/auth/session'; +import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access'; import { listAuditLogs } from '@/features/foundation/audit-log/service'; import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service'; +import { + type CrmSecurityContext, + canAccessScopedCrmRecord +} from '@/features/crm/security/server/service'; import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service'; import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; import { syncEnquiryPipelineStageFromQuotationStatus } from '@/features/crm/enquiries/server/service'; @@ -70,6 +75,8 @@ const QUOTATION_OPTION_CATEGORIES = { const REVISION_ALLOWED_STATUS_CODES = new Set(['accepted', 'rejected', 'sent', 'approved']); +export type QuotationAccessContext = CrmSecurityContext; + function mapOption( option: Awaited>[number] ): QuotationOption { @@ -450,7 +457,11 @@ async function assertEnquiryBelongsToOrganization(id: string, organizationId: st return enquiry; } -async function assertQuotationBelongsToOrganization(id: string, organizationId: string) { +async function assertQuotationBelongsToOrganization( + id: string, + organizationId: string, + accessContext?: QuotationAccessContext +) { const [quotation] = await db .select() .from(crmQuotations) @@ -467,9 +478,45 @@ async function assertQuotationBelongsToOrganization(id: string, organizationId: throw new AuthError('Quotation not found', 404); } + if (accessContext && !canAccessQuotationRow(quotation, accessContext)) { + throw new AuthError('Forbidden', 403); + } + return quotation; } +function canAccessQuotationRow( + row: typeof crmQuotations.$inferSelect, + accessContext: QuotationAccessContext +) { + return canAccessScopedCrmRecord(accessContext, { + branchId: row.branchId, + productType: row.quotationType, + createdBy: row.createdBy, + ownerUserId: row.salesmanId + }); +} + +function buildQuotationAccessScopedFilters(accessContext: QuotationAccessContext): SQL[] { + const filters: SQL[] = []; + + if (accessContext.branchScopeMode === 'assigned' && accessContext.branchScopeIds.length > 0) { + filters.push(inArray(crmQuotations.branchId, accessContext.branchScopeIds)); + } + + if (accessContext.productScopeMode === 'assigned' && accessContext.productTypeScopeIds.length > 0) { + filters.push(inArray(crmQuotations.quotationType, accessContext.productTypeScopeIds)); + } + + if (accessContext.membershipRole !== 'admin' && accessContext.ownershipScope === 'own') { + filters.push( + or(eq(crmQuotations.salesmanId, accessContext.userId), eq(crmQuotations.createdBy, accessContext.userId))! + ); + } + + return filters; +} + async function assertQuotationItemBelongsToQuotation( quotationId: string, itemId: string, @@ -607,7 +654,11 @@ async function assertSalesmanBelongsToOrganization(userId: string, organizationI } } -async function validateQuotationPayload(organizationId: string, payload: QuotationMutationPayload) { +async function validateQuotationPayload( + organizationId: string, + payload: QuotationMutationPayload, + accessContext?: QuotationAccessContext +) { await assertCustomerBelongsToOrganization(payload.customerId, organizationId); if (payload.contactId) { @@ -626,6 +677,10 @@ async function validateQuotationPayload(organizationId: string, payload: Quotati await validateBranchAccess(payload.branchId); } + if (accessContext && !hasBranchScopeAccess(accessContext, payload.branchId)) { + throw new AuthError('Forbidden branch scope', 403); + } + if (payload.salesmanId) { await assertSalesmanBelongsToOrganization(payload.salesmanId, organizationId); } @@ -652,6 +707,10 @@ async function validateQuotationPayload(organizationId: string, payload: Quotati payload.sentVia ?? null ); + if (accessContext && !hasProductScopeAccess(accessContext, payload.quotationType)) { + throw new AuthError('Forbidden product scope', 403); + } + for (const projectParty of payload.projectParties ?? []) { await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId); await assertMasterOptionValue( @@ -873,7 +932,11 @@ async function validateQuotationFollowupPayload( } } -function buildQuotationFilters(organizationId: string, filters: QuotationFilters): SQL[] { +function buildQuotationFilters( + organizationId: string, + filters: QuotationFilters, + accessContext?: QuotationAccessContext +): SQL[] { const statuses = splitFilterValue(filters.status); const quotationTypes = splitFilterValue(filters.quotationType); const branches = splitFilterValue(filters.branch); @@ -883,6 +946,7 @@ function buildQuotationFilters(organizationId: string, filters: QuotationFilters return [ eq(crmQuotations.organizationId, organizationId), isNull(crmQuotations.deletedAt), + ...(accessContext ? buildQuotationAccessScopedFilters(accessContext) : []), ...(statuses.length ? [inArray(crmQuotations.status, statuses)] : []), ...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []), ...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []), @@ -1058,11 +1122,12 @@ export async function getQuotationReferenceData( export async function listQuotations( organizationId: string, - filters: QuotationFilters + filters: QuotationFilters, + accessContext?: QuotationAccessContext ): Promise<{ items: QuotationListItem[]; totalItems: number }> { const page = filters.page ?? 1; const limit = filters.limit ?? 10; - const whereFilters = buildQuotationFilters(organizationId, filters); + const whereFilters = buildQuotationFilters(organizationId, filters, accessContext); const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters); const offset = (page - 1) * limit; @@ -1125,9 +1190,10 @@ export async function listQuotations( export async function getQuotationDetail( id: string, - organizationId: string + organizationId: string, + accessContext?: QuotationAccessContext ): Promise { - const quotation = await assertQuotationBelongsToOrganization(id, organizationId); + const quotation = await assertQuotationBelongsToOrganization(id, organizationId, accessContext); const approvedArtifactId = quotation.approvedArtifactId ?? (quotation.approvedPdfUrl?.startsWith('artifact:') @@ -1213,9 +1279,10 @@ export async function getQuotationActivity( export async function createQuotation( organizationId: string, userId: string, - payload: QuotationMutationPayload + payload: QuotationMutationPayload, + accessContext?: QuotationAccessContext ) { - await validateQuotationPayload(organizationId, payload); + await validateQuotationPayload(organizationId, payload, accessContext); const quotationStatusCode = await resolveOptionCodeById( organizationId, QUOTATION_OPTION_CATEGORIES.status, @@ -1299,15 +1366,16 @@ export async function updateQuotation( id: string, organizationId: string, userId: string, - payload: QuotationMutationPayload + payload: QuotationMutationPayload, + accessContext?: QuotationAccessContext ) { - await validateQuotationPayload(organizationId, payload); + await validateQuotationPayload(organizationId, payload, accessContext); const quotationStatusCode = await resolveOptionCodeById( organizationId, QUOTATION_OPTION_CATEGORIES.status, payload.status ); - const existing = await assertQuotationBelongsToOrganization(id, organizationId); + const existing = await assertQuotationBelongsToOrganization(id, organizationId, accessContext); const enquiry = payload.enquiryId ? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId) : null; @@ -1370,8 +1438,13 @@ export async function updateQuotation( return updated; } -export async function softDeleteQuotation(id: string, organizationId: string, userId: string) { - await assertQuotationBelongsToOrganization(id, organizationId); +export async function softDeleteQuotation( + id: string, + organizationId: string, + userId: string, + accessContext?: QuotationAccessContext +) { + await assertQuotationBelongsToOrganization(id, organizationId, accessContext); const now = new Date(); const [updated] = await db diff --git a/src/features/crm/security/server/service.ts b/src/features/crm/security/server/service.ts new file mode 100644 index 0000000..30889b7 --- /dev/null +++ b/src/features/crm/security/server/service.ts @@ -0,0 +1,111 @@ +import { auditAction } from '@/features/foundation/audit-log/service'; +import { hasCrmPermission, hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access'; +import { PERMISSIONS } from '@/lib/auth/rbac'; + +export interface CrmSecurityContext { + organizationId: string; + userId: string; + membershipRole: string; + businessRole: string; + businessRoles?: string[]; + permissions: string[]; + branchScopeIds: string[]; + productTypeScopeIds: string[]; + ownershipScope: string; + branchScopeMode: string; + productScopeMode: string; + approvalAuthority?: string | null; +} + +export function buildCrmSecurityContext(input: { + organizationId: string; + userId: string; + membership: { + role: string; + businessRole: string; + businessRoles?: string[]; + permissions?: string[] | null; + branchScopeIds?: string[] | null; + productTypeScopeIds?: string[] | null; + ownershipScope?: string | null; + branchScopeMode?: string | null; + productScopeMode?: string | null; + approvalAuthority?: string | null; + }; +}): CrmSecurityContext { + return { + organizationId: input.organizationId, + userId: input.userId, + membershipRole: input.membership.role, + businessRole: input.membership.businessRole, + businessRoles: input.membership.businessRoles ?? [input.membership.businessRole], + permissions: input.membership.permissions ?? [], + branchScopeIds: input.membership.branchScopeIds ?? [], + productTypeScopeIds: input.membership.productTypeScopeIds ?? [], + ownershipScope: input.membership.ownershipScope ?? 'organization', + branchScopeMode: input.membership.branchScopeMode ?? 'all', + productScopeMode: input.membership.productScopeMode ?? 'all', + approvalAuthority: input.membership.approvalAuthority ?? null + }; +} + +export function canViewQuotationPricing(context: Pick) { + return hasCrmPermission(context, PERMISSIONS.crmQuotationPricingRead); +} + +export function canAccessScopedCrmRecord( + context: Pick< + CrmSecurityContext, + | 'membershipRole' + | 'userId' + | 'ownershipScope' + | 'branchScopeMode' + | 'branchScopeIds' + | 'productScopeMode' + | 'productTypeScopeIds' + >, + row: { + branchId?: string | null; + productType?: string | null; + createdBy?: string | null; + ownerUserId?: string | null; + } +) { + if (!hasBranchScopeAccess(context, row.branchId)) { + return false; + } + + if (!hasProductScopeAccess(context, row.productType)) { + return false; + } + + if (context.membershipRole === 'admin' || context.ownershipScope !== 'own') { + return true; + } + + return row.createdBy === context.userId || row.ownerUserId === context.userId; +} + +export async function auditCrmSecurityEvent(input: { + organizationId: string; + userId: string; + action: 'access_denied' | 'scope_violation' | 'permission_violation' | 'pricing_hidden'; + entityType: string; + entityId: string; + reason: string; + metadata?: Record; +}) { + await auditAction({ + organizationId: input.organizationId, + userId: input.userId, + entityType: 'crm_security_access', + entityId: `${input.entityType}:${input.entityId}`, + action: input.action, + afterData: { + targetEntityType: input.entityType, + targetEntityId: input.entityId, + reason: input.reason, + ...input.metadata + } + }); +} diff --git a/src/features/foundation/approval/server/service.ts b/src/features/foundation/approval/server/service.ts index b7cc406..f375906 100644 --- a/src/features/foundation/approval/server/service.ts +++ b/src/features/foundation/approval/server/service.ts @@ -11,6 +11,8 @@ import { users } from '@/db/schema'; import { db } from '@/lib/db'; +import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access'; +import { type SystemRole } from '@/lib/auth/rbac'; import { AuthError } from '@/lib/auth/session'; import { auditAction } from '@/features/foundation/audit-log/service'; import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; @@ -611,7 +613,17 @@ async function assertActorCanHandleStep(organizationId: string, userId: string, throw new AuthError('Organization membership required', 403); } - if (membership.role === 'admin' || membership.businessRole === roleCode) { + const resolvedAccess = await resolveCrmMembershipAccess({ + systemRole: userRow.systemRole as SystemRole, + organizationId, + membership + }); + + if ( + membership.role === 'admin' || + resolvedAccess.businessRoles.includes(roleCode) || + resolvedAccess.businessRole === roleCode + ) { return; } diff --git a/src/lib/auth/crm-access.ts b/src/lib/auth/crm-access.ts index 8f7b218..ed23317 100644 --- a/src/lib/auth/crm-access.ts +++ b/src/lib/auth/crm-access.ts @@ -380,3 +380,16 @@ export function hasProductScopeAccess(access: ProductScopedAccess, productTypeId return access.productTypeScopeIds.includes(productTypeId); } + +export function hasCrmPermission( + access: Pick | { permissions?: string[] | null }, + permission: string +) { + return (access.permissions ?? []).includes(permission as Permission); +} + +export function canViewQuotationPricing( + access: Pick | { permissions?: string[] | null } +) { + return hasCrmPermission(access, 'crm.quotation.pricing.read'); +} diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index 1df4a3d..987e797 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -60,6 +60,7 @@ export async function requireOrganizationAccess(options?: { organizationId: activeOrganization.id, role: activeOrganization.role, businessRole: activeOrganization.businessRole, + businessRoles: [activeOrganization.businessRole], permissions: session.user.activePermissions, branchScopeIds: session.user.activeBranchScopeIds, productTypeScopeIds: session.user.activeProductTypeScopeIds, @@ -126,11 +127,12 @@ export async function requireOrganizationAccess(options?: { return { session, - membership: { - ...membership, - permissions: resolvedAccess.permissions, - branchScopeIds: resolvedAccess.branchScopeIds, - productTypeScopeIds: resolvedAccess.productTypeScopeIds, + membership: { + ...membership, + businessRoles: resolvedAccess.businessRoles, + permissions: resolvedAccess.permissions, + branchScopeIds: resolvedAccess.branchScopeIds, + productTypeScopeIds: resolvedAccess.productTypeScopeIds, ownershipScope: resolvedAccess.ownershipScope, branchScopeMode: resolvedAccess.branchScopeMode, productScopeMode: resolvedAccess.productScopeMode,