[FEAT] Modello Organizzativo SGSI (ISO 27001/27017/27018) + SoA pre-popolato da NIS2
Nuovo modulo guidato in 6 step (cl. 4-10 + Statement of Applicability): - migration 037 (isms_models/roles/soa/documents) + 038 (dataset 111 controlli: 93 Annex A:2022 + 7 CLD/27017 + 11 PII/27018) + runner scripts/migrate-isms.php - IsmsModelController (16 endpoint) registrato in index.php - SoA pre-popolato dalle risposte Gap Analysis NIS2 (mapping iso27001_control) - estensioni cloud condizionali 27017/27018 via flag uses_public_cloud/ is_cloud_provider/processes_pii_in_cloud - AIService::generateIsmsDocument + fonti ISO in nis2_sources.php - frontend isms.html/isms.js + api client + sidebar + help + i18n IT/EN - ingest KB ISO (scope SYSTEM, solo titoli/sintesi: no testo coperto da copyright) - version.json 1.14.0; doc studio + deploy handoff Strumento di supporto/pre-audit (non certificazione). Migration DA APPLICARE su host. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,652 @@
|
||||
<?php
|
||||
/**
|
||||
* NIS2 Agile - Modello Organizzativo SGSI (ISO/IEC 27001:2022)
|
||||
* ----------------------------------------------------------------------------
|
||||
* Procedura guidata per costruire un Sistema di Gestione della Sicurezza delle
|
||||
* Informazioni (SGSI/ISMS) secondo ISO/IEC 27001:2022 (clausole 4-10) +
|
||||
* Statement of Applicability (SoA) sui controlli Annex A:2022, estendibile con
|
||||
* i controlli cloud ISO/IEC 27017:2015 e privacy-cloud ISO/IEC 27018:2019.
|
||||
*
|
||||
* Valore chiave: il SoA viene PRE-POPOLATO dalle risposte dell'assessment NIS2
|
||||
* Art.21 (ogni domanda del questionario porta gia un iso27001_control), cosi un
|
||||
* assessment NIS2 esistente produce una prima bozza di SoA.
|
||||
*
|
||||
* NON sostituisce la valutazione di un auditor: e uno strumento di supporto /
|
||||
* pre-audit. Le bozze AI riportano sempre un disclaimer.
|
||||
*
|
||||
* Endpoint (base /api/isms):
|
||||
* GET /model - SGSI dell'org (null se non iniziato)
|
||||
* POST /model - crea/aggiorna SGSI (upsert) — clausole 4-6
|
||||
* PUT /model - alias di POST (salva step)
|
||||
* GET /annex-controls - dataset controlli applicabili (filtrato per flag cloud/PII)
|
||||
* GET /soa - Statement of Applicability (auto-derivato al 1o accesso)
|
||||
* POST /soa/derive - (ri)deriva lo stato iniziale dal NIS2
|
||||
* PUT /soa - aggiorna un controllo del SoA (body: control_code, ...)
|
||||
* GET /roles - ruoli/RACI del SGSI
|
||||
* POST /roles - crea ruolo
|
||||
* DELETE /roles/{id} - elimina ruolo
|
||||
* GET /documents - documented information
|
||||
* POST /documents - crea documento
|
||||
* POST /documents/ai-generate - genera bozza documento via AI
|
||||
* PUT /documents/{id} - aggiorna documento
|
||||
* GET /readiness - % completamento SGSI + checklist clausole 4-10
|
||||
* GET /export - export completo (model + SoA + ruoli + documenti)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
|
||||
class IsmsModelController extends BaseController
|
||||
{
|
||||
private const STANDARD_LABELS = [
|
||||
'iso27001' => 'ISO/IEC 27001:2022 Annex A',
|
||||
'iso27017' => 'ISO/IEC 27017:2015 (cloud)',
|
||||
'iso27018' => 'ISO/IEC 27018:2019 (PII in cloud)',
|
||||
];
|
||||
|
||||
// ════════════════════════ MODEL ════════════════════════
|
||||
|
||||
/** GET /api/isms/model */
|
||||
public function getModel(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$m = $this->loadModel();
|
||||
if ($m) {
|
||||
$m['interested_parties'] = $m['interested_parties'] ? json_decode($m['interested_parties'], true) : [];
|
||||
$m['isms_objectives'] = $m['isms_objectives'] ? json_decode($m['isms_objectives'], true) : [];
|
||||
}
|
||||
$this->jsonSuccess(['model' => $m]);
|
||||
}
|
||||
|
||||
/**
|
||||
* POST/PUT /api/isms/model
|
||||
* Upsert del SGSI dell'org (una riga per org). Salva i campi inviati;
|
||||
* i campi assenti non vengono toccati (salvataggio per-step).
|
||||
*/
|
||||
public function saveModel(): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$userId = $this->getCurrentUserId();
|
||||
$body = $this->getJsonBody();
|
||||
|
||||
// Whitelist campi testuali/flag.
|
||||
$fields = [];
|
||||
foreach (['scope_statement','context_internal','context_external','boundaries','exclusions','risk_methodology'] as $k) {
|
||||
if (array_key_exists($k, $body)) {
|
||||
$fields[$k] = $body[$k] !== null ? (string) $body[$k] : null;
|
||||
}
|
||||
}
|
||||
foreach (['interested_parties','isms_objectives'] as $k) {
|
||||
if (array_key_exists($k, $body)) {
|
||||
$fields[$k] = json_encode($body[$k] ?? [], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
}
|
||||
foreach (['uses_public_cloud','is_cloud_provider','processes_pii_in_cloud'] as $k) {
|
||||
if (array_key_exists($k, $body)) {
|
||||
$fields[$k] = !empty($body[$k]) ? 1 : 0;
|
||||
}
|
||||
}
|
||||
if (array_key_exists('status', $body) && in_array($body['status'], ['draft','active','under_review'], true)) {
|
||||
$fields['status'] = $body['status'];
|
||||
}
|
||||
|
||||
$existing = $this->loadModel();
|
||||
if ($existing) {
|
||||
if (!empty($fields)) {
|
||||
Database::update('isms_models', $fields, 'id = ?', [$existing['id']]);
|
||||
}
|
||||
$modelId = (int) $existing['id'];
|
||||
$this->logAudit('isms_model_updated', 'isms_model', $modelId, array_keys($fields));
|
||||
} else {
|
||||
$fields['organization_id'] = $orgId;
|
||||
$fields['created_by'] = $userId;
|
||||
$fields['status'] = $fields['status'] ?? 'draft';
|
||||
$modelId = Database::insert('isms_models', $fields);
|
||||
$this->logAudit('isms_model_created', 'isms_model', $modelId, null);
|
||||
}
|
||||
|
||||
$this->jsonSuccess(['id' => $modelId], 'SGSI salvato');
|
||||
}
|
||||
|
||||
// ════════════════════════ ANNEX CONTROLS ════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/isms/annex-controls
|
||||
* Dataset di riferimento, filtrato in base ai flag cloud/PII del modello:
|
||||
* i controlli condizionali (27017/27018) compaiono solo se pertinenti.
|
||||
*/
|
||||
public function annexControls(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$m = $this->loadModel();
|
||||
$standards = $this->applicableStandards($m);
|
||||
|
||||
$place = implode(',', array_fill(0, count($standards), '?'));
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT control_code, standard, theme, title_it, title_en, iso27002_ref, condition_tag
|
||||
FROM iso27001_annex_controls
|
||||
WHERE standard IN ($place)
|
||||
ORDER BY sort_order",
|
||||
$standards
|
||||
);
|
||||
$this->jsonSuccess([
|
||||
'standards' => array_map(fn($s) => ['key' => $s, 'label' => self::STANDARD_LABELS[$s] ?? $s], $standards),
|
||||
'controls' => $rows,
|
||||
'total' => count($rows),
|
||||
]);
|
||||
}
|
||||
|
||||
// ════════════════════════ SoA ════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/isms/soa
|
||||
* Restituisce il SoA raggruppato per standard -> tema. Se vuoto, lo deriva
|
||||
* automaticamente dal NIS2 al primo accesso.
|
||||
*/
|
||||
public function getSoa(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$model = $this->requireModel();
|
||||
|
||||
$count = Database::count('isms_soa', 'isms_model_id = ?', [$model['id']]);
|
||||
if ($count === 0) {
|
||||
$this->doDerive($model);
|
||||
}
|
||||
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT s.control_code, s.standard, s.applicable, s.justification_inclusion, s.justification_exclusion,
|
||||
s.implementation_status, s.implementation_pct, s.derived_from_nis2, s.source_ref,
|
||||
c.title_it, c.title_en, c.theme, c.condition_tag, c.sort_order
|
||||
FROM isms_soa s
|
||||
LEFT JOIN iso27001_annex_controls c ON c.control_code = s.control_code
|
||||
WHERE s.isms_model_id = ?
|
||||
ORDER BY c.sort_order, s.control_code",
|
||||
[$model['id']]
|
||||
);
|
||||
|
||||
$byStd = [];
|
||||
foreach ($rows as $r) {
|
||||
$std = $r['standard'];
|
||||
$byStd[$std] ??= ['standard' => $std, 'label' => self::STANDARD_LABELS[$std] ?? $std, 'controls' => []];
|
||||
$byStd[$std]['controls'][] = $r;
|
||||
}
|
||||
|
||||
$this->jsonSuccess([
|
||||
'model_id' => (int) $model['id'],
|
||||
'groups' => array_values($byStd),
|
||||
'stats' => $this->soaStats($model['id']),
|
||||
]);
|
||||
}
|
||||
|
||||
/** POST /api/isms/soa/derive — (ri)deriva lo stato iniziale dal NIS2. */
|
||||
public function deriveSoa(): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
$model = $this->requireModel();
|
||||
$added = $this->doDerive($model);
|
||||
$this->logAudit('isms_soa_derived', 'isms_model', (int) $model['id'], ['added' => $added]);
|
||||
$this->jsonSuccess(['added' => $added, 'stats' => $this->soaStats($model['id'])], 'SoA derivato dal NIS2');
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT /api/isms/soa
|
||||
* Body: { control_code, applicable?, justification_inclusion?, justification_exclusion?,
|
||||
* implementation_status?, implementation_pct? }
|
||||
*/
|
||||
public function updateSoaControl(): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
|
||||
$model = $this->requireModel();
|
||||
$body = $this->getJsonBody();
|
||||
|
||||
$code = trim((string) ($body['control_code'] ?? ''));
|
||||
if ($code === '') {
|
||||
$this->jsonError('control_code mancante', 400, 'MISSING_CODE');
|
||||
}
|
||||
$row = Database::fetchOne('SELECT id FROM isms_soa WHERE isms_model_id = ? AND control_code = ?', [$model['id'], $code]);
|
||||
if (!$row) {
|
||||
$this->jsonError('Controllo non presente nel SoA', 404, 'NOT_FOUND');
|
||||
}
|
||||
|
||||
$fields = ['updated_by' => $this->getCurrentUserId()];
|
||||
if (array_key_exists('applicable', $body)) {
|
||||
$fields['applicable'] = !empty($body['applicable']) ? 1 : 0;
|
||||
}
|
||||
foreach (['justification_inclusion','justification_exclusion'] as $k) {
|
||||
if (array_key_exists($k, $body)) {
|
||||
$fields[$k] = $body[$k] !== null ? (string) $body[$k] : null;
|
||||
}
|
||||
}
|
||||
if (isset($body['implementation_status']) && in_array($body['implementation_status'], ['not_started','in_progress','implemented','verified'], true)) {
|
||||
$fields['implementation_status'] = $body['implementation_status'];
|
||||
}
|
||||
if (array_key_exists('implementation_pct', $body)) {
|
||||
$fields['implementation_pct'] = max(0, min(100, (int) $body['implementation_pct']));
|
||||
}
|
||||
|
||||
Database::update('isms_soa', $fields, 'id = ?', [$row['id']]);
|
||||
$this->jsonSuccess(['stats' => $this->soaStats($model['id'])], 'Controllo aggiornato');
|
||||
}
|
||||
|
||||
// ════════════════════════ ROLES ════════════════════════
|
||||
|
||||
/** GET /api/isms/roles */
|
||||
public function listRoles(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$model = $this->requireModel();
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT r.id, r.role_name, r.user_id, r.responsibility, r.raci,
|
||||
u.full_name AS user_name
|
||||
FROM isms_roles r
|
||||
LEFT JOIN users u ON u.id = r.user_id
|
||||
WHERE r.isms_model_id = ? ORDER BY r.id",
|
||||
[$model['id']]
|
||||
);
|
||||
$this->jsonSuccess(['roles' => $rows]);
|
||||
}
|
||||
|
||||
/** POST /api/isms/roles Body: { role_name, user_id?, responsibility?, raci? } */
|
||||
public function saveRole(): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
$model = $this->requireModel();
|
||||
$body = $this->getJsonBody();
|
||||
|
||||
$name = trim((string) ($body['role_name'] ?? ''));
|
||||
if ($name === '') {
|
||||
$this->jsonError('role_name obbligatorio', 400, 'MISSING_ROLE_NAME');
|
||||
}
|
||||
$raci = (string) ($body['raci'] ?? '');
|
||||
$data = [
|
||||
'isms_model_id' => $model['id'],
|
||||
'organization_id' => $this->getCurrentOrgId(),
|
||||
'role_name' => $name,
|
||||
'user_id' => !empty($body['user_id']) ? (int) $body['user_id'] : null,
|
||||
'responsibility' => isset($body['responsibility']) ? (string) $body['responsibility'] : null,
|
||||
'raci' => in_array($raci, ['R','A','C','I'], true) ? $raci : null,
|
||||
];
|
||||
$id = Database::insert('isms_roles', $data);
|
||||
$this->jsonSuccess(['id' => $id], 'Ruolo aggiunto', 201);
|
||||
}
|
||||
|
||||
/** DELETE /api/isms/roles/{id} */
|
||||
public function deleteRole(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
$model = $this->requireModel();
|
||||
Database::delete('isms_roles', 'id = ? AND isms_model_id = ?', [$id, $model['id']]);
|
||||
$this->jsonSuccess(null, 'Ruolo eliminato');
|
||||
}
|
||||
|
||||
// ════════════════════════ DOCUMENTS ════════════════════════
|
||||
|
||||
/** GET /api/isms/documents */
|
||||
public function listDocuments(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$model = $this->requireModel();
|
||||
$rows = Database::fetchAll(
|
||||
"SELECT id, doc_type, title, status, ai_generated, version, updated_at
|
||||
FROM isms_documents WHERE isms_model_id = ? ORDER BY updated_at DESC",
|
||||
[$model['id']]
|
||||
);
|
||||
$this->jsonSuccess(['documents' => $rows]);
|
||||
}
|
||||
|
||||
/** POST /api/isms/documents Body: { doc_type, title, body_html?, status? } */
|
||||
public function createDocument(): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
$model = $this->requireModel();
|
||||
$body = $this->getJsonBody();
|
||||
|
||||
$this->validateRequired(['doc_type', 'title']);
|
||||
$id = Database::insert('isms_documents', [
|
||||
'isms_model_id' => $model['id'],
|
||||
'organization_id' => $this->getCurrentOrgId(),
|
||||
'doc_type' => (string) $body['doc_type'],
|
||||
'title' => (string) $body['title'],
|
||||
'body_html' => isset($body['body_html']) ? (string) $body['body_html'] : null,
|
||||
'status' => in_array($body['status'] ?? '', ['draft','review','approved'], true) ? $body['status'] : 'draft',
|
||||
'ai_generated' => !empty($body['ai_generated']) ? 1 : 0,
|
||||
'created_by' => $this->getCurrentUserId(),
|
||||
]);
|
||||
$this->jsonSuccess(['id' => $id], 'Documento creato', 201);
|
||||
}
|
||||
|
||||
/** PUT /api/isms/documents/{id} */
|
||||
public function updateDocument(int $id): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
$model = $this->requireModel();
|
||||
$body = $this->getJsonBody();
|
||||
$row = Database::fetchOne('SELECT id FROM isms_documents WHERE id = ? AND isms_model_id = ?', [$id, $model['id']]);
|
||||
if (!$row) {
|
||||
$this->jsonError('Documento non trovato', 404, 'NOT_FOUND');
|
||||
}
|
||||
$fields = [];
|
||||
foreach (['title','body_html'] as $k) {
|
||||
if (array_key_exists($k, $body)) $fields[$k] = (string) $body[$k];
|
||||
}
|
||||
if (isset($body['status']) && in_array($body['status'], ['draft','review','approved'], true)) {
|
||||
$fields['status'] = $body['status'];
|
||||
}
|
||||
if (!empty($fields)) {
|
||||
Database::update('isms_documents', $fields, 'id = ?', [$id]);
|
||||
}
|
||||
$this->jsonSuccess(['id' => $id], 'Documento aggiornato');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/isms/documents/ai-generate
|
||||
* Body: { doc_type, title? } - genera una bozza con AI (grounding fonti certe).
|
||||
*/
|
||||
public function aiGenerateDocument(): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
$model = $this->requireModel();
|
||||
$body = $this->getJsonBody();
|
||||
$docType = trim((string) ($body['doc_type'] ?? ''));
|
||||
if ($docType === '') {
|
||||
$this->jsonError('doc_type obbligatorio', 400, 'MISSING_DOC_TYPE');
|
||||
}
|
||||
|
||||
$org = Database::fetchOne('SELECT sector, entity_type, employee_count FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
|
||||
require_once APP_PATH . '/services/AIService.php';
|
||||
$ai = new AIService();
|
||||
|
||||
try {
|
||||
$ctx = [
|
||||
'scope' => $model['scope_statement'] ?? null,
|
||||
'methodology' => $model['risk_methodology'] ?? null,
|
||||
'uses_cloud' => (bool) ($model['uses_public_cloud'] || $model['is_cloud_provider']),
|
||||
'pii_in_cloud' => (bool) $model['processes_pii_in_cloud'],
|
||||
];
|
||||
$doc = $ai->generateIsmsDocument($docType, $org ?: [], $ctx);
|
||||
} catch (Throwable $e) {
|
||||
error_log('[ISMS] aiGenerateDocument fallita: ' . $e->getMessage());
|
||||
$this->jsonError('Generazione AI temporaneamente non disponibile. Riprova piu tardi.', 503, 'AI_UNAVAILABLE');
|
||||
}
|
||||
|
||||
$title = trim((string) ($body['title'] ?? ($doc['title'] ?? $docType)));
|
||||
$id = Database::insert('isms_documents', [
|
||||
'isms_model_id' => $model['id'],
|
||||
'organization_id' => $this->getCurrentOrgId(),
|
||||
'doc_type' => $docType,
|
||||
'title' => $title,
|
||||
'body_html' => (string) ($doc['body_html'] ?? $doc['content'] ?? ''),
|
||||
'status' => 'draft',
|
||||
'ai_generated' => 1,
|
||||
'created_by' => $this->getCurrentUserId(),
|
||||
]);
|
||||
$this->logAudit('isms_document_ai_generated', 'isms_document', $id, ['doc_type' => $docType]);
|
||||
$this->jsonSuccess([
|
||||
'id' => $id,
|
||||
'title' => $title,
|
||||
'body_html' => (string) ($doc['body_html'] ?? $doc['content'] ?? ''),
|
||||
'disclaimer' => 'Bozza generata dall\'AI: revisione umana obbligatoria prima dell\'approvazione.',
|
||||
], 'Bozza generata');
|
||||
}
|
||||
|
||||
// ════════════════════════ READINESS / EXPORT ════════════════════════
|
||||
|
||||
/** GET /api/isms/readiness — checklist clausole 4-10 + % complessiva. */
|
||||
public function readiness(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$model = $this->loadModel();
|
||||
if (!$model) {
|
||||
$this->jsonSuccess(['started' => false, 'overall_pct' => 0, 'checklist' => []]);
|
||||
}
|
||||
|
||||
$rolesCount = Database::count('isms_roles', 'isms_model_id = ?', [$model['id']]);
|
||||
$docsCount = Database::count('isms_documents', 'isms_model_id = ?', [$model['id']]);
|
||||
$soa = $this->soaStats($model['id']);
|
||||
$soaAnswered = $soa['total'] > 0 ? ($soa['total'] - $soa['not_started']) : 0;
|
||||
|
||||
$checklist = [
|
||||
['clause' => '4', 'label' => 'Contesto e ambito', 'done' => !empty($model['scope_statement'])],
|
||||
['clause' => '5', 'label' => 'Leadership: policy e ruoli (RACI)', 'done' => $rolesCount > 0],
|
||||
['clause' => '6', 'label' => 'Risk: metodologia e obiettivi', 'done' => !empty($model['risk_methodology'])],
|
||||
['clause' => 'SoA', 'label' => 'Statement of Applicability avviato', 'done' => $soa['total'] > 0],
|
||||
['clause' => '7-8', 'label' => 'Documented information', 'done' => $docsCount > 0],
|
||||
['clause' => '9-10', 'label' => 'Monitoraggio e miglioramento (audit/NCR esistenti)', 'done' => $soaAnswered > 0],
|
||||
];
|
||||
$done = count(array_filter($checklist, fn($c) => $c['done']));
|
||||
$overall = (int) round($done / count($checklist) * 100);
|
||||
|
||||
$this->jsonSuccess([
|
||||
'started' => true,
|
||||
'overall_pct' => $overall,
|
||||
'checklist' => $checklist,
|
||||
'soa' => $soa,
|
||||
'roles_count' => $rolesCount,
|
||||
'docs_count' => $docsCount,
|
||||
]);
|
||||
}
|
||||
|
||||
/** GET /api/isms/export — model + SoA + ruoli + documenti (per stampa). */
|
||||
public function export(): void
|
||||
{
|
||||
$this->requireOrgAccess();
|
||||
$model = $this->requireModel();
|
||||
$org = Database::fetchOne('SELECT name, sector, entity_type FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
|
||||
|
||||
$model['interested_parties'] = $model['interested_parties'] ? json_decode($model['interested_parties'], true) : [];
|
||||
$model['isms_objectives'] = $model['isms_objectives'] ? json_decode($model['isms_objectives'], true) : [];
|
||||
|
||||
$soa = Database::fetchAll(
|
||||
"SELECT s.control_code, s.standard, s.applicable, s.implementation_status, s.implementation_pct,
|
||||
s.justification_inclusion, s.justification_exclusion, s.derived_from_nis2, s.source_ref,
|
||||
c.title_it
|
||||
FROM isms_soa s LEFT JOIN iso27001_annex_controls c ON c.control_code = s.control_code
|
||||
WHERE s.isms_model_id = ? ORDER BY c.sort_order",
|
||||
[$model['id']]
|
||||
);
|
||||
$roles = Database::fetchAll('SELECT role_name, responsibility, raci FROM isms_roles WHERE isms_model_id = ? ORDER BY id', [$model['id']]);
|
||||
$docs = Database::fetchAll('SELECT doc_type, title, status, version FROM isms_documents WHERE isms_model_id = ? ORDER BY id', [$model['id']]);
|
||||
|
||||
$this->logAudit('isms_export', 'isms_model', (int) $model['id'], null);
|
||||
$this->jsonSuccess([
|
||||
'organization' => $org,
|
||||
'model' => $model,
|
||||
'soa' => $soa,
|
||||
'roles' => $roles,
|
||||
'documents' => $docs,
|
||||
'stats' => $this->soaStats($model['id']),
|
||||
'generated_at' => date('c'),
|
||||
'disclaimer' => 'Documento di supporto/pre-audit. Non costituisce certificazione ISO 27001 ne parere professionale vincolante.',
|
||||
]);
|
||||
}
|
||||
|
||||
// ════════════════════════ HELPER ════════════════════════
|
||||
|
||||
private function loadModel(): ?array
|
||||
{
|
||||
return Database::fetchOne('SELECT * FROM isms_models WHERE organization_id = ?', [$this->getCurrentOrgId()]);
|
||||
}
|
||||
|
||||
private function requireModel(): array
|
||||
{
|
||||
$m = $this->loadModel();
|
||||
if (!$m) {
|
||||
$this->jsonError('SGSI non ancora avviato. Completa prima il passo Contesto e Ambito.', 422, 'ISMS_NOT_STARTED');
|
||||
}
|
||||
return $m;
|
||||
}
|
||||
|
||||
/** Standard applicabili in base ai flag del modello. */
|
||||
private function applicableStandards(?array $model): array
|
||||
{
|
||||
$standards = ['iso27001'];
|
||||
if ($model && ($model['uses_public_cloud'] || $model['is_cloud_provider'])) {
|
||||
$standards[] = 'iso27017';
|
||||
}
|
||||
if ($model && $model['processes_pii_in_cloud']) {
|
||||
$standards[] = 'iso27018';
|
||||
}
|
||||
return $standards;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deriva/integra il SoA: inserisce i controlli applicabili mancanti,
|
||||
* pre-compilando stato e motivazione dalle risposte NIS2 dove possibile.
|
||||
* INSERT IGNORE => non sovrascrive il lavoro manuale gia presente.
|
||||
* @return int controlli aggiunti
|
||||
*/
|
||||
private function doDerive(array $model): int
|
||||
{
|
||||
$standards = $this->applicableStandards($model);
|
||||
$place = implode(',', array_fill(0, count($standards), '?'));
|
||||
$controls = Database::fetchAll(
|
||||
"SELECT control_code, standard FROM iso27001_annex_controls WHERE standard IN ($place) ORDER BY sort_order",
|
||||
$standards
|
||||
);
|
||||
|
||||
$nis2 = $this->nis2ControlStatus(); // [iso_control_code => ['status'=>..,'pct'=>..,'sources'=>[..]]]
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$userId = $this->getCurrentUserId();
|
||||
|
||||
$existing = array_column(
|
||||
Database::fetchAll('SELECT control_code FROM isms_soa WHERE isms_model_id = ?', [$model['id']]),
|
||||
'control_code'
|
||||
);
|
||||
$existing = array_flip($existing);
|
||||
|
||||
$added = 0;
|
||||
foreach ($controls as $c) {
|
||||
$code = $c['control_code'];
|
||||
if (isset($existing[$code])) {
|
||||
continue;
|
||||
}
|
||||
$d = $nis2[$code] ?? null;
|
||||
$row = [
|
||||
'isms_model_id' => $model['id'],
|
||||
'organization_id' => $orgId,
|
||||
'control_code' => $code,
|
||||
'standard' => $c['standard'],
|
||||
'applicable' => 1,
|
||||
'updated_by' => $userId,
|
||||
];
|
||||
if ($d) {
|
||||
$row['implementation_status'] = $d['status'];
|
||||
$row['implementation_pct'] = $d['pct'];
|
||||
$row['derived_from_nis2'] = 1;
|
||||
$row['source_ref'] = implode(', ', array_slice($d['sources'], 0, 4));
|
||||
$row['justification_inclusion'] = 'Applicabile: collegato alle misure NIS2 ' . $row['source_ref']
|
||||
. '. Stato derivato dall\'assessment Art.21 (da confermare).';
|
||||
}
|
||||
// INSERT IGNORE manuale tramite query (Database::insert non supporta IGNORE).
|
||||
$cols = implode(',', array_keys($row));
|
||||
$ph = implode(',', array_fill(0, count($row), '?'));
|
||||
Database::query("INSERT IGNORE INTO isms_soa ($cols) VALUES ($ph)", array_values($row));
|
||||
$added++;
|
||||
}
|
||||
return $added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggrega lo stato dei controlli ISO 27001 a partire dalle risposte NIS2.
|
||||
* Usa l'ultimo assessment dell'org e il mapping question->iso27001_control
|
||||
* presente nel questionario.
|
||||
* @return array<string,array{status:string,pct:int,sources:array}>
|
||||
*/
|
||||
private function nis2ControlStatus(): array
|
||||
{
|
||||
$orgId = $this->getCurrentOrgId();
|
||||
$assessment = Database::fetchOne(
|
||||
'SELECT id FROM assessments WHERE organization_id = ? ORDER BY created_at DESC LIMIT 1',
|
||||
[$orgId]
|
||||
);
|
||||
if (!$assessment) {
|
||||
return [];
|
||||
}
|
||||
$responses = Database::fetchAll(
|
||||
'SELECT question_code, response_value FROM assessment_responses WHERE assessment_id = ?',
|
||||
[$assessment['id']]
|
||||
);
|
||||
if (empty($responses)) {
|
||||
return [];
|
||||
}
|
||||
$respByCode = [];
|
||||
foreach ($responses as $r) {
|
||||
$respByCode[$r['question_code']] = $r['response_value'];
|
||||
}
|
||||
|
||||
// mappa question_code -> iso27001_control + nis2_article dal questionario
|
||||
$q = $this->questionnaire();
|
||||
$pctVal = ['implemented' => 100, 'partial' => 50, 'not_implemented' => 0];
|
||||
|
||||
$agg = []; // iso_code => ['sum'=>,'cnt'=>,'sources'=>[]]
|
||||
foreach ($q['categories'] ?? [] as $cat) {
|
||||
foreach ($cat['questions'] ?? [] as $question) {
|
||||
$iso = $question['iso27001_control'] ?? null;
|
||||
$code = $question['code'] ?? null;
|
||||
if (!$iso || !$code || !isset($respByCode[$code])) {
|
||||
continue;
|
||||
}
|
||||
$resp = $respByCode[$code];
|
||||
if ($resp === 'not_applicable' || $resp === null || !isset($pctVal[$resp])) {
|
||||
continue;
|
||||
}
|
||||
$agg[$iso] ??= ['sum' => 0, 'cnt' => 0, 'sources' => []];
|
||||
$agg[$iso]['sum'] += $pctVal[$resp];
|
||||
$agg[$iso]['cnt']++;
|
||||
$art = $question['nis2_article'] ?? '';
|
||||
$src = 'Art.' . $art . ' (' . $code . ')';
|
||||
if (!in_array($src, $agg[$iso]['sources'], true)) {
|
||||
$agg[$iso]['sources'][] = $src;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($agg as $iso => $a) {
|
||||
$pct = $a['cnt'] > 0 ? (int) round($a['sum'] / $a['cnt']) : 0;
|
||||
$status = $pct >= 100 ? 'implemented' : ($pct > 0 ? 'in_progress' : 'not_started');
|
||||
$out[$iso] = ['status' => $status, 'pct' => $pct, 'sources' => $a['sources']];
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
private ?array $questionnaireCache = null;
|
||||
private function questionnaire(): array
|
||||
{
|
||||
if ($this->questionnaireCache === null) {
|
||||
$path = APP_PATH . '/data/nis2_questionnaire.json';
|
||||
$json = is_readable($path) ? json_decode((string) file_get_contents($path), true) : null;
|
||||
$this->questionnaireCache = is_array($json) ? $json : ['categories' => []];
|
||||
}
|
||||
return $this->questionnaireCache;
|
||||
}
|
||||
|
||||
private function soaStats(int $modelId): array
|
||||
{
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT applicable, implementation_status, COUNT(*) AS n
|
||||
FROM isms_soa WHERE isms_model_id = ? GROUP BY applicable, implementation_status',
|
||||
[$modelId]
|
||||
);
|
||||
$s = ['total' => 0, 'applicable' => 0, 'excluded' => 0,
|
||||
'not_started' => 0, 'in_progress' => 0, 'implemented' => 0, 'verified' => 0];
|
||||
foreach ($rows as $r) {
|
||||
$n = (int) $r['n'];
|
||||
$s['total'] += $n;
|
||||
if ((int) $r['applicable'] === 1) {
|
||||
$s['applicable'] += $n;
|
||||
$st = $r['implementation_status'];
|
||||
if (isset($s[$st])) $s[$st] += $n;
|
||||
} else {
|
||||
$s['excluded'] += $n;
|
||||
}
|
||||
}
|
||||
// % implementazione media sui controlli applicabili
|
||||
$impl = Database::fetchOne(
|
||||
'SELECT AVG(implementation_pct) AS avg_pct FROM isms_soa WHERE isms_model_id = ? AND applicable = 1',
|
||||
[$modelId]
|
||||
);
|
||||
$s['avg_implementation_pct'] = $impl && $impl['avg_pct'] !== null ? (int) round($impl['avg_pct']) : 0;
|
||||
return $s;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user