nis2-agile/application/controllers/OrganizationController.php
Cristiano Benassati ae78a2f7f4 [CORE] Initial project scaffold - NIS2 Agile Compliance Platform
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>
2026-02-17 17:50:18 +01:00

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