requireOrgAccess(); $pagination = $this->getPagination(); $where = 'organization_id = ?'; $params = [$this->getCurrentOrgId()]; if ($this->hasParam('asset_type')) { $where .= ' AND asset_type = ?'; $params[] = $this->getParam('asset_type'); } if ($this->hasParam('criticality')) { $where .= ' AND criticality = ?'; $params[] = $this->getParam('criticality'); } if ($this->hasParam('status')) { $where .= ' AND status = ?'; $params[] = $this->getParam('status'); } if ($this->hasParam('nis2_relevant')) { $where .= ' AND is_nis2_relevant = ?'; $params[] = $this->getParam('nis2_relevant') ? 1 : 0; } $total = Database::count('assets', $where, $params); $assets = Database::fetchAll( "SELECT a.*, u.full_name as owner_name, sc.label AS subclass_label, v.label AS voce_label FROM assets a LEFT JOIN users u ON u.id = a.owner_user_id LEFT JOIN inventory_subclassi sc ON sc.id = a.subclass_id LEFT JOIN cfg_inventory_voci v ON v.voce_code = a.voce_code WHERE a.{$where} ORDER BY a.criticality DESC, a.name LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}", $params ); $this->jsonPaginated($assets, $total, $pagination['page'], $pagination['per_page']); } public function create(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $this->validateRequired(['name']); $voce = $this->getParam('voce_code'); if (!$voce) { // retro-compatibilità: deriva la voce dal vecchio asset_type (UI non ancora aggiornata / import) $at = (string) $this->getParam('asset_type'); if (in_array($at, ['software', 'service', 'data'], true)) { $voce = 'ID.AM-02'; } elseif (in_array($at, ['hardware', 'network', 'facility'], true)) { $voce = 'ID.AM-01'; } } if (!in_array($voce, ['ID.AM-01', 'ID.AM-02'], true)) { $this->jsonError('Voce inventario obbligatoria (ID.AM-01 o ID.AM-02)', 422, 'INVALID_VOCE'); } $subclassId = $this->validateSubclass($this->getParam('subclass_id'), $voce); // asset_type resta come campo legacy (viste secondarie): usa quello passato o deriva dalla voce $assetType = $this->getParam('asset_type') ?: ($voce === 'ID.AM-01' ? 'hardware' : 'service'); $assetId = Database::insert('assets', [ 'organization_id' => $this->getCurrentOrgId(), 'name' => trim($this->getParam('name')), 'asset_type' => $assetType, 'voce_code' => $voce, 'subclass_id' => $subclassId, 'category' => $this->getParam('category'), 'description' => $this->getParam('description'), 'criticality' => $this->getParam('criticality', 'medium'), 'owner_user_id' => $this->getParam('owner_user_id'), 'location' => $this->getParam('location'), 'ip_address' => $this->getParam('ip_address'), 'vendor' => $this->getParam('vendor'), 'version' => $this->getParam('version'), 'serial_number' => $this->getParam('serial_number'), 'purchase_date' => $this->getParam('purchase_date'), 'warranty_expiry' => $this->getParam('warranty_expiry'), 'dependencies' => $this->getParam('dependencies') ? json_encode($this->getParam('dependencies')) : null, ]); $this->logAudit('asset_created', 'asset', $assetId); $this->jsonSuccess(['id' => $assetId], 'Asset registrato', 201); } /** * POST /api/assets/import (JWT, org_admin/compliance_manager) * Import bulk asset da CMDB/cloud/CSV con scoring automatico GV.OC-04. * Body: { "source":"cmdb|aws|azure|csv|manual", "assets":[ {...}, ... ] } */ public function import(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $body = $this->getJsonBody(); $source = strtolower((string) ($body['source'] ?? 'csv')); $items = $body['assets'] ?? null; if (!is_array($items) || !$items) { $this->jsonError('Campo "assets" (array) obbligatorio', 422, 'VALIDATION'); } $result = self::bulkUpsert($this->getCurrentOrgId(), $items, $source, $this->getCurrentUserId()); $this->logAudit('assets_imported', 'asset', null, [ 'source' => $source, 'imported' => $result['imported'], 'updated' => $result['updated'], ]); $this->jsonSuccess($result, 'Import completato', 201); } /** * Upsert bulk + scoring GV.OC-04. Condiviso fra import UI (JWT) e * ingestion connettori (API key, ServicesController). Riceve orgId esplicito. * * @return array{imported:int,updated:int,skipped:int,relevant:int,total:int,results:array} */ public static function bulkUpsert(int $orgId, array $items, string $source, ?int $userId): array { $validType = ['hardware', 'software', 'network', 'data', 'service', 'personnel', 'facility']; $imported = 0; $updated = 0; $skipped = 0; $relevant = 0; $results = []; if (count($items) > 1000) { $items = array_slice($items, 0, 1000); } foreach ($items as $i => $a) { if (!is_array($a)) { $skipped++; $results[] = ['index' => $i, 'ok' => false, 'error' => 'not_an_object']; continue; } $name = trim((string) ($a['name'] ?? '')); if ($name === '') { $skipped++; $results[] = ['index' => $i, 'ok' => false, 'error' => 'name mancante']; continue; } $type = strtolower((string) ($a['asset_type'] ?? 'service')); if (!in_array($type, $validType, true)) $type = 'service'; // C4: voce (2 valori) derivata dal tipo legacy per gli asset importati $voce = in_array($type, ['hardware', 'network', 'facility'], true) ? 'ID.AM-01' : 'ID.AM-02'; // Scoring automatico GV.OC-04 da euristica sui campi CMDB $criteria = AssetScoringService::inferCriteria($a); $sc = AssetScoringService::calculate($criteria); $extRef = isset($a['external_ref']) ? substr(trim((string) $a['external_ref']), 0, 190) : null; $row = [ 'organization_id' => $orgId, 'name' => $name, 'asset_type' => $type, 'voce_code' => $voce, 'category' => $a['category'] ?? null, 'description' => $a['description'] ?? null, 'criticality' => $sc['criticality'], 'location' => $a['location'] ?? null, 'ip_address' => $a['ip_address'] ?? null, 'vendor' => $a['vendor'] ?? null, 'discovery_source' => substr($source, 0, 40), 'external_ref' => $extRef, 'relevance_score' => $sc['score'], 'relevance_criteria' => json_encode($criteria, JSON_UNESCAPED_UNICODE), 'relevance_class' => $sc['class'], 'is_nis2_relevant' => $sc['is_relevant'] ? 1 : 0, 'relevance_assessed_at' => date('Y-m-d H:i:s'), 'relevance_assessed_by' => $userId, ]; // Dipendenze scoperte dal connettore → popolano la Mappa Dipendenze. if (isset($a['dependencies']) && is_array($a['dependencies'])) { $row['dependencies'] = json_encode(array_values($a['dependencies']), JSON_UNESCAPED_UNICODE); } try { $existing = $extRef !== null ? Database::fetchOne('SELECT id FROM assets WHERE organization_id = ? AND external_ref = ?', [$orgId, $extRef]) : null; if ($existing) { $sets = []; $vals = []; foreach ($row as $k => $v) { if ($k === 'organization_id') continue; $sets[] = "$k = ?"; $vals[] = $v; } $vals[] = $existing['id']; Database::query('UPDATE assets SET ' . implode(', ', $sets) . ' WHERE id = ?', $vals); $assetId = (int) $existing['id']; $updated++; } else { $assetId = Database::insert('assets', $row); $imported++; } } catch (Throwable $e) { $skipped++; $results[] = ['index' => $i, 'ok' => false, 'error' => 'db_error']; error_log('[ASSET_IMPORT] ' . $e->getMessage()); continue; } if ($sc['is_relevant']) $relevant++; $results[] = [ 'index' => $i, 'ok' => true, 'id' => $assetId, 'name' => $name, 'relevance_score' => $sc['score'], 'relevance_class' => $sc['class'], 'nis2_relevant' => $sc['is_relevant'], ]; } return [ 'imported' => $imported, 'updated' => $updated, 'skipped' => $skipped, 'relevant' => $relevant, 'total' => count($items), 'results' => $results, ]; } public function get(int $id): void { $this->requireOrgAccess(); $asset = Database::fetchOne( 'SELECT a.*, u.full_name as owner_name FROM assets a LEFT JOIN users u ON u.id = a.owner_user_id WHERE a.id = ? AND a.organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$asset) { $this->jsonError('Asset non trovato', 404, 'ASSET_NOT_FOUND'); } $this->jsonSuccess($asset); } public function update(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $updates = []; $fields = ['name', 'asset_type', 'category', 'description', 'criticality', 'owner_user_id', 'location', 'ip_address', 'vendor', 'version', 'serial_number', 'purchase_date', 'warranty_expiry', 'status']; foreach ($fields as $field) { if ($this->hasParam($field)) { $updates[$field] = $this->getParam($field); } } if ($this->hasParam('dependencies')) { $updates['dependencies'] = json_encode($this->getParam('dependencies')); } // Voce/sottoclasse (Epic C / C4) — con validazione (no loop generico) if ($this->hasParam('voce_code')) { $voce = $this->getParam('voce_code'); if (!in_array($voce, ['ID.AM-01', 'ID.AM-02'], true)) { $this->jsonError('Voce inventario non valida', 422, 'INVALID_VOCE'); } $updates['voce_code'] = $voce; if ($this->hasParam('subclass_id')) { $updates['subclass_id'] = $this->validateSubclass($this->getParam('subclass_id'), $voce); } } elseif ($this->hasParam('subclass_id')) { $cur = Database::fetchOne('SELECT voce_code FROM assets WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $updates['subclass_id'] = $this->validateSubclass($this->getParam('subclass_id'), $cur['voce_code'] ?? ''); } if (!empty($updates)) { Database::update('assets', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('asset_updated', 'asset', $id, $updates); } $this->jsonSuccess($updates, 'Asset aggiornato'); } /** * Valida una sottoclasse per la voce indicata: deve esistere ed essere o di * sistema (organization_id NULL) o dell'org corrente, e appartenere alla voce. * Ritorna l'id (int) oppure null se non fornita. */ private function validateSubclass($id, string $voce): ?int { $id = (int) $id; if ($id <= 0) { return null; } $row = Database::fetchOne( 'SELECT id FROM inventory_subclassi WHERE id = ? AND voce_code = ? AND (organization_id = ? OR organization_id IS NULL)', [$id, $voce, $this->getCurrentOrgId()] ); if (!$row) { $this->jsonError('Sottoclasse non valida per la voce selezionata', 422, 'INVALID_SUBCLASS'); } return $id; } /** * GET /api/assets/subclasses — le 2 voci canoniche + le sottoclassi visibili * (default di sistema + quelle dell'org), per i selettori dell'inventario. */ public function subclasses(): void { $this->requireOrgAccess(); $orgId = $this->getCurrentOrgId(); $voci = Database::fetchAll('SELECT voce_code, label, descr, ord FROM cfg_inventory_voci ORDER BY ord'); $subs = Database::fetchAll( 'SELECT id, voce_code, label, (organization_id IS NULL) AS is_default, ord FROM inventory_subclassi WHERE organization_id IS NULL OR organization_id = ? ORDER BY voce_code, ord, label', [$orgId] ); $byVoce = []; foreach ($subs as $s) { $byVoce[$s['voce_code']][] = [ 'id' => (int) $s['id'], 'label' => $s['label'], 'is_default' => ((int) $s['is_default'] === 1), ]; } $out = []; foreach ($voci as $v) { $out[] = [ 'voce_code' => $v['voce_code'], 'label' => $v['label'], 'descr' => $v['descr'], 'subclassi' => $byVoce[$v['voce_code']] ?? [], ]; } $this->jsonSuccess(['voci' => $out]); } /** * POST /api/assets/subclasses { voce_code*, label* } * Aggiunge una sottoclasse ORGANIZZATIVA (org-scoped). Le due voci principali * NON sono modificabili dall'utente (restano due e solo due). */ public function addSubclass(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $body = $this->getJsonBody(); $voce = $body['voce_code'] ?? ''; $label = trim((string) ($body['label'] ?? '')); if (!in_array($voce, ['ID.AM-01', 'ID.AM-02'], true)) { $this->jsonError('Voce inventario non valida', 422, 'INVALID_VOCE'); } if ($label === '' || mb_strlen($label) > 120) { $this->jsonError('Etichetta sottoclasse obbligatoria (max 120 caratteri)', 422, 'INVALID_LABEL'); } $orgId = $this->getCurrentOrgId(); $dup = Database::fetchOne( 'SELECT id FROM inventory_subclassi WHERE voce_code = ? AND label = ? AND (organization_id = ? OR organization_id IS NULL)', [$voce, $label, $orgId] ); if ($dup) { $this->jsonError('Sottoclasse già esistente', 409, 'DUPLICATE'); } $id = Database::insert('inventory_subclassi', [ 'organization_id' => $orgId, 'voce_code' => $voce, 'label' => $label, 'ord' => 99, ]); $this->jsonSuccess(['id' => $id, 'voce_code' => $voce, 'label' => $label], 'Sottoclasse aggiunta', 201); } /** * PUT /api/assets/subclasses/{id} { label* } * Rinomina una sottoclasse ORGANIZZATIVA dell'org corrente. Le sottoclassi di * sistema (organization_id NULL) NON sono modificabili dall'utente. */ public function updateSubclass(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $body = $this->getJsonBody(); $label = trim((string) ($body['label'] ?? '')); if ($label === '' || mb_strlen($label) > 120) { $this->jsonError('Etichetta sottoclasse obbligatoria (max 120 caratteri)', 422, 'INVALID_LABEL'); } $orgId = $this->getCurrentOrgId(); $row = Database::fetchOne( 'SELECT id, voce_code FROM inventory_subclassi WHERE id = ? AND organization_id = ?', [$id, $orgId] ); if (!$row) { $this->jsonError('Sottoclasse non trovata o non modificabile (le voci di sistema non si modificano)', 404, 'SUBCLASS_NOT_FOUND'); } $dup = Database::fetchOne( 'SELECT id FROM inventory_subclassi WHERE voce_code = ? AND label = ? AND (organization_id = ? OR organization_id IS NULL) AND id <> ?', [$row['voce_code'], $label, $orgId, $id] ); if ($dup) { $this->jsonError('Sottoclasse già esistente', 409, 'DUPLICATE'); } Database::update('inventory_subclassi', ['label' => $label], 'id = ? AND organization_id = ?', [$id, $orgId]); $this->jsonSuccess(['id' => $id, 'label' => $label], 'Sottoclasse aggiornata'); } /** * DELETE /api/assets/subclasses/{id} * Rimuove una sottoclasse ORGANIZZATIVA dell'org corrente. Gli asset che la * usano restano senza sottoclasse (FK ON DELETE SET NULL su assets.subclass_id). * Le sottoclassi di sistema (organization_id NULL) NON sono eliminabili. */ public function deleteSubclass(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $orgId = $this->getCurrentOrgId(); $row = Database::fetchOne( 'SELECT id FROM inventory_subclassi WHERE id = ? AND organization_id = ?', [$id, $orgId] ); if (!$row) { $this->jsonError('Sottoclasse non trovata o non eliminabile (le voci di sistema non si rimuovono)', 404, 'SUBCLASS_NOT_FOUND'); } $used = Database::fetchOne('SELECT COUNT(*) AS c FROM assets WHERE subclass_id = ?', [$id]); $usedCount = (int) ($used['c'] ?? 0); Database::delete('inventory_subclassi', 'id = ? AND organization_id = ?', [$id, $orgId]); $this->jsonSuccess(['id' => $id, 'unlinked_assets' => $usedCount], 'Sottoclasse rimossa'); } public function delete(int $id): void { $this->requireOrgRole(['org_admin']); $deleted = Database::delete('assets', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); if ($deleted === 0) { $this->jsonError('Asset non trovato', 404, 'ASSET_NOT_FOUND'); } $this->logAudit('asset_deleted', 'asset', $id); $this->jsonSuccess(null, 'Asset eliminato'); } public function dependencyMap(): void { $this->requireOrgAccess(); $assets = Database::fetchAll( 'SELECT id, name, asset_type, criticality, dependencies FROM assets WHERE organization_id = ? AND status = "active"', [$this->getCurrentOrgId()] ); $nodes = []; $edges = []; foreach ($assets as $asset) { $nodes[] = [ 'id' => $asset['id'], 'label' => $asset['name'], 'type' => $asset['asset_type'], 'criticality' => $asset['criticality'], ]; $deps = json_decode($asset['dependencies'] ?? '[]', true) ?: []; foreach ($deps as $depId) { $edges[] = ['from' => $asset['id'], 'to' => (int) $depId]; } } $this->jsonSuccess(['nodes' => $nodes, 'edges' => $edges]); } /** * GET /api/assets/scoringGrid * Ritorna la griglia ufficiale di valutazione rilevanza NIS2 (GV.OC-04) * per costruire la UI di scoring lato client. */ public function scoringGrid(): void { $this->requireOrgAccess(); $this->jsonSuccess([ 'grid' => AssetScoringService::GRID, 'threshold' => AssetScoringService::RELEVANCE_THRESHOLD, 'classes' => [ ['key' => 'critico', 'min' => 80, 'max' => 100, 'label' => 'Critico - Priorita Massima'], ['key' => 'alto', 'min' => 60, 'max' => 79, 'label' => 'Alto - Priorita Alta'], ['key' => 'medio', 'min' => 40, 'max' => 59, 'label' => 'Medio - Rilevante'], ['key' => 'basso', 'min' => 20, 'max' => 39, 'label' => 'Basso - Monitoraggio'], ['key' => 'trascurabile', 'min' => 0, 'max' => 19, 'label' => 'Trascurabile'], ], ]); } /** * POST /api/assets/{id}/score * Calcola e salva la rilevanza NIS2 dell'asset a partire dalle selezioni * sui 6 criteri. Body: { criteria: { c1_operational_criticality: 'critical', ... } } */ public function score(int $id): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); $asset = Database::fetchOne( 'SELECT id FROM assets WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()] ); if (!$asset) { $this->jsonError('Asset non trovato', 404, 'ASSET_NOT_FOUND'); } $criteria = $this->getParam('criteria'); if (!is_array($criteria)) { $this->jsonError('Campo "criteria" mancante o non valido', 422, 'INVALID_CRITERIA'); } try { $result = AssetScoringService::calculate($criteria); } catch (InvalidArgumentException $e) { $this->jsonError($e->getMessage(), 422, 'INVALID_CRITERIA'); return; } Database::update('assets', [ 'relevance_score' => $result['score'], 'relevance_criteria' => json_encode($criteria, JSON_UNESCAPED_UNICODE), 'relevance_class' => $result['class'], 'is_nis2_relevant' => $result['is_relevant'] ? 1 : 0, 'criticality' => $result['criticality'], 'relevance_assessed_at' => date('Y-m-d H:i:s'), 'relevance_assessed_by' => $this->getCurrentUserId(), ], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('asset_scored', 'asset', $id, [ 'score' => $result['score'], 'class' => $result['class'], ]); $this->jsonSuccess([ 'score' => $result['score'], 'class' => $result['class'], 'is_nis2_relevant' => $result['is_relevant'], 'breakdown' => $result['breakdown'], 'required_measures'=> AssetScoringService::requiredMeasures($result['class']), ], 'Rilevanza NIS2 calcolata'); } /** * GET /api/assets/relevantSystems * Elenco dei sistemi classificati rilevanti NIS2 (score >= 40), ordinati * per punteggio. Alimenta il registro formale "Sistemi Rilevanti" (GV.OC-04). */ public function relevantSystems(): void { $this->requireOrgAccess(); $rows = Database::fetchAll( "SELECT a.id, a.name, a.asset_type, a.category, a.ip_address, a.location, a.relevance_score, a.relevance_class, a.relevance_criteria, a.relevance_assessed_at, u.full_name AS owner_name FROM assets a LEFT JOIN users u ON u.id = a.owner_user_id WHERE a.organization_id = ? AND a.is_nis2_relevant = 1 ORDER BY a.relevance_score DESC, a.name", [$this->getCurrentOrgId()] ); $stats = ['critico' => 0, 'alto' => 0, 'medio' => 0]; foreach ($rows as &$r) { $r['relevance_criteria'] = json_decode($r['relevance_criteria'] ?? 'null', true); $r['required_measures'] = AssetScoringService::requiredMeasures($r['relevance_class'] ?? ''); if (isset($stats[$r['relevance_class']])) { $stats[$r['relevance_class']]++; } } unset($r); $this->jsonSuccess([ 'systems' => $rows, 'count' => count($rows), 'by_class' => $stats, 'threshold' => AssetScoringService::RELEVANCE_THRESHOLD, ]); } }