[FEAT] Evidence Automation + Continuous Control Monitoring (P1)
Colma il gap competitivo vs Vanta/Drata (compliance automation):
- ServicesController::ingestEvidence -> POST /services/evidence-ingest (scope ingest:evidence)
raccolta evidenze automatiche dai connettori (M365/Google/AWS/Azure/IdP/EDR/SIEM), batch fino a 200, upsert idempotente su external_ref
- recomputeControlMonitoring: ricalcolo monitoring_status (healthy/warning/stale/failing) per freschezza+esito
- controlsMonitoring -> GET /services/controls-monitoring (scope read:compliance): coverage + summary semafori
- Migrazione 024: tabella control_evidence_auto + compliance_controls.{monitoring_status,last_checked_at,freshness_days}
- Route POST:evidenceIngest, GET:controlsMonitoring
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
21909994c2
commit
307993fbad
@@ -2401,4 +2401,214 @@ class ServicesController extends BaseController
|
||||
];
|
||||
return $map[$s] ?? null;
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
// EVIDENCE AUTOMATION + CONTINUOUS CONTROL MONITORING (P1)
|
||||
// Colma il gap vs Vanta/Drata: i connettori (M365, AWS, EDR, ...) inviano
|
||||
// evidenze automatiche per i controlli; lo stato/freschezza è ricalcolato.
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* POST /api/services/evidence-ingest
|
||||
* Auth: X-API-Key con scope ingest:evidence
|
||||
* Body JSON (singola evidenza o batch via "evidences":[...]):
|
||||
* {
|
||||
* "control_code": "ART21_2_A", // controllo target (obbligatorio)
|
||||
* "source": "m365|google|aws|azure|idp|edr|siem|api",
|
||||
* "source_system": "Microsoft 365",
|
||||
* "status": "pass|fail|warning|not_applicable",
|
||||
* "summary": "MFA attiva per 142/142 utenti admin",
|
||||
* "external_ref": "m365-mfa-check-2026-05-30", // idempotenza
|
||||
* "payload": { ... }, // dettaglio raw (opzionale)
|
||||
* "collected_at": "2026-05-30T06:00:00Z",
|
||||
* "valid_for_days": 7 // default freshness_days del controllo
|
||||
* }
|
||||
*/
|
||||
public function ingestEvidence(): void
|
||||
{
|
||||
$this->requireApiKey('ingest:evidence');
|
||||
$orgId = $this->currentOrgId;
|
||||
$body = $this->getJsonBody();
|
||||
|
||||
// Supporta batch
|
||||
$items = isset($body['evidences']) && is_array($body['evidences']) ? $body['evidences'] : [$body];
|
||||
if (count($items) > 200) {
|
||||
$this->jsonError('Massimo 200 evidenze per richiesta', 422, 'BATCH_TOO_LARGE');
|
||||
}
|
||||
|
||||
$validSource = ['m365', 'google', 'aws', 'azure', 'idp', 'edr', 'siem', 'api', 'manual'];
|
||||
$validStatus = ['pass', 'fail', 'warning', 'not_applicable'];
|
||||
|
||||
$results = [];
|
||||
$touchedControls = [];
|
||||
$accepted = 0;
|
||||
|
||||
foreach ($items as $i => $ev) {
|
||||
if (!is_array($ev)) { $results[] = ['index' => $i, 'ok' => false, 'error' => 'not_an_object']; continue; }
|
||||
$code = trim((string) ($ev['control_code'] ?? ''));
|
||||
if ($code === '') { $results[] = ['index' => $i, 'ok' => false, 'error' => 'control_code mancante']; continue; }
|
||||
|
||||
$source = strtolower((string) ($ev['source'] ?? 'api'));
|
||||
if (!in_array($source, $validSource, true)) $source = 'api';
|
||||
$status = strtolower((string) ($ev['status'] ?? 'pass'));
|
||||
if (!in_array($status, $validStatus, true)) $status = 'pass';
|
||||
|
||||
$collectedTs = strtotime((string) ($ev['collected_at'] ?? 'now')) ?: time();
|
||||
$collectedAt = date('Y-m-d H:i:s', $collectedTs);
|
||||
|
||||
// freshness: dal body o dal controllo (default 30)
|
||||
$ctl = Database::fetchOne(
|
||||
'SELECT id, freshness_days FROM compliance_controls WHERE organization_id = ? AND control_code = ?',
|
||||
[$orgId, $code]
|
||||
);
|
||||
$validDays = isset($ev['valid_for_days']) ? max(1, (int) $ev['valid_for_days'])
|
||||
: (int) ($ctl['freshness_days'] ?? 30);
|
||||
$validUntil = date('Y-m-d H:i:s', $collectedTs + $validDays * 86400);
|
||||
|
||||
$externalRef = isset($ev['external_ref']) ? substr(trim((string) $ev['external_ref']), 0, 190) : null;
|
||||
$payload = isset($ev['payload']) ? json_encode($ev['payload'], JSON_UNESCAPED_UNICODE) : null;
|
||||
$summary = isset($ev['summary']) ? substr(trim((string) $ev['summary']), 0, 255) : null;
|
||||
$sourceSystem = isset($ev['source_system']) ? substr(trim((string) $ev['source_system']), 0, 120) : null;
|
||||
|
||||
$data = [
|
||||
'organization_id' => $orgId,
|
||||
'control_code' => $code,
|
||||
'source' => $source,
|
||||
'source_system' => $sourceSystem,
|
||||
'status' => $status,
|
||||
'summary' => $summary,
|
||||
'payload' => $payload,
|
||||
'external_ref' => $externalRef,
|
||||
'collected_at' => $collectedAt,
|
||||
'valid_until' => $validUntil,
|
||||
];
|
||||
|
||||
// Upsert idempotente su (org, control_code, external_ref)
|
||||
try {
|
||||
if ($externalRef !== null) {
|
||||
$existing = Database::fetchOne(
|
||||
'SELECT id FROM control_evidence_auto WHERE organization_id = ? AND control_code = ? AND external_ref = ?',
|
||||
[$orgId, $code, $externalRef]
|
||||
);
|
||||
if ($existing) {
|
||||
Database::query(
|
||||
'UPDATE control_evidence_auto SET source=?, source_system=?, status=?, summary=?, payload=?, collected_at=?, valid_until=? WHERE id=?',
|
||||
[$source, $sourceSystem, $status, $summary, $payload, $collectedAt, $validUntil, $existing['id']]
|
||||
);
|
||||
$eviId = (int) $existing['id'];
|
||||
} else {
|
||||
$eviId = Database::insert('control_evidence_auto', $data);
|
||||
}
|
||||
} else {
|
||||
$eviId = Database::insert('control_evidence_auto', $data);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
$results[] = ['index' => $i, 'ok' => false, 'error' => 'db_error'];
|
||||
error_log('[EVIDENCE] ' . $e->getMessage());
|
||||
continue;
|
||||
}
|
||||
|
||||
$accepted++;
|
||||
$results[] = ['index' => $i, 'ok' => true, 'id' => $eviId, 'control_code' => $code, 'control_exists' => (bool) $ctl];
|
||||
if ($ctl) $touchedControls[$code] = true;
|
||||
}
|
||||
|
||||
// Ricalcola lo stato di monitoraggio dei controlli toccati
|
||||
$monitoring = [];
|
||||
foreach (array_keys($touchedControls) as $code) {
|
||||
$monitoring[$code] = $this->recomputeControlMonitoring($orgId, $code);
|
||||
}
|
||||
|
||||
$this->jsonSuccess([
|
||||
'accepted' => $accepted,
|
||||
'total' => count($items),
|
||||
'results' => $results,
|
||||
'monitoring_update' => $monitoring,
|
||||
], 'Evidenze elaborate', 201);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ricalcola monitoring_status + last_checked_at di un controllo in base
|
||||
* all'ultima evidenza automatica. Restituisce lo stato calcolato.
|
||||
*/
|
||||
private function recomputeControlMonitoring(int $orgId, string $code): string
|
||||
{
|
||||
$last = Database::fetchOne(
|
||||
'SELECT status, collected_at, valid_until FROM control_evidence_auto
|
||||
WHERE organization_id = ? AND control_code = ?
|
||||
ORDER BY collected_at DESC LIMIT 1',
|
||||
[$orgId, $code]
|
||||
);
|
||||
if (!$last) return 'not_monitored';
|
||||
|
||||
$now = time();
|
||||
$isStale = $last['valid_until'] && strtotime($last['valid_until']) < $now;
|
||||
|
||||
if ($isStale) {
|
||||
$monStatus = 'stale';
|
||||
} elseif ($last['status'] === 'fail') {
|
||||
$monStatus = 'failing';
|
||||
} elseif ($last['status'] === 'warning') {
|
||||
$monStatus = 'warning';
|
||||
} else { // pass / not_applicable
|
||||
$monStatus = 'healthy';
|
||||
}
|
||||
|
||||
Database::query(
|
||||
'UPDATE compliance_controls SET monitoring_status = ?, last_checked_at = ? WHERE organization_id = ? AND control_code = ?',
|
||||
[$monStatus, $last['collected_at'], $orgId, $code]
|
||||
);
|
||||
return $monStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/services/controls-monitoring
|
||||
* Auth: X-API-Key con scope read:compliance
|
||||
* Stato del monitoraggio continuo: controlli con freschezza evidenza
|
||||
* (healthy/warning/stale/failing/not_monitored).
|
||||
*/
|
||||
public function controlsMonitoring(): void
|
||||
{
|
||||
$this->requireApiKey('read:compliance');
|
||||
$orgId = $this->currentOrgId;
|
||||
|
||||
// Auto-stale: controlli la cui ultima evidenza è scaduta vanno marcati stale
|
||||
Database::query(
|
||||
"UPDATE compliance_controls cc
|
||||
LEFT JOIN (
|
||||
SELECT t.control_code, t.valid_until, t.status
|
||||
FROM control_evidence_auto t
|
||||
JOIN (SELECT control_code, MAX(collected_at) mx FROM control_evidence_auto
|
||||
WHERE organization_id = ? GROUP BY control_code) m
|
||||
ON m.control_code = t.control_code AND m.mx = t.collected_at
|
||||
WHERE t.organization_id = ?
|
||||
) last ON last.control_code = cc.control_code
|
||||
SET cc.monitoring_status = 'stale'
|
||||
WHERE cc.organization_id = ?
|
||||
AND cc.monitoring_status NOT IN ('not_monitored')
|
||||
AND last.valid_until IS NOT NULL AND last.valid_until < NOW()",
|
||||
[$orgId, $orgId, $orgId]
|
||||
);
|
||||
|
||||
$rows = Database::fetchAll(
|
||||
'SELECT control_code, title, status, monitoring_status, last_checked_at, freshness_days, implementation_percentage
|
||||
FROM compliance_controls WHERE organization_id = ? ORDER BY control_code',
|
||||
[$orgId]
|
||||
);
|
||||
|
||||
$summary = ['healthy' => 0, 'warning' => 0, 'stale' => 0, 'failing' => 0, 'not_monitored' => 0];
|
||||
foreach ($rows as $r) {
|
||||
$ms = $r['monitoring_status'] ?? 'not_monitored';
|
||||
if (isset($summary[$ms])) $summary[$ms]++;
|
||||
}
|
||||
$monitored = count($rows) - $summary['not_monitored'];
|
||||
|
||||
$this->jsonSuccess([
|
||||
'total_controls' => count($rows),
|
||||
'monitored' => $monitored,
|
||||
'coverage_percent' => count($rows) ? (int) round($monitored * 100 / count($rows)) : 0,
|
||||
'summary' => $summary,
|
||||
'controls' => $rows,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user