'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. Nota: i codici A.5/A.6/A.7/A.8 seguono ISO/IEC 27001:2022 Annex A e i CLD.* la ISO/IEC 27017; i codici PII.* sono una codifica interna di indice (i numeri di controllo ufficiali della ISO/IEC 27018 differiscono).', ]); } // ════════════════════════ 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 */ 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; } }