Files
DevEnv nis2-agileandClaude Opus 4.8 b8ac793c8f [FEAT] Connettori discovery rete/cloud per-org → auto-popola Inventario (backend, mig 064)
Più connettori per azienda; ogni connettore ha una api_key dedicata (scope ingest:assets)
che l'agente esterno usa per mappare e auto-popolare NIS2.
- mig 064: discovery_connectors (per-org, multi) + discovery_runs (storico) + network_flows (ID.AM-03).
- DiscoveryConnectorController (org_admin): CRUD connettori + emissione/rotazione api_key
  (mostrata 1 volta) + dettaglio con ultimi run.
- ServicesController::ingestAssets esteso: accetta anche "flows" (upsert network_flows,
  dedup external_ref) e, se la chiave appartiene a un connettore, registra discovery_run
  + aggiorna last_run del connettore. Auto-scoring rilevanza NIS2 già in bulkUpsert.
- AssetController::bulkUpsert: ora salva anche "dependencies" → popola la Mappa Dipendenze.
- Router: /api/discovery-connectors (list/create/{id}/update/delete/rotateKey).
Smoke E2E (HTTP 201): 2 asset scorati + 1 flusso + run tracciato + dipendenze persistite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 11:38:55 +02:00

554 lines
24 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,
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,
]);
}
}