Due vulnerabilità trovate dalla review indipendente:
1. connectorOrgGuard usava users.role (GLOBALE) invece del ruolo per-org -> la feature
era ROTTA per gli utenti reali (org_admin reale ha users.role='employee' -> 403 sulla
propria org). Ora ancora l'autorizzazione al parametro di ROUTE {id} e legge
user_organizations.role. Verificato E2E: globale=employee + per-org=org_admin -> 200;
non-membro su altra org -> 403 (no IDOR via header X-Organization-Id).
2. secret-strip era una denylist case-sensitive/non-ricorsiva aggirabile (Client_Secret,
apiKey, connection_string, segreti annidati). Sostituita con ALLOWLIST ricorsiva
(sanitizeConnectorConfig): solo campi non sensibili noti, valori forzati a stringa+troncati.
Verificato E2E: input con 11 varianti di segreti -> DB contiene solo {account_id, region}.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
594 lines
24 KiB
PHP
594 lines
24 KiB
PHP
<?php
|
|
/**
|
|
* NIS2 Agile - Organization Controller
|
|
*
|
|
* Gestione organizzazioni multi-tenant, membri, classificazione NIS2.
|
|
*/
|
|
|
|
require_once __DIR__ . '/BaseController.php';
|
|
|
|
class OrganizationController extends BaseController
|
|
{
|
|
/**
|
|
* POST /api/organizations/create
|
|
*/
|
|
public function create(): void
|
|
{
|
|
$this->requireAuth();
|
|
$this->validateRequired(['name', 'sector']);
|
|
|
|
$name = trim($this->getParam('name'));
|
|
$sector = $this->getParam('sector');
|
|
$vatNumber = $this->getParam('vat_number');
|
|
$fiscalCode = $this->getParam('fiscal_code');
|
|
|
|
// Valida P.IVA se fornita
|
|
if ($vatNumber && !$this->validateVAT($vatNumber)) {
|
|
$this->jsonError('Partita IVA non valida', 400, 'INVALID_VAT');
|
|
}
|
|
|
|
Database::beginTransaction();
|
|
try {
|
|
// Crea organizzazione
|
|
$orgId = Database::insert('organizations', [
|
|
'name' => $name,
|
|
'vat_number' => $vatNumber,
|
|
'fiscal_code' => $fiscalCode,
|
|
'sector' => $sector,
|
|
'employee_count' => $this->getParam('employee_count'),
|
|
'annual_turnover_eur' => $this->getParam('annual_turnover_eur'),
|
|
'country' => $this->getParam('country', 'IT'),
|
|
'city' => $this->getParam('city'),
|
|
'address' => $this->getParam('address'),
|
|
'website' => $this->getParam('website'),
|
|
'contact_email' => $this->getParam('contact_email'),
|
|
'contact_phone' => $this->getParam('contact_phone'),
|
|
]);
|
|
|
|
// Auto-classifica entità NIS2
|
|
$entityType = $this->classifyNis2Entity(
|
|
$sector,
|
|
(int) $this->getParam('employee_count', 0),
|
|
(float) $this->getParam('annual_turnover_eur', 0)
|
|
);
|
|
|
|
Database::update('organizations', [
|
|
'entity_type' => $entityType,
|
|
], 'id = ?', [$orgId]);
|
|
|
|
// Aggiungi creatore come org_admin
|
|
Database::insert('user_organizations', [
|
|
'user_id' => $this->getCurrentUserId(),
|
|
'organization_id' => $orgId,
|
|
'role' => 'org_admin',
|
|
'is_primary' => 1,
|
|
]);
|
|
|
|
// Inizializza controlli di compliance NIS2
|
|
$this->initializeComplianceControls($orgId);
|
|
|
|
Database::commit();
|
|
|
|
$this->currentOrgId = $orgId;
|
|
$this->logAudit('organization_created', 'organization', $orgId, [
|
|
'name' => $name, 'sector' => $sector, 'entity_type' => $entityType
|
|
]);
|
|
|
|
$this->jsonSuccess([
|
|
'id' => $orgId,
|
|
'name' => $name,
|
|
'sector' => $sector,
|
|
'entity_type' => $entityType,
|
|
], 'Organizzazione creata', 201);
|
|
|
|
} catch (Throwable $e) {
|
|
Database::rollback();
|
|
throw $e;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* GET /api/organizations/current
|
|
*/
|
|
public function getCurrent(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
|
|
$org = Database::fetchOne(
|
|
'SELECT * FROM organizations WHERE id = ?',
|
|
[$this->getCurrentOrgId()]
|
|
);
|
|
|
|
if (!$org) {
|
|
$this->jsonError('Organizzazione non trovata', 404, 'ORG_NOT_FOUND');
|
|
}
|
|
|
|
// Conta membri
|
|
$memberCount = Database::count(
|
|
'user_organizations',
|
|
'organization_id = ?',
|
|
[$this->getCurrentOrgId()]
|
|
);
|
|
|
|
$org['member_count'] = $memberCount;
|
|
$org['current_user_role'] = $this->currentOrgRole;
|
|
|
|
$this->jsonSuccess($org);
|
|
}
|
|
|
|
/**
|
|
* GET /api/organizations/list
|
|
*/
|
|
public function list(): void
|
|
{
|
|
$this->requireAuth();
|
|
|
|
if ($this->currentUser['role'] === 'super_admin') {
|
|
$orgs = Database::fetchAll('SELECT * FROM organizations WHERE is_active = 1 ORDER BY name');
|
|
} else {
|
|
$orgs = Database::fetchAll(
|
|
'SELECT o.*, uo.role as user_role, uo.is_primary
|
|
FROM organizations o
|
|
JOIN user_organizations uo ON uo.organization_id = o.id
|
|
WHERE uo.user_id = ? AND o.is_active = 1
|
|
ORDER BY uo.is_primary DESC, o.name',
|
|
[$this->getCurrentUserId()]
|
|
);
|
|
}
|
|
|
|
$this->jsonSuccess($orgs);
|
|
}
|
|
|
|
/**
|
|
* PUT /api/organizations/{id}
|
|
*/
|
|
public function update(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin']);
|
|
|
|
if ($id !== $this->getCurrentOrgId()) {
|
|
$this->jsonError('ID organizzazione non corrisponde', 400, 'ORG_MISMATCH');
|
|
}
|
|
|
|
$updates = [];
|
|
$allowedFields = [
|
|
'name', 'vat_number', 'fiscal_code', 'sector', 'employee_count',
|
|
'annual_turnover_eur', 'country', 'city', 'address', 'website',
|
|
'contact_email', 'contact_phone', 'voluntary_compliance',
|
|
];
|
|
|
|
foreach ($allowedFields as $field) {
|
|
if ($this->hasParam($field)) {
|
|
$updates[$field] = $this->getParam($field);
|
|
}
|
|
}
|
|
|
|
if (empty($updates)) {
|
|
$this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES');
|
|
}
|
|
|
|
// Ri-classifica se cambiano settore, dipendenti o fatturato
|
|
if (isset($updates['sector']) || isset($updates['employee_count']) || isset($updates['annual_turnover_eur'])) {
|
|
$org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$id]);
|
|
$newEntityType = $this->classifyNis2Entity(
|
|
$updates['sector'] ?? $org['sector'],
|
|
(int) ($updates['employee_count'] ?? $org['employee_count']),
|
|
(float) ($updates['annual_turnover_eur'] ?? $org['annual_turnover_eur'])
|
|
);
|
|
$updates['entity_type'] = $newEntityType;
|
|
// Reset voluntary compliance if no longer not_applicable
|
|
if ($newEntityType !== 'not_applicable') {
|
|
$updates['voluntary_compliance'] = 0;
|
|
}
|
|
}
|
|
|
|
// Ensure voluntary_compliance is only set when entity_type is not_applicable
|
|
if (isset($updates['voluntary_compliance'])) {
|
|
$currentOrg = $org ?? Database::fetchOne('SELECT entity_type FROM organizations WHERE id = ?', [$id]);
|
|
$currentType = $updates['entity_type'] ?? $currentOrg['entity_type'];
|
|
if ($currentType !== 'not_applicable') {
|
|
$updates['voluntary_compliance'] = 0;
|
|
}
|
|
}
|
|
|
|
Database::update('organizations', $updates, 'id = ?', [$id]);
|
|
|
|
$this->logAudit('organization_updated', 'organization', $id, $updates);
|
|
|
|
$this->jsonSuccess($updates, 'Organizzazione aggiornata');
|
|
}
|
|
|
|
/**
|
|
* GET /api/organizations/{id}/members
|
|
*/
|
|
public function listMembers(int $id): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
|
|
$members = Database::fetchAll(
|
|
'SELECT u.id, u.email, u.full_name, u.phone, u.last_login_at,
|
|
uo.role as org_role, uo.joined_at
|
|
FROM user_organizations uo
|
|
JOIN users u ON u.id = uo.user_id
|
|
WHERE uo.organization_id = ? AND u.is_active = 1
|
|
ORDER BY uo.role, u.full_name',
|
|
[$this->getCurrentOrgId()]
|
|
);
|
|
|
|
$this->jsonSuccess($members);
|
|
}
|
|
|
|
/**
|
|
* POST /api/organizations/{id}/invite
|
|
*/
|
|
public function inviteMember(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin']);
|
|
$this->validateRequired(['email', 'role']);
|
|
|
|
$email = strtolower(trim($this->getParam('email')));
|
|
$role = $this->getParam('role');
|
|
|
|
$validRoles = ['org_admin', 'compliance_manager', 'board_member', 'auditor', 'employee'];
|
|
if (!in_array($role, $validRoles)) {
|
|
$this->jsonError('Ruolo non valido', 400, 'INVALID_ROLE');
|
|
}
|
|
|
|
// Trova utente per email
|
|
$user = Database::fetchOne('SELECT id FROM users WHERE email = ?', [$email]);
|
|
|
|
if (!$user) {
|
|
$this->jsonError(
|
|
'Utente non registrato. L\'utente deve prima registrarsi sulla piattaforma.',
|
|
404,
|
|
'USER_NOT_FOUND'
|
|
);
|
|
}
|
|
|
|
// Verifica se già membro
|
|
$existing = Database::fetchOne(
|
|
'SELECT id FROM user_organizations WHERE user_id = ? AND organization_id = ?',
|
|
[$user['id'], $this->getCurrentOrgId()]
|
|
);
|
|
|
|
if ($existing) {
|
|
$this->jsonError('L\'utente è già membro di questa organizzazione', 409, 'ALREADY_MEMBER');
|
|
}
|
|
|
|
Database::insert('user_organizations', [
|
|
'user_id' => $user['id'],
|
|
'organization_id' => $this->getCurrentOrgId(),
|
|
'role' => $role,
|
|
'is_primary' => 0,
|
|
]);
|
|
|
|
$this->logAudit('member_invited', 'organization', $this->getCurrentOrgId(), [
|
|
'invited_user_id' => $user['id'], 'role' => $role
|
|
]);
|
|
|
|
$this->jsonSuccess([
|
|
'user_id' => $user['id'],
|
|
'role' => $role,
|
|
], 'Membro aggiunto');
|
|
}
|
|
|
|
/**
|
|
* DELETE /api/organizations/{id}/members/{userId}
|
|
*/
|
|
public function removeMember(int $orgId, int $userId): void
|
|
{
|
|
$this->requireOrgRole(['org_admin']);
|
|
|
|
// Non puoi rimuovere te stesso
|
|
if ($userId === $this->getCurrentUserId()) {
|
|
$this->jsonError('Non puoi rimuovere te stesso dall\'organizzazione', 400, 'CANNOT_REMOVE_SELF');
|
|
}
|
|
|
|
$deleted = Database::delete(
|
|
'user_organizations',
|
|
'user_id = ? AND organization_id = ?',
|
|
[$userId, $this->getCurrentOrgId()]
|
|
);
|
|
|
|
if ($deleted === 0) {
|
|
$this->jsonError('Membro non trovato', 404, 'MEMBER_NOT_FOUND');
|
|
}
|
|
|
|
$this->logAudit('member_removed', 'organization', $this->getCurrentOrgId(), [
|
|
'removed_user_id' => $userId
|
|
]);
|
|
|
|
$this->jsonSuccess(null, 'Membro rimosso');
|
|
}
|
|
|
|
/**
|
|
* POST /api/organizations/classify
|
|
* Classifica se l'organizzazione è Essential o Important secondo NIS2
|
|
*/
|
|
public function classifyEntity(): void
|
|
{
|
|
$this->validateRequired(['sector', 'employee_count', 'annual_turnover_eur']);
|
|
|
|
$sector = $this->getParam('sector');
|
|
$employees = (int) $this->getParam('employee_count');
|
|
$turnover = (float) $this->getParam('annual_turnover_eur');
|
|
|
|
$entityType = $this->classifyNis2Entity($sector, $employees, $turnover);
|
|
|
|
$this->jsonSuccess([
|
|
'entity_type' => $entityType,
|
|
'sector' => $sector,
|
|
'employee_count' => $employees,
|
|
'annual_turnover_eur' => $turnover,
|
|
'allows_voluntary' => $entityType === 'not_applicable',
|
|
'explanation' => $this->getClassificationExplanation($entityType, $sector, $employees, $turnover),
|
|
]);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
// METODI PRIVATI
|
|
// ═══════════════════════════════════════════════════════════════════════
|
|
|
|
/**
|
|
* Classifica entità NIS2 in base a settore, dipendenti e fatturato
|
|
*/
|
|
private function classifyNis2Entity(string $sector, int $employees, float $turnover): string
|
|
{
|
|
// Settori Essenziali (Allegato I)
|
|
$essentialSectors = [
|
|
'energy', 'transport', 'banking', 'health', 'water',
|
|
'digital_infra', 'public_admin', 'space',
|
|
];
|
|
|
|
// Settori Importanti (Allegato II)
|
|
$importantSectors = [
|
|
'manufacturing', 'postal', 'chemical', 'food', 'waste',
|
|
'ict_services', 'digital_providers', 'research',
|
|
];
|
|
|
|
// Soglie dimensionali
|
|
$isLarge = $employees >= 250 || $turnover >= 50000000;
|
|
$isMedium = ($employees >= 50 || $turnover >= 10000000) && !$isLarge;
|
|
|
|
if (in_array($sector, $essentialSectors)) {
|
|
if ($isLarge) return 'essential';
|
|
if ($isMedium) return 'important';
|
|
}
|
|
|
|
if (in_array($sector, $importantSectors)) {
|
|
if ($isLarge || $isMedium) return 'important';
|
|
}
|
|
|
|
return 'not_applicable';
|
|
}
|
|
|
|
/**
|
|
* Genera spiegazione della classificazione
|
|
*/
|
|
private function getClassificationExplanation(string $type, string $sector, int $employees, float $turnover): string
|
|
{
|
|
$sizeLabel = $employees >= 250 ? 'grande impresa' : ($employees >= 50 ? 'media impresa' : 'piccola impresa');
|
|
|
|
return match ($type) {
|
|
'essential' => "L'organizzazione opera nel settore '{$sector}' (Allegato I NIS2) ed è classificata come {$sizeLabel}. Rientra tra le entità ESSENZIALI soggette a supervisione proattiva. Sanzioni fino a EUR 10M o 2% del fatturato globale.",
|
|
'important' => "L'organizzazione opera nel settore '{$sector}' ed è classificata come {$sizeLabel}. Rientra tra le entità IMPORTANTI soggette a supervisione reattiva. Sanzioni fino a EUR 7M o 1,4% del fatturato globale.",
|
|
default => "In base ai parametri forniti ({$sizeLabel}, settore '{$sector}'), l'organizzazione NON rientra attualmente nell'ambito di applicazione della NIS2. Si consiglia comunque di adottare le best practice di cybersecurity.",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Inizializza i controlli di compliance NIS2 per una nuova organizzazione
|
|
*/
|
|
private function initializeComplianceControls(int $orgId): void
|
|
{
|
|
$controls = [
|
|
['NIS2-21.2.a', 'nis2', 'Politiche di analisi dei rischi e sicurezza dei sistemi informatici'],
|
|
['NIS2-21.2.b', 'nis2', 'Gestione degli incidenti'],
|
|
['NIS2-21.2.c', 'nis2', 'Continuità operativa e gestione delle crisi'],
|
|
['NIS2-21.2.d', 'nis2', 'Sicurezza della catena di approvvigionamento'],
|
|
['NIS2-21.2.e', 'nis2', 'Sicurezza acquisizione, sviluppo e manutenzione sistemi'],
|
|
['NIS2-21.2.f', 'nis2', 'Politiche e procedure per valutare efficacia misure'],
|
|
['NIS2-21.2.g', 'nis2', 'Pratiche di igiene informatica di base e formazione'],
|
|
['NIS2-21.2.h', 'nis2', 'Politiche e procedure relative alla crittografia'],
|
|
['NIS2-21.2.i', 'nis2', 'Sicurezza risorse umane, controllo accessi e gestione asset'],
|
|
['NIS2-21.2.j', 'nis2', 'Autenticazione multi-fattore e comunicazioni sicure'],
|
|
['NIS2-20.1', 'nis2', 'Governance: approvazione misure da parte degli organi di gestione'],
|
|
['NIS2-20.2', 'nis2', 'Formazione obbligatoria per gli organi di gestione'],
|
|
['NIS2-23.1', 'nis2', 'Notifica incidenti significativi al CSIRT (24h/72h/30gg)'],
|
|
];
|
|
|
|
foreach ($controls as [$code, $framework, $title]) {
|
|
Database::insert('compliance_controls', [
|
|
'organization_id' => $orgId,
|
|
'control_code' => $code,
|
|
'framework' => $framework,
|
|
'title' => $title,
|
|
'status' => 'not_started',
|
|
]);
|
|
}
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// 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'];
|
|
|
|
/**
|
|
* Autorizza la gestione dei connettori dell'org $orgId (parametro di ROUTE).
|
|
* L'autorizzazione è ancorata a $orgId, NON all'header X-Organization-Id,
|
|
* e usa il ruolo PER-ORG (user_organizations.role), non users.role globale.
|
|
*/
|
|
private function connectorOrgGuard(int $orgId): void
|
|
{
|
|
$this->requireAuth();
|
|
$user = $this->currentUser;
|
|
|
|
// super_admin: accesso pieno
|
|
if (($user['role'] ?? '') === 'super_admin') {
|
|
return;
|
|
}
|
|
|
|
// Ruolo dell'utente NELL'org target (route {id})
|
|
$membership = Database::fetchOne(
|
|
'SELECT role FROM user_organizations WHERE user_id = ? AND organization_id = ?',
|
|
[$user['id'], $orgId]
|
|
);
|
|
$manageRoles = ['org_admin', 'compliance_manager'];
|
|
if ($membership && in_array($membership['role'], $manageRoles, true)) {
|
|
return;
|
|
}
|
|
|
|
// Consulente dello studio che gestisce l'org (org.consulting_firm_id == user.consulting_firm_id)
|
|
$firmId = $user['consulting_firm_id'] ?? null;
|
|
if ($firmId) {
|
|
$owned = Database::fetchOne(
|
|
'SELECT id FROM organizations WHERE id = ? AND consulting_firm_id = ?',
|
|
[$orgId, $firmId]
|
|
);
|
|
// consentito se l'utente è consulente/admin del firm (membership consultant sull'org o ruolo di gestione nel firm)
|
|
$isFirmManager = ($membership && in_array($membership['role'], ['consultant', 'org_admin', 'compliance_manager'], true));
|
|
if ($owned && $isFirmManager) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
$this->jsonError('Non autorizzato a gestire i connettori di questa organizzazione', 403, 'CONNECTOR_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 = []; }
|
|
// ALLOWLIST: persistiamo SOLO i campi non sensibili noti. Qualsiasi altra
|
|
// chiave (inclusi segreti comunque nominati o annidati) viene scartata.
|
|
// I segreti vivono solo nel vault — vedi cli_hint.
|
|
$config = self::sanitizeConnectorConfig($config);
|
|
|
|
$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);
|
|
}
|
|
|
|
/**
|
|
* Allowlist dei soli campi di configurazione NON sensibili ammessi per i
|
|
* connettori. Tutto ciò che non è in lista (segreti, chiavi annidate, campi
|
|
* arbitrari) viene scartato — robusto contro denylist-bypass (case, nesting,
|
|
* nomi alternativi). I valori sono forzati a stringa e troncati.
|
|
*/
|
|
private static function sanitizeConnectorConfig(array $config): array
|
|
{
|
|
$allowed = [
|
|
'tenant_id', 'client_id', 'app_id', 'account_id', 'subscription_id',
|
|
'region', 'base_url', 'endpoint', 'domain', 'workspace', 'instance_url',
|
|
'scopes', 'project', 'directory_id', 'org_slug',
|
|
];
|
|
$clean = [];
|
|
foreach ($allowed as $k) {
|
|
if (!array_key_exists($k, $config)) {
|
|
continue;
|
|
}
|
|
$v = $config[$k];
|
|
if ($k === 'scopes' && is_array($v)) {
|
|
// lista di stringhe scope, max 30 voci
|
|
$clean[$k] = array_slice(array_map(
|
|
fn($s) => substr((string) $s, 0, 120),
|
|
array_filter($v, 'is_scalar')
|
|
), 0, 30);
|
|
} elseif (is_scalar($v)) {
|
|
$clean[$k] = substr((string) $v, 0, 255);
|
|
}
|
|
// valori non scalari (oggetti/array annidati) su campi non-scopes: scartati
|
|
}
|
|
return $clean;
|
|
}
|
|
|
|
/** 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');
|
|
}
|
|
}
|