From 789f663419d32b477248ff10a372530bdc6a8e38 Mon Sep 17 00:00:00 2001 From: DevEnv nis2-agile Date: Sat, 30 May 2026 10:53:48 +0200 Subject: [PATCH] [FIX] Connettori per-azienda: aggiunti realmente i 4 metodi controller + UI card (commit 0dc2a11 era guscio vuoto, Edit fallite su ancore errate) Verificato E2E in prod: list 200 (8 tipi), save m365 201, secret 'client_secret' STRIPPATO (assente da config DB), delete 200, openConnectors servito in companies.html. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../controllers/OrganizationController.php | 129 ++++++++++++++++++ public/companies.html | 89 ++++++++++++ 2 files changed, 218 insertions(+) diff --git a/application/controllers/OrganizationController.php b/application/controllers/OrganizationController.php index 67beaba..e887d1f 100644 --- a/application/controllers/OrganizationController.php +++ b/application/controllers/OrganizationController.php @@ -407,4 +407,133 @@ class OrganizationController extends BaseController ]); } } + + // ══════════════════════════════════════════════════════════════════════ + // CONNETTORI PER-AZIENDA (Evidence Automation) — config NON segreta + // I segreti vivono SOLO nel vault-steward (caricati via CLI admin); qui + // si salva solo config non sensibile + alias della chiave vault. + // ══════════════════════════════════════════════════════════════════════ + + private const CONNECTOR_TYPES = ['m365', 'google', 'aws', 'azure', 'idp', 'edr', 'siem', 'ticketing']; + + private function connectorOrgGuard(int $orgId): void + { + $this->requireOrgAccess(); + $role = $this->currentUser['role'] ?? ''; + if ($role === 'super_admin') { + return; + } + if ($orgId !== $this->getCurrentOrgId()) { + $firmId = $this->currentUser['consulting_firm_id'] ?? null; + $owned = $firmId ? Database::fetchOne( + 'SELECT id FROM organizations WHERE id = ? AND consulting_firm_id = ?', + [$orgId, $firmId] + ) : null; + if (!$owned) { + $this->jsonError('Accesso negato a questa organizzazione', 403, 'ORG_FORBIDDEN'); + } + } + if (!in_array($role, ['org_admin', 'compliance_manager'], true)) { + $this->jsonError('Ruolo non autorizzato a gestire i connettori', 403, 'ROLE_FORBIDDEN'); + } + } + + /** GET /api/organizations/{id}/connectors */ + public function listConnectors(int $id): void + { + $this->connectorOrgGuard($id); + $rows = Database::fetchAll( + 'SELECT id, connector_type, display_name, enabled, config, vault_key_alias, + secret_status, last_status, last_checked_at, updated_at + FROM org_connectors WHERE organization_id = ? ORDER BY connector_type', + [$id] + ); + foreach ($rows as &$r) { + $r['config'] = $r['config'] ? json_decode($r['config'], true) : new stdClass(); + $r['enabled'] = (bool) $r['enabled']; + } + unset($r); + $this->jsonSuccess([ + 'organization_id' => $id, + 'available_types' => self::CONNECTOR_TYPES, + 'connectors' => $rows, + ]); + } + + /** PUT /api/organizations/{id}/connectors — body: {type, display_name?, enabled?, config?, vault_key_alias?, secret_status?} */ + public function saveConnector(int $id): void + { + $this->connectorOrgGuard($id); + $type = strtolower((string) $this->getParam('type')); + if (!in_array($type, self::CONNECTOR_TYPES, true)) { + $this->jsonError('Tipo connettore non valido', 422, 'INVALID_TYPE'); + } + + $config = $this->getParam('config'); + if (is_string($config)) { $config = json_decode($config, true); } + if (!is_array($config)) { $config = []; } + foreach (['secret', 'client_secret', 'api_key', 'password', 'private_key', 'token'] as $banned) { + unset($config[$banned]); // difesa: mai segreti nel DB + } + + $alias = $this->getParam('vault_key_alias'); + if ($alias === null || $alias === '') { + $alias = 'tier1__nis2-app__connector_' . $type . '_org' . $id; + } + $secretStatus = $this->getParam('secret_status'); + if (!in_array($secretStatus, ['not_set', 'pending', 'configured'], true)) { + $secretStatus = null; + } + + $existing = Database::fetchOne( + 'SELECT id FROM org_connectors WHERE organization_id = ? AND connector_type = ?', + [$id, $type] + ); + + $data = [ + 'display_name' => $this->getParam('display_name'), + 'enabled' => $this->getParam('enabled') ? 1 : 0, + 'config' => json_encode($config, JSON_UNESCAPED_UNICODE), + 'vault_key_alias' => substr($alias, 0, 190), + ]; + if ($secretStatus !== null) { $data['secret_status'] = $secretStatus; } + + if ($existing) { + $sets = []; $vals = []; + foreach ($data as $k => $v) { $sets[] = "$k = ?"; $vals[] = $v; } + $vals[] = $existing['id']; + Database::query('UPDATE org_connectors SET ' . implode(', ', $sets) . ' WHERE id = ?', $vals); + $connId = (int) $existing['id']; + } else { + $data['organization_id'] = $id; + $data['connector_type'] = $type; + $data['created_by'] = $this->getCurrentUserId(); + if (!isset($data['secret_status'])) { $data['secret_status'] = 'not_set'; } + $connId = Database::insert('org_connectors', $data); + } + + $this->logAudit('connector_configured', 'organization', $id, ['type' => $type, 'enabled' => $data['enabled']]); + $this->jsonSuccess([ + 'id' => $connId, + 'connector_type' => $type, + 'vault_key_alias' => $data['vault_key_alias'], + 'cli_hint' => 'Carica il segreto nel vault: docker exec vault-steward node cli/vault-cli.js migrate ' . $alias . ' ', + ], 'Connettore salvato', $existing ? 200 : 201); + } + + /** DELETE /api/organizations/{id}/connectors?type=xxx */ + public function deleteConnector(int $id): void + { + $this->connectorOrgGuard($id); + $type = strtolower((string) ($this->getParam('type') ?? ($_GET['type'] ?? ''))); + if ($type === '') { + $this->jsonError('Parametro type obbligatorio', 422, 'MISSING_TYPE'); + } + Database::query( + 'DELETE FROM org_connectors WHERE organization_id = ? AND connector_type = ?', + [$id, $type] + ); + $this->logAudit('connector_removed', 'organization', $id, ['type' => $type]); + $this->jsonSuccess(null, 'Connettore rimosso'); + } } diff --git a/public/companies.html b/public/companies.html index 2af94cb..7b13796 100644 --- a/public/companies.html +++ b/public/companies.html @@ -432,6 +432,9 @@ `; } + // ── Connettori per-azienda (Evidence Automation) ───────────── + const CONNECTOR_LABELS = { + m365: 'Microsoft 365 / Entra ID', google: 'Google Workspace', aws: 'Amazon Web Services', + azure: 'Microsoft Azure', idp: 'Identity Provider', edr: 'EDR / XDR', + siem: 'SIEM / SOC', ticketing: 'Ticketing (Jira/ServiceNow)' + }; + let _connOrgId = null; + + async function openConnectors(orgId, orgName) { + _connOrgId = orgId; + try { + const res = await fetch(`/api/organizations/${orgId}/connectors`, { + headers: { 'Authorization': `Bearer ${api.token}`, 'X-Organization-Id': orgId } + }); + const data = await res.json(); + if (!data.success) { showConnModal(orgName, `

