[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:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user