[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>
This commit is contained in:
DevEnv nis2-agile
2026-06-20 14:25:07 +02:00
co-authored by Claude Opus 4.8
parent d29195b2c1
commit e245e76671
6 changed files with 188 additions and 10 deletions
@@ -0,0 +1,20 @@
<?php
/**
* migrate_061_doc_permissions.php — Permessi documentali profilabili per ruolo (org-scoped).
* Tabella matrice Ruolo x Azione del ciclo di vita documenti ISMS. IDEMPOTENTE.
*/
if (PHP_SAPI !== 'cli') { http_response_code(403); exit("CLI only\n"); }
require_once __DIR__ . '/../config/env.php';
require_once __DIR__ . '/../config/database.php';
$pdo = Database::getInstance();
$pdo->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";
@@ -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).
+16
View File
@@ -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;
+2
View File
@@ -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',
+60 -2
View File
@@ -229,6 +229,7 @@
<button class="settings-tab" onclick="switchTab('profile')">Profilo</button>
<button class="settings-tab" onclick="switchTab('preferences')">Preferenze</button>
<button class="settings-tab" onclick="switchTab('members')">Membri</button>
<button class="settings-tab" onclick="switchTab('docperms')">Permessi documentali</button>
<button class="settings-tab" onclick="switchTab('security')">Sicurezza</button>
<button class="settings-tab" onclick="switchTab('apikeys')">API Keys</button>
<button class="settings-tab" onclick="switchTab('webhooks')">Webhook</button>
@@ -521,6 +522,21 @@
</div>
</div>
<!-- ══════════════ TAB: Permessi documentali ══════════════ -->
<div class="tab-panel" id="tab-docperms">
<div class="card">
<div class="card-header"><h3>Permessi documentali (ciclo di vita SGSI)</h3></div>
<div class="card-body">
<p class="text-muted" style="margin-top:0;">Definisci quali <strong>ruoli</strong> 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.</p>
<div id="docperms-matrix"><div class="spinner" style="margin:30px auto;"></div></div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:14px;">
<span id="docperms-hint" class="text-muted" style="font-size:.85rem;"></span>
<button class="btn btn-primary" id="docperms-save" onclick="saveDocPerms()">Salva permessi</button>
</div>
</div>
</div>
</div>
<!-- ══════════════ TAB: Sicurezza ══════════════ -->
<div class="tab-panel" id="tab-security">
<div class="card mb-24">
@@ -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 = '<div class="spinner" style="margin:30px auto;"></div>';
const r = await api.get('/isms/docPermissions');
if (!r || !r.success){ box.innerHTML = '<p class="text-muted">'+((r&&r.message)||'Errore')+' &mdash; richiede un Modello SGSI avviato.</p>'; return; }
const caps = r.data.capabilities || [];
const roles = r.data.roleList || [];
_docpermCanEdit = !!r.data.canEdit;
let h = '<div style="overflow-x:auto;"><table style="width:100%;border-collapse:collapse;font-size:.88rem;">';
h += '<tr><th style="text-align:left;padding:8px;border-bottom:2px solid #e5e7eb;">Azione</th>';
roles.forEach(function(role){ h += '<th style="padding:8px;border-bottom:2px solid #e5e7eb;text-align:center;">'+(DOCPERM_ROLE_LABELS[role]||role)+'</th>'; });
h += '</tr>';
caps.forEach(function(c){
h += '<tr><td style="padding:8px;border-bottom:1px solid #f1f5f9;font-weight:600;">'+(DOCPERM_CAP_LABELS[c.capability]||c.capability)+'</td>';
roles.forEach(function(role){
const checked = c.roles[role] ? 'checked' : '';
const dis = _docpermCanEdit ? '' : 'disabled';
h += '<td style="padding:8px;border-bottom:1px solid #f1f5f9;text-align:center;"><input type="checkbox" data-cap="'+c.capability+'" data-role="'+role+'" '+checked+' '+dis+' style="width:18px;height:18px;cursor:pointer;"></td>';
});
h += '</tr>';
});
box.innerHTML = h + '</table></div>';
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 {
+1 -1
View File
@@ -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)."}