nis2-agile/application/controllers/RiskController.php
Cristiano Benassati ae78a2f7f4 [CORE] Initial project scaffold - NIS2 Agile Compliance Platform
Complete MVP implementation including:
- PHP 8.4 backend with Front Controller pattern (80+ API endpoints)
- Multi-tenant architecture with organization_id isolation
- JWT authentication (HS256, 2h access + 7d refresh tokens)
- 14 controllers: Auth, Organization, Assessment, Dashboard, Risk,
  Incident, Policy, SupplyChain, Training, Asset, Audit, Admin
- AI Service integration (Anthropic Claude API) for gap analysis,
  risk suggestions, policy generation, incident classification
- NIS2 gap analysis questionnaire (~80 questions, 10 categories)
- MySQL schema (20 tables) with NIS2 Art. 21 compliance controls
- NIS2 Art. 23 incident reporting workflow (24h/72h/30d)
- Frontend: login, register, dashboard, assessment wizard, org setup
- Docker configuration (PHP-FPM + Nginx + MySQL)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 17:50:18 +01:00

303 lines
10 KiB
PHP

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