Files
nis2-agile/application/controllers/DiscoveryConnectorController.php
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

181 lines
8.8 KiB
PHP

<?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];
}
}