[FEAT] Vocea whistleblowing — scaffold integrazione (dormiente) + handoff AgileHub

Scaffold per sostituire il modulo whistleblowing interno (plaintext) con Vocea
(nexus-whistleblowing-ms), prodotto zero-knowledge della suite. Modalita C
(integrazione verticale, nessuna crypto re-implementata). DORMIENTE: VOCEA_ENABLED
=false di default -> zero impatto sul modulo legacy.

- docs/sql/063_vocea_channel.sql + application/cli/migrate_063_vocea_channel.php
  (idempotente): organizations += vocea_tenant_slug/channel_status/enrolled_at.
  NON ancora applicata al DB.
- application/services/VoceaService.php (nuovo): client API integratori (X-API-Key
  su api.vocea.cloud) per submit/status/reports/keyholders + portalUrl (embed
  wb-link.js). Guard enabled()/isEnabledForOrg(): se OFF/non provisioned ritorna
  DISABLED/NOT_PROVISIONED senza chiamate di rete.
- config.php: costanti VOCEA_ENABLED/BASE_URL/API_KEY/PORTAL_URL (default off).
- WhistleblowingController: branch dormiente in list() (delega a Vocea se attivo) +
  endpoint GET channelStatus + helper voceaOrg/listViaVocea. Modulo legacy invariato.
- public/index.php: route GET:channelStatus.
- docs handoff: richiesta a VIGILE (OUTGOING) + risposta VIGILE (INCOMING).

