niente FK; esistenza verificata * in PHP. I nomi tabella/colonna provengono SEMPRE dalle const-map qui sotto * dopo validazione enum (mai da stringhe grezze della request). * - Le AZIONI sono capa_actions (figlie di non_conformities): 4.3 non le usa. */ require_once __DIR__ . '/BaseController.php'; class RaciController extends BaseController { private const MANAGE_ROLES = ['org_admin', 'compliance_manager']; /** * object_type => [tabella, colonna_label, ha_soft_delete] * I nomi sono hard-coded: dopo il gate enum sono sicuri da interpolare. */ private const OBJECT_MAP = [ 'inventory' => ['table' => 'assets', 'label' => 'name', 'soft_delete' => false], 'procedure' => ['table' => 'policies', 'label' => 'title', 'soft_delete' => true], 'risk' => ['table' => 'risks', 'label' => 'title', 'soft_delete' => true], 'supplier' => ['table' => 'suppliers', 'label' => 'name', 'soft_delete' => true], ]; /** * link_type => [tabella, a_col, a_table, a_label, b_col, b_table, b_label] * Per risk_measure il lato b e un codice ACN (stringa): b_table=null. */ private const LINK_MAP = [ 'procedure_inventory' => [ 'table' => 'procedure_inventory', 'a_col' => 'policy_id', 'a_table' => 'policies', 'a_label' => 'title', 'a_soft' => true, 'b_col' => 'asset_id', 'b_table' => 'assets', 'b_label' => 'name', 'b_soft' => false, ], 'procedure_risk' => [ 'table' => 'procedure_risk', 'a_col' => 'policy_id', 'a_table' => 'policies', 'a_label' => 'title', 'a_soft' => true, 'b_col' => 'risk_id', 'b_table' => 'risks', 'b_label' => 'title', 'b_soft' => true, ], 'inventory_risk' => [ 'table' => 'inventory_risk', 'a_col' => 'asset_id', 'a_table' => 'assets', 'a_label' => 'name', 'a_soft' => false, 'b_col' => 'risk_id', 'b_table' => 'risks', 'b_label' => 'title', 'b_soft' => true, ], 'risk_measure' => [ 'table' => 'risk_measure', 'a_col' => 'risk_id', 'a_table' => 'risks', 'a_label' => 'title', 'a_soft' => true, 'b_col' => 'measure_code', 'b_table' => null, 'b_label' => null, 'b_soft' => false, ], ]; /** @var array|null cache del catalogo misure ACN */ private static $measuresCache = null; // ═══════════════════════════════════════════════════════════════════════ // MATRICE RACI // ═══════════════════════════════════════════════════════════════════════ /** * GET /api/raci/matrix * { roles:[{role_id, role_name, is_governance_body, holder_user_id}], * objects:[{type, id, label}], assignments:[{role_id, object_type, object_id, raci}] } */ public function matrix(): void { $this->requireOrgAccess(); $orgId = $this->getCurrentOrgId(); $roleRows = Database::fetchAll( 'SELECT id, role_name, is_governance_body, holder_user_id FROM org_roles WHERE organization_id = ? ORDER BY sort_order, role_name', [$orgId] ); $roles = array_map(fn($r) => [ 'role_id' => (int) $r['id'], 'role_name' => $r['role_name'], 'is_governance_body' => (bool) $r['is_governance_body'], 'holder_user_id' => $r['holder_user_id'] !== null ? (int) $r['holder_user_id'] : null, ], $roleRows); // Oggetti linkabili: union dei 4 tipi, org-scoped, ordinati per tipo+label. $objects = []; foreach (self::OBJECT_MAP as $type => $cfg) { foreach ($this->fetchObjectsForType($type, $orgId) as $o) { $objects[] = ['type' => $type, 'id' => $o['id'], 'label' => $o['label']]; } } $assignRows = Database::fetchAll( 'SELECT role_id, object_type, object_id, raci FROM raci_assignments WHERE organization_id = ?', [$orgId] ); $assignments = array_map(fn($a) => [ 'role_id' => (int) $a['role_id'], 'object_type' => $a['object_type'], 'object_id' => (int) $a['object_id'], 'raci' => $a['raci'], ], $assignRows); $this->jsonSuccess([ 'roles' => $roles, 'objects' => $objects, 'assignments' => $assignments, ]); } /** * POST /api/raci/assign — {role_id, object_type, object_id, raci} * Upsert: una sola RACI per (role_id, object_type, object_id) nell'org. */ public function assign(): void { $this->requireOrgRole(self::MANAGE_ROLES); $this->validateRequired(['role_id', 'object_type', 'object_id', 'raci']); $orgId = $this->getCurrentOrgId(); $roleId = (int) $this->getParam('role_id'); $type = $this->validateObjectType($this->getParam('object_type')); $objId = (int) $this->getParam('object_id'); $raci = $this->validateRaci($this->getParam('raci')); $this->fetchRoleOrFail($roleId); $this->assertObjectExists($type, $objId); $existing = Database::fetchOne( 'SELECT id FROM raci_assignments WHERE organization_id = ? AND role_id = ? AND object_type = ? AND object_id = ?', [$orgId, $roleId, $type, $objId] ); if ($existing) { $id = (int) $existing['id']; Database::update('raci_assignments', ['raci' => $raci], 'id = ?', [$id]); $created = false; } else { $id = Database::insert('raci_assignments', [ 'organization_id' => $orgId, 'role_id' => $roleId, 'object_type' => $type, 'object_id' => $objId, 'raci' => $raci, 'created_by' => $this->getCurrentUserId(), ]); $created = true; } $this->logAudit('raci_assigned', 'raci_assignment', $id, [ 'role_id' => $roleId, 'object_type' => $type, 'object_id' => $objId, 'raci' => $raci, ]); $this->jsonSuccess(['id' => $id, 'raci' => $raci], $created ? 'RACI assegnata' : 'RACI aggiornata', $created ? 201 : 200); } /** * DELETE /api/raci/assign?role_id=&object_type=&object_id= * (parametri via query string: il DELETE di api.js non porta body.) */ public function unassign(): void { $this->requireOrgRole(self::MANAGE_ROLES); $this->validateRequired(['role_id', 'object_type', 'object_id']); $orgId = $this->getCurrentOrgId(); $roleId = (int) $this->getParam('role_id'); $type = $this->validateObjectType($this->getParam('object_type')); $objId = (int) $this->getParam('object_id'); $count = Database::count( 'raci_assignments', 'organization_id = ? AND role_id = ? AND object_type = ? AND object_id = ?', [$orgId, $roleId, $type, $objId] ); if ($count === 0) { $this->jsonError('Assegnazione RACI non trovata', 404, 'RACI_NOT_FOUND'); } Database::delete( 'raci_assignments', 'organization_id = ? AND role_id = ? AND object_type = ? AND object_id = ?', [$orgId, $roleId, $type, $objId] ); $this->logAudit('raci_unassigned', 'raci_assignment', null, [ 'role_id' => $roleId, 'object_type' => $type, 'object_id' => $objId, ]); $this->jsonSuccess(null, 'RACI rimossa'); } // ═══════════════════════════════════════════════════════════════════════ // OGGETTI LINKABILI (select) // ═══════════════════════════════════════════════════════════════════════ /** * GET /api/raci/objects?type= * type in {inventory,procedure,risk,supplier} -> [{id, label}] dell'org; * type=measure -> misure ACN dal catalogo JSON [{code, label}]. */ public function objects(): void { $this->requireOrgAccess(); $type = (string) $this->getParam('type', ''); if ($type === 'measure') { $out = []; foreach ($this->loadMeasures() as $m) { $code = (string) ($m['code'] ?? ''); if ($code === '') { continue; } $out[] = ['code' => $code, 'label' => $code . ' — ' . ($m['title'] ?? '')]; } $this->jsonSuccess($out); } $type = $this->validateObjectType($type); $this->jsonSuccess($this->fetchObjectsForType($type, $this->getCurrentOrgId())); } // ═══════════════════════════════════════════════════════════════════════ // LINK MOLTI-A-MOLTI // ═══════════════════════════════════════════════════════════════════════ /** * GET /api/raci/links?link_type=&id= * $id = id del lato "a" (policy/asset/risk). Ritorna i lati "b" collegati con label. */ public function links(): void { $this->requireOrgAccess(); $linkType = $this->validateLinkType($this->getParam('link_type')); $aId = (int) $this->getParam('id'); $orgId = $this->getCurrentOrgId(); $cfg = self::LINK_MAP[$linkType]; if ($linkType === 'risk_measure') { $rows = Database::fetchAll( 'SELECT id AS link_id, measure_code FROM risk_measure WHERE organization_id = ? AND risk_id = ? ORDER BY measure_code', [$orgId, $aId] ); $out = array_map(fn($r) => [ 'link_id' => (int) $r['link_id'], 'measure_code' => $r['measure_code'], 'measure_label' => $this->measureLabel($r['measure_code']), ], $rows); $this->jsonSuccess($out); } // Join sul lato b per la label. Nomi tabella/colonna dalla const-map (sicuri). $bTable = $cfg['b_table']; $bCol = $cfg['b_col']; $bLabel = $cfg['b_label']; $linkTbl = $cfg['table']; $aCol = $cfg['a_col']; $bSoftClause = $cfg['b_soft'] ? ' AND b.deleted_at IS NULL' : ''; $rows = Database::fetchAll( "SELECT l.id AS link_id, l.{$bCol} AS b_id, b.{$bLabel} AS b_label FROM {$linkTbl} l JOIN {$bTable} b ON b.id = l.{$bCol} WHERE l.organization_id = ? AND l.{$aCol} = ?{$bSoftClause} ORDER BY b.{$bLabel}", [$orgId, $aId] ); $out = array_map(fn($r) => [ 'link_id' => (int) $r['link_id'], 'b_id' => (int) $r['b_id'], 'b_label' => $r['b_label'], ], $rows); $this->jsonSuccess($out); } /** * POST /api/raci/link — {link_type, a_id, b_id} * Idempotente: se il link esiste gia ritorna 200 {existed:true}. * Per risk_measure il lato b e il codice misura (stringa, validato sul JSON). */ public function link(): void { $this->requireOrgRole(self::MANAGE_ROLES); $this->validateRequired(['link_type', 'a_id', 'b_id']); $orgId = $this->getCurrentOrgId(); $linkType = $this->validateLinkType($this->getParam('link_type')); $cfg = self::LINK_MAP[$linkType]; $aId = (int) $this->getParam('a_id'); // Lato a: sempre un oggetto org-scoped. $this->assertRowExists($cfg['a_table'], $aId, $orgId, $cfg['a_soft']); if ($linkType === 'risk_measure') { $code = trim((string) $this->getParam('b_id')); $this->assertMeasureExists($code); $existing = Database::fetchOne( 'SELECT id FROM risk_measure WHERE organization_id = ? AND risk_id = ? AND measure_code = ?', [$orgId, $aId, $code] ); if ($existing) { $this->jsonSuccess(['id' => (int) $existing['id'], 'existed' => true], 'Collegamento gia presente', 200); } $id = Database::insert('risk_measure', [ 'organization_id' => $orgId, 'risk_id' => $aId, 'measure_code' => $code, 'created_by' => $this->getCurrentUserId(), ]); $this->logAudit('raci_link_created', 'risk_measure', $id, ['risk_id' => $aId, 'measure_code' => $code]); $this->jsonSuccess(['id' => $id, 'existed' => false], 'Collegamento creato', 201); } // Lato b numerico (asset/risk), org-scoped. $bId = (int) $this->getParam('b_id'); $this->assertRowExists($cfg['b_table'], $bId, $orgId, $cfg['b_soft']); $existing = Database::fetchOne( "SELECT id FROM {$cfg['table']} WHERE organization_id = ? AND {$cfg['a_col']} = ? AND {$cfg['b_col']} = ?", [$orgId, $aId, $bId] ); if ($existing) { $this->jsonSuccess(['id' => (int) $existing['id'], 'existed' => true], 'Collegamento gia presente', 200); } $id = Database::insert($cfg['table'], [ 'organization_id' => $orgId, $cfg['a_col'] => $aId, $cfg['b_col'] => $bId, 'created_by' => $this->getCurrentUserId(), ]); $this->logAudit('raci_link_created', $cfg['table'], $id, [$cfg['a_col'] => $aId, $cfg['b_col'] => $bId]); $this->jsonSuccess(['id' => $id, 'existed' => false], 'Collegamento creato', 201); } /** * DELETE /api/raci/link?link_type=&a_id=&b_id= * (parametri via query string.) Per risk_measure b_id = codice misura. */ public function unlink(): void { $this->requireOrgRole(self::MANAGE_ROLES); $this->validateRequired(['link_type', 'a_id', 'b_id']); $orgId = $this->getCurrentOrgId(); $linkType = $this->validateLinkType($this->getParam('link_type')); $cfg = self::LINK_MAP[$linkType]; $aId = (int) $this->getParam('a_id'); if ($linkType === 'risk_measure') { $code = trim((string) $this->getParam('b_id')); $where = 'organization_id = ? AND risk_id = ? AND measure_code = ?'; $params = [$orgId, $aId, $code]; } else { $bId = (int) $this->getParam('b_id'); $where = "organization_id = ? AND {$cfg['a_col']} = ? AND {$cfg['b_col']} = ?"; $params = [$orgId, $aId, $bId]; } if (Database::count($cfg['table'], $where, $params) === 0) { $this->jsonError('Collegamento non trovato', 404, 'LINK_NOT_FOUND'); } Database::delete($cfg['table'], $where, $params); $this->logAudit('raci_link_removed', $cfg['table'], null, ['link_type' => $linkType, 'a_id' => $aId]); $this->jsonSuccess(null, 'Collegamento rimosso'); } // ═══════════════════════════════════════════════════════════════════════ // PRIVATI // ═══════════════════════════════════════════════════════════════════════ /** Elenco oggetti di un tipo per l'org corrente: [{id, label}] (esclude soft-deleted). */ private function fetchObjectsForType(string $type, int $orgId): array { $cfg = self::OBJECT_MAP[$type]; $table = $cfg['table']; $label = $cfg['label']; $softClause = $cfg['soft_delete'] ? ' AND deleted_at IS NULL' : ''; $rows = Database::fetchAll( "SELECT id, {$label} AS label FROM {$table} WHERE organization_id = ?{$softClause} ORDER BY {$label}", [$orgId] ); return array_map(fn($r) => ['id' => (int) $r['id'], 'label' => $r['label']], $rows); } /** Ruolo dell'organigramma nell'org corrente oppure 404 (anti-IDOR). */ private function fetchRoleOrFail(int $id): array { $r = Database::fetchOne('SELECT * FROM org_roles WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); if (!$r) { $this->jsonError('Ruolo non trovato', 404, 'ROLE_NOT_FOUND'); } return $r; } /** Verifica che l'oggetto (assets/policies/risks/suppliers) esista nell'org. */ private function assertObjectExists(string $type, int $id): void { $cfg = self::OBJECT_MAP[$type]; $this->assertRowExists($cfg['table'], $id, $this->getCurrentOrgId(), $cfg['soft_delete']); } /** Verifica generica di esistenza riga org-scoped (tabella dalla const-map). */ private function assertRowExists(string $table, int $id, int $orgId, bool $soft): void { $where = 'id = ? AND organization_id = ?'; $params = [$id, $orgId]; if ($soft) { $where .= ' AND deleted_at IS NULL'; } if (Database::count($table, $where, $params) === 0) { $this->jsonError('Oggetto non valido per questa organizzazione', 422, 'INVALID_OBJECT'); } } private function validateObjectType($t): string { $t = (string) $t; if (!isset(self::OBJECT_MAP[$t])) { $this->jsonError('Tipo oggetto non valido', 422, 'INVALID_OBJECT_TYPE'); } return $t; } private function validateRaci($r): string { $r = strtoupper((string) $r); if (!in_array($r, ['R', 'A', 'C', 'I'], true)) { $this->jsonError('Valore RACI non valido (atteso R/A/C/I)', 422, 'INVALID_RACI'); } return $r; } private function validateLinkType($lt): string { $lt = (string) $lt; if (!isset(self::LINK_MAP[$lt])) { $this->jsonError('Tipo di collegamento non valido', 422, 'INVALID_LINK_TYPE'); } return $lt; } /** * Carica e memoizza il catalogo misure dalla tabella canonica cfg_nis2_misure * (Epic C / C1). Forma di ritorno invariata (['code','title']) per compatibilità * con assertMeasureExists/measureLabel. Sostituisce il vecchio acn_measures.json. */ private function loadMeasures(): array { if (self::$measuresCache === null) { $rows = Database::fetchAll('SELECT misura_code, misura_descr FROM cfg_nis2_misure ORDER BY ord ASC'); self::$measuresCache = array_map(static function ($r) { return ['code' => $r['misura_code'], 'title' => $r['misura_descr']]; }, $rows); } return self::$measuresCache; } /** Verifica che il codice misura esista nel catalogo ACN. */ private function assertMeasureExists(string $code): void { foreach ($this->loadMeasures() as $m) { if ((string) ($m['code'] ?? '') === $code) { return; } } $this->jsonError('Misura ACN non valida', 422, 'INVALID_MEASURE'); } /** Label leggibile per un codice misura ('CODE — titolo'); fallback al solo codice. */ private function measureLabel(string $code): string { foreach ($this->loadMeasures() as $m) { if ((string) ($m['code'] ?? '') === $code) { return $code . ' — ' . ($m['title'] ?? ''); } } return $code; } }