checklist di conduzione -> esiti -> finding NC. * - PROGRAMMA: internal_audits (code AUD-NNN progressivo per org, scope, criteri, * auditor capo come utente o ruolo organigramma, date pianificate/eseguite, * stato, conclusione). La data pianificata alimenta il calendario (review_schedule). * - CHECKLIST: internal_audit_items pre-popolata al create con le clausole 4-10 * ISO 27001 (lista statica) + i controlli Annex A applicabili dal SoA dell'org * (isms_soa.applicable=1, query difensiva: la tabella potrebbe non esistere). * - FINDING: una voce non_conforme può generare una NC (non_conformities, * source='audit', source_entity_type='internal_audit_item'), che confluisce * nel modulo NCR/CAPA esistente. * - EVIDENZE: su evidence_files (entity_type='internal_audit') — gestite altrove. * - REPORT: HTML stampabile (no PDF lib). * * Multi-tenancy: ogni query filtra organization_id. Anti-IDOR su audit/item/role * (verificati appartenenti all'org). logAudit su create/update/delete. * NOTE: Database::query/fetchAll/fetchOne/insert/update/delete; jsonSuccess/Error exit. */ require_once __DIR__ . '/BaseController.php'; class InternalAuditController extends BaseController { private const MANAGE_ROLES = ['org_admin', 'compliance_manager', 'auditor']; /** * Clausole 4-10 ISO/IEC 27001:2022 — checklist statica (ref_type='clause'). * Pre-popolata al create di ogni audit; l'auditor parte già con la checklist. */ private const ISO_CLAUSES = [ ['4.1', 'Comprensione dell\'organizzazione e del suo contesto'], ['4.2', 'Comprensione delle esigenze e aspettative delle parti interessate'], ['4.3', 'Determinazione dello scopo del SGSI'], ['4.4', 'Sistema di gestione per la sicurezza delle informazioni'], ['5.1', 'Leadership e impegno della direzione'], ['5.2', 'Politica per la sicurezza delle informazioni'], ['5.3', 'Ruoli, responsabilità e autorità organizzative'], ['6.1.1', 'Azioni per affrontare rischi e opportunità — generalità'], ['6.1.2', 'Valutazione del rischio per la sicurezza delle informazioni'], ['6.1.3', 'Trattamento del rischio per la sicurezza delle informazioni (SoA)'], ['6.2', 'Obiettivi di sicurezza delle informazioni e pianificazione'], ['6.3', 'Pianificazione delle modifiche'], ['7.1', 'Risorse'], ['7.2', 'Competenza'], ['7.3', 'Consapevolezza'], ['7.4', 'Comunicazione'], ['7.5', 'Informazioni documentate'], ['8.1', 'Pianificazione e controllo operativi'], ['8.2', 'Valutazione del rischio per la sicurezza delle informazioni'], ['8.3', 'Trattamento del rischio per la sicurezza delle informazioni'], ['9.1', 'Monitoraggio, misurazione, analisi e valutazione'], ['9.2', 'Audit interno'], ['9.3', 'Riesame di direzione'], ['10.1', 'Miglioramento continuo'], ['10.2', 'Non conformità e azioni correttive'], ]; // ───────────────────────────────────────────────────────────────────────── // PROGRAMMA AUDIT // ───────────────────────────────────────────────────────────────────────── /** GET /api/internal-audits/list */ public function list(): void { $this->requireOrgAccess(); $orgId = $this->getCurrentOrgId(); $rows = Database::fetchAll( 'SELECT a.id, a.code, a.title, a.status, a.planned_date, a.executed_date, a.lead_auditor_user_id, u.full_name AS lead_auditor_name, a.lead_auditor_role_id, r.role_name AS lead_auditor_role_name, a.updated_at, (SELECT COUNT(*) FROM internal_audit_items i WHERE i.audit_id = a.id) AS n_items, (SELECT COUNT(*) FROM internal_audit_items i WHERE i.audit_id = a.id AND i.result = \'non_conforme\') AS n_nc FROM internal_audits a LEFT JOIN users u ON u.id = a.lead_auditor_user_id LEFT JOIN org_roles r ON r.id = a.lead_auditor_role_id WHERE a.organization_id = ? ORDER BY (a.planned_date IS NULL), a.planned_date DESC, a.id DESC', [$orgId] ); $out = array_map(static fn($a) => [ 'id' => (int) $a['id'], 'code' => $a['code'], 'title' => $a['title'], 'status' => $a['status'], 'planned_date' => $a['planned_date'], 'executed_date' => $a['executed_date'], 'lead_auditor_user_id' => $a['lead_auditor_user_id'] !== null ? (int) $a['lead_auditor_user_id'] : null, 'lead_auditor_name' => $a['lead_auditor_name'], 'lead_auditor_role_id' => $a['lead_auditor_role_id'] !== null ? (int) $a['lead_auditor_role_id'] : null, 'lead_auditor_role_name' => $a['lead_auditor_role_name'], 'n_items' => (int) $a['n_items'], 'n_nc' => (int) $a['n_nc'], 'updated_at' => $a['updated_at'], ], $rows); $this->jsonSuccess(['audits' => $out]); } /** GET /api/internal-audits/{id} (con items) */ public function get(int $id): void { $this->requireOrgAccess(); $orgId = $this->getCurrentOrgId(); $a = Database::fetchOne( 'SELECT a.*, u.full_name AS lead_auditor_name, r.role_name AS lead_auditor_role_name FROM internal_audits a LEFT JOIN users u ON u.id = a.lead_auditor_user_id LEFT JOIN org_roles r ON r.id = a.lead_auditor_role_id WHERE a.id = ? AND a.organization_id = ?', [$id, $orgId] ); if (!$a) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); } $this->jsonSuccess([ 'id' => (int) $a['id'], 'code' => $a['code'], 'title' => $a['title'], 'scope' => $a['scope'], 'criteria' => $a['criteria'], 'planned_date' => $a['planned_date'], 'executed_date' => $a['executed_date'], 'status' => $a['status'], 'lead_auditor_user_id' => $a['lead_auditor_user_id'] !== null ? (int) $a['lead_auditor_user_id'] : null, 'lead_auditor_name' => $a['lead_auditor_name'], 'lead_auditor_role_id' => $a['lead_auditor_role_id'] !== null ? (int) $a['lead_auditor_role_id'] : null, 'lead_auditor_role_name' => $a['lead_auditor_role_name'], 'conclusion' => $a['conclusion'], 'updated_at' => $a['updated_at'], 'items' => $this->loadItems($id), ]); } /** * POST /api/internal-audits/create * Genera code AUD-NNN progressivo per org e PRE-POPOLA la checklist: * - clausole 4-10 ISO 27001 (statiche), * - controlli Annex A applicabili dal SoA dell'org (se isms_soa esiste). */ public function create(): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $b = $this->getJsonBody(); $title = trim((string) ($b['title'] ?? '')); if ($title === '' || mb_strlen($title) > 255) { $this->jsonError('Titolo obbligatorio (max 255)', 422, 'INVALID_TITLE'); } $status = in_array($b['status'] ?? '', ['planned', 'in_progress', 'completed', 'cancelled'], true) ? $b['status'] : 'planned'; $planned = $this->validateDate($b['planned_date'] ?? null, 'planned_date'); $executed = $this->validateDate($b['executed_date'] ?? null, 'executed_date'); $leadUserId = $this->validateUser($b['lead_auditor_user_id'] ?? null, $orgId); $leadRoleId = $this->validateRole($b['lead_auditor_role_id'] ?? null, $orgId); // Retry-on-duplicate: generateAuditCode() è check-poi-insert non atomico (race su // create concorrenti); la UNIQUE (organization_id, code) [mig.058] blocca i doppioni // e qui si rigenera il codice. $base = [ 'organization_id' => $orgId, 'title' => $title, 'scope' => $this->nullableStr($b['scope'] ?? null), 'criteria' => $this->nullableStr($b['criteria'] ?? null), 'planned_date' => $planned, 'executed_date' => $executed, 'status' => $status, 'lead_auditor_user_id' => $leadUserId, 'lead_auditor_role_id' => $leadRoleId, 'conclusion' => $this->nullableStr($b['conclusion'] ?? null), 'created_by' => $this->getCurrentUserId(), ]; $code = null; $id = 0; for ($attempt = 0; ; $attempt++) { $code = $this->generateAuditCode($orgId); try { $id = (int) Database::insert('internal_audits', ['code' => $code] + $base); break; } catch (\PDOException $e) { if (($e->errorInfo[1] ?? 0) === 1062 && $attempt < 4) { continue; } throw $e; } } $seeded = $this->seedChecklist($id, $orgId); $this->upsertCalendar($id, $orgId, $code, $title, $planned); $this->logAudit('internal_audit_created', 'internal_audit', $id, ['code' => $code, 'title' => $title, 'items' => $seeded]); $this->jsonSuccess(['id' => $id, 'code' => $code, 'items_seeded' => $seeded], 'Audit creato', 201); } /** PUT /api/internal-audits/{id} */ public function update(int $id): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $b = $this->getJsonBody(); $a = Database::fetchOne('SELECT id, code, title, planned_date FROM internal_audits WHERE id = ? AND organization_id = ?', [$id, $orgId]); if (!$a) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); } $updates = []; if ($this->hasParam('title')) { $title = trim((string) ($b['title'] ?? '')); if ($title === '' || mb_strlen($title) > 255) { $this->jsonError('Titolo non valido (max 255)', 422, 'INVALID_TITLE'); } $updates['title'] = $title; } if ($this->hasParam('scope')) { $updates['scope'] = $this->nullableStr($b['scope'] ?? null); } if ($this->hasParam('criteria')) { $updates['criteria'] = $this->nullableStr($b['criteria'] ?? null); } if ($this->hasParam('planned_date')) { $updates['planned_date'] = $this->validateDate($b['planned_date'] ?? null, 'planned_date'); } if ($this->hasParam('executed_date')) { $updates['executed_date'] = $this->validateDate($b['executed_date'] ?? null, 'executed_date'); } if ($this->hasParam('status') && in_array($b['status'], ['planned', 'in_progress', 'completed', 'cancelled'], true)) { $updates['status'] = $b['status']; } if ($this->hasParam('conclusion')) { $updates['conclusion'] = $this->nullableStr($b['conclusion'] ?? null); } if ($this->hasParam('lead_auditor_user_id')) { $updates['lead_auditor_user_id'] = $this->validateUser($b['lead_auditor_user_id'] ?? null, $orgId); } if ($this->hasParam('lead_auditor_role_id')) { $updates['lead_auditor_role_id'] = $this->validateRole($b['lead_auditor_role_id'] ?? null, $orgId); } if (!empty($updates)) { Database::update('internal_audits', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]); } // riallinea il calendario $title = $updates['title'] ?? $a['title']; $planned = array_key_exists('planned_date', $updates) ? $updates['planned_date'] : $a['planned_date']; $this->upsertCalendar($id, $orgId, $a['code'], $title, $planned); $this->logAudit('internal_audit_updated', 'internal_audit', $id, array_keys($updates)); $this->jsonSuccess(['id' => $id, 'updated' => array_keys($updates)], 'Audit aggiornato'); } /** DELETE /api/internal-audits/{id} (org_admin) */ public function delete(int $id): void { $this->requireOrgRole(['org_admin']); $orgId = $this->getCurrentOrgId(); $a = Database::fetchOne('SELECT id FROM internal_audits WHERE id = ? AND organization_id = ?', [$id, $orgId]); if (!$a) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); } Database::delete('review_schedule', 'organization_id = ? AND entity_type = ? AND entity_id = ?', [$orgId, 'internal_audit', $id]); Database::delete('internal_audits', 'id = ? AND organization_id = ?', [$id, $orgId]); // items in cascata $this->logAudit('internal_audit_deleted', 'internal_audit', $id); $this->jsonSuccess(null, 'Audit eliminato'); } // ───────────────────────────────────────────────────────────────────────── // VOCI DI CHECKLIST // ───────────────────────────────────────────────────────────────────────── /** * PUT /api/internal-audits/items/{subId} Body: {result?, note?} * Aggiorna esito/note di una voce, verificando che appartenga a un audit dell'org. */ public function updateItem(int $itemId): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $item = $this->assertItem($itemId, $orgId); $b = $this->getJsonBody(); $updates = []; if ($this->hasParam('result')) { $allowed = ['da_verificare', 'conforme', 'non_conforme', 'osservazione', 'opportunita', 'non_applicabile']; if (!in_array($b['result'] ?? '', $allowed, true)) { $this->jsonError('Esito non valido', 422, 'INVALID_RESULT'); } $updates['result'] = $b['result']; } if ($this->hasParam('note')) { $updates['note'] = $this->nullableStr($b['note'] ?? null); } if ($this->hasParam('checkpoint')) { $cp = trim((string) ($b['checkpoint'] ?? '')); if ($cp === '') { $this->jsonError('Checkpoint non valido', 422, 'INVALID_CHECKPOINT'); } $updates['checkpoint'] = mb_substr($cp, 0, 2000); } if (empty($updates)) { $this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES'); } Database::update('internal_audit_items', $updates, 'id = ?', [$itemId]); $this->logAudit('internal_audit_item_updated', 'internal_audit_item', $itemId, ['audit_id' => (int) $item['audit_id']] + array_fill_keys(array_keys($updates), 1)); $this->jsonSuccess(['id' => $itemId, 'updated' => array_keys($updates)], 'Voce aggiornata'); } /** * POST /api/internal-audits/items Body: {audit_id*, checkpoint*, ref_type?, ref_code?, note?, result?} * Aggiunge una voce custom alla checklist (l'audit deve appartenere all'org). */ public function addItem(): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $b = $this->getJsonBody(); $auditId = (int) ($b['audit_id'] ?? 0); $this->assertAudit($auditId, $orgId); $checkpoint = trim((string) ($b['checkpoint'] ?? '')); if ($checkpoint === '') { $this->jsonError('Checkpoint obbligatorio', 422, 'INVALID_CHECKPOINT'); } $refType = in_array($b['ref_type'] ?? '', ['clause', 'annex_control', 'nis2_measure', 'custom'], true) ? $b['ref_type'] : 'custom'; $result = in_array($b['result'] ?? '', ['da_verificare', 'conforme', 'non_conforme', 'osservazione', 'opportunita', 'non_applicabile'], true) ? $b['result'] : 'da_verificare'; $maxOrd = (int) (Database::fetchOne('SELECT COALESCE(MAX(ord), 0) AS m FROM internal_audit_items WHERE audit_id = ?', [$auditId])['m'] ?? 0); $id = (int) Database::insert('internal_audit_items', [ 'audit_id' => $auditId, 'ref_type' => $refType, 'ref_code' => $this->nullableStr($b['ref_code'] ?? null, 32), 'checkpoint' => mb_substr($checkpoint, 0, 2000), 'result' => $result, 'note' => $this->nullableStr($b['note'] ?? null), 'ord' => $maxOrd + 1, ]); $this->logAudit('internal_audit_item_added', 'internal_audit_item', $id, ['audit_id' => $auditId]); $this->jsonSuccess(['id' => $id], 'Voce aggiunta', 201); } // ───────────────────────────────────────────────────────────────────────── // FINDING -> NON CONFORMITA' // ───────────────────────────────────────────────────────────────────────── /** * POST /api/internal-audits/{id}/raiseNcr Body: {item_id?} * Crea una NC (non_conformities) da una voce non conforme dell'audit. * source='audit' (l'ENUM 004 NON ha 'internal_audit'), * source_entity_type='internal_audit_item', source_entity_id=item_id. * Idempotente: se esiste già una NC per quella voce la restituisce. */ public function raiseNcr(int $id): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $audit = Database::fetchOne('SELECT id, code FROM internal_audits WHERE id = ? AND organization_id = ?', [$id, $orgId]); if (!$audit) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); } $b = $this->getJsonBody(); $itemId = (int) ($b['item_id'] ?? 0); if ($itemId <= 0) { $this->jsonError('item_id obbligatorio', 422, 'MISSING_ITEM'); } $item = Database::fetchOne('SELECT id, audit_id, ref_type, ref_code, checkpoint FROM internal_audit_items WHERE id = ? AND audit_id = ?', [$itemId, $id]); if (!$item) { $this->jsonError('Voce non trovata in questo audit', 404, 'ITEM_NOT_FOUND'); } // Idempotenza: una sola NC per voce $existing = Database::fetchOne( "SELECT id, ncr_code FROM non_conformities WHERE organization_id = ? AND source = 'audit' AND source_entity_type = 'internal_audit_item' AND source_entity_id = ?", [$orgId, $itemId] ); if ($existing) { $this->jsonSuccess(['id' => (int) $existing['id'], 'ncr_code' => $existing['ncr_code'], 'already' => true], 'Non conformità già aperta per questa voce'); } $checkpoint = (string) $item['checkpoint']; $refCode = $item['ref_code'] ? '[' . $item['ref_code'] . '] ' : ''; $titleBase = $refCode . $checkpoint; $title = mb_strlen($titleBase) > 200 ? mb_substr($titleBase, 0, 197) . '...' : $titleBase; if ($title === '') { $title = 'Non conformità da audit interno ' . $audit['code']; } $ncrCode = $this->generateCode('NCR'); $ncrId = (int) Database::insert('non_conformities', [ 'organization_id' => $orgId, 'ncr_code' => $ncrCode, 'title' => $title, 'description' => "Rilevata nell'audit interno {$audit['code']}" . ($item['ref_code'] ? " (rif. {$item['ref_code']})" : '') . ": {$checkpoint}", 'source' => 'audit', 'source_entity_type' => 'internal_audit_item', 'source_entity_id' => $itemId, 'severity' => 'minor', 'status' => 'open', 'identified_by' => $this->getCurrentUserId(), ]); $this->logAudit('internal_audit_ncr_raised', 'non_conformity', $ncrId, ['ncr_code' => $ncrCode, 'audit_id' => $id, 'item_id' => $itemId]); $this->jsonSuccess(['id' => $ncrId, 'ncr_code' => $ncrCode, 'already' => false], 'Non conformità creata', 201); } // ───────────────────────────────────────────────────────────────────────── // REPORT // ───────────────────────────────────────────────────────────────────────── /** GET /api/internal-audits/{id}/report — HTML stampabile (no PDF lib). */ public function report(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor', 'board_member']); $orgId = $this->getCurrentOrgId(); $a = Database::fetchOne( 'SELECT a.*, u.full_name AS lead_auditor_name, r.role_name AS lead_auditor_role_name, o.name AS org_name FROM internal_audits a LEFT JOIN users u ON u.id = a.lead_auditor_user_id LEFT JOIN org_roles r ON r.id = a.lead_auditor_role_id LEFT JOIN organizations o ON o.id = a.organization_id WHERE a.id = ? AND a.organization_id = ?', [$id, $orgId] ); if (!$a) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); } $items = $this->loadItems($id); header('Content-Type: text/html; charset=utf-8'); echo $this->renderReport($a, $items); exit; } // ───────────────────────────────────────────────────────────────────────── // HELPER // ───────────────────────────────────────────────────────────────────────── private function loadItems(int $auditId): array { $rows = Database::fetchAll( 'SELECT id, ref_type, ref_code, checkpoint, result, note, ord FROM internal_audit_items WHERE audit_id = ? ORDER BY FIELD(ref_type, \'clause\',\'annex_control\',\'nis2_measure\',\'custom\'), ord ASC, id ASC', [$auditId] ); // mappa item -> eventuale NC già aperta (per il pulsante "apri NC" del frontend) $ncByItem = []; $ncs = Database::fetchAll( "SELECT source_entity_id, id, ncr_code FROM non_conformities WHERE source = 'audit' AND source_entity_type = 'internal_audit_item' AND source_entity_id IN (SELECT id FROM internal_audit_items WHERE audit_id = ?)", [$auditId] ); foreach ($ncs as $n) { $ncByItem[(int) $n['source_entity_id']] = ['id' => (int) $n['id'], 'ncr_code' => $n['ncr_code']]; } return array_map(static function ($r) use ($ncByItem) { $iid = (int) $r['id']; return [ 'id' => $iid, 'ref_type' => $r['ref_type'], 'ref_code' => $r['ref_code'], 'checkpoint' => $r['checkpoint'], 'result' => $r['result'], 'note' => $r['note'], 'ord' => (int) $r['ord'], 'ncr' => $ncByItem[$iid] ?? null, ]; }, $rows); } /** Genera code AUD-NNN progressivo per org (es. AUD-001). */ private function generateAuditCode(int $orgId): string { $n = (int) (Database::fetchOne( "SELECT COUNT(*) AS c FROM internal_audits WHERE organization_id = ?", [$orgId] )['c'] ?? 0); // evita collisione su uno UNIQUE eventuale futuro: incrementa finché libero for ($i = $n + 1; $i < $n + 1000; $i++) { $code = 'AUD-' . str_pad((string) $i, 3, '0', STR_PAD_LEFT); $exists = Database::fetchOne('SELECT id FROM internal_audits WHERE organization_id = ? AND code = ?', [$orgId, $code]); if (!$exists) { return $code; } } return 'AUD-' . str_pad((string) ($n + 1), 3, '0', STR_PAD_LEFT); } /** * Pre-popola la checklist: clausole 4-10 (statiche) + Annex A applicabili da SoA. * Ritorna il numero di voci create. */ private function seedChecklist(int $auditId, int $orgId): int { $ord = 0; $count = 0; foreach (self::ISO_CLAUSES as [$code, $checkpoint]) { Database::insert('internal_audit_items', [ 'audit_id' => $auditId, 'ref_type' => 'clause', 'ref_code' => $code, 'checkpoint' => $checkpoint, 'result' => 'da_verificare', 'ord' => $ord++, ]); $count++; } // Annex A dal SoA dell'org (query difensiva: isms_soa potrebbe non esistere) try { $soa = Database::fetchAll( "SELECT control_code, source_ref FROM isms_soa WHERE organization_id = ? AND applicable = 1 ORDER BY control_code ASC", [$orgId] ); $ord = 0; foreach ($soa as $s) { $cp = $s['source_ref'] ? (string) $s['source_ref'] : ('Controllo applicabile (SoA): ' . $s['control_code']); Database::insert('internal_audit_items', [ 'audit_id' => $auditId, 'ref_type' => 'annex_control', 'ref_code' => mb_substr((string) $s['control_code'], 0, 32), 'checkpoint' => mb_substr($cp, 0, 2000), 'result' => 'da_verificare', 'ord' => $ord++, ]); $count++; } } catch (PDOException $e) { // tabella SoA assente o non popolata per l'org: la checklist resta con le sole clausole error_log('[InternalAudit] SoA seed skipped: ' . $e->getMessage()); } return $count; } /** Crea/aggiorna/elimina la riga di calendario (review_schedule) per l'audit. */ private function upsertCalendar(int $auditId, int $orgId, ?string $code, string $title, ?string $plannedDate): void { if ($plannedDate === null) { Database::delete('review_schedule', 'organization_id = ? AND entity_type = ? AND entity_id = ?', [$orgId, 'internal_audit', $auditId]); return; } $label = mb_substr('Audit interno ' . ($code ? $code . ': ' : '') . $title, 0, 255); try { Database::query( 'INSERT INTO review_schedule (organization_id, entity_type, entity_id, title, next_review_date, created_by) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE title = VALUES(title), next_review_date = VALUES(next_review_date)', [$orgId, 'internal_audit', $auditId, $label, $plannedDate, $this->getCurrentUserId()] ); } catch (PDOException $e) { // l'ENUM review_schedule.entity_type potrebbe non includere ancora 'internal_audit' // (estensione lato seeder/flotta): non bloccare la creazione dell'audit. error_log('[InternalAudit] calendar upsert skipped: ' . $e->getMessage()); } } private function assertAudit(int $id, int $orgId): array { $a = Database::fetchOne('SELECT id, code FROM internal_audits WHERE id = ? AND organization_id = ?', [$id, $orgId]); if (!$a) { $this->jsonError('Audit non trovato', 404, 'NOT_FOUND'); } return $a; } /** Verifica che la voce appartenga a un audit dell'org (anti-IDOR). */ private function assertItem(int $itemId, int $orgId): array { $item = Database::fetchOne( 'SELECT i.id, i.audit_id FROM internal_audit_items i JOIN internal_audits a ON a.id = i.audit_id WHERE i.id = ? AND a.organization_id = ?', [$itemId, $orgId] ); if (!$item) { $this->jsonError('Voce non trovata', 404, 'NOT_FOUND'); } return $item; } private function validateUser($id, int $orgId): ?int { $id = ($id === null || $id === '') ? null : (int) $id; if ($id === null) { return null; } // l'utente deve essere membro dell'org (anti-IDOR) $row = Database::fetchOne( 'SELECT u.id FROM users u JOIN user_organizations uo ON uo.user_id = u.id WHERE u.id = ? AND uo.organization_id = ?', [$id, $orgId] ); if (!$row) { $this->jsonError('Auditor capo non valido', 422, 'INVALID_AUDITOR'); } return $id; } private function validateRole($id, int $orgId): ?int { $id = ($id === null || $id === '') ? null : (int) $id; if ($id === null) { return null; } $row = Database::fetchOne('SELECT id FROM org_roles WHERE id = ? AND organization_id = ?', [$id, $orgId]); if (!$row) { $this->jsonError('Ruolo auditor non valido', 422, 'INVALID_ROLE'); } return $id; } private function validateDate($v, string $field): ?string { if ($v === null || $v === '') { return null; } $d = trim((string) $v); $dt = DateTime::createFromFormat('Y-m-d', $d); if (!$dt || $dt->format('Y-m-d') !== $d) { $this->jsonError("Data $field non valida (atteso AAAA-MM-GG)", 422, 'INVALID_DATE'); } return $d; } private function nullableStr($v, ?int $max = null): ?string { if ($v === null) { return null; } $s = trim((string) $v); if ($s === '') { return null; } if ($max !== null && mb_strlen($s) > $max) { $s = mb_substr($s, 0, $max); } return $s; } /** Report HTML stampabile, self-contained (no PDF lib, no asset esterni). */ private function renderReport(array $a, array $items): string { $esc = static fn($s) => htmlspecialchars((string) ($s ?? ''), ENT_QUOTES, 'UTF-8'); $resLabels = [ 'da_verificare' => 'Da verificare', 'conforme' => 'Conforme', 'non_conforme' => 'Non conforme', 'osservazione' => 'Osservazione', 'opportunita' => 'Opportunità', 'non_applicabile' => 'Non applicabile', ]; $resColors = [ 'da_verificare' => '#6b7280', 'conforme' => '#166534', 'non_conforme' => '#991b1b', 'osservazione' => '#92400e', 'opportunita' => '#1e40af', 'non_applicabile' => '#6b7280', ]; $typeLabels = ['clause' => 'Clausole ISO 27001', 'annex_control' => 'Controlli Annex A', 'nis2_measure' => 'Misure NIS2', 'custom' => 'Voci aggiuntive']; // raggruppa per ref_type mantenendo l'ordine $groups = []; foreach ($items as $it) { $groups[$it['ref_type']][] = $it; } // conteggi esiti $tally = []; foreach ($items as $it) { $tally[$it['result']] = ($tally[$it['result']] ?? 0) + 1; } $leadParts = []; if (!empty($a['lead_auditor_name'])) { $leadParts[] = $a['lead_auditor_name']; } if (!empty($a['lead_auditor_role_name'])) { $leadParts[] = '(' . $a['lead_auditor_role_name'] . ')'; } $lead = $leadParts ? implode(' ', $leadParts) : '—'; $h = ''; $h .= ''; $h .= 'Report audit interno ' . $esc($a['code']) . ''; $h .= ''; $h .= '

Report di audit interno

'; $h .= '
' . $esc($a['org_name']) . ' · ISO/IEC 27001 §9.2 · generato il ' . date('d/m/Y H:i') . '
'; $h .= ''; $h .= ''; $h .= ''; $h .= ''; $h .= ''; $h .= ''; $h .= ''; $h .= ''; $h .= ''; $h .= '
Codice' . $esc($a['code']) . '
Titolo' . $esc($a['title']) . '
Stato' . $esc($a['status']) . '
Auditor capo' . $esc($lead) . '
Data pianificata' . ($a['planned_date'] ? $esc(date('d/m/Y', strtotime($a['planned_date']))) : '—') . '
Data esecuzione' . ($a['executed_date'] ? $esc(date('d/m/Y', strtotime($a['executed_date']))) : '—') . '
Ambito (scope)' . nl2br($esc($a['scope'])) . '
Criteri' . nl2br($esc($a['criteria'])) . '
'; $h .= '

Sintesi esiti

'; foreach ($resLabels as $k => $lbl) { $c = $tally[$k] ?? 0; $h .= '' . $esc($lbl) . ' ' . $c . ''; } $h .= '
'; foreach ($typeLabels as $type => $label) { if (empty($groups[$type])) { continue; } $h .= '

' . $esc($label) . '

'; $h .= ''; foreach ($groups[$type] as $it) { $col = $resColors[$it['result']] ?? '#6b7280'; $rl = $resLabels[$it['result']] ?? $it['result']; $ncSuffix = $it['ncr'] ? ' NC ' . $esc($it['ncr']['ncr_code']) . '' : ''; $h .= ''; $h .= ''; $h .= ''; } $h .= '
Rif.Punto di verificaEsitoNote
' . $esc($it['ref_code']) . '' . $esc($it['checkpoint']) . $ncSuffix . '' . $esc($rl) . '' . nl2br($esc($it['note'])) . '
'; } if (!empty($a['conclusion'])) { $h .= '

Conclusioni

' . nl2br($esc($a['conclusion'])) . '

'; } $h .= '

Documento generato da NIS2 Agile. ISO/IEC 27001 §9.2 e\' una buona prassi volontaria; gli obblighi normativi in Italia derivano da NIS2 / D.Lgs. 138/2024. Strumento di supporto organizzativo, non un parere legale.

'; $h .= ''; return $h; } }