From ad946ad82691ee07024629810cee555492ee9dfe Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 16 Jul 2026 19:03:01 +0200 Subject: [PATCH 01/12] feat(compliance): add 7 intl framework catalogs (ISO 27005/31000, NIST 800-53, RGPD, DORA, NIS2, SOX) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the "5. Conformité" target framework list. Each catalog is a plugin file (init()+register()) that auto-surfaces in GET /compliance/catalogs and the import modal — no handler or frontend change needed. - ISO/IEC 27005:2022 (19) — infosec risk management process activities - ISO 31000:2018 (22) — 8 principles + framework + process - NIST SP 800-53 Rev.5 (20) — the 20 control families - RGPD / EU 2016/679 (22) — key operational articles (FR descriptions) - DORA / EU 2022/2554 (19) — 5 pillars, key articles (FR) - NIS2 / EU 2022/2555 (12) — governance + art.21 measures + notification (FR) - SOX 2002 (10) — statutory sections + ITGC domains IDs/codes are each framework's public structure; descriptions are original summaries with a source citation per control. TestExpectedControlCounts locks each count against truncation. --- .../pkg/compliance/catalog_dora_2022_2554.go | 58 ++++++++++++++++++ .../pkg/compliance/catalog_gdpr_2016_679.go | 57 ++++++++++++++++++ .../pkg/compliance/catalog_iso27005_2022.go | 60 +++++++++++++++++++ .../pkg/compliance/catalog_iso31000_2018.go | 57 ++++++++++++++++++ .../pkg/compliance/catalog_nis2_2022_2555.go | 47 +++++++++++++++ .../pkg/compliance/catalog_nist_800_53_r5.go | 50 ++++++++++++++++ backend/pkg/compliance/catalog_sox_2002.go | 46 ++++++++++++++ backend/pkg/compliance/catalog_test.go | 8 +++ 8 files changed, 383 insertions(+) create mode 100644 backend/pkg/compliance/catalog_dora_2022_2554.go create mode 100644 backend/pkg/compliance/catalog_gdpr_2016_679.go create mode 100644 backend/pkg/compliance/catalog_iso27005_2022.go create mode 100644 backend/pkg/compliance/catalog_iso31000_2018.go create mode 100644 backend/pkg/compliance/catalog_nis2_2022_2555.go create mode 100644 backend/pkg/compliance/catalog_nist_800_53_r5.go create mode 100644 backend/pkg/compliance/catalog_sox_2002.go diff --git a/backend/pkg/compliance/catalog_dora_2022_2554.go b/backend/pkg/compliance/catalog_dora_2022_2554.go new file mode 100644 index 00000000..996ac0bc --- /dev/null +++ b/backend/pkg/compliance/catalog_dora_2022_2554.go @@ -0,0 +1,58 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +// DORA — Règlement (UE) 2022/2554 sur la résilience opérationnelle numérique du +// secteur financier. Structuré autour de cinq piliers : gestion du risque TIC, +// gestion/notification des incidents, tests de résilience, risque lié aux tiers +// prestataires TIC, et partage d'informations. Modélisé au niveau des articles +// clés. Les numéros d'articles sont la structure publique du règlement (fiables) ; +// les descriptions sont des résumés originaux, pas le texte officiel. Vérifier +// contre le Règlement (UE) 2022/2554 avant un audit. + +func init() { + register(Catalog{ + Key: "dora-2022-2554", + Name: "DORA (UE 2022/2554)", + Version: "2022", + Description: "Digital Operational Resilience Act — résilience opérationnelle numérique du secteur financier : risque TIC, incidents, tests, tiers prestataires et partage d'informations.", + Available: true, + Controls: dora20222554Controls, + }) +} + +const doraSource = "DORA (UE) 2022/2554, art. " + +var dora20222554Controls = []CatalogControl{ + // Pilier 1 — Gestion du risque lié aux TIC (Chapitre II) + {"Art.5", "Gouvernance et organisation", "L'organe de direction définit, approuve et supervise le cadre de gestion du risque lié aux TIC et en porte la responsabilité finale.", doraSource + "5"}, + {"Art.6", "Cadre de gestion du risque lié aux TIC", "Disposer d'un cadre solide, documenté et réexaminé, couvrant les stratégies, politiques, procédures et outils de protection des actifs informationnels et TIC.", doraSource + "6"}, + {"Art.7", "Systèmes, protocoles et outils TIC", "Utiliser et maintenir des systèmes, protocoles et outils TIC fiables, dotés de capacités suffisantes et technologiquement résilients.", doraSource + "7"}, + {"Art.8", "Identification", "Identifier, classer et documenter les fonctions métier, actifs informationnels et actifs TIC ainsi que leurs interdépendances.", doraSource + "8"}, + {"Art.9", "Protection et prévention", "Mettre en œuvre des politiques et mesures de sécurité (contrôle d'accès, chiffrement, gestion des changements) pour protéger les systèmes TIC.", doraSource + "9"}, + {"Art.10", "Détection", "Mettre en place des mécanismes de détection rapide des activités anormales et des incidents potentiels liés aux TIC.", doraSource + "10"}, + {"Art.11", "Réponse et rétablissement", "Disposer de politiques de continuité des activités et de plans de réponse et de rétablissement TIC testés et à jour.", doraSource + "11"}, + {"Art.12", "Sauvegarde, restauration et rétablissement", "Définir des politiques et procédures de sauvegarde et des méthodes de restauration/rétablissement préservant l'intégrité des données.", doraSource + "12"}, + {"Art.13", "Apprentissage et évolution", "Recueillir les enseignements des incidents et tests pour faire évoluer le cadre de gestion du risque TIC et la sensibilisation.", doraSource + "13"}, + + // Pilier 2 — Gestion, classification et notification des incidents (Chapitre III) + {"Art.17", "Processus de gestion des incidents TIC", "Définir et mettre en œuvre un processus de détection, gestion et notification des incidents liés aux TIC.", doraSource + "17"}, + {"Art.18", "Classification des incidents et cybermenaces", "Classer les incidents liés aux TIC et évaluer leur importance selon les critères prévus (clients affectés, durée, portée géographique, pertes de données…).", doraSource + "18"}, + {"Art.19", "Notification des incidents majeurs", "Notifier les incidents majeurs liés aux TIC à l'autorité compétente selon les délais et modèles prescrits.", doraSource + "19"}, + + // Pilier 3 — Tests de résilience opérationnelle numérique (Chapitre IV) + {"Art.24", "Programme de tests de résilience", "Établir un programme de tests de résilience opérationnelle numérique proportionné, sain et complet.", doraSource + "24"}, + {"Art.25", "Tests des outils et systèmes TIC", "Tester régulièrement les outils et systèmes TIC (analyses de vulnérabilités, tests de sécurité, tests de continuité) et remédier aux faiblesses.", doraSource + "25"}, + {"Art.26", "Tests avancés (TLPT)", "Réaliser des tests de pénétration fondés sur la menace (Threat-Led Penetration Testing) pour les entités désignées.", doraSource + "26"}, + + // Pilier 4 — Gestion du risque lié aux tiers prestataires TIC (Chapitre V) + {"Art.28", "Principes de gestion du risque tiers TIC", "Gérer le risque lié aux prestataires tiers de services TIC dans le cadre global de gestion du risque, avec une stratégie et un registre d'information.", doraSource + "28"}, + {"Art.29", "Concentration du risque", "Évaluer le risque de concentration lié au recours à des prestataires tiers de services TIC, y compris la sous-traitance en chaîne.", doraSource + "29"}, + {"Art.30", "Dispositions contractuelles clés", "Encadrer les prestations TIC par des contrats comportant les clauses obligatoires (accès, audit, résiliation, niveaux de service, sécurité).", doraSource + "30"}, + + // Pilier 5 — Partage d'informations (Chapitre VI) + {"Art.45", "Partage d'informations sur les cybermenaces", "Participer, le cas échéant, à des accords de partage d'informations et de renseignements sur les cybermenaces au sein de communautés de confiance.", doraSource + "45"}, +} diff --git a/backend/pkg/compliance/catalog_gdpr_2016_679.go b/backend/pkg/compliance/catalog_gdpr_2016_679.go new file mode 100644 index 00000000..f25fd089 --- /dev/null +++ b/backend/pkg/compliance/catalog_gdpr_2016_679.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +// RGPD — Règlement (UE) 2016/679 relatif à la protection des personnes physiques +// à l'égard du traitement des données à caractère personnel. Modélisé au niveau des +// articles opérationnels que le responsable de traitement/sous-traitant doit +// satisfaire. Les numéros et intitulés d'articles sont la structure publique du +// règlement (fiables) ; les descriptions sont des résumés originaux, pas le texte +// officiel. Vérifier contre le texte du Règlement (UE) 2016/679 avant un audit. +// Descriptions en français : cadre européen, marché cible francophone. + +func init() { + register(Catalog{ + Key: "gdpr-2016-679", + Name: "RGPD (UE 2016/679)", + Version: "2016", + Description: "Règlement Général sur la Protection des Données — obligations du responsable de traitement et du sous-traitant : principes, bases légales, droits des personnes, sécurité, violations, DPIA, DPO et transferts.", + Available: true, + Controls: gdpr2016679Controls, + }) +} + +const gdprSource = "RGPD (UE) 2016/679, art. " + +var gdpr2016679Controls = []CatalogControl{ + // Chapitre II — Principes + {"Art.5", "Principes relatifs au traitement", "Traiter les données de manière licite, loyale et transparente, pour des finalités déterminées, avec minimisation, exactitude, limitation de la conservation, intégrité/confidentialité et responsabilité (accountability).", gdprSource + "5"}, + {"Art.6", "Licéité du traitement", "Ne traiter des données que si au moins une base légale s'applique (consentement, contrat, obligation légale, intérêt vital, mission d'intérêt public, intérêt légitime).", gdprSource + "6"}, + {"Art.7", "Conditions applicables au consentement", "Pouvoir démontrer que la personne a consenti, présenter la demande de manière claire et distincte, et permettre un retrait aussi simple que le consentement.", gdprSource + "7"}, + {"Art.9", "Catégories particulières de données", "Interdire par principe le traitement des données sensibles (santé, biométrie, opinions…) sauf exception encadrée, et mettre en place des garanties renforcées.", gdprSource + "9"}, + + // Chapitre III — Droits de la personne concernée + {"Art.12-14", "Transparence et information", "Fournir une information concise, transparente et accessible sur le traitement, que les données soient collectées directement ou indirectement.", gdprSource + "12-14"}, + {"Art.15", "Droit d'accès", "Permettre à la personne d'obtenir la confirmation que ses données sont traitées, l'accès à ces données et les informations sur le traitement.", gdprSource + "15"}, + {"Art.16", "Droit de rectification", "Permettre la rectification et le complètement des données inexactes ou incomplètes dans les meilleurs délais.", gdprSource + "16"}, + {"Art.17", "Droit à l'effacement (« droit à l'oubli »)", "Effacer les données sur demande lorsque les conditions sont réunies et répercuter la demande aux destinataires et sous-traitants.", gdprSource + "17"}, + {"Art.18", "Droit à la limitation du traitement", "Restreindre le traitement dans les cas prévus (contestation d'exactitude, opposition, traitement illicite) plutôt que d'effacer.", gdprSource + "18"}, + {"Art.20", "Droit à la portabilité des données", "Restituer les données fournies par la personne dans un format structuré, couramment utilisé et lisible par machine, et les transmettre à un autre responsable si techniquement possible.", gdprSource + "20"}, + {"Art.21", "Droit d'opposition", "Permettre à la personne de s'opposer au traitement, notamment à la prospection commerciale et au profilage.", gdprSource + "21"}, + {"Art.22", "Décision individuelle automatisée", "Encadrer les décisions fondées exclusivement sur un traitement automatisé, y compris le profilage, produisant des effets juridiques.", gdprSource + "22"}, + + // Chapitre IV — Responsable du traitement et sous-traitant + {"Art.24", "Responsabilité du responsable de traitement", "Mettre en œuvre des mesures techniques et organisationnelles appropriées pour garantir et démontrer la conformité du traitement.", gdprSource + "24"}, + {"Art.25", "Protection des données dès la conception et par défaut", "Intégrer la protection des données dès la conception (privacy by design) et par défaut (privacy by default) dans les traitements.", gdprSource + "25"}, + {"Art.28", "Sous-traitant", "N'avoir recours qu'à des sous-traitants présentant des garanties suffisantes et encadrer la relation par un contrat conforme (article 28.3).", gdprSource + "28"}, + {"Art.30", "Registre des activités de traitement", "Tenir un registre des activités de traitement documentant finalités, catégories, destinataires, transferts et mesures de sécurité.", gdprSource + "30"}, + {"Art.32", "Sécurité du traitement", "Mettre en œuvre des mesures de sécurité adaptées au risque : pseudonymisation, chiffrement, confidentialité, intégrité, disponibilité, résilience et tests réguliers.", gdprSource + "32"}, + {"Art.33", "Notification d'une violation à l'autorité", "Notifier une violation de données à l'autorité de contrôle dans les 72 heures lorsqu'elle présente un risque pour les personnes.", gdprSource + "33"}, + {"Art.34", "Communication d'une violation à la personne", "Communiquer la violation aux personnes concernées dans les meilleurs délais lorsqu'elle engendre un risque élevé.", gdprSource + "34"}, + {"Art.35", "Analyse d'impact (AIPD/DPIA)", "Réaliser une analyse d'impact relative à la protection des données pour les traitements susceptibles d'engendrer un risque élevé.", gdprSource + "35"}, + {"Art.37-39", "Délégué à la protection des données (DPO)", "Désigner un DPO lorsque requis, garantir son indépendance et ses moyens, et lui confier l'information, le conseil et le contrôle de conformité.", gdprSource + "37-39"}, + {"Art.44-49", "Transferts vers des pays tiers", "N'effectuer de transferts hors UE que sous garanties appropriées (décision d'adéquation, clauses types, BCR) ou dérogation prévue.", gdprSource + "44-49"}, +} diff --git a/backend/pkg/compliance/catalog_iso27005_2022.go b/backend/pkg/compliance/catalog_iso27005_2022.go new file mode 100644 index 00000000..b13b894f --- /dev/null +++ b/backend/pkg/compliance/catalog_iso27005_2022.go @@ -0,0 +1,60 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +// ISO/IEC 27005:2022 — Guidance on managing information security risks. Unlike +// ISO 27001, this is a process standard, not a control set: it describes the +// activities of the information security risk management process. We model it at +// the clause/activity level so a tenant can assess whether each activity of the +// process is in place. Clause numbers are ISO's own published structure and are +// reliable; the descriptions are original summaries of each activity's intent, +// not ISO's own text. Verify against ISO/IEC 27005:2022 before an audit. + +func init() { + register(Catalog{ + Key: "iso27005-2022", + Name: "ISO/IEC 27005", + Version: "2022", + Description: "Information security risk management — the activities of the ISO 27005 risk management process (context, assessment, treatment, operation, ISMS integration).", + Available: true, + Controls: iso270052022Controls, + }) +} + +const iso27005Source = "ISO/IEC 27005:2022, Clause " + +var iso270052022Controls = []CatalogControl{ + // Clause 5 — Information security risk management process + {"5", "Information Security Risk Management Process", "Establish and run an iterative information security risk management process aligned with the organization's ISMS, covering context establishment, assessment, treatment, acceptance, communication and monitoring.", iso27005Source + "5"}, + + // Clause 6 — Context establishment + {"6.1", "Organizational Considerations", "Establish the internal and external context, purpose and organizational considerations that frame information security risk management.", iso27005Source + "6.1"}, + {"6.2", "Identifying Basic Requirements of Interested Parties", "Identify the information security requirements and expectations of interested parties that risk management must satisfy.", iso27005Source + "6.2"}, + {"6.3", "Applying Risk Assessment", "Define and document the risk criteria — risk acceptance criteria and criteria for performing risk assessments — before assessment begins.", iso27005Source + "6.3"}, + + // Clause 7 — Information security risk assessment process + {"7.1", "General Risk Assessment Approach", "Define the overall approach to identifying, analysing and evaluating information security risks consistently across the organization.", iso27005Source + "7.1"}, + {"7.2", "Identifying Risks", "Identify the risks associated with the loss of confidentiality, integrity and availability of information, using an event-based and/or asset-based approach.", iso27005Source + "7.2"}, + {"7.3", "Analysing Risks", "Assess the potential consequences and the likelihood of identified risks and determine the resulting level of risk.", iso27005Source + "7.3"}, + {"7.4", "Evaluating Risks", "Compare the results of risk analysis against the risk criteria to prioritize risks and decide which require treatment.", iso27005Source + "7.4"}, + + // Clause 8 — Information security risk treatment process + {"8.1", "General Risk Treatment Approach", "Define how risk treatment options are selected and how the treatment process is documented and approved.", iso27005Source + "8.1"}, + {"8.2", "Selecting Risk Treatment Options", "Select appropriate treatment options (modify, retain, avoid or share the risk) based on the risk assessment results.", iso27005Source + "8.2"}, + {"8.3", "Determining Controls", "Determine the controls necessary to implement the chosen treatment options and compare them with a reference control set such as ISO 27001 Annex A.", iso27005Source + "8.3"}, + {"8.4", "Producing a Statement of Applicability", "Produce a Statement of Applicability justifying the inclusion or exclusion of controls and their implementation status.", iso27005Source + "8.4"}, + {"8.5", "Risk Treatment Plan", "Formulate a risk treatment plan assigning owners, resources, priorities and timelines to the selected controls.", iso27005Source + "8.5"}, + {"8.6", "Residual Risk Acceptance", "Obtain the risk owners' documented approval of the risk treatment plan and their acceptance of the residual risks.", iso27005Source + "8.6"}, + + // Clause 9 — Operation + {"9.1", "Performing Information Security Risk Assessment", "Perform information security risk assessments at planned intervals and when significant changes occur, retaining documented results.", iso27005Source + "9.1"}, + {"9.2", "Performing Information Security Risk Treatment", "Implement the risk treatment plan and retain documented information on the results of risk treatment.", iso27005Source + "9.2"}, + + // Clause 10 — Leveraging related ISMS processes + {"10.1", "Context of the Organization", "Integrate risk management with the ISMS's understanding of the organization and its context.", iso27005Source + "10.1"}, + {"10.2", "Monitoring and Review", "Continually monitor and review risks and the risk factors (value of assets, threats, vulnerabilities, likelihood, consequences) to keep the risk picture current.", iso27005Source + "10.2"}, + {"10.3", "Communication and Consultation", "Communicate and consult with internal and external interested parties about information security risks throughout the process.", iso27005Source + "10.3"}, +} diff --git a/backend/pkg/compliance/catalog_iso31000_2018.go b/backend/pkg/compliance/catalog_iso31000_2018.go new file mode 100644 index 00000000..4e018071 --- /dev/null +++ b/backend/pkg/compliance/catalog_iso31000_2018.go @@ -0,0 +1,57 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +// ISO 31000:2018 — Risk management — Guidelines. A generic (all-risk, not just +// information security) framework built on three parts: the Principles (Clause 4), +// the Framework (Clause 5) and the Process (Clause 6). We model each principle, +// framework component and process activity as an assessable item. Clause numbers +// and component names are ISO's own published structure and are reliable; the +// descriptions are original summaries, not ISO's own text. Verify against +// ISO 31000:2018 before an audit. + +func init() { + register(Catalog{ + Key: "iso31000-2018", + Name: "ISO 31000", + Version: "2018", + Description: "Risk management — Guidelines. The 8 principles, the risk management framework (leadership, integration, design, implementation, evaluation, improvement) and the risk management process.", + Available: true, + Controls: iso310002018Controls, + }) +} + +const iso31000Source = "ISO 31000:2018, Clause " + +var iso310002018Controls = []CatalogControl{ + // Clause 4 — Principles + {"4-INT", "Principle: Integrated", "Risk management is an integral part of all organizational activities.", iso31000Source + "4 (Integrated)"}, + {"4-STR", "Principle: Structured and Comprehensive", "A structured and comprehensive approach to risk management contributes to consistent and comparable results.", iso31000Source + "4 (Structured and comprehensive)"}, + {"4-CUS", "Principle: Customized", "The risk management framework and process are customized and proportionate to the organization's external and internal context and objectives.", iso31000Source + "4 (Customized)"}, + {"4-INC", "Principle: Inclusive", "Appropriate and timely involvement of stakeholders enables their knowledge, views and perceptions to be considered.", iso31000Source + "4 (Inclusive)"}, + {"4-DYN", "Principle: Dynamic", "Risks can emerge, change or disappear as context changes; risk management anticipates, detects, acknowledges and responds to those changes in an appropriate and timely manner.", iso31000Source + "4 (Dynamic)"}, + {"4-INF", "Principle: Best Available Information", "The inputs to risk management are based on historical and current information, as well as on future expectations, with account taken of any limitations and uncertainties.", iso31000Source + "4 (Best available information)"}, + {"4-HUM", "Principle: Human and Cultural Factors", "Human behaviour and culture significantly influence all aspects of risk management at each level and stage.", iso31000Source + "4 (Human and cultural factors)"}, + {"4-IMP", "Principle: Continual Improvement", "Risk management is continually improved through learning and experience.", iso31000Source + "4 (Continual improvement)"}, + + // Clause 5 — Framework + {"5.2", "Leadership and Commitment", "Top management and oversight bodies ensure that risk management is integrated into all organizational activities and demonstrate leadership and commitment.", iso31000Source + "5.2"}, + {"5.3", "Integration", "Integrate risk management into the organization's structure, governance and all its activities, recognizing that it is dynamic and iterative.", iso31000Source + "5.3"}, + {"5.4", "Design", "Design the risk management framework by understanding the organization and its context, articulating commitment, assigning roles and authorities, and allocating resources.", iso31000Source + "5.4"}, + {"5.5", "Implementation", "Implement the framework by developing a plan, identifying decision-making, and ensuring arrangements for managing risk are understood and practised.", iso31000Source + "5.5"}, + {"5.6", "Evaluation", "Periodically measure the framework's performance against its purpose, implementation plans, indicators and expected behaviour.", iso31000Source + "5.6"}, + {"5.7", "Improvement", "Continually monitor, adapt and improve the risk management framework to address internal and external changes.", iso31000Source + "5.7"}, + + // Clause 6 — Process + {"6.2", "Communication and Consultation", "Assist relevant stakeholders in understanding risk, the basis for decisions, and the reasons particular actions are required, throughout the process.", iso31000Source + "6.2"}, + {"6.3", "Scope, Context and Criteria", "Define the scope of risk management activities and establish the external and internal context and the risk criteria.", iso31000Source + "6.3"}, + {"6.4.2", "Risk Identification", "Find, recognize and describe risks that might help or prevent the organization from achieving its objectives.", iso31000Source + "6.4.2"}, + {"6.4.3", "Risk Analysis", "Comprehend the nature of risk and its characteristics, including the level of risk, considering uncertainties, sources, consequences, likelihood, events, scenarios and controls.", iso31000Source + "6.4.3"}, + {"6.4.4", "Risk Evaluation", "Support decisions by comparing the results of risk analysis with the established risk criteria to determine where additional action is required.", iso31000Source + "6.4.4"}, + {"6.5", "Risk Treatment", "Select and implement options for addressing risk, formulate and implement risk treatment plans, and assess residual risk.", iso31000Source + "6.5"}, + {"6.6", "Monitoring and Review", "Assure and improve the quality and effectiveness of process design, implementation and outcomes through ongoing monitoring and periodic review.", iso31000Source + "6.6"}, + {"6.7", "Recording and Reporting", "Document and report the risk management process and its outcomes through appropriate mechanisms to support decisions and improve activities.", iso31000Source + "6.7"}, +} diff --git a/backend/pkg/compliance/catalog_nis2_2022_2555.go b/backend/pkg/compliance/catalog_nis2_2022_2555.go new file mode 100644 index 00000000..a80947ea --- /dev/null +++ b/backend/pkg/compliance/catalog_nis2_2022_2555.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +// NIS2 — Directive (UE) 2022/2555 concernant des mesures pour un niveau élevé +// commun de cybersécurité dans l'ensemble de l'Union. Le cœur opérationnel est +// l'article 21 (mesures de gestion des risques de cybersécurité) qui énumère dix +// mesures minimales (a→j), encadré par la gouvernance (art. 20) et les obligations +// de notification (art. 23). Les numéros d'articles sont la structure publique de +// la directive (fiables) ; les descriptions sont des résumés originaux, pas le +// texte officiel. Vérifier contre la Directive (UE) 2022/2555 avant un audit. + +func init() { + register(Catalog{ + Key: "nis2-2022-2555", + Name: "NIS2 (UE 2022/2555)", + Version: "2022", + Description: "Directive NIS2 — cybersécurité des entités essentielles et importantes : gouvernance, les dix mesures de gestion des risques (art. 21) et les obligations de notification d'incidents.", + Available: true, + Controls: nis220222555Controls, + }) +} + +const nis2Source = "NIS2 (UE) 2022/2555, art. " + +var nis220222555Controls = []CatalogControl{ + // Gouvernance + {"Art.20", "Gouvernance", "Les organes de direction approuvent les mesures de gestion des risques de cybersécurité, en supervisent la mise en œuvre et suivent des formations dédiées.", nis2Source + "20"}, + + // Article 21 — les dix mesures minimales de gestion des risques (a → j) + {"Art.21(a)", "Politiques d'analyse des risques et de sécurité des SI", "Adopter des politiques relatives à l'analyse des risques et à la sécurité des systèmes d'information.", nis2Source + "21.2(a)"}, + {"Art.21(b)", "Gestion des incidents", "Mettre en place une capacité de gestion des incidents (prévention, détection et réponse).", nis2Source + "21.2(b)"}, + {"Art.21(c)", "Continuité des activités", "Assurer la continuité des activités : gestion des sauvegardes, reprise après sinistre et gestion de crise.", nis2Source + "21.2(c)"}, + {"Art.21(d)", "Sécurité de la chaîne d'approvisionnement", "Sécuriser la chaîne d'approvisionnement, y compris les relations avec les fournisseurs et prestataires de services directs.", nis2Source + "21.2(d)"}, + {"Art.21(e)", "Sécurité de l'acquisition, du développement et de la maintenance", "Intégrer la sécurité dans l'acquisition, le développement et la maintenance des réseaux et systèmes, y compris la gestion et la divulgation des vulnérabilités.", nis2Source + "21.2(e)"}, + {"Art.21(f)", "Évaluation de l'efficacité des mesures", "Définir des politiques et procédures pour évaluer l'efficacité des mesures de gestion des risques de cybersécurité.", nis2Source + "21.2(f)"}, + {"Art.21(g)", "Cyberhygiène et formation", "Mettre en œuvre des pratiques d'hygiène informatique de base et une formation à la cybersécurité.", nis2Source + "21.2(g)"}, + {"Art.21(h)", "Cryptographie et chiffrement", "Définir des politiques et procédures relatives à l'usage de la cryptographie et, le cas échéant, du chiffrement.", nis2Source + "21.2(h)"}, + {"Art.21(i)", "Sécurité des ressources humaines et contrôle d'accès", "Gérer la sécurité des ressources humaines, les politiques de contrôle d'accès et la gestion des actifs.", nis2Source + "21.2(i)"}, + {"Art.21(j)", "Authentification multifacteur et communications sécurisées", "Recourir à l'authentification multifacteur ou continue, et à des communications vocales, vidéo et textuelles sécurisées, ainsi qu'à des communications d'urgence sécurisées.", nis2Source + "21.2(j)"}, + + // Obligations de notification + {"Art.23", "Obligations de notification d'incidents", "Notifier sans retard injustifié tout incident important : alerte précoce (24 h), notification (72 h) et rapport final (1 mois).", nis2Source + "23"}, +} diff --git a/backend/pkg/compliance/catalog_nist_800_53_r5.go b/backend/pkg/compliance/catalog_nist_800_53_r5.go new file mode 100644 index 00000000..c4c38874 --- /dev/null +++ b/backend/pkg/compliance/catalog_nist_800_53_r5.go @@ -0,0 +1,50 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +// NIST SP 800-53 Rev. 5 — Security and Privacy Controls for Information Systems +// and Organizations. The catalog has 1000+ base controls and enhancements across +// 20 control families. We model it at the family level (the 20 families), which is +// NIST's own public structure and is reliable; a tenant can add specific base +// controls (e.g. AC-2) as ad-hoc controls on the imported framework. Descriptions +// are original summaries of each family's purpose, not NIST's own text. Verify +// against NIST SP 800-53 Rev. 5 before an audit. + +func init() { + register(Catalog{ + Key: "nist-800-53-r5", + Name: "NIST SP 800-53", + Version: "Rev. 5", + Description: "Security and Privacy Controls for Information Systems and Organizations — the 20 control families (AC, AU, CM, IA, IR, RA, SC, SI, SR, PT, …).", + Available: true, + Controls: nist80053r5Controls, + }) +} + +const nist80053Source = "NIST SP 800-53 Rev. 5, Family " + +var nist80053r5Controls = []CatalogControl{ + {"AC", "Access Control", "Limit information system access to authorized users, processes and devices, and to the types of transactions and functions authorized users are permitted to exercise.", nist80053Source + "AC"}, + {"AT", "Awareness and Training", "Ensure that personnel are trained to carry out their assigned information security and privacy responsibilities and are aware of applicable policies, standards and procedures.", nist80053Source + "AT"}, + {"AU", "Audit and Accountability", "Create, protect and retain system audit records to enable monitoring, analysis, investigation and reporting of unlawful or unauthorized activity, and ensure actions can be traced to individuals.", nist80053Source + "AU"}, + {"CA", "Assessment, Authorization, and Monitoring", "Assess controls, authorize system operation, and continuously monitor security and privacy posture, including plans of action and milestones for deficiencies.", nist80053Source + "CA"}, + {"CM", "Configuration Management", "Establish and maintain baseline configurations and inventories of systems, and enforce configuration change control throughout the system development life cycle.", nist80053Source + "CM"}, + {"CP", "Contingency Planning", "Establish, maintain and test plans for emergency response, backup operations and post-disaster recovery to ensure the availability of critical information resources and continuity of operations.", nist80053Source + "CP"}, + {"IA", "Identification and Authentication", "Uniquely identify and authenticate organizational users, processes and devices before granting access to systems.", nist80053Source + "IA"}, + {"IR", "Incident Response", "Establish an operational incident-handling capability — preparation, detection, analysis, containment, eradication and recovery — and track, document and report incidents.", nist80053Source + "IR"}, + {"MA", "Maintenance", "Perform periodic and timely maintenance on systems and provide effective controls on the tools, techniques, mechanisms and personnel used to conduct maintenance.", nist80053Source + "MA"}, + {"MP", "Media Protection", "Protect system media (digital and non-digital), limit access to authorized users, and sanitize or destroy media before disposal or reuse.", nist80053Source + "MP"}, + {"PE", "Physical and Environmental Protection", "Limit physical access to systems, equipment and operating environments to authorized individuals, and protect against environmental hazards and supporting utilities failures.", nist80053Source + "PE"}, + {"PL", "Planning", "Develop, document and maintain security and privacy plans that describe the controls in place or planned and the rules of behaviour for individuals accessing systems.", nist80053Source + "PL"}, + {"PM", "Program Management", "Implement organization-wide information security and privacy program management controls, including a risk management strategy, resources and enterprise architecture.", nist80053Source + "PM"}, + {"PS", "Personnel Security", "Ensure individuals occupying positions of responsibility are trustworthy, apply screening, and protect systems during personnel actions such as transfers and terminations.", nist80053Source + "PS"}, + {"PT", "PII Processing and Transparency", "Process personally identifiable information in accordance with authority and privacy requirements, and provide transparency to individuals about that processing.", nist80053Source + "PT"}, + {"RA", "Risk Assessment", "Assess the risk to organizational operations, assets and individuals resulting from the operation of systems, including vulnerability scanning and the processing of PII.", nist80053Source + "RA"}, + {"SA", "System and Services Acquisition", "Allocate resources to protect systems, employ a system development life cycle with security and privacy considerations, and manage acquired services and supply.", nist80053Source + "SA"}, + {"SC", "System and Communications Protection", "Monitor, control and protect communications at system boundaries and employ architectural designs, software development techniques and systems engineering to promote security.", nist80053Source + "SC"}, + {"SI", "System and Information Integrity", "Identify, report and correct system flaws in a timely manner, protect against malicious code, and monitor systems for security alerts and advisories.", nist80053Source + "SI"}, + {"SR", "Supply Chain Risk Management", "Manage supply chain risks by developing a strategy, applying provenance and integrity controls, and assessing suppliers and the products and services they provide.", nist80053Source + "SR"}, +} diff --git a/backend/pkg/compliance/catalog_sox_2002.go b/backend/pkg/compliance/catalog_sox_2002.go new file mode 100644 index 00000000..9ce4cabd --- /dev/null +++ b/backend/pkg/compliance/catalog_sox_2002.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +// SOX — the Sarbanes-Oxley Act of 2002. From a GRC/IT perspective, SOX compliance +// centers on internal control over financial reporting (ICFR) — the key statutory +// sections (302, 404, 409, 802, 806, 906) plus the IT General Controls (ITGC) +// domains auditors assess under the COSO framework. Section numbers are the Act's +// own public structure and the ITGC domains are the widely-recognized categories; +// descriptions are original summaries, not legal text. Verify against the Act and +// your auditor's control matrix before an audit. + +func init() { + register(Catalog{ + Key: "sox-2002", + Name: "SOX (Sarbanes-Oxley)", + Version: "2002", + Description: "Sarbanes-Oxley Act — internal control over financial reporting: the key statutory sections and the IT General Controls (access, change management, operations, program development) auditors assess.", + Available: true, + Controls: sox2002Controls, + }) +} + +const ( + soxSecSource = "Sarbanes-Oxley Act of 2002, Section " + soxITGCSource = "SOX ITGC (COSO), Domain " +) + +var sox2002Controls = []CatalogControl{ + // Key statutory sections + {"SOX-302", "Corporate Responsibility for Financial Reports", "Principal officers certify each periodic report — its accuracy, the effectiveness of disclosure controls, and disclosure of deficiencies and fraud to auditors and the audit committee.", soxSecSource + "302"}, + {"SOX-404", "Management Assessment of Internal Controls", "Management establishes, documents, assesses and reports on the effectiveness of internal control over financial reporting, with independent auditor attestation.", soxSecSource + "404"}, + {"SOX-409", "Real Time Issuer Disclosures", "Disclose to the public, on a rapid and current basis, material changes in financial condition or operations.", soxSecSource + "409"}, + {"SOX-802", "Criminal Penalties for Altering Documents", "Retain records and audit work papers, and prohibit the alteration, destruction or falsification of records relevant to financial reporting.", soxSecSource + "802"}, + {"SOX-806", "Protection for Whistleblowers", "Protect employees who report fraud or violations of securities law from retaliation, and provide a confidential reporting mechanism.", soxSecSource + "806"}, + {"SOX-906", "Corporate Responsibility for Financial Reports (Certification)", "Principal executive and financial officers certify that periodic reports fully comply with securities law and fairly present the financial condition of the issuer.", soxSecSource + "906"}, + + // IT General Controls (ITGC) domains + {"ITGC-AC", "Access to Programs and Data", "Restrict logical access to financial applications, databases and infrastructure to authorized users through provisioning, periodic review, segregation of duties and privileged-access controls.", soxITGCSource + "Access to Programs and Data"}, + {"ITGC-CM", "Program Changes", "Authorize, test, approve and migrate changes to financially-relevant systems through a controlled change management process that separates development from production.", soxITGCSource + "Program Changes"}, + {"ITGC-PD", "Program Development", "Govern the acquisition and development of new financially-relevant systems with documented requirements, testing, approval and data-conversion controls.", soxITGCSource + "Program Development"}, + {"ITGC-OP", "Computer Operations", "Control the operation of financially-relevant systems: job scheduling, backup and recovery, incident/problem management and physical/environmental protection of infrastructure.", soxITGCSource + "Computer Operations"}, +} diff --git a/backend/pkg/compliance/catalog_test.go b/backend/pkg/compliance/catalog_test.go index 542267df..7f61838a 100644 --- a/backend/pkg/compliance/catalog_test.go +++ b/backend/pkg/compliance/catalog_test.go @@ -67,6 +67,14 @@ func TestExpectedControlCounts(t *testing.T) { "pci-dss-4.0": 12, // 12 core requirements "hipaa-security": 22, // Administrative(9)+Physical(4)+Technical(5)+Organizational(2)+Docs(2) "soc2-tsc": 51, // Common Criteria(33)+A(3)+C(2)+PI(5)+P(8) + // International frameworks added for the "5. Conformité" spec — full target list. + "iso27005-2022": 19, // Process clauses 5,6,7,8,9,10 activities + "iso31000-2018": 22, // 8 principles + 6 framework components + 8 process activities + "nist-800-53-r5": 20, // 20 control families (AC…SR) + "gdpr-2016-679": 22, // key operational articles (principles, rights, security, DPO, transfers) + "dora-2022-2554": 19, // 5 pillars — key articles + "nis2-2022-2555": 12, // governance + the 10 art.21 measures + notification + "sox-2002": 10, // 6 statutory sections + 4 ITGC domains } for key, want := range cases { t.Run(key, func(t *testing.T) { From 32b167dada9ed9bd625244ca6080b36bdadaab4a Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 16 Jul 2026 19:21:55 +0200 Subject: [PATCH 02/12] =?UTF-8?q?feat(backend):=20compliance=20gap=20analy?= =?UTF-8?q?sis=20endpoint=20(analyse=20d'=C3=A9carts)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /compliance/gap-analysis returns every unsatisfied control (not implemented / in progress) across the tenant's frameworks, with per-framework and overall roll-ups. Optional ?framework_id= scopes to one framework. - GetGapAnalysisUseCase reuses existing repo ports (ListFrameworks, ListControlsByFramework, CountEvidencesByFramework) — no new persistence. - Fully tenant-scoped; unknown/other-tenant framework → ErrNotFound. - Evidence counts folded in via one grouped query per framework (no N+1). - 3 use-case tests: all-frameworks roll-up, single-framework, unknown→404. --- backend/cmd/server/main.go | 5 + .../application/compliance/gap_analysis.go | 157 ++++++++++++++++++ .../compliance/gap_analysis_test.go | 98 +++++++++++ .../internal/handler/compliance_handler.go | 23 +++ .../handler/compliance_handler_test.go | 2 + 5 files changed, 285 insertions(+) create mode 100644 backend/internal/application/compliance/gap_analysis.go create mode 100644 backend/internal/application/compliance/gap_analysis_test.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 797a40fa..d0a233ec 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -643,6 +643,7 @@ func main() { deleteEvidenceUC := compliance.NewDeleteEvidenceUseCase(complianceRepo, fileStorage) downloadEvidenceUC := compliance.NewDownloadEvidenceUseCase(complianceRepo, fileStorage) getProgressUC := compliance.NewGetComplianceProgressUseCase(complianceRepo) + getGapAnalysisUC := compliance.NewGetGapAnalysisUseCase(complianceRepo) listCatalogsUC := compliance.NewListCatalogsUseCase() importCatalogUC := compliance.NewImportCatalogUseCase(complianceRepo) // M4 — official compliance report (PDF). Reuses userRepo/orgRepo (declared @@ -653,6 +654,7 @@ func main() { createControlUC, getControlUC, listControlsUC, updateControlUC, deleteControlUC, createEvidenceUC, listEvidencesUC, deleteEvidenceUC, downloadEvidenceUC, getProgressUC, listCatalogsUC, importCatalogUC, generateReportUC, + getGapAnalysisUC, ) // NOTE: these routes sit under `protected`, whose base middleware (middleware.Protected, @@ -685,6 +687,9 @@ func main() { protected.Get("/compliance/frameworks/:frameworkId", complianceFrameworkRead, complianceHandler.GetFramework) protected.Delete("/compliance/frameworks/:frameworkId", complianceFrameworkDelete, complianceHandler.DeleteFramework) protected.Get("/compliance/frameworks/:frameworkId/progress", complianceControlRead, complianceHandler.GetProgress) + // Gap analysis ("analyse d'écarts") — every unsatisfied control across the + // tenant's frameworks (optional ?framework_id= scopes to one). + protected.Get("/compliance/gap-analysis", complianceControlRead, complianceHandler.GetGapAnalysis) // Official compliance report (PDF, 1-click) — reads a tenant's controls/evidence, same tier as reading them. protected.Get("/compliance/frameworks/:frameworkId/report", complianceControlRead, complianceHandler.GenerateReport) protected.Get("/compliance/frameworks/:frameworkId/controls", complianceControlRead, complianceHandler.ListControls) diff --git a/backend/internal/application/compliance/gap_analysis.go b/backend/internal/application/compliance/gap_analysis.go new file mode 100644 index 00000000..2f67fe60 --- /dev/null +++ b/backend/internal/application/compliance/gap_analysis.go @@ -0,0 +1,157 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +import ( + "context" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" +) + +// GapControl is one unsatisfied control surfaced by the gap analysis — a control +// that is neither implemented nor marked not-applicable, i.e. an open compliance +// gap the tenant must remediate. +type GapControl struct { + ControlID uuid.UUID `json:"control_id"` + FrameworkID uuid.UUID `json:"framework_id"` + FrameworkName string `json:"framework_name"` + ReferenceCode string `json:"reference_code"` + Name string `json:"name"` + Description string `json:"description"` + Status domain.ControlStatus `json:"status"` + SourceReference string `json:"source_reference"` + EvidenceCount int `json:"evidence_count"` +} + +// FrameworkGapSummary rolls up gap counts per framework so the UI can show, at a +// glance, which framework carries the most risk. +type FrameworkGapSummary struct { + FrameworkID uuid.UUID `json:"framework_id"` + FrameworkName string `json:"framework_name"` + Version string `json:"version"` + Total int `json:"total"` + Implemented int `json:"implemented"` + InProgress int `json:"in_progress"` + NotImplemented int `json:"not_implemented"` + NotApplicable int `json:"not_applicable"` + Gaps int `json:"gaps"` + PercentComplete float64 `json:"percent_complete"` +} + +// GapAnalysis is the "analyse d'écarts" DTO: every open gap across a tenant's +// frameworks, plus per-framework and overall roll-ups. Computed, not persisted. +type GapAnalysis struct { + TotalControls int `json:"total_controls"` + TotalGaps int `json:"total_gaps"` + Frameworks []FrameworkGapSummary `json:"frameworks"` + Gaps []GapControl `json:"gaps"` +} + +// GetGapAnalysisUseCase identifies unsatisfied controls across all of a tenant's +// frameworks (or a single framework when a frameworkID is supplied). It reuses the +// existing repository ports — no new persistence method needed — and stays fully +// tenant-scoped. +type GetGapAnalysisUseCase struct { + repo domain.ComplianceRepository +} + +func NewGetGapAnalysisUseCase(repo domain.ComplianceRepository) *GetGapAnalysisUseCase { + return &GetGapAnalysisUseCase{repo: repo} +} + +// isGap reports whether a control counts as an open compliance gap: everything +// that is not fully implemented and not explicitly out of scope. +func isGap(s domain.ControlStatus) bool { + return s != domain.ControlStatusImplemented && s != domain.ControlStatusNotApplicable +} + +// Execute runs the gap analysis. If frameworkID is uuid.Nil, it spans every +// framework the tenant owns; otherwise it scopes to that single framework. +func (uc *GetGapAnalysisUseCase) Execute(ctx context.Context, tenantID uuid.UUID, frameworkID uuid.UUID) (*GapAnalysis, error) { + var frameworks []domain.ComplianceFramework + + if frameworkID != uuid.Nil { + fw, err := uc.repo.GetFrameworkByID(ctx, frameworkID, tenantID) + if err != nil { + return nil, err + } + if fw == nil { + return nil, domain.ErrNotFound + } + frameworks = []domain.ComplianceFramework{*fw} + } else { + all, err := uc.repo.ListFrameworks(ctx, tenantID) + if err != nil { + return nil, err + } + frameworks = all + } + + result := &GapAnalysis{ + Frameworks: make([]FrameworkGapSummary, 0, len(frameworks)), + Gaps: make([]GapControl, 0), + } + + for _, fw := range frameworks { + controls, err := uc.repo.ListControlsByFramework(ctx, tenantID, fw.ID) + if err != nil { + return nil, err + } + + // Evidence counts in one grouped query per framework (no N+1). + evCounts, err := uc.repo.CountEvidencesByFramework(ctx, tenantID, fw.ID) + if err != nil { + return nil, err + } + + summary := FrameworkGapSummary{ + FrameworkID: fw.ID, + FrameworkName: fw.Name, + Version: fw.Version, + Total: len(controls), + } + + for _, c := range controls { + switch c.Status { + case domain.ControlStatusImplemented: + summary.Implemented++ + case domain.ControlStatusInProgress: + summary.InProgress++ + case domain.ControlStatusNotApplicable: + summary.NotApplicable++ + default: + summary.NotImplemented++ + } + + if isGap(c.Status) { + summary.Gaps++ + result.Gaps = append(result.Gaps, GapControl{ + ControlID: c.ID, + FrameworkID: fw.ID, + FrameworkName: fw.Name, + ReferenceCode: c.ReferenceCode, + Name: c.Name, + Description: c.Description, + Status: c.Status, + SourceReference: c.SourceReference, + EvidenceCount: evCounts[c.ID], + }) + } + } + + applicable := summary.Total - summary.NotApplicable + if applicable > 0 { + summary.PercentComplete = float64(summary.Implemented) / float64(applicable) * 100 + } + + result.TotalControls += summary.Total + result.TotalGaps += summary.Gaps + result.Frameworks = append(result.Frameworks, summary) + } + + return result, nil +} diff --git a/backend/internal/application/compliance/gap_analysis_test.go b/backend/internal/application/compliance/gap_analysis_test.go new file mode 100644 index 00000000..c813d3f5 --- /dev/null +++ b/backend/internal/application/compliance/gap_analysis_test.go @@ -0,0 +1,98 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package compliance + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGetGapAnalysis_AllFrameworks checks the cross-framework roll-up: a gap is +// any control that is neither implemented nor not-applicable. +func TestGetGapAnalysis_AllFrameworks(t *testing.T) { + fwID := uuid.New() + c1, c2, c3 := uuid.New(), uuid.New(), uuid.New() + repo := &MockComplianceRepository{ + listFrameworksFunc: func(ctx context.Context, tid uuid.UUID) ([]domain.ComplianceFramework, error) { + return []domain.ComplianceFramework{{ID: fwID, Name: "ISO 27001", Version: "2022"}}, nil + }, + listControlsByFrameworkFunc: func(ctx context.Context, tid, fid uuid.UUID) ([]domain.ComplianceControl, error) { + return []domain.ComplianceControl{ + {ID: c1, FrameworkID: fwID, Status: domain.ControlStatusImplemented}, + {ID: c2, FrameworkID: fwID, Status: domain.ControlStatusInProgress}, + {ID: c3, FrameworkID: fwID, Status: domain.ControlStatusNotImplemented}, + {ID: uuid.New(), FrameworkID: fwID, Status: domain.ControlStatusNotApplicable}, + }, nil + }, + countEvidencesByFwFunc: func(ctx context.Context, tid, fid uuid.UUID) (map[uuid.UUID]int, error) { + return map[uuid.UUID]int{c2: 1}, nil + }, + } + uc := NewGetGapAnalysisUseCase(repo) + + res, err := uc.Execute(context.Background(), uuid.New(), uuid.Nil) + + require.NoError(t, err) + assert.Equal(t, 4, res.TotalControls) + assert.Equal(t, 2, res.TotalGaps, "in_progress + not_implemented are gaps; implemented and not_applicable are not") + require.Len(t, res.Frameworks, 1) + assert.Equal(t, 1, res.Frameworks[0].Implemented) + assert.Equal(t, 1, res.Frameworks[0].InProgress) + assert.Equal(t, 1, res.Frameworks[0].NotImplemented) + assert.Equal(t, 1, res.Frameworks[0].NotApplicable) + assert.Equal(t, 2, res.Frameworks[0].Gaps) + // 1 implemented of 3 applicable ≈ 33.3% + assert.InDelta(t, 33.333, res.Frameworks[0].PercentComplete, 0.01) + require.Len(t, res.Gaps, 2) + // Evidence count is carried onto the gap entry. + for _, g := range res.Gaps { + if g.ControlID == c2 { + assert.Equal(t, 1, g.EvidenceCount) + } + } +} + +// TestGetGapAnalysis_SingleFramework scopes to one framework when a frameworkID is given. +func TestGetGapAnalysis_SingleFramework(t *testing.T) { + fwID := uuid.New() + repo := &MockComplianceRepository{ + getFrameworkByIDFunc: func(ctx context.Context, id, tid uuid.UUID) (*domain.ComplianceFramework, error) { + return &domain.ComplianceFramework{ID: fwID, Name: "DORA"}, nil + }, + listControlsByFrameworkFunc: func(ctx context.Context, tid, fid uuid.UUID) ([]domain.ComplianceControl, error) { + return []domain.ComplianceControl{{ID: uuid.New(), Status: domain.ControlStatusNotImplemented}}, nil + }, + countEvidencesByFwFunc: func(ctx context.Context, tid, fid uuid.UUID) (map[uuid.UUID]int, error) { + return map[uuid.UUID]int{}, nil + }, + } + uc := NewGetGapAnalysisUseCase(repo) + + res, err := uc.Execute(context.Background(), uuid.New(), fwID) + + require.NoError(t, err) + require.Len(t, res.Frameworks, 1) + assert.Equal(t, 1, res.TotalGaps) +} + +// TestGetGapAnalysis_UnknownFramework returns ErrNotFound (never another tenant's data). +func TestGetGapAnalysis_UnknownFramework(t *testing.T) { + repo := &MockComplianceRepository{ + getFrameworkByIDFunc: func(ctx context.Context, id, tid uuid.UUID) (*domain.ComplianceFramework, error) { + return nil, nil // not found or belongs to another tenant + }, + } + uc := NewGetGapAnalysisUseCase(repo) + + _, err := uc.Execute(context.Background(), uuid.New(), uuid.New()) + + assert.ErrorIs(t, err, domain.ErrNotFound) +} diff --git a/backend/internal/handler/compliance_handler.go b/backend/internal/handler/compliance_handler.go index cb33a9a1..ee468105 100644 --- a/backend/internal/handler/compliance_handler.go +++ b/backend/internal/handler/compliance_handler.go @@ -44,6 +44,7 @@ type ComplianceHandler struct { listCatalogsUC *compliance.ListCatalogsUseCase importCatalogUC *compliance.ImportCatalogUseCase generateReportUC *compliance.GenerateComplianceReportUseCase + getGapAnalysisUC *compliance.GetGapAnalysisUseCase } func NewComplianceHandler( @@ -64,6 +65,7 @@ func NewComplianceHandler( listCatalogs *compliance.ListCatalogsUseCase, importCatalog *compliance.ImportCatalogUseCase, generateReport *compliance.GenerateComplianceReportUseCase, + getGapAnalysis *compliance.GetGapAnalysisUseCase, ) *ComplianceHandler { return &ComplianceHandler{ createFrameworkUC: createFramework, @@ -83,6 +85,7 @@ func NewComplianceHandler( listCatalogsUC: listCatalogs, importCatalogUC: importCatalog, generateReportUC: generateReport, + getGapAnalysisUC: getGapAnalysis, } } @@ -183,6 +186,26 @@ func (h *ComplianceHandler) GetProgress(c *fiber.Ctx) error { return c.JSON(progress) } +// GetGapAnalysis godoc — the "analyse d'écarts" endpoint. Returns every +// unsatisfied control (not implemented / in progress) across the tenant's +// frameworks, with per-framework and overall roll-ups. An optional +// ?framework_id= query param scopes the analysis to a single framework. +func (h *ComplianceHandler) GetGapAnalysis(c *fiber.Ctx) error { + frameworkID := uuid.Nil + if raw := c.Query("framework_id"); raw != "" { + id, err := uuid.Parse(raw) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid framework id"}) + } + frameworkID = id + } + analysis, err := h.getGapAnalysisUC.Execute(c.UserContext(), tenantID(c), frameworkID) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(analysis) +} + // GenerateReport godoc — streams an official compliance report (PDF) for one // framework in a single click. Data is strictly tenant-scoped; the framework is // global but only the requesting tenant's controls/evidence appear. The locale diff --git a/backend/internal/handler/compliance_handler_test.go b/backend/internal/handler/compliance_handler_test.go index 9029ca52..f1d76789 100644 --- a/backend/internal/handler/compliance_handler_test.go +++ b/backend/internal/handler/compliance_handler_test.go @@ -101,6 +101,7 @@ func buildComplianceApp(t *testing.T, db *gorm.DB, store storage.Storage, tenant applicationcompliance.NewListCatalogsUseCase(), applicationcompliance.NewImportCatalogUseCase(repo), applicationcompliance.NewGenerateComplianceReportUseCase(repo, repository.NewGormOrganizationRepository(db), repository.NewGormUserRepository(db)), + applicationcompliance.NewGetGapAnalysisUseCase(repo), ) app := fiber.New() @@ -128,6 +129,7 @@ func buildComplianceApp(t *testing.T, db *gorm.DB, store storage.Storage, tenant api.Post("/compliance/frameworks", frameworkCreate, h.CreateFramework) api.Get("/compliance/frameworks/:frameworkId", frameworkRead, h.GetFramework) api.Get("/compliance/frameworks/:frameworkId/progress", controlRead, h.GetProgress) + api.Get("/compliance/gap-analysis", controlRead, h.GetGapAnalysis) api.Get("/compliance/frameworks/:frameworkId/controls", controlRead, h.ListControls) api.Post("/compliance/frameworks/:frameworkId/controls", controlCreate, h.CreateControl) api.Get("/compliance/controls/:controlId", controlRead, h.GetControl) From 80ab168336b89504a9fe76275495fd5b5d1449b5 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 16 Jul 2026 19:21:55 +0200 Subject: [PATCH 03/12] =?UTF-8?q?feat(frontend):=20build=20gap=20analysis?= =?UTF-8?q?=20screen,=20wire=20"Voir=20les=20=C3=A9carts"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Voir les écarts" CTA on ComplianceScreen was a dead button — it now navigates to a real gap analysis page (/compliance/gap-analysis). - GapAnalysisPage: coverage gauge hero, per-framework filter chips, gaps grouped by framework with status badge + evidence count + source citation; 3 UI states (loading/error/empty = "100% covered"). - complianceService.getGapAnalysis + useGapAnalysis hook + typed GapAnalysis interfaces (no `any`; follow-up: add schema to openapi.yaml). - Route registered before compliance/:frameworkId so "gap-analysis" is never parsed as a framework id. Co-Authored-By: Claude Opus 4.8 --- frontend/src/App.tsx | 2 + .../features/compliance/ComplianceScreen.tsx | 2 +- .../features/compliance/GapAnalysisPage.tsx | 179 ++++++++++++++++++ .../src/features/compliance/useCompliance.ts | 11 ++ frontend/src/services/complianceService.ts | 11 ++ frontend/src/types/compliance.ts | 36 ++++ 6 files changed, 240 insertions(+), 1 deletion(-) create mode 100644 frontend/src/features/compliance/GapAnalysisPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18de7750..be21b228 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -30,6 +30,7 @@ import { VulnerabilitiesPage } from './features/vulnerabilities/VulnerabilitiesP import { MitigationsBoard } from './features/mitigations/MitigationsBoard'; import { ComplianceScreen } from './features/compliance/ComplianceScreen'; import { FrameworkDetail } from './features/compliance/FrameworkDetail'; +import { GapAnalysisPage } from './features/compliance/GapAnalysisPage'; import { InventoryPage } from './features/assets/InventoryPage'; import { AssetUniverse } from './features/universe/AssetUniverse'; import { AnalyticsCiso } from './features/analytics/AnalyticsCiso'; @@ -139,6 +140,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/compliance/ComplianceScreen.tsx b/frontend/src/features/compliance/ComplianceScreen.tsx index fa26c4a7..5d6b717e 100644 --- a/frontend/src/features/compliance/ComplianceScreen.tsx +++ b/frontend/src/features/compliance/ComplianceScreen.tsx @@ -103,7 +103,7 @@ export function ComplianceScreen() {
navigate('/reports')} /> - + navigate('/compliance/gap-analysis')} />
diff --git a/frontend/src/features/compliance/GapAnalysisPage.tsx b/frontend/src/features/compliance/GapAnalysisPage.tsx new file mode 100644 index 00000000..d0895670 --- /dev/null +++ b/frontend/src/features/compliance/GapAnalysisPage.tsx @@ -0,0 +1,179 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// +// Gap analysis ("analyse d'écarts") — every unsatisfied control across the +// tenant's frameworks, grouped by framework, with per-framework roll-ups. Wired +// to GET /compliance/gap-analysis. The "Voir les écarts" CTA on ComplianceScreen +// lands here. + +import { useMemo, useState } from 'react'; +import { AlertTriangle, ChevronRight, ShieldCheck, FileText, Filter } from 'lucide-react'; +import { useNavigate } from 'react-router-dom'; +import { PageFrame, PageHeader, Btn, Card, RingGauge, SkeletonRows, EmptyState, ErrorState } from '../../shared/ui'; +import { useUIStore } from '../../store/uiStore'; +import { useGapAnalysis } from './useCompliance'; +import type { ControlStatus, GapControl } from '../../types/compliance'; + +const STATUS_META: Record = { + in_progress: { color: 'var(--high)', fr: 'En cours', en: 'In progress' }, + not_implemented: { color: 'var(--critical)', fr: 'Non implémenté', en: 'Not implemented' }, +}; + +export function GapAnalysisPage() { + const lang = useUIStore((s) => s.lang); + const navigate = useNavigate(); + const tr = (fr: string, en: string) => (lang === 'fr' ? fr : en); + const { data, isLoading, error, refetch } = useGapAnalysis(); + const [fwFilter, setFwFilter] = useState('all'); + + const meta = (s?: string) => STATUS_META[s ?? 'not_implemented'] ?? STATUS_META.not_implemented; + + const gaps = useMemo(() => { + const all = data?.gaps ?? []; + return fwFilter === 'all' ? all : all.filter((g) => g.framework_id === fwFilter); + }, [data, fwFilter]); + + // Group gaps by framework for a readable, sectioned list. + const grouped = useMemo(() => { + const m = new Map(); + for (const g of gaps) { + const entry = m.get(g.framework_id) ?? { name: g.framework_name, items: [] }; + entry.items.push(g); + m.set(g.framework_id, entry); + } + return Array.from(m.entries()); + }, [gaps]); + + const totalGaps = data?.total_gaps ?? 0; + const totalControls = data?.total_controls ?? 0; + const coverage = totalControls > 0 ? Math.round(((totalControls - totalGaps) / totalControls) * 100) : 100; + const gaugeColor = coverage >= 70 ? 'var(--low)' : coverage >= 40 ? 'var(--high)' : 'var(--critical)'; + + return ( + + navigate('/compliance')} />} + /> + + {isLoading ? ( + + ) : error ? ( + refetch()} retryLabel={tr('Réessayer', 'Retry')} /> + ) : totalControls === 0 ? ( + + navigate('/compliance')} />} + /> + + ) : totalGaps === 0 ? ( + + + + ) : ( + <> + {/* Hero */} + +
+ + {coverage}% + {tr('couvert', 'covered')} + +
+
+ + {totalGaps} {tr('écart', 'gap')}{totalGaps > 1 ? 's' : ''} +
+
+ {tr( + `Sur ${totalControls} contrôles suivis, ${totalGaps} ne sont pas satisfaits (non implémentés ou en cours). Traitez-les via un plan de remédiation.`, + `Of ${totalControls} tracked controls, ${totalGaps} are unsatisfied (not implemented or in progress). Address them with a remediation plan.` + )} +
+
+
+
+ + {/* Per-framework roll-up + filter chips */} +
+ {tr('Filtrer', 'Filter')} + setFwFilter('all')} count={data?.total_gaps ?? 0} /> + {(data?.frameworks ?? []).filter((f) => f.gaps > 0).map((f) => ( + setFwFilter(f.framework_id)} count={f.gaps} /> + ))} +
+ + {/* Grouped gap list */} +
+ {grouped.map(([fwId, group], gi) => ( + + +
+ {group.items.map((g) => { + const m = meta(g.status); + return ( +
+ {g.reference_code} +
+
{g.name}
+ {g.source_reference &&
{g.source_reference}
} +
+
+ {g.evidence_count > 0 && ( + {g.evidence_count} {tr('preuve', 'evidence')}{g.evidence_count > 1 ? 's' : ''} + )} + + + {lang === 'fr' ? m.fr : m.en} + +
+
+ ); + })} +
+
+ ))} +
+ + )} +
+ ); +} + +function FilterChip({ label, active, onClick, count }: { label: string; active?: boolean; onClick?: () => void; count?: number }) { + return ( + + ); +} diff --git a/frontend/src/features/compliance/useCompliance.ts b/frontend/src/features/compliance/useCompliance.ts index 51635c68..b985e062 100644 --- a/frontend/src/features/compliance/useCompliance.ts +++ b/frontend/src/features/compliance/useCompliance.ts @@ -121,6 +121,17 @@ export function useImportCatalogAsFramework() { }); } +// useGapAnalysis fetches the tenant's open compliance gaps (all frameworks, or a +// single one). Shares the ['compliance','overview'] invalidation family so a +// status change on a control refreshes the gap list too. +export function useGapAnalysis(frameworkId?: string) { + return useQuery({ + queryKey: ['compliance', 'gap-analysis', frameworkId ?? 'all'], + queryFn: () => complianceService.getGapAnalysis(frameworkId), + staleTime: 1000 * 30, + }); +} + export function useComplianceProgress(frameworkId: string | undefined) { return useQuery({ queryKey: frameworkId ? progressQueryKey(frameworkId) : ['compliance', 'progress', 'disabled'], diff --git a/frontend/src/services/complianceService.ts b/frontend/src/services/complianceService.ts index 9626ec2f..bdd64378 100644 --- a/frontend/src/services/complianceService.ts +++ b/frontend/src/services/complianceService.ts @@ -15,6 +15,7 @@ import type { ComplianceCatalogSummary, ImportCatalogInput, ImportCatalogResult, + GapAnalysis, } from '../types/compliance'; export const complianceService = { @@ -74,6 +75,16 @@ export const complianceService = { URL.revokeObjectURL(url); }, + // getGapAnalysis returns every unsatisfied control across the tenant's + // frameworks (or a single framework when frameworkId is provided), with + // per-framework roll-ups. Backs the "Analyse d'écarts" screen. + getGapAnalysis: async (frameworkId?: string): Promise => { + const response = await api.get('/compliance/gap-analysis', { + params: frameworkId ? { framework_id: frameworkId } : undefined, + }); + return response.data; + }, + listControls: async (frameworkId: string): Promise => { const response = await api.get(`/compliance/frameworks/${frameworkId}/controls`); return response.data; diff --git a/frontend/src/types/compliance.ts b/frontend/src/types/compliance.ts index 99a67258..ecd05166 100644 --- a/frontend/src/types/compliance.ts +++ b/frontend/src/types/compliance.ts @@ -31,3 +31,39 @@ export const CONTROL_STATUSES: ControlStatus[] = [ 'implemented', 'not_applicable', ]; + +// --- Gap analysis ("analyse d'écarts") -------------------------------------- +// Hand-written to match backend/internal/application/compliance/gap_analysis.go. +// Not yet in the generated OpenAPI types (follow-up: add the GapAnalysis schema +// to docs/openapi.yaml and regenerate). Fully typed — no `any`. +export interface GapControl { + control_id: string; + framework_id: string; + framework_name: string; + reference_code: string; + name: string; + description: string; + status: ControlStatus; + source_reference: string; + evidence_count: number; +} + +export interface FrameworkGapSummary { + framework_id: string; + framework_name: string; + version: string; + total: number; + implemented: number; + in_progress: number; + not_implemented: number; + not_applicable: number; + gaps: number; + percent_complete: number; +} + +export interface GapAnalysis { + total_controls: number; + total_gaps: number; + frameworks: FrameworkGapSummary[]; + gaps: GapControl[]; +} From 305b5fd34e55bfbbb3b0bbd3d268d865a7a7a765 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 16 Jul 2026 21:23:11 +0200 Subject: [PATCH 04/12] feat(backend): compliance audits + remediation plans module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the two missing "5. Conformité" sub-features as tenant-scoped aggregates in Clean Architecture (domain → repo → use cases → handler → routes). Audits ("Audits" — plan/execute/history): - domain.ComplianceAudit (type: internal/external/certification/surveillance; status: planned→in_progress→completed/cancelled; optional framework scope; auditor, scope, summary, compliance-score snapshot, scheduled/completed dates). - CRUD use cases; completing stamps completed_at once, reopening clears it. - Routes /compliance/audits[/:id] gated by compliance:audits:read|write. Remediation plans ("Plans de remédiation" — close a gap, assign, track): - domain.RemediationPlan linked to a compliance control (the gap) and, optionally, the audit that surfaced it; priority + status lifecycle, assignee, due date. DISTINCT from risk Mitigations. - Create validates the linked control belongs to the tenant (double cross-tenant guard) and derives the framework from it; list enriches each plan with its control code/name (cached, no N+1). - Routes /compliance/remediations[/:id] gated by compliance:remediations:read|write. One GormComplianceAuditRepository backs both; tenant_id filtered on every query (forged id from another tenant → ErrNotFound). Both entities added to AutoMigrate. 7 sqlite repo tests: create/get/list/update/delete + cross-tenant isolation on both aggregates + control filter. --- backend/cmd/server/main.go | 40 ++ .../complianceaudit/audit_usecases.go | 203 ++++++++++ .../complianceaudit/remediation_usecases.go | 216 ++++++++++ backend/internal/domain/compliance_audit.go | 213 ++++++++++ .../handler/compliance_audit_handler.go | 370 ++++++++++++++++++ .../gorm_compliance_audit_repository.go | 186 +++++++++ .../gorm_compliance_audit_repository_test.go | 181 +++++++++ 7 files changed, 1409 insertions(+) create mode 100644 backend/internal/application/complianceaudit/audit_usecases.go create mode 100644 backend/internal/application/complianceaudit/remediation_usecases.go create mode 100644 backend/internal/domain/compliance_audit.go create mode 100644 backend/internal/handler/compliance_audit_handler.go create mode 100644 backend/internal/infrastructure/repository/gorm_compliance_audit_repository.go create mode 100644 backend/internal/infrastructure/repository/gorm_compliance_audit_repository_test.go diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index d0a233ec..f7886ef3 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -28,6 +28,7 @@ import ( "github.com/opendefender/openrisk/internal/application/auth" "github.com/opendefender/openrisk/internal/application/board" "github.com/opendefender/openrisk/internal/application/compliance" + "github.com/opendefender/openrisk/internal/application/complianceaudit" appmitigation "github.com/opendefender/openrisk/internal/application/mitigation" notificationapp "github.com/opendefender/openrisk/internal/application/notification" "github.com/opendefender/openrisk/internal/application/risk" @@ -204,6 +205,10 @@ func main() { // sets it (a bare map[string]interface{} has no driver.Valuer). &domain.Notification{}, &domain.NotificationPreference{}, + // Compliance audits ("Audits" — plan/execute/history) and remediation + // plans ("Plans de remédiation" — close a gap, assign, track). Tenant-scoped. + &domain.ComplianceAudit{}, + &domain.RemediationPlan{}, ); err != nil { log.Fatalf("Database Migration Failed: %v", err) } @@ -702,6 +707,41 @@ func main() { protected.Get("/compliance/evidences/:evidenceId/download", complianceEvidenceRead, complianceHandler.DownloadEvidence) protected.Delete("/compliance/evidences/:evidenceId", complianceEvidenceDelete, complianceHandler.DeleteEvidence) + // ------------------------------------------------------------------------- + // Compliance audits ("Audits") + remediation plans ("Plans de remédiation"). + // One Gorm repo backs both aggregates. New permission strings — admin/root + // hold "*" so they're granted; a future Profile rule can open them per-role. + // ------------------------------------------------------------------------- + complianceAuditRepo := repository.NewGormComplianceAuditRepository(database.DB) + complianceAuditHandler := handlers.NewComplianceAuditHandler( + complianceaudit.NewCreateAuditUseCase(complianceAuditRepo), + complianceaudit.NewListAuditsUseCase(complianceAuditRepo), + complianceaudit.NewGetAuditUseCase(complianceAuditRepo), + complianceaudit.NewUpdateAuditUseCase(complianceAuditRepo), + complianceaudit.NewDeleteAuditUseCase(complianceAuditRepo), + complianceaudit.NewCreateRemediationUseCase(complianceAuditRepo, complianceRepo), + complianceaudit.NewListRemediationsUseCase(complianceAuditRepo, complianceRepo), + complianceaudit.NewUpdateRemediationUseCase(complianceAuditRepo), + complianceaudit.NewDeleteRemediationUseCase(complianceAuditRepo), + ) + complianceAuditRead := middleware.RequirePermission("compliance:audits:read") + complianceAuditWrite := middleware.RequirePermission("compliance:audits:write") + complianceRemediationRead := middleware.RequirePermission("compliance:remediations:read") + complianceRemediationWrite := middleware.RequirePermission("compliance:remediations:write") + + // Static paths — registered as siblings of /compliance/frameworks etc.; no + // dynamic :segment under /compliance would greedily catch "audits"/"remediations". + protected.Get("/compliance/audits", complianceAuditRead, complianceAuditHandler.ListAudits) + protected.Post("/compliance/audits", complianceAuditWrite, complianceAuditHandler.CreateAudit) + protected.Get("/compliance/audits/:id", complianceAuditRead, complianceAuditHandler.GetAudit) + protected.Patch("/compliance/audits/:id", complianceAuditWrite, complianceAuditHandler.UpdateAudit) + protected.Delete("/compliance/audits/:id", complianceAuditWrite, complianceAuditHandler.DeleteAudit) + + protected.Get("/compliance/remediations", complianceRemediationRead, complianceAuditHandler.ListRemediations) + protected.Post("/compliance/remediations", complianceRemediationWrite, complianceAuditHandler.CreateRemediation) + protected.Patch("/compliance/remediations/:id", complianceRemediationWrite, complianceAuditHandler.UpdateRemediation) + protected.Delete("/compliance/remediations/:id", complianceRemediationWrite, complianceAuditHandler.DeleteRemediation) + // ========================================================================= // Board Report (M4, second half — see ROADMAP.md §3 M4). // Monthly, non-technical board-of-directors report: aggregates the tenant's diff --git a/backend/internal/application/complianceaudit/audit_usecases.go b/backend/internal/application/complianceaudit/audit_usecases.go new file mode 100644 index 00000000..c6033fa2 --- /dev/null +++ b/backend/internal/application/complianceaudit/audit_usecases.go @@ -0,0 +1,203 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +// Package complianceaudit holds the use cases for compliance audits (plan, +// execute, archive) and remediation plans (create, assign, track). Each use case +// is a small injectable struct; CRUD for one aggregate is grouped per file for +// readability. Every method is tenant-scoped and returns typed domain errors. +package complianceaudit + +import ( + "context" + "strings" + "time" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" +) + +// -------------------- Create -------------------- + +// CreateAuditInput is the payload to schedule a new audit. +type CreateAuditInput struct { + Title string + FrameworkID *uuid.UUID + Type string + Auditor string + Scope string + ScheduledStart *time.Time + ScheduledEnd *time.Time +} + +type CreateAuditUseCase struct{ repo domain.ComplianceAuditRepository } + +func NewCreateAuditUseCase(repo domain.ComplianceAuditRepository) *CreateAuditUseCase { + return &CreateAuditUseCase{repo: repo} +} + +func (uc *CreateAuditUseCase) Execute(ctx context.Context, tenantID, createdBy uuid.UUID, in CreateAuditInput) (*domain.ComplianceAudit, error) { + title := strings.TrimSpace(in.Title) + if title == "" { + return nil, domain.NewValidationError("title is required") + } + auditType, err := domain.ParseAuditType(in.Type) + if err != nil { + return nil, err + } + + audit := &domain.ComplianceAudit{ + TenantID: tenantID, + Title: title, + FrameworkID: in.FrameworkID, + Type: auditType, + Status: domain.AuditStatusPlanned, + Auditor: strings.TrimSpace(in.Auditor), + Scope: in.Scope, + ScheduledStart: in.ScheduledStart, + ScheduledEnd: in.ScheduledEnd, + } + if createdBy != uuid.Nil { + audit.CreatedBy = &createdBy + } + if err := uc.repo.CreateAudit(ctx, audit); err != nil { + return nil, err + } + return audit, nil +} + +// -------------------- List -------------------- + +type ListAuditsUseCase struct{ repo domain.ComplianceAuditRepository } + +func NewListAuditsUseCase(repo domain.ComplianceAuditRepository) *ListAuditsUseCase { + return &ListAuditsUseCase{repo: repo} +} + +func (uc *ListAuditsUseCase) Execute(ctx context.Context, tenantID uuid.UUID) ([]domain.ComplianceAudit, error) { + return uc.repo.ListAudits(ctx, tenantID) +} + +// -------------------- Get -------------------- + +type GetAuditUseCase struct{ repo domain.ComplianceAuditRepository } + +func NewGetAuditUseCase(repo domain.ComplianceAuditRepository) *GetAuditUseCase { + return &GetAuditUseCase{repo: repo} +} + +func (uc *GetAuditUseCase) Execute(ctx context.Context, tenantID, id uuid.UUID) (*domain.ComplianceAudit, error) { + audit, err := uc.repo.GetAuditByID(ctx, id, tenantID) + if err != nil { + return nil, err + } + if audit == nil { + return nil, domain.ErrNotFound + } + return audit, nil +} + +// -------------------- Update -------------------- + +// UpdateAuditInput carries the editable fields. Nil pointers leave a field +// unchanged; Status is applied as-is (empty keeps the current status). +type UpdateAuditInput struct { + Title *string + FrameworkID *uuid.UUID + ClearFramework bool // set FrameworkID back to nil (program-wide) + Type *string + Status *string + Auditor *string + Scope *string + Summary *string + ComplianceScore *float64 + ScheduledStart *time.Time + ScheduledEnd *time.Time +} + +type UpdateAuditUseCase struct{ repo domain.ComplianceAuditRepository } + +func NewUpdateAuditUseCase(repo domain.ComplianceAuditRepository) *UpdateAuditUseCase { + return &UpdateAuditUseCase{repo: repo} +} + +func (uc *UpdateAuditUseCase) Execute(ctx context.Context, tenantID, id uuid.UUID, in UpdateAuditInput) (*domain.ComplianceAudit, error) { + audit, err := uc.repo.GetAuditByID(ctx, id, tenantID) + if err != nil { + return nil, err + } + if audit == nil { + return nil, domain.ErrNotFound + } + + if in.Title != nil { + t := strings.TrimSpace(*in.Title) + if t == "" { + return nil, domain.NewValidationError("title cannot be empty") + } + audit.Title = t + } + if in.ClearFramework { + audit.FrameworkID = nil + } else if in.FrameworkID != nil { + audit.FrameworkID = in.FrameworkID + } + if in.Type != nil { + at, err := domain.ParseAuditType(*in.Type) + if err != nil { + return nil, err + } + audit.Type = at + } + if in.Status != nil { + st, err := domain.ParseAuditStatus(*in.Status) + if err != nil { + return nil, err + } + audit.Status = st + // Completing the audit stamps CompletedAt once; reopening clears it. + if st == domain.AuditStatusCompleted && audit.CompletedAt == nil { + now := time.Now().UTC() + audit.CompletedAt = &now + } + if st != domain.AuditStatusCompleted { + audit.CompletedAt = nil + } + } + if in.Auditor != nil { + audit.Auditor = strings.TrimSpace(*in.Auditor) + } + if in.Scope != nil { + audit.Scope = *in.Scope + } + if in.Summary != nil { + audit.Summary = *in.Summary + } + if in.ComplianceScore != nil { + audit.ComplianceScore = *in.ComplianceScore + } + if in.ScheduledStart != nil { + audit.ScheduledStart = in.ScheduledStart + } + if in.ScheduledEnd != nil { + audit.ScheduledEnd = in.ScheduledEnd + } + + if err := uc.repo.UpdateAudit(ctx, audit); err != nil { + return nil, err + } + return audit, nil +} + +// -------------------- Delete -------------------- + +type DeleteAuditUseCase struct{ repo domain.ComplianceAuditRepository } + +func NewDeleteAuditUseCase(repo domain.ComplianceAuditRepository) *DeleteAuditUseCase { + return &DeleteAuditUseCase{repo: repo} +} + +func (uc *DeleteAuditUseCase) Execute(ctx context.Context, tenantID, id uuid.UUID) error { + return uc.repo.DeleteAudit(ctx, id, tenantID) +} diff --git a/backend/internal/application/complianceaudit/remediation_usecases.go b/backend/internal/application/complianceaudit/remediation_usecases.go new file mode 100644 index 00000000..671977f0 --- /dev/null +++ b/backend/internal/application/complianceaudit/remediation_usecases.go @@ -0,0 +1,216 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package complianceaudit + +import ( + "context" + "strings" + "time" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" +) + +// -------------------- Create -------------------- + +// CreateRemediationInput is the payload to open a remediation plan against a +// compliance gap. +type CreateRemediationInput struct { + Title string + Description string + ControlID *uuid.UUID + AuditID *uuid.UUID + Priority string + AssignedTo *uuid.UUID + DueDate *time.Time +} + +// CreateRemediationUseCase opens a remediation plan. It depends on the compliance +// repository too, so it can validate the linked control belongs to the tenant +// (double cross-tenant guard) and derive the framework from it. +type CreateRemediationUseCase struct { + repo domain.RemediationPlanRepository + complianceRepo domain.ComplianceRepository +} + +func NewCreateRemediationUseCase(repo domain.RemediationPlanRepository, complianceRepo domain.ComplianceRepository) *CreateRemediationUseCase { + return &CreateRemediationUseCase{repo: repo, complianceRepo: complianceRepo} +} + +func (uc *CreateRemediationUseCase) Execute(ctx context.Context, tenantID, createdBy uuid.UUID, in CreateRemediationInput) (*domain.RemediationPlan, error) { + title := strings.TrimSpace(in.Title) + if title == "" { + return nil, domain.NewValidationError("title is required") + } + priority, err := domain.ParseRemediationPriority(in.Priority) + if err != nil { + return nil, err + } + + plan := &domain.RemediationPlan{ + TenantID: tenantID, + Title: title, + Description: in.Description, + ControlID: in.ControlID, + AuditID: in.AuditID, + Priority: priority, + Status: domain.RemediationStatusOpen, + AssignedTo: in.AssignedTo, + DueDate: in.DueDate, + } + if createdBy != uuid.Nil { + plan.CreatedBy = &createdBy + } + + // If linked to a control, verify it exists for THIS tenant and derive the + // framework from it. GetControlByID returns (nil, nil) for another tenant's + // control — so a forged control_id can never attach across tenants. + if in.ControlID != nil { + ctrl, err := uc.complianceRepo.GetControlByID(ctx, *in.ControlID, tenantID) + if err != nil { + return nil, err + } + if ctrl == nil { + return nil, domain.NewValidationError("linked control not found") + } + plan.FrameworkID = &ctrl.FrameworkID + } + + if err := uc.repo.CreateRemediation(ctx, plan); err != nil { + return nil, err + } + return plan, nil +} + +// -------------------- List -------------------- + +type ListRemediationsUseCase struct { + repo domain.RemediationPlanRepository + complianceRepo domain.ComplianceRepository +} + +func NewListRemediationsUseCase(repo domain.RemediationPlanRepository, complianceRepo domain.ComplianceRepository) *ListRemediationsUseCase { + return &ListRemediationsUseCase{repo: repo, complianceRepo: complianceRepo} +} + +func (uc *ListRemediationsUseCase) Execute(ctx context.Context, tenantID uuid.UUID, filter domain.RemediationFilter) ([]domain.RemediationPlan, error) { + plans, err := uc.repo.ListRemediations(ctx, tenantID, filter) + if err != nil { + return nil, err + } + // Enrich each plan with its linked control's code/name for a readable UI. + // Cached per control id so repeated links don't re-query. Lists are small + // (remediation plans per tenant), so this bounded lookup is cheap. + cache := map[uuid.UUID]*domain.ComplianceControl{} + for i := range plans { + if plans[i].ControlID == nil { + continue + } + cid := *plans[i].ControlID + ctrl, ok := cache[cid] + if !ok { + ctrl, err = uc.complianceRepo.GetControlByID(ctx, cid, tenantID) + if err != nil { + return nil, err + } + cache[cid] = ctrl + } + if ctrl != nil { + plans[i].ControlCode = ctrl.ReferenceCode + plans[i].ControlName = ctrl.Name + } + } + return plans, nil +} + +// -------------------- Update -------------------- + +// UpdateRemediationInput carries editable fields; nil leaves a field unchanged. +type UpdateRemediationInput struct { + Title *string + Description *string + Priority *string + Status *string + AssignedTo *uuid.UUID + ClearAssignee bool + DueDate *time.Time + ClearDueDate bool +} + +type UpdateRemediationUseCase struct{ repo domain.RemediationPlanRepository } + +func NewUpdateRemediationUseCase(repo domain.RemediationPlanRepository) *UpdateRemediationUseCase { + return &UpdateRemediationUseCase{repo: repo} +} + +func (uc *UpdateRemediationUseCase) Execute(ctx context.Context, tenantID, id uuid.UUID, in UpdateRemediationInput) (*domain.RemediationPlan, error) { + plan, err := uc.repo.GetRemediationByID(ctx, id, tenantID) + if err != nil { + return nil, err + } + if plan == nil { + return nil, domain.ErrNotFound + } + + if in.Title != nil { + t := strings.TrimSpace(*in.Title) + if t == "" { + return nil, domain.NewValidationError("title cannot be empty") + } + plan.Title = t + } + if in.Description != nil { + plan.Description = *in.Description + } + if in.Priority != nil { + p, err := domain.ParseRemediationPriority(*in.Priority) + if err != nil { + return nil, err + } + plan.Priority = p + } + if in.Status != nil { + s, err := domain.ParseRemediationStatus(*in.Status) + if err != nil { + return nil, err + } + plan.Status = s + if s == domain.RemediationStatusCompleted && plan.CompletedAt == nil { + now := time.Now().UTC() + plan.CompletedAt = &now + } + if s != domain.RemediationStatusCompleted { + plan.CompletedAt = nil + } + } + if in.ClearAssignee { + plan.AssignedTo = nil + } else if in.AssignedTo != nil { + plan.AssignedTo = in.AssignedTo + } + if in.ClearDueDate { + plan.DueDate = nil + } else if in.DueDate != nil { + plan.DueDate = in.DueDate + } + + if err := uc.repo.UpdateRemediation(ctx, plan); err != nil { + return nil, err + } + return plan, nil +} + +// -------------------- Delete -------------------- + +type DeleteRemediationUseCase struct{ repo domain.RemediationPlanRepository } + +func NewDeleteRemediationUseCase(repo domain.RemediationPlanRepository) *DeleteRemediationUseCase { + return &DeleteRemediationUseCase{repo: repo} +} + +func (uc *DeleteRemediationUseCase) Execute(ctx context.Context, tenantID, id uuid.UUID) error { + return uc.repo.DeleteRemediation(ctx, id, tenantID) +} diff --git a/backend/internal/domain/compliance_audit.go b/backend/internal/domain/compliance_audit.go new file mode 100644 index 00000000..56995c05 --- /dev/null +++ b/backend/internal/domain/compliance_audit.go @@ -0,0 +1,213 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package domain + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +// ============================================================================= +// Compliance audits ("Audits" — planification, exécution, historique) +// ============================================================================= + +// AuditType is the nature of a compliance audit. +type AuditType string + +const ( + AuditTypeInternal AuditType = "internal" // self-assessment + AuditTypeExternal AuditType = "external" // third-party audit + AuditTypeCertification AuditType = "certification" // certification body audit (e.g. ISO 27001 stage 2) + AuditTypeSurveillance AuditType = "surveillance" // periodic surveillance audit +) + +// ParseAuditType validates an audit type (empty → internal). +func ParseAuditType(s string) (AuditType, error) { + if s == "" { + return AuditTypeInternal, nil + } + switch AuditType(s) { + case AuditTypeInternal, AuditTypeExternal, AuditTypeCertification, AuditTypeSurveillance: + return AuditType(s), nil + default: + return "", NewValidationError(fmt.Sprintf("invalid audit type: %q", s)) + } +} + +// AuditStatus is the lifecycle state of a compliance audit. +type AuditStatus string + +const ( + AuditStatusPlanned AuditStatus = "planned" + AuditStatusInProgress AuditStatus = "in_progress" + AuditStatusCompleted AuditStatus = "completed" + AuditStatusCancelled AuditStatus = "cancelled" +) + +// ParseAuditStatus validates an audit status (empty → planned). +func ParseAuditStatus(s string) (AuditStatus, error) { + if s == "" { + return AuditStatusPlanned, nil + } + switch AuditStatus(s) { + case AuditStatusPlanned, AuditStatusInProgress, AuditStatusCompleted, AuditStatusCancelled: + return AuditStatus(s), nil + default: + return "", NewValidationError(fmt.Sprintf("invalid audit status: %q", s)) + } +} + +// ComplianceAudit is a tenant-scoped audit: planned, executed, then archived as +// history. It may target a single framework (FrameworkID set) or the whole +// compliance program (FrameworkID nil). +type ComplianceAudit struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + TenantID uuid.UUID `gorm:"type:uuid;not null;index" json:"tenant_id"` + + Title string `gorm:"size:255;not null" json:"title"` + FrameworkID *uuid.UUID `gorm:"type:uuid;index" json:"framework_id"` // nil = program-wide + Type AuditType `gorm:"type:varchar(24);not null;default:'internal'" json:"type"` + Status AuditStatus `gorm:"type:varchar(24);not null;default:'planned';index" json:"status"` + + Auditor string `gorm:"size:255" json:"auditor"` // auditor name or firm + Scope string `gorm:"type:text" json:"scope"` + Summary string `gorm:"type:text" json:"summary"` // conclusions / findings summary + + // ComplianceScore is an optional posture snapshot (0–100) recorded when the + // audit is completed. + ComplianceScore float64 `gorm:"type:numeric(5,2)" json:"compliance_score"` + + ScheduledStart *time.Time `json:"scheduled_start"` + ScheduledEnd *time.Time `json:"scheduled_end"` + CompletedAt *time.Time `json:"completed_at"` + + CreatedBy *uuid.UUID `gorm:"type:uuid" json:"created_by"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (ComplianceAudit) TableName() string { return "compliance_audits" } + +// ComplianceAuditRepository is the port for audit persistence. Every method is +// tenant-scoped: a resource belonging to another tenant is returned as not found. +type ComplianceAuditRepository interface { + CreateAudit(ctx context.Context, a *ComplianceAudit) error + GetAuditByID(ctx context.Context, id, tenantID uuid.UUID) (*ComplianceAudit, error) + ListAudits(ctx context.Context, tenantID uuid.UUID) ([]ComplianceAudit, error) + UpdateAudit(ctx context.Context, a *ComplianceAudit) error + DeleteAudit(ctx context.Context, id, tenantID uuid.UUID) error +} + +// ============================================================================= +// Remediation plans ("Plans de remédiation") +// ============================================================================= + +// RemediationPriority ranks the urgency of a remediation action. +type RemediationPriority string + +const ( + RemediationPriorityLow RemediationPriority = "low" + RemediationPriorityMedium RemediationPriority = "medium" + RemediationPriorityHigh RemediationPriority = "high" + RemediationPriorityCritical RemediationPriority = "critical" +) + +// ParseRemediationPriority validates a priority (empty → medium). +func ParseRemediationPriority(s string) (RemediationPriority, error) { + if s == "" { + return RemediationPriorityMedium, nil + } + switch RemediationPriority(s) { + case RemediationPriorityLow, RemediationPriorityMedium, RemediationPriorityHigh, RemediationPriorityCritical: + return RemediationPriority(s), nil + default: + return "", NewValidationError(fmt.Sprintf("invalid remediation priority: %q", s)) + } +} + +// RemediationStatus is the lifecycle state of a remediation plan. +type RemediationStatus string + +const ( + RemediationStatusOpen RemediationStatus = "open" + RemediationStatusInProgress RemediationStatus = "in_progress" + RemediationStatusCompleted RemediationStatus = "completed" + RemediationStatusCancelled RemediationStatus = "cancelled" +) + +// ParseRemediationStatus validates a status (empty → open). +func ParseRemediationStatus(s string) (RemediationStatus, error) { + if s == "" { + return RemediationStatusOpen, nil + } + switch RemediationStatus(s) { + case RemediationStatusOpen, RemediationStatusInProgress, RemediationStatusCompleted, RemediationStatusCancelled: + return RemediationStatus(s), nil + default: + return "", NewValidationError(fmt.Sprintf("invalid remediation status: %q", s)) + } +} + +// RemediationPlan is a tenant-scoped action to close a compliance gap. It is +// linked to the control it remediates (ControlID) and, optionally, to the audit +// that surfaced the gap (AuditID). This is compliance-specific remediation, +// distinct from risk Mitigations (which hang off a Risk). +type RemediationPlan struct { + ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"` + TenantID uuid.UUID `gorm:"type:uuid;not null;index" json:"tenant_id"` + + Title string `gorm:"size:255;not null" json:"title"` + Description string `gorm:"type:text" json:"description"` + + ControlID *uuid.UUID `gorm:"type:uuid;index" json:"control_id"` // the gap being remediated + FrameworkID *uuid.UUID `gorm:"type:uuid;index" json:"framework_id"` // denormalised for filtering + AuditID *uuid.UUID `gorm:"type:uuid;index" json:"audit_id"` // origin audit, if any + + Priority RemediationPriority `gorm:"type:varchar(16);not null;default:'medium'" json:"priority"` + Status RemediationStatus `gorm:"type:varchar(24);not null;default:'open';index" json:"status"` + + AssignedTo *uuid.UUID `gorm:"type:uuid;index" json:"assigned_to"` + DueDate *time.Time `json:"due_date"` + CompletedAt *time.Time `json:"completed_at"` + + CreatedBy *uuid.UUID `gorm:"type:uuid" json:"created_by"` + + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + + // Computed, NOT persisted — the linked control's reference code/name, filled + // by the list use case for a readable UI without an extra round-trip. + ControlCode string `gorm:"-" json:"control_code,omitempty"` + ControlName string `gorm:"-" json:"control_name,omitempty"` +} + +func (RemediationPlan) TableName() string { return "remediation_plans" } + +// RemediationFilter narrows a remediation list query. Zero-value fields are +// ignored (no filter on that dimension). +type RemediationFilter struct { + ControlID *uuid.UUID + FrameworkID *uuid.UUID + AuditID *uuid.UUID + Status RemediationStatus +} + +// RemediationPlanRepository is the port for remediation-plan persistence. +// Tenant-scoped throughout. +type RemediationPlanRepository interface { + CreateRemediation(ctx context.Context, r *RemediationPlan) error + GetRemediationByID(ctx context.Context, id, tenantID uuid.UUID) (*RemediationPlan, error) + ListRemediations(ctx context.Context, tenantID uuid.UUID, filter RemediationFilter) ([]RemediationPlan, error) + UpdateRemediation(ctx context.Context, r *RemediationPlan) error + DeleteRemediation(ctx context.Context, id, tenantID uuid.UUID) error +} diff --git a/backend/internal/handler/compliance_audit_handler.go b/backend/internal/handler/compliance_audit_handler.go new file mode 100644 index 00000000..603091a1 --- /dev/null +++ b/backend/internal/handler/compliance_audit_handler.go @@ -0,0 +1,370 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package handler + +import ( + "time" + + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + + "github.com/opendefender/openrisk/internal/application/complianceaudit" + "github.com/opendefender/openrisk/internal/domain" +) + +// ComplianceAuditHandler exposes the compliance audit + remediation-plan use +// cases. Tenant/user come from middleware.GetContext via the shared tenantID()/ +// userID() helpers (defined in compliance_handler.go). +type ComplianceAuditHandler struct { + createAudit *complianceaudit.CreateAuditUseCase + listAudits *complianceaudit.ListAuditsUseCase + getAudit *complianceaudit.GetAuditUseCase + updateAudit *complianceaudit.UpdateAuditUseCase + deleteAudit *complianceaudit.DeleteAuditUseCase + + createRemediation *complianceaudit.CreateRemediationUseCase + listRemediations *complianceaudit.ListRemediationsUseCase + updateRemediation *complianceaudit.UpdateRemediationUseCase + deleteRemediation *complianceaudit.DeleteRemediationUseCase +} + +func NewComplianceAuditHandler( + createAudit *complianceaudit.CreateAuditUseCase, + listAudits *complianceaudit.ListAuditsUseCase, + getAudit *complianceaudit.GetAuditUseCase, + updateAudit *complianceaudit.UpdateAuditUseCase, + deleteAudit *complianceaudit.DeleteAuditUseCase, + createRemediation *complianceaudit.CreateRemediationUseCase, + listRemediations *complianceaudit.ListRemediationsUseCase, + updateRemediation *complianceaudit.UpdateRemediationUseCase, + deleteRemediation *complianceaudit.DeleteRemediationUseCase, +) *ComplianceAuditHandler { + return &ComplianceAuditHandler{ + createAudit: createAudit, listAudits: listAudits, getAudit: getAudit, + updateAudit: updateAudit, deleteAudit: deleteAudit, + createRemediation: createRemediation, listRemediations: listRemediations, + updateRemediation: updateRemediation, deleteRemediation: deleteRemediation, + } +} + +// parseOptionalDate accepts RFC3339 or a plain YYYY-MM-DD date; "" → nil. +func parseOptionalDate(s string) (*time.Time, error) { + if s == "" { + return nil, nil + } + if t, err := time.Parse(time.RFC3339, s); err == nil { + return &t, nil + } + if t, err := time.Parse("2006-01-02", s); err == nil { + return &t, nil + } + return nil, domain.NewValidationError("invalid date: " + s) +} + +// parseOptionalUUID parses a non-empty uuid string; "" → nil. +func parseOptionalUUID(s string) (*uuid.UUID, error) { + if s == "" { + return nil, nil + } + id, err := uuid.Parse(s) + if err != nil { + return nil, domain.NewValidationError("invalid id: " + s) + } + return &id, nil +} + +// ============================================================================= +// Audits +// ============================================================================= + +type createAuditBody struct { + Title string `json:"title"` + FrameworkID string `json:"framework_id"` + Type string `json:"type"` + Auditor string `json:"auditor"` + Scope string `json:"scope"` + ScheduledStart string `json:"scheduled_start"` + ScheduledEnd string `json:"scheduled_end"` +} + +func (h *ComplianceAuditHandler) ListAudits(c *fiber.Ctx) error { + audits, err := h.listAudits.Execute(c.UserContext(), tenantID(c)) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(audits) +} + +func (h *ComplianceAuditHandler) CreateAudit(c *fiber.Ctx) error { + var body createAuditBody + if err := c.BodyParser(&body); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid input format"}) + } + fwID, err := parseOptionalUUID(body.FrameworkID) + if err != nil { + return writeAppError(c, err) + } + start, err := parseOptionalDate(body.ScheduledStart) + if err != nil { + return writeAppError(c, err) + } + end, err := parseOptionalDate(body.ScheduledEnd) + if err != nil { + return writeAppError(c, err) + } + audit, err := h.createAudit.Execute(c.UserContext(), tenantID(c), userID(c), complianceaudit.CreateAuditInput{ + Title: body.Title, + FrameworkID: fwID, + Type: body.Type, + Auditor: body.Auditor, + Scope: body.Scope, + ScheduledStart: start, + ScheduledEnd: end, + }) + if err != nil { + return writeAppError(c, err) + } + return c.Status(201).JSON(audit) +} + +func (h *ComplianceAuditHandler) GetAudit(c *fiber.Ctx) error { + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid audit id"}) + } + audit, err := h.getAudit.Execute(c.UserContext(), tenantID(c), id) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(audit) +} + +type updateAuditBody struct { + Title *string `json:"title"` + FrameworkID *string `json:"framework_id"` // "" clears (program-wide) + Type *string `json:"type"` + Status *string `json:"status"` + Auditor *string `json:"auditor"` + Scope *string `json:"scope"` + Summary *string `json:"summary"` + ComplianceScore *float64 `json:"compliance_score"` + ScheduledStart *string `json:"scheduled_start"` + ScheduledEnd *string `json:"scheduled_end"` +} + +func (h *ComplianceAuditHandler) UpdateAudit(c *fiber.Ctx) error { + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid audit id"}) + } + var body updateAuditBody + if err := c.BodyParser(&body); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid input format"}) + } + + in := complianceaudit.UpdateAuditInput{ + Title: body.Title, + Type: body.Type, + Status: body.Status, + Auditor: body.Auditor, + Scope: body.Scope, + Summary: body.Summary, + ComplianceScore: body.ComplianceScore, + } + if body.FrameworkID != nil { + if *body.FrameworkID == "" { + in.ClearFramework = true + } else { + fwID, err := parseOptionalUUID(*body.FrameworkID) + if err != nil { + return writeAppError(c, err) + } + in.FrameworkID = fwID + } + } + if body.ScheduledStart != nil { + start, err := parseOptionalDate(*body.ScheduledStart) + if err != nil { + return writeAppError(c, err) + } + in.ScheduledStart = start + } + if body.ScheduledEnd != nil { + end, err := parseOptionalDate(*body.ScheduledEnd) + if err != nil { + return writeAppError(c, err) + } + in.ScheduledEnd = end + } + + audit, err := h.updateAudit.Execute(c.UserContext(), tenantID(c), id, in) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(audit) +} + +func (h *ComplianceAuditHandler) DeleteAudit(c *fiber.Ctx) error { + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid audit id"}) + } + if err := h.deleteAudit.Execute(c.UserContext(), tenantID(c), id); err != nil { + return writeAppError(c, err) + } + return c.SendStatus(204) +} + +// ============================================================================= +// Remediation plans +// ============================================================================= + +type createRemediationBody struct { + Title string `json:"title"` + Description string `json:"description"` + ControlID string `json:"control_id"` + AuditID string `json:"audit_id"` + Priority string `json:"priority"` + AssignedTo string `json:"assigned_to"` + DueDate string `json:"due_date"` +} + +func (h *ComplianceAuditHandler) ListRemediations(c *fiber.Ctx) error { + filter := domain.RemediationFilter{} + if v := c.Query("control_id"); v != "" { + id, err := parseOptionalUUID(v) + if err != nil { + return writeAppError(c, err) + } + filter.ControlID = id + } + if v := c.Query("framework_id"); v != "" { + id, err := parseOptionalUUID(v) + if err != nil { + return writeAppError(c, err) + } + filter.FrameworkID = id + } + if v := c.Query("audit_id"); v != "" { + id, err := parseOptionalUUID(v) + if err != nil { + return writeAppError(c, err) + } + filter.AuditID = id + } + if v := c.Query("status"); v != "" { + st, err := domain.ParseRemediationStatus(v) + if err != nil { + return writeAppError(c, err) + } + filter.Status = st + } + plans, err := h.listRemediations.Execute(c.UserContext(), tenantID(c), filter) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(plans) +} + +func (h *ComplianceAuditHandler) CreateRemediation(c *fiber.Ctx) error { + var body createRemediationBody + if err := c.BodyParser(&body); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid input format"}) + } + ctrlID, err := parseOptionalUUID(body.ControlID) + if err != nil { + return writeAppError(c, err) + } + auditID, err := parseOptionalUUID(body.AuditID) + if err != nil { + return writeAppError(c, err) + } + assignee, err := parseOptionalUUID(body.AssignedTo) + if err != nil { + return writeAppError(c, err) + } + due, err := parseOptionalDate(body.DueDate) + if err != nil { + return writeAppError(c, err) + } + plan, err := h.createRemediation.Execute(c.UserContext(), tenantID(c), userID(c), complianceaudit.CreateRemediationInput{ + Title: body.Title, + Description: body.Description, + ControlID: ctrlID, + AuditID: auditID, + Priority: body.Priority, + AssignedTo: assignee, + DueDate: due, + }) + if err != nil { + return writeAppError(c, err) + } + return c.Status(201).JSON(plan) +} + +type updateRemediationBody struct { + Title *string `json:"title"` + Description *string `json:"description"` + Priority *string `json:"priority"` + Status *string `json:"status"` + AssignedTo *string `json:"assigned_to"` // "" clears + DueDate *string `json:"due_date"` // "" clears +} + +func (h *ComplianceAuditHandler) UpdateRemediation(c *fiber.Ctx) error { + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid remediation id"}) + } + var body updateRemediationBody + if err := c.BodyParser(&body); err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid input format"}) + } + in := complianceaudit.UpdateRemediationInput{ + Title: body.Title, + Description: body.Description, + Priority: body.Priority, + Status: body.Status, + } + if body.AssignedTo != nil { + if *body.AssignedTo == "" { + in.ClearAssignee = true + } else { + a, err := parseOptionalUUID(*body.AssignedTo) + if err != nil { + return writeAppError(c, err) + } + in.AssignedTo = a + } + } + if body.DueDate != nil { + if *body.DueDate == "" { + in.ClearDueDate = true + } else { + d, err := parseOptionalDate(*body.DueDate) + if err != nil { + return writeAppError(c, err) + } + in.DueDate = d + } + } + plan, err := h.updateRemediation.Execute(c.UserContext(), tenantID(c), id, in) + if err != nil { + return writeAppError(c, err) + } + return c.JSON(plan) +} + +func (h *ComplianceAuditHandler) DeleteRemediation(c *fiber.Ctx) error { + id, err := uuid.Parse(c.Params("id")) + if err != nil { + return c.Status(400).JSON(fiber.Map{"error": "invalid remediation id"}) + } + if err := h.deleteRemediation.Execute(c.UserContext(), tenantID(c), id); err != nil { + return writeAppError(c, err) + } + return c.SendStatus(204) +} diff --git a/backend/internal/infrastructure/repository/gorm_compliance_audit_repository.go b/backend/internal/infrastructure/repository/gorm_compliance_audit_repository.go new file mode 100644 index 00000000..d9edf5f4 --- /dev/null +++ b/backend/internal/infrastructure/repository/gorm_compliance_audit_repository.go @@ -0,0 +1,186 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package repository + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "gorm.io/gorm" + + "github.com/opendefender/openrisk/internal/domain" +) + +// GormComplianceAuditRepository implements domain.ComplianceAuditRepository and +// domain.RemediationPlanRepository using GORM. Both are tenant-scoped on every +// query — a resource owned by another tenant reads back as not found (nil, nil). +type GormComplianceAuditRepository struct { + db *gorm.DB +} + +func NewGormComplianceAuditRepository(db *gorm.DB) *GormComplianceAuditRepository { + return &GormComplianceAuditRepository{db: db} +} + +// ============================================================================= +// Audits +// ============================================================================= + +func (r *GormComplianceAuditRepository) CreateAudit(ctx context.Context, a *domain.ComplianceAudit) error { + if a.TenantID == uuid.Nil { + return fmt.Errorf("tenant_id is required") + } + return r.db.WithContext(ctx).Create(a).Error +} + +func (r *GormComplianceAuditRepository) GetAuditByID(ctx context.Context, id, tenantID uuid.UUID) (*domain.ComplianceAudit, error) { + var a domain.ComplianceAudit + err := r.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", id, tenantID).First(&a).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, fmt.Errorf("failed to get audit: %w", err) + } + return &a, nil +} + +func (r *GormComplianceAuditRepository) ListAudits(ctx context.Context, tenantID uuid.UUID) ([]domain.ComplianceAudit, error) { + var audits []domain.ComplianceAudit + err := r.db.WithContext(ctx). + Where("tenant_id = ?", tenantID). + Order("COALESCE(scheduled_start, created_at) DESC"). + Find(&audits).Error + if err != nil { + return nil, fmt.Errorf("failed to list audits: %w", err) + } + return audits, nil +} + +func (r *GormComplianceAuditRepository) UpdateAudit(ctx context.Context, a *domain.ComplianceAudit) error { + // Scope the write to the tenant so a forged ID can never touch another + // tenant's row. Save on a tenant-filtered query updates only the matched row. + res := r.db.WithContext(ctx). + Model(&domain.ComplianceAudit{}). + Where("id = ? AND tenant_id = ?", a.ID, a.TenantID). + Updates(map[string]interface{}{ + "title": a.Title, + "framework_id": a.FrameworkID, + "type": a.Type, + "status": a.Status, + "auditor": a.Auditor, + "scope": a.Scope, + "summary": a.Summary, + "compliance_score": a.ComplianceScore, + "scheduled_start": a.ScheduledStart, + "scheduled_end": a.ScheduledEnd, + "completed_at": a.CompletedAt, + }) + if res.Error != nil { + return fmt.Errorf("failed to update audit: %w", res.Error) + } + if res.RowsAffected == 0 { + return domain.ErrNotFound + } + return nil +} + +func (r *GormComplianceAuditRepository) DeleteAudit(ctx context.Context, id, tenantID uuid.UUID) error { + res := r.db.WithContext(ctx). + Where("id = ? AND tenant_id = ?", id, tenantID). + Delete(&domain.ComplianceAudit{}) + if res.Error != nil { + return fmt.Errorf("failed to delete audit: %w", res.Error) + } + if res.RowsAffected == 0 { + return domain.ErrNotFound + } + return nil +} + +// ============================================================================= +// Remediation plans +// ============================================================================= + +func (r *GormComplianceAuditRepository) CreateRemediation(ctx context.Context, rp *domain.RemediationPlan) error { + if rp.TenantID == uuid.Nil { + return fmt.Errorf("tenant_id is required") + } + return r.db.WithContext(ctx).Create(rp).Error +} + +func (r *GormComplianceAuditRepository) GetRemediationByID(ctx context.Context, id, tenantID uuid.UUID) (*domain.RemediationPlan, error) { + var rp domain.RemediationPlan + err := r.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", id, tenantID).First(&rp).Error + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, fmt.Errorf("failed to get remediation plan: %w", err) + } + return &rp, nil +} + +func (r *GormComplianceAuditRepository) ListRemediations(ctx context.Context, tenantID uuid.UUID, filter domain.RemediationFilter) ([]domain.RemediationPlan, error) { + q := r.db.WithContext(ctx).Where("tenant_id = ?", tenantID) + if filter.ControlID != nil { + q = q.Where("control_id = ?", *filter.ControlID) + } + if filter.FrameworkID != nil { + q = q.Where("framework_id = ?", *filter.FrameworkID) + } + if filter.AuditID != nil { + q = q.Where("audit_id = ?", *filter.AuditID) + } + if filter.Status != "" { + q = q.Where("status = ?", filter.Status) + } + + var plans []domain.RemediationPlan + if err := q.Order("created_at DESC").Find(&plans).Error; err != nil { + return nil, fmt.Errorf("failed to list remediation plans: %w", err) + } + return plans, nil +} + +func (r *GormComplianceAuditRepository) UpdateRemediation(ctx context.Context, rp *domain.RemediationPlan) error { + res := r.db.WithContext(ctx). + Model(&domain.RemediationPlan{}). + Where("id = ? AND tenant_id = ?", rp.ID, rp.TenantID). + Updates(map[string]interface{}{ + "title": rp.Title, + "description": rp.Description, + "control_id": rp.ControlID, + "framework_id": rp.FrameworkID, + "audit_id": rp.AuditID, + "priority": rp.Priority, + "status": rp.Status, + "assigned_to": rp.AssignedTo, + "due_date": rp.DueDate, + "completed_at": rp.CompletedAt, + }) + if res.Error != nil { + return fmt.Errorf("failed to update remediation plan: %w", res.Error) + } + if res.RowsAffected == 0 { + return domain.ErrNotFound + } + return nil +} + +func (r *GormComplianceAuditRepository) DeleteRemediation(ctx context.Context, id, tenantID uuid.UUID) error { + res := r.db.WithContext(ctx). + Where("id = ? AND tenant_id = ?", id, tenantID). + Delete(&domain.RemediationPlan{}) + if res.Error != nil { + return fmt.Errorf("failed to delete remediation plan: %w", res.Error) + } + if res.RowsAffected == 0 { + return domain.ErrNotFound + } + return nil +} diff --git a/backend/internal/infrastructure/repository/gorm_compliance_audit_repository_test.go b/backend/internal/infrastructure/repository/gorm_compliance_audit_repository_test.go new file mode 100644 index 00000000..a3a3b8ce --- /dev/null +++ b/backend/internal/infrastructure/repository/gorm_compliance_audit_repository_test.go @@ -0,0 +1,181 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// This Source Code Form is subject to the terms of the Business Source License, Version 1.1. +// If a copy of the BUSL was not distributed with this file, You can obtain one at https://mariadb.com/bsl11/ + +package repository + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/opendefender/openrisk/internal/domain" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func setupAuditRepo(t *testing.T) *GormComplianceAuditRepository { + t.Helper() + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + require.NoError(t, err) + + require.NoError(t, db.Exec(` + CREATE TABLE compliance_audits ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + title TEXT NOT NULL, + framework_id TEXT, + type TEXT NOT NULL DEFAULT 'internal', + status TEXT NOT NULL DEFAULT 'planned', + auditor TEXT, + scope TEXT, + summary TEXT, + compliance_score REAL, + scheduled_start DATETIME, + scheduled_end DATETIME, + completed_at DATETIME, + created_by TEXT, + created_at DATETIME, + updated_at DATETIME, + deleted_at DATETIME + ); + `).Error) + + require.NoError(t, db.Exec(` + CREATE TABLE remediation_plans ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT, + control_id TEXT, + framework_id TEXT, + audit_id TEXT, + priority TEXT NOT NULL DEFAULT 'medium', + status TEXT NOT NULL DEFAULT 'open', + assigned_to TEXT, + due_date DATETIME, + completed_at DATETIME, + created_by TEXT, + created_at DATETIME, + updated_at DATETIME, + deleted_at DATETIME + ); + `).Error) + + return NewGormComplianceAuditRepository(db) +} + +func TestAuditRepo_CreateGet_TenantIsolation(t *testing.T) { + repo := setupAuditRepo(t) + ctx := context.Background() + tenantA, tenantB := uuid.New(), uuid.New() + + a := &domain.ComplianceAudit{ID: uuid.New(), TenantID: tenantA, Title: "ISO 27001 internal audit", Type: domain.AuditTypeInternal, Status: domain.AuditStatusPlanned} + require.NoError(t, repo.CreateAudit(ctx, a)) + + // Same tenant → found. + got, err := repo.GetAuditByID(ctx, a.ID, tenantA) + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, "ISO 27001 internal audit", got.Title) + + // Other tenant → nil (never 403, never leak). + other, err := repo.GetAuditByID(ctx, a.ID, tenantB) + require.NoError(t, err) + assert.Nil(t, other) +} + +func TestAuditRepo_CreateRequiresTenant(t *testing.T) { + repo := setupAuditRepo(t) + err := repo.CreateAudit(context.Background(), &domain.ComplianceAudit{ID: uuid.New(), Title: "x"}) + assert.Error(t, err) +} + +func TestAuditRepo_List_ScopedToTenant(t *testing.T) { + repo := setupAuditRepo(t) + ctx := context.Background() + tenantA, tenantB := uuid.New(), uuid.New() + require.NoError(t, repo.CreateAudit(ctx, &domain.ComplianceAudit{ID: uuid.New(), TenantID: tenantA, Title: "A1"})) + require.NoError(t, repo.CreateAudit(ctx, &domain.ComplianceAudit{ID: uuid.New(), TenantID: tenantA, Title: "A2"})) + require.NoError(t, repo.CreateAudit(ctx, &domain.ComplianceAudit{ID: uuid.New(), TenantID: tenantB, Title: "B1"})) + + list, err := repo.ListAudits(ctx, tenantA) + require.NoError(t, err) + assert.Len(t, list, 2) +} + +func TestAuditRepo_Update_CrossTenantRefused(t *testing.T) { + repo := setupAuditRepo(t) + ctx := context.Background() + tenantA, tenantB := uuid.New(), uuid.New() + a := &domain.ComplianceAudit{ID: uuid.New(), TenantID: tenantA, Title: "orig", Status: domain.AuditStatusPlanned} + require.NoError(t, repo.CreateAudit(ctx, a)) + + // Forge the same ID but another tenant → no row matched → ErrNotFound. + forged := &domain.ComplianceAudit{ID: a.ID, TenantID: tenantB, Title: "hijacked", Status: domain.AuditStatusCompleted} + err := repo.UpdateAudit(ctx, forged) + assert.ErrorIs(t, err, domain.ErrNotFound) + + // Original untouched. + got, _ := repo.GetAuditByID(ctx, a.ID, tenantA) + require.NotNil(t, got) + assert.Equal(t, "orig", got.Title) +} + +func TestAuditRepo_Delete(t *testing.T) { + repo := setupAuditRepo(t) + ctx := context.Background() + tenant := uuid.New() + a := &domain.ComplianceAudit{ID: uuid.New(), TenantID: tenant, Title: "gone"} + require.NoError(t, repo.CreateAudit(ctx, a)) + require.NoError(t, repo.DeleteAudit(ctx, a.ID, tenant)) + // Deleting again → not found. + assert.ErrorIs(t, repo.DeleteAudit(ctx, a.ID, tenant), domain.ErrNotFound) +} + +func TestRemediationRepo_CreateListFilter_TenantIsolation(t *testing.T) { + repo := setupAuditRepo(t) + ctx := context.Background() + tenantA, tenantB := uuid.New(), uuid.New() + ctrl := uuid.New() + + p1 := &domain.RemediationPlan{ID: uuid.New(), TenantID: tenantA, Title: "Patch web-01", ControlID: &ctrl, Priority: domain.RemediationPriorityHigh, Status: domain.RemediationStatusOpen} + require.NoError(t, repo.CreateRemediation(ctx, p1)) + require.NoError(t, repo.CreateRemediation(ctx, &domain.RemediationPlan{ID: uuid.New(), TenantID: tenantA, Title: "Other", Priority: domain.RemediationPriorityLow, Status: domain.RemediationStatusInProgress})) + require.NoError(t, repo.CreateRemediation(ctx, &domain.RemediationPlan{ID: uuid.New(), TenantID: tenantB, Title: "B plan", Priority: domain.RemediationPriorityLow, Status: domain.RemediationStatusOpen})) + + // Tenant scoping. + all, err := repo.ListRemediations(ctx, tenantA, domain.RemediationFilter{}) + require.NoError(t, err) + assert.Len(t, all, 2) + + // Filter by control id. + byCtrl, err := repo.ListRemediations(ctx, tenantA, domain.RemediationFilter{ControlID: &ctrl}) + require.NoError(t, err) + require.Len(t, byCtrl, 1) + assert.Equal(t, "Patch web-01", byCtrl[0].Title) + + // Filter by status. + open, err := repo.ListRemediations(ctx, tenantA, domain.RemediationFilter{Status: domain.RemediationStatusOpen}) + require.NoError(t, err) + assert.Len(t, open, 1) + + // Cross-tenant get → nil. + got, err := repo.GetRemediationByID(ctx, p1.ID, tenantB) + require.NoError(t, err) + assert.Nil(t, got) +} + +func TestRemediationRepo_Update_CrossTenantRefused(t *testing.T) { + repo := setupAuditRepo(t) + ctx := context.Background() + tenantA, tenantB := uuid.New(), uuid.New() + p := &domain.RemediationPlan{ID: uuid.New(), TenantID: tenantA, Title: "orig", Priority: domain.RemediationPriorityMedium, Status: domain.RemediationStatusOpen} + require.NoError(t, repo.CreateRemediation(ctx, p)) + + forged := &domain.RemediationPlan{ID: p.ID, TenantID: tenantB, Title: "hijacked", Priority: domain.RemediationPriorityCritical, Status: domain.RemediationStatusCompleted} + assert.ErrorIs(t, repo.UpdateRemediation(ctx, forged), domain.ErrNotFound) +} From c73ee509268b86eb489984033150a104a7527152 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 16 Jul 2026 21:39:09 +0200 Subject: [PATCH 05/12] feat(frontend): audits + remediation-plans UI, compliance hub nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the two new backend aggregates and turns Compliance into a hub. - AuditsPage (/compliance/audits): schedule an audit (type/framework/auditor/ dates/scope), inline status lifecycle (planned→in progress→completed/ cancelled), status filter chips, delete. Gated by compliance:audits:write. - RemediationPage (/compliance/remediations): plans with linked control code, priority + status badges, overdue highlighting, inline status change, delete. Gated by compliance:remediations:write. - CreateAuditDialog + CreateRemediationDialog reuse the dc.html modal primitives (ModalShell/Field/SelectField/TextArea/FooterButtons, now exported). - GapAnalysisPage: each gap gets a "Remédier" button that opens a remediation plan pre-linked to that control — closes the loop gap → remediation. - ComplianceScreen: hub nav row (Gap analysis / Audits / Remediation plans). - Service methods + useAudits/useRemediations hooks + typed interfaces (no any). tsc -b + vite build green. --- frontend/src/App.tsx | 4 + .../compliance/AuditRemediationModals.tsx | 166 +++++++++++++++++ .../src/features/compliance/AuditsPage.tsx | 164 ++++++++++++++++ .../features/compliance/ComplianceModals.tsx | 38 +++- .../features/compliance/ComplianceScreen.tsx | 22 ++- .../features/compliance/GapAnalysisPage.tsx | 25 ++- .../features/compliance/RemediationPage.tsx | 175 ++++++++++++++++++ .../src/features/compliance/useCompliance.ts | 60 ++++++ frontend/src/services/complianceService.ts | 45 +++++ frontend/src/types/compliance.ts | 98 ++++++++++ 10 files changed, 789 insertions(+), 8 deletions(-) create mode 100644 frontend/src/features/compliance/AuditRemediationModals.tsx create mode 100644 frontend/src/features/compliance/AuditsPage.tsx create mode 100644 frontend/src/features/compliance/RemediationPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index be21b228..5634e2f6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,6 +31,8 @@ import { MitigationsBoard } from './features/mitigations/MitigationsBoard'; import { ComplianceScreen } from './features/compliance/ComplianceScreen'; import { FrameworkDetail } from './features/compliance/FrameworkDetail'; import { GapAnalysisPage } from './features/compliance/GapAnalysisPage'; +import { AuditsPage } from './features/compliance/AuditsPage'; +import { RemediationPage } from './features/compliance/RemediationPage'; import { InventoryPage } from './features/assets/InventoryPage'; import { AssetUniverse } from './features/universe/AssetUniverse'; import { AnalyticsCiso } from './features/analytics/AnalyticsCiso'; @@ -141,6 +143,8 @@ function App() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/compliance/AuditRemediationModals.tsx b/frontend/src/features/compliance/AuditRemediationModals.tsx new file mode 100644 index 00000000..d17f5238 --- /dev/null +++ b/frontend/src/features/compliance/AuditRemediationModals.tsx @@ -0,0 +1,166 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: BUSL-1.1 +// +// Dialogs for compliance audits and remediation plans, in the dc.html design +// language. Reuse the shared modal primitives from ComplianceModals.tsx. + +import { useState } from 'react'; +import { CalendarClock, Wrench } from 'lucide-react'; +import { toast } from 'sonner'; +import { useUIStore } from '../../store/uiStore'; +import { ModalShell, Field, SelectField, TextArea, FooterButtons } from './ComplianceModals'; +import { useAudits, useFrameworks, useRemediations } from './useCompliance'; +import type { AuditType, RemediationPriority } from '../../types/compliance'; + +function useTr() { + const lang = useUIStore((s) => s.lang); + return (fr: string, en: string) => (lang === 'fr' ? fr : en); +} + +function errMsg(err: unknown, fallback: string): string { + const e = err as { response?: { data?: { error?: string } } }; + return e?.response?.data?.error || fallback; +} + +/* ------------------------------------------------------------------ */ +/* Schedule an audit */ +/* ------------------------------------------------------------------ */ + +export function CreateAuditDialog({ onClose, onCreated }: { onClose: () => void; onCreated?: (id: string) => void }) { + const tr = useTr(); + const { createAudit } = useAudits(); + const { frameworks } = useFrameworks(); + const [title, setTitle] = useState(''); + const [type, setType] = useState('internal'); + const [frameworkId, setFrameworkId] = useState(''); + const [auditor, setAuditor] = useState(''); + const [scope, setScope] = useState(''); + const [start, setStart] = useState(''); + const [end, setEnd] = useState(''); + const [error, setError] = useState(''); + + const typeOptions = [ + { value: 'internal', label: tr('Interne', 'Internal') }, + { value: 'external', label: tr('Externe', 'External') }, + { value: 'certification', label: tr('Certification', 'Certification') }, + { value: 'surveillance', label: tr('Surveillance', 'Surveillance') }, + ]; + const fwOptions = [{ value: '', label: tr('Programme entier', 'Whole program') }, ...frameworks.map((f) => ({ value: f.id, label: `${f.name}${f.version ? ` ${f.version}` : ''}` }))]; + + const submit = () => { + if (title.trim().length < 2) { + setError(tr('Le titre doit comporter au moins 2 caractères.', 'Title must be at least 2 characters.')); + return; + } + createAudit.mutate( + { + title: title.trim(), + type, + framework_id: frameworkId || undefined, + auditor: auditor.trim() || undefined, + scope: scope.trim() || undefined, + scheduled_start: start || undefined, + scheduled_end: end || undefined, + }, + { + onSuccess: (a) => { + toast.success(tr('Audit planifié', 'Audit scheduled')); + onCreated?.(a.id); + onClose(); + }, + onError: (err) => toast.error(errMsg(err, tr('Création échouée', 'Creation failed'))), + } + ); + }; + + return ( + } onClose={onClose} onSubmit={submit} + footer={}> + { setTitle(v); setError(''); }} required autoFocus + placeholder={tr('ex. Audit interne ISO 27001 Q3', 'e.g. ISO 27001 internal audit Q3')} error={error} /> +
+ setType(v as AuditType)} options={typeOptions} /> + +
+ +
+ + +
+