This commit is contained in:
phaichayon
2026-06-22 13:36:00 +07:00
parent 827fd13fa7
commit 42f403a7ed
23 changed files with 1425 additions and 68 deletions

View 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`

View File

@@ -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

View 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.

View 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`

View File

@@ -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
View 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
```

View 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);
}

View File

@@ -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,
userId: session.user.id,
membership
});
if (section === 'revenue-analytics' && !canViewQuotationPricing(securityContext)) {
await auditCrmSecurityEvent({
organizationId: organization.id, organizationId: organization.id,
userId: session.user.id, userId: session.user.id,
membershipRole: membership.role, action: 'permission_violation',
businessRole: membership.businessRole, entityType: 'crm_dashboard_export',
permissions: membership.permissions, entityId: section,
branchScopeIds: membership.branchScopeIds ?? [], reason: 'Revenue export requires quotation pricing visibility',
productTypeScopeIds: membership.productTypeScopeIds ?? [], metadata: { format }
ownershipScope: membership.ownershipScope ?? 'organization', });
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all', return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all' }
},
const data = await getCrmDashboardData(
securityContext,
{ {
dateFrom: searchParams.get('dateFrom'), dateFrom: searchParams.get('dateFrom'),
dateTo: searchParams.get('dateTo'), dateTo: searchParams.get('dateTo'),

View File

@@ -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 securityContext = buildCrmSecurityContext({
organizationId: organization.id,
userId: session.user.id,
membership
});
const data = await getCrmDashboardData( const data = await getCrmDashboardData(
{ securityContext,
organizationId: organization.id,
userId: session.user.id,
membershipRole: membership.role,
businessRole: membership.businessRole,
permissions: membership.permissions,
branchScopeIds: membership.branchScopeIds ?? [],
productTypeScopeIds: membership.productTypeScopeIds ?? [],
ownershipScope: membership.ownershipScope ?? 'organization',
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
},
{ {
dateFrom: searchParams.get('dateFrom'), dateFrom: searchParams.get('dateFrom'),
dateTo: searchParams.get('dateTo'), dateTo: searchParams.get('dateTo'),

View File

@@ -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,

View File

@@ -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,

View File

@@ -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({

View File

@@ -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({

View File

@@ -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, {

View File

@@ -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, {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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

View 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
}
});
}

View File

@@ -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;
} }

View File

@@ -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');
}

View File

@@ -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,
@@ -126,11 +127,12 @@ export async function requireOrganizationAccess(options?: {
return { return {
session, session,
membership: { membership: {
...membership, ...membership,
permissions: resolvedAccess.permissions, businessRoles: resolvedAccess.businessRoles,
branchScopeIds: resolvedAccess.branchScopeIds, permissions: resolvedAccess.permissions,
productTypeScopeIds: resolvedAccess.productTypeScopeIds, branchScopeIds: resolvedAccess.branchScopeIds,
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
ownershipScope: resolvedAccess.ownershipScope, ownershipScope: resolvedAccess.ownershipScope,
branchScopeMode: resolvedAccess.branchScopeMode, branchScopeMode: resolvedAccess.branchScopeMode,
productScopeMode: resolvedAccess.productScopeMode, productScopeMode: resolvedAccess.productScopeMode,