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>
449 lines
15 KiB
PHP
449 lines
15 KiB
PHP
<?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,
|
|
];
|
|
}
|
|
}
|