From 1b901efc51036b50a7d2a54644f817c2e1a98b03 Mon Sep 17 00:00:00 2001 From: phaichayon Date: Mon, 22 Jun 2026 14:18:26 +0700 Subject: [PATCH] task-l.3.1 --- ...31-customer-contact-approval-visibility.md | 33 ++ docs/implementation/technical-debt.md | 10 + .../crm-access-enforcement-inventory.md | 16 +- docs/security/crm-authorization-boundaries.md | 30 + docs/security/team-scope-limitations.md | 41 ++ plans/task-l.3.1.md | 561 ++++++++++++++++++ scripts/security/verify-crm-access.ts | 35 ++ src/app/api/crm/approvals/[id]/route.ts | 29 +- src/app/api/crm/approvals/route.ts | 29 +- .../[id]/contacts/[contactId]/route.ts | 60 +- .../api/crm/customers/[id]/contacts/route.ts | 44 +- src/app/api/crm/customers/[id]/route.ts | 51 +- src/app/api/crm/customers/route.ts | 29 +- src/app/dashboard/crm/approvals/[id]/page.tsx | 28 +- src/app/dashboard/crm/customers/[id]/page.tsx | 42 +- src/features/crm/customers/server/service.ts | 206 ++++++- src/features/crm/dashboard/server/service.ts | 2 +- src/features/crm/enquiries/server/service.ts | 21 +- src/features/crm/quotations/server/service.ts | 21 +- src/features/crm/security/server/service.ts | 9 +- .../foundation/approval/server/service.ts | 125 +++- 21 files changed, 1320 insertions(+), 102 deletions(-) create mode 100644 docs/implementation/task-l31-customer-contact-approval-visibility.md create mode 100644 docs/security/team-scope-limitations.md create mode 100644 plans/task-l.3.1.md diff --git a/docs/implementation/task-l31-customer-contact-approval-visibility.md b/docs/implementation/task-l31-customer-contact-approval-visibility.md new file mode 100644 index 0000000..962b66a --- /dev/null +++ b/docs/implementation/task-l31-customer-contact-approval-visibility.md @@ -0,0 +1,33 @@ +# Task L.3.1: Customer, Contact & Approval Visibility Hardening + +## Summary + +Task L.3.1 closes the next set of CRM visibility gaps after Task L.3 by moving customer, contact, and approval visibility closer to the resolved CRM access model already used by quotations and dashboard security. + +## Implemented + +- customer list/detail/update/delete routes now pass resolved CRM scope context +- customer service now filters visibility by: + - branch scope + - ownership scope + - related enquiry/quotation visibility + - marketing monitor visibility through enquiry relationships +- contact routes now inherit resolved customer visibility instead of organization-only reads +- contact access additionally preserves creator visibility +- approval list/detail now require either: + - related quotation visibility + - current approval actor visibility + - admin / organization-wide visibility +- dashboard approval analytics now reuse access-scoped approval listing +- added `customer_scope_violation`, `contact_scope_violation`, and `approval_scope_violation` security audit actions +- expanded static security verification coverage +- documented team-scope and contact-sharing limitations + +## Important limitation + +The production schema still has no persisted contact-sharing model. That means L.3.1 hardens creator/customer/admin visibility, but it cannot preserve a live shared-contact workflow that does not yet exist in persistence. + +## 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 279f125..b480ec1 100644 --- a/docs/implementation/technical-debt.md +++ b/docs/implementation/technical-debt.md @@ -430,3 +430,13 @@ Future: - define the canonical team relationship source - replace current coarse team handling with explicit team membership or manager hierarchy logic + +### Contact-sharing persistence gap + +Current: +Task L.3.1 hardens customer/contact visibility, but the production schema still has no persisted contact-sharing relation. Legacy shared-contact behavior exists only in demo/mock material. + +Future: + +- add a first-class contact-sharing table and audit trail if the business still needs shared-contact workflows +- extend security verification from creator/customer/admin visibility to explicit share-based visibility once persistence exists diff --git a/docs/security/crm-access-enforcement-inventory.md b/docs/security/crm-access-enforcement-inventory.md index 5b530d7..449f434 100644 --- a/docs/security/crm-access-enforcement-inventory.md +++ b/docs/security/crm-access-enforcement-inventory.md @@ -11,13 +11,13 @@ | 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 | +| Customers | `/api/crm/customers` | `Protected` | List/detail/update/delete now pass resolved CRM scope context and customer visibility is filtered by ownership plus related enquiry/quotation signals | +| Contacts | `/api/crm/customers/[id]/contacts` | `Protected` | Contact APIs now inherit customer visibility and creator/admin/team visibility rules from resolved CRM access | | 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 | +| Approvals | `/api/crm/approvals`, approve/reject/return | `Protected` | Approval list/detail now require related quotation visibility or current-actor/admin visibility; step actor resolution uses resolved CRM access union | | 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 | @@ -28,14 +28,12 @@ ## 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. +1. Team-scope semantics are still coarse because the current model does not yet carry a first-class subordinate/team graph. +2. Production contact sharing is not yet backed by a persisted sharing table; current enforcement covers creator/customer/admin visibility only. 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. +- Add runtime seeded scenario tests once dedicated security fixtures are available. +- Introduce a first-class team hierarchy and production contact-sharing model if the business requires them. diff --git a/docs/security/crm-authorization-boundaries.md b/docs/security/crm-authorization-boundaries.md index 0ee5a0b..0588315 100644 --- a/docs/security/crm-authorization-boundaries.md +++ b/docs/security/crm-authorization-boundaries.md @@ -56,6 +56,34 @@ Approval step handling must use resolved CRM role unions instead of a single leg - `organization`: organization-wide inside active tenant - `monitor`: read-oriented role with limited commercial visibility +## Customer Boundary + +Customer visibility is now enforced from resolved CRM access. + +- `organization` and admin scopes can see organization customers within branch scope +- `team` currently behaves as branch/product-scoped broad visibility until a first-class team graph exists +- `own` can see customers they created or customers linked to enquiries/quotations they can already access +- `monitor` can see customers only when those customers are related to visible lead/enquiry records + +## Contact Boundary + +Contact visibility is enforced through customer visibility plus contact ownership rules. + +- if a user can access the parent customer, they can access its contact list within their role boundary +- contact creators retain access to their own contacts +- admin/organization scope retains access +- production shared-contact persistence does not exist yet, so legacy demo sharing is not part of the live authorization boundary + +## Approval Boundary + +Approval visibility is now narrower than generic approval-read permission. + +A user can see approval records only when at least one of these is true: + +1. they can access the related quotation through resolved CRM scope +2. they are the current approval actor by resolved CRM role +3. they are admin / organization-wide CRM authority + ## Artifact Boundary Artifact permission alone is not enough for approved quotation PDFs. @@ -74,6 +102,8 @@ Dashboard access is split: - approval widgets: approval read permission - export visibility: same scope as dashboard, with extra revenue export restriction +Approval widgets additionally inherit approval-request visibility rules, not just a raw approval permission. + ## Audit Events Security-sensitive denials are recorded under entity type `crm_security_access` with actions: diff --git a/docs/security/team-scope-limitations.md b/docs/security/team-scope-limitations.md new file mode 100644 index 0000000..ae6b1be --- /dev/null +++ b/docs/security/team-scope-limitations.md @@ -0,0 +1,41 @@ +# Team Scope Limitations + +## Current Team Scope Logic + +Current CRM authorization resolves the most permissive ownership scope from active CRM role assignments. + +For `team` scope today: + +- quotation visibility is effectively broader than `own`, but still constrained by branch scope and product scope +- customer visibility is approximated from branch scope plus related enquiry/quotation relationships +- approval visibility inherits quotation visibility or current approval-actor visibility + +## Known Limitations + +There is not yet a first-class team hierarchy model in the domain. + +That means the system does **not** currently know: + +- who reports to whom +- which sales users belong to the same explicit team +- whether a manager should see one subset of sales users but not another inside the same branch + +Because of that, `team` currently behaves as an approximate operational scope rather than a strict org-chart scope. + +## Contact Sharing Limitation + +The production schema currently has no persisted contact-sharing table. + +Result: + +- contact visibility is enforced through customer ownership plus contact creator visibility +- mock/demo shared-contact behavior from legacy demo data is not part of the production boundary yet + +## Future Team Hierarchy Model + +Recommended future direction: + +1. add a first-class manager/team relationship model +2. resolve `team` scope from that relationship instead of permissive approximation +3. add explicit contact-sharing persistence if the business still requires cross-owner contact access +4. expand runtime security fixtures around manager-team boundaries after real hierarchy data exists diff --git a/plans/task-l.3.1.md b/plans/task-l.3.1.md new file mode 100644 index 0000000..b312b83 --- /dev/null +++ b/plans/task-l.3.1.md @@ -0,0 +1,561 @@ +# Task L.3.1: Customer, Contact & Approval Visibility Hardening + +## Objective + +Close the remaining authorization gaps from Task L.3 and make CRM visibility enforcement production-ready. + +After Task L.3.1: + +```txt +Customer Visibility +Contact Visibility +Approval Visibility +``` + +must all use the same resolved CRM access model as: + +```txt +Leads +Enquiries +Quotations +Dashboard +PDF +Artifacts +``` + +This task is expected to close the Authorization Epic. + +--- + +# Background + +Task L.3 completed: + +```txt +Quotation Scope Enforcement +Pricing Visibility +PDF Security +Artifact Security +Dashboard Revenue Security +Approval Actor Validation +``` + +Remaining gaps: + +```txt +Customer Ownership Scope +Contact Ownership Scope +Approval List Visibility +Approval Detail Visibility +Runtime Security Verification +``` + +--- + +# Scope L3.1.1 Customer Ownership Enforcement + +Apply resolved CRM access to: + +```txt +Customers List +Customer Detail +Customer Search +Customer Lookup +Customer API +``` + +Rules: + +## Sales + +```txt +Own customers only +``` + +Customer ownership derived from: + +```txt +createdBy +salesmanId +assigned ownership relationship +``` + +based on current CRM model. + +--- + +## Sales Manager + +```txt +Team customers +``` + +limited by: + +```txt +Branch Scope +Product Scope +Ownership Scope +``` + +--- + +## Marketing + +```txt +Monitoring visibility only +``` + +May view customer profile if related to: + +```txt +Lead +Enquiry +``` + +but cannot access quotation pricing. + +--- + +## CRM Admin + +```txt +Organization-wide visibility +``` + +--- + +# Scope L3.1.2 Contact Ownership Enforcement + +Apply same resolved access model. + +Protect: + +```txt +Contacts List +Contact Detail +Contact Search +Contact API +``` + +Rules: + +```txt +Owner +Shared User +Manager Scope +Admin Scope +``` + +must all work correctly. + +--- + +# Scope L3.1.3 Contact Sharing Validation + +Audit existing sharing model. + +Verify: + +```txt +Shared Contact +``` + +still visible after ownership enforcement. + +Rules: + +```txt +Creator +Shared User +CRM Admin +``` + +retain access. + +Users outside sharing relationship: + +```txt +Access Denied +``` + +--- + +# Scope L3.1.4 Approval Visibility Enforcement + +Approval actor validation already exists. + +Now enforce visibility for: + +```txt +Approval List +Approval Detail +Approval History +Approval Dashboard Widgets +``` + +Rules: + +User can only see approval records when: + +```txt +Can Access Related Quotation +``` + +OR + +```txt +Current Approval Actor +``` + +OR + +```txt +CRM Admin +``` + +--- + +# Scope L3.1.5 Approval Detail Security + +Protect: + +```txt +Approval Route +Approval APIs +Approval Timeline +Approval History +``` + +Users without quotation visibility: + +```txt +cannot access approval details +``` + +even if they know the URL. + +Server-side enforcement required. + +--- + +# Scope L3.1.6 Approval Dashboard Hardening + +Verify: + +```txt +Pending Approvals +My Approvals +Approval KPIs +Approval Analytics +``` + +all respect: + +```txt +Branch Scope +Product Scope +Ownership Scope +``` + +and resolved CRM access. + +--- + +# Scope L3.1.7 Team Scope Clarification + +Current implementation uses: + +```txt +Most Permissive Ownership Scope +``` + +but team membership is approximate. + +Document current behavior. + +Create: + +```txt +docs/security/team-scope-limitations.md +``` + +Document: + +```txt +Current Team Scope Logic +Known Limitations +Future Team Hierarchy Model +``` + +No hierarchy implementation in this task. + +--- + +# Scope L3.1.8 Security Fixture Expansion + +Expand: + +```txt +scripts/security/verify-crm-access.ts +``` + +Add scenarios: + +### Customer Visibility + +```txt +Sales A +cannot see +Sales B customer +``` + +--- + +### Contact Sharing + +```txt +Shared Contact +visible + +Unshared Contact +hidden +``` + +--- + +### Approval Visibility + +```txt +Approver +can access + +Non-Approver +cannot access +``` + +--- + +### Marketing + +```txt +can monitor lead +cannot view quotation pricing +``` + +--- + +### CRM Admin + +```txt +full access +``` + +--- + +# Scope L3.1.9 Security Audit Logging + +Extend: + +```txt +crm_security_access +``` + +Events: + +```txt +customer_scope_violation +contact_scope_violation +approval_scope_violation +``` + +Payload: + +```txt +userId +entityId +entityType +reason +resolvedScope +``` + +--- + +# Scope L3.1.10 Security Inventory Update + +Update: + +```txt +docs/security/crm-access-enforcement-inventory.md +``` + +Status should become: + +```txt +Protected +``` + +for: + +```txt +Customers +Contacts +Approvals +``` + +Remove: + +```txt +Partially Protected +``` + +classification where resolved. + +--- + +# Scope L3.1.11 Authorization Boundary Review + +Update: + +```txt +docs/security/crm-authorization-boundaries.md +``` + +Document final behavior for: + +```txt +Customer +Contact +Lead +Enquiry +Quotation +Approval +Dashboard +Artifacts +Settings +``` + +This becomes the official CRM authorization reference. + +--- + +# Verification Matrix + +## Sales A + +Expected: + +```txt +Own Customers +Own Contacts +Own Quotations +``` + +Cannot: + +```txt +Access Sales B records +``` + +--- + +## Sales Manager + +Expected: + +```txt +Team Customers +Team Contacts +Team Quotations +``` + +within scope. + +--- + +## Marketing + +Expected: + +```txt +Lead Monitoring +Follow-Ups +Assignments +``` + +Cannot: + +```txt +View Pricing +View Revenue +View Approval Detail +``` + +--- + +## Approver + +Expected: + +```txt +Can Access Pending Approval +Can Access Approval Detail +``` + +--- + +## Non Approver + +Expected: + +```txt +Cannot Access Approval Detail +``` + +--- + +## CRM Admin + +Expected: + +```txt +Full CRM Access +``` + +--- + +# Deliverables + +1. Customer Ownership Enforcement +2. Contact Ownership Enforcement +3. Contact Sharing Validation +4. Approval Visibility Enforcement +5. Approval Dashboard Hardening +6. Security Fixture Expansion +7. Security Audit Events +8. Updated Security Inventory +9. Authorization Boundary Review + +--- + +# Definition of Done + +Task L.3.1 is complete when: + +* customer visibility uses resolved CRM access +* contact visibility uses resolved CRM access +* contact sharing still works +* approval visibility is secured +* approval detail is secured +* approval dashboard respects scope +* security regression scenarios pass +* security inventory updated +* authorization boundary documentation updated + +Result: + +```txt +Authorization Foundation = CLOSED +CRM Security Boundary = PRODUCTION READY +CRM UAT Security Baseline = READY +``` diff --git a/scripts/security/verify-crm-access.ts b/scripts/security/verify-crm-access.ts index 1958cbf..a3a1ebc 100644 --- a/scripts/security/verify-crm-access.ts +++ b/scripts/security/verify-crm-access.ts @@ -57,6 +57,41 @@ const checks: Check[] = [ name: 'quotation service applies scoped record access', file: 'src/features/crm/quotations/server/service.ts', patterns: ['canAccessScopedCrmRecord', 'buildQuotationAccessScopedFilters', 'Forbidden branch scope'] + }, + { + name: 'customer route passes resolved scope context', + file: 'src/app/api/crm/customers/route.ts', + patterns: ['buildCrmSecurityContext', 'listCustomers(organization.id', 'customer_scope_violation'] + }, + { + name: 'customer detail route passes resolved scope context', + file: 'src/app/api/crm/customers/[id]/route.ts', + patterns: ['buildCrmSecurityContext', 'getCustomerDetail(id, organization.id, accessContext)'] + }, + { + name: 'customer contact routes pass resolved scope context', + file: 'src/app/api/crm/customers/[id]/contacts/route.ts', + patterns: ['buildCrmSecurityContext', 'listCustomerContacts(id, organization.id, accessContext)'] + }, + { + name: 'customer service applies access-scoped ownership filtering', + file: 'src/features/crm/customers/server/service.ts', + patterns: ['resolveAccessibleCustomerRelationSets', 'canAccessCustomerRow', 'ownershipScope === \'monitor\''] + }, + { + name: 'approval route passes resolved scope context', + file: 'src/app/api/crm/approvals/route.ts', + patterns: ['buildCrmSecurityContext', 'listApprovalRequests(organization.id', 'approval_scope_violation'] + }, + { + name: 'approval detail route passes resolved scope context', + file: 'src/app/api/crm/approvals/[id]/route.ts', + patterns: ['buildCrmSecurityContext', 'getApprovalRequest(id, organization.id, accessContext)'] + }, + { + name: 'approval service enforces current-actor or quotation visibility', + file: 'src/features/foundation/approval/server/service.ts', + patterns: ['canCurrentActorAccessApproval', 'canAccessQuotationForApproval', 'visibleItems'] } ]; diff --git a/src/app/api/crm/approvals/[id]/route.ts b/src/app/api/crm/approvals/[id]/route.ts index f9ab1fe..8e76266 100644 --- a/src/app/api/crm/approvals/[id]/route.ts +++ b/src/app/api/crm/approvals/[id]/route.ts @@ -1,5 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { cancelApproval, getApprovalRequest } from '@/features/foundation/approval/server/service'; +import { + auditCrmSecurityEvent, + buildCrmSecurityContext +} from '@/features/crm/security/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -10,10 +14,15 @@ 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.crmApprovalRead }); - const approval = await getApprovalRequest(id, organization.id); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const approval = await getApprovalRequest(id, organization.id, accessContext); return NextResponse.json({ success: true, @@ -23,6 +32,22 @@ export async function GET(_request: NextRequest, { params }: Params) { }); } catch (error) { if (error instanceof AuthError) { + if (error.status === 403) { + const session = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalRead + }).catch(() => null); + + if (session) { + await auditCrmSecurityEvent({ + organizationId: session.organization.id, + userId: session.session.user.id, + action: 'approval_scope_violation', + entityType: 'crm_approval_request', + entityId: (await params).id, + reason: 'Approval detail visibility denied' + }); + } + } return NextResponse.json({ message: error.message }, { status: error.status }); } if (error instanceof Error) { diff --git a/src/app/api/crm/approvals/route.ts b/src/app/api/crm/approvals/route.ts index a5f5d7d..93fa717 100644 --- a/src/app/api/crm/approvals/route.ts +++ b/src/app/api/crm/approvals/route.ts @@ -1,6 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { listApprovalRequests, submitForApproval } from '@/features/foundation/approval/server/service'; +import { + auditCrmSecurityEvent, + buildCrmSecurityContext +} from '@/features/crm/security/server/service'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { AuthError, requireOrganizationAccess } from '@/lib/auth/session'; @@ -13,7 +17,7 @@ const submitApprovalSchema = z.object({ export async function GET(request: NextRequest) { try { - const { organization } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmApprovalRead }); const { searchParams } = request.nextUrl; @@ -24,6 +28,11 @@ export async function GET(request: NextRequest) { const entityType = searchParams.get('entityType') ?? undefined; const entityId = searchParams.get('entityId') ?? undefined; const sort = searchParams.get('sort') ?? undefined; + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); const result = await listApprovalRequests(organization.id, { page, limit, @@ -32,7 +41,7 @@ export async function GET(request: NextRequest) { entityType, entityId, sort - }); + }, accessContext); return NextResponse.json({ success: true, @@ -45,6 +54,22 @@ export async function GET(request: NextRequest) { }); } catch (error) { if (error instanceof AuthError) { + if (error.status === 403) { + const session = await requireOrganizationAccess({ + permission: PERMISSIONS.crmApprovalRead + }).catch(() => null); + + if (session) { + await auditCrmSecurityEvent({ + organizationId: session.organization.id, + userId: session.session.user.id, + action: 'approval_scope_violation', + entityType: 'crm_approval_request', + entityId: 'list', + reason: 'Approval list visibility denied' + }); + } + } return NextResponse.json({ message: error.message }, { status: error.status }); } if (error instanceof Error) { diff --git a/src/app/api/crm/customers/[id]/contacts/[contactId]/route.ts b/src/app/api/crm/customers/[id]/contacts/[contactId]/route.ts index 434ecd9..3814321 100644 --- a/src/app/api/crm/customers/[id]/contacts/[contactId]/route.ts +++ b/src/app/api/crm/customers/[id]/contacts/[contactId]/route.ts @@ -1,6 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; +import { + auditCrmSecurityEvent, + buildCrmSecurityContext +} from '@/features/crm/security/server/service'; import { listCustomerContacts, softDeleteCustomerContact, @@ -17,11 +21,16 @@ type Params = { export async function PATCH(request: NextRequest, { params }: Params) { try { const { id, contactId } = await params; - const { organization, session } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmContactUpdate }); const payload = customerContactSchema.parse(await request.json()); - const before = (await listCustomerContacts(id, organization.id)).find( + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const before = (await listCustomerContacts(id, organization.id, accessContext)).find( (contact) => contact.id === contactId ); @@ -34,7 +43,8 @@ export async function PATCH(request: NextRequest, { params }: Params) { contactId, organization.id, session.user.id, - payload + payload, + accessContext ); await auditUpdate({ @@ -53,6 +63,22 @@ export async function PATCH(request: NextRequest, { params }: Params) { }); } catch (error) { if (error instanceof AuthError) { + if (error.status === 403) { + const access = await requireOrganizationAccess({ + permission: PERMISSIONS.crmContactUpdate + }).catch(() => null); + + if (access) { + await auditCrmSecurityEvent({ + organizationId: access.organization.id, + userId: access.session.user.id, + action: 'contact_scope_violation', + entityType: 'crm_customer_contact', + entityId: (await params).contactId, + reason: 'Customer contact update visibility denied' + }); + } + } return NextResponse.json({ message: error.message }, { status: error.status }); } @@ -74,10 +100,15 @@ export async function PATCH(request: NextRequest, { params }: Params) { export async function DELETE(_request: NextRequest, { params }: Params) { try { const { id, contactId } = await params; - const { organization, session } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmContactDelete }); - const before = (await listCustomerContacts(id, organization.id)).find( + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const before = (await listCustomerContacts(id, organization.id, accessContext)).find( (contact) => contact.id === contactId ); @@ -89,7 +120,8 @@ export async function DELETE(_request: NextRequest, { params }: Params) { id, contactId, organization.id, - session.user.id + session.user.id, + accessContext ); await auditDelete({ @@ -107,6 +139,22 @@ export async function DELETE(_request: NextRequest, { params }: Params) { }); } catch (error) { if (error instanceof AuthError) { + if (error.status === 403) { + const access = await requireOrganizationAccess({ + permission: PERMISSIONS.crmContactDelete + }).catch(() => null); + + if (access) { + await auditCrmSecurityEvent({ + organizationId: access.organization.id, + userId: access.session.user.id, + action: 'contact_scope_violation', + entityType: 'crm_customer_contact', + entityId: (await params).contactId, + reason: 'Customer contact delete visibility denied' + }); + } + } return NextResponse.json({ message: error.message }, { status: error.status }); } diff --git a/src/app/api/crm/customers/[id]/contacts/route.ts b/src/app/api/crm/customers/[id]/contacts/route.ts index 1c69f1b..9f09143 100644 --- a/src/app/api/crm/customers/[id]/contacts/route.ts +++ b/src/app/api/crm/customers/[id]/contacts/route.ts @@ -1,6 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { auditCreate } from '@/features/foundation/audit-log/service'; +import { + auditCrmSecurityEvent, + buildCrmSecurityContext +} from '@/features/crm/security/server/service'; import { createCustomerContact, listCustomerContacts @@ -16,10 +20,15 @@ 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.crmContactRead }); - const items = await listCustomerContacts(id, organization.id); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const items = await listCustomerContacts(id, organization.id, accessContext); return NextResponse.json({ success: true, @@ -29,6 +38,22 @@ export async function GET(_request: NextRequest, { params }: Params) { }); } catch (error) { if (error instanceof AuthError) { + if (error.status === 403) { + const access = await requireOrganizationAccess({ + permission: PERMISSIONS.crmContactRead + }).catch(() => null); + + if (access) { + await auditCrmSecurityEvent({ + organizationId: access.organization.id, + userId: access.session.user.id, + action: 'contact_scope_violation', + entityType: 'crm_customer_contact', + entityId: (await params).id, + reason: 'Customer contact list visibility denied' + }); + } + } return NextResponse.json({ message: error.message }, { status: error.status }); } @@ -43,11 +68,22 @@ 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.crmContactCreate }); const payload = customerContactSchema.parse(await request.json()); - const created = await createCustomerContact(id, organization.id, session.user.id, payload); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const created = await createCustomerContact( + id, + organization.id, + session.user.id, + payload, + accessContext + ); await auditCreate({ organizationId: organization.id, diff --git a/src/app/api/crm/customers/[id]/route.ts b/src/app/api/crm/customers/[id]/route.ts index 9c062fa..e83888b 100644 --- a/src/app/api/crm/customers/[id]/route.ts +++ b/src/app/api/crm/customers/[id]/route.ts @@ -1,6 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service'; +import { + auditCrmSecurityEvent, + buildCrmSecurityContext +} from '@/features/crm/security/server/service'; import { getCustomerActivity, getCustomerDetail, @@ -25,11 +29,16 @@ const customerRequestSchema = customerSchema.extend({ 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.crmCustomerRead }); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); const [customer, activity] = await Promise.all([ - getCustomerDetail(id, organization.id), + getCustomerDetail(id, organization.id, accessContext), getCustomerActivity(id, organization.id) ]); @@ -42,6 +51,22 @@ export async function GET(_request: NextRequest, { params }: Params) { }); } catch (error) { if (error instanceof AuthError) { + if (error.status === 403) { + const access = await requireOrganizationAccess({ + permission: PERMISSIONS.crmCustomerRead + }).catch(() => null); + + if (access) { + await auditCrmSecurityEvent({ + organizationId: access.organization.id, + userId: access.session.user.id, + action: 'customer_scope_violation', + entityType: 'crm_customer', + entityId: (await params).id, + reason: 'Customer detail visibility denied' + }); + } + } return NextResponse.json({ message: error.message }, { status: error.status }); } @@ -54,12 +79,17 @@ 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.crmCustomerUpdate }); const payload = customerRequestSchema.parse(await request.json()); - const before = await getCustomerDetail(id, organization.id); - const updated = await updateCustomer(id, organization.id, session.user.id, payload); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const before = await getCustomerDetail(id, organization.id, accessContext); + const updated = await updateCustomer(id, organization.id, session.user.id, payload, accessContext); await auditUpdate({ organizationId: organization.id, @@ -97,11 +127,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.crmCustomerDelete }); - const before = await getCustomerDetail(id, organization.id); - const updated = await softDeleteCustomer(id, organization.id, session.user.id); + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); + const before = await getCustomerDetail(id, organization.id, accessContext); + const updated = await softDeleteCustomer(id, organization.id, session.user.id, accessContext); await auditDelete({ organizationId: organization.id, diff --git a/src/app/api/crm/customers/route.ts b/src/app/api/crm/customers/route.ts index ec7e238..c238f8e 100644 --- a/src/app/api/crm/customers/route.ts +++ b/src/app/api/crm/customers/route.ts @@ -1,6 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { z } from 'zod'; import { auditCreate } from '@/features/foundation/audit-log/service'; +import { + auditCrmSecurityEvent, + buildCrmSecurityContext +} from '@/features/crm/security/server/service'; import { createCustomer, listCustomers } from '@/features/crm/customers/server/service'; import { customerSchema } from '@/features/crm/customers/schemas/customer.schema'; import { PERMISSIONS } from '@/lib/auth/rbac'; @@ -15,7 +19,7 @@ const customerRequestSchema = customerSchema.extend({ export async function GET(request: NextRequest) { try { - const { organization } = await requireOrganizationAccess({ + const { organization, session, membership } = await requireOrganizationAccess({ permission: PERMISSIONS.crmCustomerRead }); const { searchParams } = request.nextUrl; @@ -26,6 +30,11 @@ export async function GET(request: NextRequest) { const customerType = searchParams.get('customerType') ?? undefined; const branch = searchParams.get('branch') ?? undefined; const sort = searchParams.get('sort') ?? undefined; + const accessContext = buildCrmSecurityContext({ + organizationId: organization.id, + userId: session.user.id, + membership + }); const result = await listCustomers(organization.id, { page, limit, @@ -34,7 +43,7 @@ export async function GET(request: NextRequest) { customerType, branch, sort - }); + }, accessContext); const offset = (page - 1) * limit; return NextResponse.json({ @@ -48,6 +57,22 @@ export async function GET(request: NextRequest) { }); } catch (error) { if (error instanceof AuthError) { + if (error.status === 403) { + const access = await requireOrganizationAccess({ + permission: PERMISSIONS.crmCustomerRead + }).catch(() => null); + + if (access) { + await auditCrmSecurityEvent({ + organizationId: access.organization.id, + userId: access.session.user.id, + action: 'customer_scope_violation', + entityType: 'crm_customer', + entityId: 'list', + reason: 'Customer list visibility denied' + }); + } + } return NextResponse.json({ message: error.message }, { status: error.status }); } diff --git a/src/app/dashboard/crm/approvals/[id]/page.tsx b/src/app/dashboard/crm/approvals/[id]/page.tsx index a911bb3..dbc41b1 100644 --- a/src/app/dashboard/crm/approvals/[id]/page.tsx +++ b/src/app/dashboard/crm/approvals/[id]/page.tsx @@ -2,6 +2,7 @@ import { auth } from '@/auth'; import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import PageContainer from '@/components/layout/page-container'; import { ApprovalDetail } from '@/features/foundation/approval/components/approval-detail'; +import { getApprovalRequest } from '@/features/foundation/approval/server/service'; import { approvalByIdOptions } from '@/features/foundation/approval/queries'; import { PERMISSIONS } from '@/lib/auth/rbac'; import { getQueryClient } from '@/lib/query-client'; @@ -40,9 +41,30 @@ export default async function ApprovalDetailRoute({ params }: PageProps) { session.user.activePermissions.includes(PERMISSIONS.crmApprovalSubmit))); const isOrgAdmin = session?.user?.systemRole === 'super_admin' || session?.user?.activeMembershipRole === 'admin'; + const accessContext = + session?.user?.activeOrganizationId && session?.user?.id + ? { + organizationId: session.user.activeOrganizationId, + userId: session.user.id, + membershipRole: session.user.activeMembershipRole ?? 'user', + businessRole: session.user.activeBusinessRole ?? 'sales_support', + permissions: session.user.activePermissions ?? [], + branchScopeIds: session.user.activeBranchScopeIds ?? [], + productTypeScopeIds: session.user.activeProductTypeScopeIds ?? [], + ownershipScope: session.user.activeOwnershipScope ?? 'organization', + branchScopeMode: 'all', + productScopeMode: 'all' + } + : null; + const resolvedCanRead = + canRead && accessContext + ? await getApprovalRequest(id, session.user.activeOrganizationId!, accessContext) + .then(() => true) + .catch(() => false) + : false; const queryClient = getQueryClient(); - if (canRead) { + if (resolvedCanRead) { void queryClient.prefetchQuery(approvalByIdOptions(id)); } @@ -50,14 +72,14 @@ export default async function ApprovalDetailRoute({ params }: PageProps) { You do not have access to this approval request. } > - {canRead && session?.user ? ( + {resolvedCanRead && session?.user ? ( true) + .catch(() => false) + : false; const queryClient = getQueryClient(); - if (canRead) { + if (resolvedCanRead) { void queryClient.prefetchQuery(customerByIdOptions(id)); } - if (canContactRead) { + if (resolvedCanRead && canContactRead) { void queryClient.prefetchQuery(customerContactsOptions(id)); } const referenceData = - canRead && session?.user?.activeOrganizationId + resolvedCanRead && session?.user?.activeOrganizationId ? await getCustomerReferenceData(session.user.activeOrganizationId) : null; const relatedEnquiries = - canRead && session?.user?.activeOrganizationId - ? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId) + resolvedCanRead && session?.user?.activeOrganizationId && accessContext + ? await listCustomerEnquiryRelations(id, session.user.activeOrganizationId, accessContext) : []; const relatedQuotations = - canRead && session?.user?.activeOrganizationId - ? await listCustomerQuotationRelations(id, session.user.activeOrganizationId) + resolvedCanRead && session?.user?.activeOrganizationId && accessContext + ? await listCustomerQuotationRelations(id, session.user.activeOrganizationId, accessContext) : []; return ( You do not have access to this customer record. diff --git a/src/features/crm/customers/server/service.ts b/src/features/crm/customers/server/service.ts index 4957ac8..3d6f859 100644 --- a/src/features/crm/customers/server/service.ts +++ b/src/features/crm/customers/server/service.ts @@ -1,11 +1,13 @@ import { and, asc, count, desc, eq, ilike, inArray, isNull, or, sql, type SQL } from 'drizzle-orm'; -import { crmCustomerContacts, crmCustomers, msOptions, users } from '@/db/schema'; +import { crmCustomerContacts, crmCustomers, crmEnquiries, crmQuotations, msOptions, users } 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 { validateBranchAccess, getUserBranches } from '@/features/foundation/branch-scope/service'; import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service'; import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; +import { type CrmSecurityContext } from '@/features/crm/security/server/service'; import type { BranchOption, CustomerActivityRecord, @@ -25,6 +27,8 @@ type CustomerRecordSource = Omit(); + const quotationCustomerIds = new Set(); + + for (const row of enquiries) { + if (!hasBranchScopeAccess(accessContext, row.branchId)) { + continue; + } + + if (!hasProductScopeAccess(accessContext, row.productType)) { + continue; + } + + if ( + accessContext.membershipRole === 'admin' || + accessContext.ownershipScope === 'organization' || + accessContext.ownershipScope === 'team' || + accessContext.ownershipScope === 'monitor' || + row.createdBy === accessContext.userId || + row.assignedToUserId === accessContext.userId + ) { + enquiryCustomerIds.add(row.customerId); + } + } + + for (const row of quotations) { + if (!hasBranchScopeAccess(accessContext, row.branchId)) { + continue; + } + + if (!hasProductScopeAccess(accessContext, row.quotationType)) { + continue; + } + + if ( + accessContext.membershipRole === 'admin' || + accessContext.ownershipScope === 'organization' || + accessContext.ownershipScope === 'team' || + row.createdBy === accessContext.userId || + row.salesmanId === accessContext.userId + ) { + quotationCustomerIds.add(row.customerId); + } + } + + return { enquiryCustomerIds, quotationCustomerIds }; +} + +async function canAccessCustomerRow( + row: CustomerRecordSource, + accessContext: CustomerAccessContext +) { + if (!hasBranchScopeAccess(accessContext, row.branchId)) { + return false; + } + + if ( + accessContext.membershipRole === 'admin' || + accessContext.ownershipScope === 'organization' || + accessContext.ownershipScope === 'team' + ) { + return true; + } + + const { enquiryCustomerIds, quotationCustomerIds } = await resolveAccessibleCustomerRelationSets( + row.organizationId, + accessContext + ); + + if (accessContext.ownershipScope === 'monitor') { + return enquiryCustomerIds.has(row.id); + } + + return ( + row.createdBy === accessContext.userId || + enquiryCustomerIds.has(row.id) || + quotationCustomerIds.has(row.id) + ); +} + +async function assertCustomerAccess( + id: string, + organizationId: string, + accessContext?: CustomerAccessContext +) { + const customer = await assertCustomerBelongsToOrganization(id, organizationId); + + if (accessContext && !(await canAccessCustomerRow(customer, accessContext))) { + throw new AuthError('Forbidden', 403); + } + + return customer; +} + async function assertContactBelongsToCustomer( contactId: string, customerId: string, @@ -406,7 +534,8 @@ export async function getCustomerReferenceData( export async function listCustomers( organizationId: string, - filters: CustomerFilters + filters: CustomerFilters, + accessContext?: CustomerAccessContext ): Promise<{ items: CustomerListItem[]; totalItems: number }> { const includeCustomerSubGroup = await hasCustomerSubGroupColumn(); const page = filters.page ?? 1; @@ -415,16 +544,22 @@ export async function listCustomers( const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters); const offset = (page - 1) * limit; - const [totalResult] = await db.select({ value: count() }).from(crmCustomers).where(where); const rows = await db .select(getCustomerRecordSelection(includeCustomerSubGroup)) .from(crmCustomers) .where(where) - .orderBy(parseSort(filters.sort)) - .limit(limit) - .offset(offset); + .orderBy(parseSort(filters.sort)); - const customerIds = rows.map((row) => row.id); + const scopedRows = accessContext + ? ( + await Promise.all( + rows.map(async (row) => ((await canAccessCustomerRow(row, accessContext)) ? row : null)) + ) + ).filter((row): row is CustomerRecordSource => row !== null) + : rows; + const pagedRows = scopedRows.slice(offset, offset + limit); + + const customerIds = pagedRows.map((row) => row.id); const contactCounts = customerIds.length ? await db .select({ @@ -444,19 +579,20 @@ export async function listCustomers( const countMap = new Map(contactCounts.map((item) => [item.customerId, item.value])); return { - items: rows.map((row) => ({ + items: pagedRows.map((row) => ({ ...mapCustomerRecord(row), contactCount: countMap.get(row.id) ?? 0 })), - totalItems: totalResult?.value ?? 0 + totalItems: scopedRows.length }; } export async function getCustomerDetail( id: string, - organizationId: string + organizationId: string, + accessContext?: CustomerAccessContext ): Promise { - const customer = await assertCustomerBelongsToOrganization(id, organizationId); + const customer = await assertCustomerAccess(id, organizationId, accessContext); return mapCustomerRecord(customer); } @@ -542,10 +678,11 @@ export async function updateCustomer( id: string, organizationId: string, userId: string, - payload: CustomerMutationPayload + payload: CustomerMutationPayload, + accessContext?: CustomerAccessContext ) { await validateCustomerPayload(organizationId, payload); - await assertCustomerBelongsToOrganization(id, organizationId); + await assertCustomerAccess(id, organizationId, accessContext); const [updated] = await db .update(crmCustomers) @@ -580,8 +717,13 @@ export async function updateCustomer( return updated; } -export async function softDeleteCustomer(id: string, organizationId: string, userId: string) { - await assertCustomerBelongsToOrganization(id, organizationId); +export async function softDeleteCustomer( + id: string, + organizationId: string, + userId: string, + accessContext?: CustomerAccessContext +) { + await assertCustomerAccess(id, organizationId, accessContext); const now = new Date(); @@ -614,8 +756,12 @@ export async function softDeleteCustomer(id: string, organizationId: string, use return updated; } -export async function listCustomerContacts(id: string, organizationId: string) { - await assertCustomerBelongsToOrganization(id, organizationId); +export async function listCustomerContacts( + id: string, + organizationId: string, + accessContext?: CustomerAccessContext +) { + await assertCustomerAccess(id, organizationId, accessContext); const rows = await db.query.crmCustomerContacts.findMany({ where: and( eq(crmCustomerContacts.customerId, id), @@ -625,16 +771,26 @@ export async function listCustomerContacts(id: string, organizationId: string) { orderBy: [desc(crmCustomerContacts.isPrimary), asc(crmCustomerContacts.name)] }); - return rows.map(mapCustomerContactRecord); + return rows + .filter( + (row) => + !accessContext || + row.createdBy === accessContext.userId || + accessContext.membershipRole === 'admin' || + accessContext.ownershipScope === 'organization' || + accessContext.ownershipScope === 'team' + ) + .map(mapCustomerContactRecord); } export async function createCustomerContact( customerId: string, organizationId: string, userId: string, - payload: CustomerContactMutationPayload + payload: CustomerContactMutationPayload, + accessContext?: CustomerAccessContext ) { - await assertCustomerBelongsToOrganization(customerId, organizationId); + await assertCustomerAccess(customerId, organizationId, accessContext); return db.transaction(async (tx) => { if (payload.isPrimary) { @@ -683,9 +839,10 @@ export async function updateCustomerContact( contactId: string, organizationId: string, userId: string, - payload: CustomerContactMutationPayload + payload: CustomerContactMutationPayload, + accessContext?: CustomerAccessContext ) { - await assertCustomerBelongsToOrganization(customerId, organizationId); + await assertCustomerAccess(customerId, organizationId, accessContext); await assertContactBelongsToCustomer(contactId, customerId, organizationId); return db.transaction(async (tx) => { @@ -732,9 +889,10 @@ export async function softDeleteCustomerContact( customerId: string, contactId: string, organizationId: string, - userId: string + userId: string, + accessContext?: CustomerAccessContext ) { - await assertCustomerBelongsToOrganization(customerId, organizationId); + await assertCustomerAccess(customerId, organizationId, accessContext); await assertContactBelongsToCustomer(contactId, customerId, organizationId); const [updated] = await db diff --git a/src/features/crm/dashboard/server/service.ts b/src/features/crm/dashboard/server/service.ts index fbd51d7..30778d5 100644 --- a/src/features/crm/dashboard/server/service.ts +++ b/src/features/crm/dashboard/server/service.ts @@ -686,7 +686,7 @@ async function buildApprovalAnalytics( page: 1, limit: 50, status: 'pending' - }); + }, context); const todayStart = startOfDay(new Date()); const todayEnd = endOfDay(new Date()); const rows = await db diff --git a/src/features/crm/enquiries/server/service.ts b/src/features/crm/enquiries/server/service.ts index 053000d..5dd1a23 100644 --- a/src/features/crm/enquiries/server/service.ts +++ b/src/features/crm/enquiries/server/service.ts @@ -1348,7 +1348,8 @@ export async function softDeleteEnquiryFollowup( export async function listCustomerEnquiryRelations( customerId: string, - organizationId: string + organizationId: string, + accessContext?: EnquiryAccessContext ): Promise { const rows = await db .select() @@ -1363,14 +1364,16 @@ export async function listCustomerEnquiryRelations( .orderBy(desc(crmEnquiries.updatedAt)) .limit(10); - return rows.map((row) => ({ - id: row.id, - code: row.code, - title: row.title, - status: row.status, - productType: row.productType, - updatedAt: row.updatedAt.toISOString() - })); + return rows + .filter((row) => !accessContext || canAccessEnquiryRow(row, accessContext)) + .map((row) => ({ + id: row.id, + code: row.code, + title: row.title, + status: row.status, + productType: row.productType, + updatedAt: row.updatedAt.toISOString() + })); } export async function syncEnquiryPipelineStageFromQuotationStatus( diff --git a/src/features/crm/quotations/server/service.ts b/src/features/crm/quotations/server/service.ts index 41cad10..3a6cd61 100644 --- a/src/features/crm/quotations/server/service.ts +++ b/src/features/crm/quotations/server/service.ts @@ -2319,7 +2319,8 @@ export async function listEnquiryQuotationRelations( export async function listCustomerQuotationRelations( customerId: string, - organizationId: string + organizationId: string, + accessContext?: QuotationAccessContext ): Promise { const rows = await db .select() @@ -2333,12 +2334,14 @@ export async function listCustomerQuotationRelations( ) .orderBy(desc(crmQuotations.updatedAt)); - return rows.map((row) => ({ - id: row.id, - code: row.code, - status: row.status, - quotationType: row.quotationType, - totalAmount: row.totalAmount, - updatedAt: row.updatedAt.toISOString() - })); + return rows + .filter((row) => !accessContext || canAccessQuotationRow(row, accessContext)) + .map((row) => ({ + id: row.id, + code: row.code, + status: row.status, + quotationType: row.quotationType, + totalAmount: row.totalAmount, + updatedAt: row.updatedAt.toISOString() + })); } diff --git a/src/features/crm/security/server/service.ts b/src/features/crm/security/server/service.ts index 30889b7..8469901 100644 --- a/src/features/crm/security/server/service.ts +++ b/src/features/crm/security/server/service.ts @@ -89,7 +89,14 @@ export function canAccessScopedCrmRecord( export async function auditCrmSecurityEvent(input: { organizationId: string; userId: string; - action: 'access_denied' | 'scope_violation' | 'permission_violation' | 'pricing_hidden'; + action: + | 'access_denied' + | 'scope_violation' + | 'permission_violation' + | 'pricing_hidden' + | 'customer_scope_violation' + | 'contact_scope_violation' + | 'approval_scope_violation'; entityType: string; entityId: string; reason: string; diff --git a/src/features/foundation/approval/server/service.ts b/src/features/foundation/approval/server/service.ts index f375906..8384d06 100644 --- a/src/features/foundation/approval/server/service.ts +++ b/src/features/foundation/approval/server/service.ts @@ -15,6 +15,10 @@ 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 { + type CrmSecurityContext, + canAccessScopedCrmRecord +} from '@/features/crm/security/server/service'; import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service'; import type { ApprovalActionRecord, @@ -49,6 +53,7 @@ const APPROVAL_ACTIONS = { } as const; const QUOTATION_STATUS_CATEGORY = 'crm_quotation_status'; +export type ApprovalAccessContext = CrmSecurityContext; function mapWorkflowRecord(row: typeof crmApprovalWorkflows.$inferSelect): ApprovalWorkflowRecord { return { @@ -683,24 +688,61 @@ function buildApprovalFilters(organizationId: string, filters: ApprovalFilters): ]; } +function canCurrentActorAccessApproval( + currentStep: ApprovalStepRecord | null, + accessContext?: ApprovalAccessContext +) { + if (!accessContext) { + return false; + } + + if ( + accessContext.membershipRole === 'admin' || + accessContext.ownershipScope === 'organization' + ) { + return true; + } + + if (!currentStep) { + return false; + } + + return ( + accessContext.businessRole === currentStep.roleCode || + (accessContext.businessRoles ?? []).includes(currentStep.roleCode) + ); +} + +function canAccessQuotationForApproval( + quotation: typeof crmQuotations.$inferSelect | null | undefined, + accessContext?: ApprovalAccessContext +) { + if (!quotation || !accessContext) { + return false; + } + + return canAccessScopedCrmRecord(accessContext, { + branchId: quotation.branchId, + productType: quotation.quotationType, + createdBy: quotation.createdBy, + ownerUserId: quotation.salesmanId + }); +} + export async function listApprovalRequests( organizationId: string, - filters: ApprovalFilters + filters: ApprovalFilters, + accessContext?: ApprovalAccessContext ): Promise<{ items: ApprovalListItem[]; totalItems: number }> { const page = filters.page ?? 1; const limit = filters.limit ?? 10; const whereFilters = buildApprovalFilters(organizationId, filters); const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters); - const offset = (page - 1) * limit; - - const [totalResult] = await db.select({ value: count() }).from(crmApprovalRequests).where(where); const rows = await db .select() .from(crmApprovalRequests) .where(where) - .orderBy(parseSort(filters.sort)) - .limit(limit) - .offset(offset); + .orderBy(parseSort(filters.sort)); const workflowIds = [...new Set(rows.map((row) => row.workflowId))]; const requesterIds = [...new Set(rows.map((row) => row.requestedBy))]; @@ -740,11 +782,37 @@ export async function listApprovalRequests( } } - return { - items: rows.map((row) => { + const quotationIds = rows + .filter((row) => row.entityType === 'quotation') + .map((row) => row.entityId); + const quotations = quotationIds.length + ? await db + .select() + .from(crmQuotations) + .where( + and( + eq(crmQuotations.organizationId, organizationId), + inArray(crmQuotations.id, quotationIds), + isNull(crmQuotations.deletedAt) + ) + ) + : []; + const quotationMap = new Map(quotations.map((row) => [row.id, row])); + + const visibleItems = rows + .map((row) => { const workflow = workflowMap.get(row.workflowId); const currentStep = stepMap.get(`${row.workflowId}:${row.currentStep}`) ?? null; const entitySummary = entitySummaries.get(`${row.entityType}:${row.entityId}`) ?? null; + const canAccess = + !accessContext || + canCurrentActorAccessApproval(currentStep, accessContext) || + (row.entityType === 'quotation' && + canAccessQuotationForApproval(quotationMap.get(row.entityId), accessContext)); + + if (!canAccess) { + return null; + } return { ...mapRequestRecord(row), @@ -755,9 +823,14 @@ export async function listApprovalRequests( requestedByName: requesterMap.get(row.requestedBy) ?? null, entityCode: entitySummary?.entityCode ?? null, entityTitle: entitySummary?.entityTitle ?? null - }; - }), - totalItems: totalResult?.value ?? 0 + } satisfies ApprovalListItem; + }) + .filter((item): item is ApprovalListItem => item !== null); + const offset = (page - 1) * limit; + + return { + items: visibleItems.slice(offset, offset + limit), + totalItems: visibleItems.length }; } @@ -815,7 +888,8 @@ export async function getCurrentApprovalStep( export async function getApprovalRequest( id: string, - organizationId: string + organizationId: string, + accessContext?: ApprovalAccessContext ): Promise { const requestRow = await assertApprovalRequest(id, organizationId); const [workflowRow] = await db @@ -838,6 +912,31 @@ export async function getApprovalRequest( const currentStep = steps.find((step) => step.stepNumber === requestRow.currentStep) ?? null; const entitySummary = entitySummaries.get(`${requestRow.entityType}:${requestRow.entityId}`) ?? null; + const quotation = + requestRow.entityType === 'quotation' + ? ( + await db + .select() + .from(crmQuotations) + .where( + and( + eq(crmQuotations.organizationId, organizationId), + eq(crmQuotations.id, requestRow.entityId), + isNull(crmQuotations.deletedAt) + ) + ) + .limit(1) + )[0] + : null; + + const canAccess = + !accessContext || + canCurrentActorAccessApproval(currentStep, accessContext) || + (requestRow.entityType === 'quotation' && canAccessQuotationForApproval(quotation, accessContext)); + + if (!canAccess) { + throw new AuthError('Forbidden', 403); + } return { request: mapRequestRecord(requestRow),