[FEAT] A4 Fase 4.3 — Matrice RACI + link m2m: raci_assignments + procedure_inventory/procedure_risk/inventory_risk/risk_measure (mig.043), RaciController org-scoped (anti-IDOR, validazione object/measure), raci.html+raci.js (Bootstrap Italia/AGID), routing+sidebar+api+help+i18n. Build+review adversariale via workflow (0 finding critical/major). Cache-buster ?v=20260619.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
DevEnv nis2-agile
2026-06-15 22:44:08 +02:00
co-authored by Claude Opus 4.8
parent 05dfba6fcd
commit b4d47d58a2
44 changed files with 1154 additions and 111 deletions
+498
View File
@@ -0,0 +1,498 @@
<?php
/**
* NIS2 Agile - Matrice RACI + link m2m (A4 Fase 4.3)
* ----------------------------------------------------------------------------
* Hub relazionale che collega i RUOLI dell'organigramma (mig.041, org_roles)
* agli OGGETTI di compliance con una responsabilita RACI (R/A/C/I), e gestisce i
* collegamenti molti-a-molti fra procedure (policies), inventario (assets),
* rischi (risks) e misure ACN (catalogo JSON).
*
* Multi-tenancy ancorata a getCurrentOrgId(): OGNI query filtra organization_id.
* Scritture riservate a org_admin/compliance_manager (super_admin bypassa; il
* demo guard gestisce il read-only). Anti-IDOR: ogni id referenziato (ruolo,
* asset, policy, risk, supplier) e verificato come appartenente all'org corrente
* prima di insert (fetchRoleOrFail / assertObjectExists, org-scoped).
*
* Ancoraggio normativo (docs/DESIGN_A4_RELATIONAL.md):
* - GV.RR-02 (NIST CSF 2.0): ruoli, responsabilita e poteri stabiliti/comunicati
* - ID.AM (asset management): inventario; GV.PO (governance): procedure
* - art. 24 D.Lgs. 138/2024 + ID.RA (risk assessment): rischi
*
* NOTE strutturali (verificate sul codice reale):
* - 'inventario' = `assets`; 'procedure' = `policies`; rischi = `risks`;
* fornitori = `suppliers`. Label: assets.name, policies.title, risks.title,
* suppliers.name, org_roles.role_name.
* - misure ACN = application/data/acn_measures.json (campo 'code'); NON e una
* tabella DB -> risk_measure.measure_code e una stringa validata sul JSON.
* - raci_assignments.object_id e POLIMORFICO -> niente FK; esistenza verificata
* in PHP. I nomi tabella/colonna provengono SEMPRE dalle const-map qui sotto
* dopo validazione enum (mai da stringhe grezze della request).
* - Le AZIONI sono capa_actions (figlie di non_conformities): 4.3 non le usa.
*/
require_once __DIR__ . '/BaseController.php';
class RaciController extends BaseController
{
private const MANAGE_ROLES = ['org_admin', 'compliance_manager'];
/**
* object_type => [tabella, colonna_label, ha_soft_delete]
* I nomi sono hard-coded: dopo il gate enum sono sicuri da interpolare.
*/
private const OBJECT_MAP = [
'inventory' => ['table' => 'assets', 'label' => 'name', 'soft_delete' => false],
'procedure' => ['table' => 'policies', 'label' => 'title', 'soft_delete' => true],
'risk' => ['table' => 'risks', 'label' => 'title', 'soft_delete' => true],
'supplier' => ['table' => 'suppliers', 'label' => 'name', 'soft_delete' => true],
];
/**
* link_type => [tabella, a_col, a_table, a_label, b_col, b_table, b_label]
* Per risk_measure il lato b e un codice ACN (stringa): b_table=null.
*/
private const LINK_MAP = [
'procedure_inventory' => [
'table' => 'procedure_inventory',
'a_col' => 'policy_id', 'a_table' => 'policies', 'a_label' => 'title', 'a_soft' => true,
'b_col' => 'asset_id', 'b_table' => 'assets', 'b_label' => 'name', 'b_soft' => false,
],
'procedure_risk' => [
'table' => 'procedure_risk',
'a_col' => 'policy_id', 'a_table' => 'policies', 'a_label' => 'title', 'a_soft' => true,
'b_col' => 'risk_id', 'b_table' => 'risks', 'b_label' => 'title', 'b_soft' => true,
],
'inventory_risk' => [
'table' => 'inventory_risk',
'a_col' => 'asset_id', 'a_table' => 'assets', 'a_label' => 'name', 'a_soft' => false,
'b_col' => 'risk_id', 'b_table' => 'risks', 'b_label' => 'title', 'b_soft' => true,
],
'risk_measure' => [
'table' => 'risk_measure',
'a_col' => 'risk_id', 'a_table' => 'risks', 'a_label' => 'title', 'a_soft' => true,
'b_col' => 'measure_code', 'b_table' => null, 'b_label' => null, 'b_soft' => false,
],
];
/** @var array<string,array>|null cache del catalogo misure ACN */
private static $measuresCache = null;
// ═══════════════════════════════════════════════════════════════════════
// MATRICE RACI
// ═══════════════════════════════════════════════════════════════════════
/**
* GET /api/raci/matrix
* { roles:[{role_id, role_name, is_governance_body, holder_user_id}],
* objects:[{type, id, label}], assignments:[{role_id, object_type, object_id, raci}] }
*/
public function matrix(): void
{
$this->requireOrgAccess();
$orgId = $this->getCurrentOrgId();
$roleRows = Database::fetchAll(
'SELECT id, role_name, is_governance_body, holder_user_id
FROM org_roles WHERE organization_id = ?
ORDER BY sort_order, role_name',
[$orgId]
);
$roles = array_map(fn($r) => [
'role_id' => (int) $r['id'],
'role_name' => $r['role_name'],
'is_governance_body' => (bool) $r['is_governance_body'],
'holder_user_id' => $r['holder_user_id'] !== null ? (int) $r['holder_user_id'] : null,
], $roleRows);
// Oggetti linkabili: union dei 4 tipi, org-scoped, ordinati per tipo+label.
$objects = [];
foreach (self::OBJECT_MAP as $type => $cfg) {
foreach ($this->fetchObjectsForType($type, $orgId) as $o) {
$objects[] = ['type' => $type, 'id' => $o['id'], 'label' => $o['label']];
}
}
$assignRows = Database::fetchAll(
'SELECT role_id, object_type, object_id, raci
FROM raci_assignments WHERE organization_id = ?',
[$orgId]
);
$assignments = array_map(fn($a) => [
'role_id' => (int) $a['role_id'],
'object_type' => $a['object_type'],
'object_id' => (int) $a['object_id'],
'raci' => $a['raci'],
], $assignRows);
$this->jsonSuccess([
'roles' => $roles,
'objects' => $objects,
'assignments' => $assignments,
]);
}
/**
* POST /api/raci/assign — {role_id, object_type, object_id, raci}
* Upsert: una sola RACI per (role_id, object_type, object_id) nell'org.
*/
public function assign(): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$this->validateRequired(['role_id', 'object_type', 'object_id', 'raci']);
$orgId = $this->getCurrentOrgId();
$roleId = (int) $this->getParam('role_id');
$type = $this->validateObjectType($this->getParam('object_type'));
$objId = (int) $this->getParam('object_id');
$raci = $this->validateRaci($this->getParam('raci'));
$this->fetchRoleOrFail($roleId);
$this->assertObjectExists($type, $objId);
$existing = Database::fetchOne(
'SELECT id FROM raci_assignments
WHERE organization_id = ? AND role_id = ? AND object_type = ? AND object_id = ?',
[$orgId, $roleId, $type, $objId]
);
if ($existing) {
$id = (int) $existing['id'];
Database::update('raci_assignments', ['raci' => $raci], 'id = ?', [$id]);
$created = false;
} else {
$id = Database::insert('raci_assignments', [
'organization_id' => $orgId,
'role_id' => $roleId,
'object_type' => $type,
'object_id' => $objId,
'raci' => $raci,
'created_by' => $this->getCurrentUserId(),
]);
$created = true;
}
$this->logAudit('raci_assigned', 'raci_assignment', $id, [
'role_id' => $roleId, 'object_type' => $type, 'object_id' => $objId, 'raci' => $raci,
]);
$this->jsonSuccess(['id' => $id, 'raci' => $raci], $created ? 'RACI assegnata' : 'RACI aggiornata', $created ? 201 : 200);
}
/**
* DELETE /api/raci/assign?role_id=&object_type=&object_id=
* (parametri via query string: il DELETE di api.js non porta body.)
*/
public function unassign(): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$this->validateRequired(['role_id', 'object_type', 'object_id']);
$orgId = $this->getCurrentOrgId();
$roleId = (int) $this->getParam('role_id');
$type = $this->validateObjectType($this->getParam('object_type'));
$objId = (int) $this->getParam('object_id');
$count = Database::count(
'raci_assignments',
'organization_id = ? AND role_id = ? AND object_type = ? AND object_id = ?',
[$orgId, $roleId, $type, $objId]
);
if ($count === 0) {
$this->jsonError('Assegnazione RACI non trovata', 404, 'RACI_NOT_FOUND');
}
Database::delete(
'raci_assignments',
'organization_id = ? AND role_id = ? AND object_type = ? AND object_id = ?',
[$orgId, $roleId, $type, $objId]
);
$this->logAudit('raci_unassigned', 'raci_assignment', null, [
'role_id' => $roleId, 'object_type' => $type, 'object_id' => $objId,
]);
$this->jsonSuccess(null, 'RACI rimossa');
}
// ═══════════════════════════════════════════════════════════════════════
// OGGETTI LINKABILI (select)
// ═══════════════════════════════════════════════════════════════════════
/**
* GET /api/raci/objects?type=
* type in {inventory,procedure,risk,supplier} -> [{id, label}] dell'org;
* type=measure -> misure ACN dal catalogo JSON [{code, label}].
*/
public function objects(): void
{
$this->requireOrgAccess();
$type = (string) $this->getParam('type', '');
if ($type === 'measure') {
$out = [];
foreach ($this->loadMeasures() as $m) {
$code = (string) ($m['code'] ?? '');
if ($code === '') {
continue;
}
$out[] = ['code' => $code, 'label' => $code . ' — ' . ($m['title'] ?? '')];
}
$this->jsonSuccess($out);
}
$type = $this->validateObjectType($type);
$this->jsonSuccess($this->fetchObjectsForType($type, $this->getCurrentOrgId()));
}
// ═══════════════════════════════════════════════════════════════════════
// LINK MOLTI-A-MOLTI
// ═══════════════════════════════════════════════════════════════════════
/**
* GET /api/raci/links?link_type=&id=
* $id = id del lato "a" (policy/asset/risk). Ritorna i lati "b" collegati con label.
*/
public function links(): void
{
$this->requireOrgAccess();
$linkType = $this->validateLinkType($this->getParam('link_type'));
$aId = (int) $this->getParam('id');
$orgId = $this->getCurrentOrgId();
$cfg = self::LINK_MAP[$linkType];
if ($linkType === 'risk_measure') {
$rows = Database::fetchAll(
'SELECT id AS link_id, measure_code FROM risk_measure
WHERE organization_id = ? AND risk_id = ? ORDER BY measure_code',
[$orgId, $aId]
);
$out = array_map(fn($r) => [
'link_id' => (int) $r['link_id'],
'measure_code' => $r['measure_code'],
'measure_label' => $this->measureLabel($r['measure_code']),
], $rows);
$this->jsonSuccess($out);
}
// Join sul lato b per la label. Nomi tabella/colonna dalla const-map (sicuri).
$bTable = $cfg['b_table'];
$bCol = $cfg['b_col'];
$bLabel = $cfg['b_label'];
$linkTbl = $cfg['table'];
$aCol = $cfg['a_col'];
$bSoftClause = $cfg['b_soft'] ? ' AND b.deleted_at IS NULL' : '';
$rows = Database::fetchAll(
"SELECT l.id AS link_id, l.{$bCol} AS b_id, b.{$bLabel} AS b_label
FROM {$linkTbl} l
JOIN {$bTable} b ON b.id = l.{$bCol}
WHERE l.organization_id = ? AND l.{$aCol} = ?{$bSoftClause}
ORDER BY b.{$bLabel}",
[$orgId, $aId]
);
$out = array_map(fn($r) => [
'link_id' => (int) $r['link_id'],
'b_id' => (int) $r['b_id'],
'b_label' => $r['b_label'],
], $rows);
$this->jsonSuccess($out);
}
/**
* POST /api/raci/link — {link_type, a_id, b_id}
* Idempotente: se il link esiste gia ritorna 200 {existed:true}.
* Per risk_measure il lato b e il codice misura (stringa, validato sul JSON).
*/
public function link(): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$this->validateRequired(['link_type', 'a_id', 'b_id']);
$orgId = $this->getCurrentOrgId();
$linkType = $this->validateLinkType($this->getParam('link_type'));
$cfg = self::LINK_MAP[$linkType];
$aId = (int) $this->getParam('a_id');
// Lato a: sempre un oggetto org-scoped.
$this->assertRowExists($cfg['a_table'], $aId, $orgId, $cfg['a_soft']);
if ($linkType === 'risk_measure') {
$code = trim((string) $this->getParam('b_id'));
$this->assertMeasureExists($code);
$existing = Database::fetchOne(
'SELECT id FROM risk_measure WHERE organization_id = ? AND risk_id = ? AND measure_code = ?',
[$orgId, $aId, $code]
);
if ($existing) {
$this->jsonSuccess(['id' => (int) $existing['id'], 'existed' => true], 'Collegamento gia presente', 200);
}
$id = Database::insert('risk_measure', [
'organization_id' => $orgId,
'risk_id' => $aId,
'measure_code' => $code,
'created_by' => $this->getCurrentUserId(),
]);
$this->logAudit('raci_link_created', 'risk_measure', $id, ['risk_id' => $aId, 'measure_code' => $code]);
$this->jsonSuccess(['id' => $id, 'existed' => false], 'Collegamento creato', 201);
}
// Lato b numerico (asset/risk), org-scoped.
$bId = (int) $this->getParam('b_id');
$this->assertRowExists($cfg['b_table'], $bId, $orgId, $cfg['b_soft']);
$existing = Database::fetchOne(
"SELECT id FROM {$cfg['table']} WHERE organization_id = ? AND {$cfg['a_col']} = ? AND {$cfg['b_col']} = ?",
[$orgId, $aId, $bId]
);
if ($existing) {
$this->jsonSuccess(['id' => (int) $existing['id'], 'existed' => true], 'Collegamento gia presente', 200);
}
$id = Database::insert($cfg['table'], [
'organization_id' => $orgId,
$cfg['a_col'] => $aId,
$cfg['b_col'] => $bId,
'created_by' => $this->getCurrentUserId(),
]);
$this->logAudit('raci_link_created', $cfg['table'], $id, [$cfg['a_col'] => $aId, $cfg['b_col'] => $bId]);
$this->jsonSuccess(['id' => $id, 'existed' => false], 'Collegamento creato', 201);
}
/**
* DELETE /api/raci/link?link_type=&a_id=&b_id=
* (parametri via query string.) Per risk_measure b_id = codice misura.
*/
public function unlink(): void
{
$this->requireOrgRole(self::MANAGE_ROLES);
$this->validateRequired(['link_type', 'a_id', 'b_id']);
$orgId = $this->getCurrentOrgId();
$linkType = $this->validateLinkType($this->getParam('link_type'));
$cfg = self::LINK_MAP[$linkType];
$aId = (int) $this->getParam('a_id');
if ($linkType === 'risk_measure') {
$code = trim((string) $this->getParam('b_id'));
$where = 'organization_id = ? AND risk_id = ? AND measure_code = ?';
$params = [$orgId, $aId, $code];
} else {
$bId = (int) $this->getParam('b_id');
$where = "organization_id = ? AND {$cfg['a_col']} = ? AND {$cfg['b_col']} = ?";
$params = [$orgId, $aId, $bId];
}
if (Database::count($cfg['table'], $where, $params) === 0) {
$this->jsonError('Collegamento non trovato', 404, 'LINK_NOT_FOUND');
}
Database::delete($cfg['table'], $where, $params);
$this->logAudit('raci_link_removed', $cfg['table'], null, ['link_type' => $linkType, 'a_id' => $aId]);
$this->jsonSuccess(null, 'Collegamento rimosso');
}
// ═══════════════════════════════════════════════════════════════════════
// PRIVATI
// ═══════════════════════════════════════════════════════════════════════
/** Elenco oggetti di un tipo per l'org corrente: [{id, label}] (esclude soft-deleted). */
private function fetchObjectsForType(string $type, int $orgId): array
{
$cfg = self::OBJECT_MAP[$type];
$table = $cfg['table'];
$label = $cfg['label'];
$softClause = $cfg['soft_delete'] ? ' AND deleted_at IS NULL' : '';
$rows = Database::fetchAll(
"SELECT id, {$label} AS label FROM {$table}
WHERE organization_id = ?{$softClause} ORDER BY {$label}",
[$orgId]
);
return array_map(fn($r) => ['id' => (int) $r['id'], 'label' => $r['label']], $rows);
}
/** Ruolo dell'organigramma nell'org corrente oppure 404 (anti-IDOR). */
private function fetchRoleOrFail(int $id): array
{
$r = Database::fetchOne('SELECT * FROM org_roles WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]);
if (!$r) {
$this->jsonError('Ruolo non trovato', 404, 'ROLE_NOT_FOUND');
}
return $r;
}
/** Verifica che l'oggetto (assets/policies/risks/suppliers) esista nell'org. */
private function assertObjectExists(string $type, int $id): void
{
$cfg = self::OBJECT_MAP[$type];
$this->assertRowExists($cfg['table'], $id, $this->getCurrentOrgId(), $cfg['soft_delete']);
}
/** Verifica generica di esistenza riga org-scoped (tabella dalla const-map). */
private function assertRowExists(string $table, int $id, int $orgId, bool $soft): void
{
$where = 'id = ? AND organization_id = ?';
$params = [$id, $orgId];
if ($soft) {
$where .= ' AND deleted_at IS NULL';
}
if (Database::count($table, $where, $params) === 0) {
$this->jsonError('Oggetto non valido per questa organizzazione', 422, 'INVALID_OBJECT');
}
}
private function validateObjectType($t): string
{
$t = (string) $t;
if (!isset(self::OBJECT_MAP[$t])) {
$this->jsonError('Tipo oggetto non valido', 422, 'INVALID_OBJECT_TYPE');
}
return $t;
}
private function validateRaci($r): string
{
$r = strtoupper((string) $r);
if (!in_array($r, ['R', 'A', 'C', 'I'], true)) {
$this->jsonError('Valore RACI non valido (atteso R/A/C/I)', 422, 'INVALID_RACI');
}
return $r;
}
private function validateLinkType($lt): string
{
$lt = (string) $lt;
if (!isset(self::LINK_MAP[$lt])) {
$this->jsonError('Tipo di collegamento non valido', 422, 'INVALID_LINK_TYPE');
}
return $lt;
}
/** Carica e memoizza il catalogo misure ACN dal JSON. */
private function loadMeasures(): array
{
if (self::$measuresCache === null) {
$path = APP_PATH . '/data/acn_measures.json';
$raw = is_readable($path) ? file_get_contents($path) : false;
$data = $raw !== false ? json_decode($raw, true) : null;
self::$measuresCache = is_array($data) && isset($data['measures']) && is_array($data['measures'])
? $data['measures'] : [];
}
return self::$measuresCache;
}
/** Verifica che il codice misura esista nel catalogo ACN. */
private function assertMeasureExists(string $code): void
{
foreach ($this->loadMeasures() as $m) {
if ((string) ($m['code'] ?? '') === $code) {
return;
}
}
$this->jsonError('Misura ACN non valida', 422, 'INVALID_MEASURE');
}
/** Label leggibile per un codice misura ('CODE — titolo'); fallback al solo codice. */
private function measureLabel(string $code): string
{
foreach ($this->loadMeasures() as $m) {
if ((string) ($m['code'] ?? '') === $code) {
return $code . ' — ' . ($m['title'] ?? '');
}
}
return $code;
}
}