[FIX] SupplyChain: aggiunti i 5 metodi self-assessment (Edit precedente rifiutato per file non letto)

sendQuestionnaire/publicQuestionnaire/submitPublicQuestionnaire/questionnaireStatus/resolveQuestionnaire.
Test E2E prod: send 201 -> public GET 200 -> submit 201 (score 61) -> re-submit 409 -> suppliers.risk_score=39.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-05-30 10:39:24 +02:00
parent 31b8a4572c
commit 172d9270e6

View File

@ -165,4 +165,140 @@ class SupplyChainController extends BaseController
return $totalWeight > 0 ? (int) round($totalScore / $totalWeight * 100) : 0; 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',
"<p>Gentile {$supplier['name']},</p><p>vi chiediamo di compilare il questionario di sicurezza al seguente link (valido 30 giorni):</p><p><a href=\"{$link}\">{$link}</a></p>");
}
}
} 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'));
Database::query(
'UPDATE supplier_questionnaires SET status=?, answers=?, score=?, risk_level=?, completed_at=NOW() WHERE id=?',
['completed', json_encode($clean, JSON_UNESCAPED_UNICODE), $score, $riskLevel, $q['id']]
);
Database::query(
'UPDATE suppliers SET risk_score=?, criticality=?, last_assessment_date=CURDATE() WHERE id=? AND organization_id=?',
[100 - $score, $riskLevel, $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;
}
} }