[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) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile 2026-05-30 10:53:48 +02:00
parent 109aa57d04
commit 789f663419
2 changed files with 218 additions and 0 deletions

View File

@ -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 . ' <key> <value>',
], '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');
}
} }

View File

@ -432,6 +432,9 @@
<div class="company-card-footer"> <div class="company-card-footer">
<span class="company-card-sector">${escapeHtml(sectorLabel)}</span> <span class="company-card-sector">${escapeHtml(sectorLabel)}</span>
<button class="btn btn-ghost btn-sm" onclick="openConnectors(${orgId}, '${escapeHtml(name).replace(/'/g, "\\'")}')" title="Connettori Evidence Automation">
Connettori
</button>
<button class="btn btn-primary btn-sm" onclick="manageCompany(${orgId})"> <button class="btn btn-primary btn-sm" onclick="manageCompany(${orgId})">
Gestisci Gestisci
<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/></svg> <svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>
@ -440,6 +443,92 @@
</div>`; </div>`;
} }
// ── 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, `<p style="color:#ef4444;">${escapeHtml(data.message || 'Errore')}</p>`); 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 `<div class="conn-row" data-type="${t}" style="border:1px solid var(--gray-200);border-radius:8px;padding:12px;margin-bottom:10px;">
<div style="display:flex;justify-content:space-between;align-items:center;">
<strong>${escapeHtml(CONNECTOR_LABELS[t] || t)}</strong>
<label style="font-size:.8rem;"><input type="checkbox" class="c-en" ${c.enabled ? 'checked' : ''}> Attivo</label>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:8px;">
<input class="c-tenant" placeholder="tenant_id / account_id" value="${escapeHtml(cfg.tenant_id || cfg.account_id || '')}" style="padding:6px;border:1px solid var(--gray-300);border-radius:6px;">
<input class="c-client" placeholder="client_id / app_id" value="${escapeHtml(cfg.client_id || cfg.app_id || '')}" style="padding:6px;border:1px solid var(--gray-300);border-radius:6px;">
</div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-top:8px;">
<span style="font-size:.72rem;color:${ssBadge[1]};">● ${ssBadge[0]}</span>
<button class="btn btn-sm btn-primary" onclick="saveConn('${t}', this)">Salva</button>
</div>
</div>`;
}).join('');
showConnModal(orgName, `
<p style="font-size:.82rem;color:var(--gray-600);margin-bottom:12px;">
Configura i connettori di <strong>Evidence Automation</strong>. Qui si salvano solo i parametri
NON segreti (tenant/client id). Il <strong>client secret</strong> va caricato nel vault dall'amministratore
(il comando viene mostrato dopo il salvataggio).</p>${rows}`);
} catch (e) { showConnModal(orgName, '<p style="color:#ef4444;">Errore di connessione</p>'); }
}
function showConnModal(orgName, body) {
const old = document.getElementById('conn-modal'); if (old) old.remove();
document.body.insertAdjacentHTML('beforeend', `
<div id="conn-modal" onclick="if(event.target===this)closeConn()" style="position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:1000;display:flex;align-items:center;justify-content:center;padding:20px;">
<div style="background:var(--card-bg,#fff);border-radius:12px;max-width:640px;width:100%;max-height:85vh;overflow-y:auto;padding:22px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:14px;">
<h3 style="margin:0;">Connettori — ${escapeHtml(orgName)}</h3>
<button class="btn btn-ghost btn-sm" onclick="closeConn()"></button>
</div>${body}
</div></div>`);
}
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) { function renderEntityBadge(type, voluntary) {
if (voluntary) { if (voluntary) {
return `<span class="entity-badge voluntary">Volontaria</span>`; return `<span class="entity-badge voluntary">Volontaria</span>`;