[FEAT] A4 Fase 4.1 — Organigramma (org_roles): ruoli/gerarchia + nodo governance Art.23

Primo modulo del modello relazionale A4 (docs/DESIGN_A4_RELATIONAL.md).

Backend:
- Migration 041 (docs/sql/041_org_roles.sql) + runner scripts/migrate-a4.php.
  Tabella org_roles additiva/idempotente: gerarchia self-FK (parent_role_id),
  titolare (holder_user_id), is_governance_body (organi amministrazione/direttivi
  Art.23 D.Lgs.138/2024), description (GV.RR-02). APPLICATA su prod (container
  nis2-db v8.0.45, TLSv1.3, 11 col, 4 FK).
- OrgRoleController: list (flat+tree arricchiti), get, create, update, delete,
  assignableUsers. Multi-tenancy ancorata a getCurrentOrgId(), anti-IDOR
  (id+organization_id), prevenzione cicli nella gerarchia, holder = membro org,
  delete bloccato se ha figli (409). Route registrate in public/index.php.

Frontend:
- public/organigramma.html + js/organigramma.js: vista ad albero (badge governance,
  titolare/vacante), editor crea/modifica/elimina con select padre anti-ciclo,
  "crea struttura di base". Bootstrap Italia V2.
- Voce sidebar "Organigramma" (common.js + common-bi.js) + nav.org_chart i18n IT/EN.
- api.js: metodi orgRole* (wrapper _acn).

Help/KB:
- help.js: guida contestuale 'org' (cosa rappresenta, nodo Art.23, come si usa,
  fonti certe D.Lgs.138/2024 art.23 + GV.RR-02 best practice, disclaimer no-parere-legale).

