requireOrgAccess(); $suppliers = Database::fetchAll( 'SELECT * FROM suppliers WHERE organization_id = ? ORDER BY criticality DESC, name', [$this->getCurrentOrgId()] ); $this->jsonSuccess($suppliers); } public function create(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $this->validateRequired(['name', 'service_type']); $supplierId = Database::insert('suppliers', [ 'organization_id' => $this->getCurrentOrgId(), 'name' => trim($this->getParam('name')), 'vat_number' => $this->getParam('vat_number'), 'contact_email' => $this->getParam('contact_email'), 'contact_name' => $this->getParam('contact_name'), 'service_type' => $this->getParam('service_type'), 'service_description' => $this->getParam('service_description'), 'criticality' => $this->getParam('criticality', 'medium'), 'contract_start_date' => $this->getParam('contract_start_date'), 'contract_expiry_date' => $this->getParam('contract_expiry_date'), 'notes' => $this->getParam('notes'), ]); $this->logAudit('supplier_created', 'supplier', $supplierId); $this->jsonSuccess(['id' => $supplierId], 'Fornitore aggiunto', 201); } public function get(int $id): void { $this->requireOrgAccess(); $supplier = Database::fetchOne( 'SELECT * FROM suppliers WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$supplier) { $this->jsonError('Fornitore non trovato', 404, 'SUPPLIER_NOT_FOUND'); } $this->jsonSuccess($supplier); } public function update(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $updates = []; $fields = ['name', 'vat_number', 'contact_email', 'contact_name', 'service_type', 'service_description', 'criticality', 'contract_start_date', 'contract_expiry_date', 'security_requirements_met', 'notes', 'status']; foreach ($fields as $field) { if ($this->hasParam($field)) { $updates[$field] = $this->getParam($field); } } if (!empty($updates)) { Database::update('suppliers', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('supplier_updated', 'supplier', $id, $updates); } $this->jsonSuccess($updates, 'Fornitore aggiornato'); } public function delete(int $id): void { $this->requireOrgRole(['org_admin']); $deleted = Database::delete('suppliers', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); if ($deleted === 0) { $this->jsonError('Fornitore non trovato', 404, 'SUPPLIER_NOT_FOUND'); } $this->logAudit('supplier_deleted', 'supplier', $id); $this->jsonSuccess(null, 'Fornitore eliminato'); } public function assessSupplier(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $this->validateRequired(['assessment_responses']); $responses = $this->getParam('assessment_responses'); $riskScore = $this->calculateSupplierRiskScore($responses); Database::update('suppliers', [ 'assessment_responses' => json_encode($responses), 'risk_score' => $riskScore, 'last_assessment_date' => date('Y-m-d'), 'next_assessment_date' => date('Y-m-d', strtotime('+6 months')), 'security_requirements_met' => $riskScore >= 70 ? 1 : 0, ], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('supplier_assessed', 'supplier', $id, ['risk_score' => $riskScore]); $this->jsonSuccess(['risk_score' => $riskScore], 'Assessment fornitore completato'); } public function riskOverview(): void { $this->requireOrgAccess(); $overview = Database::fetchAll( 'SELECT criticality, status, COUNT(*) as count, AVG(risk_score) as avg_risk_score, SUM(CASE WHEN security_requirements_met = 0 THEN 1 ELSE 0 END) as non_compliant FROM suppliers WHERE organization_id = ? GROUP BY criticality, status', [$this->getCurrentOrgId()] ); $expiring = Database::fetchAll( 'SELECT id, name, contract_expiry_date, criticality FROM suppliers WHERE organization_id = ? AND contract_expiry_date IS NOT NULL AND contract_expiry_date <= DATE_ADD(NOW(), INTERVAL 90 DAY) AND status = "active" ORDER BY contract_expiry_date', [$this->getCurrentOrgId()] ); $this->jsonSuccess([ 'overview' => $overview, 'expiring_contracts' => $expiring, ]); } private function calculateSupplierRiskScore(array $responses): int { if (empty($responses)) return 0; $totalScore = 0; $totalWeight = 0; foreach ($responses as $resp) { $weight = $resp['weight'] ?? 1; $value = match ($resp['value'] ?? '') { 'yes', 'implemented' => 100, 'partial' => 50, default => 0, }; $totalScore += $value * $weight; $totalWeight += 100 * $weight; } return $totalWeight > 0 ? (int) round($totalScore / $totalWeight * 100) : 0; } // ══════════════════════════════════════════════════════════════════════ // SELF-ASSESSMENT FORNITORI (P3) — questionario sicurezza Art.21.2.d // ══════════════════════════════════════════════════════════════════════ private const QUESTIONNAIRE = [ ['key' => 'iso27001', 'q' => 'Disponete di una certificazione ISO/IEC 27001 valida?', 'weight' => 15], ['key' => 'mfa', 'q' => 'Imponete autenticazione a piu fattori (MFA) per gli accessi ai sistemi che trattano dati del cliente?', 'weight' => 15], ['key' => 'patching', 'q' => 'Avete un processo formale di patch management e gestione vulnerabilita?', 'weight' => 12], ['key' => 'backup', 'q' => 'Eseguite backup regolari con test di ripristino documentati?', 'weight' => 12], ['key' => 'incident', 'q' => 'Disponete di un piano di gestione degli incidenti con notifica al cliente?', 'weight' => 14], ['key' => 'access_review', 'q' => 'Effettuate revisioni periodiche degli accessi e revoca tempestiva?', 'weight' => 10], ['key' => 'encryption', 'q' => 'I dati del cliente sono cifrati a riposo e in transito?', 'weight' => 12], ['key' => 'subcontractor', 'q' => 'Valutate la sicurezza dei vostri sub-fornitori (quarta parte)?', 'weight' => 10], ]; /** POST /api/supply-chain/{id}/send-questionnaire (JWT): genera token + link pubblico. */ public function sendQuestionnaire(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $supplier = Database::fetchOne( 'SELECT id, name, contact_email FROM suppliers WHERE id = ? AND organization_id = ? AND deleted_at IS NULL', [$id, $this->getCurrentOrgId()] ); if (!$supplier) { $this->jsonError('Fornitore non trovato', 404, 'NOT_FOUND'); } $rawToken = 'sq_' . bin2hex(random_bytes(20)); $email = $this->getParam('email') ?: $supplier['contact_email']; $expires = date('Y-m-d H:i:s', time() + 30 * 86400); Database::insert('supplier_questionnaires', [ 'organization_id' => $this->getCurrentOrgId(), 'supplier_id' => $id, 'token_hash' => hash('sha256', $rawToken), 'status' => 'sent', 'sent_to_email' => $email, 'expires_at' => $expires, ]); $link = 'https://nis2.agile.software/supplier-assessment.html?token=' . $rawToken; try { if ($email && class_exists('EmailService')) { $svc = new EmailService(); if (method_exists($svc, 'send')) { $svc->send($email, 'Questionario di sicurezza fornitore - NIS2', "

Gentile {$supplier['name']},

vi chiediamo di compilare il questionario di sicurezza al seguente link (valido 30 giorni):

{$link}

"); } } } catch (Throwable $e) { error_log('[SUPPLIER_Q] email: ' . $e->getMessage()); } $this->logAudit('supplier_questionnaire_sent', 'supplier', $id, ['email' => $email]); $this->jsonSuccess(['supplier_id' => $id, 'sent_to' => $email, 'link' => $link, 'expires_at' => $expires], 'Questionario generato', 201); } /** GET /api/supply-chain/public-questionnaire?token=xxx (NO AUTH). */ public function publicQuestionnaire(): void { $q = $this->resolveQuestionnaire($_GET['token'] ?? ''); if ($q['status'] === 'completed') { $this->jsonError('Questionario gia compilato', 409, 'ALREADY_COMPLETED'); } $supplier = Database::fetchOne('SELECT name FROM suppliers WHERE id = ?', [$q['supplier_id']]); $this->jsonSuccess([ 'supplier_name' => $supplier['name'] ?? 'Fornitore', 'questions' => array_map(fn($x) => ['key' => $x['key'], 'question' => $x['q']], self::QUESTIONNAIRE), 'answer_scale' => ['yes' => 'Si', 'partial' => 'Parzialmente', 'no' => 'No'], ]); } /** POST /api/supply-chain/submit-public-questionnaire (NO AUTH). Body: {token, answers}. */ public function submitPublicQuestionnaire(): void { $body = $this->getJsonBody(); $q = $this->resolveQuestionnaire($body['token'] ?? ''); if ($q['status'] === 'completed') { $this->jsonError('Questionario gia compilato', 409, 'ALREADY_COMPLETED'); } $answers = is_array($body['answers'] ?? null) ? $body['answers'] : []; $scoreMap = ['yes' => 1.0, 'partial' => 0.5, 'no' => 0.0]; $earned = 0; $maxScore = 0; $clean = []; foreach (self::QUESTIONNAIRE as $item) { $maxScore += $item['weight']; $a = strtolower((string) ($answers[$item['key']] ?? 'no')); if (!isset($scoreMap[$a])) $a = 'no'; $clean[$item['key']] = $a; $earned += $item['weight'] * $scoreMap[$a]; } $score = $maxScore > 0 ? (int) round($earned / $maxScore * 100) : 0; $riskLevel = $score >= 80 ? 'low' : ($score >= 60 ? 'medium' : ($score >= 40 ? 'high' : 'critical')); // Completamento ATOMICO: vincola lo UPDATE a status='sent' così due submit // concorrenti con lo stesso token non possono completare due volte (la seconda // tocca 0 righe -> 409). Previene il doppio-submit senza transazione esplicita. $upd = Database::query( 'UPDATE supplier_questionnaires SET status=?, answers=?, score=?, risk_level=?, completed_at=NOW() WHERE id=? AND status=?', ['completed', json_encode($clean, JSON_UNESCAPED_UNICODE), $score, $riskLevel, $q['id'], 'sent'] ); if ($upd->rowCount() === 0) { $this->jsonError('Questionario gia compilato', 409, 'ALREADY_COMPLETED'); } // Coerenza con assessSupplier: suppliers.risk_score = punteggio di COMPLIANCE // (alto = buono), e security_requirements_met soglia 70. NON sovrascriviamo // suppliers.criticality (è la criticità del fornitore, non l'esito del questionario). Database::query( 'UPDATE suppliers SET risk_score=?, security_requirements_met=?, last_assessment_date=CURDATE() WHERE id=? AND organization_id=?', [$score, $score >= 70 ? 1 : 0, $q['supplier_id'], $q['organization_id']] ); $this->jsonSuccess(['score' => $score, 'risk_level' => $riskLevel], 'Questionario inviato. Grazie.', 201); } /** GET /api/supply-chain/{id}/questionnaire-status (JWT). */ public function questionnaireStatus(int $id): void { $this->requireOrgAccess(); $rows = Database::fetchAll( 'SELECT id, status, score, risk_level, sent_to_email, sent_at, completed_at, expires_at FROM supplier_questionnaires WHERE supplier_id = ? AND organization_id = ? ORDER BY sent_at DESC', [$id, $this->getCurrentOrgId()] ); $this->jsonSuccess(['questionnaires' => $rows]); } /** Risolve un token grezzo nel record questionario, validando scadenza. */ private function resolveQuestionnaire(string $rawToken): array { if (!preg_match('/^sq_[a-f0-9]{40}$/', $rawToken)) { $this->jsonError('Token non valido', 404, 'INVALID_TOKEN'); } $q = Database::fetchOne('SELECT * FROM supplier_questionnaires WHERE token_hash = ?', [hash('sha256', $rawToken)]); if (!$q) { $this->jsonError('Questionario non trovato', 404, 'NOT_FOUND'); } if ($q['expires_at'] && strtotime($q['expires_at']) < time() && $q['status'] !== 'completed') { Database::query('UPDATE supplier_questionnaires SET status=? WHERE id=?', ['expired', $q['id']]); $this->jsonError('Questionario scaduto', 410, 'EXPIRED'); } return $q; } }