task-l.3
This commit is contained in:
37
docs/implementation/task-l3-full-crm-scope-enforcement.md
Normal file
37
docs/implementation/task-l3-full-crm-scope-enforcement.md
Normal file
@@ -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`
|
||||||
@@ -398,3 +398,35 @@ Future:
|
|||||||
|
|
||||||
- decide which non-revenue widgets should fully honor project-party role filtering
|
- 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
|
- 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
|
||||||
|
|||||||
41
docs/security/crm-access-enforcement-inventory.md
Normal file
41
docs/security/crm-access-enforcement-inventory.md
Normal file
@@ -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.
|
||||||
84
docs/security/crm-authorization-boundaries.md
Normal file
84
docs/security/crm-authorization-boundaries.md
Normal file
@@ -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`
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
"seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
|
"seed:task-h1-fixture": "node scripts/seed-task-h1-fixture.js",
|
||||||
"verify:task-h1": "node scripts/verify-task-h1.js",
|
"verify:task-h1": "node scripts/verify-task-h1.js",
|
||||||
"verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.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: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:coverage": "node --experimental-strip-types scripts/audit-pdf-mapping-coverage.ts",
|
||||||
"audit:pdf:runtime": "node --experimental-strip-types scripts/audit-pdf-runtime-payload.ts",
|
"audit:pdf:runtime": "node --experimental-strip-types scripts/audit-pdf-runtime-payload.ts",
|
||||||
|
|||||||
663
plans/task-l.3.md
Normal file
663
plans/task-l.3.md
Normal file
@@ -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
|
||||||
|
```
|
||||||
83
scripts/security/verify-crm-access.ts
Normal file
83
scripts/security/verify-crm-access.ts
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
import { getCrmDashboardData } from '@/features/crm/dashboard/server/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 { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -34,19 +39,27 @@ export async function GET(request: NextRequest) {
|
|||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const section = searchParams.get('section') ?? 'sales-ranking';
|
const section = searchParams.get('section') ?? 'sales-ranking';
|
||||||
const format = searchParams.get('format') ?? 'csv';
|
const format = searchParams.get('format') ?? 'csv';
|
||||||
const data = await getCrmDashboardData(
|
const securityContext = buildCrmSecurityContext({
|
||||||
{
|
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
membershipRole: membership.role,
|
membership
|
||||||
businessRole: membership.businessRole,
|
});
|
||||||
permissions: membership.permissions,
|
|
||||||
branchScopeIds: membership.branchScopeIds ?? [],
|
if (section === 'revenue-analytics' && !canViewQuotationPricing(securityContext)) {
|
||||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
await auditCrmSecurityEvent({
|
||||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
organizationId: organization.id,
|
||||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
userId: session.user.id,
|
||||||
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'),
|
dateFrom: searchParams.get('dateFrom'),
|
||||||
dateTo: searchParams.get('dateTo'),
|
dateTo: searchParams.get('dateTo'),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getCrmDashboardData } from '@/features/crm/dashboard/server/service';
|
import { getCrmDashboardData } from '@/features/crm/dashboard/server/service';
|
||||||
|
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -9,19 +10,13 @@ export async function GET(request: NextRequest) {
|
|||||||
permission: PERMISSIONS.crmDashboardRead
|
permission: PERMISSIONS.crmDashboardRead
|
||||||
});
|
});
|
||||||
const searchParams = request.nextUrl.searchParams;
|
const searchParams = request.nextUrl.searchParams;
|
||||||
const data = await getCrmDashboardData(
|
const securityContext = buildCrmSecurityContext({
|
||||||
{
|
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
membershipRole: membership.role,
|
membership
|
||||||
businessRole: membership.businessRole,
|
});
|
||||||
permissions: membership.permissions,
|
const data = await getCrmDashboardData(
|
||||||
branchScopeIds: membership.branchScopeIds ?? [],
|
securityContext,
|
||||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
|
||||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
|
||||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
|
||||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
dateFrom: searchParams.get('dateFrom'),
|
dateFrom: searchParams.get('dateFrom'),
|
||||||
dateTo: searchParams.get('dateTo'),
|
dateTo: searchParams.get('dateTo'),
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
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 { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -10,9 +18,33 @@ type Params = {
|
|||||||
export async function GET(_request: NextRequest, { params }: Params) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmDocumentArtifactDownload
|
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({
|
const { object } = await downloadArtifact({
|
||||||
artifactId: id,
|
artifactId: id,
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
|
import {
|
||||||
|
auditCrmSecurityEvent,
|
||||||
|
buildCrmSecurityContext,
|
||||||
|
canViewQuotationPricing
|
||||||
|
} from '@/features/crm/security/server/service';
|
||||||
import {
|
import {
|
||||||
generateApprovedQuotationPdf,
|
generateApprovedQuotationPdf,
|
||||||
getStoredApprovedQuotationPdf
|
getStoredApprovedQuotationPdf
|
||||||
@@ -15,9 +20,27 @@ type Params = {
|
|||||||
export async function GET(_request: NextRequest, { params }: Params) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmQuotationPdfDownload
|
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, {
|
const pdf = await getStoredApprovedQuotationPdf(id, organization.id, {
|
||||||
userId: session.user.id,
|
userId: session.user.id,
|
||||||
auditDownload: true
|
auditDownload: true
|
||||||
@@ -40,12 +63,30 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
export async function POST(_request: NextRequest, { params }: Params) {
|
export async function POST(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmQuotationPdfGenerateApproved
|
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 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({
|
await auditAction({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { buildQuotationDocumentData } from '@/features/crm/quotations/document/server/service';
|
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 { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -10,9 +15,27 @@ type Params = {
|
|||||||
export async function GET(_request: NextRequest, { params }: Params) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmQuotationDocumentPreview
|
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);
|
const documentData = await buildQuotationDocumentData(id, organization.id);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { getQuotationDocumentPreviewData } from '@/features/crm/quotations/document/server/service';
|
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 { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -10,9 +15,27 @@ type Params = {
|
|||||||
export async function GET(_request: NextRequest, { params }: Params) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmQuotationDocumentPreview
|
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);
|
const preview = await getQuotationDocumentPreviewData(id, organization.id);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { generateQuotationPdf } from '@/features/foundation/pdf-generator/server/service';
|
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 { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -10,9 +15,27 @@ type Params = {
|
|||||||
export async function GET(_request: NextRequest, { params }: Params) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmQuotationPdfDownload
|
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);
|
const pdf = await generateQuotationPdf(id, organization.id);
|
||||||
|
|
||||||
return new NextResponse(pdf.buffer, {
|
return new NextResponse(pdf.buffer, {
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { generateQuotationPreviewPdf } from '@/features/foundation/pdf-generator/server/service';
|
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 { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||||
|
|
||||||
@@ -10,9 +15,27 @@ type Params = {
|
|||||||
export async function GET(_request: NextRequest, { params }: Params) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmQuotationPdfPreview
|
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);
|
const pdf = await generateQuotationPreviewPdf(id, organization.id);
|
||||||
|
|
||||||
return new NextResponse(pdf.buffer, {
|
return new NextResponse(pdf.buffer, {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
||||||
|
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||||
import {
|
import {
|
||||||
getQuotationActivity,
|
getQuotationActivity,
|
||||||
getQuotationDetail,
|
getQuotationDetail,
|
||||||
@@ -18,11 +19,16 @@ type Params = {
|
|||||||
export async function GET(_request: NextRequest, { params }: Params) {
|
export async function GET(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmQuotationRead
|
permission: PERMISSIONS.crmQuotationRead
|
||||||
});
|
});
|
||||||
|
const accessContext = buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
const [quotation, activity] = await Promise.all([
|
const [quotation, activity] = await Promise.all([
|
||||||
getQuotationDetail(id, organization.id),
|
getQuotationDetail(id, organization.id, accessContext),
|
||||||
getQuotationActivity(id, organization.id)
|
getQuotationActivity(id, organization.id)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -49,12 +55,23 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
|||||||
export async function PATCH(request: NextRequest, { params }: Params) {
|
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmQuotationUpdate
|
permission: PERMISSIONS.crmQuotationUpdate
|
||||||
});
|
});
|
||||||
const payload = quotationSchema.parse(await request.json());
|
const payload = quotationSchema.parse(await request.json());
|
||||||
const before = await getQuotationDetail(id, organization.id);
|
const accessContext = buildCrmSecurityContext({
|
||||||
const updated = await updateQuotation(id, organization.id, session.user.id, payload);
|
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({
|
await auditUpdate({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
@@ -94,11 +111,16 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
|||||||
export async function DELETE(_request: NextRequest, { params }: Params) {
|
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmQuotationDelete
|
permission: PERMISSIONS.crmQuotationDelete
|
||||||
});
|
});
|
||||||
const before = await getQuotationDetail(id, organization.id);
|
const accessContext = buildCrmSecurityContext({
|
||||||
const updated = await softDeleteQuotation(id, organization.id, session.user.id);
|
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({
|
await auditDelete({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { auditCreate } from '@/features/foundation/audit-log/service';
|
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 { createQuotation, listQuotations } from '@/features/crm/quotations/server/service';
|
||||||
import { quotationSchema } from '@/features/crm/quotations/schemas/quotation.schema';
|
import { quotationSchema } from '@/features/crm/quotations/schemas/quotation.schema';
|
||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
@@ -8,7 +9,7 @@ import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
|||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { organization } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmQuotationRead
|
permission: PERMISSIONS.crmQuotationRead
|
||||||
});
|
});
|
||||||
const { searchParams } = request.nextUrl;
|
const { searchParams } = request.nextUrl;
|
||||||
@@ -22,6 +23,11 @@ export async function GET(request: NextRequest) {
|
|||||||
const enquiry = searchParams.get('enquiry') ?? undefined;
|
const enquiry = searchParams.get('enquiry') ?? undefined;
|
||||||
const hot = searchParams.get('hot') ?? undefined;
|
const hot = searchParams.get('hot') ?? undefined;
|
||||||
const sort = searchParams.get('sort') ?? undefined;
|
const sort = searchParams.get('sort') ?? undefined;
|
||||||
|
const accessContext = buildCrmSecurityContext({
|
||||||
|
organizationId: organization.id,
|
||||||
|
userId: session.user.id,
|
||||||
|
membership
|
||||||
|
});
|
||||||
const result = await listQuotations(organization.id, {
|
const result = await listQuotations(organization.id, {
|
||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
@@ -33,7 +39,7 @@ export async function GET(request: NextRequest) {
|
|||||||
enquiry,
|
enquiry,
|
||||||
hot,
|
hot,
|
||||||
sort
|
sort
|
||||||
});
|
}, accessContext);
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
@@ -60,11 +66,16 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const { organization, session } = await requireOrganizationAccess({
|
const { organization, session, membership } = await requireOrganizationAccess({
|
||||||
permission: PERMISSIONS.crmQuotationCreate
|
permission: PERMISSIONS.crmQuotationCreate
|
||||||
});
|
});
|
||||||
const payload = quotationSchema.parse(await request.json());
|
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({
|
await auditCreate({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { db } from '@/lib/db';
|
|||||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||||
import { getUserBranches } from '@/features/foundation/branch-scope/service';
|
import { getUserBranches } from '@/features/foundation/branch-scope/service';
|
||||||
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
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 { listApprovalRequests } from '@/features/foundation/approval/server/service';
|
||||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
import {
|
import {
|
||||||
@@ -113,9 +114,7 @@ function average(values: number[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canViewCommercialData(context: DashboardContext) {
|
function canViewCommercialData(context: DashboardContext) {
|
||||||
return (
|
return context.membershipRole === 'admin' || canViewQuotationPricing(context);
|
||||||
context.membershipRole === 'admin' || context.permissions.includes(PERMISSIONS.crmQuotationRead)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function canViewApprovalData(context: DashboardContext) {
|
function canViewApprovalData(context: DashboardContext) {
|
||||||
|
|||||||
@@ -16,8 +16,13 @@ import {
|
|||||||
} from '@/db/schema';
|
} from '@/db/schema';
|
||||||
import { db } from '@/lib/db';
|
import { db } from '@/lib/db';
|
||||||
import { AuthError } from '@/lib/auth/session';
|
import { AuthError } from '@/lib/auth/session';
|
||||||
|
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
||||||
import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
||||||
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/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 { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||||
import { syncEnquiryPipelineStageFromQuotationStatus } from '@/features/crm/enquiries/server/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']);
|
const REVISION_ALLOWED_STATUS_CODES = new Set(['accepted', 'rejected', 'sent', 'approved']);
|
||||||
|
|
||||||
|
export type QuotationAccessContext = CrmSecurityContext;
|
||||||
|
|
||||||
function mapOption(
|
function mapOption(
|
||||||
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
||||||
): QuotationOption {
|
): QuotationOption {
|
||||||
@@ -450,7 +457,11 @@ async function assertEnquiryBelongsToOrganization(id: string, organizationId: st
|
|||||||
return enquiry;
|
return enquiry;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function assertQuotationBelongsToOrganization(id: string, organizationId: string) {
|
async function assertQuotationBelongsToOrganization(
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
accessContext?: QuotationAccessContext
|
||||||
|
) {
|
||||||
const [quotation] = await db
|
const [quotation] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(crmQuotations)
|
.from(crmQuotations)
|
||||||
@@ -467,9 +478,45 @@ async function assertQuotationBelongsToOrganization(id: string, organizationId:
|
|||||||
throw new AuthError('Quotation not found', 404);
|
throw new AuthError('Quotation not found', 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accessContext && !canAccessQuotationRow(quotation, accessContext)) {
|
||||||
|
throw new AuthError('Forbidden', 403);
|
||||||
|
}
|
||||||
|
|
||||||
return quotation;
|
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(
|
async function assertQuotationItemBelongsToQuotation(
|
||||||
quotationId: string,
|
quotationId: string,
|
||||||
itemId: 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);
|
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
|
||||||
|
|
||||||
if (payload.contactId) {
|
if (payload.contactId) {
|
||||||
@@ -626,6 +677,10 @@ async function validateQuotationPayload(organizationId: string, payload: Quotati
|
|||||||
await validateBranchAccess(payload.branchId);
|
await validateBranchAccess(payload.branchId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (accessContext && !hasBranchScopeAccess(accessContext, payload.branchId)) {
|
||||||
|
throw new AuthError('Forbidden branch scope', 403);
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.salesmanId) {
|
if (payload.salesmanId) {
|
||||||
await assertSalesmanBelongsToOrganization(payload.salesmanId, organizationId);
|
await assertSalesmanBelongsToOrganization(payload.salesmanId, organizationId);
|
||||||
}
|
}
|
||||||
@@ -652,6 +707,10 @@ async function validateQuotationPayload(organizationId: string, payload: Quotati
|
|||||||
payload.sentVia ?? null
|
payload.sentVia ?? null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (accessContext && !hasProductScopeAccess(accessContext, payload.quotationType)) {
|
||||||
|
throw new AuthError('Forbidden product scope', 403);
|
||||||
|
}
|
||||||
|
|
||||||
for (const projectParty of payload.projectParties ?? []) {
|
for (const projectParty of payload.projectParties ?? []) {
|
||||||
await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
|
await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
|
||||||
await assertMasterOptionValue(
|
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 statuses = splitFilterValue(filters.status);
|
||||||
const quotationTypes = splitFilterValue(filters.quotationType);
|
const quotationTypes = splitFilterValue(filters.quotationType);
|
||||||
const branches = splitFilterValue(filters.branch);
|
const branches = splitFilterValue(filters.branch);
|
||||||
@@ -883,6 +946,7 @@ function buildQuotationFilters(organizationId: string, filters: QuotationFilters
|
|||||||
return [
|
return [
|
||||||
eq(crmQuotations.organizationId, organizationId),
|
eq(crmQuotations.organizationId, organizationId),
|
||||||
isNull(crmQuotations.deletedAt),
|
isNull(crmQuotations.deletedAt),
|
||||||
|
...(accessContext ? buildQuotationAccessScopedFilters(accessContext) : []),
|
||||||
...(statuses.length ? [inArray(crmQuotations.status, statuses)] : []),
|
...(statuses.length ? [inArray(crmQuotations.status, statuses)] : []),
|
||||||
...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []),
|
...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []),
|
||||||
...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []),
|
...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []),
|
||||||
@@ -1058,11 +1122,12 @@ export async function getQuotationReferenceData(
|
|||||||
|
|
||||||
export async function listQuotations(
|
export async function listQuotations(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
filters: QuotationFilters
|
filters: QuotationFilters,
|
||||||
|
accessContext?: QuotationAccessContext
|
||||||
): Promise<{ items: QuotationListItem[]; totalItems: number }> {
|
): Promise<{ items: QuotationListItem[]; totalItems: number }> {
|
||||||
const page = filters.page ?? 1;
|
const page = filters.page ?? 1;
|
||||||
const limit = filters.limit ?? 10;
|
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 where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||||
const offset = (page - 1) * limit;
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
@@ -1125,9 +1190,10 @@ export async function listQuotations(
|
|||||||
|
|
||||||
export async function getQuotationDetail(
|
export async function getQuotationDetail(
|
||||||
id: string,
|
id: string,
|
||||||
organizationId: string
|
organizationId: string,
|
||||||
|
accessContext?: QuotationAccessContext
|
||||||
): Promise<QuotationRecord> {
|
): Promise<QuotationRecord> {
|
||||||
const quotation = await assertQuotationBelongsToOrganization(id, organizationId);
|
const quotation = await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
|
||||||
const approvedArtifactId =
|
const approvedArtifactId =
|
||||||
quotation.approvedArtifactId ??
|
quotation.approvedArtifactId ??
|
||||||
(quotation.approvedPdfUrl?.startsWith('artifact:')
|
(quotation.approvedPdfUrl?.startsWith('artifact:')
|
||||||
@@ -1213,9 +1279,10 @@ export async function getQuotationActivity(
|
|||||||
export async function createQuotation(
|
export async function createQuotation(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
payload: QuotationMutationPayload
|
payload: QuotationMutationPayload,
|
||||||
|
accessContext?: QuotationAccessContext
|
||||||
) {
|
) {
|
||||||
await validateQuotationPayload(organizationId, payload);
|
await validateQuotationPayload(organizationId, payload, accessContext);
|
||||||
const quotationStatusCode = await resolveOptionCodeById(
|
const quotationStatusCode = await resolveOptionCodeById(
|
||||||
organizationId,
|
organizationId,
|
||||||
QUOTATION_OPTION_CATEGORIES.status,
|
QUOTATION_OPTION_CATEGORIES.status,
|
||||||
@@ -1299,15 +1366,16 @@ export async function updateQuotation(
|
|||||||
id: string,
|
id: string,
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
payload: QuotationMutationPayload
|
payload: QuotationMutationPayload,
|
||||||
|
accessContext?: QuotationAccessContext
|
||||||
) {
|
) {
|
||||||
await validateQuotationPayload(organizationId, payload);
|
await validateQuotationPayload(organizationId, payload, accessContext);
|
||||||
const quotationStatusCode = await resolveOptionCodeById(
|
const quotationStatusCode = await resolveOptionCodeById(
|
||||||
organizationId,
|
organizationId,
|
||||||
QUOTATION_OPTION_CATEGORIES.status,
|
QUOTATION_OPTION_CATEGORIES.status,
|
||||||
payload.status
|
payload.status
|
||||||
);
|
);
|
||||||
const existing = await assertQuotationBelongsToOrganization(id, organizationId);
|
const existing = await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
|
||||||
const enquiry = payload.enquiryId
|
const enquiry = payload.enquiryId
|
||||||
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
|
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
|
||||||
: null;
|
: null;
|
||||||
@@ -1370,8 +1438,13 @@ export async function updateQuotation(
|
|||||||
return updated;
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function softDeleteQuotation(id: string, organizationId: string, userId: string) {
|
export async function softDeleteQuotation(
|
||||||
await assertQuotationBelongsToOrganization(id, organizationId);
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
userId: string,
|
||||||
|
accessContext?: QuotationAccessContext
|
||||||
|
) {
|
||||||
|
await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
const [updated] = await db
|
const [updated] = await db
|
||||||
|
|||||||
111
src/features/crm/security/server/service.ts
Normal file
111
src/features/crm/security/server/service.ts
Normal file
@@ -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<CrmSecurityContext, 'permissions'>) {
|
||||||
|
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<string, unknown>;
|
||||||
|
}) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
users
|
users
|
||||||
} from '@/db/schema';
|
} from '@/db/schema';
|
||||||
import { db } from '@/lib/db';
|
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 { AuthError } from '@/lib/auth/session';
|
||||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/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);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -380,3 +380,16 @@ export function hasProductScopeAccess(access: ProductScopedAccess, productTypeId
|
|||||||
|
|
||||||
return access.productTypeScopeIds.includes(productTypeId);
|
return access.productTypeScopeIds.includes(productTypeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hasCrmPermission(
|
||||||
|
access: Pick<ResolvedCrmAccess, 'permissions'> | { permissions?: string[] | null },
|
||||||
|
permission: string
|
||||||
|
) {
|
||||||
|
return (access.permissions ?? []).includes(permission as Permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canViewQuotationPricing(
|
||||||
|
access: Pick<ResolvedCrmAccess, 'permissions'> | { permissions?: string[] | null }
|
||||||
|
) {
|
||||||
|
return hasCrmPermission(access, 'crm.quotation.pricing.read');
|
||||||
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export async function requireOrganizationAccess(options?: {
|
|||||||
organizationId: activeOrganization.id,
|
organizationId: activeOrganization.id,
|
||||||
role: activeOrganization.role,
|
role: activeOrganization.role,
|
||||||
businessRole: activeOrganization.businessRole,
|
businessRole: activeOrganization.businessRole,
|
||||||
|
businessRoles: [activeOrganization.businessRole],
|
||||||
permissions: session.user.activePermissions,
|
permissions: session.user.activePermissions,
|
||||||
branchScopeIds: session.user.activeBranchScopeIds,
|
branchScopeIds: session.user.activeBranchScopeIds,
|
||||||
productTypeScopeIds: session.user.activeProductTypeScopeIds,
|
productTypeScopeIds: session.user.activeProductTypeScopeIds,
|
||||||
@@ -128,6 +129,7 @@ export async function requireOrganizationAccess(options?: {
|
|||||||
session,
|
session,
|
||||||
membership: {
|
membership: {
|
||||||
...membership,
|
...membership,
|
||||||
|
businessRoles: resolvedAccess.businessRoles,
|
||||||
permissions: resolvedAccess.permissions,
|
permissions: resolvedAccess.permissions,
|
||||||
branchScopeIds: resolvedAccess.branchScopeIds,
|
branchScopeIds: resolvedAccess.branchScopeIds,
|
||||||
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
|
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
|
||||||
|
|||||||
Reference in New Issue
Block a user