nis2-agile/application/controllers/OrganizationController.php
DevEnv nis2-agile 2fd4b7ff26 [FIX][SEC] Connettori: autorizzazione per-org + secret allowlist (findings review multi-agente)
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>
2026-05-30 11:37:25 +02:00

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