- 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>
381 lines
15 KiB
PHP
381 lines
15 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Asset Controller
|
|
*
|
|
* Inventario asset IT/OT, classificazione, dipendenze.
|
|
*/
|
|
|
|
require_once __DIR__ . '/BaseController.php';
|
|
require_once __DIR__ . '/../services/AssetScoringService.php';
|
|
|
|
class AssetController extends BaseController
|
|
{
|
|
public function list(): void
|
|
{
|
|
$this->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,
|
|
]);
|
|
}
|
|
}
|