[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:
co-authored by
Claude Opus 4.8
parent
ef9a7282c5
commit
4e2e56ec0b
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
/**
|
||||
* migrate_063_vocea_channel.php — Integrazione Vocea (whistleblowing zero-knowledge).
|
||||
* organizations += vocea_tenant_slug, vocea_channel_status, vocea_enrolled_at. IDEMPOTENTE.
|
||||
* SCAFFOLD DORMIENTE: nessun effetto funzionale finché VOCEA_ENABLED=false (default).
|
||||
*
|
||||
* Eseguire (host): docker exec nis2-app php /var/www/nis2-agile/application/cli/migrate_063_vocea_channel.php
|
||||
*/
|
||||
if (PHP_SAPI !== 'cli') { http_response_code(403); exit("CLI only\n"); }
|
||||
require_once __DIR__ . '/../config/env.php';
|
||||
require_once __DIR__ . '/../config/database.php';
|
||||
|
||||
$pdo = Database::getInstance();
|
||||
|
||||
function colExists(PDO $pdo, string $table, string $col): bool {
|
||||
$st = $pdo->prepare(
|
||||
"SELECT 1 FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?"
|
||||
);
|
||||
$st->execute([$table, $col]);
|
||||
return (bool)$st->fetchColumn();
|
||||
}
|
||||
|
||||
$adds = [
|
||||
'vocea_tenant_slug' => "ADD COLUMN vocea_tenant_slug VARCHAR(64) NULL COMMENT 'Slug canale Vocea (NULL=nessuno)'",
|
||||
'vocea_channel_status' => "ADD COLUMN vocea_channel_status ENUM('none','provisioning','active','suspended') NOT NULL DEFAULT 'none'",
|
||||
'vocea_enrolled_at' => "ADD COLUMN vocea_enrolled_at TIMESTAMP NULL COMMENT 'Enrollment canale + key holder'",
|
||||
];
|
||||
|
||||
foreach ($adds as $col => $clause) {
|
||||
if (colExists($pdo, 'organizations', $col)) { echo " - $col gia presente, skip\n"; continue; }
|
||||
$pdo->exec("ALTER TABLE organizations $clause");
|
||||
echo " + aggiunta colonna organizations.$col\n";
|
||||
}
|
||||
|
||||
echo "Migrazione 063 — organizations.vocea_* OK. Prossima mig=064.\n";
|
||||
@@ -103,6 +103,18 @@ define('ANTHROPIC_API_KEY', Env::get('ANTHROPIC_API_KEY', ''));
|
||||
define('ANTHROPIC_MODEL', Env::get('ANTHROPIC_MODEL', 'claude-sonnet-4-5-20250929'));
|
||||
define('ANTHROPIC_MAX_TOKENS', Env::int('ANTHROPIC_MAX_TOKENS', 4096));
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// VOCEA (Whistleblowing zero-knowledge — integrazione, DISABILITATA di default)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// SCAFFOLD DORMIENTE: con VOCEA_ENABLED=false (default) VoceaService non effettua
|
||||
// nessuna chiamata di rete. Attivazione a MS live: applicare migrate_063 +
|
||||
// VOCEA_ENABLED=true + VOCEA_API_KEY (vault tier1__nis2-agile__vocea__*) +
|
||||
// slug del canale su organizations.vocea_tenant_slug (status='active').
|
||||
define('VOCEA_ENABLED', filter_var(Env::get('VOCEA_ENABLED', 'false'), FILTER_VALIDATE_BOOLEAN));
|
||||
define('VOCEA_BASE_URL', Env::get('VOCEA_BASE_URL', 'https://api.vocea.cloud/api/wb/v1')); // API integratori (X-API-Key)
|
||||
define('VOCEA_API_KEY', Env::get('VOCEA_API_KEY', '')); // wbk_... per-tenant (da vault)
|
||||
define('VOCEA_PORTAL_URL', Env::get('VOCEA_PORTAL_URL', 'https://app.vocea.cloud')); // portale embed via wb-link.js
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CERTISOURCE (atti-service.php)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
require_once __DIR__ . '/BaseController.php';
|
||||
require_once APP_PATH . '/services/WebhookService.php';
|
||||
require_once APP_PATH . '/services/VoceaService.php';
|
||||
|
||||
class WhistleblowingController extends BaseController
|
||||
{
|
||||
@@ -126,6 +127,17 @@ class WhistleblowingController extends BaseController
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager']);
|
||||
|
||||
// ── Delega a Vocea quando il canale esterno è attivo per l'org (dormiente di
|
||||
// default: VOCEA_ENABLED=false → si salta del tutto e resta il modulo legacy).
|
||||
if (VoceaService::enabled()) {
|
||||
$org = $this->voceaOrg();
|
||||
$svc = new VoceaService();
|
||||
if ($org && $svc->isEnabledForOrg($org)) {
|
||||
$this->listViaVocea($svc, $org);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$conditions = ['wr.organization_id = ?'];
|
||||
$params = [$this->getCurrentOrgId()];
|
||||
|
||||
@@ -383,4 +395,69 @@ class WhistleblowingController extends BaseController
|
||||
'timeline' => $timeline,
|
||||
]);
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// VOCEA (integrazione whistleblowing zero-knowledge) — scaffold dormiente
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* GET /api/whistleblowing/channelStatus
|
||||
* Indica alla UI se mostrare il canale Vocea (embed wb-link.js) o il modulo legacy.
|
||||
* Non espone segreti (mai l'API key).
|
||||
*/
|
||||
public function channelStatus(): void
|
||||
{
|
||||
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member']);
|
||||
|
||||
$enabled = VoceaService::enabled();
|
||||
$org = $enabled ? $this->voceaOrg() : null; // query solo se attivo (colonne vocea_* presenti)
|
||||
$slug = $org['vocea_tenant_slug'] ?? null;
|
||||
$status = $org['vocea_channel_status'] ?? 'none';
|
||||
|
||||
$this->jsonSuccess([
|
||||
'integration_enabled' => $enabled,
|
||||
'channel_status' => $status,
|
||||
'provisioned' => $enabled && !empty($slug) && $status === 'active',
|
||||
'portal_url' => !empty($slug) ? VoceaService::portalUrl($slug) : null,
|
||||
]);
|
||||
}
|
||||
|
||||
/** Carica l'org corrente con le colonne vocea_* (chiamata solo quando VOCEA_ENABLED). */
|
||||
private function voceaOrg(): ?array
|
||||
{
|
||||
return Database::fetchOne(
|
||||
'SELECT * FROM organizations WHERE id = ?',
|
||||
[$this->getCurrentOrgId()]
|
||||
) ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista segnalazioni dal canale Vocea (metadati + ciphertext, zero-knowledge).
|
||||
* Il plaintext NON transita mai: la decifratura avviene client-side dal gestore.
|
||||
* NB: mapping campi da riconfermare allo smoke a MS live (shape risposta del MS).
|
||||
*/
|
||||
private function listViaVocea(VoceaService $svc, array $org): void
|
||||
{
|
||||
$query = [];
|
||||
foreach (['status', 'category', 'page', 'per_page'] as $f) {
|
||||
if ($this->hasParam($f)) $query[$f] = $this->getParam($f);
|
||||
}
|
||||
|
||||
$res = $svc->listReports($org['vocea_tenant_slug'], $query);
|
||||
if (!($res['ok'] ?? false)) {
|
||||
$this->jsonError(
|
||||
'Canale Vocea non raggiungibile: ' . ($res['error'] ?? 'errore'),
|
||||
502,
|
||||
'VOCEA_UPSTREAM'
|
||||
);
|
||||
}
|
||||
|
||||
$data = $res['data'] ?? [];
|
||||
$reports = $data['items'] ?? ($data['reports'] ?? (is_array($data) ? $data : []));
|
||||
$this->jsonSuccess([
|
||||
'reports' => $reports,
|
||||
'total' => $data['total'] ?? count($reports),
|
||||
'source' => 'vocea',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user