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 FROM assets a LEFT JOIN users u ON u.id = a.owner_user_id 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', 'asset_type']); $assetId = Database::insert('assets', [ 'organization_id' => $this->getCurrentOrgId(), 'name' => trim($this->getParam('name')), 'asset_type' => $this->getParam('asset_type'), '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'; // 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, '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, ]; 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')); } 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'); } 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($result['breakdown'], 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, ]); } }