[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>
This commit is contained in:
2026-02-17 17:50:18 +01:00
co-authored by Claude Opus 4.6
commit ae78a2f7f4
36 changed files with 10483 additions and 0 deletions
@@ -0,0 +1,71 @@
<?php
/**
* NIS2 Agile - Admin Controller
*
* Gestione piattaforma (solo super_admin).
*/
require_once __DIR__ . '/BaseController.php';
class AdminController extends BaseController
{
public function listOrganizations(): void
{
$this->requireSuperAdmin();
$pagination = $this->getPagination();
$total = Database::count('organizations', '1=1');
$orgs = Database::fetchAll(
"SELECT o.*,
(SELECT COUNT(*) FROM user_organizations WHERE organization_id = o.id) as member_count,
(SELECT overall_score FROM assessments WHERE organization_id = o.id AND status = 'completed' ORDER BY completed_at DESC LIMIT 1) as last_score
FROM organizations o
ORDER BY o.created_at DESC
LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}"
);
$this->jsonPaginated($orgs, $total, $pagination['page'], $pagination['per_page']);
}
public function listUsers(): void
{
$this->requireSuperAdmin();
$pagination = $this->getPagination();
$total = Database::count('users', '1=1');
$users = Database::fetchAll(
"SELECT u.id, u.email, u.full_name, u.role, u.is_active, u.last_login_at, u.created_at,
GROUP_CONCAT(o.name) as organizations
FROM users u
LEFT JOIN user_organizations uo ON uo.user_id = u.id
LEFT JOIN organizations o ON o.id = uo.organization_id
GROUP BY u.id
ORDER BY u.created_at DESC
LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}"
);
$this->jsonPaginated($users, $total, $pagination['page'], $pagination['per_page']);
}
public function platformStats(): void
{
$this->requireSuperAdmin();
$this->jsonSuccess([
'total_organizations' => Database::count('organizations', '1=1'),
'active_organizations' => Database::count('organizations', 'is_active = 1'),
'total_users' => Database::count('users', '1=1'),
'active_users' => Database::count('users', 'is_active = 1'),
'total_assessments' => Database::count('assessments', '1=1'),
'completed_assessments' => Database::count('assessments', 'status = "completed"'),
'total_incidents' => Database::count('incidents', '1=1'),
'open_incidents' => Database::count('incidents', 'status NOT IN ("closed", "post_mortem")'),
'total_risks' => Database::count('risks', '1=1'),
'total_policies' => Database::count('policies', '1=1'),
'ai_interactions' => Database::count('ai_interactions', '1=1'),
'plans_distribution' => Database::fetchAll(
'SELECT subscription_plan, COUNT(*) as count FROM organizations GROUP BY subscription_plan'
),
]);
}
}
@@ -0,0 +1,448 @@
<?php
/**
* NIS2 Agile - Assessment Controller
*
* Gap Analysis wizard: questionario NIS2, scoring, AI analysis.
*/
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/AIService.php';
class AssessmentController extends BaseController
{
/**
* GET /api/assessments/list
*/
public function list(): void
{
$this->requireOrgAccess();
$assessments = Database::fetchAll(
'SELECT a.*, u.full_name as completed_by_name
FROM assessments a
LEFT JOIN users u ON u.id = a.completed_by
WHERE a.organization_id = ?
ORDER BY a.created_at DESC',
[$this->getCurrentOrgId()]
);
$this->jsonSuccess($assessments);
}
/**
* POST /api/assessments/create
*/
public function create(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$title = $this->getParam('title', 'Assessment NIS2 - ' . date('d/m/Y'));
$type = $this->getParam('assessment_type', 'initial');
$assessmentId = Database::insert('assessments', [
'organization_id' => $this->getCurrentOrgId(),
'title' => $title,
'assessment_type' => $type,
'status' => 'draft',
]);
// Pre-popola le risposte con le domande dal questionario
$questionnaire = $this->loadQuestionnaire();
foreach ($questionnaire['categories'] as $category) {
foreach ($category['questions'] as $question) {
Database::insert('assessment_responses', [
'assessment_id' => $assessmentId,
'question_code' => $question['code'],
'nis2_article' => $question['nis2_article'],
'iso27001_control' => $question['iso27001_control'],
'category' => $category['id'],
'question_text' => $question['text_it'],
]);
}
}
$this->logAudit('assessment_created', 'assessment', $assessmentId);
$this->jsonSuccess([
'id' => $assessmentId,
'title' => $title,
'status' => 'draft',
], 'Assessment creato', 201);
}
/**
* GET /api/assessments/{id}
*/
public function get(int $id): void
{
$this->requireOrgAccess();
$assessment = $this->getAssessment($id);
// Conta risposte per stato
$stats = Database::fetchAll(
'SELECT response_value, COUNT(*) as count
FROM assessment_responses
WHERE assessment_id = ? AND response_value IS NOT NULL
GROUP BY response_value',
[$id]
);
$totalQuestions = Database::count('assessment_responses', 'assessment_id = ?', [$id]);
$answeredQuestions = Database::count(
'assessment_responses',
'assessment_id = ? AND response_value IS NOT NULL',
[$id]
);
$assessment['stats'] = $stats;
$assessment['total_questions'] = $totalQuestions;
$assessment['answered_questions'] = $answeredQuestions;
$assessment['progress_percentage'] = $totalQuestions > 0
? round($answeredQuestions / $totalQuestions * 100)
: 0;
$this->jsonSuccess($assessment);
}
/**
* GET /api/assessments/{id}/questions
* Restituisce domande con risposte correnti, organizzate per categoria
*/
public function getQuestions(int $id): void
{
$this->requireOrgAccess();
$this->getAssessment($id);
$responses = Database::fetchAll(
'SELECT * FROM assessment_responses WHERE assessment_id = ? ORDER BY question_code',
[$id]
);
// Carica questionario per i metadati
$questionnaire = $this->loadQuestionnaire();
$questionMeta = [];
foreach ($questionnaire['categories'] as $cat) {
foreach ($cat['questions'] as $q) {
$questionMeta[$q['code']] = [
'text_en' => $q['text_en'],
'guidance_it' => $q['guidance_it'],
'evidence_examples' => $q['evidence_examples'],
'weight' => $q['weight'],
];
}
}
// Organizza per categoria
$byCategory = [];
foreach ($responses as $r) {
$cat = $r['category'];
if (!isset($byCategory[$cat])) {
// Trova titolo categoria
$catTitle = $cat;
foreach ($questionnaire['categories'] as $c) {
if ($c['id'] === $cat) {
$catTitle = $c['title_it'];
break;
}
}
$byCategory[$cat] = [
'category_id' => $cat,
'category_title' => $catTitle,
'questions' => [],
];
}
$meta = $questionMeta[$r['question_code']] ?? [];
$r['text_en'] = $meta['text_en'] ?? null;
$r['guidance_it'] = $meta['guidance_it'] ?? null;
$r['evidence_examples'] = $meta['evidence_examples'] ?? [];
$r['weight'] = $meta['weight'] ?? 1;
$byCategory[$cat]['questions'][] = $r;
}
$this->jsonSuccess(array_values($byCategory));
}
/**
* POST /api/assessments/{id}/respond
* Salva una o più risposte
*/
public function saveResponse(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
$assessment = $this->getAssessment($id);
if ($assessment['status'] === 'completed') {
$this->jsonError('Assessment già completato', 400, 'ALREADY_COMPLETED');
}
// Accetta singola risposta o array di risposte
$responses = $this->getParam('responses');
if (!$responses) {
// Singola risposta
$this->validateRequired(['question_code', 'response_value']);
$responses = [[
'question_code' => $this->getParam('question_code'),
'response_value' => $this->getParam('response_value'),
'maturity_level' => $this->getParam('maturity_level'),
'evidence_description' => $this->getParam('evidence_description'),
'notes' => $this->getParam('notes'),
]];
}
$savedCount = 0;
foreach ($responses as $resp) {
$code = $resp['question_code'] ?? null;
$value = $resp['response_value'] ?? null;
if (!$code || !$value) continue;
Database::update('assessment_responses', [
'response_value' => $value,
'maturity_level' => $resp['maturity_level'] ?? null,
'evidence_description' => $resp['evidence_description'] ?? null,
'notes' => $resp['notes'] ?? null,
'answered_by' => $this->getCurrentUserId(),
'answered_at' => date('Y-m-d H:i:s'),
], 'assessment_id = ? AND question_code = ?', [$id, $code]);
$savedCount++;
}
// Aggiorna status a in_progress se era draft
if ($assessment['status'] === 'draft') {
Database::update('assessments', ['status' => 'in_progress'], 'id = ?', [$id]);
}
$this->jsonSuccess(['saved' => $savedCount], "{$savedCount} risposte salvate");
}
/**
* POST /api/assessments/{id}/complete
*/
public function complete(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$assessment = $this->getAssessment($id);
if ($assessment['status'] === 'completed') {
$this->jsonError('Assessment già completato', 400, 'ALREADY_COMPLETED');
}
// Calcola score
$responses = Database::fetchAll(
'SELECT * FROM assessment_responses WHERE assessment_id = ?',
[$id]
);
$scores = $this->calculateScores($responses);
Database::update('assessments', [
'status' => 'completed',
'overall_score' => $scores['overall'],
'category_scores' => json_encode($scores['by_category']),
'completed_by' => $this->getCurrentUserId(),
'completed_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$id]);
$this->logAudit('assessment_completed', 'assessment', $id, [
'overall_score' => $scores['overall']
]);
$this->jsonSuccess([
'overall_score' => $scores['overall'],
'category_scores' => $scores['by_category'],
], 'Assessment completato');
}
/**
* GET /api/assessments/{id}/report
*/
public function getReport(int $id): void
{
$this->requireOrgAccess();
$assessment = $this->getAssessment($id);
if ($assessment['status'] !== 'completed') {
$this->jsonError('L\'assessment deve essere completato prima di generare il report', 400, 'NOT_COMPLETED');
}
$responses = Database::fetchAll(
'SELECT ar.*, u.full_name as answered_by_name
FROM assessment_responses ar
LEFT JOIN users u ON u.id = ar.answered_by
WHERE ar.assessment_id = ?
ORDER BY ar.category, ar.question_code',
[$id]
);
$categoryScores = json_decode($assessment['category_scores'], true) ?? [];
$this->jsonSuccess([
'assessment' => $assessment,
'category_scores' => $categoryScores,
'responses' => $responses,
'ai_summary' => $assessment['ai_summary'],
'ai_recommendations' => json_decode($assessment['ai_recommendations'], true),
]);
}
/**
* POST /api/assessments/{id}/ai-analyze
* Richiede analisi AI dell'assessment
*/
public function aiAnalyze(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$assessment = $this->getAssessment($id);
if ($assessment['status'] !== 'completed') {
$this->jsonError('Completare l\'assessment prima dell\'analisi AI', 400, 'NOT_COMPLETED');
}
// Carica organizzazione
$org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
// Carica risposte
$responses = Database::fetchAll(
'SELECT * FROM assessment_responses WHERE assessment_id = ?',
[$id]
);
try {
$aiService = new AIService();
$analysis = $aiService->analyzeGapAssessment($org, $responses, (float) $assessment['overall_score']);
// Salva risultati AI
Database::update('assessments', [
'ai_summary' => $analysis['executive_summary'] ?? json_encode($analysis),
'ai_recommendations' => json_encode($analysis),
], 'id = ?', [$id]);
// Log interazione AI
$aiService->logInteraction(
$this->getCurrentOrgId(),
$this->getCurrentUserId(),
'gap_analysis',
"Gap analysis assessment #{$id}",
substr(json_encode($analysis), 0, 500)
);
$this->logAudit('ai_analysis_requested', 'assessment', $id);
$this->jsonSuccess($analysis, 'Analisi AI completata');
} catch (Throwable $e) {
error_log('[AI_ERROR] ' . $e->getMessage());
$this->jsonError('Errore durante l\'analisi AI: ' . $e->getMessage(), 500, 'AI_ERROR');
}
}
// ═══════════════════════════════════════════════════════════════════════
// METODI PRIVATI
// ═══════════════════════════════════════════════════════════════════════
/**
* Carica assessment verificando ownership
*/
private function getAssessment(int $id): array
{
$assessment = Database::fetchOne(
'SELECT * FROM assessments WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$assessment) {
$this->jsonError('Assessment non trovato', 404, 'ASSESSMENT_NOT_FOUND');
}
return $assessment;
}
/**
* Carica questionario da file JSON
*/
private function loadQuestionnaire(): array
{
$file = DATA_PATH . '/nis2_questionnaire.json';
if (!file_exists($file)) {
$this->jsonError('Questionario NIS2 non disponibile', 500, 'QUESTIONNAIRE_MISSING');
}
$data = json_decode(file_get_contents($file), true);
if (!$data) {
$this->jsonError('Errore caricamento questionario', 500, 'QUESTIONNAIRE_ERROR');
}
return $data;
}
/**
* Calcola score dell'assessment
*/
private function calculateScores(array $responses): array
{
$byCategory = [];
$totalWeightedScore = 0;
$totalWeight = 0;
// Carica pesi dalle domande
$questionnaire = $this->loadQuestionnaire();
$weights = [];
foreach ($questionnaire['categories'] as $cat) {
foreach ($cat['questions'] as $q) {
$weights[$q['code']] = $q['weight'] ?? 1;
}
}
foreach ($responses as $r) {
$cat = $r['category'] ?? 'other';
$value = $r['response_value'];
$weight = $weights[$r['question_code']] ?? 1;
if (!isset($byCategory[$cat])) {
$byCategory[$cat] = ['score' => 0, 'max' => 0, 'count' => 0];
}
if ($value === 'not_applicable') continue;
$score = match ($value) {
'implemented' => 100,
'partial' => 50,
default => 0,
};
$byCategory[$cat]['score'] += $score * $weight;
$byCategory[$cat]['max'] += 100 * $weight;
$byCategory[$cat]['count']++;
$totalWeightedScore += $score * $weight;
$totalWeight += 100 * $weight;
}
// Calcola percentuali per categoria
$categoryScores = [];
foreach ($byCategory as $cat => $data) {
$categoryScores[$cat] = [
'score' => $data['max'] > 0 ? round($data['score'] / $data['max'] * 100, 1) : 0,
'count' => $data['count'],
];
}
$overallScore = $totalWeight > 0 ? round($totalWeightedScore / $totalWeight * 100, 1) : 0;
return [
'overall' => $overallScore,
'by_category' => $categoryScores,
];
}
}
+160
View File
@@ -0,0 +1,160 @@
<?php
/**
* NIS2 Agile - Asset Controller
*
* Inventario asset IT/OT, classificazione, dipendenze.
*/
require_once __DIR__ . '/BaseController.php';
class AssetController extends BaseController
{
public function list(): void
{
$this->requireOrgAccess();
$pagination = $this->getPagination();
$where = 'organization_id = ?';
$params = [$this->getCurrentOrgId()];
if ($this->hasParam('asset_type')) {
$where .= ' AND asset_type = ?';
$params[] = $this->getParam('asset_type');
}
if ($this->hasParam('criticality')) {
$where .= ' AND criticality = ?';
$params[] = $this->getParam('criticality');
}
if ($this->hasParam('status')) {
$where .= ' AND status = ?';
$params[] = $this->getParam('status');
}
$total = Database::count('assets', $where, $params);
$assets = Database::fetchAll(
"SELECT a.*, u.full_name as owner_name
FROM assets a
LEFT JOIN users u ON u.id = a.owner_user_id
WHERE a.{$where}
ORDER BY a.criticality DESC, a.name
LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}",
$params
);
$this->jsonPaginated($assets, $total, $pagination['page'], $pagination['per_page']);
}
public function create(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$this->validateRequired(['name', 'asset_type']);
$assetId = Database::insert('assets', [
'organization_id' => $this->getCurrentOrgId(),
'name' => trim($this->getParam('name')),
'asset_type' => $this->getParam('asset_type'),
'category' => $this->getParam('category'),
'description' => $this->getParam('description'),
'criticality' => $this->getParam('criticality', 'medium'),
'owner_user_id' => $this->getParam('owner_user_id'),
'location' => $this->getParam('location'),
'ip_address' => $this->getParam('ip_address'),
'vendor' => $this->getParam('vendor'),
'version' => $this->getParam('version'),
'serial_number' => $this->getParam('serial_number'),
'purchase_date' => $this->getParam('purchase_date'),
'warranty_expiry' => $this->getParam('warranty_expiry'),
'dependencies' => $this->getParam('dependencies') ? json_encode($this->getParam('dependencies')) : null,
]);
$this->logAudit('asset_created', 'asset', $assetId);
$this->jsonSuccess(['id' => $assetId], 'Asset registrato', 201);
}
public function get(int $id): void
{
$this->requireOrgAccess();
$asset = Database::fetchOne(
'SELECT a.*, u.full_name as owner_name
FROM assets a LEFT JOIN users u ON u.id = a.owner_user_id
WHERE a.id = ? AND a.organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$asset) {
$this->jsonError('Asset non trovato', 404, 'ASSET_NOT_FOUND');
}
$this->jsonSuccess($asset);
}
public function update(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$updates = [];
$fields = ['name', 'asset_type', 'category', 'description', 'criticality',
'owner_user_id', 'location', 'ip_address', 'vendor', 'version',
'serial_number', 'purchase_date', 'warranty_expiry', 'status'];
foreach ($fields as $field) {
if ($this->hasParam($field)) {
$updates[$field] = $this->getParam($field);
}
}
if ($this->hasParam('dependencies')) {
$updates['dependencies'] = json_encode($this->getParam('dependencies'));
}
if (!empty($updates)) {
Database::update('assets', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
$this->logAudit('asset_updated', 'asset', $id, $updates);
}
$this->jsonSuccess($updates, 'Asset aggiornato');
}
public function delete(int $id): void
{
$this->requireOrgRole(['org_admin']);
$deleted = Database::delete('assets', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
if ($deleted === 0) {
$this->jsonError('Asset non trovato', 404, 'ASSET_NOT_FOUND');
}
$this->logAudit('asset_deleted', 'asset', $id);
$this->jsonSuccess(null, 'Asset eliminato');
}
public function dependencyMap(): void
{
$this->requireOrgAccess();
$assets = Database::fetchAll(
'SELECT id, name, asset_type, criticality, dependencies
FROM assets
WHERE organization_id = ? AND status = "active"',
[$this->getCurrentOrgId()]
);
$nodes = [];
$edges = [];
foreach ($assets as $asset) {
$nodes[] = [
'id' => $asset['id'],
'label' => $asset['name'],
'type' => $asset['asset_type'],
'criticality' => $asset['criticality'],
];
$deps = json_decode($asset['dependencies'] ?? '[]', true) ?: [];
foreach ($deps as $depId) {
$edges[] = ['from' => $asset['id'], 'to' => (int) $depId];
}
}
$this->jsonSuccess(['nodes' => $nodes, 'edges' => $edges]);
}
}
+196
View File
@@ -0,0 +1,196 @@
<?php
/**
* NIS2 Agile - Audit Controller
*
* Controlli compliance, evidenze, audit logs, mapping ISO 27001.
*/
require_once __DIR__ . '/BaseController.php';
class AuditController extends BaseController
{
public function listControls(): void
{
$this->requireOrgAccess();
$controls = Database::fetchAll(
'SELECT cc.*, u.full_name as responsible_name
FROM compliance_controls cc
LEFT JOIN users u ON u.id = cc.responsible_user_id
WHERE cc.organization_id = ?
ORDER BY cc.control_code',
[$this->getCurrentOrgId()]
);
$this->jsonSuccess($controls);
}
public function updateControl(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
$updates = [];
foreach (['status', 'implementation_percentage', 'evidence_description', 'responsible_user_id', 'next_review_date'] as $field) {
if ($this->hasParam($field)) {
$updates[$field] = $this->getParam($field);
}
}
if (isset($updates['status']) && $updates['status'] === 'verified') {
$updates['last_verified_at'] = date('Y-m-d H:i:s');
}
if (!empty($updates)) {
Database::update('compliance_controls', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
$this->logAudit('control_updated', 'compliance_control', $id, $updates);
}
$this->jsonSuccess($updates, 'Controllo aggiornato');
}
public function uploadEvidence(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'auditor']);
if (!isset($_FILES['file'])) {
$this->jsonError('File non fornito', 400, 'NO_FILE');
}
$file = $_FILES['file'];
$maxSize = 10 * 1024 * 1024; // 10MB
if ($file['size'] > $maxSize) {
$this->jsonError('File troppo grande (max 10MB)', 400, 'FILE_TOO_LARGE');
}
$orgId = $this->getCurrentOrgId();
$uploadDir = UPLOAD_PATH . "/evidence/{$orgId}";
if (!is_dir($uploadDir)) {
mkdir($uploadDir, 0755, true);
}
$ext = pathinfo($file['name'], PATHINFO_EXTENSION);
$filename = uniqid('ev_') . '.' . $ext;
$filePath = $uploadDir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $filePath)) {
$this->jsonError('Errore caricamento file', 500, 'UPLOAD_ERROR');
}
$evidenceId = Database::insert('evidence_files', [
'organization_id' => $orgId,
'control_id' => $this->getParam('control_id'),
'entity_type' => $this->getParam('entity_type'),
'entity_id' => $this->getParam('entity_id'),
'file_name' => $file['name'],
'file_path' => "evidence/{$orgId}/{$filename}",
'file_size' => $file['size'],
'mime_type' => $file['type'],
'uploaded_by' => $this->getCurrentUserId(),
]);
$this->logAudit('evidence_uploaded', 'evidence', $evidenceId);
$this->jsonSuccess(['id' => $evidenceId], 'Evidenza caricata', 201);
}
public function listEvidence(): void
{
$this->requireOrgAccess();
$where = 'organization_id = ?';
$params = [$this->getCurrentOrgId()];
if ($this->hasParam('control_id')) {
$where .= ' AND control_id = ?';
$params[] = $this->getParam('control_id');
}
if ($this->hasParam('entity_type') && $this->hasParam('entity_id')) {
$where .= ' AND entity_type = ? AND entity_id = ?';
$params[] = $this->getParam('entity_type');
$params[] = $this->getParam('entity_id');
}
$evidence = Database::fetchAll(
"SELECT ef.*, u.full_name as uploaded_by_name
FROM evidence_files ef
LEFT JOIN users u ON u.id = ef.uploaded_by
WHERE ef.{$where}
ORDER BY ef.created_at DESC",
$params
);
$this->jsonSuccess($evidence);
}
public function generateReport(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$orgId]);
$controls = Database::fetchAll('SELECT * FROM compliance_controls WHERE organization_id = ? ORDER BY control_code', [$orgId]);
$lastAssessment = Database::fetchOne('SELECT * FROM assessments WHERE organization_id = ? AND status = "completed" ORDER BY completed_at DESC LIMIT 1', [$orgId]);
$riskCount = Database::count('risks', 'organization_id = ? AND status != "closed"', [$orgId]);
$incidentCount = Database::count('incidents', 'organization_id = ?', [$orgId]);
$policyCount = Database::count('policies', 'organization_id = ? AND status IN ("approved","published")', [$orgId]);
$totalControls = count($controls);
$implemented = count(array_filter($controls, fn($c) => in_array($c['status'], ['implemented', 'verified'])));
$this->jsonSuccess([
'organization' => $org,
'report_date' => date('Y-m-d H:i:s'),
'compliance_summary' => [
'total_controls' => $totalControls,
'implemented_controls' => $implemented,
'compliance_percentage' => $totalControls > 0 ? round($implemented / $totalControls * 100) : 0,
],
'controls' => $controls,
'last_assessment' => $lastAssessment,
'risk_count' => $riskCount,
'incident_count' => $incidentCount,
'policy_count' => $policyCount,
]);
}
public function getAuditLogs(): void
{
$this->requireOrgRole(['org_admin', 'auditor']);
$pagination = $this->getPagination(50);
$total = Database::count('audit_logs', 'organization_id = ?', [$this->getCurrentOrgId()]);
$logs = Database::fetchAll(
"SELECT al.*, u.full_name
FROM audit_logs al
LEFT JOIN users u ON u.id = al.user_id
WHERE al.organization_id = ?
ORDER BY al.created_at DESC
LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}",
[$this->getCurrentOrgId()]
);
$this->jsonPaginated($logs, $total, $pagination['page'], $pagination['per_page']);
}
public function getIsoMapping(): void
{
$this->requireOrgAccess();
$mapping = [
['nis2' => '21.2.a', 'iso27001' => 'A.5.1, A.5.2, A.8.1, A.8.2', 'title' => 'Risk analysis and security policies'],
['nis2' => '21.2.b', 'iso27001' => 'A.5.24, A.5.25, A.5.26, A.6.8', 'title' => 'Incident handling'],
['nis2' => '21.2.c', 'iso27001' => 'A.5.29, A.5.30', 'title' => 'Business continuity'],
['nis2' => '21.2.d', 'iso27001' => 'A.5.19, A.5.20, A.5.21, A.5.22', 'title' => 'Supply chain security'],
['nis2' => '21.2.e', 'iso27001' => 'A.8.25, A.8.26, A.8.27, A.8.28', 'title' => 'System acquisition and development'],
['nis2' => '21.2.f', 'iso27001' => 'A.5.35, A.5.36', 'title' => 'Effectiveness assessment'],
['nis2' => '21.2.g', 'iso27001' => 'A.6.3, A.6.6', 'title' => 'Cyber hygiene and training'],
['nis2' => '21.2.h', 'iso27001' => 'A.8.24', 'title' => 'Cryptography and encryption'],
['nis2' => '21.2.i', 'iso27001' => 'A.5.15, A.5.16, A.5.17, A.5.18, A.6.1, A.6.2', 'title' => 'HR security, access control, asset management'],
['nis2' => '21.2.j', 'iso27001' => 'A.8.5', 'title' => 'Multi-factor authentication'],
];
$this->jsonSuccess($mapping);
}
}
+280
View File
@@ -0,0 +1,280 @@
<?php
/**
* NIS2 Agile - Auth Controller
*
* Gestisce registrazione, login, JWT tokens, profilo utente.
*/
require_once __DIR__ . '/BaseController.php';
class AuthController extends BaseController
{
/**
* POST /api/auth/register
*/
public function register(): void
{
$this->validateRequired(['email', 'password', 'full_name']);
$email = strtolower(trim($this->getParam('email')));
$password = $this->getParam('password');
$fullName = trim($this->getParam('full_name'));
$phone = $this->getParam('phone');
// Validazione email
if (!$this->validateEmail($email)) {
$this->jsonError('Formato email non valido', 400, 'INVALID_EMAIL');
}
// Validazione password
$passwordErrors = $this->validatePassword($password);
if (!empty($passwordErrors)) {
$this->jsonError(
implode('. ', $passwordErrors),
400,
'WEAK_PASSWORD'
);
}
// Verifica email duplicata
$existing = Database::fetchOne('SELECT id FROM users WHERE email = ?', [$email]);
if ($existing) {
$this->jsonError('Email già registrata', 409, 'EMAIL_EXISTS');
}
// Crea utente
$userId = Database::insert('users', [
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'full_name' => $fullName,
'phone' => $phone,
'role' => 'employee',
'is_active' => 1,
]);
// Genera tokens
$accessToken = $this->generateJWT($userId);
$refreshToken = $this->generateRefreshToken($userId);
// Audit log
$this->currentUser = ['id' => $userId];
$this->logAudit('user_registered', 'user', $userId);
$this->jsonSuccess([
'user' => [
'id' => $userId,
'email' => $email,
'full_name' => $fullName,
'role' => 'employee',
],
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'expires_in' => JWT_EXPIRES_IN,
], 'Registrazione completata', 201);
}
/**
* POST /api/auth/login
*/
public function login(): void
{
$this->validateRequired(['email', 'password']);
$email = strtolower(trim($this->getParam('email')));
$password = $this->getParam('password');
// Trova utente
$user = Database::fetchOne(
'SELECT * FROM users WHERE email = ? AND is_active = 1',
[$email]
);
if (!$user || !password_verify($password, $user['password_hash'])) {
$this->jsonError('Credenziali non valide', 401, 'INVALID_CREDENTIALS');
}
// Aggiorna ultimo login
Database::update('users', [
'last_login_at' => date('Y-m-d H:i:s'),
], 'id = ?', [$user['id']]);
// Genera tokens
$accessToken = $this->generateJWT((int) $user['id']);
$refreshToken = $this->generateRefreshToken((int) $user['id']);
// Carica organizzazioni
$organizations = Database::fetchAll(
'SELECT uo.organization_id, uo.role, uo.is_primary, o.name, o.sector, o.entity_type
FROM user_organizations uo
JOIN organizations o ON o.id = uo.organization_id
WHERE uo.user_id = ? AND o.is_active = 1',
[$user['id']]
);
$this->currentUser = $user;
$this->logAudit('user_login', 'user', (int) $user['id']);
$this->jsonSuccess([
'user' => [
'id' => (int) $user['id'],
'email' => $user['email'],
'full_name' => $user['full_name'],
'role' => $user['role'],
'preferred_language' => $user['preferred_language'],
],
'organizations' => $organizations,
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'expires_in' => JWT_EXPIRES_IN,
], 'Login effettuato');
}
/**
* POST /api/auth/logout
*/
public function logout(): void
{
$this->requireAuth();
// Invalida tutti i refresh token dell'utente
Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]);
$this->logAudit('user_logout', 'user', $this->getCurrentUserId());
$this->jsonSuccess(null, 'Logout effettuato');
}
/**
* POST /api/auth/refresh
*/
public function refresh(): void
{
$this->validateRequired(['refresh_token']);
$refreshToken = $this->getParam('refresh_token');
$hashedToken = hash('sha256', $refreshToken);
// Verifica refresh token
$tokenRecord = Database::fetchOne(
'SELECT * FROM refresh_tokens WHERE token = ? AND expires_at > NOW()',
[$hashedToken]
);
if (!$tokenRecord) {
$this->jsonError('Refresh token non valido o scaduto', 401, 'INVALID_REFRESH_TOKEN');
}
// Elimina vecchio token
Database::delete('refresh_tokens', 'id = ?', [$tokenRecord['id']]);
// Genera nuovi tokens
$userId = (int) $tokenRecord['user_id'];
$accessToken = $this->generateJWT($userId);
$newRefreshToken = $this->generateRefreshToken($userId);
$this->jsonSuccess([
'access_token' => $accessToken,
'refresh_token' => $newRefreshToken,
'expires_in' => JWT_EXPIRES_IN,
], 'Token rinnovato');
}
/**
* GET /api/auth/me
*/
public function me(): void
{
$this->requireAuth();
$user = $this->getCurrentUser();
// Carica organizzazioni
$organizations = Database::fetchAll(
'SELECT uo.organization_id, uo.role as org_role, uo.is_primary,
o.name, o.sector, o.entity_type, o.subscription_plan
FROM user_organizations uo
JOIN organizations o ON o.id = uo.organization_id
WHERE uo.user_id = ? AND o.is_active = 1',
[$user['id']]
);
$this->jsonSuccess([
'id' => (int) $user['id'],
'email' => $user['email'],
'full_name' => $user['full_name'],
'phone' => $user['phone'],
'role' => $user['role'],
'preferred_language' => $user['preferred_language'],
'last_login_at' => $user['last_login_at'],
'created_at' => $user['created_at'],
'organizations' => $organizations,
]);
}
/**
* PUT /api/auth/profile
*/
public function updateProfile(): void
{
$this->requireAuth();
$updates = [];
if ($this->hasParam('full_name')) {
$updates['full_name'] = trim($this->getParam('full_name'));
}
if ($this->hasParam('phone')) {
$updates['phone'] = $this->getParam('phone');
}
if ($this->hasParam('preferred_language')) {
$lang = $this->getParam('preferred_language');
if (in_array($lang, ['it', 'en', 'fr', 'de'])) {
$updates['preferred_language'] = $lang;
}
}
if (empty($updates)) {
$this->jsonError('Nessun campo da aggiornare', 400, 'NO_UPDATES');
}
Database::update('users', $updates, 'id = ?', [$this->getCurrentUserId()]);
$this->logAudit('profile_updated', 'user', $this->getCurrentUserId(), $updates);
$this->jsonSuccess($updates, 'Profilo aggiornato');
}
/**
* POST /api/auth/change-password
*/
public function changePassword(): void
{
$this->requireAuth();
$this->validateRequired(['current_password', 'new_password']);
$currentPassword = $this->getParam('current_password');
$newPassword = $this->getParam('new_password');
// Verifica password attuale
if (!password_verify($currentPassword, $this->currentUser['password_hash'])) {
$this->jsonError('Password attuale non corretta', 400, 'WRONG_PASSWORD');
}
// Validazione nuova password
$errors = $this->validatePassword($newPassword);
if (!empty($errors)) {
$this->jsonError(implode('. ', $errors), 400, 'WEAK_PASSWORD');
}
Database::update('users', [
'password_hash' => password_hash($newPassword, PASSWORD_DEFAULT),
], 'id = ?', [$this->getCurrentUserId()]);
// Invalida tutti i refresh token (force re-login)
Database::delete('refresh_tokens', 'user_id = ?', [$this->getCurrentUserId()]);
$this->logAudit('password_changed', 'user', $this->getCurrentUserId());
$this->jsonSuccess(null, 'Password modificata. Effettua nuovamente il login.');
}
}
+576
View File
@@ -0,0 +1,576 @@
<?php
/**
* NIS2 Agile - Base Controller
*
* Classe base per tutti i controller.
* Gestisce autenticazione, multi-tenancy, risposte JSON, validazione.
*/
require_once APP_PATH . '/config/database.php';
class BaseController
{
protected ?array $currentUser = null;
protected ?int $currentOrgId = null;
protected ?string $currentOrgRole = null;
// ═══════════════════════════════════════════════════════════════════════
// RISPOSTE JSON
// ═══════════════════════════════════════════════════════════════════════
/**
* Invia risposta JSON di successo
*/
protected function jsonSuccess($data = null, string $message = 'OK', int $statusCode = 200): void
{
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode([
'success' => true,
'message' => $message,
'data' => $data,
], JSON_UNESCAPED_UNICODE);
exit;
}
/**
* Invia risposta JSON di errore
*/
protected function jsonError(string $message, int $statusCode = 400, ?string $errorCode = null, ?array $data = null): void
{
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
$response = [
'success' => false,
'message' => $message,
];
if ($errorCode) {
$response['error_code'] = $errorCode;
}
if ($data) {
$response['data'] = $data;
}
echo json_encode($response, JSON_UNESCAPED_UNICODE);
exit;
}
/**
* Invia risposta paginata
*/
protected function jsonPaginated(array $items, int $total, int $page, int $perPage): void
{
$this->jsonSuccess([
'items' => $items,
'total' => $total,
'page' => $page,
'per_page' => $perPage,
'pages' => ceil($total / $perPage),
]);
}
// ═══════════════════════════════════════════════════════════════════════
// PARAMETRI RICHIESTA
// ═══════════════════════════════════════════════════════════════════════
/**
* Ottiene parametro dalla richiesta (GET, POST o JSON body)
*/
protected function getParam(string $key, $default = null)
{
if (isset($_REQUEST[$key])) {
return $_REQUEST[$key];
}
$jsonBody = $this->getJsonBody();
if (isset($jsonBody[$key])) {
return $jsonBody[$key];
}
return $default;
}
/**
* Verifica se un parametro esiste
*/
protected function hasParam(string $key): bool
{
if (isset($_REQUEST[$key])) {
return true;
}
$jsonBody = $this->getJsonBody();
return isset($jsonBody[$key]);
}
/**
* Ottiene tutti i parametri dalla richiesta
*/
protected function getAllParams(): array
{
$params = $_REQUEST;
$jsonBody = $this->getJsonBody();
return array_merge($params, $jsonBody);
}
/**
* Ottiene il body JSON della richiesta
*/
protected function getJsonBody(): array
{
static $jsonBody = null;
if ($jsonBody === null) {
$input = file_get_contents('php://input');
$jsonBody = json_decode($input, true) ?? [];
}
return $jsonBody;
}
/**
* Ottiene parametri di paginazione
*/
protected function getPagination(int $defaultPerPage = 20): array
{
$page = max(1, (int) $this->getParam('page', 1));
$perPage = min(100, max(1, (int) $this->getParam('per_page', $defaultPerPage)));
$offset = ($page - 1) * $perPage;
return ['page' => $page, 'per_page' => $perPage, 'offset' => $offset];
}
// ═══════════════════════════════════════════════════════════════════════
// VALIDAZIONE
// ═══════════════════════════════════════════════════════════════════════
/**
* Valida parametri obbligatori
*/
protected function validateRequired(array $required): void
{
$missing = [];
foreach ($required as $field) {
$value = $this->getParam($field);
if ($value === null || $value === '') {
$missing[] = $field;
}
}
if (!empty($missing)) {
$this->jsonError(
'Campi obbligatori mancanti: ' . implode(', ', $missing),
400,
'MISSING_REQUIRED_FIELDS'
);
}
}
/**
* Valida formato email
*/
protected function validateEmail(string $email): bool
{
return filter_var($email, FILTER_VALIDATE_EMAIL) !== false;
}
/**
* Valida Partita IVA italiana
*/
protected function validateVAT(string $vat): bool
{
$vat = preg_replace('/\s+/', '', $vat);
$vat = preg_replace('/^IT/i', '', $vat);
if (!preg_match('/^\d{11}$/', $vat)) {
return false;
}
$sum = 0;
for ($i = 0; $i < 11; $i++) {
$digit = (int) $vat[$i];
if ($i % 2 === 0) {
$sum += $digit;
} else {
$double = $digit * 2;
$sum += ($double > 9) ? $double - 9 : $double;
}
}
return ($sum % 10) === 0;
}
/**
* Valida Codice Fiscale italiano
*/
protected function validateFiscalCode(string $cf): bool
{
$cf = strtoupper(trim($cf));
return (bool) preg_match('/^[A-Z0-9]{16}$/', $cf);
}
/**
* Valida password secondo policy
*/
protected function validatePassword(string $password): array
{
$errors = [];
if (strlen($password) < PASSWORD_MIN_LENGTH) {
$errors[] = 'La password deve essere di almeno ' . PASSWORD_MIN_LENGTH . ' caratteri';
}
if (PASSWORD_REQUIRE_UPPERCASE && !preg_match('/[A-Z]/', $password)) {
$errors[] = 'La password deve contenere almeno una lettera maiuscola';
}
if (PASSWORD_REQUIRE_NUMBER && !preg_match('/[0-9]/', $password)) {
$errors[] = 'La password deve contenere almeno un numero';
}
if (PASSWORD_REQUIRE_SPECIAL && !preg_match('/[!@#$%^&*(),.?":{}|<>]/', $password)) {
$errors[] = 'La password deve contenere almeno un carattere speciale';
}
return $errors;
}
// ═══════════════════════════════════════════════════════════════════════
// AUTENTICAZIONE JWT
// ═══════════════════════════════════════════════════════════════════════
/**
* Richiede autenticazione JWT
*/
protected function requireAuth(): void
{
$token = $this->getBearerToken();
if (!$token) {
$this->jsonError('Token di autenticazione mancante', 401, 'MISSING_TOKEN');
}
$payload = $this->verifyJWT($token);
if (!$payload) {
$this->jsonError('Token non valido o scaduto', 401, 'INVALID_TOKEN');
}
$user = Database::fetchOne(
'SELECT * FROM users WHERE id = ? AND is_active = 1',
[$payload['user_id']]
);
if (!$user) {
$this->jsonError('Utente non trovato o disabilitato', 401, 'USER_NOT_FOUND');
}
$this->currentUser = $user;
}
/**
* Richiede ruolo super_admin
*/
protected function requireSuperAdmin(): void
{
$this->requireAuth();
if ($this->currentUser['role'] !== 'super_admin') {
$this->jsonError('Accesso riservato ai super amministratori', 403, 'SUPER_ADMIN_REQUIRED');
}
}
// ═══════════════════════════════════════════════════════════════════════
// MULTI-TENANCY
// ═══════════════════════════════════════════════════════════════════════
/**
* Richiede accesso all'organizzazione corrente
*/
protected function requireOrgAccess(): void
{
$this->requireAuth();
$orgId = $this->resolveOrgId();
if (!$orgId) {
$this->jsonError('Organizzazione non selezionata', 403, 'NO_ORG');
}
// Super admin ha accesso a tutto
if ($this->currentUser['role'] === 'super_admin') {
$this->currentOrgId = $orgId;
$this->currentOrgRole = 'super_admin';
return;
}
// Verifica membership
$membership = Database::fetchOne(
'SELECT role FROM user_organizations WHERE user_id = ? AND organization_id = ?',
[$this->getCurrentUserId(), $orgId]
);
if (!$membership) {
$this->jsonError('Accesso non autorizzato a questa organizzazione', 403, 'ORG_ACCESS_DENIED');
}
$this->currentOrgId = $orgId;
$this->currentOrgRole = $membership['role'];
}
/**
* Richiede ruolo minimo nell'organizzazione
*/
protected function requireOrgRole(array $allowedRoles): void
{
$this->requireOrgAccess();
if ($this->currentOrgRole === 'super_admin') {
return;
}
if (!in_array($this->currentOrgRole, $allowedRoles)) {
$this->jsonError(
'Ruolo insufficiente. Richiesto: ' . implode(' o ', $allowedRoles),
403,
'INSUFFICIENT_ROLE'
);
}
}
/**
* Risolve l'ID organizzazione dalla richiesta
*/
protected function resolveOrgId(): ?int
{
// 1. Header X-Organization-Id
$orgId = $_SERVER['HTTP_X_ORGANIZATION_ID'] ?? null;
if ($orgId) {
return (int) $orgId;
}
// 2. Query parameter org_id
$orgId = $this->getParam('org_id');
if ($orgId) {
return (int) $orgId;
}
// 3. Organizzazione primaria dell'utente
$primary = Database::fetchOne(
'SELECT organization_id FROM user_organizations WHERE user_id = ? AND is_primary = 1',
[$this->getCurrentUserId()]
);
return $primary ? (int) $primary['organization_id'] : null;
}
/**
* Ottiene utente corrente
*/
protected function getCurrentUser(): ?array
{
return $this->currentUser;
}
/**
* Ottiene ID utente corrente
*/
protected function getCurrentUserId(): ?int
{
return $this->currentUser ? (int) $this->currentUser['id'] : null;
}
/**
* Ottiene ID organizzazione corrente
*/
protected function getCurrentOrgId(): ?int
{
return $this->currentOrgId;
}
// ═══════════════════════════════════════════════════════════════════════
// JWT TOKEN MANAGEMENT
// ═══════════════════════════════════════════════════════════════════════
/**
* Estrae Bearer token dall'header Authorization
*/
protected function getBearerToken(): ?string
{
$headers = $this->getAuthorizationHeader();
if ($headers && preg_match('/Bearer\s(\S+)/', $headers, $matches)) {
return $matches[1];
}
if (isset($_GET['token']) && !empty($_GET['token'])) {
return $_GET['token'];
}
return null;
}
/**
* Ottiene header Authorization
*/
private function getAuthorizationHeader(): ?string
{
if (isset($_SERVER['Authorization'])) {
return $_SERVER['Authorization'];
}
if (isset($_SERVER['HTTP_AUTHORIZATION'])) {
return $_SERVER['HTTP_AUTHORIZATION'];
}
if (function_exists('apache_request_headers')) {
$headers = apache_request_headers();
if (isset($headers['Authorization'])) {
return $headers['Authorization'];
}
}
return null;
}
/**
* Genera JWT token
*/
protected function generateJWT(int $userId, array $extraData = []): string
{
$header = json_encode([
'typ' => 'JWT',
'alg' => JWT_ALGORITHM,
]);
$payload = json_encode(array_merge([
'user_id' => $userId,
'iat' => time(),
'exp' => time() + JWT_EXPIRES_IN,
], $extraData));
$base64Header = $this->base64UrlEncode($header);
$base64Payload = $this->base64UrlEncode($payload);
$signature = hash_hmac('sha256', "$base64Header.$base64Payload", JWT_SECRET, true);
$base64Signature = $this->base64UrlEncode($signature);
return "$base64Header.$base64Payload.$base64Signature";
}
/**
* Verifica JWT token
*/
protected function verifyJWT(string $token): ?array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
[$base64Header, $base64Payload, $base64Signature] = $parts;
$signature = $this->base64UrlDecode($base64Signature);
$expectedSignature = hash_hmac('sha256', "$base64Header.$base64Payload", JWT_SECRET, true);
if (!hash_equals($signature, $expectedSignature)) {
return null;
}
$payload = json_decode($this->base64UrlDecode($base64Payload), true);
if (!$payload) {
return null;
}
if (isset($payload['exp']) && $payload['exp'] < time()) {
return null;
}
return $payload;
}
/**
* Genera refresh token
*/
protected function generateRefreshToken(int $userId): string
{
$token = bin2hex(random_bytes(32));
$expiresAt = date('Y-m-d H:i:s', time() + JWT_REFRESH_EXPIRES_IN);
Database::insert('refresh_tokens', [
'user_id' => $userId,
'token' => hash('sha256', $token),
'expires_at' => $expiresAt,
]);
return $token;
}
private function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private function base64UrlDecode(string $data): string
{
return base64_decode(strtr($data, '-_', '+/'));
}
// ═══════════════════════════════════════════════════════════════════════
// AUDIT LOGGING
// ═══════════════════════════════════════════════════════════════════════
/**
* Registra azione nell'audit log
*/
protected function logAudit(string $action, ?string $entityType = null, ?int $entityId = null, ?array $details = null): void
{
Database::insert('audit_logs', [
'user_id' => $this->getCurrentUserId(),
'organization_id' => $this->currentOrgId,
'action' => $action,
'entity_type' => $entityType,
'entity_id' => $entityId,
'details' => $details ? json_encode($details) : null,
'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null,
'user_agent' => $_SERVER['HTTP_USER_AGENT'] ?? null,
]);
}
// ═══════════════════════════════════════════════════════════════════════
// UTILITY
// ═══════════════════════════════════════════════════════════════════════
/**
* Sanitizza stringa per output
*/
protected function sanitize(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES, 'UTF-8');
}
/**
* Ottiene metodo HTTP della richiesta
*/
protected function getMethod(): string
{
return strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
}
/**
* Genera codice univoco
*/
protected function generateCode(string $prefix, int $length = 6): string
{
$number = str_pad(mt_rand(0, pow(10, $length) - 1), $length, '0', STR_PAD_LEFT);
return $prefix . '-' . $number;
}
}
@@ -0,0 +1,348 @@
<?php
/**
* NIS2 Agile - Dashboard Controller
*
* Overview di compliance, score, scadenze, attività recenti.
*/
require_once __DIR__ . '/BaseController.php';
class DashboardController extends BaseController
{
/**
* GET /api/dashboard/overview
*/
public function overview(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
// Ultimo assessment
$lastAssessment = Database::fetchOne(
'SELECT id, title, overall_score, status, completed_at
FROM assessments
WHERE organization_id = ? AND status = "completed"
ORDER BY completed_at DESC LIMIT 1',
[$orgId]
);
// Compliance controls status
$controlStats = Database::fetchAll(
'SELECT status, COUNT(*) as count
FROM compliance_controls
WHERE organization_id = ?
GROUP BY status',
[$orgId]
);
// Rischi aperti
$openRisks = Database::fetchAll(
'SELECT severity, COUNT(*) as count
FROM risks
WHERE organization_id = ? AND status NOT IN ("closed")
GROUP BY FIELD(severity, "critical", "high", "medium", "low") -- non supportato, usiamo ORDER',
[$orgId]
);
$riskCounts = Database::fetchOne(
'SELECT
SUM(CASE WHEN inherent_risk_score >= 20 THEN 1 ELSE 0 END) as critical_high,
SUM(CASE WHEN inherent_risk_score BETWEEN 10 AND 19 THEN 1 ELSE 0 END) as medium,
SUM(CASE WHEN inherent_risk_score < 10 THEN 1 ELSE 0 END) as low,
COUNT(*) as total
FROM risks
WHERE organization_id = ? AND status != "closed"',
[$orgId]
);
// Incidenti attivi
$activeIncidents = Database::count(
'incidents',
'organization_id = ? AND status NOT IN ("closed", "post_mortem")',
[$orgId]
);
// Policy per stato
$policyStats = Database::fetchAll(
'SELECT status, COUNT(*) as count
FROM policies
WHERE organization_id = ?
GROUP BY status',
[$orgId]
);
// Fornitori critici
$criticalSuppliers = Database::count(
'suppliers',
'organization_id = ? AND criticality IN ("high", "critical") AND security_requirements_met = 0',
[$orgId]
);
// Training completamento
$trainingStats = Database::fetchOne(
'SELECT
COUNT(*) as total,
SUM(CASE WHEN status = "completed" THEN 1 ELSE 0 END) as completed,
SUM(CASE WHEN status = "overdue" THEN 1 ELSE 0 END) as overdue
FROM training_assignments
WHERE organization_id = ?',
[$orgId]
);
// Conteggi asset
$assetCount = Database::count('assets', 'organization_id = ? AND status = "active"', [$orgId]);
// Organizzazione info
$org = Database::fetchOne('SELECT name, sector, entity_type, subscription_plan FROM organizations WHERE id = ?', [$orgId]);
$this->jsonSuccess([
'organization' => $org,
'last_assessment' => $lastAssessment,
'compliance_score' => $lastAssessment ? (float) $lastAssessment['overall_score'] : null,
'controls' => $controlStats,
'risks' => $riskCounts,
'active_incidents' => $activeIncidents,
'policies' => $policyStats,
'critical_suppliers' => $criticalSuppliers,
'training' => $trainingStats,
'asset_count' => $assetCount,
]);
}
/**
* GET /api/dashboard/compliance-score
*/
public function complianceScore(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
// Score dall'ultimo assessment
$assessments = Database::fetchAll(
'SELECT id, title, overall_score, category_scores, completed_at
FROM assessments
WHERE organization_id = ? AND status = "completed"
ORDER BY completed_at DESC
LIMIT 5',
[$orgId]
);
// Score dei controlli
$controls = Database::fetchAll(
'SELECT control_code, title, status, implementation_percentage
FROM compliance_controls
WHERE organization_id = ?
ORDER BY control_code',
[$orgId]
);
$totalControls = count($controls);
$implementedControls = 0;
$avgImplementation = 0;
foreach ($controls as $c) {
if ($c['status'] === 'implemented' || $c['status'] === 'verified') {
$implementedControls++;
}
$avgImplementation += (int) $c['implementation_percentage'];
}
$avgImplementation = $totalControls > 0 ? round($avgImplementation / $totalControls) : 0;
$this->jsonSuccess([
'assessments' => $assessments,
'controls' => $controls,
'total_controls' => $totalControls,
'implemented_controls' => $implementedControls,
'avg_implementation' => $avgImplementation,
]);
}
/**
* GET /api/dashboard/upcoming-deadlines
*/
public function deadlines(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$deadlines = [];
// Incidenti con scadenze notifica
$incidentDeadlines = Database::fetchAll(
'SELECT id, incident_code, title, severity,
early_warning_due, early_warning_sent_at,
notification_due, notification_sent_at,
final_report_due, final_report_sent_at
FROM incidents
WHERE organization_id = ? AND is_significant = 1 AND status NOT IN ("closed", "post_mortem")
ORDER BY detected_at DESC',
[$orgId]
);
foreach ($incidentDeadlines as $inc) {
if ($inc['early_warning_due'] && !$inc['early_warning_sent_at']) {
$deadlines[] = [
'type' => 'incident_early_warning',
'title' => "Early Warning: {$inc['title']}",
'due_date' => $inc['early_warning_due'],
'severity' => 'critical',
'entity_type' => 'incident',
'entity_id' => $inc['id'],
];
}
if ($inc['notification_due'] && !$inc['notification_sent_at']) {
$deadlines[] = [
'type' => 'incident_notification',
'title' => "Notifica CSIRT: {$inc['title']}",
'due_date' => $inc['notification_due'],
'severity' => 'high',
'entity_type' => 'incident',
'entity_id' => $inc['id'],
];
}
if ($inc['final_report_due'] && !$inc['final_report_sent_at']) {
$deadlines[] = [
'type' => 'incident_final_report',
'title' => "Report finale: {$inc['title']}",
'due_date' => $inc['final_report_due'],
'severity' => 'medium',
'entity_type' => 'incident',
'entity_id' => $inc['id'],
];
}
}
// Policy in scadenza revisione
$policyDeadlines = Database::fetchAll(
'SELECT id, title, next_review_date
FROM policies
WHERE organization_id = ? AND next_review_date IS NOT NULL
AND next_review_date <= DATE_ADD(NOW(), INTERVAL 30 DAY)
AND status NOT IN ("archived")
ORDER BY next_review_date',
[$orgId]
);
foreach ($policyDeadlines as $p) {
$deadlines[] = [
'type' => 'policy_review',
'title' => "Revisione policy: {$p['title']}",
'due_date' => $p['next_review_date'],
'severity' => 'medium',
'entity_type' => 'policy',
'entity_id' => $p['id'],
];
}
// Risk treatments in scadenza
$treatmentDeadlines = Database::fetchAll(
'SELECT rt.id, rt.action_description, rt.due_date, r.title as risk_title
FROM risk_treatments rt
JOIN risks r ON r.id = rt.risk_id
WHERE r.organization_id = ? AND rt.status IN ("planned", "in_progress")
AND rt.due_date IS NOT NULL AND rt.due_date <= DATE_ADD(NOW(), INTERVAL 30 DAY)
ORDER BY rt.due_date',
[$orgId]
);
foreach ($treatmentDeadlines as $t) {
$deadlines[] = [
'type' => 'risk_treatment',
'title' => "Trattamento rischio: {$t['risk_title']}",
'due_date' => $t['due_date'],
'severity' => 'medium',
'entity_type' => 'risk_treatment',
'entity_id' => $t['id'],
];
}
// Training in scadenza
$trainingDeadlines = Database::fetchAll(
'SELECT ta.id, tc.title, ta.due_date, u.full_name
FROM training_assignments ta
JOIN training_courses tc ON tc.id = ta.course_id
JOIN users u ON u.id = ta.user_id
WHERE ta.organization_id = ? AND ta.status IN ("assigned", "in_progress")
AND ta.due_date IS NOT NULL AND ta.due_date <= DATE_ADD(NOW(), INTERVAL 30 DAY)
ORDER BY ta.due_date',
[$orgId]
);
foreach ($trainingDeadlines as $t) {
$deadlines[] = [
'type' => 'training_due',
'title' => "Formazione: {$t['title']} - {$t['full_name']}",
'due_date' => $t['due_date'],
'severity' => 'low',
'entity_type' => 'training',
'entity_id' => $t['id'],
];
}
// Ordina per data
usort($deadlines, fn($a, $b) => strcmp($a['due_date'], $b['due_date']));
$this->jsonSuccess($deadlines);
}
/**
* GET /api/dashboard/recent-activity
*/
public function recentActivity(): void
{
$this->requireOrgAccess();
$activities = Database::fetchAll(
'SELECT al.*, u.full_name
FROM audit_logs al
LEFT JOIN users u ON u.id = al.user_id
WHERE al.organization_id = ?
ORDER BY al.created_at DESC
LIMIT 20',
[$this->getCurrentOrgId()]
);
$this->jsonSuccess($activities);
}
/**
* GET /api/dashboard/risk-heatmap
*/
public function riskHeatmap(): void
{
$this->requireOrgAccess();
$risks = Database::fetchAll(
'SELECT id, title, category, likelihood, impact, inherent_risk_score, status
FROM risks
WHERE organization_id = ? AND status != "closed"
ORDER BY inherent_risk_score DESC',
[$this->getCurrentOrgId()]
);
// Costruisci matrice 5x5
$matrix = [];
for ($l = 1; $l <= 5; $l++) {
for ($i = 1; $i <= 5; $i++) {
$matrix["{$l}_{$i}"] = [];
}
}
foreach ($risks as $risk) {
if ($risk['likelihood'] && $risk['impact']) {
$key = "{$risk['likelihood']}_{$risk['impact']}";
$matrix[$key][] = [
'id' => $risk['id'],
'title' => $risk['title'],
'score' => $risk['inherent_risk_score'],
];
}
}
$this->jsonSuccess([
'risks' => $risks,
'matrix' => $matrix,
]);
}
}
@@ -0,0 +1,325 @@
<?php
/**
* NIS2 Agile - Incident Controller
*
* Gestione incidenti con workflow NIS2 Art. 23 (24h/72h/30d).
*/
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/AIService.php';
class IncidentController extends BaseController
{
/**
* GET /api/incidents/list
*/
public function list(): void
{
$this->requireOrgAccess();
$pagination = $this->getPagination();
$where = 'organization_id = ?';
$params = [$this->getCurrentOrgId()];
if ($this->hasParam('status')) {
$where .= ' AND status = ?';
$params[] = $this->getParam('status');
}
if ($this->hasParam('severity')) {
$where .= ' AND severity = ?';
$params[] = $this->getParam('severity');
}
$total = Database::count('incidents', $where, $params);
$incidents = Database::fetchAll(
"SELECT i.*, u1.full_name as reported_by_name, u2.full_name as assigned_to_name
FROM incidents i
LEFT JOIN users u1 ON u1.id = i.reported_by
LEFT JOIN users u2 ON u2.id = i.assigned_to
WHERE i.{$where}
ORDER BY i.detected_at DESC
LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}",
$params
);
$this->jsonPaginated($incidents, $total, $pagination['page'], $pagination['per_page']);
}
/**
* POST /api/incidents/create
*/
public function create(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'employee']);
$this->validateRequired(['title', 'classification', 'severity', 'detected_at']);
$detectedAt = $this->getParam('detected_at');
$isSignificant = (bool) $this->getParam('is_significant', false);
$data = [
'organization_id' => $this->getCurrentOrgId(),
'incident_code' => $this->generateCode('INC'),
'title' => trim($this->getParam('title')),
'description' => $this->getParam('description'),
'classification' => $this->getParam('classification'),
'severity' => $this->getParam('severity'),
'is_significant' => $isSignificant ? 1 : 0,
'detected_at' => $detectedAt,
'affected_services' => $this->getParam('affected_services'),
'affected_users_count' => $this->getParam('affected_users_count'),
'cross_border_impact' => $this->getParam('cross_border_impact', 0),
'malicious_action' => $this->getParam('malicious_action', 0),
'reported_by' => $this->getCurrentUserId(),
'assigned_to' => $this->getParam('assigned_to'),
];
// Calcola scadenze NIS2 Art. 23 se significativo
if ($isSignificant) {
$detectedTime = strtotime($detectedAt);
$data['early_warning_due'] = date('Y-m-d H:i:s', $detectedTime + 24 * 3600); // +24h
$data['notification_due'] = date('Y-m-d H:i:s', $detectedTime + 72 * 3600); // +72h
$data['final_report_due'] = date('Y-m-d H:i:s', $detectedTime + 30 * 86400); // +30 giorni
}
$incidentId = Database::insert('incidents', $data);
// Aggiungi evento timeline
Database::insert('incident_timeline', [
'incident_id' => $incidentId,
'event_type' => 'detection',
'description' => "Incidente rilevato: {$data['title']}",
'created_by' => $this->getCurrentUserId(),
]);
$this->logAudit('incident_created', 'incident', $incidentId, [
'severity' => $data['severity'], 'is_significant' => $isSignificant
]);
$this->jsonSuccess([
'id' => $incidentId,
'incident_code' => $data['incident_code'],
'is_significant' => $isSignificant,
'deadlines' => $isSignificant ? [
'early_warning' => $data['early_warning_due'],
'notification' => $data['notification_due'],
'final_report' => $data['final_report_due'],
] : null,
], 'Incidente registrato', 201);
}
/**
* GET /api/incidents/{id}
*/
public function get(int $id): void
{
$this->requireOrgAccess();
$incident = Database::fetchOne(
'SELECT i.*, u1.full_name as reported_by_name, u2.full_name as assigned_to_name
FROM incidents i
LEFT JOIN users u1 ON u1.id = i.reported_by
LEFT JOIN users u2 ON u2.id = i.assigned_to
WHERE i.id = ? AND i.organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$incident) {
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
}
$incident['timeline'] = Database::fetchAll(
'SELECT it.*, u.full_name as created_by_name
FROM incident_timeline it
LEFT JOIN users u ON u.id = it.created_by
WHERE it.incident_id = ?
ORDER BY it.created_at',
[$id]
);
$this->jsonSuccess($incident);
}
/**
* PUT /api/incidents/{id}
*/
public function update(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$incident = Database::fetchOne(
'SELECT * FROM incidents WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$incident) {
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
}
$updates = [];
$allowedFields = [
'title', 'description', 'classification', 'severity', 'is_significant',
'status', 'affected_services', 'affected_users_count', 'cross_border_impact',
'malicious_action', 'root_cause', 'remediation_actions', 'lessons_learned',
'assigned_to',
];
foreach ($allowedFields as $field) {
if ($this->hasParam($field)) {
$updates[$field] = $this->getParam($field);
}
}
// Se chiuso, registra data
if (isset($updates['status']) && $updates['status'] === 'closed') {
$updates['closed_at'] = date('Y-m-d H:i:s');
}
// Se diventa significativo, calcola scadenze
if (isset($updates['is_significant']) && $updates['is_significant'] && !$incident['is_significant']) {
$detectedTime = strtotime($incident['detected_at']);
$updates['early_warning_due'] = date('Y-m-d H:i:s', $detectedTime + 24 * 3600);
$updates['notification_due'] = date('Y-m-d H:i:s', $detectedTime + 72 * 3600);
$updates['final_report_due'] = date('Y-m-d H:i:s', $detectedTime + 30 * 86400);
}
if (!empty($updates)) {
Database::update('incidents', $updates, 'id = ?', [$id]);
$this->logAudit('incident_updated', 'incident', $id, $updates);
}
$this->jsonSuccess($updates, 'Incidente aggiornato');
}
/**
* POST /api/incidents/{id}/timeline
*/
public function addTimelineEvent(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager', 'employee']);
$this->validateRequired(['event_type', 'description']);
$incident = Database::fetchOne(
'SELECT id FROM incidents WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$incident) {
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
}
$eventId = Database::insert('incident_timeline', [
'incident_id' => $id,
'event_type' => $this->getParam('event_type'),
'description' => $this->getParam('description'),
'created_by' => $this->getCurrentUserId(),
]);
$this->jsonSuccess(['id' => $eventId], 'Evento aggiunto alla timeline', 201);
}
/**
* POST /api/incidents/{id}/early-warning
* Registra invio early warning (24h) al CSIRT
*/
public function sendEarlyWarning(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
Database::update('incidents', [
'early_warning_sent_at' => date('Y-m-d H:i:s'),
], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
Database::insert('incident_timeline', [
'incident_id' => $id,
'event_type' => 'notification',
'description' => 'Early warning (24h) inviato al CSIRT nazionale (ACN)',
'created_by' => $this->getCurrentUserId(),
]);
$this->logAudit('early_warning_sent', 'incident', $id);
$this->jsonSuccess(null, 'Early warning registrato');
}
/**
* POST /api/incidents/{id}/notification
* Registra invio notifica (72h) al CSIRT
*/
public function sendNotification(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
Database::update('incidents', [
'notification_sent_at' => date('Y-m-d H:i:s'),
], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
Database::insert('incident_timeline', [
'incident_id' => $id,
'event_type' => 'notification',
'description' => 'Notifica incidente (72h) inviata al CSIRT nazionale (ACN)',
'created_by' => $this->getCurrentUserId(),
]);
$this->logAudit('notification_sent', 'incident', $id);
$this->jsonSuccess(null, 'Notifica CSIRT registrata');
}
/**
* POST /api/incidents/{id}/final-report
* Registra invio report finale (30 giorni)
*/
public function sendFinalReport(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
Database::update('incidents', [
'final_report_sent_at' => date('Y-m-d H:i:s'),
], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
Database::insert('incident_timeline', [
'incident_id' => $id,
'event_type' => 'notification',
'description' => 'Report finale (30 giorni) inviato al CSIRT nazionale (ACN)',
'created_by' => $this->getCurrentUserId(),
]);
$this->logAudit('final_report_sent', 'incident', $id);
$this->jsonSuccess(null, 'Report finale registrato');
}
/**
* POST /api/incidents/{id}/ai-classify
*/
public function aiClassify(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$incident = Database::fetchOne(
'SELECT * FROM incidents WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$incident) {
$this->jsonError('Incidente non trovato', 404, 'INCIDENT_NOT_FOUND');
}
$org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
try {
$aiService = new AIService();
$classification = $aiService->classifyIncident($incident['title'], $incident['description'] ?? '', $org);
$aiService->logInteraction(
$this->getCurrentOrgId(),
$this->getCurrentUserId(),
'incident_classification',
"Classify incident #{$id}: {$incident['title']}",
substr(json_encode($classification), 0, 500)
);
$this->jsonSuccess($classification, 'Classificazione AI completata');
} catch (Throwable $e) {
$this->jsonError('Errore AI: ' . $e->getMessage(), 500, 'AI_ERROR');
}
}
}
@@ -0,0 +1,395 @@
<?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',
]);
}
}
}
@@ -0,0 +1,170 @@
<?php
/**
* NIS2 Agile - Policy Controller
*
* Gestione policy e procedure di sicurezza, con AI generation.
*/
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/AIService.php';
class PolicyController extends BaseController
{
public function list(): void
{
$this->requireOrgAccess();
$where = 'organization_id = ?';
$params = [$this->getCurrentOrgId()];
if ($this->hasParam('status')) {
$where .= ' AND status = ?';
$params[] = $this->getParam('status');
}
if ($this->hasParam('category')) {
$where .= ' AND category = ?';
$params[] = $this->getParam('category');
}
$policies = Database::fetchAll(
"SELECT p.*, u.full_name as approved_by_name
FROM policies p
LEFT JOIN users u ON u.id = p.approved_by
WHERE p.{$where}
ORDER BY p.category, p.title",
$params
);
$this->jsonSuccess($policies);
}
public function create(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$this->validateRequired(['title', 'category']);
$policyId = Database::insert('policies', [
'organization_id' => $this->getCurrentOrgId(),
'title' => trim($this->getParam('title')),
'category' => $this->getParam('category'),
'nis2_article' => $this->getParam('nis2_article'),
'content' => $this->getParam('content'),
'next_review_date' => $this->getParam('next_review_date'),
'ai_generated' => $this->getParam('ai_generated', 0),
]);
$this->logAudit('policy_created', 'policy', $policyId);
$this->jsonSuccess(['id' => $policyId], 'Policy creata', 201);
}
public function get(int $id): void
{
$this->requireOrgAccess();
$policy = Database::fetchOne(
'SELECT p.*, u.full_name as approved_by_name
FROM policies p
LEFT JOIN users u ON u.id = p.approved_by
WHERE p.id = ? AND p.organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$policy) {
$this->jsonError('Policy non trovata', 404, 'POLICY_NOT_FOUND');
}
$this->jsonSuccess($policy);
}
public function update(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$updates = [];
foreach (['title', 'content', 'category', 'nis2_article', 'status', 'version', 'next_review_date'] as $field) {
if ($this->hasParam($field)) {
$updates[$field] = $this->getParam($field);
}
}
if (!empty($updates)) {
Database::update('policies', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
$this->logAudit('policy_updated', 'policy', $id, $updates);
}
$this->jsonSuccess($updates, 'Policy aggiornata');
}
public function delete(int $id): void
{
$this->requireOrgRole(['org_admin']);
$deleted = Database::delete('policies', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
if ($deleted === 0) {
$this->jsonError('Policy non trovata', 404, 'POLICY_NOT_FOUND');
}
$this->logAudit('policy_deleted', 'policy', $id);
$this->jsonSuccess(null, 'Policy eliminata');
}
public function approve(int $id): void
{
$this->requireOrgRole(['org_admin']);
Database::update('policies', [
'status' => 'approved',
'approved_by' => $this->getCurrentUserId(),
'approved_at' => date('Y-m-d H:i:s'),
], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
$this->logAudit('policy_approved', 'policy', $id);
$this->jsonSuccess(null, 'Policy approvata');
}
public function aiGeneratePolicy(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$this->validateRequired(['category']);
$category = $this->getParam('category');
$org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
try {
$aiService = new AIService();
$generated = $aiService->generatePolicy($category, $org);
$aiService->logInteraction(
$this->getCurrentOrgId(),
$this->getCurrentUserId(),
'policy_draft',
"Generate {$category} policy",
substr($generated['title'] ?? '', 0, 500)
);
$this->jsonSuccess($generated, 'Policy generata dall\'AI');
} catch (Throwable $e) {
$this->jsonError('Errore AI: ' . $e->getMessage(), 500, 'AI_ERROR');
}
}
public function getTemplates(): void
{
$this->requireOrgAccess();
$templates = [
['category' => 'information_security', 'title' => 'Politica di Sicurezza delle Informazioni', 'nis2_article' => '21.2.a'],
['category' => 'access_control', 'title' => 'Politica di Controllo degli Accessi', 'nis2_article' => '21.2.i'],
['category' => 'incident_response', 'title' => 'Piano di Risposta agli Incidenti', 'nis2_article' => '21.2.b'],
['category' => 'business_continuity', 'title' => 'Piano di Continuità Operativa', 'nis2_article' => '21.2.c'],
['category' => 'supply_chain', 'title' => 'Politica di Sicurezza della Supply Chain', 'nis2_article' => '21.2.d'],
['category' => 'encryption', 'title' => 'Politica sulla Crittografia', 'nis2_article' => '21.2.h'],
['category' => 'hr_security', 'title' => 'Politica di Sicurezza delle Risorse Umane', 'nis2_article' => '21.2.i'],
['category' => 'asset_management', 'title' => 'Politica di Gestione degli Asset', 'nis2_article' => '21.2.i'],
['category' => 'network_security', 'title' => 'Politica di Sicurezza della Rete', 'nis2_article' => '21.2.e'],
['category' => 'vulnerability_management', 'title' => 'Politica di Gestione delle Vulnerabilità', 'nis2_article' => '21.2.e'],
];
$this->jsonSuccess($templates);
}
}
+302
View File
@@ -0,0 +1,302 @@
<?php
/**
* NIS2 Agile - Risk Controller
*
* Gestione rischi cyber, matrice rischi, trattamenti.
*/
require_once __DIR__ . '/BaseController.php';
require_once APP_PATH . '/services/AIService.php';
class RiskController extends BaseController
{
/**
* GET /api/risks/list
*/
public function list(): void
{
$this->requireOrgAccess();
$pagination = $this->getPagination();
$where = 'organization_id = ?';
$params = [$this->getCurrentOrgId()];
// Filtri opzionali
if ($this->hasParam('status')) {
$where .= ' AND status = ?';
$params[] = $this->getParam('status');
}
if ($this->hasParam('category')) {
$where .= ' AND category = ?';
$params[] = $this->getParam('category');
}
$total = Database::count('risks', $where, $params);
$risks = Database::fetchAll(
"SELECT r.*, u.full_name as owner_name
FROM risks r
LEFT JOIN users u ON u.id = r.owner_user_id
WHERE r.{$where}
ORDER BY r.inherent_risk_score DESC
LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}",
$params
);
$this->jsonPaginated($risks, $total, $pagination['page'], $pagination['per_page']);
}
/**
* POST /api/risks/create
*/
public function create(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$this->validateRequired(['title', 'category']);
$likelihood = (int) $this->getParam('likelihood', 0);
$impact = (int) $this->getParam('impact', 0);
$riskId = Database::insert('risks', [
'organization_id' => $this->getCurrentOrgId(),
'risk_code' => $this->generateCode('RSK'),
'title' => trim($this->getParam('title')),
'description' => $this->getParam('description'),
'category' => $this->getParam('category'),
'threat_source' => $this->getParam('threat_source'),
'vulnerability' => $this->getParam('vulnerability'),
'affected_assets' => $this->getParam('affected_assets') ? json_encode($this->getParam('affected_assets')) : null,
'likelihood' => $likelihood,
'impact' => $impact,
'inherent_risk_score' => $likelihood * $impact,
'treatment' => $this->getParam('treatment', 'mitigate'),
'owner_user_id' => $this->getParam('owner_user_id'),
'review_date' => $this->getParam('review_date'),
'nis2_article' => $this->getParam('nis2_article'),
]);
$this->logAudit('risk_created', 'risk', $riskId);
$this->jsonSuccess(['id' => $riskId], 'Rischio registrato', 201);
}
/**
* GET /api/risks/{id}
*/
public function get(int $id): void
{
$this->requireOrgAccess();
$risk = Database::fetchOne(
'SELECT r.*, u.full_name as owner_name
FROM risks r
LEFT JOIN users u ON u.id = r.owner_user_id
WHERE r.id = ? AND r.organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$risk) {
$this->jsonError('Rischio non trovato', 404, 'RISK_NOT_FOUND');
}
// Carica trattamenti
$risk['treatments'] = Database::fetchAll(
'SELECT rt.*, u.full_name as responsible_name
FROM risk_treatments rt
LEFT JOIN users u ON u.id = rt.responsible_user_id
WHERE rt.risk_id = ?
ORDER BY rt.due_date',
[$id]
);
$this->jsonSuccess($risk);
}
/**
* PUT /api/risks/{id}
*/
public function update(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$risk = Database::fetchOne(
'SELECT * FROM risks WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$risk) {
$this->jsonError('Rischio non trovato', 404, 'RISK_NOT_FOUND');
}
$updates = [];
$allowedFields = [
'title', 'description', 'category', 'threat_source', 'vulnerability',
'likelihood', 'impact', 'treatment', 'residual_likelihood', 'residual_impact',
'status', 'owner_user_id', 'review_date', 'nis2_article',
];
foreach ($allowedFields as $field) {
if ($this->hasParam($field)) {
$updates[$field] = $this->getParam($field);
}
}
// Ricalcola score se likelihood o impact cambiano
$likelihood = (int) ($updates['likelihood'] ?? $risk['likelihood']);
$impact = (int) ($updates['impact'] ?? $risk['impact']);
$updates['inherent_risk_score'] = $likelihood * $impact;
if (isset($updates['residual_likelihood']) || isset($updates['residual_impact'])) {
$resLikelihood = (int) ($updates['residual_likelihood'] ?? $risk['residual_likelihood']);
$resImpact = (int) ($updates['residual_impact'] ?? $risk['residual_impact']);
$updates['residual_risk_score'] = $resLikelihood * $resImpact;
}
if (!empty($updates)) {
Database::update('risks', $updates, 'id = ?', [$id]);
$this->logAudit('risk_updated', 'risk', $id, $updates);
}
$this->jsonSuccess($updates, 'Rischio aggiornato');
}
/**
* DELETE /api/risks/{id}
*/
public function delete(int $id): void
{
$this->requireOrgRole(['org_admin']);
$deleted = Database::delete('risks', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
if ($deleted === 0) {
$this->jsonError('Rischio non trovato', 404, 'RISK_NOT_FOUND');
}
$this->logAudit('risk_deleted', 'risk', $id);
$this->jsonSuccess(null, 'Rischio eliminato');
}
/**
* POST /api/risks/{id}/treatments
*/
public function addTreatment(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$this->validateRequired(['action_description']);
// Verifica che il rischio esista per l'organizzazione
$risk = Database::fetchOne(
'SELECT id FROM risks WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$risk) {
$this->jsonError('Rischio non trovato', 404, 'RISK_NOT_FOUND');
}
$treatmentId = Database::insert('risk_treatments', [
'risk_id' => $id,
'action_description' => $this->getParam('action_description'),
'responsible_user_id' => $this->getParam('responsible_user_id'),
'due_date' => $this->getParam('due_date'),
'status' => 'planned',
'notes' => $this->getParam('notes'),
]);
$this->logAudit('treatment_added', 'risk', $id, ['treatment_id' => $treatmentId]);
$this->jsonSuccess(['id' => $treatmentId], 'Trattamento aggiunto', 201);
}
/**
* PUT /api/risks/treatments/{id}
*/
public function updateTreatment(int $treatmentId): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$treatment = Database::fetchOne(
'SELECT rt.* FROM risk_treatments rt
JOIN risks r ON r.id = rt.risk_id
WHERE rt.id = ? AND r.organization_id = ?',
[$treatmentId, $this->getCurrentOrgId()]
);
if (!$treatment) {
$this->jsonError('Trattamento non trovato', 404, 'TREATMENT_NOT_FOUND');
}
$updates = [];
foreach (['action_description', 'responsible_user_id', 'due_date', 'status', 'completion_date', 'notes'] as $field) {
if ($this->hasParam($field)) {
$updates[$field] = $this->getParam($field);
}
}
if (!empty($updates)) {
Database::update('risk_treatments', $updates, 'id = ?', [$treatmentId]);
$this->logAudit('treatment_updated', 'risk_treatment', $treatmentId, $updates);
}
$this->jsonSuccess($updates, 'Trattamento aggiornato');
}
/**
* GET /api/risks/matrix
*/
public function getRiskMatrix(): void
{
$this->requireOrgAccess();
$risks = Database::fetchAll(
'SELECT id, title, category, likelihood, impact, inherent_risk_score,
residual_likelihood, residual_impact, residual_risk_score, status
FROM risks
WHERE organization_id = ? AND status != "closed"',
[$this->getCurrentOrgId()]
);
$this->jsonSuccess([
'risks' => $risks,
'summary' => [
'total' => count($risks),
'critical' => count(array_filter($risks, fn($r) => ($r['inherent_risk_score'] ?? 0) >= 20)),
'high' => count(array_filter($risks, fn($r) => ($r['inherent_risk_score'] ?? 0) >= 12 && ($r['inherent_risk_score'] ?? 0) < 20)),
'medium' => count(array_filter($risks, fn($r) => ($r['inherent_risk_score'] ?? 0) >= 6 && ($r['inherent_risk_score'] ?? 0) < 12)),
'low' => count(array_filter($risks, fn($r) => ($r['inherent_risk_score'] ?? 0) < 6)),
],
]);
}
/**
* POST /api/risks/ai-suggest
*/
public function aiSuggestRisks(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$org = Database::fetchOne('SELECT * FROM organizations WHERE id = ?', [$this->getCurrentOrgId()]);
$assets = Database::fetchAll(
'SELECT name, asset_type, criticality FROM assets WHERE organization_id = ? AND status = "active"',
[$this->getCurrentOrgId()]
);
try {
$aiService = new AIService();
$suggestions = $aiService->suggestRisks($org, $assets);
$aiService->logInteraction(
$this->getCurrentOrgId(),
$this->getCurrentUserId(),
'risk_suggestion',
'Risk suggestions for ' . $org['sector'],
substr(json_encode($suggestions), 0, 500)
);
$this->jsonSuccess($suggestions, 'Suggerimenti rischi generati');
} catch (Throwable $e) {
$this->jsonError('Errore AI: ' . $e->getMessage(), 500, 'AI_ERROR');
}
}
}
@@ -0,0 +1,168 @@
<?php
/**
* NIS2 Agile - Supply Chain Controller
*
* Gestione fornitori, assessment cybersecurity, risk scoring.
*/
require_once __DIR__ . '/BaseController.php';
class SupplyChainController extends BaseController
{
public function list(): void
{
$this->requireOrgAccess();
$suppliers = Database::fetchAll(
'SELECT * FROM suppliers WHERE organization_id = ? ORDER BY criticality DESC, name',
[$this->getCurrentOrgId()]
);
$this->jsonSuccess($suppliers);
}
public function create(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$this->validateRequired(['name', 'service_type']);
$supplierId = Database::insert('suppliers', [
'organization_id' => $this->getCurrentOrgId(),
'name' => trim($this->getParam('name')),
'vat_number' => $this->getParam('vat_number'),
'contact_email' => $this->getParam('contact_email'),
'contact_name' => $this->getParam('contact_name'),
'service_type' => $this->getParam('service_type'),
'service_description' => $this->getParam('service_description'),
'criticality' => $this->getParam('criticality', 'medium'),
'contract_start_date' => $this->getParam('contract_start_date'),
'contract_expiry_date' => $this->getParam('contract_expiry_date'),
'notes' => $this->getParam('notes'),
]);
$this->logAudit('supplier_created', 'supplier', $supplierId);
$this->jsonSuccess(['id' => $supplierId], 'Fornitore aggiunto', 201);
}
public function get(int $id): void
{
$this->requireOrgAccess();
$supplier = Database::fetchOne(
'SELECT * FROM suppliers WHERE id = ? AND organization_id = ?',
[$id, $this->getCurrentOrgId()]
);
if (!$supplier) {
$this->jsonError('Fornitore non trovato', 404, 'SUPPLIER_NOT_FOUND');
}
$this->jsonSuccess($supplier);
}
public function update(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$updates = [];
$fields = ['name', 'vat_number', 'contact_email', 'contact_name', 'service_type',
'service_description', 'criticality', 'contract_start_date', 'contract_expiry_date',
'security_requirements_met', 'notes', 'status'];
foreach ($fields as $field) {
if ($this->hasParam($field)) {
$updates[$field] = $this->getParam($field);
}
}
if (!empty($updates)) {
Database::update('suppliers', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
$this->logAudit('supplier_updated', 'supplier', $id, $updates);
}
$this->jsonSuccess($updates, 'Fornitore aggiornato');
}
public function delete(int $id): void
{
$this->requireOrgRole(['org_admin']);
$deleted = Database::delete('suppliers', 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
if ($deleted === 0) {
$this->jsonError('Fornitore non trovato', 404, 'SUPPLIER_NOT_FOUND');
}
$this->logAudit('supplier_deleted', 'supplier', $id);
$this->jsonSuccess(null, 'Fornitore eliminato');
}
public function assessSupplier(int $id): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$this->validateRequired(['assessment_responses']);
$responses = $this->getParam('assessment_responses');
$riskScore = $this->calculateSupplierRiskScore($responses);
Database::update('suppliers', [
'assessment_responses' => json_encode($responses),
'risk_score' => $riskScore,
'last_assessment_date' => date('Y-m-d'),
'next_assessment_date' => date('Y-m-d', strtotime('+6 months')),
'security_requirements_met' => $riskScore >= 70 ? 1 : 0,
], 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
$this->logAudit('supplier_assessed', 'supplier', $id, ['risk_score' => $riskScore]);
$this->jsonSuccess(['risk_score' => $riskScore], 'Assessment fornitore completato');
}
public function riskOverview(): void
{
$this->requireOrgAccess();
$overview = Database::fetchAll(
'SELECT criticality, status,
COUNT(*) as count,
AVG(risk_score) as avg_risk_score,
SUM(CASE WHEN security_requirements_met = 0 THEN 1 ELSE 0 END) as non_compliant
FROM suppliers
WHERE organization_id = ?
GROUP BY criticality, status',
[$this->getCurrentOrgId()]
);
$expiring = Database::fetchAll(
'SELECT id, name, contract_expiry_date, criticality
FROM suppliers
WHERE organization_id = ? AND contract_expiry_date IS NOT NULL
AND contract_expiry_date <= DATE_ADD(NOW(), INTERVAL 90 DAY)
AND status = "active"
ORDER BY contract_expiry_date',
[$this->getCurrentOrgId()]
);
$this->jsonSuccess([
'overview' => $overview,
'expiring_contracts' => $expiring,
]);
}
private function calculateSupplierRiskScore(array $responses): int
{
if (empty($responses)) return 0;
$totalScore = 0;
$totalWeight = 0;
foreach ($responses as $resp) {
$weight = $resp['weight'] ?? 1;
$value = match ($resp['value'] ?? '') {
'yes', 'implemented' => 100,
'partial' => 50,
default => 0,
};
$totalScore += $value * $weight;
$totalWeight += 100 * $weight;
}
return $totalWeight > 0 ? (int) round($totalScore / $totalWeight * 100) : 0;
}
}
@@ -0,0 +1,150 @@
<?php
/**
* NIS2 Agile - Training Controller
*
* Gestione formazione cybersecurity (Art. 20 NIS2).
*/
require_once __DIR__ . '/BaseController.php';
class TrainingController extends BaseController
{
public function listCourses(): void
{
$this->requireOrgAccess();
$courses = Database::fetchAll(
'SELECT * FROM training_courses
WHERE (organization_id = ? OR organization_id IS NULL) AND is_active = 1
ORDER BY is_mandatory DESC, title',
[$this->getCurrentOrgId()]
);
$this->jsonSuccess($courses);
}
public function createCourse(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$this->validateRequired(['title']);
$courseId = Database::insert('training_courses', [
'organization_id' => $this->getCurrentOrgId(),
'title' => trim($this->getParam('title')),
'description' => $this->getParam('description'),
'target_role' => $this->getParam('target_role', 'all'),
'nis2_article' => $this->getParam('nis2_article'),
'is_mandatory' => $this->getParam('is_mandatory', 0),
'duration_minutes' => $this->getParam('duration_minutes'),
'content' => $this->getParam('content') ? json_encode($this->getParam('content')) : null,
'quiz' => $this->getParam('quiz') ? json_encode($this->getParam('quiz')) : null,
'passing_score' => $this->getParam('passing_score', 70),
]);
$this->logAudit('course_created', 'training_course', $courseId);
$this->jsonSuccess(['id' => $courseId], 'Corso creato', 201);
}
public function myAssignments(): void
{
$this->requireAuth();
$assignments = Database::fetchAll(
'SELECT ta.*, tc.title, tc.description, tc.duration_minutes, tc.is_mandatory
FROM training_assignments ta
JOIN training_courses tc ON tc.id = ta.course_id
WHERE ta.user_id = ?
ORDER BY ta.status, ta.due_date',
[$this->getCurrentUserId()]
);
$this->jsonSuccess($assignments);
}
public function assignCourse(): void
{
$this->requireOrgRole(['org_admin', 'compliance_manager']);
$this->validateRequired(['course_id', 'user_ids']);
$courseId = (int) $this->getParam('course_id');
$userIds = $this->getParam('user_ids');
$dueDate = $this->getParam('due_date');
$assigned = 0;
foreach ($userIds as $userId) {
$existing = Database::fetchOne(
'SELECT id FROM training_assignments WHERE course_id = ? AND user_id = ?',
[$courseId, $userId]
);
if ($existing) continue;
Database::insert('training_assignments', [
'course_id' => $courseId,
'user_id' => (int) $userId,
'organization_id' => $this->getCurrentOrgId(),
'due_date' => $dueDate,
]);
$assigned++;
}
$this->logAudit('training_assigned', 'training_course', $courseId, ['assigned_count' => $assigned]);
$this->jsonSuccess(['assigned' => $assigned], "{$assigned} assegnazioni create");
}
public function updateAssignment(int $id): void
{
$this->requireAuth();
$updates = [];
foreach (['status', 'quiz_score'] as $field) {
if ($this->hasParam($field)) {
$updates[$field] = $this->getParam($field);
}
}
if (isset($updates['status']) && $updates['status'] === 'in_progress' && !isset($updates['started_at'])) {
$updates['started_at'] = date('Y-m-d H:i:s');
}
if (isset($updates['status']) && $updates['status'] === 'completed') {
$updates['completed_at'] = date('Y-m-d H:i:s');
}
if (!empty($updates)) {
Database::update('training_assignments', $updates, 'id = ? AND user_id = ?', [$id, $this->getCurrentUserId()]);
}
$this->jsonSuccess($updates, 'Assegnazione aggiornata');
}
public function complianceStatus(): void
{
$this->requireOrgAccess();
$members = Database::fetchAll(
'SELECT u.id, u.full_name, u.email, uo.role
FROM user_organizations uo
JOIN users u ON u.id = uo.user_id
WHERE uo.organization_id = ? AND u.is_active = 1',
[$this->getCurrentOrgId()]
);
foreach ($members as &$member) {
$member['assignments'] = Database::fetchAll(
'SELECT ta.status, ta.completed_at, ta.quiz_score, tc.title, tc.is_mandatory
FROM training_assignments ta
JOIN training_courses tc ON tc.id = ta.course_id
WHERE ta.user_id = ? AND ta.organization_id = ?',
[$member['id'], $this->getCurrentOrgId()]
);
$mandatory = array_filter($member['assignments'], fn($a) => $a['is_mandatory']);
$completedMandatory = array_filter($mandatory, fn($a) => $a['status'] === 'completed');
$member['mandatory_compliance'] = count($mandatory) > 0
? round(count($completedMandatory) / count($mandatory) * 100)
: 100;
}
$this->jsonSuccess($members);
}
}