diff --git a/application/controllers/AssetController.php b/application/controllers/AssetController.php index 08b1350..0c8ddd9 100644 --- a/application/controllers/AssetController.php +++ b/application/controllers/AssetController.php @@ -37,9 +37,12 @@ class AssetController extends BaseController $total = Database::count('assets', $where, $params); $assets = Database::fetchAll( - "SELECT a.*, u.full_name as owner_name + "SELECT a.*, u.full_name as owner_name, + sc.label AS subclass_label, v.label AS voce_label FROM assets a LEFT JOIN users u ON u.id = a.owner_user_id + LEFT JOIN inventory_subclassi sc ON sc.id = a.subclass_id + LEFT JOIN cfg_inventory_voci v ON v.voce_code = a.voce_code WHERE a.{$where} ORDER BY a.criticality DESC, a.name LIMIT {$pagination['per_page']} OFFSET {$pagination['offset']}", @@ -52,12 +55,28 @@ class AssetController extends BaseController public function create(): void { $this->requireOrgRole(['org_admin', 'compliance_manager']); - $this->validateRequired(['name', 'asset_type']); + $this->validateRequired(['name']); + + $voce = $this->getParam('voce_code'); + if (!$voce) { + // retro-compatibilità: deriva la voce dal vecchio asset_type (UI non ancora aggiornata / import) + $at = (string) $this->getParam('asset_type'); + if (in_array($at, ['software', 'service', 'data'], true)) { $voce = 'ID.AM-02'; } + elseif (in_array($at, ['hardware', 'network', 'facility'], true)) { $voce = 'ID.AM-01'; } + } + if (!in_array($voce, ['ID.AM-01', 'ID.AM-02'], true)) { + $this->jsonError('Voce inventario obbligatoria (ID.AM-01 o ID.AM-02)', 422, 'INVALID_VOCE'); + } + $subclassId = $this->validateSubclass($this->getParam('subclass_id'), $voce); + // asset_type resta come campo legacy (viste secondarie): usa quello passato o deriva dalla voce + $assetType = $this->getParam('asset_type') ?: ($voce === 'ID.AM-01' ? 'hardware' : 'service'); $assetId = Database::insert('assets', [ 'organization_id' => $this->getCurrentOrgId(), 'name' => trim($this->getParam('name')), - 'asset_type' => $this->getParam('asset_type'), + 'asset_type' => $assetType, + 'voce_code' => $voce, + 'subclass_id' => $subclassId, 'category' => $this->getParam('category'), 'description' => $this->getParam('description'), 'criticality' => $this->getParam('criticality', 'medium'), @@ -119,6 +138,8 @@ class AssetController extends BaseController $type = strtolower((string) ($a['asset_type'] ?? 'service')); if (!in_array($type, $validType, true)) $type = 'service'; + // C4: voce (2 valori) derivata dal tipo legacy per gli asset importati + $voce = in_array($type, ['hardware', 'network', 'facility'], true) ? 'ID.AM-01' : 'ID.AM-02'; // Scoring automatico GV.OC-04 da euristica sui campi CMDB $criteria = AssetScoringService::inferCriteria($a); @@ -130,6 +151,7 @@ class AssetController extends BaseController 'organization_id' => $orgId, 'name' => $name, 'asset_type' => $type, + 'voce_code' => $voce, 'category' => $a['category'] ?? null, 'description' => $a['description'] ?? null, 'criticality' => $sc['criticality'], @@ -216,6 +238,21 @@ class AssetController extends BaseController $updates['dependencies'] = json_encode($this->getParam('dependencies')); } + // Voce/sottoclasse (Epic C / C4) — con validazione (no loop generico) + if ($this->hasParam('voce_code')) { + $voce = $this->getParam('voce_code'); + if (!in_array($voce, ['ID.AM-01', 'ID.AM-02'], true)) { + $this->jsonError('Voce inventario non valida', 422, 'INVALID_VOCE'); + } + $updates['voce_code'] = $voce; + if ($this->hasParam('subclass_id')) { + $updates['subclass_id'] = $this->validateSubclass($this->getParam('subclass_id'), $voce); + } + } elseif ($this->hasParam('subclass_id')) { + $cur = Database::fetchOne('SELECT voce_code FROM assets WHERE id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); + $updates['subclass_id'] = $this->validateSubclass($this->getParam('subclass_id'), $cur['voce_code'] ?? ''); + } + if (!empty($updates)) { Database::update('assets', $updates, 'id = ? AND organization_id = ?', [$id, $this->getCurrentOrgId()]); $this->logAudit('asset_updated', 'asset', $id, $updates); @@ -224,6 +261,85 @@ class AssetController extends BaseController $this->jsonSuccess($updates, 'Asset aggiornato'); } + /** + * Valida una sottoclasse per la voce indicata: deve esistere ed essere o di + * sistema (organization_id NULL) o dell'org corrente, e appartenere alla voce. + * Ritorna l'id (int) oppure null se non fornita. + */ + private function validateSubclass($id, string $voce): ?int + { + $id = (int) $id; + if ($id <= 0) { return null; } + $row = Database::fetchOne( + 'SELECT id FROM inventory_subclassi + WHERE id = ? AND voce_code = ? AND (organization_id = ? OR organization_id IS NULL)', + [$id, $voce, $this->getCurrentOrgId()] + ); + if (!$row) { $this->jsonError('Sottoclasse non valida per la voce selezionata', 422, 'INVALID_SUBCLASS'); } + return $id; + } + + /** + * GET /api/assets/subclasses — le 2 voci canoniche + le sottoclassi visibili + * (default di sistema + quelle dell'org), per i selettori dell'inventario. + */ + public function subclasses(): void + { + $this->requireOrgAccess(); + $orgId = $this->getCurrentOrgId(); + $voci = Database::fetchAll('SELECT voce_code, label, descr, ord FROM cfg_inventory_voci ORDER BY ord'); + $subs = Database::fetchAll( + 'SELECT id, voce_code, label, (organization_id IS NULL) AS is_default, ord + FROM inventory_subclassi + WHERE organization_id IS NULL OR organization_id = ? + ORDER BY voce_code, ord, label', + [$orgId] + ); + $byVoce = []; + foreach ($subs as $s) { + $byVoce[$s['voce_code']][] = [ + 'id' => (int) $s['id'], 'label' => $s['label'], 'is_default' => ((int) $s['is_default'] === 1), + ]; + } + $out = []; + foreach ($voci as $v) { + $out[] = [ + 'voce_code' => $v['voce_code'], 'label' => $v['label'], 'descr' => $v['descr'], + 'subclassi' => $byVoce[$v['voce_code']] ?? [], + ]; + } + $this->jsonSuccess(['voci' => $out]); + } + + /** + * POST /api/assets/subclasses { voce_code*, label* } + * Aggiunge una sottoclasse ORGANIZZATIVA (org-scoped). Le due voci principali + * NON sono modificabili dall'utente (restano due e solo due). + */ + public function addSubclass(): void + { + $this->requireOrgRole(['org_admin', 'compliance_manager']); + $body = $this->getJsonBody(); + $voce = $body['voce_code'] ?? ''; + $label = trim((string) ($body['label'] ?? '')); + if (!in_array($voce, ['ID.AM-01', 'ID.AM-02'], true)) { + $this->jsonError('Voce inventario non valida', 422, 'INVALID_VOCE'); + } + if ($label === '' || mb_strlen($label) > 120) { + $this->jsonError('Etichetta sottoclasse obbligatoria (max 120 caratteri)', 422, 'INVALID_LABEL'); + } + $orgId = $this->getCurrentOrgId(); + $dup = Database::fetchOne( + 'SELECT id FROM inventory_subclassi WHERE voce_code = ? AND label = ? AND (organization_id = ? OR organization_id IS NULL)', + [$voce, $label, $orgId] + ); + if ($dup) { $this->jsonError('Sottoclasse già esistente', 409, 'DUPLICATE'); } + $id = Database::insert('inventory_subclassi', [ + 'organization_id' => $orgId, 'voce_code' => $voce, 'label' => $label, 'ord' => 99, + ]); + $this->jsonSuccess(['id' => $id, 'voce_code' => $voce, 'label' => $label], 'Sottoclasse aggiunta', 201); + } + public function delete(int $id): void { $this->requireOrgRole(['org_admin']); diff --git a/docs/sql/050_inventory_two_voci.sql b/docs/sql/050_inventory_two_voci.sql new file mode 100644 index 0000000..ce8c7a1 --- /dev/null +++ b/docs/sql/050_inventory_two_voci.sql @@ -0,0 +1,40 @@ +-- ===================================================================== +-- 050 — Inventario a DUE voci + sottoclassi configurabili (Epic C / C4) +-- ===================================================================== +-- Simon C4: nell'inventario DUE sole voci principali (ID.AM-01 Hardware, +-- ID.AM-02 Software/Servizi/Sistemi); il dettaglio diventa SOTTOCLASSE +-- (config: default di sistema + create dall'utente). Riconduzione concordata: +-- hardware/network/facility -> ID.AM-01 ; software/service/data -> ID.AM-02 ; +-- personnel -> Organigramma (nessun asset personnel presente). +-- +-- Additivo: aggiunge cfg_inventory_voci, inventory_subclassi e +-- assets.voce_code/subclass_id; NON tocca asset_type (resta come legacy per le +-- viste secondarie). Migrazione dati inclusa (vedi runner). Reversibile. +-- Runner-safe: solo CREATE/ALTER bare (no DELIMITER). +-- ===================================================================== + +CREATE TABLE IF NOT EXISTS cfg_inventory_voci ( + voce_code VARCHAR(16) NOT NULL, + label VARCHAR(120) NOT NULL, + descr TEXT NULL, + ord INT NOT NULL, + PRIMARY KEY (voce_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS inventory_subclassi ( + id INT NOT NULL AUTO_INCREMENT, + organization_id INT NULL, -- NULL = default di sistema; valorizzato = sottoclasse dell'org + voce_code VARCHAR(16) NOT NULL, + label VARCHAR(120) NOT NULL, + ord INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uq_subclass (organization_id, voce_code, label), + KEY idx_subclass_org (organization_id), + KEY idx_subclass_voce (voce_code), + CONSTRAINT fk_subclass_voce FOREIGN KEY (voce_code) REFERENCES cfg_inventory_voci (voce_code), + CONSTRAINT fk_subclass_org FOREIGN KEY (organization_id) REFERENCES organizations (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +ALTER TABLE assets ADD COLUMN voce_code VARCHAR(16) NULL DEFAULT NULL; +ALTER TABLE assets ADD COLUMN subclass_id INT NULL DEFAULT NULL; +ALTER TABLE assets ADD CONSTRAINT fk_assets_subclass FOREIGN KEY (subclass_id) REFERENCES inventory_subclassi (id) ON DELETE SET NULL; diff --git a/public/_app-bi-demo.html b/public/_app-bi-demo.html index 3eaeaa8..204e9f4 100644 --- a/public/_app-bi-demo.html +++ b/public/_app-bi-demo.html @@ -70,9 +70,9 @@ - - - + + + - - - + + + + - + + @@ -165,9 +165,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -377,15 +372,16 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - - - + + + - + + - - - + + + diff --git a/public/cross-analysis.html b/public/cross-analysis.html index c95ab01..6177e80 100644 --- a/public/cross-analysis.html +++ b/public/cross-analysis.html @@ -382,8 +382,8 @@ - - + + @@ -393,8 +393,8 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - + + - + + @@ -154,9 +154,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + - + + @@ -1152,9 +1152,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -362,9 +362,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - + + @@ -195,9 +195,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + diff --git a/public/js/api.js b/public/js/api.js index efa9392..6e0dd4c 100644 --- a/public/js/api.js +++ b/public/js/api.js @@ -384,6 +384,8 @@ class NIS2API { scoreAsset(id, criteria) { return this.post(`/assets/${id}/score`, { criteria }); } listRelevantSystems() { return this.get('/assets/relevantSystems'); } importAssets(data) { return this.post('/assets/import', data); } // P2 import CMDB/CSV + listAssetSubclasses() { return this.get('/assets/subclasses'); } // C4 — voci + sottoclassi + addAssetSubclass(data) { return this.post('/assets/subclasses', data); } // C4 — nuova sottoclasse org getControlsMonitoring() { return this.get('/audit/controlsMonitoring'); } getAcnRequirements() { return this.get('/audit/acnRequirements'); } // requisiti ACN per org updateAcnRequirement(id, status, note) { return this.put(`/audit/acnRequirements/${id}`, { status, evidence_note: note }); } diff --git a/public/js/help.js b/public/js/help.js index 1306075..58ba675 100644 --- a/public/js/help.js +++ b/public/js/help.js @@ -689,12 +689,12 @@ const HelpSystem = (function () { intro: 'Il modulo Inventario consente di catalogare e gestire i beni ICT (hardware e software) dell\'organizzazione, fondamentale per la gestione dei rischi e la sicurezza delle reti e dei sistemi informativi ai sensi dell\'art. 24 del D.Lgs. 138/2024 (che recepisce l\'art. 21 della Direttiva (UE) 2022/2555).', sections: [ { - heading: 'Categorizzazione dei beni', + heading: 'Le due voci dell\'inventario', items: [ - 'Hardware - server, workstation, dispositivi di rete, dispositivi mobili, apparati di sicurezza.', - 'Software - applicativi, sistemi operativi, database, middleware, servizi cloud.', - 'Rete - reti LAN/WAN, collegamenti internet, VPN, segmenti di rete.', - 'Dati - archivi, database, backup, dati personali, dati critici per il business.' + 'La normativa prevede due sole voci principali: ID.AM-01 Hardware (apparati fisici: dispositivi IT, IoT, OT e mobili) e ID.AM-02 Software, servizi e sistemi (applicazioni commerciali/open-source/custom, servizi, sistemi, API).', + 'Il dettaglio si esprime con le sottoclassi: alcune di default (es. Server, Dispositivi di rete, Applicazioni software, Basi dati e archivi) e altre che puoi aggiungere tu con "+ aggiungi" accanto al campo Sottoclasse.', + 'Le due voci principali non sono modificabili; le sottoclassi organizzano il tuo inventario sotto di esse.', + 'I flussi di rete (ID.AM-03, solo soggetti essenziali) e i servizi dei fornitori (ID.AM-04) sono registri distinti, non voci dell\'inventario asset.' ] }, { diff --git a/public/kb.html b/public/kb.html index 4f60d80..83ebce9 100644 --- a/public/kb.html +++ b/public/kb.html @@ -151,8 +151,8 @@ - - + + @@ -161,9 +161,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + + - - + + - + + - - - + + + + diff --git a/public/normative.html b/public/normative.html index f79549b..c5719a4 100644 --- a/public/normative.html +++ b/public/normative.html @@ -112,8 +112,8 @@ - - + + @@ -123,9 +123,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - - + + - + + - - - + + + diff --git a/public/policies.html b/public/policies.html index 94137c7..03cc3f0 100644 --- a/public/policies.html +++ b/public/policies.html @@ -333,8 +333,8 @@ - - + + @@ -344,9 +344,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - - - + + + diff --git a/public/register.html b/public/register.html index c1d69e1..434581b 100644 --- a/public/register.html +++ b/public/register.html @@ -268,8 +268,8 @@ - - + + - + + @@ -454,9 +454,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - - - + + + - + + @@ -505,9 +505,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -683,9 +683,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + - + + - - - + + + - + + @@ -488,9 +488,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -303,9 +303,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - + + @@ -218,9 +218,9 @@ bootstrap.loadFonts('/vendor/bootstrap-italia/dist/fonts'); } - - - + + + - - + + +