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>
181 lines
8.8 KiB
PHP
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];
|
|
}
|
|
}
|