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