Multi-Level Validation
Overview
Every write operation passes through 5 validation layers before execution. This ensures data integrity, security, and business rule compliance.
Validation Layers:
- Token Validation - JWT signature, expiration, issuer
- Grant Validation - User permissions via TB_MENU
- Required Field Validation - TB_COST.REQUIRED='1'
- COD_ON_OFF Validation - Field visibility flags
- Business Logic Validation - Custom rules (optional)
Failure at any layer rejects the entire request.
Validation Flow
Request Processing
POST /api/v4/core/PRD
↓
1. Token Validation
└─ Valid? → Continue
└─ Invalid? → 401 Unauthorized
↓
2. Grant Validation
└─ Has permission? → Continue
└─ No permission? → 403 Forbidden
↓
3. Required Field Validation
└─ All required fields present? → Continue
└─ Missing field? → 400 ValidationError
↓
4. COD_ON_OFF Validation
└─ Fields allowed for operation? → Continue
└─ Field not allowed? → 400 ValidationError
↓
5. Business Logic Validation (optional)
└─ Custom rules pass? → Continue
└─ Rule violation? → 400 BusinessRuleError
↓
Execute Write Operation
Validation Middleware Architecture
Symfony Event Dispatcher Chain:
HTTP Request
↓
CoreService (API Gateway) - Routing
↓
CoreWrite (Symfony) - Event Dispatcher
↓
RequestValidatorSubscriber::onKernelRequest
├─ TokenValidatorMiddleware
│ └─ JWT decode, signature verification, expiration check
│ └─ Success → Continue
│ └─ Failure → 401 Unauthorized
├─ GrantValidatorMiddleware
│ └─ TB_MENU nested set query, permission check
│ └─ Success → Continue
│ └─ Failure → 403 Forbidden
├─ RequiredFieldValidatorMiddleware
│ └─ TB_COST.REQUIRED='1' check
│ └─ Success → Continue
│ └─ Failure → 400 ValidationError
├─ COD_ON_OFF_ValidatorMiddleware
│ └─ TB_COST.COD_ON_OFF flag check (N/M based on operation)
│ └─ Success → Continue
│ └─ Failure → 400 ValidationError
└─ BusinessLogicValidatorMiddleware (optional)
└─ Custom rules, foreign key checks, AC/VARS validation
└─ Success → Controller
└─ Failure → 400 BusinessRuleError
↓
Controller → DataStore → Database
Implementation:
// src/EventDispatcher/Subscriber/RequestValidatorSubscriber.php
class RequestValidatorSubscriber implements EventSubscriberInterface
{
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => [
['validateToken', 100], // Priority 100 - first
['validateGrant', 90], // Priority 90 - second
['validateRequiredFields', 80], // Priority 80 - third
['validateCOD_ON_OFF', 70], // Priority 70 - fourth
['validateBusinessLogic', 60] // Priority 60 - fifth
],
];
}
}
Key Points:
- ✅ Middleware runs in priority order (100 → 60)
- ✅ Failure at any middleware stops the chain
- ✅ RequestValidatorSubscriber orchestrates all validation
- ✅ Each middleware is single-purpose (SOLID principles)
Layer 1: Token Validation
JWT Validation
Validates Authorization header:
POST /api/v4/core/PRD
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
Checks:
- Signature valid (cryptographic verification)
- Not expired (
expclaim) - Correct issuer (
issclaim) - Valid format (3-part JWT)
Success:
{
"user": "user@example.com",
"tenant": "company_abc",
"roles": ["admin", "product_manager"],
"exp": 1703012345
}
Failure:
{
"error": "UnauthorizedError",
"message": "Invalid or expired token",
"code": "TOKEN_INVALID",
"status": 401
}
Common Token Errors
// Expired token
{
"code": "TOKEN_EXPIRED",
"message": "Token has expired",
"status": 401
}
// Invalid signature
{
"code": "TOKEN_INVALID_SIGNATURE",
"message": "Token signature verification failed",
"status": 401
}
// Missing token
{
"code": "TOKEN_MISSING",
"message": "Authorization header missing",
"status": 401
}
Layer 2: Grant Validation
Permission Check via TB_MENU
TB_MENU nested set determines user permissions:
-- User grants stored in TB_MENU hierarchy
SELECT *
FROM TB_MENU
WHERE COD_MENU IN (user_grants)
AND NLEFT <= (SELECT NLEFT FROM TB_MENU WHERE COD_MENU = target_area)
AND NRIGHT >= (SELECT NRIGHT FROM TB_MENU WHERE COD_MENU = target_area);
Grant Hierarchy:
admin (NLEFT=1, NRIGHT=100)
├── products (NLEFT=2, NRIGHT=50)
│ ├── products.read (NLEFT=3, NRIGHT=20)
│ └── products.write (NLEFT=21, NRIGHT=49)
│ ├── products.create (NLEFT=22, NRIGHT=30)
│ ├── products.update (NLEFT=31, NRIGHT=39)
│ └── products.delete (NLEFT=40, NRIGHT=48)
└── orders (NLEFT=51, NRIGHT=99)
User with products.write grant can:
- Create products (POST)
- Update products (PUT/PATCH)
- Delete products (DELETE)
User with only products.read grant cannot:
- Create/update/delete products
- Receives 403 Forbidden
Failure:
{
"error": "ForbiddenError",
"message": "Insufficient permissions for this operation",
"code": "GRANT_DENIED",
"required_grant": "products.write",
"user_grants": ["products.read"],
"status": 403
}
Layer 3: Required Field Validation
TB_COST.REQUIRED='1'
Metadata marks mandatory fields:
INSERT INTO TB_COST (COD_DIM, NUM_COST, COD_VAR, REQUIRED, DESCRIZIONE_COST) VALUES
('PRD', 1, 'XPRD01', 1, 'Product name'), -- Required
('PRD', 2, 'XPRD02', 1, 'Price'), -- Required
('PRD', 4, 'XPRD04', 0, 'Description'), -- Optional
('PRD', 5, 'XPRD05', 1, 'Category'); -- Required
Validation Logic:
// CoreWrite RequestValidatorSubscriber.php
foreach ($costs as $cost) {
if ($cost['REQUIRED'] == 1 && !isset($data[$cost['COD_VAR']])) {
throw new ValidationException(
"Required field missing: {$cost['COD_VAR']}",
'REQUIRED_FIELD_MISSING',
$cost['COD_VAR']
);
}
}
Request Missing Required Field:
Request:
POST /api/v4/core/PRD
{
"data": {
"XPRD02": 99.99,
"XPRD04": "Description"
// Missing XPRD01 (required)
}
}
Response:
{
"error": "ValidationError",
"message": "Required field missing: XPRD01",
"code": "REQUIRED_FIELD_MISSING",
"field": "XPRD01",
"fieldDescription": "Product name",
"status": 400
}
Layer 4: COD_ON_OFF Validation
Field Visibility Flags
TB_COST.COD_ON_OFF controls field usage:
INSERT INTO TB_COST (COD_DIM, NUM_COST, COD_VAR, COD_ON_OFF) VALUES
('PRD', 1, 'XPRD01', 'LDNMR'), -- Allowed: List, Detail, New, Modify, Ricerca
('PRD', 3, 'XPRD03', 'LDR'), -- Allowed: List, Detail, Ricerca (read-only counter)
('PRD', 12, 'XPRD12', 'LDM'); -- Allowed: List, Detail, Modify (not on creation)
Operation Checks:
| Operation | Flag Required | Example |
|---|---|---|
| POST | N (New) | Field can be set on creation |
| PUT/PATCH | M (Modify) | Field can be updated |
| GET | L/D/R | Field visible in queries |
Validation Example (POST):
Request:
POST /api/v4/core/PRD
{
"data": {
"XPRD01": "Product", // ✅ COD_ON_OFF='LDNMR' (N flag present)
"XPRD03": "PRD-2025-001", // ❌ COD_ON_OFF='LDR' (N flag missing)
"XPRD12": "image.jpg" // ❌ COD_ON_OFF='LDM' (N flag missing)
}
}
Response:
{
"error": "ValidationError",
"message": "Field not allowed in create operation: XPRD03",
"code": "FIELD_NOT_CREATEABLE",
"field": "XPRD03",
"cod_on_off": "LDR",
"required_flag": "N",
"status": 400
}
Validation Example (PUT/PATCH):
Request:
PATCH /api/v4/core/PRD/123
{
"data": {
"XPRD01": "Updated", // ✅ COD_ON_OFF='LDNMR' (M flag present)
"XPRD03": "PRD-2025-999" // ❌ COD_ON_OFF='LDR' (M flag missing)
}
}
Response:
{
"error": "ValidationError",
"message": "Field not modifiable: XPRD03",
"code": "FIELD_NOT_MODIFIABLE",
"field": "XPRD03",
"cod_on_off": "LDR",
"required_flag": "M",
"status": 400
}
COD_UTENTE Weight-Based Access Control
TB_COST.COD_UTENTE defines minimum user weight required to access a field:
INSERT INTO TB_COST (COD_DIM, NUM_COST, COD_VAR, COD_ON_OFF, COD_UTENTE) VALUES
('PRD', 1, 'XPRD01', 'LDNMR', '*'), -- All users can access
('PRD', 8, 'XPRD08', 'LDNM', '70'), -- Only users with peso >= 70
('PRD', 15, 'XPRD15', 'LDM', '90'); -- Only users with peso >= 90 (admin)
User Peso (Weight) Levels:
- peso = 1-30: Admin users (full access)
- peso = 31-60: Manager users (most fields)
- peso = 61-90: Standard users (basic fields)
- peso = 91-100: Limited users (read-only)
Validation Logic:
// Check user peso vs COD_UTENTE requirement
if ($cost['COD_UTENTE'] !== '*' && $userPeso > $cost['COD_UTENTE']) {
throw new ValidationException(
"Insufficient user weight to access field: {$cost['COD_VAR']}",
'USER_WEIGHT_INSUFFICIENT',
$cost['COD_VAR']
);
}
Example: Manager (peso=50) Attempting to Modify Admin Field:
Request:
PATCH /api/v4/core/PRD/123
Authorization: Bearer {token} # User with peso=50
{
"data": {
"XPRD01": "Product Name", // ✅ COD_UTENTE='*' (allowed)
"XPRD15": "Admin Value" // ❌ COD_UTENTE='90' (peso 50 < 90)
}
}
Response:
{
"error": "ValidationError",
"message": "Insufficient user weight to modify field: XPRD15",
"code": "USER_WEIGHT_INSUFFICIENT",
"field": "XPRD15",
"required_peso": 90,
"user_peso": 50,
"status": 400
}
Use Cases:
- Sensitive financial fields (COD_UTENTE='10') - Only top admin access
- Pricing fields (COD_UTENTE='70') - Manager and above
- Standard product info (COD_UTENTE='*') - All users
Layer 5: Business Logic Validation
Custom Validation Rules
Application-specific constraints (optional):
// Custom validator in CoreWrite
class ProductValidator {
public function validate(array $data): void {
// Price must be positive
if (isset($data['XPRD02']) && $data['XPRD02'] <= 0) {
throw new BusinessRuleException(
'Price must be greater than zero',
'INVALID_PRICE'
);
}
// Stock cannot be negative
if (isset($data['XPRD09']) && $data['XPRD09'] < 0) {
throw new BusinessRuleException(
'Stock cannot be negative',
'INVALID_STOCK'
);
}
// Discount cannot exceed 100%
if (isset($data['XPRD_DISCOUNT']) && $data['XPRD_DISCOUNT'] > 100) {
throw new BusinessRuleException(
'Discount cannot exceed 100%',
'INVALID_DISCOUNT'
);
}
}
}
Failure:
{
"error": "BusinessRuleError",
"message": "Price must be greater than zero",
"code": "INVALID_PRICE",
"field": "XPRD02",
"value": -10.00,
"status": 400
}
AC/VARS Foreign Key Validation
AC (Autocomplete) and VARS (Select) fields are foreign keys that must reference existing records in the destination table. The platform prevents orphaned links by validating foreign key existence before insert/update.
Validation Steps:
- Type validation - Ensure value matches TIPO_CAMPO definition (AC or VARS)
- Foreign key existence check - Verify value exists in destination table
- Return error if not found - Prevent orphaned links
Metadata Structure:
-- TB_VAR: Field definition
COD_DIM: ART
COD_VAR: XART53
TIPO_CAMPO: VARS
TIPO_VAR: DEPFOOD -- Foreign key to TB_ANAG_DEPFOOD00
-- TB_OBJECT: Foreign key relationship
COD_DIM: ART (source dimension)
COD_VAR: XART53 (source field - stores foreign key)
COD_DIM_OBJ: DEPFOOD (destination dimension)
COD_VAR_OBJ: XDEPFOOD03 (destination field - primary key)
Relationship:
TB_ANAG_ART00.XART53 → TB_ANAG_DEPFOOD00.XDEPFOOD03
Validation Logic:
// CoreWrite validates foreign key existence
if ($var['TIPO_CAMPO'] === 'VARS' || $var['TIPO_CAMPO'] === 'AC') {
// Get destination table from TB_OBJECT
$foreignKey = $this->getForeignKeyDefinition($dim, $field);
// Check if foreign key value exists
$exists = $this->checkForeignKeyExists(
$foreignKey['COD_DIM_OBJ'],
$foreignKey['COD_VAR_OBJ'],
$value
);
if (!$exists) {
throw new ValidationException(
"Invalid foreign key: {$field} references {$foreignKey['COD_DIM_OBJ']}, but '{$value}' does not exist",
'INVALID_FOREIGN_KEY',
$field
);
}
}
Example: Valid Foreign Key:
Request:
POST /api/v4/core/ART
{
"data": {
"XART20": "Product Name",
"XART53": "rep1" // Must exist in TB_ANAG_DEPFOOD00.XDEPFOOD03
}
}
Validation Query:
SELECT COUNT(*) FROM TB_ANAG_DEPFOOD00
WHERE XDEPFOOD03 = 'rep1'; -- Must return > 0
Success: Record created if foreign key exists.
Example: Invalid Foreign Key:
Request:
POST /api/v4/core/ART
{
"data": {
"XART20": "Product Name",
"XART53": "invalid_dept" // Does NOT exist in TB_ANAG_DEPFOOD00
}
}
Response:
{
"error": "ValidationError",
"message": "Invalid foreign key: XART53 references DEPFOOD, but 'invalid_dept' does not exist",
"code": "INVALID_FOREIGN_KEY",
"field": "XART53",
"foreign_table": "TB_ANAG_DEPFOOD00",
"foreign_field": "XDEPFOOD03",
"value": "invalid_dept",
"status": 400
}
Why This Matters:
- ✅ Prevents orphaned links (referencing non-existent records)
- ✅ Ensures data integrity across dimensions
- ✅ Validates foreign keys before database write
- ✅ Client can query
/api/v4/core/varsto get valid values
Related: See AC/VARS Queries for how to query valid foreign key values.
JavaScript Validation Handling
Client-Side Validation
class ProductValidator {
/**
* Validate before POST
*/
static validateCreate(data) {
const errors = [];
// Required field check
if (!data.XPRD01) {
errors.push({
field: 'XPRD01',
message: 'Product name is required'
});
}
if (!data.XPRD02) {
errors.push({
field: 'XPRD02',
message: 'Price is required'
});
}
if (!data.XPRD05) {
errors.push({
field: 'XPRD05',
message: 'Category is required'
});
}
// Business logic
if (data.XPRD02 && data.XPRD02 <= 0) {
errors.push({
field: 'XPRD02',
message: 'Price must be greater than zero'
});
}
if (data.XPRD09 && data.XPRD09 < 0) {
errors.push({
field: 'XPRD09',
message: 'Stock cannot be negative'
});
}
return errors;
}
/**
* Validate before PUT/PATCH
*/
static validateUpdate(data) {
const errors = [];
// Business logic
if (data.XPRD02 !== undefined && data.XPRD02 <= 0) {
errors.push({
field: 'XPRD02',
message: 'Price must be greater than zero'
});
}
if (data.XPRD09 !== undefined && data.XPRD09 < 0) {
errors.push({
field: 'XPRD09',
message: 'Stock cannot be negative'
});
}
return errors;
}
}
// Usage
async function createProduct(data) {
// Client-side validation
const errors = ProductValidator.validateCreate(data);
if (errors.length > 0) {
alert(`Validation errors:\n${errors.map(e => `- ${e.message}`).join('\n')}`);
return;
}
// API call
try {
const response = await fetch(`${apiBase}/api/v4/core/PRD`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ data })
});
if (!response.ok) {
const error = await response.json();
handleValidationError(error);
return;
}
const result = await response.json();
alert('Product created successfully!');
return result;
} catch (error) {
alert('Network error');
}
}
Error Handling
function handleValidationError(error) {
switch (error.code) {
case 'TOKEN_INVALID':
case 'TOKEN_EXPIRED':
// Redirect to login
window.location.href = '/login';
break;
case 'GRANT_DENIED':
alert(`Access denied: You don't have permission for this operation`);
break;
case 'REQUIRED_FIELD_MISSING':
alert(`Required field missing: ${error.field}`);
highlightField(error.field);
break;
case 'FIELD_NOT_CREATEABLE':
alert(`Field ${error.field} cannot be set on creation`);
break;
case 'FIELD_NOT_MODIFIABLE':
alert(`Field ${error.field} is read-only`);
break;
case 'INVALID_PRICE':
case 'INVALID_STOCK':
case 'INVALID_DISCOUNT':
alert(error.message);
highlightField(error.field);
break;
default:
alert(`Validation error: ${error.message}`);
}
}
function highlightField(fieldName) {
const input = document.querySelector(`[name="${fieldName}"]`);
if (input) {
input.classList.add('error');
input.focus();
}
}
Best Practices
✅ DO:
Validate client-side before API call:
// ✅ Good - catch errors early
const errors = validateCreate(data);
if (errors.length > 0) {
showErrors(errors);
return;
}
await createProduct(data);
Handle specific error codes:
// ✅ Good - specific handling
if (error.code === 'REQUIRED_FIELD_MISSING') {
highlightField(error.field);
}
Provide clear error messages:
// ✅ Good - user-friendly
alert(`${error.fieldDescription} is required`);
❌ DON'T:
Don't skip client-side validation:
// ❌ Bad - unnecessary API roundtrip
await createProduct(data); // Will fail with obvious errors
Don't show technical error codes to users:
// ❌ Bad - confusing
alert(error.code); // "REQUIRED_FIELD_MISSING"
// ✅ Good - user-friendly
alert(`Please fill in ${error.fieldDescription}`);
Don't try to set read-only fields:
// ❌ Bad - will be rejected
{
"data": {
"XPRD03": "custom-code", // Auto-generated counter
"TREC": "N" // Auto-managed
}
}
Summary
- ✅ 5 validation layers: Token → Grant → Required → COD_ON_OFF → Business Logic
- ✅ Failure at any layer rejects entire request
- ✅ Token validation ensures authentication (401 Unauthorized)
- ✅ Grant validation ensures authorization (403 Forbidden)
- ✅ Required field validation via TB_COST.REQUIRED='1'
- ✅ COD_ON_OFF validation enforces N (create) and M (modify) flags
- ✅ Business logic validation enforces custom rules
Key Takeaways:
- Validation happens at 5 layers before write execution
- Token validation: JWT signature, expiration, issuer
- Grant validation: TB_MENU nested set permissions
- Required fields: TB_COST.REQUIRED='1'
- COD_ON_OFF: N flag for POST, M flag for PUT/PATCH
- Client-side validation reduces unnecessary API calls
- Handle specific error codes for better UX
Error Code Reference:
| Code | Layer | HTTP Status | Meaning |
|---|---|---|---|
| TOKEN_INVALID | 1 | 401 | Invalid/expired token |
| GRANT_DENIED | 2 | 403 | Insufficient permissions |
| REQUIRED_FIELD_MISSING | 3 | 400 | Mandatory field missing |
| FIELD_NOT_CREATEABLE | 4 | 400 | Field missing N flag |
| FIELD_NOT_MODIFIABLE | 4 | 400 | Field missing M flag |
| INVALID_* | 5 | 400 | Business rule violation |
Next: Transactions →
Related Concepts
- Field Visibility - COD_ON_OFF flags
- Context Chain - source/peso/ambiente
- Multi-Level Permissions - TB_MENU grants