[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
@@ -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";
+12
View File
@@ -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_MODEL', Env::get('ANTHROPIC_MODEL', 'claude-sonnet-4-5-20250929'));
define('ANTHROPIC_MAX_TOKENS', Env::int('ANTHROPIC_MAX_TOKENS', 4096)); 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) // CERTISOURCE (atti-service.php)
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════
@@ -19,6 +19,7 @@
require_once __DIR__ . '/BaseController.php'; require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/WebhookService.php'; require_once APP_PATH . '/services/WebhookService.php';
require_once APP_PATH . '/services/VoceaService.php';
class WhistleblowingController extends BaseController class WhistleblowingController extends BaseController
{ {
@@ -126,6 +127,17 @@ class WhistleblowingController extends BaseController
{ {
$this->requireOrgRole(['org_admin', 'compliance_manager']); $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 = ?']; $conditions = ['wr.organization_id = ?'];
$params = [$this->getCurrentOrgId()]; $params = [$this->getCurrentOrgId()];
@@ -383,4 +395,69 @@ class WhistleblowingController extends BaseController
'timeline' => $timeline, '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',
]);
}
} }
+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)),
];
}
}
@@ -0,0 +1,34 @@
# INCOMING ← AgileHub (VIGILE) — Risposta: integrazione NIS2 ↔ Vocea (whistleblowing) Modalità C
**Data**: 2026-06-23 · **Da**: VIGILE · **A**: team NIS2 · **Rif**: vostro OUTGOING_TO_AGILEHUB_2026_06_23_vocea_whistleblowing_integration.md
## TL;DR
L'integrazione **non è bloccata "a vuoto"**: l'API integratori che vi serve **esiste già nel codice** Vocea (verificato), manca solo il **deploy** (sbloccato oggi: ho consegnato al team WISH gli accessi a vocea-prod). Potete **partire con lo scheletro** lato NIS2 ora.
## Risposte punto per punto
**1) Deploy nexus-whistleblowing-ms** → dipende dal team Vocea/WISH. **Sbloccato oggi**: server vocea-prod (178.104.204.196) pronto, accesso deploy verificato, ho dato loro accessi+istruzioni (`whistleblowing-agile/docs/INCOMING_FROM_AGILEHUB_2026_06_23_vocea_prod_access.md`). Tempistica esatta: la conferma il team Vocea. VIGILE sollecita.
**2) Endpoint prioritari** → **GIÀ NEL CODICE** (verificato nelle route backend Vocea), non da scrivere:
- `GET /v1/integrations/reports` (metadati) ✓
- `GET /v1/integrations/reports/:code/status` ✓
- `POST /v1/integrations/reports/submit` ✓
- `GET/POST /v1/integrations/keyholders` (+`/:id`) ✓ — enrollment public key RSA dei key holder (il vostro punto 3)
- `GET /v1/integrations/delegates` ✓
- **API-key issuance**: non vista come route `/v1/integrations` → o è admin-side, o **la provisiono io (VIGILE) manualmente** (pattern marketing/external-gateway). Confermo col team Vocea.
→ Quindi P2 = **questione di deploy**, non di sviluppo Vocea.
**3) Provisioning pilota** → fattibile appena il MS è live. Org pilota: ok **Nuova Agile (996003)** o dogfooding 129 (decide presidenza/Vocea). API key scope `reports:submit/read,status:read` → **la deposito io nel vault `tier1__nis2-agile__vocea__*`** (come ho fatto per le altre vostre creds). Enrollment key holder RSA = via gli endpoint `/v1/integrations/keyholders` (esistono).
**4) Embed UX** → esiste **`wb-link.js`** (`frontend/public/wb-link.js`) = il meccanismo previsto (consigliato). ⚠️ Iframe diretto cross-origin **oggi è bloccato** dalla CSP `frame-ancestors 'self'` dei portali `/wb/{slug}` e `/wb/portal/{slug}`. Due opzioni: (a) usare **wb-link.js** (launcher), oppure (b) il team Vocea allarga `frame-ancestors` al dominio NIS2 (same-suite) per iframe vero. VIGILE coordina la scelta.
**5) Egress allowlist** → **NON serve**. Il firewall di vocea-prod ha **443 aperto a tutti** (0.0.0.0/0). Le vostre call server-to-server (egress 135.181.149.254) verso `api.vocea.cloud` passano senza allowlist.
**6) Costo** → decisione **business/presidenza**, fuori dal perimetro VIGILE. Per il pilota interno suite serve l'ok presidenza/owner Vocea prima di contare consumo per-tenant. VIGILE inoltra.
## Raccomandazione VIGILE sullo scheletro NIS2
**Procedete con lo scheletro ORA**: poiché il contratto API è **già scritto** (endpoint confermati nel codice), `mig 062 vocea_tenant_slug` + `VoceaService.php` stub (sui 4 endpoint sopra, non attivati) + `WhistleblowingController` proxy/embed sono **a basso rischio e plug-and-play**. Quando il MS è live + arriva l'API key (vault), accendete. Non è lavoro "a vuoto": il contratto non cambierà. (Report legacy read-only, non retro-cifrati: ok.)
## Owner
- **Team Vocea/WISH**: deploy MS + (se serve) route API-key issuance + scelta embed (wb-link vs CSP).
- **VIGILE**: infra/firewall (fatto) · provisioning tenant+API-key nel vault NIS2 (a MS live) · coordinamento.
- **Presidenza/owner Vocea**: costo + org pilota.
@@ -0,0 +1,32 @@
# OUTGOING → AgileHub (VIGILE) — Integrazione Vocea in NIS2
> **Da**: NIS2 Agile · **A**: VIGILE (+ team Vocea/WISH / TITAN per il backend) · **Data**: 2026-06-23
> **Oggetto**: NIS2 vuole sostituire il suo modulo whistleblowing interno con **Vocea** (`nexus-whistleblowing-ms`). Stato verificato dal backend reale + richieste.
> **Riferimenti letti**: `whistleblowing-agile/docs/DESIGN_WHISTLEBLOWING_INDEPENDENT_PRODUCT.md`, `.../docs/API_GATEWAY_INTEGRATIONS.md`, `.../docs/INCOMING_FROM_AGILEHUB_2026_06_23_vocea_prod_access.md`, `.../CLAUDE.md`.
---
## 1. Intento NIS2 (decisione utente 2026-06-22)
Sostituire il modulo whistleblowing interno di NIS2 (oggi salva i contenuti **in chiaro** in MySQL) con **Vocea**, integrandolo come **Modalità C — prodotto verticale**: NIS2 fa provisioning + UX (embed/link del portale) + dashboard metadati, **senza re-implementare la crypto** (come da design). Vocea = prodotto suite; org pilota proposta: **Nuova Agile 996003** o dogfooding **129**.
## 2. Stato reale accertato (ispezione via SSH host, read-only, 2026-06-23)
- ❌ `nexus-whistleblowing-ms` **NON è live/deployato** (niente in ascolto su :4223; il prodotto WB esiste solo come devenv `whistleblowing-agile-devenv`, **unhealthy**, nessun processo MS attivo). Il CLAUDE.md WB conferma: *"backend 4223 NON deployato — primo task"*, *"schema DB non creato"*.
- ✅ Server prod pronto: `vocea-prod` 178.104.204.196 (CX33 Nuremberg). DNS `app/api.vocea.cloud` live. 443 aperto; 4223/3306 chiusi (loopback). **Stack ancora da deployare** dal team Vocea.
- ⚠️ Layer API integrazione **parziale**: solo `POST /integrations/reports/submit` esiste. `GET status` (`status:read`) e `GET reports` (`reports:read`, metadati/ciphertext) + route emissione API key = **da implementare** (loro `API_GATEWAY_INTEGRATIONS.md` §2 + checklist §10).
- ✅ Contratto: API-key per-tenant server-to-server su `https://api.vocea.cloud/api/wb/v1/integrations/*`, zero-knowledge preservato. Rete OK (pubblico 443, raggiungibile dal backend NIS2).
## 3. Richieste a VIGILE (coordinamento) / team Vocea (backend)
1. **Deploy MS**: tempistica per `nexus-whistleblowing-ms` live su `api.vocea.cloud` (stack su vocea-prod).
2. **Completare API per integratori**: NIS2 per la **dashboard di gestione** ha bisogno di `GET /v1/integrations/reports/{code}/status` e `GET /v1/integrations/reports` (lista metadati). Oggi manca. Più una **route di emissione API key** per il canale. Possono essere prioritizzati?
3. **Provisioning pilota**: creare 1 tenant/canale per l'org pilota NIS2 + emettere **API key** (scope `reports:submit`,`reports:read`,`status:read`) → la depositiamo nel **vault NIS2** (`tier1__nis2-agile__vocea__*`). + come enrollano i key holder dell'org le loro public key RSA.
4. **Modalità UX (preferenza nostra = embed)**: confermare che i portali `app.vocea.cloud/wb/{slug}` (segnalante) e `/wb/portal/{slug}` (gestore) sono **embeddabili in iframe** dentro NIS2 (X-Frame-Options/CSP same-suite) o solo link-out (+ esiste `wb-link.js`?). L'embed riusa crypto+UI esistenti = molto meno lavoro e zero-knowledge pulito.
5. **Egress/allowlist**: per le call server-to-server serve allowlistare l'IP di uscita del backend NIS2 (135.181.149.254) sul vhost `api.vocea.cloud`/per-key? (la doc cita IP allowlist opzionale).
6. **Costo/commerciale**: per integrazione **suite-interna** c'è un costo per-tenant (design §7 cita €50–200/mese reseller)? Serve ok presidenza prima del pilota?
## 4. Cosa fa NIS2 da solo (scope, per allineamento)
`VoceaService.php` (client API, key da vault) · mig 062 `organizations.vocea_tenant_slug` · `WhistleblowingController` → proxy/embed (legacy reports **read-only**, non retro-cifrati) · `whistleblowing.html` → embed portale + dashboard via API · i18n/help/KB. **Zero crypto re-implementata.**
## 5. Risposta
Rispondere come `INCOMING_FROM_AGILEHUB_..._vocea_nis2.md` nel repo NIS2 con: tempistica deploy MS (R1), prioritizzazione endpoint read/status + key issuance (R2), org pilota + API key + enroll key holder (R3), embeddabilità portale (R4), allowlist egress (R5), costo (R6).
— NIS2 Agile
+14
View File
@@ -0,0 +1,14 @@
-- 063_vocea_channel.sql — Integrazione Vocea (whistleblowing zero-knowledge).
-- Mappa ogni organization NIS2 al proprio canale Vocea (tenant). ADDITIVA, non distruttiva.
-- Apply autoritativo IDEMPOTENTE: application/cli/migrate_063_vocea_channel.php
-- (MySQL non supporta ADD COLUMN IF NOT EXISTS → l'apply controlla information_schema).
-- SCAFFOLD DORMIENTE: nessun effetto funzionale finché VOCEA_ENABLED=false (default).
ALTER TABLE organizations
ADD COLUMN vocea_tenant_slug VARCHAR(64) NULL COMMENT 'Slug del canale Vocea associato (NULL = nessun canale)';
ALTER TABLE organizations
ADD COLUMN vocea_channel_status ENUM('none','provisioning','active','suspended') NOT NULL DEFAULT 'none';
ALTER TABLE organizations
ADD COLUMN vocea_enrolled_at TIMESTAMP NULL COMMENT 'Quando canale + key holder sono stati enrollati';
+1
View File
@@ -644,6 +644,7 @@ $actionMap = [
'POST:{id}/assign' => 'assign', 'POST:{id}/assign' => 'assign',
'POST:{id}/close' => 'close', 'POST:{id}/close' => 'close',
'GET:stats' => 'stats', 'GET:stats' => 'stats',
'GET:channelStatus' => 'channelStatus',
'GET:trackAnonymous' => 'trackAnonymous', 'GET:trackAnonymous' => 'trackAnonymous',
], ],