Files
nis2-agile/application/controllers/IsmsModelController.php
T
DevEnv nis2-agileandClaude Opus 4.8 e245e76671 [FEAT] Permessi documentali profilabili per ruolo (Impostazioni > Permessi documentali) — v1.24.2
I diritti sul ciclo di vita dei documenti SGSI sono ora configurabili per ruolo (matrice Ruolo x Azione, org-scoped) invece che cablati.
- mig.061: tabella isms_doc_permissions (org x capability x role x allowed)
- IsmsModelController: requireDocCapability() sostituisce i requireOrgRole cablati nelle 7 transizioni (edit/submit/approve/publish/reject/archive/new_version); default = comportamento precedente; super_admin sempre ok; endpoint GET/PUT /api/isms/docPermissions (canEdit)
- settings.html: tab 'Permessi documentali' con matrice checkbox (lettura per i membri, salvataggio Amministratore/Direzione)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 14:25:07 +02:00

960 lines
47 KiB
PHP

<?php
/**
* NIS2 Agile - Modello Organizzativo SGSI (ISO/IEC 27001:2022)
* ----------------------------------------------------------------------------
* Procedura guidata per costruire un Sistema di Gestione della Sicurezza delle
* Informazioni (SGSI/ISMS) secondo ISO/IEC 27001:2022 (clausole 4-10) +
* Statement of Applicability (SoA) sui controlli Annex A:2022, estendibile con
* i controlli cloud ISO/IEC 27017:2015 e privacy-cloud ISO/IEC 27018:2019.
*
* Valore chiave: il SoA viene PRE-POPOLATO dalle risposte dell'assessment NIS2
* Art.21 (ogni domanda del questionario porta gia un iso27001_control), cosi un
* assessment NIS2 esistente produce una prima bozza di SoA.
*
* NON sostituisce la valutazione di un auditor: e uno strumento di supporto /
* pre-audit. Le bozze AI riportano sempre un disclaimer.
*
* Endpoint (base /api/isms):
* GET /model - SGSI dell'org (null se non iniziato)
* POST /model - crea/aggiorna SGSI (upsert) — clausole 4-6
* PUT /model - alias di POST (salva step)
* GET /annex-controls - dataset controlli applicabili (filtrato per flag cloud/PII)
* GET /soa - Statement of Applicability (auto-derivato al 1o accesso)
* POST /soa/derive - (ri)deriva lo stato iniziale dal NIS2
* PUT /soa - aggiorna un controllo del SoA (body: control_code, ...)
* GET /roles - ruoli/RACI del SGSI
* POST /roles - crea ruolo
* DELETE /roles/{id} - elimina ruolo
* GET /documents - documented information
* POST /documents - crea documento
* POST /documents/ai-generate - genera bozza documento via AI
* PUT /documents/{id} - aggiorna documento
* GET /readiness - % completamento SGSI + checklist clausole 4-10
* GET /export - export completo (model + SoA + ruoli + documenti)
*/
require_once __DIR__ . '/BaseController.php';
class IsmsModelController extends BaseController
{
private const STANDARD_LABELS = [
'iso27001' => '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 . ' &mdash; Versione ' . htmlspecialchars((string) ($doc['version'] ?? '1.0')) . ' &mdash; Stato: ' . $statusLabel;
if (!empty($doc['approved_at'])) $meta .= ' &mdash; Approvato il ' . date('d/m/Y', strtotime($doc['approved_at']));
if (!empty($doc['published_at'])) $meta .= ' &mdash; In vigore dal ' . date('d/m/Y', strtotime($doc['published_at']));
if (!empty($doc['next_review_date'])) $meta .= ' &mdash; Prossimo riesame: ' . date('d/m/Y', strtotime($doc['next_review_date']));
$title = htmlspecialchars((string) $doc['title']);
$body = $doc['body_html'] ?? '';
$html = "<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'>"
. "<head><meta charset='utf-8'><title>$title</title>"
. "<style>body{font-family:Calibri,Arial,sans-serif;font-size:11pt;color:#1b1b1b;line-height:1.4;}"
. "h1{font-size:18pt;color:#0066CC;}h2{font-size:14pt;color:#0066CC;}h3{font-size:12pt;color:#17324d;}"
. "table{border-collapse:collapse;width:100%;}td,th{border:1px solid #999;padding:5px;font-size:10pt;}th{background:#eef4fb;}"
. ".meta{color:#555;font-size:9pt;border-bottom:1px solid #ccc;padding-bottom:6px;margin-bottom:12px;}</style></head><body>"
. "<h1>$title</h1><p class='meta'>$meta</p>$body</body></html>";
$slug = trim(preg_replace('/[^A-Za-z0-9]+/', '_', (string) $doc['title']), '_');
$fname = $slug . '_v' . ($doc['version'] ?? '1.0') . '.doc';
header('Content-Type: application/msword; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $fname . '"');
echo $html;
exit;
}
/** GET /api/isms/documents/{id} — documento completo (per visualizzazione/editor) */
public function getDocument(int $id): void
{
$this->requireOrgAccess();
$doc = $this->requireDocument($id);
$this->jsonSuccess(['document' => $doc]);
}
/** GET /api/isms/documentsWordAll — scarica l'INTERO set documentale in un unico .doc (Word) */
public function exportAllDocumentsWord(): void
{
$this->requireOrgAccess();
$model = $this->requireModel();
$org = Database::fetchOne('SELECT name FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
$orgName = htmlspecialchars((string) ($org['name'] ?? ''));
$docs = Database::fetchAll(
"SELECT title, doc_type, status, version, body_html, published_at
FROM isms_documents WHERE isms_model_id = ? AND status <> 'archived' ORDER BY id",
[$model['id']]);
$statusLabel = ['draft' => 'Bozza', 'review' => 'In revisione', 'approved' => 'Approvato', 'published' => 'Pubblicato', 'archived' => 'Archiviato'];
$pageBreak = "<br clear='all' style='mso-special-character:line-break;page-break-before:always'>";
$cover = "<h1 style='font-size:24pt;'>Sistema di Gestione per la Sicurezza delle Informazioni</h1>"
. "<h2 style='color:#555;'>$orgName</h2>"
. "<p class='meta'>Raccolta documentale &mdash; generata il " . date('d/m/Y') . " &mdash; " . count($docs) . " documenti</p>";
$toc = "<h2>Indice</h2><ol>";
foreach ($docs as $d) {
$toc .= "<li>" . htmlspecialchars((string) $d['title']) . " <span style='color:#888;'>(v" . htmlspecialchars((string) ($d['version'] ?? '1.0')) . ")</span></li>";
}
$toc .= "</ol>";
$bodyAll = '';
foreach ($docs as $d) {
$sl = $statusLabel[$d['status']] ?? $d['status'];
$m = $orgName . ' &mdash; v' . htmlspecialchars((string) ($d['version'] ?? '1.0')) . ' &mdash; ' . $sl;
if (!empty($d['published_at'])) $m .= ' &mdash; in vigore dal ' . date('d/m/Y', strtotime($d['published_at']));
$bodyAll .= $pageBreak . "<h1>" . htmlspecialchars((string) $d['title']) . "</h1><p class='meta'>$m</p>" . ($d['body_html'] ?? '');
}
$html = "<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word' xmlns='http://www.w3.org/TR/REC-html40'>"
. "<head><meta charset='utf-8'><title>SGSI - $orgName</title>"
. "<style>body{font-family:Calibri,Arial,sans-serif;font-size:11pt;color:#1b1b1b;line-height:1.4;}"
. "h1{font-size:18pt;color:#0066CC;}h2{font-size:14pt;color:#0066CC;}h3{font-size:12pt;color:#17324d;}"
. "table{border-collapse:collapse;width:100%;}td,th{border:1px solid #999;padding:5px;font-size:10pt;}th{background:#eef4fb;}"
. ".meta{color:#555;font-size:9pt;border-bottom:1px solid #ccc;padding-bottom:6px;margin-bottom:12px;}</style></head><body>"
. $cover . $toc . $bodyAll . "</body></html>";
$slug = trim(preg_replace('/[^A-Za-z0-9]+/', '_', (string) ($org['name'] ?? 'SGSI')), '_');
header('Content-Type: application/msword; charset=utf-8');
header('Content-Disposition: attachment; filename="SGSI_' . $slug . '_completo.doc"');
echo $html;
exit;
}
/** GET /api/isms/docPermissions — matrice effettiva Ruolo x Azione (default + override org). Lettura aperta ai membri. */
public function getDocPermissions(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$canEdit = in_array((string) $this->currentOrgRole, ['super_admin', 'org_admin', 'board_member'], true);
$stored = [];
foreach (Database::fetchAll('SELECT capability, role, allowed FROM isms_doc_permissions WHERE organization_id = ?', [$orgId]) as $r) {
$stored[$r['capability'] . '|' . $r['role']] = (int) $r['allowed'];
}
$caps = [];
foreach (self::DOC_CAPS as $cap) {
$roles = [];
foreach (self::DOC_ROLES as $role) {
$key = $cap . '|' . $role;
$roles[$role] = array_key_exists($key, $stored)
? (bool) $stored[$key]
: in_array($role, self::DOC_CAP_DEFAULTS[$cap] ?? [], true);
}
$caps[] = ['capability' => $cap, 'roles' => $roles];
}
$this->jsonSuccess(['capabilities' => $caps, 'roleList' => self::DOC_ROLES, 'canEdit' => $canEdit]);
}
/** PUT /api/isms/docPermissions — salva la matrice. Body: { entries:[{capability,role,allowed}] } */
public function saveDocPermissions(): void
{
$this->requireOrgRole(['org_admin', 'board_member']);
$orgId = $this->getCurrentOrgId();
$entries = $this->getJsonBody()['entries'] ?? [];
if (!is_array($entries)) { $this->jsonError('Formato non valido', 400, 'BAD_REQUEST'); }
$now = date('Y-m-d H:i:s'); $count = 0;
foreach ($entries as $e) {
$cap = (string) ($e['capability'] ?? ''); $role = (string) ($e['role'] ?? '');
if (!in_array($cap, self::DOC_CAPS, true) || !in_array($role, self::DOC_ROLES, true)) { continue; }
$allowed = !empty($e['allowed']) ? 1 : 0;
$existing = Database::fetchOne('SELECT id FROM isms_doc_permissions WHERE organization_id = ? AND capability = ? AND role = ?', [$orgId, $cap, $role]);
if ($existing) {
Database::update('isms_doc_permissions', ['allowed' => $allowed, 'updated_at' => $now], 'id = ?', [$existing['id']]);
} else {
Database::insert('isms_doc_permissions', ['organization_id' => $orgId, 'capability' => $cap, 'role' => $role, 'allowed' => $allowed, 'updated_at' => $now]);
}
$count++;
}
$this->logAudit('isms_doc_permissions_updated', 'organization', $orgId, ['count' => $count]);
$this->jsonSuccess(['saved' => $count], 'Permessi documentali aggiornati');
}
/**
* 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<string,array{status:string,pct:int,sources: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;
}
}