[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:
co-authored by
Claude Opus 4.8
parent
dcb9a14f0f
commit
8540b53cb2
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user