[FEAT] Asset import CMDB/cloud + scoring automatico GV.OC-04 (P2)
- AssetScoringService::inferCriteria: euristica 6 criteri da campi CMDB (criticality, data_classification, internet_facing, dependencies, regulated) - AssetController::import (JWT org_admin/compliance_manager) + bulkUpsert condiviso: upsert dedup su external_ref, scoring auto GV.OC-04, max 1000 asset/batch - ServicesController::ingestAssets -> POST /services/assets-ingest (scope ingest:assets) per connettori CMDB/cloud - Migrazione 025: assets += external_ref + discovery_source + indice univoco dedup - Route POST:assetsIngest (services) + POST:import (assets) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
307993fbad
commit
4924075142
@ -76,6 +76,109 @@ class AssetController extends BaseController
|
|||||||
$this->jsonSuccess(['id' => $assetId], 'Asset registrato', 201);
|
$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
|
public function get(int $id): void
|
||||||
{
|
{
|
||||||
$this->requireOrgAccess();
|
$this->requireOrgAccess();
|
||||||
|
|||||||
@ -2561,6 +2561,26 @@ class ServicesController extends BaseController
|
|||||||
return $monStatus;
|
return $monStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/services/assets-ingest
|
||||||
|
* Auth: X-API-Key con scope ingest:assets
|
||||||
|
* Import asset da CMDB/cloud con scoring automatico GV.OC-04.
|
||||||
|
* Body: { "source":"cmdb|aws|azure", "assets":[ {...}, ... ] }
|
||||||
|
*/
|
||||||
|
public function ingestAssets(): void
|
||||||
|
{
|
||||||
|
$this->requireApiKey('ingest:assets');
|
||||||
|
$body = $this->getJsonBody();
|
||||||
|
$source = strtolower((string) ($body['source'] ?? 'cmdb'));
|
||||||
|
$items = $body['assets'] ?? null;
|
||||||
|
if (!is_array($items) || !$items) {
|
||||||
|
$this->jsonError('Campo "assets" (array) obbligatorio', 422, 'VALIDATION');
|
||||||
|
}
|
||||||
|
require_once __DIR__ . '/AssetController.php';
|
||||||
|
$result = AssetController::bulkUpsert($this->currentOrgId, $items, $source, null);
|
||||||
|
$this->jsonSuccess($result, 'Asset importati', 201);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/services/controls-monitoring
|
* GET /api/services/controls-monitoring
|
||||||
* Auth: X-API-Key con scope read:compliance
|
* Auth: X-API-Key con scope read:compliance
|
||||||
|
|||||||
@ -106,6 +106,55 @@ class AssetScoringService
|
|||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inferenza euristica dei 6 criteri GV.OC-04 dai campi di un asset
|
||||||
|
* importato da CMDB/cloud. Permette lo scoring automatico in bulk senza
|
||||||
|
* compilazione manuale della griglia. I valori restituiti sono chiavi
|
||||||
|
* VALIDE della GRID, quindi calculate() li accetta senza eccezioni.
|
||||||
|
*
|
||||||
|
* Campi letti: criticality, asset_type, data_classification,
|
||||||
|
* internet_facing, dependencies_count, regulated.
|
||||||
|
*/
|
||||||
|
public static function inferCriteria(array $a): array
|
||||||
|
{
|
||||||
|
$crit = strtolower((string) ($a['criticality'] ?? 'medium'));
|
||||||
|
|
||||||
|
// C1 Criticita Operativa <- criticality
|
||||||
|
$c1 = ['critical' => 'critical', 'high' => 'high', 'medium' => 'medium', 'low' => 'low'][$crit] ?? 'medium';
|
||||||
|
|
||||||
|
// C2 Impatto Interruzione <- criticality (proxy)
|
||||||
|
$c2 = ['critical' => 'gt24h_gt70', 'high' => 'h4_8_30_50', 'medium' => 'h1_4_10_30', 'low' => 'lt1h_lt10'][$crit] ?? 'h1_4_10_30';
|
||||||
|
|
||||||
|
// C3 Dati Trattati <- data_classification (con proxy dal tipo asset)
|
||||||
|
$dc = strtolower((string) ($a['data_classification'] ?? ''));
|
||||||
|
$c3map = [
|
||||||
|
'special' => 'gdpr_art9', 'art9' => 'gdpr_art9', 'sensitive' => 'gdpr_art9',
|
||||||
|
'personal' => 'personal_large', 'pii' => 'personal_large',
|
||||||
|
'financial' => 'personal_fin', 'confidential' => 'confidential',
|
||||||
|
'internal' => 'confidential', 'public' => 'public',
|
||||||
|
];
|
||||||
|
$c3 = $c3map[$dc] ?? ((($a['asset_type'] ?? '') === 'data') ? 'personal_large' : 'confidential');
|
||||||
|
|
||||||
|
// C4 Dipendenze <- numero dipendenze critiche
|
||||||
|
$deps = (int) ($a['dependencies_count'] ?? 0);
|
||||||
|
$c4 = $deps >= 5 ? 'ge5_critical' : ($deps >= 3 ? 'n3_4_critical' : ($deps === 2 ? 'n2_critical' : ($deps === 1 ? 'n1_critical' : 'none')));
|
||||||
|
|
||||||
|
// C5 Esposizione <- internet_facing
|
||||||
|
$c5 = !empty($a['internet_facing']) ? 'internet_mfa' : 'intranet';
|
||||||
|
|
||||||
|
// C6 Obblighi Normativi <- regulated
|
||||||
|
$c6 = !empty($a['regulated']) ? 'nis2_required' : 'none';
|
||||||
|
|
||||||
|
return [
|
||||||
|
'c1_operational_criticality' => $c1,
|
||||||
|
'c2_disruption_impact' => $c2,
|
||||||
|
'c3_data_processed' => $c3,
|
||||||
|
'c4_dependencies' => $c4,
|
||||||
|
'c5_exposure' => $c5,
|
||||||
|
'c6_regulatory' => $c6,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calcola lo score a partire dalle selezioni dell'utente.
|
* Calcola lo score a partire dalle selezioni dell'utente.
|
||||||
*
|
*
|
||||||
|
|||||||
32
docs/sql/025_asset_import.sql
Normal file
32
docs/sql/025_asset_import.sql
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
-- ============================================================================
|
||||||
|
-- Migration 025 - Asset import CMDB/cloud (P2)
|
||||||
|
-- ----------------------------------------------------------------------------
|
||||||
|
-- Abilita l'import bulk asset da CMDB/export cloud con dedup idempotente:
|
||||||
|
-- - assets.external_ref : ID asset nel sistema sorgente (CMDB/cloud)
|
||||||
|
-- - assets.discovery_source : provenienza (cmdb/aws/azure/csv/manual/...)
|
||||||
|
-- + indice univoco (organization_id, external_ref) per upsert.
|
||||||
|
--
|
||||||
|
-- Idempotente via information_schema. Rilanciabile.
|
||||||
|
-- mysql -h localhost nis2_agile_db -e "source docs/sql/025_asset_import.sql"
|
||||||
|
-- ============================================================================
|
||||||
|
|
||||||
|
DELIMITER //
|
||||||
|
DROP PROCEDURE IF EXISTS _mig025 //
|
||||||
|
CREATE PROCEDURE _mig025()
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='assets' AND COLUMN_NAME='external_ref') THEN
|
||||||
|
ALTER TABLE assets ADD COLUMN external_ref VARCHAR(190) NULL COMMENT 'ID asset nel sistema sorgente (dedup import)' AFTER serial_number;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='assets' AND COLUMN_NAME='discovery_source') THEN
|
||||||
|
ALTER TABLE assets ADD COLUMN discovery_source VARCHAR(40) NOT NULL DEFAULT 'manual' COMMENT 'Provenienza asset (cmdb/aws/azure/csv/manual)' AFTER external_ref;
|
||||||
|
END IF;
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='assets' AND INDEX_NAME='uq_asset_external_ref') THEN
|
||||||
|
ALTER TABLE assets ADD UNIQUE KEY uq_asset_external_ref (organization_id, external_ref);
|
||||||
|
END IF;
|
||||||
|
END //
|
||||||
|
DELIMITER ;
|
||||||
|
CALL _mig025();
|
||||||
|
DROP PROCEDURE IF EXISTS _mig025;
|
||||||
|
|
||||||
|
-- ROLLBACK:
|
||||||
|
-- ALTER TABLE assets DROP INDEX uq_asset_external_ref, DROP COLUMN external_ref, DROP COLUMN discovery_source;
|
||||||
@ -273,6 +273,7 @@ $actionMap = [
|
|||||||
'assets' => [
|
'assets' => [
|
||||||
'GET:list' => 'list',
|
'GET:list' => 'list',
|
||||||
'POST:create' => 'create',
|
'POST:create' => 'create',
|
||||||
|
'POST:import' => 'import',
|
||||||
'GET:scoringGrid' => 'scoringGrid',
|
'GET:scoringGrid' => 'scoringGrid',
|
||||||
'GET:relevantSystems' => 'relevantSystems',
|
'GET:relevantSystems' => 'relevantSystems',
|
||||||
'GET:dependencyMap' => 'dependencyMap',
|
'GET:dependencyMap' => 'dependencyMap',
|
||||||
@ -351,6 +352,7 @@ $actionMap = [
|
|||||||
'POST:incidentsIngest' => 'ingestIncident', // P1: ingestion alert SIEM/SOC/EDR
|
'POST:incidentsIngest' => 'ingestIncident', // P1: ingestion alert SIEM/SOC/EDR
|
||||||
'POST:evidenceIngest' => 'ingestEvidence', // P1: evidence automation (collector connettori)
|
'POST:evidenceIngest' => 'ingestEvidence', // P1: evidence automation (collector connettori)
|
||||||
'GET:controlsMonitoring' => 'controlsMonitoring', // P1: continuous control monitoring
|
'GET:controlsMonitoring' => 'controlsMonitoring', // P1: continuous control monitoring
|
||||||
|
'POST:assetsIngest' => 'ingestAssets', // P2: import asset CMDB/cloud + scoring GV.OC-04
|
||||||
'GET:controlsStatus' => 'controlsStatus',
|
'GET:controlsStatus' => 'controlsStatus',
|
||||||
'GET:assetsCritical' => 'assetsCritical',
|
'GET:assetsCritical' => 'assetsCritical',
|
||||||
'GET:suppliersRisk' => 'suppliersRisk',
|
'GET:suppliersRisk' => 'suppliersRisk',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user