[FEAT] Gestione Rischi: tabella 12 colonne (inerente+residuo) — integrazione prototipo "Modulo Azioni" (Simon)
Integra la spec di Simon per la pagina Rischi con le correzioni normative (parere nis2-expert + iso-27001-expert) applicate come eccezioni motivate: - 12 colonne: Codice (link → Misure e Requisiti), Descrizione (RISCHIO DEFAULT = cfg_nis2_rischi.risk_descr), VALUTAZIONE RISCHIO INERENTE (P/I/PxI), Alert, Rischio Inerente, VALUTAZIONE RISCHIO RESIDUO (P/I/PxI), Esito residuo, Rischio Residuo. - Editing INLINE di Probabilità/Impatto (1-5), salvataggio automatico. - Alert a bande (15-25 Prioritaria / 9-14 Critica / 3-8 Necessaria / 1-2 Suggerita) → popup azioni + "crea azione" (tabella requisito_actions, seme Modulo Azioni). - Eccezione #1: il rischio RESIDUO NON aggiorna in automatico lo STATO DI CONFORMITÀ del requisito (piani distinti). Col.11 "Esito residuo" = flag Rivalutare/Adeguato; conformità esplicita. - Eccezione #3: banda 13-14 inclusa in "Critica" (9-14). "Danno €" → "Impatto" (1-5). Soglie = metodo interno (ISO 27001 §6.1.2), non obbligo (help/UI lo dichiarano). Backend: mig 067; derivedList (+risk_descr +residuo), setState (+residuo), requisitoActions list/create. help.js + i18n IT/EN. Cache-buster api/i18n/help 20260730a. Prossima mig=068. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
cf224480cc
commit
8a0bc5ca39
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
/**
|
||||
* migrate_067_requisito_residual_and_actions.php — Integrazione "Modulo Azioni" (Simon), Fase 1.
|
||||
* (1) org_requisito_state += residual_likelihood, residual_impact (TINYINT NULL, scala 1-5).
|
||||
* (2) requisito_actions (azioni collegate al rischio-da-requisito).
|
||||
* Additiva e IDEMPOTENTE. Nessun dato esistente toccato.
|
||||
*
|
||||
* Eseguire (host): docker exec nis2-app php /var/www/nis2-agile/application/cli/migrate_067_requisito_residual_and_actions.php
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') { http_response_code(403); exit("CLI only\n"); }
|
||||
require_once __DIR__ . '/../config/env.php';
|
||||
require_once __DIR__ . '/../config/database.php';
|
||||
|
||||
$pdo = Database::getInstance();
|
||||
|
||||
function colExists(PDO $pdo, string $table, string $col): bool {
|
||||
$st = $pdo->prepare(
|
||||
"SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?"
|
||||
);
|
||||
$st->execute([$table, $col]);
|
||||
return (bool) $st->fetchColumn();
|
||||
}
|
||||
function tblExists(PDO $pdo, string $table): bool {
|
||||
$st = $pdo->prepare(
|
||||
"SELECT 1 FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?"
|
||||
);
|
||||
$st->execute([$table]);
|
||||
return (bool) $st->fetchColumn();
|
||||
}
|
||||
|
||||
$adds = [
|
||||
'residual_likelihood' => "ADD COLUMN residual_likelihood TINYINT NULL COMMENT 'Probabilita residua 1-5' AFTER impact",
|
||||
'residual_impact' => "ADD COLUMN residual_impact TINYINT NULL COMMENT 'Impatto residuo 1-5' AFTER residual_likelihood",
|
||||
];
|
||||
foreach ($adds as $col => $clause) {
|
||||
if (colExists($pdo, 'org_requisito_state', $col)) { echo " - $col gia presente, skip\n"; continue; }
|
||||
$pdo->exec("ALTER TABLE org_requisito_state $clause");
|
||||
echo " + aggiunta colonna org_requisito_state.$col\n";
|
||||
}
|
||||
|
||||
if (tblExists($pdo, 'requisito_actions')) {
|
||||
echo " - tabella requisito_actions gia presente, skip\n";
|
||||
} else {
|
||||
$pdo->exec(
|
||||
"CREATE TABLE requisito_actions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
organization_id INT NOT NULL,
|
||||
requisito_id INT NOT NULL,
|
||||
risk_code VARCHAR(64) NULL,
|
||||
description TEXT NOT NULL,
|
||||
status ENUM('bozza','in_corso','archiviata') NOT NULL DEFAULT 'bozza',
|
||||
created_by INT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_reqact_org_req (organization_id, requisito_id, created_at),
|
||||
INDEX idx_reqact_org (organization_id, created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci"
|
||||
);
|
||||
echo " + creata tabella requisito_actions\n";
|
||||
}
|
||||
|
||||
echo "Migrazione 067 — residuo + requisito_actions OK. Prossima mig=068.\n";
|
||||
@@ -207,24 +207,29 @@ class FrameworkController extends BaseController
|
||||
};
|
||||
$likelihood = array_key_exists('likelihood', $body) ? $scale($body['likelihood']) : null;
|
||||
$impact = array_key_exists('impact', $body) ? $scale($body['impact']) : null;
|
||||
// Rischio RESIDUO (mig 067) — dopo il trattamento. Il client invia sempre la riga intera.
|
||||
$resLikelihood = array_key_exists('residual_likelihood', $body) ? $scale($body['residual_likelihood']) : null;
|
||||
$resImpact = array_key_exists('residual_impact', $body) ? $scale($body['residual_impact']) : null;
|
||||
|
||||
// upsert org-scoped (UNIQUE org+requisito)
|
||||
Database::query(
|
||||
'INSERT INTO org_requisito_state
|
||||
(organization_id, requisito_id, stato, valutazione_rischio, note, likelihood, impact, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(organization_id, requisito_id, stato, valutazione_rischio, note, likelihood, impact, residual_likelihood, residual_impact, updated_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
stato = VALUES(stato),
|
||||
valutazione_rischio = VALUES(valutazione_rischio),
|
||||
note = VALUES(note),
|
||||
likelihood = VALUES(likelihood),
|
||||
impact = VALUES(impact),
|
||||
residual_likelihood = VALUES(residual_likelihood),
|
||||
residual_impact = VALUES(residual_impact),
|
||||
updated_by = VALUES(updated_by)',
|
||||
[$orgId, $reqId, $stato ?? 'da_valutare', $valutazione, $note, $likelihood, $impact, $this->getCurrentUserId()]
|
||||
[$orgId, $reqId, $stato ?? 'da_valutare', $valutazione, $note, $likelihood, $impact, $resLikelihood, $resImpact, $this->getCurrentUserId()]
|
||||
);
|
||||
|
||||
$row = Database::fetchOne(
|
||||
'SELECT requisito_id, stato, valutazione_rischio, note, likelihood, impact, updated_at
|
||||
'SELECT requisito_id, stato, valutazione_rischio, note, likelihood, impact, residual_likelihood, residual_impact, updated_at
|
||||
FROM org_requisito_state WHERE organization_id = ? AND requisito_id = ?',
|
||||
[$orgId, $reqId]
|
||||
);
|
||||
@@ -235,6 +240,8 @@ class FrameworkController extends BaseController
|
||||
'note' => $row['note'],
|
||||
'likelihood' => $row['likelihood'] !== null ? (int) $row['likelihood'] : null,
|
||||
'impact' => $row['impact'] !== null ? (int) $row['impact'] : null,
|
||||
'residual_likelihood' => $row['residual_likelihood'] !== null ? (int) $row['residual_likelihood'] : null,
|
||||
'residual_impact' => $row['residual_impact'] !== null ? (int) $row['residual_impact'] : null,
|
||||
'updated_at' => $row['updated_at'],
|
||||
], 'Valutazione salvata');
|
||||
}
|
||||
|
||||
@@ -69,10 +69,13 @@ class RiskController extends BaseController
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT q.id AS requisito_id, q.n, q.misura_code, q.requisito_code,
|
||||
q.requisito_descr, q.applies_important, q.applies_essential,
|
||||
q.risk_code AS default_risk_code, rk.risk_descr AS default_risk_descr,
|
||||
m.misura_descr, m.nist_code, m.area_politica, m.ord,
|
||||
s.stato, s.valutazione_rischio, s.note, s.likelihood, s.impact
|
||||
s.stato, s.valutazione_rischio, s.note, s.likelihood, s.impact,
|
||||
s.residual_likelihood, s.residual_impact
|
||||
FROM cfg_nis2_requisiti q
|
||||
JOIN cfg_nis2_misure m ON m.misura_code = q.misura_code
|
||||
LEFT JOIN cfg_nis2_rischi rk ON rk.risk_code = q.risk_code
|
||||
LEFT JOIN org_requisito_state s
|
||||
ON s.requisito_id = q.id AND s.organization_id = ?
|
||||
ORDER BY m.ord ASC, q.n ASC, q.id ASC",
|
||||
@@ -100,15 +103,24 @@ class RiskController extends BaseController
|
||||
'requisito_code' => $q['requisito_code'],
|
||||
'comma' => $commaN,
|
||||
'requisito_descr' => $q['requisito_descr'],
|
||||
// RISCHIO(DEFAULT) del framework (cfg_nis2_rischi.risk_descr); fallback = requisito_descr.
|
||||
'risk_descr' => ($q['default_risk_descr'] !== null && $q['default_risk_descr'] !== '')
|
||||
? $q['default_risk_descr'] : $q['requisito_descr'],
|
||||
'applies_important' => (int) $q['applies_important'],
|
||||
'applies_essential' => (int) $q['applies_essential'],
|
||||
'stato' => $q['stato'] ?? 'da_valutare',
|
||||
'valutazione_rischio' => $q['valutazione_rischio'] ?? null,
|
||||
'note' => $q['note'] ?? null,
|
||||
// Rischio INERENTE (prima del trattamento) = likelihood/impact (mig 066)
|
||||
'likelihood' => isset($q['likelihood']) && $q['likelihood'] !== null ? (int) $q['likelihood'] : null,
|
||||
'impact' => isset($q['impact']) && $q['impact'] !== null ? (int) $q['impact'] : null,
|
||||
'score' => (isset($q['likelihood'], $q['impact']) && $q['likelihood'] !== null && $q['impact'] !== null)
|
||||
? (int) $q['likelihood'] * (int) $q['impact'] : null,
|
||||
// Rischio RESIDUO (dopo il trattamento) = residual_* (mig 067)
|
||||
'residual_likelihood' => isset($q['residual_likelihood']) && $q['residual_likelihood'] !== null ? (int) $q['residual_likelihood'] : null,
|
||||
'residual_impact' => isset($q['residual_impact']) && $q['residual_impact'] !== null ? (int) $q['residual_impact'] : null,
|
||||
'residual_score' => (isset($q['residual_likelihood'], $q['residual_impact']) && $q['residual_likelihood'] !== null && $q['residual_impact'] !== null)
|
||||
? (int) $q['residual_likelihood'] * (int) $q['residual_impact'] : null,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -611,4 +623,57 @@ class RiskController extends BaseController
|
||||
{
|
||||
return ($v === null || $v === '') ? null : (float) $v;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/risks/requisitoActions?requisito_id=NN
|
||||
* Azioni collegate al rischio-da-requisito (seme del Modulo Azioni). Piu' recenti prima.
|
||||
*/
|
||||
public function requisitoActionsList(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$reqId = (int) $this->getParam('requisito_id', 0);
|
||||
if ($reqId <= 0) { $this->jsonSuccess(['actions' => []]); return; }
|
||||
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT id, risk_code, description, status, created_at
|
||||
FROM requisito_actions
|
||||
WHERE organization_id = ? AND requisito_id = ?
|
||||
ORDER BY created_at DESC, id DESC',
|
||||
[$orgId, $reqId]
|
||||
);
|
||||
$this->jsonSuccess(['actions' => $rows]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/risks/requisitoActions {requisito_id*, description*, risk_code?}
|
||||
* Crea un'azione collegata al rischio-da-requisito. NON tocca lo stato di conformita'
|
||||
* del requisito (org_requisito_state.stato): la conformita' resta atto di attuazione+evidenza.
|
||||
*/
|
||||
public function requisitoActionsCreate(): void
|
||||
{
|
||||
$this->requireOrgRole(['super_admin', 'org_admin', 'compliance_manager']);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$reqId = (int) $this->getParam('requisito_id', 0);
|
||||
$descr = trim((string) $this->getParam('description', ''));
|
||||
$riskCode = $this->getParam('risk_code');
|
||||
if ($reqId <= 0 || $descr === '') {
|
||||
$this->jsonError('requisito_id e description sono obbligatori', 422);
|
||||
return;
|
||||
}
|
||||
$id = Database::insert('requisito_actions', [
|
||||
'organization_id' => $orgId,
|
||||
'requisito_id' => $reqId,
|
||||
'risk_code' => $riskCode ? substr((string) $riskCode, 0, 64) : null,
|
||||
'description' => $descr,
|
||||
'status' => 'bozza',
|
||||
'created_by' => $this->getCurrentUserId(),
|
||||
]);
|
||||
$this->logAudit('requisito_action_created', 'requisito_action', $id);
|
||||
$row = Database::fetchOne(
|
||||
'SELECT id, risk_code, description, status, created_at FROM requisito_actions WHERE id = ?',
|
||||
[$id]
|
||||
);
|
||||
$this->jsonSuccess(['action' => $row], 'Azione creata');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user