art. 24 D.Lgs. 138/2024). La matrice di Mendelow * (potere/interesse) e' BUONA PRASSI, NON un obbligo NIS2. * * Multi-tenancy: ogni query filtra organization_id. Scritture: org_admin / * compliance_manager. Anti-IDOR su tutti i link (org_role_id/supplier_id/policy_id/ * stak_code) verificati appartenere all'org corrente (o di sistema per i tipi). * * NOTE strutturali: DB API Database::query/fetchAll/fetchOne/insert/update/delete * (NON Database::execute). jsonSuccess/jsonError fanno exit. */ require_once __DIR__ . '/BaseController.php'; class StakeholderController extends BaseController { private const MANAGE_ROLES = ['org_admin', 'compliance_manager']; // ───────────────────────────────────────────────────────────────────────── // LETTURE // ───────────────────────────────────────────────────────────────────────── /** * GET /api/stakeholders/list * Registro dell'org + quadrante calcolato + procedure collegate + i 4 quadranti * (per disegnare gli assi anche quando non ci sono stakeholder). */ public function list(): void { $this->requireOrgAccess(); $orgId = $this->getCurrentOrgId(); $quads = $this->loadQuadrants(); $rows = Database::fetchAll( 'SELECT s.id, s.stak_code, t.tipo, t.descr AS type_descr, s.name, s.org_role_id, r.role_name AS org_role_name, s.supplier_id, sup.name AS supplier_name, s.power, s.interest, s.contact_name, s.contact_email, s.notes, s.created_at, s.updated_at FROM stakeholders s JOIN cfg_stakeholder_types t ON t.code = s.stak_code LEFT JOIN org_roles r ON r.id = s.org_role_id LEFT JOIN suppliers sup ON sup.id = s.supplier_id WHERE s.organization_id = ? ORDER BY t.tipo ASC, s.stak_code ASC, s.name ASC', [$orgId] ); // Procedure collegate (m2m) per tutti gli stakeholder in un colpo solo $procMap = []; if ($rows) { $ids = array_map(static fn($r) => (int) $r['id'], $rows); $place = implode(',', array_fill(0, count($ids), '?')); foreach (Database::fetchAll( "SELECT sp.stakeholder_id, sp.policy_id, p.title AS policy_title FROM stakeholder_procedures sp JOIN policies p ON p.id = sp.policy_id WHERE sp.stakeholder_id IN ($place)", $ids ) as $lp) { $sid = (int) $lp['stakeholder_id']; $procMap[$sid][] = ['id' => (int) $lp['policy_id'], 'title' => $lp['policy_title']]; } } $stakeholders = []; foreach ($rows as $r) { $power = $r['power'] !== null ? (int) $r['power'] : null; $interest = $r['interest'] !== null ? (int) $r['interest'] : null; $rated = ($power !== null && $interest !== null); $quad = $rated ? $this->quadrantFor($quads, $power, $interest) : null; $sid = (int) $r['id']; $procs = $procMap[$sid] ?? []; $stakeholders[] = [ 'id' => $sid, 'stak_code' => $r['stak_code'], 'tipo' => $r['tipo'], 'kind' => $r['tipo'] === 'Interno' ? 'internal' : 'external', 'type_descr' => $r['type_descr'], 'name' => $r['name'], 'org_role_id' => $r['org_role_id'] !== null ? (int) $r['org_role_id'] : null, 'org_role_name' => $r['org_role_name'], 'supplier_id' => $r['supplier_id'] !== null ? (int) $r['supplier_id'] : null, 'supplier_name' => $r['supplier_name'], 'power' => $power, 'interest' => $interest, 'rated' => $rated, 'quadrant_code' => $quad['code'] ?? null, 'quadrant_label' => $quad['label'] ?? null, 'contact_name' => $r['contact_name'], 'contact_email' => $r['contact_email'], 'notes' => $r['notes'], 'policies' => $procs, 'policy_ids' => array_map(static fn($p) => $p['id'], $procs), 'updated_at' => $r['updated_at'], ]; } $this->jsonSuccess([ 'stakeholders' => $stakeholders, 'quadrants' => $quads, 'total' => count($stakeholders), 'mendelow_note'=> 'La matrice di Mendelow (potere/interesse) e\' una buona prassi di gestione degli stakeholder, non un obbligo NIS2. L\'obbligo e\' GV.SC-02 (ruoli e responsabilita verso fornitori, clienti e partner).', ]); } /** * GET /api/stakeholders/types * Tipi visibili all'org: 30 di sistema (organization_id NULL) + quelli aggiunti * dall'org. La codifica di sistema NON e' modificabile dall'utente. */ public function types(): void { $this->requireOrgAccess(); $orgId = $this->getCurrentOrgId(); $rows = Database::fetchAll( 'SELECT code, tipo, descr, (organization_id IS NULL) AS is_default, ord FROM cfg_stakeholder_types WHERE organization_id IS NULL OR organization_id = ? ORDER BY ord ASC, code ASC', [$orgId] ); $types = array_map(static fn($t) => [ 'code' => $t['code'], 'tipo' => $t['tipo'], 'descr' => $t['descr'], 'is_default' => ((int) $t['is_default'] === 1), ], $rows); $this->jsonSuccess(['types' => $types]); } /** * GET /api/stakeholders/quadrants — i 4 quadranti di sistema (per gli assi). */ public function quadrants(): void { $this->requireOrgAccess(); $this->jsonSuccess(['quadrants' => $this->loadQuadrants()]); } /** * GET /api/stakeholders/pickers — opzioni leggere per i selettori del form: * ruoli dell'organigramma (interni) + procedure (m2m) + fornitori (link esterni). */ public function pickers(): void { $this->requireOrgAccess(); $orgId = $this->getCurrentOrgId(); $roles = Database::fetchAll( 'SELECT id, role_name FROM org_roles WHERE organization_id = ? ORDER BY sort_order ASC, role_name ASC', [$orgId] ); $policies = Database::fetchAll( 'SELECT id, title FROM policies WHERE organization_id = ? AND deleted_at IS NULL ORDER BY title ASC', [$orgId] ); $suppliers = Database::fetchAll( 'SELECT id, name, stakeholder_type FROM suppliers WHERE organization_id = ? AND deleted_at IS NULL ORDER BY name ASC', [$orgId] ); $this->jsonSuccess([ 'org_roles' => array_map(static fn($r) => ['id' => (int) $r['id'], 'role_name' => $r['role_name']], $roles), 'policies' => array_map(static fn($p) => ['id' => (int) $p['id'], 'title' => $p['title']], $policies), 'suppliers' => array_map(static fn($s) => ['id' => (int) $s['id'], 'name' => $s['name'], 'stakeholder_type' => $s['stakeholder_type']], $suppliers), ]); } // ───────────────────────────────────────────────────────────────────────── // SCRITTURE // ───────────────────────────────────────────────────────────────────────── /** * POST /api/stakeholders/create * Body: {stak_code*, name*, org_role_id?, supplier_id?, power?, interest?, * contact_name?, contact_email?, notes?, policy_ids?:[]} */ public function create(): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $body = $this->getJsonBody(); $code = trim((string) ($body['stak_code'] ?? '')); $name = trim((string) ($body['name'] ?? '')); if ($code === '') { $this->jsonError('Tipo stakeholder (stak_code) obbligatorio', 422, 'MISSING_TYPE'); } if ($name === '' || mb_strlen($name) > 255) { $this->jsonError('Nome obbligatorio (max 255 caratteri)', 422, 'INVALID_NAME'); } $tipo = $this->resolveTypeTipo($code, $orgId); // 422 se non visibile $orgRoleId = $this->validateOrgRoleForKind($body['org_role_id'] ?? null, $tipo, $orgId); $supplierId = $this->validateSupplierForKind($body['supplier_id'] ?? null, $tipo, $orgId); $power = $this->validateScore($body['power'] ?? null, 'power'); $interest = $this->validateScore($body['interest'] ?? null, 'interest'); $policyIds = $this->validatePolicyIds($body['policy_ids'] ?? null, $orgId); // dup soft: stesso tipo + stesso nome nell'org $dup = Database::fetchOne( 'SELECT id FROM stakeholders WHERE organization_id = ? AND stak_code = ? AND name = ?', [$orgId, $code, $name] ); if ($dup) { $this->jsonError('Stakeholder gia\' presente con questo tipo e nome', 409, 'DUPLICATE'); } $id = Database::insert('stakeholders', [ 'organization_id' => $orgId, 'stak_code' => $code, 'name' => $name, 'org_role_id' => $orgRoleId, 'supplier_id' => $supplierId, 'power' => $power, 'interest' => $interest, 'contact_name' => $this->nullableStr($body['contact_name'] ?? null, 255), 'contact_email' => $this->nullableStr($body['contact_email'] ?? null, 255), 'notes' => $this->nullableStr($body['notes'] ?? null), 'created_by' => $this->getCurrentUserId(), ]); $this->syncPolicies((int) $id, $policyIds); $this->logAudit('stakeholder_created', 'stakeholder', (int) $id, ['stak_code' => $code, 'name' => $name]); $this->jsonSuccess(['id' => (int) $id], 'Stakeholder creato', 201); } /** * PUT /api/stakeholders/{id} — update parziale. */ public function update(int $id): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $body = $this->getJsonBody(); $existing = Database::fetchOne( 'SELECT id, stak_code FROM stakeholders WHERE id = ? AND organization_id = ?', [$id, $orgId] ); if (!$existing) { $this->jsonError('Stakeholder non trovato', 404, 'NOT_FOUND'); } // Il tipo (e quindi il "kind") puo' cambiare: determina quello effettivo $code = $existing['stak_code']; if ($this->hasParam('stak_code')) { $code = trim((string) ($body['stak_code'] ?? '')); if ($code === '') { $this->jsonError('Tipo stakeholder non valido', 422, 'INVALID_TYPE'); } } $tipo = $this->resolveTypeTipo($code, $orgId); $updates = []; if ($this->hasParam('stak_code')) { $updates['stak_code'] = $code; } if ($this->hasParam('name')) { $name = trim((string) ($body['name'] ?? '')); if ($name === '' || mb_strlen($name) > 255) { $this->jsonError('Nome non valido (max 255 caratteri)', 422, 'INVALID_NAME'); } $updates['name'] = $name; } // Link coerenti col kind: se cambia il tipo verso Interno azzero supplier (e viceversa) if ($this->hasParam('org_role_id') || $tipo === 'Esterno') { $updates['org_role_id'] = $this->validateOrgRoleForKind($body['org_role_id'] ?? null, $tipo, $orgId); } if ($this->hasParam('supplier_id') || $tipo === 'Interno') { $updates['supplier_id'] = $this->validateSupplierForKind($body['supplier_id'] ?? null, $tipo, $orgId); } if ($this->hasParam('power')) { $updates['power'] = $this->validateScore($body['power'] ?? null, 'power'); } if ($this->hasParam('interest')) { $updates['interest'] = $this->validateScore($body['interest'] ?? null, 'interest'); } if ($this->hasParam('contact_name')) { $updates['contact_name'] = $this->nullableStr($body['contact_name'] ?? null, 255); } if ($this->hasParam('contact_email')) { $updates['contact_email'] = $this->nullableStr($body['contact_email'] ?? null, 255); } if ($this->hasParam('notes')) { $updates['notes'] = $this->nullableStr($body['notes'] ?? null); } if (!empty($updates)) { Database::update('stakeholders', $updates, 'id = ? AND organization_id = ?', [$id, $orgId]); } if ($this->hasParam('policy_ids')) { $this->syncPolicies($id, $this->validatePolicyIds($body['policy_ids'] ?? null, $orgId)); } $this->logAudit('stakeholder_updated', 'stakeholder', $id, array_keys($updates)); $this->jsonSuccess(['id' => $id, 'updated' => array_keys($updates)], 'Stakeholder aggiornato'); } /** * DELETE /api/stakeholders/{id} */ public function delete(int $id): void { $this->requireOrgRole(['org_admin']); $deleted = Database::delete('stakeholders', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); if ($deleted === 0) { $this->jsonError('Stakeholder non trovato', 404, 'NOT_FOUND'); } $this->logAudit('stakeholder_deleted', 'stakeholder', $id); $this->jsonSuccess(null, 'Stakeholder eliminato'); } /** * POST /api/stakeholders/types — aggiunge un TIPO org-scoped, codifica * automatica proseguendo da Stak.31 (codice globalmente univoco: e' PK). * Body: {tipo*, descr*} */ public function addType(): void { $this->requireOrgRole(self::MANAGE_ROLES); $orgId = $this->getCurrentOrgId(); $body = $this->getJsonBody(); $tipo = ($body['tipo'] ?? '') === 'Interno' ? 'Interno' : (($body['tipo'] ?? '') === 'Esterno' ? 'Esterno' : ''); $descr = trim((string) ($body['descr'] ?? '')); if ($tipo === '') { $this->jsonError('Tipo deve essere "Interno" o "Esterno"', 422, 'INVALID_TIPO'); } if ($descr === '') { $this->jsonError('Descrizione obbligatoria', 422, 'INVALID_DESCR'); } // Codice successivo: MAX suffisso numerico tra TUTTI i codici Stak.NN (sistema + org). $next = $this->nextStakCode(); try { Database::insert('cfg_stakeholder_types', [ 'code' => $next, 'organization_id' => $orgId, 'tipo' => $tipo, 'descr' => $descr, 'ord' => 1000, // le voci org si ordinano dopo le 30 di sistema 'created_by' => $this->getCurrentUserId(), ]); } catch (PDOException $e) { // 1062 = race su PK duplicata: ritenta una volta con il codice ricalcolato if (($e->errorInfo[1] ?? 0) === 1062) { $next = $this->nextStakCode(); Database::insert('cfg_stakeholder_types', [ 'code' => $next, 'organization_id' => $orgId, 'tipo' => $tipo, 'descr' => $descr, 'ord' => 1000, 'created_by' => $this->getCurrentUserId(), ]); } else { throw $e; } } $this->logAudit('stakeholder_type_added', 'cfg_stakeholder_types', null, ['code' => $next, 'tipo' => $tipo]); $this->jsonSuccess(['code' => $next, 'tipo' => $tipo, 'descr' => $descr], 'Tipo stakeholder aggiunto', 201); } // ───────────────────────────────────────────────────────────────────────── // HELPER // ───────────────────────────────────────────────────────────────────────── /** Carica i 4 quadranti di sistema (ordinati). */ private function loadQuadrants(): array { $rows = Database::fetchAll( 'SELECT code, label, strategy, power_min, power_max, interest_min, interest_max, ord FROM cfg_stakeholder_quadrants WHERE organization_id IS NULL ORDER BY ord ASC, code ASC' ); return array_map(static fn($q) => [ 'code' => $q['code'], 'label' => $q['label'], 'strategy' => $q['strategy'], 'power_min' => (int) $q['power_min'], 'power_max' => (int) $q['power_max'], 'interest_min' => (int) $q['interest_min'], 'interest_max' => (int) $q['interest_max'], ], $rows); } /** Trova il quadrante che contiene (power, interest) dai range di config. */ private function quadrantFor(array $quads, int $power, int $interest): ?array { foreach ($quads as $q) { if ($power >= $q['power_min'] && $power <= $q['power_max'] && $interest >= $q['interest_min'] && $interest <= $q['interest_max']) { return $q; } } return null; } /** Verifica che il tipo sia visibile all'org (sistema o proprio) e ne ritorna il "tipo". */ private function resolveTypeTipo(string $code, int $orgId): string { $row = Database::fetchOne( 'SELECT tipo FROM cfg_stakeholder_types WHERE code = ? AND (organization_id IS NULL OR organization_id = ?)', [$code, $orgId] ); if (!$row) { $this->jsonError('Tipo stakeholder inesistente o non accessibile', 422, 'INVALID_TYPE'); } return $row['tipo']; } /** org_role consentito solo agli INTERNI; deve appartenere all'org (anti-IDOR). */ private function validateOrgRoleForKind($id, string $tipo, int $orgId): ?int { $id = ($id === null || $id === '') ? null : (int) $id; if ($id === null) { return null; } if ($tipo !== 'Interno') { $this->jsonError('Il collegamento all\'organigramma e\' previsto solo per stakeholder interni', 422, 'ROLE_NOT_ALLOWED'); } $row = Database::fetchOne('SELECT id FROM org_roles WHERE id = ? AND organization_id = ?', [$id, $orgId]); if (!$row) { $this->jsonError('Ruolo dell\'organigramma non valido', 422, 'INVALID_ORG_ROLE'); } return $id; } /** supplier consentito solo agli ESTERNI; deve appartenere all'org (anti-IDOR). */ private function validateSupplierForKind($id, string $tipo, int $orgId): ?int { $id = ($id === null || $id === '') ? null : (int) $id; if ($id === null) { return null; } if ($tipo !== 'Esterno') { $this->jsonError('Il collegamento a un fornitore e\' previsto solo per stakeholder esterni', 422, 'SUPPLIER_NOT_ALLOWED'); } $row = Database::fetchOne('SELECT id FROM suppliers WHERE id = ? AND organization_id = ?', [$id, $orgId]); if (!$row) { $this->jsonError('Fornitore collegato non valido', 422, 'INVALID_SUPPLIER'); } return $id; } /** Punteggio 0-5 o null (assente / vuoto = non valutato). */ private function validateScore($v, string $field): ?int { if ($v === null || $v === '') { return null; } if (!is_numeric($v)) { $this->jsonError("Valore $field non numerico", 422, 'INVALID_SCORE'); } $n = (int) $v; if ($n < 0 || $n > 5) { $this->jsonError("Il valore $field deve essere tra 0 e 5", 422, 'SCORE_OUT_OF_RANGE'); } return $n; } /** Normalizza array di policy_id e verifica che TUTTE appartengano all'org (anti-IDOR). */ private function validatePolicyIds($raw, int $orgId): array { if ($raw === null) { return []; } if (!is_array($raw)) { $this->jsonError('policy_ids deve essere un array', 422, 'INVALID_POLICIES'); } $ids = array_values(array_unique(array_filter(array_map('intval', $raw), static fn($i) => $i > 0))); if (!$ids) { return []; } $place = implode(',', array_fill(0, count($ids), '?')); $rows = Database::fetchAll( "SELECT id FROM policies WHERE id IN ($place) AND organization_id = ? AND deleted_at IS NULL", array_merge($ids, [$orgId]) ); if (count($rows) !== count($ids)) { $this->jsonError('Una o piu\' procedure collegate non sono valide', 422, 'INVALID_POLICIES'); } return $ids; } /** Sostituisce le procedure collegate (m2m) per uno stakeholder. */ private function syncPolicies(int $stakeholderId, array $policyIds): void { Database::delete('stakeholder_procedures', 'stakeholder_id = ?', [$stakeholderId]); foreach ($policyIds as $pid) { Database::insert('stakeholder_procedures', ['stakeholder_id' => $stakeholderId, 'policy_id' => $pid]); } } /** Prossimo codice Stak.NN (globale: il codice e' PK). */ private function nextStakCode(): string { $max = Database::fetchOne( "SELECT MAX(CAST(SUBSTRING(code, 6) AS UNSIGNED)) AS m FROM cfg_stakeholder_types WHERE code REGEXP '^Stak\\\\.[0-9]+$'" ); $n = ((int) ($max['m'] ?? 0)) + 1; return 'Stak.' . str_pad((string) $n, 2, '0', STR_PAD_LEFT); } private function nullableStr($v, ?int $max = null): ?string { if ($v === null) { return null; } $s = trim((string) $v); if ($s === '') { return null; } if ($max !== null && mb_strlen($s) > $max) { $s = mb_substr($s, 0, $max); } return $s; } }