From e245e766710ce0e63edd51eded9f81def1c03ebe Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sat, 20 Jun 2026 14:25:07 +0200 Subject: [PATCH] =?UTF-8?q?[FEAT]=20Permessi=20documentali=20profilabili?= =?UTF-8?q?=20per=20ruolo=20(Impostazioni=20>=20Permessi=20documentali)=20?= =?UTF-8?q?=E2=80=94=20v1.24.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../cli/migrate_061_doc_permissions.php | 20 ++++ .../controllers/IsmsModelController.php | 96 +++++++++++++++++-- docs/sql/061_doc_permissions.sql | 16 ++++ public/index.php | 2 + public/settings.html | 62 +++++++++++- public/version.json | 2 +- 6 files changed, 188 insertions(+), 10 deletions(-) create mode 100644 application/cli/migrate_061_doc_permissions.php create mode 100644 docs/sql/061_doc_permissions.sql diff --git a/application/cli/migrate_061_doc_permissions.php b/application/cli/migrate_061_doc_permissions.php new file mode 100644 index 0000000..d076f8d --- /dev/null +++ b/application/cli/migrate_061_doc_permissions.php @@ -0,0 +1,20 @@ +exec("CREATE TABLE IF NOT EXISTS isms_doc_permissions ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + capability VARCHAR(20) NOT NULL, + role VARCHAR(30) NOT NULL, + allowed TINYINT(1) NOT NULL DEFAULT 1, + updated_at DATETIME NULL, + UNIQUE KEY uk_docperm (organization_id, capability, role), + INDEX idx_docperm_org (organization_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4"); +echo "Migrazione 061 — tabella isms_doc_permissions OK. Prossima mig=062.\n"; diff --git a/application/controllers/IsmsModelController.php b/application/controllers/IsmsModelController.php index 3bcf051..49a680c 100644 --- a/application/controllers/IsmsModelController.php +++ b/application/controllers/IsmsModelController.php @@ -323,7 +323,7 @@ class IsmsModelController extends BaseController /** PUT /api/isms/documents/{id} */ public function updateDocument(int $id): void { - $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']); + $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']]); @@ -379,10 +379,44 @@ class IsmsModelController extends BaseController ]); } + /* ── 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->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']); + $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]); @@ -393,7 +427,7 @@ class IsmsModelController extends BaseController /** POST /api/isms/documents/{id}/approve in revisione -> approvato */ public function approveDocument(int $id): void { - $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']); + $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'); @@ -408,7 +442,7 @@ class IsmsModelController extends BaseController /** POST /api/isms/documents/{id}/publish approvato -> pubblicato (in vigore) */ public function publishDocument(int $id): void { - $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']); + $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')); @@ -424,7 +458,7 @@ class IsmsModelController extends BaseController /** POST /api/isms/documents/{id}/reject in revisione -> bozza (con nota) */ public function rejectDocument(int $id): void { - $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']); + $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'] ?? '')); @@ -436,7 +470,7 @@ class IsmsModelController extends BaseController /** POST /api/isms/documents/{id}/archive pubblicato -> archiviato */ public function archiveDocument(int $id): void { - $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']); + $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'); @@ -448,7 +482,7 @@ class IsmsModelController extends BaseController /** POST /api/isms/documents/{id}/newVersion pubblicato/archiviato -> nuova bozza */ public function newVersionDocument(int $id): void { - $this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']); + $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']); @@ -563,6 +597,54 @@ class IsmsModelController extends BaseController 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). diff --git a/docs/sql/061_doc_permissions.sql b/docs/sql/061_doc_permissions.sql new file mode 100644 index 0000000..770a39f --- /dev/null +++ b/docs/sql/061_doc_permissions.sql @@ -0,0 +1,16 @@ +-- 061_doc_permissions.sql — Permessi documentali profilabili per ruolo (org-scoped). +-- Matrice Ruolo x Azione del ciclo di vita documenti ISMS (Impostazioni > Permessi documentali). +-- Apply autoritativo idempotente: application/cli/migrate_061_doc_permissions.php +-- Default (se nessun override): edit/submit/approve/publish/reject/archive/new_version = +-- org_admin, compliance_manager, board_member. super_admin sempre consentito. + +CREATE TABLE IF NOT EXISTS isms_doc_permissions ( + id INT AUTO_INCREMENT PRIMARY KEY, + organization_id INT NOT NULL, + capability VARCHAR(20) NOT NULL, -- edit|submit|approve|publish|reject|archive|new_version + role VARCHAR(30) NOT NULL, -- org_admin|compliance_manager|board_member|auditor|employee|consultant + allowed TINYINT(1) NOT NULL DEFAULT 1, + updated_at DATETIME NULL, + UNIQUE KEY uk_docperm (organization_id, capability, role), + INDEX idx_docperm_org (organization_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/public/index.php b/public/index.php index 7cfdb89..ea7128d 100644 --- a/public/index.php +++ b/public/index.php @@ -302,6 +302,8 @@ $actionMap = [ 'GET:documents/{subId}/versions' => 'documentVersions', 'GET:documents/{subId}/word' => 'exportDocumentWord', // download .doc Word 'GET:documentsWordAll' => 'exportAllDocumentsWord', // download UNICO di tutto il set + 'GET:docPermissions' => 'getDocPermissions', // matrice permessi documentali (Ruolo x Azione) + 'PUT:docPermissions' => 'saveDocPermissions', 'GET:documents/{subId}' => 'getDocument', // documento completo (editor) — {subId} numerico 'GET:readiness' => 'readiness', 'GET:export' => 'export', diff --git a/public/settings.html b/public/settings.html index 604b6de..a093d63 100644 --- a/public/settings.html +++ b/public/settings.html @@ -229,6 +229,7 @@ + @@ -521,6 +522,21 @@ + +
+
+

Permessi documentali (ciclo di vita SGSI)

+
+

Definisci quali ruoli possono compiere ogni azione del ciclo di vita dei documenti del Modello SGSI (ISO/IEC 27001 cl. 7.5). La configurazione vale per questa organizzazione; il super amministratore ha sempre tutti i permessi.

+
+
+ + +
+
+
+
+
@@ -718,8 +734,8 @@ document.querySelectorAll('.settings-tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); - const tabMap = { org: 0, profile: 1, preferences: 2, members: 3, security: 4, apikeys: 5, webhooks: 6, branding: 7 }; - const panelMap = { org: 'tab-org', profile: 'tab-profile', preferences: 'tab-preferences', members: 'tab-members', security: 'tab-security', apikeys: 'tab-apikeys', webhooks: 'tab-webhooks', branding: 'tab-branding' }; + const tabMap = { org: 0, profile: 1, preferences: 2, members: 3, docperms: 4, security: 5, apikeys: 6, webhooks: 7, branding: 8 }; + const panelMap = { org: 'tab-org', profile: 'tab-profile', preferences: 'tab-preferences', members: 'tab-members', docperms: 'tab-docperms', security: 'tab-security', apikeys: 'tab-apikeys', webhooks: 'tab-webhooks', branding: 'tab-branding' }; document.querySelectorAll('.settings-tab')[tabMap[tab]].classList.add('active'); document.getElementById(panelMap[tab]).classList.add('active'); @@ -729,9 +745,51 @@ if (tab === 'security') { loadSessions(); loadAuditLog(); } if (tab === 'apikeys') loadApiKeys(); if (tab === 'webhooks') { loadWebhooks(); loadDeliveries(); } + if (tab === 'docperms') loadDocPerms(); if (tab === 'branding') loadBranding(); } + // ── Permessi documentali (matrice Ruolo x Azione) ── + const DOCPERM_CAP_LABELS = { edit:'Modifica contenuto', submit:'Invia in revisione', approve:'Approva', publish:'Pubblica', reject:'Rimanda in bozza', archive:'Archivia', new_version:'Nuova versione' }; + const DOCPERM_ROLE_LABELS = { org_admin:'Amministratore', compliance_manager:'Resp. SGSI/Compliance', board_member:'Direzione/CdA', auditor:'Auditor', employee:'Dipendente', consultant:'Consulente' }; + let _docpermCanEdit = false; + async function loadDocPerms(){ + const box = document.getElementById('docperms-matrix'); + box.innerHTML = '
'; + const r = await api.get('/isms/docPermissions'); + if (!r || !r.success){ box.innerHTML = '

'+((r&&r.message)||'Errore')+' — richiede un Modello SGSI avviato.

'; return; } + const caps = r.data.capabilities || []; + const roles = r.data.roleList || []; + _docpermCanEdit = !!r.data.canEdit; + let h = '
'; + h += ''; + roles.forEach(function(role){ h += ''; }); + h += ''; + caps.forEach(function(c){ + h += ''; + roles.forEach(function(role){ + const checked = c.roles[role] ? 'checked' : ''; + const dis = _docpermCanEdit ? '' : 'disabled'; + h += ''; + }); + h += ''; + }); + box.innerHTML = h + '
Azione'+(DOCPERM_ROLE_LABELS[role]||role)+'
'+(DOCPERM_CAP_LABELS[c.capability]||c.capability)+'
'; + const saveBtn = document.getElementById('docperms-save'); + const hint = document.getElementById('docperms-hint'); + if (saveBtn) saveBtn.style.display = _docpermCanEdit ? '' : 'none'; + if (hint) hint.textContent = _docpermCanEdit ? 'Suggerimento: lascia almeno un ruolo per ogni azione critica (approva, pubblica).' : 'Solo Amministratore o Direzione possono modificare i permessi.'; + } + async function saveDocPerms(){ + if (!_docpermCanEdit) return; + const boxes = document.querySelectorAll('#docperms-matrix input[type=checkbox]'); + const entries = []; + boxes.forEach(function(b){ entries.push({ capability:b.getAttribute('data-cap'), role:b.getAttribute('data-role'), allowed:b.checked }); }); + const hint = document.getElementById('docperms-hint'); hint.textContent = 'Salvataggio…'; + const r = await api.put('/isms/docPermissions', { entries: entries }); + hint.textContent = (r && r.success) ? 'Permessi salvati ✓' : ((r && r.message) || 'Errore nel salvataggio'); + } + // ── Preferenze utente (Fase 4 / G12 — UI) ───────────────── async function loadPreferences() { try { diff --git a/public/version.json b/public/version.json index 1d7fc15..f160c4a 100644 --- a/public/version.json +++ b/public/version.json @@ -1 +1 @@ -{"version": "1.24.1", "build": "2026-06-20-v1.24.1", "date": "2026-06-20", "changelog": "Gestione documentale ISMS — completata con: (1) download UNICO dell'intero set documentale in un solo file Word (.doc) con copertina e indice (endpoint /api/isms/documentsWordAll); (2) editor in-app del contenuto per le bozze e i documenti in revisione (barra strumenti grassetto/titoli/elenchi + modalita HTML), con guard che impedisce la modifica diretta dei documenti pubblicati (creare prima una nuova versione). Endpoint GET /api/isms/documents/{id} per il caricamento del documento nell'editor. Permessi di modifica estesi a board_member."} +{"version": "1.24.2", "build": "2026-06-20-v1.24.2", "date": "2026-06-20", "changelog": "Permessi documentali profilabili per ruolo. I diritti sul ciclo di vita dei documenti SGSI (modifica, invia in revisione, approva, pubblica, rimanda in bozza, archivia, nuova versione) non sono piu cablati: si configurano in Impostazioni > Permessi documentali con una matrice Ruolo x Azione (org_admin, compliance_manager, board_member, auditor, employee, consultant), per organizzazione. Migrazione 061 (tabella isms_doc_permissions). I controlli di ruolo nelle 7 transizioni usano ora requireDocCapability (con default = comportamento precedente); super_admin sempre consentito. Endpoint GET/PUT /api/isms/docPermissions (lettura aperta ai membri, scrittura ad Amministratore/Direzione)."}