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>
327 lines
12 KiB
PHP
327 lines
12 KiB
PHP
<?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);
|
|
}
|
|
}
|