[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>
This commit is contained in:
DevEnv nis2-agile
2026-06-26 11:38:55 +02:00
co-authored by Claude Opus 4.8
parent cc7c6e4cf2
commit b8ac793c8f
6 changed files with 387 additions and 0 deletions
@@ -0,0 +1,65 @@
<?php
/**
* migrate_064_discovery_connectors.php — Connettori discovery + run + flussi di rete. IDEMPOTENTE.
* Esegui: docker exec nis2-app php /var/www/nis2-agile/application/cli/migrate_064_discovery_connectors.php
*/
if (PHP_SAPI !== 'cli') { http_response_code(403); exit("CLI only\n"); }
require_once __DIR__ . '/../config/env.php';
require_once __DIR__ . '/../config/database.php';
$pdo = Database::getInstance();
$pdo->exec("CREATE TABLE IF NOT EXISTS discovery_connectors (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
organization_id INT NOT NULL,
name VARCHAR(120) NOT NULL,
connector_type ENUM('network','aws','azure','gcp','cmdb','agent','custom') NOT NULL DEFAULT 'network',
config JSON NULL,
api_key_id INT UNSIGNED NULL,
status ENUM('active','paused','error') NOT NULL DEFAULT 'active',
schedule ENUM('manual','hourly','daily','weekly') NOT NULL DEFAULT 'manual',
last_run_at TIMESTAMP NULL,
last_status VARCHAR(20) NULL,
last_discovered INT NULL,
last_message VARCHAR(255) NULL,
created_by INT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_dc_org_name (organization_id, name),
INDEX idx_dc_org (organization_id),
INDEX idx_dc_key (api_key_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
echo " + discovery_connectors OK\n";
$pdo->exec("CREATE TABLE IF NOT EXISTS discovery_runs (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
connector_id INT UNSIGNED NOT NULL,
organization_id INT NOT NULL,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
finished_at TIMESTAMP NULL,
status ENUM('running','ok','error') NOT NULL DEFAULT 'ok',
discovered_assets INT NOT NULL DEFAULT 0,
discovered_flows INT NOT NULL DEFAULT 0,
message VARCHAR(255) NULL,
INDEX idx_dr_conn (connector_id),
INDEX idx_dr_org (organization_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
echo " + discovery_runs OK\n";
$pdo->exec("CREATE TABLE IF NOT EXISTS network_flows (
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
organization_id INT NOT NULL,
src VARCHAR(190) NOT NULL,
dst VARCHAR(190) NOT NULL,
port INT NULL,
protocol VARCHAR(12) NULL,
direction ENUM('internal','inbound','outbound') NOT NULL DEFAULT 'internal',
discovery_source VARCHAR(40) NOT NULL DEFAULT 'manual',
external_ref VARCHAR(190) NULL,
last_seen_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_flow (organization_id, external_ref),
INDEX idx_flow_org (organization_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
echo " + network_flows OK\n";
echo "Migrazione 064 — connettori discovery OK. Prossima mig=065.\n";
@@ -167,6 +167,10 @@ class AssetController extends BaseController
'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
@@ -0,0 +1,180 @@
<?php
/**
* NIS2 Agile - DiscoveryConnectorController
*
* Connettori di discovery per-organizzazione (più connettori per azienda).
* Ogni connettore ha una api_key dedicata (scope ingest:assets) che l'agente esterno
* usa per auto-popolare Inventario + dipendenze + flussi di rete (ID.AM-03) via
* POST /api/services/assets-ingest. Lo storico run è in discovery_runs.
*
* Endpoint (org_admin):
* GET /api/discovery-connectors lista
* POST /api/discovery-connectors crea (+ emette api_key, mostrata 1 volta)
* GET /api/discovery-connectors/{id} dettaglio + ultimi run
* PUT /api/discovery-connectors/{id} aggiorna (name/config/schedule/status)
* DELETE /api/discovery-connectors/{id} elimina (+ disattiva la sua api_key)
* POST /api/discovery-connectors/{id}/rotateKey rigenera api_key
*/
require_once __DIR__ . '/BaseController.php';
class DiscoveryConnectorController extends BaseController
{
private const TYPES = ['network', 'aws', 'azure', 'gcp', 'cmdb', 'agent', 'custom'];
private const SCHEDULES = ['manual', 'hourly', 'daily', 'weekly'];
private const STATUSES = ['active', 'paused', 'error'];
public function list(): void
{
$this->requireOrgRole(['org_admin']);
$orgId = $this->getCurrentOrgId();
$rows = Database::fetchAll(
"SELECT dc.id, dc.name, dc.connector_type, dc.config, dc.status, dc.schedule,
dc.last_run_at, dc.last_status, dc.last_discovered, dc.last_message, dc.created_at,
ak.key_prefix
FROM discovery_connectors dc
LEFT JOIN api_keys ak ON ak.id = dc.api_key_id
WHERE dc.organization_id = ?
ORDER BY dc.created_at DESC",
[$orgId]
);
foreach ($rows as &$r) { $r['config'] = json_decode($r['config'] ?? 'null', true); }
$this->jsonSuccess(['connectors' => $rows, 'total' => count($rows), 'types' => self::TYPES]);
}
public function create(): void
{
$this->requireOrgRole(['org_admin']);
$this->validateRequired(['name']);
$orgId = $this->getCurrentOrgId();
$userId = $this->getCurrentUserId();
$name = trim((string) $this->getParam('name'));
$type = $this->getParam('connector_type', 'network');
if (!in_array($type, self::TYPES, true)) $type = 'network';
$schedule = $this->getParam('schedule', 'manual');
if (!in_array($schedule, self::SCHEDULES, true)) $schedule = 'manual';
$config = $this->getParam('config');
$configJson = $config !== null ? json_encode($config, JSON_UNESCAPED_UNICODE) : null;
if (Database::fetchOne('SELECT id FROM discovery_connectors WHERE organization_id=? AND name=?', [$orgId, $name])) {
$this->jsonError('Esiste già un connettore con questo nome', 409, 'DUPLICATE');
}
[$keyId, $rawKey, $prefix] = $this->issueIngestKey($orgId, $userId, "discovery: {$name}");
$id = Database::insert('discovery_connectors', [
'organization_id' => $orgId,
'name' => $name,
'connector_type' => $type,
'config' => $configJson,
'api_key_id' => $keyId,
'status' => 'active',
'schedule' => $schedule,
'created_by' => $userId,
]);
$this->logAudit('discovery_connector_created', 'discovery_connector', $id, ['name' => $name, 'type' => $type]);
$this->jsonSuccess([
'id' => $id,
'name' => $name,
'connector_type' => $type,
'api_key' => $rawKey, // mostrata UNA sola volta
'api_key_prefix' => $prefix,
'ingest_url' => 'https://nis2.agile.software/api/services/assets-ingest',
'note' => "Copia ORA la chiave: non sarà più visibile. L'agente la usa nell'header X-API-Key.",
], 'Connettore creato', 201);
}
public function get(int $id): void
{
$this->requireOrgRole(['org_admin']);
$orgId = $this->getCurrentOrgId();
$c = Database::fetchOne(
"SELECT dc.*, ak.key_prefix FROM discovery_connectors dc
LEFT JOIN api_keys ak ON ak.id = dc.api_key_id
WHERE dc.id = ? AND dc.organization_id = ?",
[$id, $orgId]
);
if (!$c) $this->jsonError('Connettore non trovato', 404, 'NOT_FOUND');
$c['config'] = json_decode($c['config'] ?? 'null', true);
unset($c['api_key_id']);
$c['runs'] = Database::fetchAll(
'SELECT id, started_at, finished_at, status, discovered_assets, discovered_flows, message
FROM discovery_runs WHERE connector_id = ? ORDER BY started_at DESC LIMIT 20',
[$id]
);
$this->jsonSuccess($c);
}
public function update(int $id): void
{
$this->requireOrgRole(['org_admin']);
$orgId = $this->getCurrentOrgId();
$c = Database::fetchOne('SELECT * FROM discovery_connectors WHERE id=? AND organization_id=?', [$id, $orgId]);
if (!$c) $this->jsonError('Connettore non trovato', 404, 'NOT_FOUND');
$updates = [];
if ($this->hasParam('name')) $updates['name'] = trim((string) $this->getParam('name'));
if ($this->hasParam('connector_type')) { $t = $this->getParam('connector_type'); if (in_array($t, self::TYPES, true)) $updates['connector_type'] = $t; }
if ($this->hasParam('schedule')) { $s = $this->getParam('schedule'); if (in_array($s, self::SCHEDULES, true)) $updates['schedule'] = $s; }
if ($this->hasParam('status')) { $st = $this->getParam('status'); if (in_array($st, self::STATUSES, true)) $updates['status'] = $st; }
if ($this->hasParam('config')) $updates['config'] = json_encode($this->getParam('config'), JSON_UNESCAPED_UNICODE);
if (!$updates) { $this->jsonSuccess(null, 'Nessuna modifica'); return; }
Database::query(
'UPDATE discovery_connectors SET ' . implode(', ', array_map(fn($k) => "{$k} = ?", array_keys($updates))) . ', updated_at = NOW() WHERE id = ?',
array_merge(array_values($updates), [$id])
);
$this->logAudit('discovery_connector_updated', 'discovery_connector', $id, $updates);
$this->jsonSuccess(null, 'Connettore aggiornato');
}
public function delete(int $id): void
{
$this->requireOrgRole(['org_admin']);
$orgId = $this->getCurrentOrgId();
$c = Database::fetchOne('SELECT * FROM discovery_connectors WHERE id=? AND organization_id=?', [$id, $orgId]);
if (!$c) $this->jsonError('Connettore non trovato', 404, 'NOT_FOUND');
if (!empty($c['api_key_id'])) {
Database::query('UPDATE api_keys SET is_active=0 WHERE id=? AND organization_id=?', [$c['api_key_id'], $orgId]);
}
Database::query('DELETE FROM discovery_runs WHERE connector_id=?', [$id]);
Database::query('DELETE FROM discovery_connectors WHERE id=?', [$id]);
$this->logAudit('discovery_connector_deleted', 'discovery_connector', $id, ['name' => $c['name']]);
$this->jsonSuccess(null, 'Connettore eliminato');
}
public function rotateKey(int $id): void
{
$this->requireOrgRole(['org_admin']);
$orgId = $this->getCurrentOrgId();
$userId = $this->getCurrentUserId();
$c = Database::fetchOne('SELECT * FROM discovery_connectors WHERE id=? AND organization_id=?', [$id, $orgId]);
if (!$c) $this->jsonError('Connettore non trovato', 404, 'NOT_FOUND');
if (!empty($c['api_key_id'])) {
Database::query('UPDATE api_keys SET is_active=0 WHERE id=?', [$c['api_key_id']]);
}
[$keyId, $rawKey, $prefix] = $this->issueIngestKey($orgId, $userId, "discovery: {$c['name']}");
Database::query('UPDATE discovery_connectors SET api_key_id=?, updated_at=NOW() WHERE id=?', [$keyId, $id]);
$this->logAudit('discovery_connector_key_rotated', 'discovery_connector', $id, []);
$this->jsonSuccess(['api_key' => $rawKey, 'api_key_prefix' => $prefix], 'Chiave rigenerata (copia ora)');
}
/** Emette una api_key con scope ingest:assets. @return [id, rawKey, prefix] */
private function issueIngestKey(int $orgId, int $userId, string $name): array
{
$rawKey = 'nis2_' . bin2hex(random_bytes(16));
$prefix = substr($rawKey, 0, 12);
$keyId = Database::insert('api_keys', [
'organization_id' => $orgId,
'created_by' => $userId,
'name' => substr($name, 0, 100),
'key_prefix' => $prefix,
'key_hash' => hash('sha256', $rawKey),
'scopes' => json_encode(['ingest:assets']),
'is_active' => 1,
]);
return [$keyId, $rawKey, $prefix];
}
}
@@ -2645,9 +2645,79 @@ class ServicesController extends BaseController
}
require_once __DIR__ . '/AssetController.php';
$result = AssetController::bulkUpsert($this->currentOrgId, $items, $source, null);
// Flussi di rete (ID.AM-03) opzionali nello stesso payload
$flowsCount = 0;
if (!empty($body['flows']) && is_array($body['flows'])) {
$flowsCount = $this->upsertFlows($this->currentOrgId, $body['flows'], $source);
}
$result['flows_ingested'] = $flowsCount;
// Se la chiave appartiene a un connettore discovery → registra il run + last_run
$this->recordDiscoveryRun((int) ($this->apiKeyRecord['id'] ?? 0), $result, $flowsCount);
$this->jsonSuccess($result, 'Asset importati', 201);
}
/** Upsert idempotente dei flussi di rete (dedup su organization_id+external_ref). */
private function upsertFlows(int $orgId, array $flows, string $source): int
{
$n = 0;
foreach ($flows as $f) {
if (!is_array($f)) continue;
$src = trim((string) ($f['src'] ?? ''));
$dst = trim((string) ($f['dst'] ?? ''));
if ($src === '' || $dst === '') continue;
$port = isset($f['port']) ? (int) $f['port'] : null;
$proto = isset($f['protocol']) ? substr((string) $f['protocol'], 0, 12) : null;
$dir = in_array(($f['direction'] ?? ''), ['internal', 'inbound', 'outbound'], true) ? $f['direction'] : 'internal';
$ref = isset($f['external_ref'])
? substr((string) $f['external_ref'], 0, 190)
: substr($src . '>' . $dst . ':' . $port . '/' . $proto, 0, 190);
try {
Database::query(
"INSERT INTO network_flows (organization_id, src, dst, port, protocol, direction, discovery_source, external_ref, last_seen_at)
VALUES (?,?,?,?,?,?,?,?,NOW())
ON DUPLICATE KEY UPDATE port=VALUES(port), protocol=VALUES(protocol), direction=VALUES(direction), last_seen_at=NOW()",
[$orgId, substr($src, 0, 190), substr($dst, 0, 190), $port, $proto, $dir, substr($source, 0, 40), $ref]
);
$n++;
} catch (Throwable $e) {
error_log('[flows-ingest] ' . $e->getMessage());
}
}
return $n;
}
/** Registra il run del connettore discovery legato all'api_key, se esiste. */
private function recordDiscoveryRun(int $apiKeyId, array $result, int $flowsCount): void
{
if ($apiKeyId <= 0) return;
try {
$conn = Database::fetchOne(
'SELECT id FROM discovery_connectors WHERE api_key_id = ? AND organization_id = ?',
[$apiKeyId, $this->currentOrgId]
);
if (!$conn) return;
$assets = (int) ($result['imported'] ?? 0) + (int) ($result['updated'] ?? 0);
Database::insert('discovery_runs', [
'connector_id' => $conn['id'],
'organization_id' => $this->currentOrgId,
'finished_at' => date('Y-m-d H:i:s'),
'status' => 'ok',
'discovered_assets' => $assets,
'discovered_flows' => $flowsCount,
'message' => 'ingest via api',
]);
Database::query(
'UPDATE discovery_connectors SET last_run_at=NOW(), last_status=?, last_discovered=?, last_message=? WHERE id=?',
['ok', $assets, "asset {$assets}, flussi {$flowsCount}", $conn['id']]
);
} catch (Throwable $e) {
error_log('[discovery-run] ' . $e->getMessage());
}
}
/**
* GET /api/services/controls-monitoring
* Auth: X-API-Key con scope read:compliance