10 Commits

Author SHA1 Message Date
Alessio b75e57fe31 [Feature] Aggiunto supporto OAuth2 client_credentials per Salesforce
Implementato il flusso OAuth2 grant_type=client_credentials come alternativa
al flusso password gia' esistente per l'autenticazione Salesforce server-to-server.
La modifica e' completamente retrocompatibile (default rimane Password).

## Dettaglio modifiche

### CredentialManager/Models/CredentialModels.cs
- Aggiunto enum SalesforceGrantType con valori Password e ClientCredentials
- Aggiunta proprieta' GrantType (default: Password) su RestApiCredential
- Aggiunta proprieta' GrantType (default: Password) su SalesforceCredential

### DataConnection/REST/Configuration/RestServiceOptions.cs
- Aggiunta proprieta' SalesforceGrantType per passare il tipo di flusso al client

### DataConnection/REST/Implementations/SalesforceServiceClient.cs
- Iniettato ILogger<SalesforceServiceClient> con NullLogger come fallback
- Sostituiti ~165 Console.WriteLine con chiamate ILogger appropriate
  (LogDebug per dettagli, LogInformation per eventi, LogWarning/LogError per problemi)
- Aggiunto AuthenticateWithPasswordAsync: incapsula il flusso grant_type=password
- Aggiunto AuthenticateWithClientCredentialsAsync: implementa grant_type=client_credentials
  (richiede solo ClientId e ClientSecret, nessun utente, URL My Domain obbligatorio)
- Aggiunto SendTokenRequestAsync: helper condiviso per la POST al token endpoint
- Aggiornato AuthenticateAsync() override: instrada al flusso corretto in base a GrantType
- Rimosso modificatore static da NormalizeNumericValues (usava _logger, causava CS0120)

### Data_Coupler/Services/DataConnectionFactory.cs
- Mappatura del campo GrantType dalle opzioni Salesforce a RestServiceOptions
- Passaggio dell'ILogger al costruttore di SalesforceServiceClient

### CredentialManager/Services/CredentialService.cs
- SaveRestApiCredentialAsync (blocco Salesforce): serializza GrantType in AdditionalParameters
- SaveSalesforceCredentialAsync: aggiunto GrantType nel dizionario iniziale
- MapToRestApiCredential: deserializza GrantType da AdditionalParameters con Enum.TryParse
- MapToSalesforceCredential: idem per il tipo SalesforceCredential

### DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs
- TestSalesforceOAuthLogin aggiornato: per ClientCredentials invia solo client_id e
  client_secret (senza username/password/security_token); per Password comportamento invariato

### Data_Coupler/Pages/CredentialManagement.razor
- Aggiunto dropdown 'Tipo di Autenticazione OAuth2' nella sezione Salesforce
- I campi Username, Password e Security Token vengono nascosti quando si seleziona
  il flusso ClientCredentials
- Alert contestuale: warning My Domain URL per ClientCredentials, info per Password
- GrantType propagato correttamente in EditRestApiCredential e TestRestApiConnectionFromModal

### AGENTS.md
- Aggiunta sezione di documentazione per la nuova funzionalita' OAuth2 client_credentials
2026-05-24 23:11:22 +02:00
Alessio Dal Santo 9fab99112b [Fix] Sicurezza e affidabilità storico esecuzioni schedulazioni
- SchedulingHistory.razor / .cs: iniettato IWebHostEnvironment per nascondere
  lo stack trace (con percorsi di file) in produzione; in produzione viene
  mostrato solo il messaggio di errore sanitizzato e un avviso che invita a
  consultare i log dell'applicazione; in sviluppo il dettaglio completo resta
  visibile invariato.

- Scheduling.razor.cs (ExecuteScheduleManually): isolata la notifica JS
  (ShowSuccessMessage / ShowErrorMessage) in un blocco try-catch separato per
  TaskCanceledException / OperationCanceledException. In questo modo una
  disconnessione del browser durante un'esecuzione lunga non sovrascrive più
  il risultato già salvato correttamente come 'success' con uno stato 'failed'
  e lo stack trace di un'eccezione JSInterop. L'evento viene registrato come
  avviso di log senza impatto sul record storico.

- ScheduledJobService.cs: aggiunto commento esplicativo sul motivo per cui
  il dettaglio completo (ex.ToString) è salvato nel DB ma la UI ne mostra
  solo la versione sanitizzata in produzione.
2026-05-08 13:46:56 +02:00
Alessio Dal Santo 91dbe9ae11 [Feature] Aggiunta protezione machine-binding tramite MachineGuard
- Nuovo progetto MachineGuard: libreria che verifica se la macchina corrente
  è autorizzata all'esecuzione tramite DPAPI (Data Protection API di Windows)
- Nuovo progetto MachineGuardSetup: tool di configurazione da eseguire come
  Amministratore per registrare la macchina autorizzata
- Data_Coupler.sln: aggiunti entrambi i nuovi progetti alla soluzione
- Data_Coupler.csproj: aggiunto riferimento al progetto MachineGuard
- Program.cs: integrazione MachineGuard all'avvio dell'applicazione;
  se la macchina non è autorizzata l'app viene arrestata immediatamente
  con log critico e scrittura nel Windows Event Log
2026-03-30 16:42:43 +02:00
Alessio Dal Santo e43b7dc869 [Fix] Correzione controllo sicurezza query SQL: uso regex con word boundary per evitare falsi positivi su nomi colonna (es. UpdateDate, CreateDate) e gestione corretta dei prefissi sp_/xp_ 2026-03-30 16:40:39 +02:00
Alessio Dal Santo f1f75d59ac [Fix] Dockerfile.windows: aggiunge ContinuousIntegrationBuild=true a dotnet build e publish
Build and Push Docker Images / Build Linux Container (push) Successful in 6m9s
Build and Push Docker Images / Create Multi-Platform Manifest (push) Has been cancelled
Build and Push Docker Images / Build Windows Container (push) Has been cancelled
Senza questo flag il target GenerateVersionJson nel .csproj veniva eseguito
dentro il container Docker Windows dove:
- Non esiste .git (fatal: not a git repository)
- version.json copiato da COPY e' read-only o protetto
→ MSB3491: Access to the path '...\wwwroot\version.json' is denied

Il Dockerfile Linux aveva gia /p:ContinuousIntegrationBuild=true.
La condizione nel .csproj e':
  Condition="'' != 'true' AND '' != 'true'"
quindi con il flag il target viene saltato e version.json usa quello
generato dal workflow prima del docker build.
2026-03-22 16:18:14 +01:00
Alessio Dal Santo 46fc21bf7b [Fix] GitHub Actions: aggiunge generazione version.json prima del Docker build
Il workflow GitHub non generava version.json sul runner prima del build,
quindi Docker copiava il file statico del repository (con versione vecchia 2.1.0).

La Gitea Actions usava gia questo approccio correttamente.

Fix applicato: lo step 'Calcola versione' ora genera anche version.json in
Data_Coupler/wwwroot/version.json per entrambi i job Linux e Windows,
con versione, commit SHA, branch, data build e ambiente (GitHub Actions).

Il VersionService legge version.json all'avvio per display nell'UI.
2026-03-22 16:11:30 +01:00
Alessio Dal Santo e125e758fb [Fix] Refactoring calcolo versione CI/CD: usa git describe invece di dotnet msbuild
Problemi risolti:
- GitHub Windows: errore PowerShell 'Missing closing )' causato da (cd Dir; cmd)
  sintassi bash non valida in PowerShell
- GitHub Linux: versione 1.0.0 invece di 2.3.2 perche il tag v2.3.2 esiste solo
  su Gitea e non su GitHub, quindi MinVer trovava il vecchio tag v1.0.0

Soluzione:
- Sostituito dotnet msbuild -getProperty:Version con git describe --tags --abbrev=0
  che e lo strumento nativo Git per ottenere l'ultimo tag raggiungibile
- Funziona identicamente su Linux (bash) e Windows (PowerShell)
- Non richiede dotnet installato ne accesso al .git dentro Docker
- Rimosso il dotnet build intermedio sul runner (non piu necessario)
- Corretti i percorsi version.json: ora usa Data_Coupler/wwwroot/version.json
  dal root del repo invece di wwwroot/ relativo dopo cd
2026-03-22 15:49:42 +01:00
Alessio Dal Santo c15e6c9065 [Fix] Passa versione MinVer come build-arg al Docker per evitare errore MINVER1001
Il problema era che MinVer veniva eseguito dentro il container Docker dove la
directory .git non esiste, causando il warning MINVER1001 e l'uso di 0.0.0-alpha.0
(poi sostituito dal fallback hardcoded 2.1.0).

