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
|
||||
- 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",
|
||||
"verify:task-h1": "node scripts/verify-task-h1.js",
|
||||
"verify:task-h3": "node --experimental-strip-types scripts/verify-task-h3.js",
|
||||
"verify:crm-access": "node --experimental-strip-types scripts/security/verify-crm-access.ts",
|
||||
"audit:pdf:inventory": "node --experimental-strip-types scripts/audit-pdf-template-inventory.ts",
|
||||
"audit:pdf:coverage": "node --experimental-strip-types scripts/audit-pdf-mapping-coverage.ts",
|
||||
"audit:pdf:runtime": "node --experimental-strip-types scripts/audit-pdf-runtime-payload.ts",
|
||||
|
||||
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 { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { getCrmDashboardData } from '@/features/crm/dashboard/server/service';
|
||||
import {
|
||||
auditCrmSecurityEvent,
|
||||
buildCrmSecurityContext,
|
||||
canViewQuotationPricing
|
||||
} from '@/features/crm/security/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
@@ -34,19 +39,27 @@ export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const section = searchParams.get('section') ?? 'sales-ranking';
|
||||
const format = searchParams.get('format') ?? 'csv';
|
||||
const data = await getCrmDashboardData(
|
||||
{
|
||||
const securityContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
|
||||
if (section === 'revenue-analytics' && !canViewQuotationPricing(securityContext)) {
|
||||
await auditCrmSecurityEvent({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole,
|
||||
permissions: membership.permissions,
|
||||
branchScopeIds: membership.branchScopeIds ?? [],
|
||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||
},
|
||||
action: 'permission_violation',
|
||||
entityType: 'crm_dashboard_export',
|
||||
entityId: section,
|
||||
reason: 'Revenue export requires quotation pricing visibility',
|
||||
metadata: { format }
|
||||
});
|
||||
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const data = await getCrmDashboardData(
|
||||
securityContext,
|
||||
{
|
||||
dateFrom: searchParams.get('dateFrom'),
|
||||
dateTo: searchParams.get('dateTo'),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getCrmDashboardData } from '@/features/crm/dashboard/server/service';
|
||||
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
@@ -9,19 +10,13 @@ export async function GET(request: NextRequest) {
|
||||
permission: PERMISSIONS.crmDashboardRead
|
||||
});
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const securityContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const data = await getCrmDashboardData(
|
||||
{
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membershipRole: membership.role,
|
||||
businessRole: membership.businessRole,
|
||||
permissions: membership.permissions,
|
||||
branchScopeIds: membership.branchScopeIds ?? [],
|
||||
productTypeScopeIds: membership.productTypeScopeIds ?? [],
|
||||
ownershipScope: membership.ownershipScope ?? 'organization',
|
||||
branchScopeMode: membership.branchScopeMode === 'assigned' ? 'assigned' : 'all',
|
||||
productScopeMode: membership.productScopeMode === 'assigned' ? 'assigned' : 'all'
|
||||
},
|
||||
securityContext,
|
||||
{
|
||||
dateFrom: searchParams.get('dateFrom'),
|
||||
dateTo: searchParams.get('dateTo'),
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { downloadArtifact } from '@/features/foundation/document-artifact/server/service';
|
||||
import {
|
||||
downloadArtifact,
|
||||
getArtifact
|
||||
} from '@/features/foundation/document-artifact/server/service';
|
||||
import {
|
||||
auditCrmSecurityEvent,
|
||||
buildCrmSecurityContext,
|
||||
canViewQuotationPricing
|
||||
} from '@/features/crm/security/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
@@ -10,9 +18,33 @@ type Params = {
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmDocumentArtifactDownload
|
||||
});
|
||||
const securityContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const artifact = await getArtifact(id, organization.id);
|
||||
|
||||
if (
|
||||
artifact.entityType === 'quotation' &&
|
||||
artifact.artifactType === 'approved_pdf' &&
|
||||
!canViewQuotationPricing(securityContext)
|
||||
) {
|
||||
await auditCrmSecurityEvent({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
action: 'pricing_hidden',
|
||||
entityType: artifact.entityType,
|
||||
entityId: artifact.entityId,
|
||||
reason: 'Approved quotation artifact download requires pricing visibility',
|
||||
metadata: { artifactId: artifact.id }
|
||||
});
|
||||
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const { object } = await downloadArtifact({
|
||||
artifactId: id,
|
||||
organizationId: organization.id,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import {
|
||||
auditCrmSecurityEvent,
|
||||
buildCrmSecurityContext,
|
||||
canViewQuotationPricing
|
||||
} from '@/features/crm/security/server/service';
|
||||
import {
|
||||
generateApprovedQuotationPdf,
|
||||
getStoredApprovedQuotationPdf
|
||||
@@ -15,9 +20,27 @@ type Params = {
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmQuotationPdfDownload
|
||||
});
|
||||
const securityContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
|
||||
if (!canViewQuotationPricing(securityContext)) {
|
||||
await auditCrmSecurityEvent({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
action: 'pricing_hidden',
|
||||
entityType: 'crm_quotation',
|
||||
entityId: id,
|
||||
reason: 'Approved quotation PDF download requires pricing visibility'
|
||||
});
|
||||
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const pdf = await getStoredApprovedQuotationPdf(id, organization.id, {
|
||||
userId: session.user.id,
|
||||
auditDownload: true
|
||||
@@ -40,12 +63,30 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
export async function POST(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmQuotationPdfGenerateApproved
|
||||
});
|
||||
const before = await getQuotationDetail(id, organization.id);
|
||||
const securityContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
|
||||
if (!canViewQuotationPricing(securityContext)) {
|
||||
await auditCrmSecurityEvent({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
action: 'pricing_hidden',
|
||||
entityType: 'crm_quotation',
|
||||
entityId: id,
|
||||
reason: 'Approved quotation PDF generation requires pricing visibility'
|
||||
});
|
||||
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const before = await getQuotationDetail(id, organization.id, securityContext);
|
||||
const pdf = await generateApprovedQuotationPdf(id, organization.id, session.user.id);
|
||||
const after = await getQuotationDetail(id, organization.id);
|
||||
const after = await getQuotationDetail(id, organization.id, securityContext);
|
||||
|
||||
await auditAction({
|
||||
organizationId: organization.id,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { buildQuotationDocumentData } from '@/features/crm/quotations/document/server/service';
|
||||
import {
|
||||
auditCrmSecurityEvent,
|
||||
buildCrmSecurityContext,
|
||||
canViewQuotationPricing
|
||||
} from '@/features/crm/security/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
@@ -10,9 +15,27 @@ type Params = {
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmQuotationDocumentPreview
|
||||
});
|
||||
const securityContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
|
||||
if (!canViewQuotationPricing(securityContext)) {
|
||||
await auditCrmSecurityEvent({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
action: 'pricing_hidden',
|
||||
entityType: 'crm_quotation',
|
||||
entityId: id,
|
||||
reason: 'Quotation document data requires pricing visibility'
|
||||
});
|
||||
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const documentData = await buildQuotationDocumentData(id, organization.id);
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getQuotationDocumentPreviewData } from '@/features/crm/quotations/document/server/service';
|
||||
import {
|
||||
auditCrmSecurityEvent,
|
||||
buildCrmSecurityContext,
|
||||
canViewQuotationPricing
|
||||
} from '@/features/crm/security/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
@@ -10,9 +15,27 @@ type Params = {
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmQuotationDocumentPreview
|
||||
});
|
||||
const securityContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
|
||||
if (!canViewQuotationPricing(securityContext)) {
|
||||
await auditCrmSecurityEvent({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
action: 'pricing_hidden',
|
||||
entityType: 'crm_quotation',
|
||||
entityId: id,
|
||||
reason: 'Quotation document preview requires pricing visibility'
|
||||
});
|
||||
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const preview = await getQuotationDocumentPreviewData(id, organization.id);
|
||||
|
||||
return NextResponse.json({
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { generateQuotationPdf } from '@/features/foundation/pdf-generator/server/service';
|
||||
import {
|
||||
auditCrmSecurityEvent,
|
||||
buildCrmSecurityContext,
|
||||
canViewQuotationPricing
|
||||
} from '@/features/crm/security/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
@@ -10,9 +15,27 @@ type Params = {
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmQuotationPdfDownload
|
||||
});
|
||||
const securityContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
|
||||
if (!canViewQuotationPricing(securityContext)) {
|
||||
await auditCrmSecurityEvent({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
action: 'pricing_hidden',
|
||||
entityType: 'crm_quotation',
|
||||
entityId: id,
|
||||
reason: 'Quotation PDF download requires pricing visibility'
|
||||
});
|
||||
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const pdf = await generateQuotationPdf(id, organization.id);
|
||||
|
||||
return new NextResponse(pdf.buffer, {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { generateQuotationPreviewPdf } from '@/features/foundation/pdf-generator/server/service';
|
||||
import {
|
||||
auditCrmSecurityEvent,
|
||||
buildCrmSecurityContext,
|
||||
canViewQuotationPricing
|
||||
} from '@/features/crm/security/server/service';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
@@ -10,9 +15,27 @@ type Params = {
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmQuotationPdfPreview
|
||||
});
|
||||
const securityContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
|
||||
if (!canViewQuotationPricing(securityContext)) {
|
||||
await auditCrmSecurityEvent({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
action: 'pricing_hidden',
|
||||
entityType: 'crm_quotation',
|
||||
entityId: id,
|
||||
reason: 'Quotation PDF preview requires pricing visibility'
|
||||
});
|
||||
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const pdf = await generateQuotationPreviewPdf(id, organization.id);
|
||||
|
||||
return new NextResponse(pdf.buffer, {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { auditDelete, auditUpdate } from '@/features/foundation/audit-log/service';
|
||||
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||
import {
|
||||
getQuotationActivity,
|
||||
getQuotationDetail,
|
||||
@@ -18,11 +19,16 @@ type Params = {
|
||||
export async function GET(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmQuotationRead
|
||||
});
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const [quotation, activity] = await Promise.all([
|
||||
getQuotationDetail(id, organization.id),
|
||||
getQuotationDetail(id, organization.id, accessContext),
|
||||
getQuotationActivity(id, organization.id)
|
||||
]);
|
||||
|
||||
@@ -49,12 +55,23 @@ export async function GET(_request: NextRequest, { params }: Params) {
|
||||
export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmQuotationUpdate
|
||||
});
|
||||
const payload = quotationSchema.parse(await request.json());
|
||||
const before = await getQuotationDetail(id, organization.id);
|
||||
const updated = await updateQuotation(id, organization.id, session.user.id, payload);
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const before = await getQuotationDetail(id, organization.id, accessContext);
|
||||
const updated = await updateQuotation(
|
||||
id,
|
||||
organization.id,
|
||||
session.user.id,
|
||||
payload,
|
||||
accessContext
|
||||
);
|
||||
|
||||
await auditUpdate({
|
||||
organizationId: organization.id,
|
||||
@@ -94,11 +111,16 @@ export async function PATCH(request: NextRequest, { params }: Params) {
|
||||
export async function DELETE(_request: NextRequest, { params }: Params) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmQuotationDelete
|
||||
});
|
||||
const before = await getQuotationDetail(id, organization.id);
|
||||
const updated = await softDeleteQuotation(id, organization.id, session.user.id);
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const before = await getQuotationDetail(id, organization.id, accessContext);
|
||||
const updated = await softDeleteQuotation(id, organization.id, session.user.id, accessContext);
|
||||
|
||||
await auditDelete({
|
||||
organizationId: organization.id,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { auditCreate } from '@/features/foundation/audit-log/service';
|
||||
import { buildCrmSecurityContext } from '@/features/crm/security/server/service';
|
||||
import { createQuotation, listQuotations } from '@/features/crm/quotations/server/service';
|
||||
import { quotationSchema } from '@/features/crm/quotations/schemas/quotation.schema';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
@@ -8,7 +9,7 @@ import { AuthError, requireOrganizationAccess } from '@/lib/auth/session';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { organization } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmQuotationRead
|
||||
});
|
||||
const { searchParams } = request.nextUrl;
|
||||
@@ -22,6 +23,11 @@ export async function GET(request: NextRequest) {
|
||||
const enquiry = searchParams.get('enquiry') ?? undefined;
|
||||
const hot = searchParams.get('hot') ?? undefined;
|
||||
const sort = searchParams.get('sort') ?? undefined;
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const result = await listQuotations(organization.id, {
|
||||
page,
|
||||
limit,
|
||||
@@ -33,7 +39,7 @@ export async function GET(request: NextRequest) {
|
||||
enquiry,
|
||||
hot,
|
||||
sort
|
||||
});
|
||||
}, accessContext);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
return NextResponse.json({
|
||||
@@ -60,11 +66,16 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { organization, session } = await requireOrganizationAccess({
|
||||
const { organization, session, membership } = await requireOrganizationAccess({
|
||||
permission: PERMISSIONS.crmQuotationCreate
|
||||
});
|
||||
const payload = quotationSchema.parse(await request.json());
|
||||
const created = await createQuotation(organization.id, session.user.id, payload);
|
||||
const accessContext = buildCrmSecurityContext({
|
||||
organizationId: organization.id,
|
||||
userId: session.user.id,
|
||||
membership
|
||||
});
|
||||
const created = await createQuotation(organization.id, session.user.id, payload, accessContext);
|
||||
|
||||
await auditCreate({
|
||||
organizationId: organization.id,
|
||||
|
||||
@@ -23,6 +23,7 @@ import { db } from '@/lib/db';
|
||||
import { PERMISSIONS } from '@/lib/auth/rbac';
|
||||
import { getUserBranches } from '@/features/foundation/branch-scope/service';
|
||||
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
||||
import { canViewQuotationPricing } from '@/features/crm/security/server/service';
|
||||
import { listApprovalRequests } from '@/features/foundation/approval/server/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import {
|
||||
@@ -113,9 +114,7 @@ function average(values: number[]) {
|
||||
}
|
||||
|
||||
function canViewCommercialData(context: DashboardContext) {
|
||||
return (
|
||||
context.membershipRole === 'admin' || context.permissions.includes(PERMISSIONS.crmQuotationRead)
|
||||
);
|
||||
return context.membershipRole === 'admin' || canViewQuotationPricing(context);
|
||||
}
|
||||
|
||||
function canViewApprovalData(context: DashboardContext) {
|
||||
|
||||
@@ -16,8 +16,13 @@ import {
|
||||
} from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { hasBranchScopeAccess, hasProductScopeAccess } from '@/lib/auth/crm-access';
|
||||
import { listAuditLogs } from '@/features/foundation/audit-log/service';
|
||||
import { getUserBranches, validateBranchAccess } from '@/features/foundation/branch-scope/service';
|
||||
import {
|
||||
type CrmSecurityContext,
|
||||
canAccessScopedCrmRecord
|
||||
} from '@/features/crm/security/server/service';
|
||||
import { generateNextDocumentCode } from '@/features/foundation/document-sequence/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
import { syncEnquiryPipelineStageFromQuotationStatus } from '@/features/crm/enquiries/server/service';
|
||||
@@ -70,6 +75,8 @@ const QUOTATION_OPTION_CATEGORIES = {
|
||||
|
||||
const REVISION_ALLOWED_STATUS_CODES = new Set(['accepted', 'rejected', 'sent', 'approved']);
|
||||
|
||||
export type QuotationAccessContext = CrmSecurityContext;
|
||||
|
||||
function mapOption(
|
||||
option: Awaited<ReturnType<typeof getActiveOptionsByCategory>>[number]
|
||||
): QuotationOption {
|
||||
@@ -450,7 +457,11 @@ async function assertEnquiryBelongsToOrganization(id: string, organizationId: st
|
||||
return enquiry;
|
||||
}
|
||||
|
||||
async function assertQuotationBelongsToOrganization(id: string, organizationId: string) {
|
||||
async function assertQuotationBelongsToOrganization(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
accessContext?: QuotationAccessContext
|
||||
) {
|
||||
const [quotation] = await db
|
||||
.select()
|
||||
.from(crmQuotations)
|
||||
@@ -467,9 +478,45 @@ async function assertQuotationBelongsToOrganization(id: string, organizationId:
|
||||
throw new AuthError('Quotation not found', 404);
|
||||
}
|
||||
|
||||
if (accessContext && !canAccessQuotationRow(quotation, accessContext)) {
|
||||
throw new AuthError('Forbidden', 403);
|
||||
}
|
||||
|
||||
return quotation;
|
||||
}
|
||||
|
||||
function canAccessQuotationRow(
|
||||
row: typeof crmQuotations.$inferSelect,
|
||||
accessContext: QuotationAccessContext
|
||||
) {
|
||||
return canAccessScopedCrmRecord(accessContext, {
|
||||
branchId: row.branchId,
|
||||
productType: row.quotationType,
|
||||
createdBy: row.createdBy,
|
||||
ownerUserId: row.salesmanId
|
||||
});
|
||||
}
|
||||
|
||||
function buildQuotationAccessScopedFilters(accessContext: QuotationAccessContext): SQL[] {
|
||||
const filters: SQL[] = [];
|
||||
|
||||
if (accessContext.branchScopeMode === 'assigned' && accessContext.branchScopeIds.length > 0) {
|
||||
filters.push(inArray(crmQuotations.branchId, accessContext.branchScopeIds));
|
||||
}
|
||||
|
||||
if (accessContext.productScopeMode === 'assigned' && accessContext.productTypeScopeIds.length > 0) {
|
||||
filters.push(inArray(crmQuotations.quotationType, accessContext.productTypeScopeIds));
|
||||
}
|
||||
|
||||
if (accessContext.membershipRole !== 'admin' && accessContext.ownershipScope === 'own') {
|
||||
filters.push(
|
||||
or(eq(crmQuotations.salesmanId, accessContext.userId), eq(crmQuotations.createdBy, accessContext.userId))!
|
||||
);
|
||||
}
|
||||
|
||||
return filters;
|
||||
}
|
||||
|
||||
async function assertQuotationItemBelongsToQuotation(
|
||||
quotationId: string,
|
||||
itemId: string,
|
||||
@@ -607,7 +654,11 @@ async function assertSalesmanBelongsToOrganization(userId: string, organizationI
|
||||
}
|
||||
}
|
||||
|
||||
async function validateQuotationPayload(organizationId: string, payload: QuotationMutationPayload) {
|
||||
async function validateQuotationPayload(
|
||||
organizationId: string,
|
||||
payload: QuotationMutationPayload,
|
||||
accessContext?: QuotationAccessContext
|
||||
) {
|
||||
await assertCustomerBelongsToOrganization(payload.customerId, organizationId);
|
||||
|
||||
if (payload.contactId) {
|
||||
@@ -626,6 +677,10 @@ async function validateQuotationPayload(organizationId: string, payload: Quotati
|
||||
await validateBranchAccess(payload.branchId);
|
||||
}
|
||||
|
||||
if (accessContext && !hasBranchScopeAccess(accessContext, payload.branchId)) {
|
||||
throw new AuthError('Forbidden branch scope', 403);
|
||||
}
|
||||
|
||||
if (payload.salesmanId) {
|
||||
await assertSalesmanBelongsToOrganization(payload.salesmanId, organizationId);
|
||||
}
|
||||
@@ -652,6 +707,10 @@ async function validateQuotationPayload(organizationId: string, payload: Quotati
|
||||
payload.sentVia ?? null
|
||||
);
|
||||
|
||||
if (accessContext && !hasProductScopeAccess(accessContext, payload.quotationType)) {
|
||||
throw new AuthError('Forbidden product scope', 403);
|
||||
}
|
||||
|
||||
for (const projectParty of payload.projectParties ?? []) {
|
||||
await assertCustomerBelongsToOrganization(projectParty.customerId, organizationId);
|
||||
await assertMasterOptionValue(
|
||||
@@ -873,7 +932,11 @@ async function validateQuotationFollowupPayload(
|
||||
}
|
||||
}
|
||||
|
||||
function buildQuotationFilters(organizationId: string, filters: QuotationFilters): SQL[] {
|
||||
function buildQuotationFilters(
|
||||
organizationId: string,
|
||||
filters: QuotationFilters,
|
||||
accessContext?: QuotationAccessContext
|
||||
): SQL[] {
|
||||
const statuses = splitFilterValue(filters.status);
|
||||
const quotationTypes = splitFilterValue(filters.quotationType);
|
||||
const branches = splitFilterValue(filters.branch);
|
||||
@@ -883,6 +946,7 @@ function buildQuotationFilters(organizationId: string, filters: QuotationFilters
|
||||
return [
|
||||
eq(crmQuotations.organizationId, organizationId),
|
||||
isNull(crmQuotations.deletedAt),
|
||||
...(accessContext ? buildQuotationAccessScopedFilters(accessContext) : []),
|
||||
...(statuses.length ? [inArray(crmQuotations.status, statuses)] : []),
|
||||
...(quotationTypes.length ? [inArray(crmQuotations.quotationType, quotationTypes)] : []),
|
||||
...(branches.length ? [inArray(crmQuotations.branchId, branches)] : []),
|
||||
@@ -1058,11 +1122,12 @@ export async function getQuotationReferenceData(
|
||||
|
||||
export async function listQuotations(
|
||||
organizationId: string,
|
||||
filters: QuotationFilters
|
||||
filters: QuotationFilters,
|
||||
accessContext?: QuotationAccessContext
|
||||
): Promise<{ items: QuotationListItem[]; totalItems: number }> {
|
||||
const page = filters.page ?? 1;
|
||||
const limit = filters.limit ?? 10;
|
||||
const whereFilters = buildQuotationFilters(organizationId, filters);
|
||||
const whereFilters = buildQuotationFilters(organizationId, filters, accessContext);
|
||||
const where = whereFilters.length === 1 ? whereFilters[0] : and(...whereFilters);
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
@@ -1125,9 +1190,10 @@ export async function listQuotations(
|
||||
|
||||
export async function getQuotationDetail(
|
||||
id: string,
|
||||
organizationId: string
|
||||
organizationId: string,
|
||||
accessContext?: QuotationAccessContext
|
||||
): Promise<QuotationRecord> {
|
||||
const quotation = await assertQuotationBelongsToOrganization(id, organizationId);
|
||||
const quotation = await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
|
||||
const approvedArtifactId =
|
||||
quotation.approvedArtifactId ??
|
||||
(quotation.approvedPdfUrl?.startsWith('artifact:')
|
||||
@@ -1213,9 +1279,10 @@ export async function getQuotationActivity(
|
||||
export async function createQuotation(
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: QuotationMutationPayload
|
||||
payload: QuotationMutationPayload,
|
||||
accessContext?: QuotationAccessContext
|
||||
) {
|
||||
await validateQuotationPayload(organizationId, payload);
|
||||
await validateQuotationPayload(organizationId, payload, accessContext);
|
||||
const quotationStatusCode = await resolveOptionCodeById(
|
||||
organizationId,
|
||||
QUOTATION_OPTION_CATEGORIES.status,
|
||||
@@ -1299,15 +1366,16 @@ export async function updateQuotation(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
payload: QuotationMutationPayload
|
||||
payload: QuotationMutationPayload,
|
||||
accessContext?: QuotationAccessContext
|
||||
) {
|
||||
await validateQuotationPayload(organizationId, payload);
|
||||
await validateQuotationPayload(organizationId, payload, accessContext);
|
||||
const quotationStatusCode = await resolveOptionCodeById(
|
||||
organizationId,
|
||||
QUOTATION_OPTION_CATEGORIES.status,
|
||||
payload.status
|
||||
);
|
||||
const existing = await assertQuotationBelongsToOrganization(id, organizationId);
|
||||
const existing = await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
|
||||
const enquiry = payload.enquiryId
|
||||
? await assertEnquiryBelongsToOrganization(payload.enquiryId, organizationId)
|
||||
: null;
|
||||
@@ -1370,8 +1438,13 @@ export async function updateQuotation(
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function softDeleteQuotation(id: string, organizationId: string, userId: string) {
|
||||
await assertQuotationBelongsToOrganization(id, organizationId);
|
||||
export async function softDeleteQuotation(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
userId: string,
|
||||
accessContext?: QuotationAccessContext
|
||||
) {
|
||||
await assertQuotationBelongsToOrganization(id, organizationId, accessContext);
|
||||
const now = new Date();
|
||||
|
||||
const [updated] = await db
|
||||
|
||||
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
|
||||
} from '@/db/schema';
|
||||
import { db } from '@/lib/db';
|
||||
import { resolveCrmMembershipAccess } from '@/lib/auth/crm-access';
|
||||
import { type SystemRole } from '@/lib/auth/rbac';
|
||||
import { AuthError } from '@/lib/auth/session';
|
||||
import { auditAction } from '@/features/foundation/audit-log/service';
|
||||
import { getActiveOptionsByCategory } from '@/features/foundation/master-options/service';
|
||||
@@ -611,7 +613,17 @@ async function assertActorCanHandleStep(organizationId: string, userId: string,
|
||||
throw new AuthError('Organization membership required', 403);
|
||||
}
|
||||
|
||||
if (membership.role === 'admin' || membership.businessRole === roleCode) {
|
||||
const resolvedAccess = await resolveCrmMembershipAccess({
|
||||
systemRole: userRow.systemRole as SystemRole,
|
||||
organizationId,
|
||||
membership
|
||||
});
|
||||
|
||||
if (
|
||||
membership.role === 'admin' ||
|
||||
resolvedAccess.businessRoles.includes(roleCode) ||
|
||||
resolvedAccess.businessRole === roleCode
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -380,3 +380,16 @@ export function hasProductScopeAccess(access: ProductScopedAccess, 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,
|
||||
role: activeOrganization.role,
|
||||
businessRole: activeOrganization.businessRole,
|
||||
businessRoles: [activeOrganization.businessRole],
|
||||
permissions: session.user.activePermissions,
|
||||
branchScopeIds: session.user.activeBranchScopeIds,
|
||||
productTypeScopeIds: session.user.activeProductTypeScopeIds,
|
||||
@@ -126,11 +127,12 @@ export async function requireOrganizationAccess(options?: {
|
||||
|
||||
return {
|
||||
session,
|
||||
membership: {
|
||||
...membership,
|
||||
permissions: resolvedAccess.permissions,
|
||||
branchScopeIds: resolvedAccess.branchScopeIds,
|
||||
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
|
||||
membership: {
|
||||
...membership,
|
||||
businessRoles: resolvedAccess.businessRoles,
|
||||
permissions: resolvedAccess.permissions,
|
||||
branchScopeIds: resolvedAccess.branchScopeIds,
|
||||
productTypeScopeIds: resolvedAccess.productTypeScopeIds,
|
||||
ownershipScope: resolvedAccess.ownershipScope,
|
||||
branchScopeMode: resolvedAccess.branchScopeMode,
|
||||
productScopeMode: resolvedAccess.productScopeMode,
|
||||
|
||||
Reference in New Issue
Block a user