nis2-agile/application/controllers/OnboardingController.php
DevEnv nis2-agile 219479959e [FIX] InviteController requireRole→requireSuperAdmin + OnboardingController add RateLimitService
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-10 15:54:16 +01:00

405 lines
18 KiB
PHP

<?php
/**
* NIS2 Agile - Onboarding Controller
*
* Gestisce il wizard di onboarding: upload visura, fetch CertiSource, completamento.
*/
require_once __DIR__ . '/BaseController.php';
require_once __DIR__ . '/../services/VisuraService.php';
require_once APP_PATH . '/services/RateLimitService.php';
class OnboardingController extends BaseController
{
/**
* POST /api/onboarding/upload-visura
* Riceve PDF visura camerale, estrae dati con AI
*/
public function uploadVisura(): void
{
$this->requireAuth();
// Validate file upload
if (!isset($_FILES['visura']) || $_FILES['visura']['error'] !== UPLOAD_ERR_OK) {
$this->jsonError('Nessun file caricato o errore upload', 400, 'UPLOAD_ERROR');
}
$file = $_FILES['visura'];
// Validate file type (PDF only)
$allowedTypes = ['application/pdf'];
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$mimeType = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!in_array($mimeType, $allowedTypes)) {
$this->jsonError('Solo file PDF sono accettati', 400, 'INVALID_FILE_TYPE');
}
// Max 10MB
if ($file['size'] > 10 * 1024 * 1024) {
$this->jsonError('File troppo grande (max 10MB)', 400, 'FILE_TOO_LARGE');
}
// Save file temporarily
$uploadDir = UPLOAD_PATH . '/visure';
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$fileName = 'visura_' . $this->getCurrentUserId() . '_' . time() . '.pdf';
$filePath = $uploadDir . '/' . $fileName;
if (!move_uploaded_file($file['tmp_name'], $filePath)) {
$this->jsonError('Errore nel salvataggio del file', 500, 'SAVE_ERROR');
}
try {
// Extract data using AI
$visuraService = new VisuraService();
$extractedData = $visuraService->extractFromPdf($filePath);
$this->logAudit('visura_uploaded', 'onboarding', null, [
'file' => $fileName,
'extracted' => !empty($extractedData)
]);
$this->jsonSuccess($extractedData, 'Dati estratti dalla visura');
} catch (Throwable $e) {
error_log('[VISURA_ERROR] ' . $e->getMessage());
$this->jsonError(
APP_DEBUG ? 'Errore estrazione: ' . $e->getMessage() : 'Errore nell\'analisi della visura',
500,
'EXTRACTION_ERROR'
);
}
}
/**
* POST /api/onboarding/fetch-company
* Recupera dati aziendali da CertiSource tramite P.IVA
*/
public function fetchCompany(): void
{
$this->requireAuth();
$this->validateRequired(['vat_number']);
$vatNumber = trim($this->getParam('vat_number'));
// Clean VAT: remove IT prefix, spaces
$vatNumber = preg_replace('/^IT/i', '', $vatNumber);
$vatNumber = preg_replace('/\s+/', '', $vatNumber);
// Validate Italian P.IVA format (11 digits)
if (!preg_match('/^\d{11}$/', $vatNumber)) {
$this->jsonError('Formato Partita IVA non valido (11 cifre)', 400, 'INVALID_VAT');
}
try {
$visuraService = new VisuraService();
$companyData = $visuraService->fetchFromCertiSource($vatNumber);
$this->logAudit('certisource_fetch', 'onboarding', null, [
'vat_number' => $vatNumber,
'found' => !empty($companyData)
]);
if (empty($companyData) || empty($companyData['company_name'])) {
$this->jsonError(
'Azienda non trovata per la P.IVA fornita',
404,
'COMPANY_NOT_FOUND'
);
}
$this->jsonSuccess($companyData, 'Dati aziendali recuperati');
} catch (Throwable $e) {
error_log('[CERTISOURCE_ERROR] ' . $e->getMessage());
$this->jsonError(
APP_DEBUG ? 'Errore CertiSource: ' . $e->getMessage() : 'Errore nel recupero dati aziendali',
500,
'CERTISOURCE_ERROR'
);
}
}
/**
* POST /api/onboarding/lookup-piva
*
* Lookup P.IVA pubblico senza autenticazione — usato dalla pagina register.html
* per pre-compilare il nome azienda prima che l'utente abbia un account.
*
* Rate limiting soft: 10 req/min per IP (file-based).
* Restituisce solo company_name e sector (nessun dato sensibile).
*/
public function lookupPiva(): void
{
// Soft rate limiting (no auth)
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? 'unknown';
$ip = trim(explode(',', $ip)[0]);
RateLimitService::check("piva_lookup:{$ip}", [['max' => 10, 'window_seconds' => 60]]);
RateLimitService::increment("piva_lookup:{$ip}");
$vatNumber = trim($this->getParam('vat_number', ''));
$vatNumber = preg_replace('/^IT/i', '', $vatNumber);
$vatNumber = preg_replace('/\s+/', '', $vatNumber);
if (!preg_match('/^\d{11}$/', $vatNumber)) {
$this->jsonError('Formato P.IVA non valido (11 cifre)', 400, 'INVALID_VAT');
}
try {
$visuraService = new VisuraService();
$data = $visuraService->fetchFromCertiSource($vatNumber);
if (empty($data) || empty($data['company_name'])) {
$this->jsonError('Azienda non trovata', 404, 'COMPANY_NOT_FOUND');
}
// Restituisce solo i campi necessari per il pre-fill del form
$this->jsonSuccess([
'company_name' => $data['company_name'],
'sector' => $data['sector'] ?? null,
'nis2_entity_type'=> $data['nis2_entity_type'] ?? null,
], 'Azienda trovata');
} catch (Throwable $e) {
// CertiSource non raggiungibile o endpoint cambiato — trattato come "non trovato"
$this->jsonError('Azienda non trovata nel registro. Inserisci i dati manualmente.', 404, 'COMPANY_NOT_FOUND');
}
}
/**
* POST /api/onboarding/complete
* Completa l'onboarding: crea organizzazione e aggiorna profilo utente
*/
public function complete(): void
{
$this->requireAuth();
$this->validateRequired(['name', 'sector']);
$userId = $this->getCurrentUserId();
$currentUser = $this->getCurrentUser();
$isConsultant = ($currentUser['role'] === 'consultant');
// Check if user already has an organization (blocca solo per non-consulenti)
$existingOrg = Database::fetchOne(
'SELECT organization_id FROM user_organizations WHERE user_id = ? AND is_primary = 1',
[$userId]
);
if ($existingOrg && !$isConsultant) {
$this->jsonError('Hai già un\'organizzazione configurata', 409, 'ORG_EXISTS');
}
// Per consulenti: verifica che la P.IVA non sia già associata al loro account
$vatNumber = $this->getParam('vat_number');
if ($isConsultant && $vatNumber) {
$cleanVat = preg_replace('/^IT/i', '', preg_replace('/\s+/', '', $vatNumber));
$dupOrg = Database::fetchOne(
'SELECT o.id FROM organizations o
JOIN user_organizations uo ON uo.organization_id = o.id
WHERE uo.user_id = ? AND o.vat_number = ?',
[$userId, $cleanVat]
);
if ($dupOrg) {
$this->jsonError('Questa azienda è già presente nel tuo portafoglio clienti', 409, 'ORG_DUPLICATE_VAT');
}
}
Database::beginTransaction();
try {
// Create organization
$orgData = [
'name' => trim($this->getParam('name')),
'vat_number' => $this->getParam('vat_number'),
'fiscal_code' => $this->getParam('fiscal_code'),
'sector' => $this->getParam('sector'),
'employee_count' => $this->getParam('employee_count'),
'annual_turnover_eur' => $this->getParam('annual_turnover_eur'),
'country' => $this->getParam('country', 'IT'),
'city' => $this->getParam('city'),
'address' => $this->getParam('address'),
'website' => $this->getParam('website'),
'contact_email' => $this->getParam('contact_email'),
'contact_phone' => $this->getParam('contact_phone'),
];
$orgId = Database::insert('organizations', $orgData);
// Auto-classify NIS2 entity type
$entityType = $this->classifyNis2Entity(
$orgData['sector'],
(int) ($orgData['employee_count'] ?? 0),
(float) ($orgData['annual_turnover_eur'] ?? 0)
);
// Handle voluntary compliance
$voluntaryCompliance = ($entityType === 'not_applicable' && (int) $this->getParam('voluntary_compliance', 0)) ? 1 : 0;
Database::update('organizations', [
'entity_type' => $entityType,
'voluntary_compliance' => $voluntaryCompliance,
], 'id = ?', [$orgId]);
// Link user: consulente → ruolo 'consultant', is_primary solo se prima org
$orgRole = $isConsultant ? 'consultant' : 'org_admin';
$isPrimary = $existingOrg ? 0 : 1;
Database::insert('user_organizations', [
'user_id' => $userId,
'organization_id' => $orgId,
'role' => $orgRole,
'is_primary' => $isPrimary,
]);
// Update user profile if provided
$profileUpdates = [];
if ($this->hasParam('phone') && $this->getParam('phone')) {
$profileUpdates['phone'] = $this->getParam('phone');
}
if ($this->hasParam('full_name') && $this->getParam('full_name')) {
$profileUpdates['full_name'] = trim($this->getParam('full_name'));
}
if (!empty($profileUpdates)) {
Database::update('users', $profileUpdates, 'id = ?', [$userId]);
}
// Initialize NIS2 compliance controls
$this->initializeComplianceControls($orgId);
Database::commit();
$this->currentOrgId = $orgId;
$this->logAudit('onboarding_completed', 'organization', $orgId, [
'name' => $orgData['name'],
'sector' => $orgData['sector'],
'entity_type' => $entityType,
'is_consultant' => $isConsultant,
]);
$this->jsonSuccess([
'organization_id' => $orgId,
'name' => $orgData['name'],
'entity_type' => $entityType,
'voluntary_compliance' => $voluntaryCompliance,
'classification' => $this->getClassificationDetails($entityType, $orgData['sector'], (int)($orgData['employee_count'] ?? 0), (float)($orgData['annual_turnover_eur'] ?? 0)),
], 'Onboarding completato', 201);
} catch (Throwable $e) {
Database::rollback();
throw $e;
}
}
// ═══════════════════════════════════════════════════════════════════════
// PRIVATE METHODS (copied from OrganizationController for independence)
// ═══════════════════════════════════════════════════════════════════════
private function classifyNis2Entity(string $sector, int $employees, float $turnover): string
{
// Map detailed sector codes to NIS2 categories
$essentialSectors = [
'energy', 'energy_electricity', 'energy_district_heating', 'energy_oil', 'energy_gas', 'energy_hydrogen',
'transport', 'transport_air', 'transport_rail', 'transport_water', 'transport_road',
'banking', 'financial_markets', 'health', 'water', 'drinking_water', 'waste_water',
'digital_infra', 'digital_infrastructure', 'ict_service_management',
'public_admin', 'public_administration', 'space',
];
$importantSectors = [
'manufacturing', 'manufacturing_medical', 'manufacturing_computers', 'manufacturing_electrical',
'manufacturing_machinery', 'manufacturing_vehicles', 'manufacturing_transport',
'postal', 'postal_courier', 'chemical', 'chemicals', 'food',
'waste', 'waste_management', 'ict_services', 'digital_providers', 'research',
];
$isLarge = $employees >= 250 || $turnover >= 50000000;
$isMedium = ($employees >= 50 || $turnover >= 10000000) && !$isLarge;
if (in_array($sector, $essentialSectors)) {
if ($isLarge) return 'essential';
if ($isMedium) return 'important';
}
if (in_array($sector, $importantSectors)) {
if ($isLarge || $isMedium) return 'important';
}
return 'not_applicable';
}
private function getClassificationDetails(string $type, string $sector, int $employees, float $turnover): array
{
$sizeLabel = $employees >= 250 ? 'Grande Impresa' : ($employees >= 50 ? 'Media Impresa' : 'Piccola Impresa');
return match ($type) {
'essential' => [
'type' => 'essential',
'label' => 'Soggetto Essenziale',
'size' => $sizeLabel,
'description' => "La vostra organizzazione rientra tra i soggetti essenziali ai sensi della Direttiva NIS2 (UE 2022/2555). Operate in un settore ad alta criticità e superate le soglie dimensionali.",
'obligations' => [
'Misure di sicurezza informatica (Art. 21)',
'Notifica incidenti significativi al CSIRT entro 24h/72h/30gg (Art. 23)',
'Formazione obbligatoria per gli organi di gestione (Art. 20)',
'Vigilanza proattiva (ex ante) da parte delle autorità',
'Sanzioni fino a EUR 10M o 2% del fatturato mondiale annuo',
],
],
'important' => [
'type' => 'important',
'label' => 'Soggetto Importante',
'size' => $sizeLabel,
'description' => "La vostra organizzazione rientra tra i soggetti importanti ai sensi della Direttiva NIS2. Siete tenuti a rispettare gli obblighi di sicurezza e notifica incidenti.",
'obligations' => [
'Misure di sicurezza informatica (Art. 21)',
'Notifica incidenti significativi al CSIRT entro 24h/72h/30gg (Art. 23)',
'Formazione obbligatoria per gli organi di gestione (Art. 20)',
'Vigilanza reattiva (ex post) da parte delle autorità',
'Sanzioni fino a EUR 7M o 1,4% del fatturato mondiale annuo',
],
],
default => [
'type' => 'not_applicable',
'label' => 'Non Applicabile',
'size' => $sizeLabel,
'description' => "In base ai dati forniti, la vostra organizzazione non sembra rientrare nell'ambito di applicazione della Direttiva NIS2. Consigliamo comunque di adottare le best practice di cybersecurity.",
'obligations' => [
'Nessun obbligo NIS2 specifico',
'Si raccomandano comunque buone pratiche di sicurezza informatica',
'Possibile designazione futura da parte delle autorità nazionali',
],
],
};
}
private function initializeComplianceControls(int $orgId): void
{
$controls = [
['NIS2-21.2.a', 'nis2', 'Politiche di analisi dei rischi e sicurezza dei sistemi informatici'],
['NIS2-21.2.b', 'nis2', 'Gestione degli incidenti'],
['NIS2-21.2.c', 'nis2', 'Continuità operativa e gestione delle crisi'],
['NIS2-21.2.d', 'nis2', 'Sicurezza della catena di approvvigionamento'],
['NIS2-21.2.e', 'nis2', 'Sicurezza acquisizione, sviluppo e manutenzione sistemi'],
['NIS2-21.2.f', 'nis2', 'Politiche e procedure per valutare efficacia misure'],
['NIS2-21.2.g', 'nis2', 'Pratiche di igiene informatica di base e formazione'],
['NIS2-21.2.h', 'nis2', 'Politiche e procedure relative alla crittografia'],
['NIS2-21.2.i', 'nis2', 'Sicurezza risorse umane, controllo accessi e gestione asset'],
['NIS2-21.2.j', 'nis2', 'Autenticazione multi-fattore e comunicazioni sicure'],
['NIS2-20.1', 'nis2', 'Governance: approvazione misure da parte degli organi di gestione'],
['NIS2-20.2', 'nis2', 'Formazione obbligatoria per gli organi di gestione'],
['NIS2-23.1', 'nis2', 'Notifica incidenti significativi al CSIRT (24h/72h/30gg)'],
];
foreach ($controls as [$code, $framework, $title]) {
Database::insert('compliance_controls', [
'organization_id' => $orgId,
'control_code' => $code,
'framework' => $framework,
'title' => $title,
'status' => 'not_started',
]);
}
}
}