[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:
co-authored by
Claude Opus 4.8
parent
d29195b2c1
commit
e245e76671
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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')+' — 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
@@ -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)."}
|
||||
|
||||
Reference in New Issue
Block a user