Flotta di verifica (14 agenti): sicurezza pulita, fedeltà del dato perfetta (codifica Simon intatta, 0 scostamenti). Corretti i 6 finding confermati: - MAJOR: PolicyController::ensureFrameworkProcedures ora in transazione + SELECT...FOR UPDATE sulla riga org -> no procedure duplicate su fallimento parziale (rollback) o richieste concorrenti. - MINOR: importer idempotente committato application/cli/seed_framework.php (DDL + upsert da nis2_framework_seed.json) -> re-seed riproducibile/versionato (verificato 2 run = 0 duplicati). - MINOR: allineato '92 requisiti' importanti (dato autoritativo Simon) in help.js + nis2_sources.php (era 87, contraddizione interna); coerente '24 requisiti aggiuntivi essenziali' (116-92). - MINOR (UI): troncamento anteprima valutazione sul RAW prima di esc() (no entita HTML spezzate). - MINOR (UI a11y/AGID): modale valutazione con focus iniziale, ripristino focus, focus-trap. Cache-buster ?v=20260623, version 1.18.2, SW nis2-shell-v1.18.2. NB residuo da verificare: data esatta Determinazione 333017/2025 (mese senza giorno) in nis2_sources.php. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
488 lines
21 KiB
PHP
488 lines
21 KiB
PHP
<?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';
|
|
require_once APP_PATH . '/services/WebhookService.php';
|
|
|
|
class PolicyController extends BaseController
|
|
{
|
|
// Epic C / C1 operativo: mappa famiglia NIST -> categoria policies (ENUM)
|
|
// per assegnare una categoria sensata alle procedure di default copiate.
|
|
private const PROC_NIST_CATEGORY = [
|
|
'GV.OC' => 'information_security', 'GV.RM' => 'information_security',
|
|
'GV.RR' => 'information_security', 'GV.PO' => 'information_security',
|
|
'GV.SC' => 'supply_chain',
|
|
'ID.AM' => 'asset_management', 'ID.RA' => 'vulnerability_management',
|
|
'ID.IM' => 'business_continuity',
|
|
'PR.AA' => 'access_control', 'PR.AT' => 'hr_security',
|
|
'PR.DS' => 'encryption', 'PR.PS' => 'information_security',
|
|
'PR.IR' => 'network_security', 'DE.CM' => 'information_security',
|
|
'RS.MA' => 'incident_response', 'RS.CO' => 'incident_response',
|
|
'RC.RP' => 'business_continuity', 'RC.CO' => 'business_continuity',
|
|
];
|
|
|
|
public function list(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$this->ensureFrameworkProcedures();
|
|
|
|
$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);
|
|
}
|
|
|
|
/**
|
|
* Epic C / C1 operativo — copia le 42 procedure di default (cfg_nis2_procedure)
|
|
* nell'elenco procedure dell'org al PRIMO accesso al modulo, come contenuto
|
|
* editabile/cancellabile. Idempotente (flag organizations.framework_procedures_seeded_at).
|
|
* Salta le org demo/sandbox (no inquinamento del dataset dimostrativo). Best-effort:
|
|
* un errore NON blocca la lista (log + return).
|
|
*/
|
|
private function ensureFrameworkProcedures(): void
|
|
{
|
|
if (!empty($this->isDemo)) { return; }
|
|
$orgId = $this->getCurrentOrgId();
|
|
if (!$orgId) { return; }
|
|
$pdo = Database::getInstance();
|
|
try {
|
|
// Transazione + lock di riga: serializza richieste concorrenti (niente doppio
|
|
// seed) e, in caso di fallimento parziale, il rollback annulla gli insert già
|
|
// fatti (niente procedure duplicate alla ri-prova). Il flag si scrive nello
|
|
// stesso commit degli insert.
|
|
$pdo->beginTransaction();
|
|
$org = Database::fetchOne(
|
|
'SELECT framework_procedures_seeded_at FROM organizations WHERE id = ? FOR UPDATE',
|
|
[$orgId]
|
|
);
|
|
if (!$org || $org['framework_procedures_seeded_at'] !== null) { $pdo->commit(); return; }
|
|
|
|
$procs = Database::fetchAll(
|
|
"SELECT p.proc_code, p.proc_descr,
|
|
(SELECT m.nist_code FROM cfg_nis2_requisiti q
|
|
JOIN cfg_nis2_misure m ON m.misura_code = q.misura_code
|
|
WHERE q.proc_code = p.proc_code ORDER BY q.n LIMIT 1) AS nist_code,
|
|
(SELECT q2.misura_code FROM cfg_nis2_requisiti q2
|
|
WHERE q2.proc_code = p.proc_code ORDER BY q2.n LIMIT 1) AS first_misura,
|
|
(SELECT GROUP_CONCAT(DISTINCT q3.misura_code ORDER BY q3.misura_code SEPARATOR ', ')
|
|
FROM cfg_nis2_requisiti q3 WHERE q3.proc_code = p.proc_code) AS misure
|
|
FROM cfg_nis2_procedure p ORDER BY p.proc_code"
|
|
);
|
|
if (!$procs) { $pdo->commit(); return; } // framework non ancora seedato: riprova al prossimo accesso
|
|
|
|
foreach ($procs as $p) {
|
|
$cat = self::PROC_NIST_CATEGORY[$p['nist_code'] ?? ''] ?? 'information_security';
|
|
$content = $p['proc_descr']
|
|
. "\n\nProcedura di default del framework NIS2 (Determinazione ACN n. 164179 del 14 aprile 2025)."
|
|
. ($p['misure'] ? ' Copre le misure: ' . $p['misure'] . '.' : '')
|
|
. ' Contenuto iniziale personalizzabile e cancellabile.';
|
|
Database::insert('policies', [
|
|
'organization_id' => $orgId,
|
|
'title' => mb_substr($p['proc_descr'], 0, 255),
|
|
'category' => $cat,
|
|
'nis2_article' => mb_substr($p['first_misura'] ?? '', 0, 20),
|
|
'content' => $content,
|
|
'status' => 'draft',
|
|
'ai_generated' => 0,
|
|
]);
|
|
}
|
|
Database::query(
|
|
'UPDATE organizations SET framework_procedures_seeded_at = NOW() WHERE id = ?',
|
|
[$orgId]
|
|
);
|
|
$pdo->commit();
|
|
} catch (Throwable $e) {
|
|
if ($pdo->inTransaction()) { $pdo->rollBack(); }
|
|
error_log('[PolicyController] seed procedure default fallito (org ' . $orgId . '): ' . $e->getMessage());
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
$policy = Database::fetchOne('SELECT * FROM policies WHERE id = ?', [$id]);
|
|
|
|
// Snapshot versione per storico/diff/attestation (P3).
|
|
// Idempotente sulla coppia (policy_id, version): la riapprovazione della
|
|
// stessa versione aggiorna lo snapshot invece di duplicarlo (UNIQUE uq_policy_version).
|
|
try {
|
|
Database::query(
|
|
'INSERT INTO policy_versions (policy_id, organization_id, version, content, change_note, created_by)
|
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
ON DUPLICATE KEY UPDATE content = VALUES(content), change_note = VALUES(change_note),
|
|
created_by = VALUES(created_by), created_at = NOW()',
|
|
[
|
|
$id,
|
|
$this->getCurrentOrgId(),
|
|
$policy['version'] ?? '1.0',
|
|
$policy['content'] ?? null,
|
|
$this->getParam('change_note') ?: 'Approvazione',
|
|
$this->getCurrentUserId(),
|
|
]
|
|
);
|
|
} catch (Throwable $e) {
|
|
error_log('[POLICY_VER] ' . $e->getMessage());
|
|
}
|
|
|
|
// Dispatch webhook policy.approved
|
|
try {
|
|
(new WebhookService())->dispatch(
|
|
$this->getCurrentOrgId(),
|
|
'policy.approved',
|
|
WebhookService::policyPayload($policy)
|
|
);
|
|
} catch (Throwable $e) {
|
|
error_log('[WEBHOOK] dispatch error: ' . $e->getMessage());
|
|
}
|
|
|
|
$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);
|
|
}
|
|
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
// ATTESTATION + VERSIONING (P3)
|
|
// ══════════════════════════════════════════════════════════════════════
|
|
|
|
/** POST /api/policies/{id}/attest — dipendente prende visione (versione corrente). */
|
|
public function attest(int $id): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$policy = Database::fetchOne(
|
|
"SELECT id, version, status FROM policies WHERE id = ? AND organization_id = ? AND deleted_at IS NULL",
|
|
[$id, $this->getCurrentOrgId()]
|
|
);
|
|
if (!$policy) {
|
|
$this->jsonError('Policy non trovata', 404, 'NOT_FOUND');
|
|
}
|
|
if ($policy['status'] !== 'approved') {
|
|
$this->jsonError('Si puo attestare solo una policy approvata', 422, 'NOT_APPROVED');
|
|
}
|
|
$version = $policy['version'] ?? '1.0';
|
|
|
|
$existing = Database::fetchOne(
|
|
'SELECT id FROM policy_attestations WHERE policy_id = ? AND user_id = ? AND version = ?',
|
|
[$id, $this->getCurrentUserId(), $version]
|
|
);
|
|
if ($existing) {
|
|
$this->jsonSuccess(['already' => true, 'version' => $version], 'Presa visione gia registrata');
|
|
return;
|
|
}
|
|
|
|
Database::insert('policy_attestations', [
|
|
'policy_id' => $id,
|
|
'organization_id' => $this->getCurrentOrgId(),
|
|
'user_id' => $this->getCurrentUserId(),
|
|
'version' => $version,
|
|
'ip_address' => $this->attClientIp(),
|
|
]);
|
|
$this->logAudit('policy_attested', 'policy', $id, ['version' => $version]);
|
|
$this->jsonSuccess(['version' => $version], 'Presa visione registrata', 201);
|
|
}
|
|
|
|
/** GET /api/policies/{id}/attestations — copertura % presa visione (versione corrente). */
|
|
public function attestations(int $id): void
|
|
{
|
|
$this->requireOrgRole(['org_admin', 'compliance_manager', 'board_member', 'auditor']);
|
|
$policy = Database::fetchOne(
|
|
"SELECT id, title, version FROM policies WHERE id = ? AND organization_id = ? AND deleted_at IS NULL",
|
|
[$id, $this->getCurrentOrgId()]
|
|
);
|
|
if (!$policy) {
|
|
$this->jsonError('Policy non trovata', 404, 'NOT_FOUND');
|
|
}
|
|
$version = $policy['version'] ?? '1.0';
|
|
|
|
$members = Database::fetchAll(
|
|
'SELECT u.id, u.full_name, u.email FROM user_organizations uo
|
|
JOIN users u ON u.id = uo.user_id
|
|
WHERE uo.organization_id = ? AND u.is_active = 1',
|
|
[$this->getCurrentOrgId()]
|
|
);
|
|
$attested = Database::fetchAll(
|
|
'SELECT user_id, attested_at FROM policy_attestations WHERE policy_id = ? AND version = ?',
|
|
[$id, $version]
|
|
);
|
|
$attMap = [];
|
|
foreach ($attested as $a) { $attMap[(int) $a['user_id']] = $a['attested_at']; }
|
|
|
|
$list = array_map(fn($m) => [
|
|
'user_id' => (int) $m['id'],
|
|
'full_name' => $m['full_name'],
|
|
'email' => $m['email'],
|
|
'attested' => isset($attMap[(int) $m['id']]),
|
|
'attested_at' => $attMap[(int) $m['id']] ?? null,
|
|
], $members);
|
|
|
|
$total = count($members);
|
|
$done = count(array_filter($list, fn($x) => $x['attested']));
|
|
$this->jsonSuccess([
|
|
'policy_id' => $id,
|
|
'version' => $version,
|
|
'total_members' => $total,
|
|
'attested' => $done,
|
|
'coverage_percent' => $total ? (int) round($done * 100 / $total) : 0,
|
|
'members' => $list,
|
|
]);
|
|
}
|
|
|
|
/** GET /api/policies/{id}/versions — storico versioni. */
|
|
public function versions(int $id): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$rows = Database::fetchAll(
|
|
'SELECT id, version, change_note, created_by, created_at FROM policy_versions
|
|
WHERE policy_id = ? AND organization_id = ? ORDER BY created_at DESC',
|
|
[$id, $this->getCurrentOrgId()]
|
|
);
|
|
$this->jsonSuccess(['versions' => $rows]);
|
|
}
|
|
|
|
/** GET /api/policies/{id}/diff?from={vId}&to={vId} — diff line-by-line. */
|
|
public function diff(int $id): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$fromId = (int) $this->getParam('from');
|
|
$toId = (int) $this->getParam('to');
|
|
$a = Database::fetchOne('SELECT version, content FROM policy_versions WHERE id = ? AND policy_id = ? AND organization_id = ?', [$fromId, $id, $this->getCurrentOrgId()]);
|
|
$b = Database::fetchOne('SELECT version, content FROM policy_versions WHERE id = ? AND policy_id = ? AND organization_id = ?', [$toId, $id, $this->getCurrentOrgId()]);
|
|
if (!$a || !$b) {
|
|
$this->jsonError('Versione non trovata', 404, 'NOT_FOUND');
|
|
}
|
|
$linesA = preg_split('/\r\n|\r|\n/', (string) $a['content']);
|
|
$linesB = preg_split('/\r\n|\r|\n/', (string) $b['content']);
|
|
$d = self::lcsLineDiff($linesA, $linesB);
|
|
$this->jsonSuccess([
|
|
'from' => ['id' => $fromId, 'version' => $a['version']],
|
|
'to' => ['id' => $toId, 'version' => $b['version']],
|
|
'added' => $d['added'],
|
|
'removed' => $d['removed'],
|
|
'summary' => ['added' => count($d['added']), 'removed' => count($d['removed'])],
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* Diff line-by-line basato su LCS (Longest Common Subsequence).
|
|
* Rileva correttamente righe duplicate, spostamenti e modifiche posizionali,
|
|
* a differenza di un confronto set-based. Restituisce le righe aggiunte (in B
|
|
* ma non allineate in A) e rimosse (in A ma non allineate in B), con posizione.
|
|
*
|
|
* @return array{added: list<array{line:int,text:string}>, removed: list<array{line:int,text:string}>}
|
|
*/
|
|
private static function lcsLineDiff(array $a, array $b): array
|
|
{
|
|
$n = count($a);
|
|
$m = count($b);
|
|
// Tabella LCS (lunghezze). Cap di sicurezza per evitare blow-up su documenti enormi.
|
|
if ($n > 4000 || $m > 4000) {
|
|
// fallback prudente: confronto posizionale semplice riga per riga
|
|
$added = []; $removed = [];
|
|
$max = max($n, $m);
|
|
for ($i = 0; $i < $max; $i++) {
|
|
$la = $a[$i] ?? null; $lb = $b[$i] ?? null;
|
|
if ($la !== $lb) {
|
|
if ($lb !== null && trim($lb) !== '') $added[] = ['line' => $i + 1, 'text' => $lb];
|
|
if ($la !== null && trim($la) !== '') $removed[] = ['line' => $i + 1, 'text' => $la];
|
|
}
|
|
}
|
|
return ['added' => $added, 'removed' => $removed];
|
|
}
|
|
|
|
$dp = array_fill(0, $n + 1, array_fill(0, $m + 1, 0));
|
|
for ($i = $n - 1; $i >= 0; $i--) {
|
|
for ($j = $m - 1; $j >= 0; $j--) {
|
|
$dp[$i][$j] = ($a[$i] === $b[$j])
|
|
? $dp[$i + 1][$j + 1] + 1
|
|
: max($dp[$i + 1][$j], $dp[$i][$j + 1]);
|
|
}
|
|
}
|
|
$added = []; $removed = [];
|
|
$i = 0; $j = 0;
|
|
while ($i < $n && $j < $m) {
|
|
if ($a[$i] === $b[$j]) {
|
|
$i++; $j++;
|
|
} elseif ($dp[$i + 1][$j] >= $dp[$i][$j + 1]) {
|
|
if (trim($a[$i]) !== '') $removed[] = ['line' => $i + 1, 'text' => $a[$i]];
|
|
$i++;
|
|
} else {
|
|
if (trim($b[$j]) !== '') $added[] = ['line' => $j + 1, 'text' => $b[$j]];
|
|
$j++;
|
|
}
|
|
}
|
|
for (; $i < $n; $i++) { if (trim($a[$i]) !== '') $removed[] = ['line' => $i + 1, 'text' => $a[$i]]; }
|
|
for (; $j < $m; $j++) { if (trim($b[$j]) !== '') $added[] = ['line' => $j + 1, 'text' => $b[$j]]; }
|
|
return ['added' => $added, 'removed' => $removed];
|
|
}
|
|
|
|
/** GET /api/policies/pending-attestations — policy approvate non ancora attestate dall'utente. */
|
|
public function pendingAttestations(): void
|
|
{
|
|
$this->requireOrgAccess();
|
|
$rows = Database::fetchAll(
|
|
"SELECT p.id, p.title, p.category, p.version, p.approved_at
|
|
FROM policies p
|
|
WHERE p.organization_id = ? AND p.status = 'approved' AND p.deleted_at IS NULL
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM policy_attestations a
|
|
WHERE a.policy_id = p.id AND a.user_id = ? AND a.version = p.version
|
|
)
|
|
ORDER BY p.approved_at DESC",
|
|
[$this->getCurrentOrgId(), $this->getCurrentUserId()]
|
|
);
|
|
$this->jsonSuccess(['pending' => $rows, 'count' => count($rows)]);
|
|
}
|
|
|
|
private function attClientIp(): ?string
|
|
{
|
|
$fwd = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
|
|
if ($fwd) { return trim(explode(',', $fwd)[0]); }
|
|
return $_SERVER['REMOTE_ADDR'] ?? null;
|
|
}
|
|
}
|