Cache-buster: bump ?v=20260617 dei 5 JS condivisi su tutte le 32 HTML referenti.
version.json 1.15.2 -> 1.16.0. Smoke E2E su prod (fpm reale): login, CRUD, tree,
anti-ciclo (422), delete-con-figli (409), cleanup tutti verdi.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-15 18:05:56 +02:00
co-authored by Claude Opus 4.8
parent dcb9a14f0f
commit 8540b53cb2
44 changed files with 994 additions and 126 deletions
@@ -0,0 +1,326 @@
<?php
/**
* NIS2 Agile - Organigramma (A4 Fase 4.1)
* ----------------------------------------------------------------------------
* Gestisce i ruoli organizzativi org-wide: gerarchia (parent_role_id), titolare
* (holder_user_id) e il nodo distinto degli organi di amministrazione/direttivi
* (is_governance_body) richiesto dall'Art. 23 D.Lgs. 138/2024 e da GV.RR-02.
*
* Multi-tenancy: tutte le query sono ancorate a getCurrentOrgId(); le scritture
* richiedono org_admin / compliance_manager (super_admin bypassa). Anti-IDOR:
* un ruolo si modifica solo se appartiene all'org corrente. Niente cicli nella
* gerarchia (un ruolo non puo discendere da se stesso).
*
* Design: docs/DESIGN_A4_RELATIONAL.md (sez. 2/4/7, Fase 4.1).
*/
require_once __DIR__ . '/BaseController.php';
class OrgRoleController extends BaseController
{
/** Ruoli per-org che possono modificare l'organigramma. */
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
/**
* GET /api/org-roles/list
* Ritorna i ruoli dell'org (flat, arricchiti) + l'albero gerarchico.
*/
public function list(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$rows = Database::fetchAll(
'SELECT r.id, r.organization_id, r.role_name, r.parent_role_id, r.holder_user_id,
r.is_governance_body, r.description, r.sort_order, r.created_at, r.updated_at,
u.full_name AS holder_name, u.email AS holder_email,
p.role_name AS parent_role_name
FROM org_roles r
LEFT JOIN users u ON u.id = r.holder_user_id
LEFT JOIN org_roles p ON p.id = r.parent_role_id
WHERE r.organization_id = ?
ORDER BY r.sort_order, r.role_name',
[$orgId]
);
$roles = array_map([$this, 'normalizeRow'], $rows);
$this->jsonSuccess([
'roles' => $roles,
'tree' => $this->buildTree($roles),
'governance_count' => count(array_filter($roles, fn($r) => $r['is_governance_body'])),
'total' => count($roles),
]);
}
/**
* GET /api/org-roles/assignableUsers
* Membri dell'org corrente (per la select del titolare).
*/
public function assignableUsers(): void
{
$this->requireOrgAccess();
$users = Database::fetchAll(
'SELECT u.id, u.full_name, u.email, uo.role AS org_role
FROM user_organizations uo
JOIN users u ON u.id = uo.user_id
WHERE uo.organization_id = ? AND u.is_active = 1
ORDER BY u.full_name',
[$this->getCurrentOrgId()]
);
$this->jsonSuccess($users);
}
/**
* GET /api/org-roles/{id}
*/
public function get(int $id): void
{
$this->requireOrgAccess();
$role = $this->fetchRoleOrFail($id);
$this->jsonSuccess($this->normalizeRow($role));
}
/**
* POST /api/org-roles/create
* body: {role_name, parent_role_id?, holder_user_id?, is_governance_body?, description?, sort_order?}
*/
public function create(): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$orgId = $this->getCurrentOrgId();
$name = trim((string) $this->getParam('role_name', ''));
if ($name === '') {
$this->jsonError('Il nome del ruolo e obbligatorio', 422, 'ROLE_NAME_REQUIRED');
}
if (mb_strlen($name) > 150) {
$this->jsonError('Il nome del ruolo supera 150 caratteri', 422, 'ROLE_NAME_TOO_LONG');
}
$parentId = $this->validateParent($this->getParam('parent_role_id'), null);
$holderId = $this->validateHolder($this->getParam('holder_user_id'));
$id = Database::insert('org_roles', [
'organization_id' => $orgId,
'role_name' => $name,
'parent_role_id' => $parentId,
'holder_user_id' => $holderId,
'is_governance_body' => $this->getParam('is_governance_body') ? 1 : 0,
'description' => $this->nullableText($this->getParam('description')),
'sort_order' => (int) $this->getParam('sort_order', 0),
'created_by' => $this->getCurrentUserId(),
]);
$this->logAudit('org_role_created', 'org_role', $id, [
'role_name' => $name, 'is_governance_body' => $this->getParam('is_governance_body') ? 1 : 0,
]);
$role = $this->fetchRoleOrFail($id);
$this->jsonSuccess($this->normalizeRow($role), 'Ruolo creato', 201);
}
/**
* PUT /api/org-roles/{id}
*/
public function update(int $id): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$this->fetchRoleOrFail($id); // anti-IDOR: deve appartenere all'org corrente
$updates = [];
if ($this->hasParam('role_name')) {
$name = trim((string) $this->getParam('role_name', ''));
if ($name === '') {
$this->jsonError('Il nome del ruolo e obbligatorio', 422, 'ROLE_NAME_REQUIRED');
}
if (mb_strlen($name) > 150) {
$this->jsonError('Il nome del ruolo supera 150 caratteri', 422, 'ROLE_NAME_TOO_LONG');
}
$updates['role_name'] = $name;
}
if ($this->hasParam('parent_role_id')) {
$updates['parent_role_id'] = $this->validateParent($this->getParam('parent_role_id'), $id);
}
if ($this->hasParam('holder_user_id')) {
$updates['holder_user_id'] = $this->validateHolder($this->getParam('holder_user_id'));
}
if ($this->hasParam('is_governance_body')) {
$updates['is_governance_body'] = $this->getParam('is_governance_body') ? 1 : 0;
}
if ($this->hasParam('description')) {
$updates['description'] = $this->nullableText($this->getParam('description'));
}
if ($this->hasParam('sort_order')) {
$updates['sort_order'] = (int) $this->getParam('sort_order', 0);
}
if (empty($updates)) {
$this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES');
}
Database::update('org_roles', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
$this->logAudit('org_role_updated', 'org_role', $id, $updates);
$role = $this->fetchRoleOrFail($id);
$this->jsonSuccess($this->normalizeRow($role), 'Ruolo aggiornato');
}
/**
* DELETE /api/org-roles/{id}
* Bloccato se il ruolo ha figli (vanno prima riassegnati/spostati).
*/
public function delete(int $id): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$this->fetchRoleOrFail($id);
$childCount = Database::count(
'org_roles',
'parent_role_id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if ($childCount > 0) {
$this->jsonError(
'Il ruolo ha ' . $childCount . ' ruoli subordinati: riassegnali o spostali prima di eliminarlo',
409,
'ROLE_HAS_CHILDREN'
);
}
Database::delete('org_roles', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
$this->logAudit('org_role_deleted', 'org_role', $id, null);
$this->jsonSuccess(null, 'Ruolo eliminato');
}
// ═══════════════════════════════════════════════════════════════════════
// PRIVATI
// ═══════════════════════════════════════════════════════════════════════
/** Recupera il ruolo solo se appartiene all'org corrente, altrimenti 404. */
private function fetchRoleOrFail(int $id): array
{
$role = Database::fetchOne(
'SELECT * FROM org_roles WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$role) {
$this->jsonError('Ruolo non trovato', 404, 'ROLE_NOT_FOUND');
}
return $role;
}
/**
* Valida il parent: deve appartenere all'org corrente e non creare cicli.
* $selfId = id del ruolo in modifica (null in create). Ritorna int|null.
*/
private function validateParent($parentRaw, ?int $selfId): ?int
{
if ($parentRaw === null || $parentRaw === '' || (int) $parentRaw === 0) {
return null; // ruolo radice
}
$parentId = (int) $parentRaw;
if ($selfId !== null && $parentId === $selfId) {
$this->jsonError('Un ruolo non puo essere padre di se stesso', 422, 'ROLE_SELF_PARENT');
}
$parent = Database::fetchOne(
'SELECT id, parent_role_id FROM org_roles WHERE id = ? AND organization_id = ?',
[$parentId, $this->getCurrentOrgId()]
);
if (!$parent) {
$this->jsonError('Ruolo padre non valido per questa organizzazione', 422, 'INVALID_PARENT');
}
// Anti-ciclo: risali la catena del padre proposto; se incontri $selfId, e un ciclo.
if ($selfId !== null) {
$cursor = $parent['parent_role_id'] !== null ? (int) $parent['parent_role_id'] : null;
$guard = 0;
while ($cursor !== null && $guard++ < 1000) {
if ($cursor === $selfId) {
$this->jsonError('Gerarchia non valida: si creerebbe un ciclo', 422, 'ROLE_CYCLE');
}
$row = Database::fetchOne(
'SELECT parent_role_id FROM org_roles WHERE id = ? AND organization_id = ?',
[$cursor, $this->getCurrentOrgId()]
);
$cursor = ($row && $row['parent_role_id'] !== null) ? (int) $row['parent_role_id'] : null;
}
}
return $parentId;
}
/** Valida il titolare: deve essere membro dell'org corrente. Ritorna int|null. */
private function validateHolder($holderRaw): ?int
{
if ($holderRaw === null || $holderRaw === '' || (int) $holderRaw === 0) {
return null; // ruolo vacante
}
$holderId = (int) $holderRaw;
$member = Database::fetchOne(
'SELECT id FROM user_organizations WHERE user_id = ? AND organization_id = ?',
[$holderId, $this->getCurrentOrgId()]
);
if (!$member) {
$this->jsonError('Il titolare deve essere un membro dell organizzazione', 422, 'HOLDER_NOT_MEMBER');
}
return $holderId;
}
private function nullableText($v): ?string
{
if ($v === null) {
return null;
}
$v = trim((string) $v);
return $v === '' ? null : $v;
}
/** Normalizza tipi per l'output JSON. */
private function normalizeRow(array $r): array
{
$r['id'] = (int) $r['id'];
$r['organization_id'] = (int) $r['organization_id'];
$r['parent_role_id'] = $r['parent_role_id'] !== null ? (int) $r['parent_role_id'] : null;
$r['holder_user_id'] = $r['holder_user_id'] !== null ? (int) $r['holder_user_id'] : null;
$r['is_governance_body'] = (bool) $r['is_governance_body'];
$r['sort_order'] = (int) ($r['sort_order'] ?? 0);
return $r;
}
/**
* Costruisce l'albero gerarchico da una lista flat. I ruoli con parent NULL
* o con parent assente dall'insieme sono radici (robusto a ON DELETE SET NULL).
*/
private function buildTree(array $rows): array
{
$ids = [];
foreach ($rows as $r) {
$ids[(int) $r['id']] = true;
}
$childrenByParent = [];
$roots = [];
foreach ($rows as $r) {
$pid = $r['parent_role_id'];
if ($pid !== null && isset($ids[(int) $pid])) {
$childrenByParent[(int) $pid][] = $r;
} else {
$roots[] = $r;
}
}
$attach = function (array $node) use (&$attach, $childrenByParent) {
$kids = $childrenByParent[(int) $node['id']] ?? [];
$node['children'] = array_map($attach, $kids);
return $node;
};
return array_map($attach, $roots);
}
}