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 @@ + +
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.
+'+((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 = '| Azione | '; + roles.forEach(function(role){ h += ''+(DOCPERM_ROLE_LABELS[role]||role)+' | '; }); + h += '
|---|---|
| '+(DOCPERM_CAP_LABELS[c.capability]||c.capability)+' | '; + roles.forEach(function(role){ + const checked = c.roles[role] ? 'checked' : ''; + const dis = _docpermCanEdit ? '' : 'disabled'; + h += ''; + }); + h += ' |