Complete MVP implementation including: - PHP 8.4 backend with Front Controller pattern (80+ API endpoints) - Multi-tenant architecture with organization_id isolation - JWT authentication (HS256, 2h access + 7d refresh tokens) - 14 controllers: Auth, Organization, Assessment, Dashboard, Risk, Incident, Policy, SupplyChain, Training, Asset, Audit, Admin - AI Service integration (Anthropic Claude API) for gap analysis, risk suggestions, policy generation, incident classification - NIS2 gap analysis questionnaire (~80 questions, 10 categories) - MySQL schema (20 tables) with NIS2 Art. 21 compliance controls - NIS2 Art. 23 incident reporting workflow (24h/72h/30d) - Frontend: login, register, dashboard, assessment wizard, org setup - Docker configuration (PHP-FPM + Nginx + MySQL) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
396 lines
15 KiB
PHP
396 lines
15 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',
|
|
];
|
|
|
|
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]);
|
|
$updates['entity_type'] = $this->classifyNis2Entity(
|
|
$updates['sector'] ?? $org['sector'],
|
|
(int) ($updates['employee_count'] ?? $org['employee_count']),
|
|
(float) ($updates['annual_turnover_eur'] ?? $org['annual_turnover_eur'])
|
|
);
|
|
}
|
|
|
|
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,
|
|
'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',
|
|
]);
|
|
}
|
|
}
|
|
}
|