Attivazione a MS live: applicare migrate_063 + VOCEA_ENABLED=true + VOCEA_API_KEY
(vault tier1__nis2-agile__vocea__*) + slug canale su organizations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-23 09:07:49 +02:00
co-authored by Claude Opus 4.8
parent ef9a7282c5
commit 4e2e56ec0b
8 changed files with 362 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
<?php
/**
* NIS2 Agile - VoceaService
*
* Client per l'integrazione con Vocea (nexus-whistleblowing-ms), il prodotto
* whistleblowing ZERO-KNOWLEDGE della suite. Lettura/gestione server-to-server via
* API key per-tenant (header X-API-Key) su api.vocea.cloud.
*
* SCAFFOLD DORMIENTE: finché VOCEA_ENABLED=false (default) ogni metodo che richiede
* rete ritorna ['ok'=>false,'error'=>'DISABLED']. Si "accende" così:
* 1. applicare migrate_063_vocea_channel.php (colonne organizations.vocea_*)
* 2. VOCEA_ENABLED=true + VOCEA_API_KEY (vault tier1__nis2-agile__vocea__*)
* 3. impostare organizations.vocea_tenant_slug + vocea_channel_status='active' sul canale
*
* CONFINE ZERO-KNOWLEDGE: l'API NON ritorna MAI contenuti in chiaro. listReports
* restituisce metadati + ciphertext; la decifratura avviene client-side dal gestore.
*
* Contratto: whistleblowing-agile/docs/API_GATEWAY_INTEGRATIONS.md
* GET /integrations/reports (scope reports:read) — lista metadati (paginata)
* GET /integrations/reports/{code}/status (scope status:read)
* POST /integrations/reports/submit (scope reports:submit) — payload GIÀ cifrato
* GET /integrations/keyholders · POST /integrations/keyholders (enroll public key RSA)
* GET /tenants/{slug}/info (pubblico — public key del canale)
*
* NB: base URL/prefisso (`/api/wb/v1` vs `/v1`) e shape esatto risposte vanno
* riconfermati allo smoke a MS live; sono parametrizzati (VOCEA_BASE_URL) apposta.
*/
class VoceaService
{
private const TIMEOUT_SEC = 12;
private const CONNECT_TIMEOUT_SEC = 6;
private string $baseUrl;
private string $apiKey;
public function __construct(?string $apiKey = null)
{
$this->baseUrl = rtrim(defined('VOCEA_BASE_URL') ? VOCEA_BASE_URL : 'https://api.vocea.cloud/api/wb/v1', '/');
$this->apiKey = $apiKey ?? (defined('VOCEA_API_KEY') ? VOCEA_API_KEY : '');
}
/** Flag globale: l'integrazione Vocea è attiva? */
public static function enabled(): bool
{
return defined('VOCEA_ENABLED') && VOCEA_ENABLED === true;
}
/** Il canale è utilizzabile per questa organization? (flag globale + slug + stato active + key). */
public function isEnabledForOrg(array $org): bool
{
return self::enabled()
&& !empty($org['vocea_tenant_slug'])
&& (($org['vocea_channel_status'] ?? 'none') === 'active')
&& $this->apiKey !== '';
}
/**
* URL del portale segnalante da embeddare via wb-link.js.
* NB: iframe diretto è bloccato dalla CSP `frame-ancestors 'self'` di Vocea →
* usare il widget wb-link.js oppure far allargare la CSP al dominio NIS2.
*/
public static function portalUrl(string $slug): string
{
$base = rtrim(defined('VOCEA_PORTAL_URL') ? VOCEA_PORTAL_URL : 'https://app.vocea.cloud', '/');
return "{$base}/wb/" . rawurlencode($slug);
}
// ── Read / management (server-to-server, API key) ────────────────────────
/** Public key del canale (endpoint pubblico, no API key). */
public function tenantInfo(string $slug): array
{
return $this->request('GET', '/tenants/' . rawurlencode($slug) . '/info', null, false);
}
/** Lista metadati segnalazioni del canale (paginata, SENZA plaintext). */
public function listReports(string $slug, array $query = []): array
{
$qs = $query ? ('?' . http_build_query($query)) : '';
return $this->request('GET', "/integrations/reports{$qs}");
}
/** Stato di una segnalazione tramite codice ricevuta. */
public function getReportStatus(string $slug, string $code): array
{
return $this->request('GET', '/integrations/reports/' . rawurlencode($code) . '/status');
}
/** Invio segnalazione: $encryptedPayload è GIÀ cifrato lato client (zero-knowledge). */
public function submitReport(string $slug, array $encryptedPayload): array
{
return $this->request('POST', '/integrations/reports/submit', $encryptedPayload);
}
public function listKeyHolders(string $slug): array
{
return $this->request('GET', '/integrations/keyholders');
}
public function enrollKeyHolder(string $slug, array $keyHolder): array
{
return $this->request('POST', '/integrations/keyholders', $keyHolder);
}
// ── HTTP ─────────────────────────────────────────────────────────────────
/**
* @param bool $auth Se true invia X-API-Key (default). Endpoint pubblici (tenantInfo) → false.
* @return array ['ok'=>bool,'status'=>int,'data'=>mixed,'error'=>?string]
*/
private function request(string $method, string $path, ?array $body = null, bool $auth = true): array
{
if (!self::enabled()) {
return ['ok' => false, 'error' => 'DISABLED', 'message' => 'Integrazione Vocea non attiva (VOCEA_ENABLED=false).'];
}
if ($auth && $this->apiKey === '') {
return ['ok' => false, 'error' => 'NOT_PROVISIONED', 'message' => 'VOCEA_API_KEY non configurata (vault tier1__nis2-agile__vocea__*).'];
}
$headers = ['Accept: application/json'];
if ($auth) { $headers[] = 'X-API-Key: ' . $this->apiKey; }
$ch = curl_init($this->baseUrl . $path);
$opts = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_TIMEOUT => self::TIMEOUT_SEC,
CURLOPT_CONNECTTIMEOUT => self::CONNECT_TIMEOUT_SEC,
];
if ($body !== null) {
$opts[CURLOPT_POSTFIELDS] = json_encode($body, JSON_UNESCAPED_UNICODE);
$headers[] = 'Content-Type: application/json';
}
$opts[CURLOPT_HTTPHEADER] = $headers;
curl_setopt_array($ch, $opts);
$raw = curl_exec($ch);
$status = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
$err = curl_error($ch);
curl_close($ch);
if ($raw === false || $status === 0) {
return ['ok' => false, 'status' => 0, 'error' => 'NETWORK', 'message' => $err ?: 'connessione fallita'];
}
$decoded = json_decode($raw, true);
$ok = $status >= 200 && $status < 300;
return [
'ok' => $ok,
'status' => $status,
'data' => $decoded,
'error' => $ok ? null : (($decoded['error']['code'] ?? null) ?: ('HTTP_' . $status)),
];
}
}