|
| 1 | +# DTO Validation Engine (API + CSRF) |
| 2 | + |
| 3 | +## Purpose |
| 4 | + |
| 5 | +PSFS now supports declarative validation directly in DTOs, including optional CSRF enforcement, without depending on Twig `Form` objects. |
| 6 | + |
| 7 | +This provides: |
| 8 | + |
| 9 | +- Cleaner API input validation. |
| 10 | +- Explicit, testable contracts per endpoint. |
| 11 | +- Opt-in CSRF validation in DTOs for admin/session contexts. |
| 12 | +- Strict unknown-field rejection by default. |
| 13 | + |
| 14 | +## Core Components |
| 15 | + |
| 16 | +- `PSFS\base\dto\ValidatableDtoTrait` |
| 17 | +- `PSFS\base\dto\ValidationContext` |
| 18 | +- `PSFS\base\dto\ValidationResult` |
| 19 | +- `PSFS\base\dto\CsrfValidator` |
| 20 | +- `PSFS\base\dto\Dto` (integrates trait) |
| 21 | + |
| 22 | +## Validation Attributes |
| 23 | + |
| 24 | +Supported attributes under `src/base/types/helpers/attributes`: |
| 25 | + |
| 26 | +- `Required` (existing) |
| 27 | +- `VarType` (existing) |
| 28 | +- `Values` (existing enum semantics) |
| 29 | +- `DefaultValue` (existing) |
| 30 | +- `Pattern` |
| 31 | +- `Min` |
| 32 | +- `Max` |
| 33 | +- `Length` |
| 34 | +- `Nullable` |
| 35 | +- `CsrfProtected` (DTO-level) |
| 36 | +- `CsrfField` (DTO-level, optional custom field names) |
| 37 | + |
| 38 | +## Validation Flow |
| 39 | + |
| 40 | +When `validate()` is called on a DTO: |
| 41 | + |
| 42 | +1. Input is hydrated (`fromArray()` or explicit setters). |
| 43 | +2. Default values are applied. |
| 44 | +3. Unknown fields are checked (`strictUnknownFields=true` by default). |
| 45 | +4. Per-property constraints are validated: |
| 46 | + - required |
| 47 | + - type |
| 48 | + - enum/values |
| 49 | + - pattern |
| 50 | + - length |
| 51 | + - min/max |
| 52 | +5. If DTO has `#[CsrfProtected]`, CSRF token is validated. |
| 53 | +6. A `ValidationResult` is returned and can be queried via: |
| 54 | + - `isValid()` |
| 55 | + - `getErrors()` |
| 56 | + - `getValidationErrors()` |
| 57 | + |
| 58 | +## Unknown Fields Policy |
| 59 | + |
| 60 | +Default behavior is **fail-closed**: |
| 61 | + |
| 62 | +- Any payload key not declared in DTO public properties is rejected. |
| 63 | +- Error code: `unknown_field`. |
| 64 | + |
| 65 | +This prevents accidental mass-assignment and hidden payload drift. |
| 66 | + |
| 67 | +## CSRF in DTOs |
| 68 | + |
| 69 | +CSRF is fully declarative: |
| 70 | + |
| 71 | +- Add `#[CsrfProtected(formKey: '...')]` to the DTO class. |
| 72 | +- Optionally add `#[CsrfField(tokenField: '...', tokenKeyField: '...')]`. |
| 73 | + |
| 74 | +Resolution order: |
| 75 | + |
| 76 | +1. Payload fields (`tokenField`, `tokenKeyField`). |
| 77 | +2. Header fallback (`X-CSRF-Token` by default, configurable via `CsrfProtected`). |
| 78 | + |
| 79 | +Validation enforces one-time token semantics and expiration using `csrf.expiration`. |
| 80 | + |
| 81 | +## Example DTO |
| 82 | + |
| 83 | +```php |
| 84 | +<?php |
| 85 | + |
| 86 | +namespace PSFS\base\dto; |
| 87 | + |
| 88 | +use PSFS\base\types\helpers\attributes\CsrfField; |
| 89 | +use PSFS\base\types\helpers\attributes\CsrfProtected; |
| 90 | +use PSFS\base\types\helpers\attributes\Length; |
| 91 | +use PSFS\base\types\helpers\attributes\Pattern; |
| 92 | + |
| 93 | +#[CsrfProtected(formKey: 'admin_setup')] |
| 94 | +#[CsrfField(tokenField: 'admin_setup_token', tokenKeyField: 'admin_setup_token_key')] |
| 95 | +class DeleteUserRequestDto extends Dto |
| 96 | +{ |
| 97 | + /** @required */ |
| 98 | + #[Length(min: 1, max: 64)] |
| 99 | + #[Pattern('/^[a-zA-Z0-9._-]+$/')] |
| 100 | + public ?string $username = null; |
| 101 | +} |
| 102 | +``` |
| 103 | + |
| 104 | +## API Usage Pattern |
| 105 | + |
| 106 | +```php |
| 107 | +$dto = DeleteUserRequestDto::fromArray($request->getRawData()); |
| 108 | +$result = $dto->validate(ValidationContext::fromRequest($request)); |
| 109 | + |
| 110 | +if (!$result->isValid()) { |
| 111 | + return ApiResponse::error($result->getFirstErrorMessage(), 400, $result->getErrors()); |
| 112 | +} |
| 113 | +``` |
| 114 | + |
| 115 | +Notes: |
| 116 | + |
| 117 | +- Prefer `Request::getRawData()` for payload fidelity in API controllers. |
| 118 | +- Use `ValidationContext::fromRequest($request)` to include headers for CSRF fallback. |
| 119 | + |
| 120 | +## Backward Compatibility |
| 121 | + |
| 122 | +- Legacy Twig/Form flow remains available during migration. |
| 123 | +- DTO validation is opt-in: only DTOs calling `validate()` are enforced. |
| 124 | +- Existing DTOs without attributes keep current behavior. |
| 125 | + |
| 126 | +## Migration Strategy |
| 127 | + |
| 128 | +1. Start with mutation endpoints (create/update/delete). |
| 129 | +2. Introduce DTO per endpoint command. |
| 130 | +3. Add explicit constraints and optional `CsrfProtected`. |
| 131 | +4. Replace ad-hoc checks in controller/service with `validate()`. |
| 132 | +5. Keep legacy paths temporarily where needed. |
| 133 | + |
| 134 | +## Testing Recommendations |
| 135 | + |
| 136 | +Mandatory for each new validated DTO: |
| 137 | + |
| 138 | +- Unit tests for rules: |
| 139 | + - required/type/enum/pattern/min-max/length/defaults/nullable |
| 140 | + - unknown fields rejected |
| 141 | +- CSRF tests (when applicable): |
| 142 | + - valid token |
| 143 | + - missing token |
| 144 | + - expired token |
| 145 | + - replay token |
| 146 | +- Integration tests: |
| 147 | + - success path (`200`) |
| 148 | + - validation error path (`4xx`) |
| 149 | + |
| 150 | +## Security Notes |
| 151 | + |
| 152 | +- Default strict unknown-field policy is intentional. |
| 153 | +- CSRF should be enabled for browser/session-admin mutations. |
| 154 | +- Machine-to-machine/JWT-only endpoints may skip CSRF by design. |
0 commit comments