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