'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 d.id, d.doc_type, d.title, d.status, d.ai_generated, d.version, d.updated_at, d.approved_at, d.published_at, d.next_review_date, d.effective_date, d.review_note, ua.full_name AS approved_by_name, up.full_name AS published_by_name FROM isms_documents d LEFT JOIN users ua ON ua.id = d.approved_by LEFT JOIN users up ON up.id = d.published_by WHERE d.isms_model_id = ? ORDER BY d.id", [$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->requireDocCapability('edit'); $model = $this->requireModel(); $body = $this->getJsonBody(); $row = Database::fetchOne('SELECT id, status FROM isms_documents WHERE id = ? AND isms_model_id = ?', [$id, $model['id']]); if (!$row) { $this->jsonError('Documento non trovato', 404, 'NOT_FOUND'); } if (array_key_exists('body_html', $body) && !in_array($row['status'], ['draft', 'review'], true)) { $this->jsonError('Il contenuto è modificabile solo in stato Bozza o In revisione. Crea una nuova versione per modificare un documento pubblicato.', 409, 'NOT_EDITABLE'); } $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'); } /* ───────── Ciclo di vita documentale (ISO 27001 cl.7.5) ───────── */ private function requireDocument(int $id): array { $model = $this->requireModel(); $doc = Database::fetchOne('SELECT * FROM isms_documents WHERE id = ? AND isms_model_id = ?', [$id, $model['id']]); if (!$doc) { $this->jsonError('Documento non trovato', 404, 'NOT_FOUND'); } return $doc; } private function bumpVersion(string $v, bool $major = false): string { $parts = explode('.', preg_replace('/[^0-9.]/', '', $v ?: '1.0')); $maj = (int) ($parts[0] ?? 1); $min = (int) ($parts[1] ?? 0); if ($major) { $maj++; $min = 0; } else { $min++; } return $maj . '.' . $min; } private function snapshotDocument(array $doc, string $changeNote): void { Database::insert('isms_document_versions', [ 'document_id' => $doc['id'], 'isms_model_id' => $doc['isms_model_id'], 'organization_id' => $doc['organization_id'], 'version' => $doc['version'] ?? '1.0', 'status' => $doc['status'], 'body_html' => $doc['body_html'] ?? null, 'change_note' => $changeNote, 'created_by' => $this->getCurrentUserId(), 'created_at' => date('Y-m-d H:i:s'), ]); } /* ── Permessi documentali profilabili per ruolo (Impostazioni > Permessi documentali) ── */ private const DOC_CAPS = ['edit', 'submit', 'approve', 'publish', 'reject', 'archive', 'new_version']; private const DOC_ROLES = ['org_admin', 'compliance_manager', 'board_member', 'auditor', 'employee', 'consultant']; private const DOC_CAP_DEFAULTS = [ 'edit' => ['org_admin', 'compliance_manager', 'board_member'], 'submit' => ['org_admin', 'compliance_manager', 'board_member'], 'approve' => ['org_admin', 'compliance_manager', 'board_member'], 'publish' => ['org_admin', 'compliance_manager', 'board_member'], 'reject' => ['org_admin', 'compliance_manager', 'board_member'], 'archive' => ['org_admin', 'compliance_manager', 'board_member'], 'new_version' => ['org_admin', 'compliance_manager', 'board_member'], ]; private function docCapabilityAllowed(string $cap, string $role): bool { $row = Database::fetchOne( 'SELECT allowed FROM isms_doc_permissions WHERE organization_id = ? AND capability = ? AND role = ?', [$this->getCurrentOrgId(), $cap, $role]); if (is_array($row) && array_key_exists('allowed', $row)) { return (bool) $row['allowed']; } return in_array($role, self::DOC_CAP_DEFAULTS[$cap] ?? [], true); } private function requireDocCapability(string $cap): void { $this->requireOrgAccess(); if ($this->isDemo) { return; } if ($this->currentOrgRole === 'super_admin') { return; } if (!$this->docCapabilityAllowed($cap, (string) $this->currentOrgRole)) { $this->jsonError('Permesso negato per questa azione sui documenti. Configurabile in Impostazioni > Permessi documentali.', 403, 'DOC_FORBIDDEN'); } } /** POST /api/isms/documents/{id}/submit bozza -> in revisione */ public function submitDocument(int $id): void { $this->requireDocCapability('submit'); $doc = $this->requireDocument($id); if ($doc['status'] !== 'draft') { $this->jsonError('Solo una bozza può essere inviata in revisione', 409, 'BAD_STATE'); } Database::update('isms_documents', ['status' => 'review', 'review_note' => null, 'updated_at' => date('Y-m-d H:i:s')], 'id = ?', [$id]); $this->logAudit('isms_document_submitted', 'isms_document', $id, []); $this->jsonSuccess(['id' => $id, 'status' => 'review'], 'Documento inviato in revisione'); } /** POST /api/isms/documents/{id}/approve in revisione -> approvato */ public function approveDocument(int $id): void { $this->requireDocCapability('approve'); $doc = $this->requireDocument($id); if (!in_array($doc['status'], ['review', 'draft'], true)) { $this->jsonError('Stato non valido per l\'approvazione', 409, 'BAD_STATE'); } $now = date('Y-m-d H:i:s'); Database::update('isms_documents', [ 'status' => 'approved', 'approved_by' => $this->getCurrentUserId(), 'approved_at' => $now, 'reviewed_by' => $this->getCurrentUserId(), 'reviewed_at' => $now, 'review_note' => null, 'updated_at' => $now, ], 'id = ?', [$id]); $this->logAudit('isms_document_approved', 'isms_document', $id, ['version' => $doc['version']]); $this->jsonSuccess(['id' => $id, 'status' => 'approved'], 'Documento approvato'); } /** POST /api/isms/documents/{id}/publish approvato -> pubblicato (in vigore) */ public function publishDocument(int $id): void { $this->requireDocCapability('publish'); $doc = $this->requireDocument($id); if ($doc['status'] !== 'approved') { $this->jsonError('Solo un documento approvato può essere pubblicato', 409, 'BAD_STATE'); } $now = date('Y-m-d H:i:s'); $today = date('Y-m-d'); $nextReview = date('Y-m-d', strtotime('+1 year')); $this->snapshotDocument(array_merge($doc, ['status' => 'published']), 'Pubblicazione versione ' . ($doc['version'] ?? '1.0')); Database::update('isms_documents', [ 'status' => 'published', 'published_by' => $this->getCurrentUserId(), 'published_at' => $now, 'effective_date' => $today, 'next_review_date' => $nextReview, 'updated_at' => $now, ], 'id = ?', [$id]); $this->logAudit('isms_document_published', 'isms_document', $id, ['version' => $doc['version']]); $this->jsonSuccess(['id' => $id, 'status' => 'published'], 'Documento pubblicato (in vigore)'); } /** POST /api/isms/documents/{id}/reject in revisione -> bozza (con nota) */ public function rejectDocument(int $id): void { $this->requireDocCapability('reject'); $doc = $this->requireDocument($id); if ($doc['status'] !== 'review') { $this->jsonError('Solo un documento in revisione può essere rimandato in bozza', 409, 'BAD_STATE'); } $note = trim((string) ($this->getJsonBody()['note'] ?? '')); Database::update('isms_documents', ['status' => 'draft', 'review_note' => ($note !== '' ? $note : 'Rimandato in bozza dal revisore'), 'updated_at' => date('Y-m-d H:i:s')], 'id = ?', [$id]); $this->logAudit('isms_document_rejected', 'isms_document', $id, ['note' => $note]); $this->jsonSuccess(['id' => $id, 'status' => 'draft'], 'Documento rimandato in bozza'); } /** POST /api/isms/documents/{id}/archive pubblicato -> archiviato */ public function archiveDocument(int $id): void { $this->requireDocCapability('archive'); $doc = $this->requireDocument($id); if ($doc['status'] !== 'published') { $this->jsonError('Solo un documento pubblicato può essere archiviato', 409, 'BAD_STATE'); } $now = date('Y-m-d H:i:s'); Database::update('isms_documents', ['status' => 'archived', 'archived_at' => $now, 'updated_at' => $now], 'id = ?', [$id]); $this->logAudit('isms_document_archived', 'isms_document', $id, []); $this->jsonSuccess(['id' => $id, 'status' => 'archived'], 'Documento archiviato'); } /** POST /api/isms/documents/{id}/newVersion pubblicato/archiviato -> nuova bozza */ public function newVersionDocument(int $id): void { $this->requireDocCapability('new_version'); $doc = $this->requireDocument($id); if (!in_array($doc['status'], ['published', 'archived', 'approved'], true)) { $this->jsonError('Nuova versione possibile solo da documento approvato/pubblicato/archiviato', 409, 'BAD_STATE'); } $major = !empty($this->getJsonBody()['major']); $newV = $this->bumpVersion($doc['version'] ?? '1.0', $major); $this->snapshotDocument($doc, 'Apertura nuova versione ' . $newV . ' (dalla ' . ($doc['version'] ?? '1.0') . ')'); Database::update('isms_documents', [ 'status' => 'draft', 'version' => $newV, 'approved_by' => null, 'approved_at' => null, 'reviewed_by' => null, 'reviewed_at' => null, 'published_by' => null, 'published_at' => null, 'archived_at' => null, 'review_note' => null, 'updated_at' => date('Y-m-d H:i:s'), ], 'id = ?', [$id]); $this->logAudit('isms_document_new_version', 'isms_document', $id, ['version' => $newV]); $this->jsonSuccess(['id' => $id, 'status' => 'draft', 'version' => $newV], 'Nuova versione ' . $newV . ' in bozza'); } /** GET /api/isms/documents/{id}/versions */ public function documentVersions(int $id): void { $this->requireOrgAccess(); $this->requireDocument($id); $rows = Database::fetchAll( 'SELECT v.id, v.version, v.status, v.change_note, v.created_at, u.full_name AS by_name FROM isms_document_versions v LEFT JOIN users u ON u.id = v.created_by WHERE v.document_id = ? ORDER BY v.id DESC', [$id]); $this->jsonSuccess(['versions' => $rows]); } /** GET /api/isms/documents/{id}/word — scarica .doc (Word, modificabile) */ public function exportDocumentWord(int $id): void { $this->requireOrgAccess(); $doc = $this->requireDocument($id); $org = Database::fetchOne('SELECT name FROM organizations WHERE id = ?', [$doc['organization_id']]); $orgName = htmlspecialchars((string) ($org['name'] ?? '')); $statusLabel = [ 'draft' => 'Bozza', 'review' => 'In revisione', 'approved' => 'Approvato', 'published' => 'Pubblicato', 'archived' => 'Archiviato', ][$doc['status']] ?? $doc['status']; $meta = $orgName . ' — Versione ' . htmlspecialchars((string) ($doc['version'] ?? '1.0')) . ' — Stato: ' . $statusLabel; if (!empty($doc['approved_at'])) $meta .= ' — Approvato il ' . date('d/m/Y', strtotime($doc['approved_at'])); if (!empty($doc['published_at'])) $meta .= ' — In vigore dal ' . date('d/m/Y', strtotime($doc['published_at'])); if (!empty($doc['next_review_date'])) $meta .= ' — Prossimo riesame: ' . date('d/m/Y', strtotime($doc['next_review_date'])); $title = htmlspecialchars((string) $doc['title']); $body = $doc['body_html'] ?? ''; $html = "" . "