${escapeHtml(data.message || 'Errore')}

`); return; } + const existing = {}; + (data.data.connectors || []).forEach(c => existing[c.connector_type] = c); + const rows = (data.data.available_types || []).map(t => { + const c = existing[t] || {}; + const cfg = (c.config && typeof c.config === 'object') ? c.config : {}; + const ss = c.secret_status || 'not_set'; + const ssBadge = { not_set: ['Segreto non impostato', '#9ca3af'], pending: ['Segreto in attesa', '#eab308'], configured: ['Segreto configurato', '#22c55e'] }[ss]; + return `
+
+ ${escapeHtml(CONNECTOR_LABELS[t] || t)} + +
+
+ + +
+
+ ● ${ssBadge[0]} + +
+
`; + }).join(''); + showConnModal(orgName, ` +

+ Configura i connettori di Evidence Automation. Qui si salvano solo i parametri + NON segreti (tenant/client id). Il client secret va caricato nel vault dall'amministratore + (il comando viene mostrato dopo il salvataggio).

${rows}`); + } catch (e) { showConnModal(orgName, '

Errore di connessione

'); } + } + + function showConnModal(orgName, body) { + const old = document.getElementById('conn-modal'); if (old) old.remove(); + document.body.insertAdjacentHTML('beforeend', ` +
+
+
+

Connettori — ${escapeHtml(orgName)}

+ +
${body} +
`); + } + function closeConn() { const m = document.getElementById('conn-modal'); if (m) m.remove(); } + + async function saveConn(type, btn) { + const row = btn.closest('.conn-row'); + const payload = { + type, + enabled: row.querySelector('.c-en').checked, + config: { tenant_id: row.querySelector('.c-tenant').value.trim(), client_id: row.querySelector('.c-client').value.trim() }, + secret_status: 'pending' + }; + btn.disabled = true; btn.textContent = '...'; + try { + const res = await fetch(`/api/organizations/${_connOrgId}/connectors`, { + method: 'PUT', + headers: { 'Authorization': `Bearer ${api.token}`, 'X-Organization-Id': _connOrgId, 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const j = await res.json(); + btn.disabled = false; btn.textContent = 'Salva'; + if (j.success) { + if (typeof showNotification === 'function') showNotification('Connettore salvato', 'success'); + if (j.data && j.data.cli_hint) alert('Connettore salvato.\n\nPer caricare il SEGRETO nel vault, l\'amministratore esegue:\n\n' + j.data.cli_hint); + } else if (typeof showNotification === 'function') showNotification(j.message || 'Errore', 'error'); + } catch (e) { + btn.disabled = false; btn.textContent = 'Salva'; + if (typeof showNotification === 'function') showNotification('Errore di connessione', 'error'); + } + } + function renderEntityBadge(type, voluntary) { if (voluntary) { return `Volontaria`;