nis2-agile/application/controllers/PolicyController.php
DevEnv nis2-agile 31b8a4572c [FIX] P2/P3: aggiunti i metodi+route realmente mancanti (commit precedenti incompleti)
I commit 56ce97d/1a5db30/14c06c8 contenevano migrazioni+HTML ma gli Edit dei
metodi controller e delle route erano falliti silenziosamente (ancore errate).
Ora presenti e testati E2E in produzione:
- DashboardController::sectorBenchmark (era 501)
- SupplyChainController: sendQuestionnaire/publicQuestionnaire/submitPublicQuestionnaire/questionnaireStatus/resolveQuestionnaire + route 'supply-chain' (era 404)
- PolicyController: attest/attestations/versions/diff/pendingAttestations + snapshot in approve + route (era 404)
Test: benchmark 200, supplier flow send->submit(score 61)->dedup 409->DB risk_score=39,
policy approve->attest(coverage 50%)->bump v2.0->diff(+2/-1)->pending ricompare.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 10:36:39 +02:00

350 lines
14 KiB
PHP

<?php
/**
* NIS2 Agile - Policy Controller
*
* Gestione policy e procedure di sicurezza, con AI generation.
*/
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/AIService.php';
require_once APP_PATH . '/services/WebhookService.php';
class PolicyController extends BaseController
{
public function list(): void
{
$this->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)
try {
Database::insert('policy_versions', [
'policy_id' => $id,
'organization_id' => $this->getCurrentOrgId(),
'version' => $policy['version'] ?? '1.0',
'content' => $policy['content'] ?? null,
'change_note' => $this->getParam('change_note') ?: 'Approvazione',
'created_by' => $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']);
$setA = array_count_values(array_map('trim', $linesA));
$setB = array_count_values(array_map('trim', $linesB));
$added = []; $removed = [];
foreach ($linesB as $l) { $t = trim($l); if ($t !== '' && empty($setA[$t])) $added[] = $l; }
foreach ($linesA as $l) { $t = trim($l); if ($t !== '' && empty($setB[$t])) $removed[] = $l; }
$this->jsonSuccess([
'from' => ['id' => $fromId, 'version' => $a['version']],
'to' => ['id' => $toId, 'version' => $b['version']],
'added' => $added,
'removed' => $removed,
'summary' => ['added' => count($added), 'removed' => count($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;
}
}