requireOrgAccess(); $where = 'organization_id = ?'; $params = [$this->getCurrentOrgId()]; if ($this->hasParam('status')) { $where .= ' AND status = ?'; $params[] = $this->getParam('status'); } if ($this->hasParam('category')) { $where .= ' AND category = ?'; $params[] = $this->getParam('category'); } $policies = Database::fetchAll( "SELECT p.*, u.full_name as approved_by_name FROM policies p LEFT JOIN users u ON u.id = p.approved_by WHERE p.{$where} ORDER BY p.category, p.title", $params ); $this->jsonSuccess($policies); } public function create(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $this->validateRequired(['title', 'category']); $policyId = Database::insert('policies', [ 'organization_id' => $this->getCurrentOrgId(), 'title' => trim($this->getParam('title')), 'category' => $this->getParam('category'), 'nis2_article' => $this->getParam('nis2_article'), 'content' => $this->getParam('content'), 'next_review_date' => $this->getParam('next_review_date'), 'ai_generated' => $this->getParam('ai_generated', 0), ]); $this->logAudit('policy_created', 'policy', $policyId); $this->jsonSuccess(['id' => $policyId], 'Policy creata', 201); } public function get(int $id): void { $this->requireOrgAccess(); $policy = Database::fetchOne( 'SELECT p.*, u.full_name as approved_by_name FROM policies p LEFT JOIN users u ON u.id = p.approved_by WHERE p.id = ? AND p.organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$policy) { $this->jsonError('Policy non trovata', 404, 'POLICY_NOT_FOUND'); } $this->jsonSuccess($policy); } public function update(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $updates = []; foreach (['title', 'content', 'category', 'nis2_article', 'status', 'version', 'next_review_date'] as $field) { if ($this->hasParam($field)) { $updates[$field] = $this->getParam($field); } } if (!empty($updates)) { Database::update('policies', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('policy_updated', 'policy', $id, $updates); } $this->jsonSuccess($updates, 'Policy aggiornata'); } public function delete(int $id): void { $this->requireOrgRole(['org_admin']); $deleted = Database::delete('policies', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); if ($deleted === 0) { $this->jsonError('Policy non trovata', 404, 'POLICY_NOT_FOUND'); } $this->logAudit('policy_deleted', 'policy', $id); $this->jsonSuccess(null, 'Policy eliminata'); } public function approve(int $id): void { $this->requireOrgRole(['org_admin']); Database::update('policies', [ 'status' => 'approved', 'approved_by' => $this->getCurrentUserId(), 'approved_at' => date('Y-m-d H:i:s'), ], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('policy_approved', 'policy', $id); $policy = Database::fetchOne('SELECT * FROM policies WHERE id = ?', [$id]); // Snapshot versione per storico/diff/attestation (P3). // Idempotente sulla coppia (policy_id, version): la riapprovazione della // stessa versione aggiorna lo snapshot invece di duplicarlo (UNIQUE uq_policy_version). try { Database::query( 'INSERT INTO policy_versions (policy_id, organization_id, version, content, change_note, created_by) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE content = VALUES(content), change_note = VALUES(change_note), created_by = VALUES(created_by), created_at = NOW()', [ $id, $this->getCurrentOrgId(), $policy['version'] ?? '1.0', $policy['content'] ?? null, $this->getParam('change_note') ?: 'Approvazione', $this->getCurrentUserId(), ] ); } catch (Throwable $e) { error_log('[POLICY_VER] ' . $e->getMessage()); } // Dispatch webhook policy.approved try { (new WebhookService())->dispatch( $this->getCurrentOrgId(), 'policy.approved', WebhookService::policyPayload($policy) ); } catch (Throwable $e) { error_log('[WEBHOOK] dispatch error: ' . $e->getMessage()); } $this->jsonSuccess(null, 'Policy approvata'); } public function aiGeneratePolicy(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $this->validateRequired(['category']); $category = $this->getParam('category'); $org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]); try { $aiService = new AIService(); $generated = $aiService->generatePolicy($category, $org); $aiService->logInteraction( $this->getCurrentOrgId(), $this->getCurrentUserId(), 'policy_draft', "Generate {$category} policy", substr($generated['title'] ?? '', 0, 500) ); $this->jsonSuccess($generated, 'Policy generata dall\'AI'); } catch (Throwable $e) { $this->jsonError('Errore AI: ' . $e->getMessage(), 500, 'AI_ERROR'); } } public function getTemplates(): void { $this->requireOrgAccess(); $templates = [ ['category' => 'information_security', 'title' => 'Politica di Sicurezza delle Informazioni', 'nis2_article' => '21.2.a'], ['category' => 'access_control', 'title' => 'Politica di Controllo degli Accessi', 'nis2_article' => '21.2.i'], ['category' => 'incident_response', 'title' => 'Piano di Risposta agli Incidenti', 'nis2_article' => '21.2.b'], ['category' => 'business_continuity', 'title' => 'Piano di Continuità Operativa', 'nis2_article' => '21.2.c'], ['category' => 'supply_chain', 'title' => 'Politica di Sicurezza della Supply Chain', 'nis2_article' => '21.2.d'], ['category' => 'encryption', 'title' => 'Politica sulla Crittografia', 'nis2_article' => '21.2.h'], ['category' => 'hr_security', 'title' => 'Politica di Sicurezza delle Risorse Umane', 'nis2_article' => '21.2.i'], ['category' => 'asset_management', 'title' => 'Politica di Gestione degli Asset', 'nis2_article' => '21.2.i'], ['category' => 'network_security', 'title' => 'Politica di Sicurezza della Rete', 'nis2_article' => '21.2.e'], ['category' => 'vulnerability_management', 'title' => 'Politica di Gestione delle Vulnerabilità', 'nis2_article' => '21.2.e'], ]; $this->jsonSuccess($templates); } // ══════════════════════════════════════════════════════════════════════ // ATTESTATION + VERSIONING (P3) // ══════════════════════════════════════════════════════════════════════ /** POST /api/policies/{id}/attest — dipendente prende visione (versione corrente). */ public function attest(int $id): void { $this->requireOrgAccess(); $policy = Database::fetchOne( "SELECT id, version, status FROM policies WHERE id = ? AND organization_id = ? AND deleted_at IS NULL", [$id, $this->getCurrentOrgId()] ); if (!$policy) { $this->jsonError('Policy non trovata', 404, 'NOT_FOUND'); } if ($policy['status'] !== 'approved') { $this->jsonError('Si puo attestare solo una policy approvata', 422, 'NOT_APPROVED'); } $version = $policy['version'] ?? '1.0'; $existing = Database::fetchOne( 'SELECT id FROM policy_attestations WHERE policy_id = ? AND user_id = ? AND version = ?', [$id, $this->getCurrentUserId(), $version] ); if ($existing) { $this->jsonSuccess(['already' => true, 'version' => $version], 'Presa visione gia registrata'); return; } Database::insert('policy_attestations', [ 'policy_id' => $id, 'organization_id' => $this->getCurrentOrgId(), 'user_id' => $this->getCurrentUserId(), 'version' => $version, 'ip_address' => $this->attClientIp(), ]); $this->logAudit('policy_attested', 'policy', $id, ['version' => $version]); $this->jsonSuccess(['version' => $version], 'Presa visione registrata', 201); } /** GET /api/policies/{id}/attestations — copertura % presa visione (versione corrente). */ public function attestations(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member', 'auditor']); $policy = Database::fetchOne( "SELECT id, title, version FROM policies WHERE id = ? AND organization_id = ? AND deleted_at IS NULL", [$id, $this->getCurrentOrgId()] ); if (!$policy) { $this->jsonError('Policy non trovata', 404, 'NOT_FOUND'); } $version = $policy['version'] ?? '1.0'; $members = Database::fetchAll( 'SELECT u.id, u.full_name, u.email FROM user_organizations uo JOIN users u ON u.id = uo.user_id WHERE uo.organization_id = ? AND u.is_active = 1', [$this->getCurrentOrgId()] ); $attested = Database::fetchAll( 'SELECT user_id, attested_at FROM policy_attestations WHERE policy_id = ? AND version = ?', [$id, $version] ); $attMap = []; foreach ($attested as $a) { $attMap[(int) $a['user_id']] = $a['attested_at']; } $list = array_map(fn($m) => [ 'user_id' => (int) $m['id'], 'full_name' => $m['full_name'], 'email' => $m['email'], 'attested' => isset($attMap[(int) $m['id']]), 'attested_at' => $attMap[(int) $m['id']] ?? null, ], $members); $total = count($members); $done = count(array_filter($list, fn($x) => $x['attested'])); $this->jsonSuccess([ 'policy_id' => $id, 'version' => $version, 'total_members' => $total, 'attested' => $done, 'coverage_percent' => $total ? (int) round($done * 100 / $total) : 0, 'members' => $list, ]); } /** GET /api/policies/{id}/versions — storico versioni. */ public function versions(int $id): void { $this->requireOrgAccess(); $rows = Database::fetchAll( 'SELECT id, version, change_note, created_by, created_at FROM policy_versions WHERE policy_id = ? AND organization_id = ? ORDER BY created_at DESC', [$id, $this->getCurrentOrgId()] ); $this->jsonSuccess(['versions' => $rows]); } /** GET /api/policies/{id}/diff?from={vId}&to={vId} — diff line-by-line. */ public function diff(int $id): void { $this->requireOrgAccess(); $fromId = (int) $this->getParam('from'); $toId = (int) $this->getParam('to'); $a = Database::fetchOne('SELECT version, content FROM policy_versions WHERE id = ? AND policy_id = ? AND organization_id = ?', [$fromId, $id, $this->getCurrentOrgId()]); $b = Database::fetchOne('SELECT version, content FROM policy_versions WHERE id = ? AND policy_id = ? AND organization_id = ?', [$toId, $id, $this->getCurrentOrgId()]); if (!$a || !$b) { $this->jsonError('Versione non trovata', 404, 'NOT_FOUND'); } $linesA = preg_split('/\r\n|\r|\n/', (string) $a['content']); $linesB = preg_split('/\r\n|\r|\n/', (string) $b['content']); $d = self::lcsLineDiff($linesA, $linesB); $this->jsonSuccess([ 'from' => ['id' => $fromId, 'version' => $a['version']], 'to' => ['id' => $toId, 'version' => $b['version']], 'added' => $d['added'], 'removed' => $d['removed'], 'summary' => ['added' => count($d['added']), 'removed' => count($d['removed'])], ]); } /** * Diff line-by-line basato su LCS (Longest Common Subsequence). * Rileva correttamente righe duplicate, spostamenti e modifiche posizionali, * a differenza di un confronto set-based. Restituisce le righe aggiunte (in B * ma non allineate in A) e rimosse (in A ma non allineate in B), con posizione. * * @return array{added: list, removed: list} */ private static function lcsLineDiff(array $a, array $b): array { $n = count($a); $m = count($b); // Tabella LCS (lunghezze). Cap di sicurezza per evitare blow-up su documenti enormi. if ($n > 4000 || $m > 4000) { // fallback prudente: confronto posizionale semplice riga per riga $added = []; $removed = []; $max = max($n, $m); for ($i = 0; $i < $max; $i++) { $la = $a[$i] ?? null; $lb = $b[$i] ?? null; if ($la !== $lb) { if ($lb !== null && trim($lb) !== '') $added[] = ['line' => $i + 1, 'text' => $lb]; if ($la !== null && trim($la) !== '') $removed[] = ['line' => $i + 1, 'text' => $la]; } } return ['added' => $added, 'removed' => $removed]; } $dp = array_fill(0, $n + 1, array_fill(0, $m + 1, 0)); for ($i = $n - 1; $i >= 0; $i--) { for ($j = $m - 1; $j >= 0; $j--) { $dp[$i][$j] = ($a[$i] === $b[$j]) ? $dp[$i + 1][$j + 1] + 1 : max($dp[$i + 1][$j], $dp[$i][$j + 1]); } } $added = []; $removed = []; $i = 0; $j = 0; while ($i < $n && $j < $m) { if ($a[$i] === $b[$j]) { $i++; $j++; } elseif ($dp[$i + 1][$j] >= $dp[$i][$j + 1]) { if (trim($a[$i]) !== '') $removed[] = ['line' => $i + 1, 'text' => $a[$i]]; $i++; } else { if (trim($b[$j]) !== '') $added[] = ['line' => $j + 1, 'text' => $b[$j]]; $j++; } } for (; $i < $n; $i++) { if (trim($a[$i]) !== '') $removed[] = ['line' => $i + 1, 'text' => $a[$i]]; } for (; $j < $m; $j++) { if (trim($b[$j]) !== '') $added[] = ['line' => $j + 1, 'text' => $b[$j]]; } return ['added' => $added, 'removed' => $removed]; } /** GET /api/policies/pending-attestations — policy approvate non ancora attestate dall'utente. */ public function pendingAttestations(): void { $this->requireOrgAccess(); $rows = Database::fetchAll( "SELECT p.id, p.title, p.category, p.version, p.approved_at FROM policies p WHERE p.organization_id = ? AND p.status = 'approved' AND p.deleted_at IS NULL AND NOT EXISTS ( SELECT 1 FROM policy_attestations a WHERE a.policy_id = p.id AND a.user_id = ? AND a.version = p.version ) ORDER BY p.approved_at DESC", [$this->getCurrentOrgId(), $this->getCurrentUserId()] ); $this->jsonSuccess(['pending' => $rows, 'count' => count($rows)]); } private function attClientIp(): ?string { $fwd = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''; if ($fwd) { return trim(explode(',', $fwd)[0]); } return $_SERVER['REMOTE_ADDR'] ?? null; } }