Soluzione:
- La versione viene calcolata sul runner CI/CD (dove git e' disponibile)
- Esportata come variabile d'ambiente APP_VERSION via GITHUB_ENV
- Passata al Docker build tramite --build-arg APP_VERSION
- Nei Dockerfile aggiunto ARG APP_VERSION e /p:MinVerVersionOverride per imporla
  a MinVer senza che tenti di accedere a git (assente nel container)
- ARG ridichiarato dopo ogni FROM in multi-stage build (comportamento Docker)
2026-03-20 18:25:54 +01:00
Alessio Dal Santo 4262fd6d71 [Fix] Correzione versioning CI/CD: fetch completo storia Git per MinVer
Aggiunto fetch-depth: 0 al checkout in tutti i job dei workflow GitHub Actions e Gitea Actions.
Rimosso --depth 1 dal clone manuale del job Windows in Gitea.
MinVer necessita della storia completa per risalire ai tag Git e calcolare la versione corretta.
Senza questa correzione la versione risultava sempre 2.1.0 (fallback hardcoded).
Aggiornato anche il valore di fallback da 2.1.0 a 2.3.2.
2026-03-20 18:07:34 +01:00
Alessio Dal Santo 335d587c89 [Feature] Salesforce: batch describe metadati, discovery parallela e fix scheduler External ID
Build and Push Docker Images / Build Linux Container (push) Successful in 6m56s
Build and Push Docker Images / Build Windows Container (push) Has been cancelled
Build and Push Docker Images / Create Multi-Platform Manifest (push) Has been cancelled
- Salesforce Composite Batch API per describe SObject: le describe sono ora
  raggruppate in chunk da 25 e inviate come singole POST a /composite/batch,
  riducendo le chiamate API da N a ceil(N/25); per 200 SObject: da 201 a 9 chiamate.

- Discovery entita' REST in parallelo: DiscoverEntitySummariesAsync e
  DiscoverEntitiesAsync avviate simultaneamente; la lista entita' diventa
  interattiva subito dopo le summaries, i dettagli completano in background
  con StateHasChanged() per aggiornare l'UI istantaneamente.

- Fix scheduler - preservazione ExternalIdRelationshipsJson e DefaultValuesJson:
  in DataCoupler.razor.cs entrambi i blocchi di update profilo esistente
  (riattivazione profilo inattivo e sovrascrittura profilo attivo) omettevano
  questi campi nella copia, causandone l'azzeramento silenzioso ad ogni
  re-salvataggio. Ora entrambi i percorsi propagano correttamente i campi JSON.

- Fix scheduler - esclusione campi sorgente External ID dal mapping normale:
  in ScheduledProfileExecutionService.TransformRecordForRest i campi sorgente
  usati nelle External ID Relationships venivano inclusi anche nel loop di
  field mapping standard, generando dati duplicati nell'entita' destinazione.
  Ora il comportamento e' allineato alla UI manuale (TransformRecordToRestEntity).

- Aggiornata documentazione: README.md, AGENTS.md, copilot-instructions.md
2026-02-20 14:59:13 +01:00
36 changed files with 1716 additions and 408 deletions
+36 -41
View File
@@ -30,34 +30,30 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Necessario per MinVer: deve percorrere tutta la storia Git per trovare i tag
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Generate version.json with MinVer
- name: Calcola versione e genera version.json
run: |
# Fetch all tags for MinVer to work correctly
# Calcola versione tramite git describe (non richiede dotnet build)
git fetch --tags --force
# Build project to trigger MinVer (calcola versione automaticamente)
cd Data_Coupler
dotnet build -c Release /p:ContinuousIntegrationBuild=true
# Extract version calculated by MinVer from build output
VERSION=$(dotnet msbuild -getProperty:Version -p:ContinuousIntegrationBuild=true 2>/dev/null | tail -1)
# Fallback if MinVer fails (no tags)
if [ -z "$VERSION" ] || [ "$VERSION" = "0.0.0-alpha.0" ]; then
echo "Warning: No git tags found. MinVer returned default. Using fallback."
VERSION="2.1.0-alpha.0.$(git rev-list --count HEAD)"
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$LATEST_TAG" ]; then
echo "Warning: Nessun tag Git trovato. Uso fallback."
VERSION="2.3.2"
else
VERSION="${LATEST_TAG#v}"
fi
echo "MinVer calculated version: $VERSION"
echo "Versione calcolata: $VERSION (da tag: $LATEST_TAG)"
# Create version.json
cat > wwwroot/version.json <<EOF
# Genera version.json
cat > Data_Coupler/wwwroot/version.json <<EOF
{
"version": "${VERSION}",
"commitSha": "${GITHUB_SHA:0:7}",
@@ -68,8 +64,10 @@ jobs:
EOF
echo "Generated version.json:"
cat wwwroot/version.json
cd ..
cat Data_Coupler/wwwroot/version.json
# Esporta la versione come variabile d'ambiente per il Docker build
echo "APP_VERSION=$VERSION" >> "$GITHUB_ENV"
shell: bash
- name: Set up Docker Buildx
@@ -136,6 +134,7 @@ jobs:
platforms: linux/amd64
# Aumenta timeout per registry lenti
build-args: |
APP_VERSION=${{ env.APP_VERSION }}
BUILDKIT_STEP_LOG_MAX_SIZE=50000000
provenance: false
sbom: false
@@ -159,7 +158,7 @@ jobs:
steps:
- name: Checkout repository with Git
run: |
git clone --depth 1 --branch ${{ github.ref_name }} https://alessio:%REGISTRY_TOKEN%@gitea.home-nas-ds.org/${{ github.repository }}.git .
git clone --branch ${{ github.ref_name }} https://alessio:%REGISTRY_TOKEN%@gitea.home-nas-ds.org/${{ github.repository }}.git .
if not exist Dockerfile.windows (
echo ERROR: Dockerfile.windows not found
exit /b 1
@@ -175,33 +174,26 @@ jobs:
dotnet --version
shell: pwsh
- name: Generate version.json with MinVer
- name: Calcola versione e genera version.json
run: |
# Fetch all tags for MinVer to work correctly
# Calcola versione tramite git describe (non richiede dotnet build)
git fetch --tags --force
# Build project to trigger MinVer
cd Data_Coupler
dotnet build -c Release /p:ContinuousIntegrationBuild=true
# Extract version calculated by MinVer
$VERSION = dotnet msbuild -getProperty:Version -p:ContinuousIntegrationBuild=true 2>$null | Select-Object -Last 1
# Fallback if MinVer fails (no tags)
if ([string]::IsNullOrWhiteSpace($VERSION) -or $VERSION -eq "0.0.0-alpha.0") {
Write-Host "Warning: No git tags found. MinVer returned default. Using fallback."
$commitCount = git rev-list --count HEAD
$VERSION = "2.1.0-alpha.0.$commitCount"
$LATEST_TAG = git describe --tags --abbrev=0 2>$null
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($LATEST_TAG)) {
Write-Host "Warning: Nessun tag Git trovato. Uso fallback."
$VERSION = "2.3.2"
} else {
$VERSION = $LATEST_TAG -replace '^v', ''
}
Write-Host "MinVer calculated version: $VERSION"
Write-Host "Versione calcolata: $VERSION (da tag: $LATEST_TAG)"
$COMMIT_SHA = "${{ github.sha }}"
$SHORT_SHA = $COMMIT_SHA.Substring(0, 7)
$BRANCH = "${{ github.ref_name }}"
$BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss UTC")
# Create version.json
# Genera version.json
$versionJson = @{
version = $VERSION
commitSha = $SHORT_SHA
@@ -210,11 +202,14 @@ jobs:
buildEnvironment = "Gitea Actions"
} | ConvertTo-Json
$versionJson | Out-File -FilePath "wwwroot\version.json" -Encoding UTF8
$versionJson | Out-File -FilePath "Data_Coupler\wwwroot\version.json" -Encoding UTF8
Write-Host "Generated version.json:"
Get-Content "wwwroot\version.json"
cd ..
Get-Content "Data_Coupler\wwwroot\version.json"
# Esporta la versione come variabile d'ambiente per il Docker build
"APP_VERSION=$VERSION" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
Write-Host "APP_VERSION=$VERSION esportata per Docker build"
shell: pwsh
- name: Debug - Verify files
@@ -265,7 +260,7 @@ jobs:
)
echo Building Windows Docker image...
docker build -t temp-windows -f Dockerfile.windows .
docker build --build-arg APP_VERSION=%APP_VERSION% -t temp-windows -f Dockerfile.windows .
if errorlevel 1 (
echo Build failed!
exit /b 1
+9 -2
View File
@@ -107,8 +107,11 @@
- **Parallel Processing**: Elaborazione parallela batch multipli
- **Performance**: 10-25x più veloce per grandi dataset
- **Riduzione API Calls**: 60-90% in meno chiamate
- **Batch Describe Metadata**: `BatchDescribeSObjectsAsync` raggruppa le describe degli SObject in chunk da 25 (N chiamate singole → ⌈N/25⌉ richieste batch); per 200 SObject: da 201 a 9 chiamate
- **Discovery Parallela**: `DiscoverEntitySummariesAsync` e `DiscoverEntitiesAsync` eseguite in parallelo; UI interattiva dopo le summaries, dettagli completano in background
#### Metodi Batch Implementati:
- `BatchDescribeSObjectsAsync`: Describe batch SObject tramite Composite API (max 25 per request) — discovery metadati ottimizzata
- `BatchExecuteQueriesAsync`: Esecuzione parallela multiple query SOQL
- `BatchFindEntitiesByKeysAsync`: Ricerca batch entità con diverse chiavi
- `BatchGetEntitiesByIdsAsync`: Recupero batch tramite ID (max 200 per query)
@@ -117,6 +120,10 @@
- `ExtractLargeDatasetAsync`: Estrattore intelligente con auto-detect strategia
- `ExtractRecentlyModifiedAsync`: Sincronizzazione incrementale
#### Correzioni Scheduler (Febbraio 2026):
- **ExternalIdRelationshipsJson / DefaultValuesJson preservati**: Fix ai blocchi di update profilo esistente in `DataCoupler.razor.cs` — i campi JSON venivano ignorati nella copia e quindi azzerati; ora entrambi i path (riattivazione + sovrascrittura) li propagano correttamente
- **Esclusione campi External ID dal mapping normale**: In `ScheduledProfileExecutionService.TransformRecordForRest`, i campi sorgente usati nelle External ID Relationships vengono ora esclusi dal loop di field mapping standard (comportamento allineato alla UI manuale)
#### File Chiave:
- `Data_Coupler/Pages/DataCoupler.razor.cs`
- `DataConnection/REST/Implementations/SalesforceServiceClient.cs`
@@ -528,8 +535,8 @@
---
**Versione**: 2.1
**Ultimo Aggiornamento**: 2 Febbraio 2026
**Versione**: 2.2
**Ultimo Aggiornamento**: 20 Febbraio 2026
**Framework**: .NET 9.0
**Sviluppatore**: Alessio Dalsanto
**Repository**: https://github.com/AlessioDalsi/Data-Coupler
+75 -1
View File
@@ -31,6 +31,42 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Necessario per MinVer: deve percorrere tutta la storia Git per trovare i tag
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Calcola versione e genera version.json
run: |
git fetch --tags --force
LATEST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$LATEST_TAG" ]; then
echo "Warning: Nessun tag Git trovato su questo remote. Uso fallback."
VERSION="2.3.2"
else
VERSION="${LATEST_TAG#v}"
fi
echo "Versione calcolata: $VERSION (da tag: $LATEST_TAG)"
# Genera version.json
cat > Data_Coupler/wwwroot/version.json <<EOF
{
"version": "${VERSION}",
"commitSha": "${GITHUB_SHA:0:7}",
"branch": "${GITHUB_REF_NAME}",
"buildDate": "$(date -u +"%Y-%m-%d %H:%M:%S UTC")",
"buildEnvironment": "GitHub Actions"
}
EOF
echo "Generated version.json:"
cat Data_Coupler/wwwroot/version.json
echo "APP_VERSION=$VERSION" >> "$GITHUB_ENV"
shell: bash
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -75,6 +111,8 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64
build-args: |
APP_VERSION=${{ env.APP_VERSION }}
- name: Generate artifact attestation
if: github.event_name != 'pull_request'
@@ -95,6 +133,42 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # Necessario per MinVer: deve percorrere tutta la storia Git per trovare i tag
- name: Calcola versione e genera version.json
run: |
git fetch --tags --force
$LATEST_TAG = git describe --tags --abbrev=0 2>$null
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($LATEST_TAG)) {
Write-Host "Warning: Nessun tag Git trovato su questo remote. Uso fallback."
$VERSION = "2.3.2"
} else {
$VERSION = $LATEST_TAG -replace '^v', ''
}
Write-Host "Versione calcolata: $VERSION (da tag: $LATEST_TAG)"
$COMMIT_SHA = "${{ github.sha }}"
$SHORT_SHA = $COMMIT_SHA.Substring(0, 7)
$BRANCH = "${{ github.ref_name }}"
$BUILD_DATE = (Get-Date).ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss UTC")
# Genera version.json
$versionJson = @{
version = $VERSION
commitSha = $SHORT_SHA
branch = $BRANCH
buildDate = $BUILD_DATE
buildEnvironment = "GitHub Actions"
} | ConvertTo-Json
$versionJson | Out-File -FilePath "Data_Coupler\wwwroot\version.json" -Encoding UTF8
Write-Host "Generated version.json:"
Get-Content "Data_Coupler\wwwroot\version.json"
"APP_VERSION=$VERSION" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
shell: pwsh
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
@@ -128,7 +202,7 @@ jobs:
$imageName = "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}".ToLower()
# Build with temporary tag
docker build -t "${imageName}:temp-windows" -f Dockerfile.windows .
docker build --build-arg "APP_VERSION=$env:APP_VERSION" -t "${imageName}:temp-windows" -f Dockerfile.windows .
# Parse and push all tags
$tags = "${{ steps.meta.outputs.tags }}" -split "`n"
+64 -2
View File
@@ -13,6 +13,68 @@
- **Backup e Ripristino**: Sistema completo di backup/restore per configurazioni e dati
- **Amministrazione Avanzata**: Interfaccia unificata per gestione sistema e sicurezza
## 🚀 **NUOVE FUNZIONALITÀ - Salesforce OAuth2 Client Credentials Flow (2026)**
### Supporto `grant_type=client_credentials` per autenticazione server-to-server
**Data Aggiornamento**: 2026
#### **Panoramica**
Aggiunto supporto per il flusso OAuth2 `client_credentials` come alternativa al flusso `password` già esistente.
Completamente retrocompatibile: il default rimane `Password`.
#### **Enum `SalesforceGrantType`** (in `CredentialManager/Models/CredentialModels.cs`)
```csharp
public enum SalesforceGrantType
{
Password, // grant_type=password — richiede Username, Password, SecurityToken (+ClientId/ClientSecret)
ClientCredentials // grant_type=client_credentials — server-to-server, nessun utente
}
```
#### **Differenze tra i flussi**
| Aspetto | `password` | `client_credentials` |
|---|---|---|
| Richiede Username/Password | ✅ Sì | ❌ No |
| Richiede SecurityToken | ✅ Sì (se non Connected App) | ❌ No |
| ClientId / ClientSecret | Opzionale | ✅ Obbligatorio |
| Base URL | login/test.salesforce.com | **My Domain URL** (es. `https://myorg.my.salesforce.com`) |
| Utente Salesforce | Necessario | Integration User (assegnato nella Connected App) |
#### **File modificati**
- `CredentialManager/Models/CredentialModels.cs` — enum `SalesforceGrantType`, proprietà `GrantType` su `RestApiCredential` e `SalesforceCredential`
- `DataConnection/REST/Configuration/RestServiceOptions.cs` — proprietà `SalesforceGrantType`
- `DataConnection/REST/Implementations/SalesforceServiceClient.cs``AuthenticateWithPasswordAsync`, `AuthenticateWithClientCredentialsAsync`, `SendTokenRequestAsync`; `ILogger` iniettato; `NormalizeNumericValues` reso non-static
- `Data_Coupler/Services/DataConnectionFactory.cs` — mapping `GrantType`, logger passato al client
- `CredentialManager/Services/CredentialService.cs``GrantType` serializzato/deserializzato in `AdditionalParameters` JSON
- `DataConnection/CredentialManagement/Services/DataConnectionCredentialService.cs``TestSalesforceOAuthLogin` instrada per `GrantType`
- `Data_Coupler/Pages/CredentialManagement.razor` — dropdown "Tipo di Autenticazione OAuth2"; Username/Password/SecurityToken nascosti per `ClientCredentials`; warning My Domain URL
---
## 🚀 **NUOVE FUNZIONALITÀ - Salesforce Optimizations (Febbraio 2026)**
### Salesforce Batch Describe via Composite API
**Data Aggiornamento**: Febbraio 2026
La discovery dei metadati Salesforce è stata ottimizzata tramite la Composite Batch API:
#### **`BatchDescribeSObjectsAsync`** (nuovo metodo privato in `SalesforceServiceClient`)
- Raggruppa i nomi degli SObject in chunk da 25
- Ogni chunk viene inviato come singola `POST /services/data/vXX.0/composite/batch`
- I risultati vengono processati in parallelo via `Task.WhenAll`
- **Risparmio concreto**: per 200 SObject, da 201 chiamate API a sole 9
#### **Discovery Parallela in `RESTMethod.cs`**
- `DiscoverEntitySummariesAsync` (rapida, 1 chiamata) e `DiscoverEntitiesAsync` (batch) partono in parallelo
- La lista entità diventa interattiva dopo ~0.3 s; i dettagli completano in background
- `StateHasChanged()` chiamato dopo le summaries per aggiornare subito la UI
#### **Fix Scheduler: External ID Relationships e Default Values**
- **Bug 1** (`DataCoupler.razor.cs`): in entrambi i blocchi di update profilo esistente (riattivazione profilo inattivo + sovrascrittura profilo attivo), i campi `ExternalIdRelationshipsJson` e `DefaultValuesJson` venivano omessi nella copia → cancellati silenziosamente ad ogni re-salvataggio
- **Bug 2** (`ScheduledProfileExecutionService.cs`): `TransformRecordForRest` non escludeva i campi sorgente usati nelle External ID Relationships dal loop di mapping normale, causando dati duplicati nell'entità destinazione (stessa logica già presente nella UI manuale, ora allineata allo scheduler)
---
## 🚀 **NUOVE FUNZIONALITÀ - Salesforce Batch Extraction**
### Miglioramenti Significativi alle Performance REST
@@ -1151,7 +1213,7 @@ builder.Services.AddScoped<Data_Coupler.Services.IBackupService, Data_Coupler.Se
---
**Versione**: 1.0
**Ultimo Aggiornamento**: Settembre 2024
**Versione**: 1.1
**Ultimo Aggiornamento**: 20 Febbraio 2026
**Framework**: .NET 9.0
**Sviluppatore**: Alessio Dalsanto
@@ -22,6 +22,27 @@ public enum RestServiceType
Salesforce
}
/// <summary>
/// Tipo di flusso OAuth2 per Salesforce
/// </summary>
public enum SalesforceGrantType
{
/// <summary>
/// Flusso Username/Password (grant_type=password).
/// Richiede: ClientId, ClientSecret, Username, Password (+SecurityToken se non IP-trusted).
/// URL di login: https://login.salesforce.com o https://test.salesforce.com.
/// </summary>
Password,
/// <summary>
/// Flusso Client Credentials (grant_type=client_credentials) — server-to-server, senza utente.
/// Richiede: ClientId, ClientSecret.
/// URL obbligatorio: My Domain URL (es. https://myorg.my.salesforce.com).
/// La Connected App deve avere "Enable Client Credentials Flow" attivato e un Integration User assegnato.
/// </summary>
ClientCredentials
}
/// <summary>
/// Tipi di database supportati (allineato con DataConnection.Enums.DatabaseType)
/// </summary>
@@ -106,6 +127,7 @@ public class RestApiCredential
public string? ApiVersion { get; set; } = "59.0";
public bool IsSandbox { get; set; } = false;
public bool UseSoapApi { get; set; } = false;
public SalesforceGrantType GrantType { get; set; } = SalesforceGrantType.Password;
public string? RefreshToken { get; set; }
public string? AccessToken { get; set; }
public DateTime? TokenExpiry { get; set; }
@@ -145,6 +167,8 @@ public class SalesforceCredential
public bool IsSandbox { get; set; } = false; // Se è un ambiente sandbox
public int TimeoutSeconds { get; set; } = 120;
public bool UseSoapApi { get; set; } = false; // Se usare SOAP invece di REST
/// <summary>Tipo di flusso OAuth2 da utilizzare. Default: Password (retrocompatibile).</summary>
public SalesforceGrantType GrantType { get; set; } = SalesforceGrantType.Password;
public string? RefreshToken { get; set; }
public string? AccessToken { get; set; }
public DateTime? TokenExpiry { get; set; }
@@ -233,6 +233,7 @@ public class CredentialService : ICredentialService
additionalParams["ApiVersion"] = credential.ApiVersion;
additionalParams["IsSandbox"] = credential.IsSandbox.ToString();
additionalParams["UseSoapApi"] = credential.UseSoapApi.ToString();
additionalParams["GrantType"] = credential.GrantType.ToString();
if (!string.IsNullOrEmpty(credential.RefreshToken))
additionalParams["RefreshToken"] = credential.RefreshToken;
if (!string.IsNullOrEmpty(credential.AccessToken))
@@ -523,7 +524,8 @@ public class CredentialService : ICredentialService
["SecurityToken"] = credential.SecurityToken,
["ApiVersion"] = credential.ApiVersion,
["IsSandbox"] = credential.IsSandbox.ToString(),
["UseSoapApi"] = credential.UseSoapApi.ToString()
["UseSoapApi"] = credential.UseSoapApi.ToString(),
["GrantType"] = credential.GrantType.ToString()
};
// Aggiungi ClientId e ClientSecret se forniti
@@ -793,6 +795,8 @@ public class CredentialService : ICredentialService
credential.IsSandbox = sandbox;
if (additionalParams.TryGetValue("UseSoapApi", out var useSoap) && bool.TryParse(useSoap, out var soap))
credential.UseSoapApi = soap;
if (additionalParams.TryGetValue("GrantType", out var grantTypeStr) && Enum.TryParse<SalesforceGrantType>(grantTypeStr, out var grantType))
credential.GrantType = grantType;
if (additionalParams.TryGetValue("RefreshToken", out var refreshToken))
credential.RefreshToken = refreshToken;
if (additionalParams.TryGetValue("AccessToken", out var accessToken))
@@ -915,6 +919,8 @@ public class CredentialService : ICredentialService
credential.IsSandbox = sandbox;
if (additionalParams.TryGetValue("UseSoapApi", out var useSoap) && bool.TryParse(useSoap, out var soap))
credential.UseSoapApi = soap;
if (additionalParams.TryGetValue("GrantType", out var grantTypeStr) && Enum.TryParse<SalesforceGrantType>(grantTypeStr, out var grantType))
credential.GrantType = grantType;
if (additionalParams.TryGetValue("RefreshToken", out var refreshToken))
credential.RefreshToken = refreshToken;
if (additionalParams.TryGetValue("AccessToken", out var accessToken))
@@ -109,6 +109,7 @@ public class DataCouplerProfileService : IDataCouplerProfileService
existingProfile.DestinationTable = profile.DestinationTable;
existingProfile.DestinationEndpoint = profile.DestinationEndpoint;
existingProfile.FieldMappingJson = profile.FieldMappingJson;
existingProfile.DefaultValuesJson = profile.DefaultValuesJson;
existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson;
existingProfile.SourceKeyField = profile.SourceKeyField;
existingProfile.UseRecordAssociations = profile.UseRecordAssociations;
@@ -863,8 +863,22 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
try
{
var tokenUrl = credential.LoginUrl.TrimEnd('/') + "/services/oauth2/token";
List<KeyValuePair<string, string>> tokenData;
var tokenData = new List<KeyValuePair<string, string>>
if (credential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials)
{
// Client Credentials flow — server-to-server, no user
tokenData = new List<KeyValuePair<string, string>>
{
new("grant_type", "client_credentials"),
new("client_id", credential.ClientId ?? ""),
new("client_secret", credential.ClientSecret ?? "")
};
}
else
{
// Password flow (default)
tokenData = new List<KeyValuePair<string, string>>
{
new("grant_type", "password"),
new("username", credential.Username),
@@ -872,14 +886,16 @@ public class DataConnectionCredentialService : IDataConnectionCredentialService
new("client_id", credential.ClientId ?? ""),
new("client_secret", credential.ClientSecret ?? "")
};
}
var tokenContent = new FormUrlEncodedContent(tokenData);
var response = await httpClient.PostAsync(tokenUrl, tokenContent);
if (response.IsSuccessStatusCode)
{
var responseContent = await response.Content.ReadAsStringAsync();
return (true, $"Connessione Salesforce riuscita!\n\nDettagli:\n- Login URL: {credential.LoginUrl}\n- API Version: {credential.ApiVersion}\n- Sandbox: {credential.IsSandbox}\n- Tipo Auth: OAuth2\n- Timeout: {credential.TimeoutSeconds}s");
var flowLabel = credential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials
? "client_credentials" : "password";
return (true, $"Connessione Salesforce riuscita!\n\nDettagli:\n- Login URL: {credential.LoginUrl}\n- API Version: {credential.ApiVersion}\n- Sandbox: {credential.IsSandbox}\n- Tipo Auth: OAuth2 ({flowLabel})\n- Timeout: {credential.TimeoutSeconds}s");
}
else
{
@@ -41,6 +41,11 @@ namespace DataConnection.REST.Configuration
/// </summary>
public bool IgnoreSslErrors { get; set; } = false;
// Add other relevant configuration properties (e.g., OAuth settings, specific headers)
/// <summary>
/// Salesforce OAuth2 grant type. Default: Password (retrocompatibile).
/// ClientCredentials = server-to-server, senza utente.
/// </summary>
public CredentialManager.Models.SalesforceGrantType SalesforceGrantType { get; set; }
= CredentialManager.Models.SalesforceGrantType.Password;
}
}
File diff suppressed because it is too large Load Diff
+28
View File
@@ -11,6 +11,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CredentialManager", "Creden
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Components", "Components\Components.csproj", "{B5114CAC-3E03-4150-B93C-652882F66CB7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MachineGuard", "MachineGuard\MachineGuard.csproj", "{AFF3AD52-0356-4879-A0C8-67819611445A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MachineGuardSetup", "MachineGuardSetup\MachineGuardSetup.csproj", "{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -69,6 +73,30 @@ Global
{B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x64.Build.0 = Release|Any CPU
{B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x86.ActiveCfg = Release|Any CPU
{B5114CAC-3E03-4150-B93C-652882F66CB7}.Release|x86.Build.0 = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|x64.ActiveCfg = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|x64.Build.0 = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|x86.ActiveCfg = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Debug|x86.Build.0 = Debug|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|Any CPU.Build.0 = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|x64.ActiveCfg = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|x64.Build.0 = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|x86.ActiveCfg = Release|Any CPU
{AFF3AD52-0356-4879-A0C8-67819611445A}.Release|x86.Build.0 = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|x64.ActiveCfg = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|x64.Build.0 = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|x86.ActiveCfg = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Debug|x86.Build.0 = Debug|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|Any CPU.Build.0 = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|x64.ActiveCfg = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|x64.Build.0 = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|x86.ActiveCfg = Release|Any CPU
{EACF8FA5-EF21-4D7E-8CA3-347C74C4CD0D}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -286,6 +286,8 @@ public class ScheduledJobService : BackgroundService
executionHistory.EndTime = DateTime.Now;
executionHistory.Status = "failed";
executionHistory.Message = $"Errore durante l'esecuzione automatica: {ex.Message}";
// Memorizza il dettaglio completo (stack trace) solo per scopi diagnostici;
// la UI in produzione ne mostrerà una versione sanitizzata senza percorsi di file.
executionHistory.ErrorDetails = ex.ToString();
await scheduleService.UpdateExecutionHistoryAsync(executionHistory);
}
+1
View File
@@ -20,6 +20,7 @@
<ProjectReference Include="..\DataConnection\DataConnection.csproj" />
<ProjectReference Include="..\CredentialManager\CredentialManager.csproj" />
<ProjectReference Include="..\Components\Components.csproj" />
<ProjectReference Include="..\MachineGuard\MachineGuard.csproj" />
</ItemGroup>
<ItemGroup>
@@ -140,23 +140,29 @@ public partial class DataCoupler : ComponentBase
Logger.LogInformation("Autenticazione completata con successo per il servizio REST {ServiceType}", credential.ServiceType);
// Discovery delle entità disponibili usando il metodo batch ottimizzato
Logger.LogInformation("Iniziando discovery batch delle entità REST...");
restEntities = await currentRestDiscovery.DiscoverEntitySummariesAsync();
// Avvia entrambe le discovery in parallelo:
// - DiscoverEntitySummariesAsync è veloce (1 API call) → sblocca la UI subito
// - DiscoverEntitiesAsync è pesante (batch describe) → completa in background
Logger.LogInformation("Avvio discovery parallela: entity summaries + entity details (batch)...");
var summariesTask = currentRestDiscovery.DiscoverEntitySummariesAsync();
var entitiesTask = currentRestDiscovery.DiscoverEntitiesAsync();
// Attendi le summaries (veloci) e rendi la UI interattiva immediatamente
restEntities = await summariesTask;
isRestConnected = true;
StateHasChanged();
Logger.LogInformation("Entity summaries completate: {EntityCount} entità. UI interattiva.", restEntities.Count);
Logger.LogInformation("Discovery batch completato: trovate {EntityCount} entità REST", restEntities.Count);
// Carica anche i dettagli completi delle entità per External ID Relationships
// Attendi i dettagli completi (già in esecuzione in parallelo)
try
{
Logger.LogInformation("Caricamento dettagli entità per External ID Relationships...");
availableRelationshipObjects = await currentRestDiscovery.DiscoverEntitiesAsync();
Logger.LogInformation("Caricati {Count} oggetti disponibili per External ID Relationships", availableRelationshipObjects.Count);
availableRelationshipObjects = await entitiesTask;
Logger.LogInformation("Entity details (batch) completati: {Count} oggetti disponibili per External ID Relationships.", availableRelationshipObjects.Count);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Impossibile caricare i dettagli delle entità per External ID Relationships");
Logger.LogWarning(ex, "Impossibile completare il caricamento dei dettagli entità per External ID Relationships");
availableRelationshipObjects = new List<RestEntityInfo>();
}
}
+35 -9
View File
@@ -663,11 +663,30 @@ else
} <!-- Campi specifici per Salesforce -->
@if (currentRestApiCredential.ServiceType == RestServiceType.Salesforce)
{
<div class="alert alert-info">
<strong>Opzioni di Autenticazione:</strong><br/>
• <strong>Username/Password + Security Token:</strong> Autenticazione standard<br/>
• <strong>Username/Password + Client ID/Secret:</strong> Autenticazione OAuth<br/>
• Il Security Token è richiesto solo se non si configura una Connected App (Client ID/Secret)
<div class="row mb-3">
<div class="col-md-12">
<label class="form-label fw-semibold">Tipo di Autenticazione OAuth2</label>
<InputSelect class="form-select" @bind-Value="currentRestApiCredential.GrantType">
<option value="Password">Password Flow — Username + Password + Security Token (grant_type=password)</option>
<option value="ClientCredentials">Client Credentials — Server-to-Server, nessun utente (grant_type=client_credentials)</option>
</InputSelect>
@if (currentRestApiCredential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials)
{
<div class="alert alert-warning mt-2 py-2">
<i class="fa fa-exclamation-triangle me-1"></i>
<strong>client_credentials</strong>: il <strong>Base URL</strong> deve essere il <strong>My Domain URL</strong> della tua org
(es. <code>https://myorg.my.salesforce.com</code>), <strong>non</strong> login.salesforce.com.<br/>
Richiede: Connected App con "Enable Client Credentials Flow" attivato e un Integration User assegnato.
</div>
}
else
{
<div class="alert alert-info mt-2 py-2">
<i class="fa fa-info-circle me-1"></i>
<strong>password flow</strong>: Username, Password + Security Token. Client ID/Secret facoltativi (Connected App).
</div>
}
</div>
</div>
<div class="row">
<div class="col-md-6">
@@ -687,11 +706,15 @@ else
<div class="form-text">Esempio: 59.0</div>
</div>
</div>
</div> <div class="mb-3">
</div>
@if (currentRestApiCredential.GrantType != CredentialManager.Models.SalesforceGrantType.ClientCredentials)
{
<div class="mb-3">
<label class="form-label">@GetFieldLabel("SecurityToken", currentRestApiCredential.ServiceType)</label>
<InputText type="password" class="form-control" @bind-Value="currentRestApiCredential.SecurityToken" />
<div class="form-text">Token di sicurezza Salesforce (richiesto solo se non si usa OAuth o Connected App)</div>
</div>
}
<div class="row">
<div class="col-md-6">
@@ -738,8 +761,9 @@ else
}
<!-- Campi comuni per autenticazione username/password -->
@if (currentRestApiCredential.ServiceType != RestServiceType.Generic ||
(string.IsNullOrEmpty(currentRestApiCredential.ApiKey) && string.IsNullOrEmpty(currentRestApiCredential.AuthToken)))
@if ((currentRestApiCredential.ServiceType != RestServiceType.Generic ||
(string.IsNullOrEmpty(currentRestApiCredential.ApiKey) && string.IsNullOrEmpty(currentRestApiCredential.AuthToken))) &&
!(currentRestApiCredential.ServiceType == RestServiceType.Salesforce && currentRestApiCredential.GrantType == CredentialManager.Models.SalesforceGrantType.ClientCredentials))
{ <div class="row">
<div class="col-md-6">
<div class="mb-3">
@@ -1312,6 +1336,7 @@ else
ApiVersion = credential.ApiVersion,
IsSandbox = credential.IsSandbox,
UseSoapApi = credential.UseSoapApi,
GrantType = credential.GrantType,
RefreshToken = credential.RefreshToken,
AccessToken = credential.AccessToken,
TokenExpiry = credential.TokenExpiry
@@ -1533,7 +1558,8 @@ else
ClientSecret = currentRestApiCredential.ClientSecret,
ApiVersion = currentRestApiCredential.ApiVersion,
IsSandbox = currentRestApiCredential.IsSandbox,
UseSoapApi = currentRestApiCredential.UseSoapApi
UseSoapApi = currentRestApiCredential.UseSoapApi,
GrantType = currentRestApiCredential.GrantType
}; // Salviamo temporaneamente la credenziale per il test
await CredentialService.SaveRestApiCredentialAsync(tempCredential);
+95 -22
View File
@@ -441,8 +441,26 @@ public partial class DataCoupler : ComponentBase
if (relationships != null && relationships.Any())
{
externalIdRelationships.Clear();
externalIdRelationships.AddRange(relationships);
Logger.LogInformation("External ID Relationships caricate - Totale: {Count}", externalIdRelationships.Count);
// Normalizza i RelationshipName in base al tipo di oggetto destinazione
bool isDestinationCustom = selectedRestEntity?.Name?.EndsWith("__c") ?? false;
foreach (var rel in relationships)
{
// Normalizza il RelationshipName
string normalizedName = NormalizeRelationshipName(rel.RelatedObjectName, isDestinationCustom);
if (normalizedName != rel.RelationshipName)
{
Logger.LogInformation("Normalizzato RelationshipName: {Old} → {New} (Destination: {Destination}, IsCustom: {IsCustom})",
rel.RelationshipName, normalizedName, selectedRestEntity?.Name, isDestinationCustom);
rel.RelationshipName = normalizedName;
}
externalIdRelationships.Add(rel);
}
Logger.LogInformation("External ID Relationships caricate e normalizzate - Totale: {Count}", externalIdRelationships.Count);
}
}
catch (Exception ex)
@@ -546,6 +564,8 @@ public partial class DataCoupler : ComponentBase
existingProfile.DestinationTable = profile.DestinationTable;
existingProfile.DestinationEndpoint = profile.DestinationEndpoint;
existingProfile.FieldMappingJson = profile.FieldMappingJson;
existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson;
existingProfile.DefaultValuesJson = profile.DefaultValuesJson;
existingProfile.SourceKeyField = profile.SourceKeyField;
existingProfile.UseRecordAssociations = profile.UseRecordAssociations;
existingProfile.IsActive = true;
@@ -579,6 +599,8 @@ public partial class DataCoupler : ComponentBase
existingProfile.DestinationTable = profile.DestinationTable;
existingProfile.DestinationEndpoint = profile.DestinationEndpoint;
existingProfile.FieldMappingJson = profile.FieldMappingJson;
existingProfile.ExternalIdRelationshipsJson = profile.ExternalIdRelationshipsJson;
existingProfile.DefaultValuesJson = profile.DefaultValuesJson;
existingProfile.SourceKeyField = profile.SourceKeyField;
existingProfile.UseRecordAssociations = profile.UseRecordAssociations;
@@ -1550,20 +1572,13 @@ public partial class DataCoupler : ComponentBase
return;
}
// Determina il nome della relazione in base al tipo di oggetto
// Salesforce: oggetti STANDARD usano solo il nome (es. "Account")
// oggetti CUSTOM (finiscono con __c) usano __r (es. "CustomObject__r")
string relationshipName;
if (selectedRelationshipObject.EndsWith("__c"))
{
// Oggetto custom: rimuovi __c e aggiungi __r
relationshipName = selectedRelationshipObject.Replace("__c", "__r");
}
else
{
// Oggetto standard: usa solo il nome
relationshipName = selectedRelationshipObject;
}
// Determina il nome della relazione usando il metodo helper
bool isDestinationCustom = selectedRestEntity?.Name?.EndsWith("__c") ?? false;
string relationshipName = NormalizeRelationshipName(selectedRelationshipObject, isDestinationCustom);
Logger.LogDebug("Creazione relazione - Destinazione: {Destination} (Custom: {IsCustom}), Correlato: {Related}, RelationshipName: {RelationshipName}",
selectedRestEntity?.Name, isDestinationCustom, selectedRelationshipObject, relationshipName);
// Crea la relazione
var relationship = new ExternalIdRelationshipDto
@@ -1606,6 +1621,47 @@ public partial class DataCoupler : ComponentBase
}
}
/// <summary>
/// Normalizza il nome della relazione in base al tipo di oggetto destinazione.
/// Salesforce External ID Relationships:
/// - Se l'oggetto DESTINAZIONE è CUSTOM → usa sempre __r per tutte le relazioni
/// - Se l'oggetto DESTINAZIONE è STANDARD → usa __r solo per oggetti custom correlati
/// </summary>
/// <param name="relatedObjectName">Nome dell'oggetto correlato (es. "Account", "Custom_Company__c")</param>
/// <param name="isDestinationCustom">True se l'oggetto destinazione è custom</param>
/// <returns>Nome normalizzato della relazione (es. "Account__r", "Account", "Custom_Company__r")</returns>
private string NormalizeRelationshipName(string relatedObjectName, bool isDestinationCustom)
{
if (isDestinationCustom)
{
// Destinazione CUSTOM: tutte le relazioni usano __r
if (relatedObjectName.EndsWith("__c"))
{
// Oggetto correlato custom: rimuovi __c e aggiungi __r
return relatedObjectName.Replace("__c", "__r");
}
else
{
// Oggetto correlato standard: aggiungi __r
return relatedObjectName + "__r";
}
}
else
{
// Destinazione STANDARD: solo oggetti custom correlati usano __r
if (relatedObjectName.EndsWith("__c"))
{
// Oggetto correlato custom: rimuovi __c e aggiungi __r
return relatedObjectName.Replace("__c", "__r");
}
else
{
// Oggetto correlato standard: usa solo il nome
return relatedObjectName;
}
}
}
private List<string> GetExternalIdFieldsForSelectedObject()
{
if (string.IsNullOrEmpty(selectedRelationshipObject))
@@ -2838,25 +2894,42 @@ public partial class DataCoupler : ComponentBase
if (!trimmedQuery.StartsWith("SELECT", StringComparison.OrdinalIgnoreCase))
return false;
// Lista di parole chiave vietate per sicurezza
var forbiddenKeywords = new[]
// Parole chiave complete: devono essere token SQL isolati (\bKEYWORD\b)
// Evita falsi positivi su nomi di colonne come UpdateDate, CreateDate, DeletedAt, ecc.
var forbiddenFullKeywords = new[]
{
"INSERT", "UPDATE", "DELETE", "DROP", "CREATE", "ALTER", "TRUNCATE",
"EXEC", "EXECUTE", "sp_", "xp_", "BULK", "OPENROWSET", "OPENDATASOURCE"
"EXEC", "EXECUTE", "BULK", "OPENROWSET", "OPENDATASOURCE"
};
// Prefissi pericolosi: devono iniziare la parola (sp_anything, xp_anything)
// Non si usa \bprefix\b perché _ è un word-char e mancherebbe sp_executesql, xp_cmdshell, ecc.
var forbiddenPrefixes = new[] { "SP_", "XP_" };
var upperQuery = trimmedQuery.ToUpperInvariant();
// Verifica che non contenga parole chiave vietate
foreach (var keyword in forbiddenKeywords)
// Verifica parole chiave complete con word boundary
foreach (var keyword in forbiddenFullKeywords)
{
if (upperQuery.Contains(keyword))
var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(keyword)}\b";
if (System.Text.RegularExpressions.Regex.IsMatch(upperQuery, pattern))
{
Logger.LogWarning("Query rifiutata: contiene parola chiave vietata '{Keyword}'", keyword);
return false;
}
}
// Verifica prefissi stored procedure: \bSP_ cattura sp_anything, xp_anything
foreach (var prefix in forbiddenPrefixes)
{
var pattern = $@"\b{System.Text.RegularExpressions.Regex.Escape(prefix)}";
if (System.Text.RegularExpressions.Regex.IsMatch(upperQuery, pattern))
{
Logger.LogWarning("Query rifiutata: contiene prefisso stored procedure vietato '{Prefix}'", prefix);
return false;
}
}
return true;
}
+19 -4
View File
@@ -311,14 +311,21 @@ public partial class Scheduling : ComponentBase
await ScheduleService.UpdateExecutionStatusAsync(scheduleId, status, message, result.RecordsProcessed);
// Notifica l'utente (best-effort: la connessione browser potrebbe essere stata interrotta
// durante un'esecuzione lunga senza che questo invalidi il risultato già salvato).
try
{
if (result.IsSuccess)
{
await ShowSuccessMessage($"Schedulazione eseguita con successo! {result.RecordsProcessed} record elaborati in {result.Duration.TotalSeconds:F2} secondi.");
}
else
{
await ShowErrorMessage($"Errore durante l'esecuzione: {result.ErrorMessage}");
}
catch (OperationCanceledException)
{
// La connessione Blazor è stata interrotta durante l'esecuzione: il risultato è
// già stato salvato correttamente, la notifica non può essere recapitata.
Logger.LogWarning("Notifica UI non inviata per la schedulazione {ScheduleId}: connessione browser interrotta durante l'esecuzione", scheduleId);
}
await LoadSchedules();
}
@@ -326,7 +333,7 @@ public partial class Scheduling : ComponentBase
{
Logger.LogError(ex, "Errore nell'esecuzione manuale schedulazione {ScheduleId}", scheduleId);
// Aggiorna lo storico in caso di eccezione
// Aggiorna lo storico in caso di eccezione durante l'esecuzione effettiva
if (executionHistory != null)
{
executionHistory.EndTime = DateTime.Now;
@@ -337,8 +344,16 @@ public partial class Scheduling : ComponentBase
}
await ScheduleService.UpdateExecutionStatusAsync(scheduleId, "failed", $"Errore: {ex.Message}");
try
{
await ShowErrorMessage("Errore nell'esecuzione: " + ex.Message);
}
catch (OperationCanceledException)
{
Logger.LogWarning("Notifica UI non inviata per la schedulazione {ScheduleId}: connessione browser non disponibile", scheduleId);
}
}
finally
{
isExecuting = false;
@@ -235,7 +235,15 @@
{
<h6>Dettagli Errori</h6>
<div class="alert alert-danger">
@if (IsDevelopment)
{
<pre style="white-space: pre-wrap; font-size: 0.85em;">@selectedExecution.ErrorDetails</pre>
}
else
{
<p class="mb-1">@GetSanitizedErrorMessage(selectedExecution.ErrorDetails)</p>
<small class="text-muted"><i class="fas fa-info-circle"></i> Per i dettagli tecnici completi consultare i log dell'applicazione.</small>
}
</div>
}
@@ -1,6 +1,7 @@
using CredentialManager.Models;
using CredentialManager.Services;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
@@ -11,6 +12,29 @@ public partial class SchedulingHistory : ComponentBase
[Inject] private IProfileScheduleService ScheduleService { get; set; } = null!;
[Inject] private IJSRuntime JSRuntime { get; set; } = null!;
[Inject] private ILogger<SchedulingHistory> Logger { get; set; } = null!;
[Inject] private IWebHostEnvironment WebHostEnvironment { get; set; } = null!;
protected bool IsDevelopment => WebHostEnvironment.IsDevelopment();
/// <summary>
/// Restituisce solo il messaggio dell'eccezione (senza stack trace) per la visualizzazione in produzione.
/// </summary>
protected static string GetSanitizedErrorMessage(string errorDetails)
{
if (string.IsNullOrEmpty(errorDetails))
return string.Empty;
// Prende solo le righe fino al primo stack frame (riga che inizia con " at")
var lines = errorDetails.Split('\n');
var messageLines = new System.Collections.Generic.List<string>();
foreach (var line in lines)
{
if (line.TrimStart().StartsWith("at ", StringComparison.Ordinal))
break;
messageLines.Add(line.TrimEnd());
}
return string.Join("\n", messageLines).Trim();
}
protected List<ScheduleExecutionHistory>? executionHistory;
protected ScheduleExecutionHistory? selectedExecution;
+36
View File
@@ -10,6 +10,7 @@ using CredentialManager;
using Data_Coupler.Services;
using Data_Coupler.BackgroundServices;
using CredentialManager.Services;
using MachineGuard;
using System;
using System.Threading.Tasks;
@@ -130,6 +131,9 @@ builder.Services.AddScoped<Data_Coupler.Services.IDeletionSyncService, Data_Coup
// Register Background Services (solo uno per evitare duplicazioni)
builder.Services.AddHostedService<Data_Coupler.BackgroundServices.ScheduledJobService>();
// Register MachineGuard — protezione machine-binding tramite DPAPI
builder.Services.AddMachineGuard(builder.Configuration);
// Configurazione URL e timeout per servizio Windows
var urls = builder.Configuration.GetValue<string>("Urls") ?? "http://*:7550";
builder.WebHost.UseUrls(urls);
@@ -143,6 +147,38 @@ builder.WebHost.ConfigureKestrel(serverOptions =>
var app = builder.Build();
#region MachineGuard verifica autorizzazione macchina
// Questa verifica deve avvenire PRIMA di qualsiasi altra inizializzazione.
// Se la macchina non è autorizzata, l'applicazione viene arrestata immediatamente.
{
var machineGuard = app.Services.GetRequiredService<IMachineGuard>();
if (!machineGuard.Verify())
{
var critLogger = app.Services.GetRequiredService<ILogger<Program>>();
critLogger.LogCritical(
"MachineGuard: questa macchina NON è autorizzata a eseguire Data Coupler. " +
"Eseguire MachineGuardSetup.exe come Amministratore per configurare questa macchina. " +
"Applicazione arrestata.");
if (OperatingSystem.IsWindows())
{
try
{
using var eventLog = new System.Diagnostics.EventLog("Application");
eventLog.Source = "DataCouplerService";
eventLog.WriteEntry(
"MachineGuard: macchina non autorizzata. " +
"Eseguire MachineGuardSetup.exe come Amministratore. Applicazione arrestata.",
System.Diagnostics.EventLogEntryType.Error);
}
catch { /* Ignora errori di scrittura EventLog */ }
}
Environment.Exit(1);
}
}
#endregion
// Initialize database con timeout e retry
using (var scope = app.Services.CreateScope())
{
@@ -133,7 +133,9 @@ namespace Data_Coupler.Services
// Per Salesforce usiamo i campi specifici ClientId e ClientSecret
options.ApiKey = credential.ClientId; // ClientId -> ApiKey
options.AuthToken = credential.ClientSecret; // ClientSecret -> AuthToken
_logger.LogInformation("Salesforce mapping - ClientId: '{ClientId}', ClientSecret: {HasSecret}, Username: '{Username}', Password: {HasPassword}",
options.SalesforceGrantType = credential.GrantType;
_logger.LogInformation("Salesforce mapping - GrantType: {GrantType}, ClientId: '{ClientId}', ClientSecret: {HasSecret}, Username: '{Username}', Password: {HasPassword}",
credential.GrantType,
credential.ClientId,
!string.IsNullOrEmpty(credential.ClientSecret),
credential.Username,
@@ -215,7 +217,9 @@ namespace Data_Coupler.Services
{
var httpClientFactory = _serviceProvider.GetRequiredService<IHttpClientFactory>();
var httpClient = httpClientFactory.CreateClient();
return new SalesforceServiceClient(httpClient, options);
var loggerFactory = _serviceProvider.GetService<Microsoft.Extensions.Logging.ILoggerFactory>();
var sfLogger = loggerFactory?.CreateLogger<DataConnection.REST.Implementations.SalesforceServiceClient>();
return new DataConnection.REST.Implementations.SalesforceServiceClient(httpClient, options, sfLogger);
}
/// <summary>
@@ -171,18 +171,25 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
_logger.LogInformation("Caricate {Count} External ID Relationships dal profilo", externalIdRelationships.Count);
}
// 4.6. Parse Default Values
var defaultValues = ParseDefaultValues(profile.DefaultValuesJson);
if (defaultValues.Any())
{
_logger.LogInformation("Caricati {Count} default values dal profilo", defaultValues.Count);
}
// 5. Determina se utilizzare Salesforce Composite API
bool useSalesforceComposite = restClient is DataConnection.REST.Implementations.SalesforceServiceClient;
if (useSalesforceComposite)
{
_logger.LogInformation("Utilizzo Salesforce Composite API per il trasferimento");
return await ExecuteDataTransferWithCompositeAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, externalIdRelationships, enableDeletionSync);
return await ExecuteDataTransferWithCompositeAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, defaultValues, externalIdRelationships, enableDeletionSync);
}
else
{
_logger.LogInformation("Utilizzo metodo trasferimento standard per il trasferimento");
return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, externalIdRelationships, enableDeletionSync);
return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential!, fieldMappings, defaultValues, externalIdRelationships, enableDeletionSync);
}
}
catch (Exception ex)
@@ -417,6 +424,53 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
return relationships;
}
/// <summary>
/// Parse del JSON dei default values
/// </summary>
private Dictionary<string, (object? Value, string? Type)> ParseDefaultValues(string? defaultValuesJson)
{
var defaultValues = new Dictionary<string, (object? Value, string? Type)>();
if (string.IsNullOrEmpty(defaultValuesJson))
{
_logger.LogDebug("DefaultValues JSON è vuoto o null");
return defaultValues;
}
_logger.LogDebug("Parsing DefaultValues JSON: {Json}", defaultValuesJson);
try
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
var deserializedDefaults = JsonSerializer.Deserialize<Dictionary<string, DefaultValueDto>>(defaultValuesJson, options);
if (deserializedDefaults != null)
{
foreach (var entry in deserializedDefaults)
{
defaultValues[entry.Key] = (entry.Value.Value, entry.Value.Type);
_logger.LogDebug("Default value: {Field} = {Value} ({Type})",
entry.Key, entry.Value.Value, entry.Value.Type);
}
_logger.LogInformation("Trovati {Count} default values nel JSON", defaultValues.Count);
}
else
{
_logger.LogWarning("Deserializzazione ritornato null per DefaultValues JSON");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Errore nel parsing dei default values: {Json}", defaultValuesJson);
}
return defaultValues;
}
/// <summary>
/// Ottiene tutti i record dal database
/// </summary>
@@ -685,6 +739,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
RestEntitySummary restEntity,
RestApiCredential restCredential,
Dictionary<string, string> fieldMappings,
Dictionary<string, (object? Value, string? Type)> defaultValues,
List<ExternalIdRelationshipDto> externalIdRelationships,
bool enableDeletionSync = false)
{
@@ -699,8 +754,8 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
{
try
{
// 1. Trasforma il record utilizzando i field mappings e External ID Relationships
var restData = TransformRecordForRest(record, fieldMappings, externalIdRelationships);
// 1. Trasforma il record utilizzando i field mappings, default values e External ID Relationships
var restData = TransformRecordForRest(record, fieldMappings, defaultValues, externalIdRelationships);
// 2. Gestione associazioni record se abilitata
string? entityId = null;
@@ -810,6 +865,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
RestEntitySummary restEntity,
RestApiCredential restCredential,
Dictionary<string, string> fieldMappings,
Dictionary<string, (object? Value, string? Type)> defaultValues,
List<ExternalIdRelationshipDto> externalIdRelationships,
bool enableDeletionSync = false)
{
@@ -820,7 +876,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
if (!(restClient is DataConnection.REST.Implementations.SalesforceServiceClient salesforceClient))
{
_logger.LogWarning("Client REST non è SalesforceServiceClient, fallback al metodo standard");
return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential, fieldMappings, externalIdRelationships, enableDeletionSync);
return await ExecuteDataTransferStandardAsync(profile, sourceRecords, restClient, restEntity, restCredential, fieldMappings, defaultValues, externalIdRelationships, enableDeletionSync);
}
try
@@ -851,7 +907,7 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
var recordNumber = indexedRecord.RecordNumber;
// Trasforma il record in base ai mapping e External ID Relationships (operazione locale, thread-safe)
var restData = TransformRecordForRest(record, fieldMappings, externalIdRelationships);
var restData = TransformRecordForRest(record, fieldMappings, defaultValues, externalIdRelationships);
// Genera la chiave sorgente e l'hash dei dati per questo record (include MAPPING_SIGNATURE)
var sourceKey = GenerateSourceKey(record, profile.SourceKeyField);
@@ -1144,12 +1200,30 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
private Dictionary<string, object> TransformRecordForRest(
Dictionary<string, object> sourceRecord,
Dictionary<string, string> fieldMappings,
Dictionary<string, (object? Value, string? Type)> defaultValues,
List<ExternalIdRelationshipDto>? externalIdRelationships = null)
{
var restData = new Dictionary<string, object>();
// Costruisce un set dei campi sorgente usati esclusivamente come External ID Relationship:
// questi NON devono essere inviati anche come mapping normale (stessa logica della UI manuale).
var externalIdSourceFields = (externalIdRelationships != null)
? externalIdRelationships
.Where(r => !string.IsNullOrWhiteSpace(r.SourceField))
.Select(r => r.SourceField)
.ToHashSet()
: new HashSet<string>();
// 1. Applica field mappings (escludendo i campi sorgente usati per External ID Relationships)
foreach (var mapping in fieldMappings)
{
// Salta il campo se è usato come sorgente in un External ID Relationship
if (externalIdSourceFields.Contains(mapping.Key))
{
_logger.LogDebug("Campo sorgente '{SourceField}' usato in External ID Relationship, escluso dal mapping normale", mapping.Key);
continue;
}
if (sourceRecord.ContainsKey(mapping.Key))
{
var value = sourceRecord[mapping.Key];
@@ -1164,7 +1238,22 @@ public class ScheduledProfileExecutionService : IScheduledProfileExecutionServic
}
}
// Aggiungi External ID Relationships (per Salesforce)
// 2. Applica default values (solo se il campo non è già stato mappato)
foreach (var defaultValue in defaultValues)
{
if (!restData.ContainsKey(defaultValue.Key))
{
var (value, type) = defaultValue.Value;
if (value != null)
{
restData[defaultValue.Key] = value;
_logger.LogDebug("Applicato default value: {Field} = {Value} ({Type})",
defaultValue.Key, value, type);
}
}
}
// 3. Aggiungi External ID Relationships (per Salesforce)
if (externalIdRelationships != null && externalIdRelationships.Any())
{
foreach (var relationship in externalIdRelationships)
+7 -2
View File
@@ -3,6 +3,8 @@
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
# Versione calcolata da MinVer sul runner CI/CD e passata come build-arg
ARG APP_VERSION=0.0.0-alpha.0
WORKDIR /src
# Copia i file di progetto e ripristina le dipendenze
@@ -20,15 +22,18 @@ COPY . .
# Build del progetto principale
WORKDIR "/src/Data_Coupler"
RUN dotnet build "Data_Coupler.csproj" -c Release -o /app/build /p:ContinuousIntegrationBuild=true
RUN dotnet build "Data_Coupler.csproj" -c Release -o /app/build /p:ContinuousIntegrationBuild=true /p:MinVerVersionOverride=${APP_VERSION}
# Stage 2: Publish
FROM build AS publish
# Necessario ridichiarare ARG dopo FROM in multi-stage build
ARG APP_VERSION=0.0.0-alpha.0
RUN dotnet publish "Data_Coupler.csproj" -c Release -o /app/publish \
/p:SelfContained=false \
/p:PublishTrimmed=false \
/p:PublishSingleFile=false \
/p:ContinuousIntegrationBuild=true
/p:ContinuousIntegrationBuild=true \
/p:MinVerVersionOverride=${APP_VERSION}
# Stage 3: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
+6 -2
View File
@@ -3,6 +3,8 @@
# Stage 1: Build
FROM mcr.microsoft.com/dotnet/sdk:9.0-nanoserver-ltsc2022 AS build
# Versione calcolata da MinVer sul runner CI/CD e passata come build-arg
ARG APP_VERSION=0.0.0-alpha.0
WORKDIR /s
# Copia i file di progetto e ripristina le dipendenze con nomi originali
@@ -23,11 +25,13 @@ COPY ["Components/", "Components/"]
# Build del progetto principale con output path corto
WORKDIR "/s/Data_Coupler"
RUN dotnet build "Data_Coupler.csproj" -c Release -o /o --no-restore
RUN dotnet build "Data_Coupler.csproj" -c Release -o /o --no-restore /p:ContinuousIntegrationBuild=true /p:MinVerVersionOverride=%APP_VERSION%
# Stage 2: Publish
FROM build AS publish
RUN dotnet publish "Data_Coupler.csproj" -c Release -o /p --no-restore -r win-x64 --self-contained false
# Necessario ridichiarare ARG dopo FROM in multi-stage build
ARG APP_VERSION=0.0.0-alpha.0
RUN dotnet publish "Data_Coupler.csproj" -c Release -o /p --no-restore -r win-x64 --self-contained false /p:ContinuousIntegrationBuild=true /p:MinVerVersionOverride=%APP_VERSION%
# Stage 3: Runtime
FROM mcr.microsoft.com/dotnet/aspnet:9.0-nanoserver-ltsc2022 AS final
+116 -14
View File
@@ -196,30 +196,115 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0');
## 📊 Formato Dati Salesforce
### Esempio di Trasformazione
### ⚠️ REGOLA IMPORTANTE: Formato Basato sull'Oggetto DESTINAZIONE
Il formato delle External ID Relationships dipende dal **tipo dell'oggetto DESTINAZIONE** (quello che stai creando/aggiornando), **NON** dal tipo dell'oggetto correlato:
#### **Se l'Oggetto DESTINAZIONE è CUSTOM** (es. `Sales_Quote__c`, `Custom_Order__c`):
- ✅ Tutte le relazioni usano `__r`, sia per oggetti standard che custom
- **Oggetto Standard**: `"Account__r": { "External_ID__c": "value" }`
- **Oggetto Custom**: `"Custom_Company__r": { "External_ID__c": "value" }`
#### **Se l'Oggetto DESTINAZIONE è STANDARD** (es. `Opportunity`, `Contact`):
- ✅ Solo oggetti custom correlati usano `__r`
- **Oggetto Standard**: `"Account": { "External_ID__c": "value" }`
- **Oggetto Custom**: `"Custom_Company__r": { "External_ID__c": "value" }`
### Esempi Pratici
#### Esempio 1: Destinazione CUSTOM → Relazione a Oggetto STANDARD
**Scenario**: Creo un record `Sales_Quote__c` collegato ad `Account` standard
**Configurazione:**
- **Relationship Name**: `Account__r`
- **Destination Object**: `Sales_Quote__c` (CUSTOM)
- **Relationship Name**: `Account__r` ⚠️ **Usa __r anche se Account è standard!**
- **Related Object**: `Account`
- **External ID Field**: `Codice_ERP__c`
- **Source Field**: `customerCode`
**Record Trasformato:**
```json
{
"Name": "Quote 2024-001",
"Quote_Code__c": "Q001",
"Account__r": {
"Codice_ERP__c": "C60000"
}
}
```
#### Esempio 2: Destinazione STANDARD → Relazione a Oggetto STANDARD
**Scenario**: Creo un record `Opportunity` collegato ad `Account`
**Configurazione:**
- **Destination Object**: `Opportunity` (STANDARD)
- **Relationship Name**: `Account` ⚠️ **NON usa __r**
- **Related Object**: `Account`
- **External ID Field**: `Country__c`
- **Source Field**: `CountryCode` (dalla tabella sorgente)
- **Source Field**: `CountryCode`
**Record Trasformato:**
```json
{
"Name": "New Deal 2024",
"StageName": "Prospecting",
"Account": {
"Country__c": "US"
}
}
```
#### Esempio 3: Destinazione CUSTOM → Relazione a Oggetto CUSTOM
**Configurazione:**
- **Destination Object**: `Sales_Quote__c` (CUSTOM)
- **Relationship Name**: `Custom_Territory__r`
- **Related Object**: `Custom_Territory__c`
- **External ID Field**: `Territory_Code__c`
- **Source Field**: `territoryCode`
**Record Sorgente:**
```json
{
"ProductName": "Widget A",
"Price": 99.99,
"CountryCode": "US"
"quoteName": "Quote A",
"territoryCode": "NORTH-WEST"
}
```
**Record Trasformato per Salesforce:**
**Record Trasformato:**
```json
{
"Name": "Widget A",
"Price__c": 99.99,
"Account__r": {
"Country__c": "US"
"Name": "Quote A",
"Custom_Territory__r": {
"Territory_Code__c": "NORTH-WEST"
}
}
```
### Logica di Normalizzazione Automatica
Il sistema implementa il metodo `NormalizeRelationshipName()` che garantisce il formato corretto:
```csharp
private string NormalizeRelationshipName(string relatedObjectName, bool isDestinationCustom)
{
if (isDestinationCustom)
{
// Destinazione CUSTOM: tutte le relazioni usano __r
if (relatedObjectName.EndsWith("__c"))
return relatedObjectName.Replace("__c", "__r"); // Custom_Obj__c → Custom_Obj__r
else
return relatedObjectName + "__r"; // Account → Account__r
}
else
{
// Destinazione STANDARD: solo oggetti custom usano __r
if (relatedObjectName.EndsWith("__c"))
return relatedObjectName.Replace("__c", "__r"); // Custom_Obj__c → Custom_Obj__r
else
return relatedObjectName; // Account → Account (no suffix)
}
}
```
@@ -227,9 +312,10 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0');
### Vantaggi External ID
1. **Nessun ID Salesforce Richiesto**: Non serve conoscere l'ID Salesforce dell'Account
2. **Lookup Automatico**: Salesforce cerca automaticamente l'Account con `Country__c = "US"`
3. **Upsert Intelligente**: Se non trova l'Account, può crearlo automaticamente (se configurato)
2. **Lookup Automatico**: Salesforce cerca automaticamente l'oggetto correlato tramite External ID
3. **Upsert Intelligente**: Se non trova l'oggetto, può crearlo automaticamente (se configurato)
4. **Manutenzione Semplificata**: I codici esterni sono più stabili degli ID interni
5. **Normalizzazione Automatica**: Il sistema corregge automaticamente i nomi quando carica profili salvati
## 🔄 Flusso Operativo
@@ -307,6 +393,14 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0');
- Solo dopo field mappings configurati (`fieldMappings.Any()`)
- Migliora UX evitando confusione per altre API
5. **⭐ Normalizzazione Automatica RelationshipName (FIX CRITICO - 17 Feb 2026)**
- **Problema Risolto**: Errore `"No such column 'Account' on sobject of type Sales_Quote__c"`
- **Causa**: Il formato dipende dall'oggetto DESTINAZIONE, non dall'oggetto correlato
- **Soluzione**: Metodo `NormalizeRelationshipName()` controlla tipo oggetto destinazione
- **Funzionalità**: Corregge automaticamente i RelationshipName al caricamento profili
- **Regola**: Se destinazione è custom → usa SEMPRE `__r` per tutte le relazioni
- **Benefici**: Profili esistenti vengono corretti automaticamente senza intervento manuale
### Potenziali Estensioni Future
1. **Validazione Avanzata**: Verifica esistenza oggetto/campo su Salesforce prima di salvare
@@ -319,6 +413,13 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0');
### Errori Comuni
**⚠️ Errore: "No such column 'Account' on sobject of type Sales_Quote__c"**
- **Causa**: RelationshipName incorretto per oggetto destinazione custom
- **Spiegazione**: Quando l'oggetto DESTINAZIONE è custom (es. `Sales_Quote__c`), TUTTE le relazioni devono usare `__r`, anche per oggetti standard
- **Soluzione AUTOMATICA**: ✅ Il sistema ora normalizza automaticamente i nomi delle relazioni
- **Esempio**: Se destinazione è `Sales_Quote__c` e correlato è `Account` → usa `Account__r` (non `Account`)
- **Fix Manuale**: Se usi profili vecchi, il sistema correggerà automaticamente al caricamento
**Errore: "External ID field not found"**
- Causa: Campo External ID non esiste sull'oggetto Salesforce
- Soluzione: Verificare che il campo sia configurato come External ID in Salesforce
@@ -343,7 +444,8 @@ VALUES ('20260203000000_AddExternalIdRelationships', '9.0.0');
---
**Implementazione Completata**: 3 Febbraio 2026
**Implementazione Iniziale**: 3 Febbraio 2026
**Ultimo Aggiornamento**: 17 Febbraio 2026 - ⭐ **FIX CRITICO**: Normalizzazione automatica RelationshipName
**Framework**: .NET 9.0
**Pattern**: Repository + DTO + Service Layer
**Database**: SQLite con Entity Framework Core
+85
View File
@@ -0,0 +1,85 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
namespace MachineGuard;
/// <summary>
/// Implementazione Windows-only della protezione machine-binding tramite DPAPI.
/// Utilizza <see cref="ProtectedData"/> con scope <see cref="DataProtectionScope.LocalMachine"/>:
/// il dato cifrato è legato fisicamente alla macchina e non può essere decifrato
/// su un'altra macchina, anche con gli stessi account o credenziali.
/// </summary>
[SupportedOSPlatform("windows")]
internal sealed class DpapiMachineGuard : IMachineGuard
{
private readonly MachineGuardOptions _options;
private readonly ILogger<DpapiMachineGuard> _logger;
public DpapiMachineGuard(IOptions<MachineGuardOptions> options, ILogger<DpapiMachineGuard> logger)
{
_options = options.Value;
_logger = logger;
}
/// <inheritdoc/>
public bool Verify()
{
var secretPath = ResolveSecretFilePath();
if (!File.Exists(secretPath))
{
_logger.LogError(
"MachineGuard: file secret non trovato in '{Path}'. " +
"Eseguire MachineGuardSetup.exe su questa macchina per inizializzare l'autorizzazione.",
secretPath);
return false;
}
try
{
var encryptedBytes = File.ReadAllBytes(secretPath);
var decryptedBytes = ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.LocalMachine);
var decryptedToken = Encoding.UTF8.GetString(decryptedBytes);
if (!string.Equals(decryptedToken, MachineGuardToken.ExpectedToken, StringComparison.Ordinal))
{
_logger.LogError(
"MachineGuard: il token decifrato non corrisponde al token atteso. " +
"Questa macchina non è autorizzata a eseguire questa applicazione.");
return false;
}
_logger.LogInformation("MachineGuard: autorizzazione macchina verificata con successo.");
return true;
}
catch (CryptographicException ex)
{
_logger.LogError(ex,
"MachineGuard: decifrazione fallita. " +
"Il file secret potrebbe provenire da un'altra macchina o essere corrotto. " +
"Percorso: '{Path}'", secretPath);
return false;
}
catch (Exception ex)
{
_logger.LogError(ex,
"MachineGuard: errore imprevisto durante la verifica. Percorso: '{Path}'", secretPath);
return false;
}
}
private string ResolveSecretFilePath()
{
if (!string.IsNullOrWhiteSpace(_options.SecretFilePath))
return _options.SecretFilePath;
var appData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
if (string.IsNullOrEmpty(appData))
appData = @"C:\ProgramData";
return Path.Combine(appData, "DataCoupler", "machine.guard");
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace MachineGuard;
/// <summary>
/// Interfaccia per il meccanismo di protezione machine-binding tramite DPAPI.
/// </summary>
public interface IMachineGuard
{
/// <summary>
/// Verifica che l'applicazione sia in esecuzione su una macchina autorizzata.
/// Restituisce <c>true</c> se l'autorizzazione è confermata, <c>false</c> altrimenti.
/// </summary>
bool Verify();
}
+18
View File
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="9.0.0" />
</ItemGroup>
</Project>
+75
View File
@@ -0,0 +1,75 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace MachineGuard;
/// <summary>
/// Metodi di estensione per la registrazione di MachineGuard nel container DI.
/// </summary>
public static class MachineGuardExtensions
{
/// <summary>
/// Registra il servizio <see cref="IMachineGuard"/> nel container DI.
/// <para>
/// Se <c>MachineGuard:Enabled</c> è <c>false</c> in appsettings, viene registrato
/// un guard no-op che approva sempre la verifica (utile per sviluppo/CI).
/// Su piattaforme non-Windows, DPAPI non è disponibile e il guard viene disabilitato automaticamente.
/// </para>
/// </summary>
public static IServiceCollection AddMachineGuard(
this IServiceCollection services,
IConfiguration configuration)
{
services.Configure<MachineGuardOptions>(
configuration.GetSection(MachineGuardOptions.SectionName));
services.AddSingleton<IMachineGuard>(sp =>
{
var loggerFactory = sp.GetRequiredService<ILoggerFactory>();
var logger = loggerFactory.CreateLogger("MachineGuard.Startup");
#if DEBUG
// In build Debug la protezione è sempre disabilitata — nessuna configurazione richiesta.
logger.LogInformation(
"MachineGuard: build DEBUG — protezione machine-binding disabilitata automaticamente.");
return new NullMachineGuard();
#else
// In build Release la protezione è sempre attiva.
// Può essere disabilitata esplicitamente via MachineGuard:Enabled = false
// (utile per ambienti Linux/Docker o casi eccezionali).
var options = sp.GetRequiredService<IOptions<MachineGuardOptions>>();
if (!options.Value.Enabled)
{
logger.LogWarning(
"MachineGuard: protezione machine-binding DISABILITATA via configurazione. " +
"Impostare MachineGuard:Enabled = true in produzione.");
return new NullMachineGuard();
}
if (!OperatingSystem.IsWindows())
{
logger.LogWarning(
"MachineGuard: DPAPI non è disponibile su piattaforme non-Windows. " +
"La protezione machine-binding è bypassata automaticamente.");
return new NullMachineGuard();
}
return CreateWindowsGuard(sp, options);
#endif
});
return services;
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
private static IMachineGuard CreateWindowsGuard(
IServiceProvider sp,
IOptions<MachineGuardOptions> options)
{
var logger = sp.GetRequiredService<ILogger<DpapiMachineGuard>>();
return new DpapiMachineGuard(options, logger);
}
}
+25
View File
@@ -0,0 +1,25 @@
namespace MachineGuard;
/// <summary>
/// Opzioni di configurazione per MachineGuard.
/// Configurabili tramite appsettings.json nella sezione "MachineGuard".
/// </summary>
public sealed class MachineGuardOptions
{
/// <summary>Nome della sezione in appsettings.json.</summary>
public const string SectionName = "MachineGuard";
/// <summary>
/// Imposta a <c>false</c> per disabilitare completamente la protezione machine-binding.
/// Utile in ambienti di sviluppo o CI. Default: <c>true</c>.
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Percorso del file secret cifrato.
/// Se vuoto, viene usato il percorso predefinito:
/// Windows: %ProgramData%\DataCoupler\machine.guard
/// Linux: /etc/datacoupler/machine.guard
/// </summary>
public string? SecretFilePath { get; set; }
}
+73
View File
@@ -0,0 +1,73 @@
using System.Runtime.Versioning;
using System.Security.Cryptography;
using System.Text;
namespace MachineGuard;
/// <summary>
/// Helper pubblico per la scrittura e la verifica del file secret di MachineGuard.
/// Usato da MachineGuardSetup e da eventuali script di deployment.
/// </summary>
public static class MachineGuardSetupHelper
{
/// <summary>
/// Cifra il token interno con DPAPI (LocalMachine scope) e lo scrive nel percorso specificato.
/// Crea la directory di destinazione se non esiste.
/// </summary>
/// <param name="secretFilePath">
/// Percorso completo del file in cui salvare il secret cifrato.
/// Usare <see cref="GetDefaultSecretFilePath"/> per il percorso di default.
/// </param>
[SupportedOSPlatform("windows")]
public static void WriteSecret(string secretFilePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(secretFilePath);
var tokenBytes = Encoding.UTF8.GetBytes(MachineGuardToken.ExpectedToken);
var encryptedBytes = ProtectedData.Protect(tokenBytes, null, DataProtectionScope.LocalMachine);
var directory = Path.GetDirectoryName(secretFilePath);
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
File.WriteAllBytes(secretFilePath, encryptedBytes);
}
/// <summary>
/// Verifica che il file secret nel percorso specificato decifrabile con successo prima del deployment.
/// </summary>
[SupportedOSPlatform("windows")]
public static bool VerifySecret(string secretFilePath)
{
if (!File.Exists(secretFilePath))
return false;
try
{
var encryptedBytes = File.ReadAllBytes(secretFilePath);
var decryptedBytes = ProtectedData.Unprotect(encryptedBytes, null, DataProtectionScope.LocalMachine);
var decryptedToken = Encoding.UTF8.GetString(decryptedBytes);
return string.Equals(decryptedToken, MachineGuardToken.ExpectedToken, StringComparison.Ordinal);
}
catch
{
return false;
}
}
/// <summary>
/// Restituisce il percorso predefinito del file secret in base al sistema operativo corrente.
/// </summary>
public static string GetDefaultSecretFilePath()
{
if (OperatingSystem.IsWindows())
{
var appData = Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData);
if (string.IsNullOrEmpty(appData))
appData = @"C:\ProgramData";
return Path.Combine(appData, "DataCoupler", "machine.guard");
}
return "/etc/datacoupler/machine.guard";
}
}
+14
View File
@@ -0,0 +1,14 @@
namespace MachineGuard;
/// <summary>
/// Contiene il token di machine-binding cablato nel codice.
/// Modificare questo valore per ogni distribuzione autorizzata,
/// quindi eseguire MachineGuardSetup per scrivere il secret su ogni macchina target.
/// </summary>
internal static class MachineGuardToken
{
/// <summary>
/// Token atteso. Deve corrispondere esattamente al valore firmato durante il setup.
/// </summary>
internal const string ExpectedToken = "DC-F47AC10B-58CC-4372-A567-0E02B2C3D479";
}
+13
View File
@@ -0,0 +1,13 @@
namespace MachineGuard;
/// <summary>
/// Implementazione no-op di <see cref="IMachineGuard"/>.
/// Usata quando la protezione è disabilitata via configurazione
/// o quando il sistema operativo non supporta DPAPI (es. Linux, macOS).
/// Restituisce sempre <c>true</c> senza eseguire alcuna verifica.
/// </summary>
internal sealed class NullMachineGuard : IMachineGuard
{
/// <inheritdoc/>
public bool Verify() => true;
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>MachineGuardSetup</AssemblyName>
<RootNamespace>MachineGuardSetup</RootNamespace>
<!-- Standalone: publish as single self-contained exe for easy deployment -->
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MachineGuard\MachineGuard.csproj" />
</ItemGroup>
</Project>
+198
View File
@@ -0,0 +1,198 @@
using MachineGuard;
// ============================================================
// MachineGuardSetup — Strumento di configurazione machine-binding
// Utilizzo: eseguire come Amministratore su ogni server autorizzato
// Indipendente da Data Coupler — nessuna dipendenza dall'applicazione
// ============================================================
Console.OutputEncoding = System.Text.Encoding.UTF8;
PrintBanner();
if (!OperatingSystem.IsWindows())
{
PrintError("Questo strumento richiede Windows (DPAPI è un'API esclusiva di Windows).");
return 1;
}
if (!IsRunningAsAdministrator())
{
PrintWarning("Attenzione: l'applicazione non è in esecuzione come Amministratore.");
PrintWarning("La scrittura in C:\\ProgramData potrebbe fallire senza privilegi elevati.");
Console.WriteLine();
}
return RunSetupWindows();
// ─────────────────────────────────────────────────────────────
// Entry point per Windows (isolato per soddisfare l'analizzatore)
// ─────────────────────────────────────────────────────────────
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
static int RunSetupWindows()
{
var defaultPath = MachineGuardSetupHelper.GetDefaultSecretFilePath();
Console.WriteLine("╔══════════════════════════════════════════════════════════╗");
Console.WriteLine("║ CONFIGURAZIONE MACHINE-BINDING ║");
Console.WriteLine("╚══════════════════════════════════════════════════════════╝");
Console.WriteLine();
Console.WriteLine($" Percorso predefinito secret: {defaultPath}");
Console.WriteLine();
// Chiedi conferma o percorso personalizzato
Console.Write(" Usare il percorso predefinito? [S/n]: ");
var input = Console.ReadLine()?.Trim().ToUpperInvariant();
Console.WriteLine();
string targetPath;
if (string.IsNullOrEmpty(input) || input == "S" || input == "Y")
{
targetPath = defaultPath;
}
else
{
Console.Write(" Inserire il percorso completo del file secret: ");
var customPath = Console.ReadLine()?.Trim();
if (string.IsNullOrWhiteSpace(customPath))
{
PrintError("Percorso non valido. Operazione annullata.");
return 1;
}
targetPath = customPath;
}
// Verifica se esiste già un secret
if (File.Exists(targetPath))
{
Console.WriteLine($" ⚠ Il file secret esiste già: {targetPath}");
Console.Write(" Sovrascrivere? [s/N]: ");
var overwrite = Console.ReadLine()?.Trim().ToUpperInvariant();
Console.WriteLine();
if (overwrite != "S" && overwrite != "Y")
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine(" Operazione annullata dall'utente.");
Console.ResetColor();
return 0;
}
// Verifica se il secret attuale è già valido
Console.WriteLine(" Verifica del secret esistente...");
if (MachineGuardSetupHelper.VerifySecret(targetPath))
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(" ✓ Il secret esistente è GIÀ valido per questa macchina.");
Console.ResetColor();
Console.Write(" Continuare comunque e riscrivere? [s/N]: ");
var rewrite = Console.ReadLine()?.Trim().ToUpperInvariant();
Console.WriteLine();
if (rewrite != "S" && rewrite != "Y")
{
Console.WriteLine(" Operazione annullata. Il secret esistente rimane invariato.");
return 0;
}
}
}
// Scrittura del secret
Console.WriteLine($" Scrittura del secret cifrato con DPAPI (LocalMachine) in:");
Console.WriteLine($" {targetPath}");
Console.WriteLine();
try
{
MachineGuardSetupHelper.WriteSecret(targetPath);
}
catch (UnauthorizedAccessException ex)
{
PrintError($"Accesso negato: {ex.Message}");
PrintError("Riprovare eseguendo il programma come Amministratore (tasto destro → Esegui come amministratore).");
return 1;
}
catch (Exception ex)
{
PrintError($"Errore durante la scrittura del secret: {ex.Message}");
return 1;
}
// Verifica post-scrittura
Console.WriteLine(" Verifica del secret appena scritto...");
if (MachineGuardSetupHelper.VerifySecret(targetPath))
{
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine(" ✓ Secret scritto e verificato con successo!");
Console.ResetColor();
Console.WriteLine();
Console.WriteLine(" Questa macchina è ora autorizzata a eseguire Data Coupler.");
Console.WriteLine();
PrintFileInfo(targetPath);
}
else
{
PrintError("Verifica post-scrittura fallita. Il file potrebbe essere corrotto.");
return 1;
}
Console.WriteLine();
Console.WriteLine(" Premere un tasto per uscire...");
if (!Console.IsInputRedirected)
Console.ReadKey(intercept: true);
return 0;
}
// ─────────────────────────────────────────────────────────────
// Funzioni di utilità
// ─────────────────────────────────────────────────────────────
static void PrintBanner()
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine();
Console.WriteLine(" ██████╗ █████╗ ████████╗ █████╗ ██████╗ ██████╗ ██╗ ██╗██████╗ ██╗ ███████╗██████╗ ");
Console.WriteLine(" ██╔══██╗██╔══██╗╚══██╔══╝██╔══██╗ ██╔════╝██╔═══██╗██║ ██║██╔══██╗██║ ██╔════╝██╔══██╗");
Console.WriteLine(" ██║ ██║███████║ ██║ ███████║ ██║ ██║ ██║██║ ██║██████╔╝██║ █████╗ ██████╔╝");
Console.WriteLine(" ██║ ██║██╔══██║ ██║ ██╔══██║ ██║ ██║ ██║██║ ██║██╔═══╝ ██║ ██╔══╝ ██╔══██╗");
Console.WriteLine(" ██████╔╝██║ ██║ ██║ ██║ ██║ ╚██████╗╚██████╔╝╚██████╔╝██║ ███████╗███████╗██║ ██║");
Console.WriteLine(" ╚═════╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚══════╝╚══════╝╚═╝ ╚═╝");
Console.ResetColor();
Console.WriteLine();
Console.ForegroundColor = ConsoleColor.White;
Console.WriteLine(" MachineGuardSetup — Configurazione protezione machine-binding per Data Coupler");
Console.WriteLine(" Versione: 1.0 | Tecnologia: DPAPI (DataProtectionScope.LocalMachine)");
Console.ResetColor();
Console.WriteLine(new string('─', 70));
Console.WriteLine();
}
static void PrintError(string message)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($" ✗ ERRORE: {message}");
Console.ResetColor();
}
static void PrintWarning(string message)
{
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" ⚠ {message}");
Console.ResetColor();
}
static void PrintFileInfo(string path)
{
var fi = new FileInfo(path);
Console.WriteLine(" Dettagli file:");
Console.WriteLine($" Percorso : {fi.FullName}");
Console.WriteLine($" Dimensione: {fi.Length} byte (cifrati con DPAPI)");
Console.WriteLine($" Creato : {fi.CreationTime:dd/MM/yyyy HH:mm:ss}");
}
static bool IsRunningAsAdministrator()
{
if (!OperatingSystem.IsWindows()) return false;
using var identity = System.Security.Principal.WindowsIdentity.GetCurrent();
var principal = new System.Security.Principal.WindowsPrincipal(identity);
return principal.IsInRole(System.Security.Principal.WindowsBuiltInRole.Administrator);
}
+5
View File
@@ -8,6 +8,11 @@ Data-Coupler è una soluzione integrata per la gestione di connessioni dati e cr
- **DataConnection**: Libreria per connessioni a database e API REST
- **Data_Coupler**: Applicazione Blazor Server per l'interfaccia utente
### 🆕 Novità Recenti (Febbraio 2026)
- ✅ **Salesforce Batch Describe via Composite API**: I metadati degli SObject vengono ora recuperati in batch (25 per chiamata) invece di N chiamate singole, riducendo drasticamente il consumo di API durante la discovery
- ✅ **Discovery REST Parallela**: `DiscoverEntitySummariesAsync` e `DiscoverEntitiesAsync` vengono eseguite in parallelo; la lista entità diventa interattiva quasi subito, i dettagli arrivano in background
- ✅ **Fix Scheduler External ID Relationships**: Corretti due bug nello schedulatore — `ExternalIdRelationshipsJson` e `DefaultValuesJson` venivano azzerati al re-salvataggio del profilo; i campi sorgente usati nelle relazioni External ID non venivano esclusi dal mapping normale
### 🆕 Novità Recenti (Gennaio 2026)
- ✅ **Schedulazione File CSV/Excel**: Supporto completo per schedulare trasferimenti da file
- ✅ **Validazione Percorsi**: Validazione file prima del salvataggio profili