[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
@@ -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).