From 79da3f3b654c2c4d2c8d4fafc248b3bdf22945c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 11:07:18 +0100 Subject: [PATCH 1/9] docs(controls): specify the controls register and its effectiveness tests Module 8 : Control (CTRL), ControlTest (CTST), ControlTestRun (CTRN) and write-once ControlTestEvidence, with the RG-CTL business rules, three lifecycles, permissions, API, MCP tools, notifications and UI. Controls link to requirements, risks, treatment plans, PACS measures, support assets, objectives and documents; effectiveness is derived from independently validated runs; failed runs can raise a finding; the SoA lists the implementing controls. The entity specs that anticipated a future Measure entity now point at Control. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FaEkJnhS3VNDeb6jrdn6MC --- docs/specs/README.md | 1 + docs/specs/m1-context/README.md | 2 +- docs/specs/m1-context/objective.md | 2 +- docs/specs/m2-assets/README.md | 6 +- docs/specs/m2-assets/support-asset.md | 2 +- docs/specs/m3-compliance/README.md | 4 +- docs/specs/m3-compliance/finding.md | 2 + docs/specs/m3-compliance/requirement.md | 1 + docs/specs/m4-risks/README.md | 2 +- docs/specs/m4-risks/ebios-rm/pacs-measure.md | 1 + docs/specs/m4-risks/risk-treatment-plan.md | 2 +- docs/specs/m4-risks/risk.md | 2 +- docs/specs/m7-documents/policy.md | 2 +- docs/specs/m8-controls/README.md | 314 ++++++++++++++++++ .../m8-controls/control-test-evidence.md | 33 ++ docs/specs/m8-controls/control-test-run.md | 44 +++ docs/specs/m8-controls/control-test.md | 50 +++ docs/specs/m8-controls/control.md | 63 ++++ 18 files changed, 521 insertions(+), 12 deletions(-) create mode 100644 docs/specs/m8-controls/README.md create mode 100644 docs/specs/m8-controls/control-test-evidence.md create mode 100644 docs/specs/m8-controls/control-test-run.md create mode 100644 docs/specs/m8-controls/control-test.md create mode 100644 docs/specs/m8-controls/control.md diff --git a/docs/specs/README.md b/docs/specs/README.md index 829b5f1c..4aa5784f 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -15,6 +15,7 @@ This directory is the single source of truth for what each Cairn module does and | [Module 5 : Trust Center](m5-trust-center/README.md) | The public curation layer : certifications, subprocessors, measures, documents | | [Module 6 : incidents](m6-incidents/README.md) | Events, incidents, evidence and custody, notification obligations and filings, breaches, post-incident reviews | | [Module 7 : documents](m7-documents/README.md) | Policies, procedures, standards and charters : versions, approval, review cycle, acknowledgement campaigns | +| [Module 8 : controls](m8-controls/README.md) | Security controls, their links to requirements, risks and assets, effectiveness tests, runs and evidence | | [Management review](management-review/README.md) | The ISO 27001 clause 9.3 entities | | [Assistant](assistant/README.md) | Ask Cairn : the optional question mode and its pluggable provider | diff --git a/docs/specs/m1-context/README.md b/docs/specs/m1-context/README.md index f7204ab1..aa14cef5 100644 --- a/docs/specs/m1-context/README.md +++ b/docs/specs/m1-context/README.md @@ -45,7 +45,7 @@ The module covers seven sub-domains: | Asset management | Activities/processes are linked to essential assets | | Risk management | Issues and the scope feed the risk assessment context | | Compliance | Stakeholders express requirements linked to standards | -| Measures | Objectives are broken down into security measures | +| Controls | Objectives are broken down into security [controls](../m8-controls/README.md) (`Control.objectives`) | | Suppliers | Supplier-type stakeholders feed the suppliers module | | Audits | The scope drives the audit program | | Incidents | Roles and responsibilities define the actors in incident management | diff --git a/docs/specs/m1-context/objective.md b/docs/specs/m1-context/objective.md index 6825651d..6aebfb1e 100644 --- a/docs/specs/m1-context/objective.md +++ b/docs/specs/m1-context/objective.md @@ -25,7 +25,7 @@ Represents an information security or compliance objective. | `related_issues` | relation | M2M → Issue | Issues addressed | | `related_stakeholders` | relation | M2M → Stakeholder | Stakeholders concerned | | `parent_objective_id` | relation | FK → Objective, optional | Parent objective (hierarchy) | -| `linked_measures` | relation | M2M → Measure | Measures contributing to the objective (Measures module) | +| `controls` | relation | reverse M2M ← [Control](../m8-controls/control.md) `objectives` | Controls contributing to the objective | | `review_date` | date | optional | Next review date | | `created_by` | relation | FK → User | Creator | | `created_at` | datetime | auto | Creation date | diff --git a/docs/specs/m2-assets/README.md b/docs/specs/m2-assets/README.md index 4762579b..d4815799 100644 --- a/docs/specs/m2-assets/README.md +++ b/docs/specs/m2-assets/README.md @@ -49,7 +49,7 @@ The module covers four sub-domains: | Context and Organization | Activities/processes (Module 1) are attached to essential assets. The Scope frames the asset inventory. | | Risk management | Essential and support assets are the subjects of risk assessment (ISO 27005 and EBIOS RM). The CIA security needs feed the impact evaluation. | | Compliance | Some regulatory requirements bear directly on asset categories (personal data, health data, etc.). | -| Measures | Security measures are applied to support assets to protect essential assets. | +| Controls | Security [controls](../m8-controls/README.md) are applied to support assets to protect essential assets (`Control.support_assets`). | | Suppliers | Support assets of the outsourced service type are linked to suppliers. | | Incidents | Incidents are attached to the impacted assets. | @@ -63,7 +63,7 @@ The module covers four sub-domains: |---|---| | RG-01 | Every asset (essential asset or support asset) must be attached to an active **Scope**. | | RG-02 | Every asset must have a designated **owner** (`owner_id`). | -| RG-03 | Deleting an asset referenced by the Risks or Measures module is forbidden. Deactivation (`status = decommissioned` or `disposed`) is used instead. | +| RG-03 | Deleting an asset referenced by the Risks or Controls module is forbidden. Deactivation (`status = decommissioned` or `disposed`) is used instead. | | RG-04 | Every change to an asset generates an entry in the **audit trail**. | | RG-05 | The `created_at` and `updated_at` fields are managed automatically by the system. | | RG-06 | Configurable value lists (categories, types) are managed through the dedicated configuration table. | @@ -151,7 +151,7 @@ Identical to Module 1. Base URL: `/api/v1/assets/` | `GET` | `/support-assets/{id}/dependencies` | List the dependency relationships | | `GET` | `/support-assets/{id}/inherited-dic` | Detail of the inherited CIA calculation | | `GET` | `/support-assets/{id}/children` | List the support sub-assets | -| `GET` | `/support-assets/{id}/measures` | List the applied measures (Measures module) | +| `GET` | `/api/v1/controls/controls/?support_asset_id={id}` | List the applied controls ([Module 8](../m8-controls/README.md)) | | `GET` | `/support-assets/{id}/risks` | List the associated risks (Risks module) | | `GET` | `/support-assets/{id}/incidents` | List the associated incidents (Incidents module) | | `GET` | `/support-assets/categories` | List the available categories | diff --git a/docs/specs/m2-assets/support-asset.md b/docs/specs/m2-assets/support-asset.md index 4ccff2c9..19132640 100644 --- a/docs/specs/m2-assets/support-asset.md +++ b/docs/specs/m2-assets/support-asset.md @@ -37,7 +37,7 @@ Represents a technical, human or physical asset that supports the essential asse | `environment` | enum | optional | `production`, `staging`, `development`, `test`, `disaster_recovery` | | `essential_assets` | relation | M2M → EssentialAsset (via AssetDependency) | Supported essential assets | | `parent_asset_id` | relation | FK → SupportAsset, optional | Parent support asset (composition) | -| `related_measures` | relation | M2M → Measure | Applied security measures (Measures module) | +| `controls` | relation | reverse M2M ← [Control](../m8-controls/control.md) `support_assets` | Security controls applied to the asset | | `status` | enum | required | `in_stock`, `deployed`, `active`, `under_maintenance`, `decommissioned`, `disposed` | | `review_date` | date | optional | Next review date | | `created_by` | relation | FK → User | Creator | diff --git a/docs/specs/m3-compliance/README.md b/docs/specs/m3-compliance/README.md index badda267..a43c982a 100644 --- a/docs/specs/m3-compliance/README.md +++ b/docs/specs/m3-compliance/README.md @@ -162,7 +162,7 @@ Identical to the previous modules. Base URL: `/api/v1/compliance/` | `PATCH` | `/requirements/{id}` | Partial update | | `DELETE` | `/requirements/{id}` | Delete (if not referenced) | | `PATCH` | `/requirements/{id}/assess` | Assess the compliance of a requirement (quick update) | -| `GET` | `/requirements/{id}/measures` | List the linked controls | +| `GET` | `/api/v1/controls/controls/?requirement_id={id}` | List the linked controls ([Module 8](../m8-controls/README.md)) | | `GET` | `/requirements/{id}/mappings` | List the mappings of this requirement | | `GET` | `/requirements/{id}/action-plans` | List the linked action plans | | `GET` | `/requirements/{id}/history` | Assessment history of this requirement | @@ -179,7 +179,7 @@ Identical to the previous modules. Base URL: `/api/v1/compliance/` - `?compliance_level_min=0&compliance_level_max=50` - `?owner_id={uuid}` - `?priority=high,critical` -- `?has_measures=true|false` +- `?has_controls=true|false` - `?has_mappings=true|false` - `?search=term` diff --git a/docs/specs/m3-compliance/finding.md b/docs/specs/m3-compliance/finding.md index 2c89f6dc..f2db2c46 100644 --- a/docs/specs/m3-compliance/finding.md +++ b/docs/specs/m3-compliance/finding.md @@ -25,6 +25,7 @@ The cost of the decision is real and is stated here rather than buried : it chan | `assessment` | relation | FK -> [ComplianceAssessment](compliance-assessment.md), **optional**, `SET_NULL` | **Changed.** Was required with `CASCADE`. Now nullable, so a nonconformity can exist without a fabricated audit, and `SET_NULL`, so deleting an audit no longer destroys the nonconformities it raised. Reverse accessor `findings`. | | `source` | enum | required, default `audit` | **New.** `FindingSource` : what surfaced the nonconformity. The default keeps every pre-existing row semantically unchanged. | | `incident` | relation | FK -> [Incident](../m6-incidents/incident.md), optional, `SET_NULL` | **New.** Set when `source = incident`. Reverse accessor `findings`. Note that `findings` now denotes different things on `User`, `ComplianceAssessment`, `Requirement` and `Incident`; that is legal but worth knowing when reading code. | +| `control_test_run` | relation | FK -> [ControlTestRun](../m8-controls/control-test-run.md), optional, `SET_NULL` | Set when `source = control_test`, by the "Raise a finding" action (RG-CTL-16). Reverse accessor `findings`. | | `finding_type` | enum | required | `FindingType` : `major_nc`, `minor_nc`, `observation`, `improvement`, `strength`. Drives the reference prefix and the severity ordering. | | `description` | text | required | The nonconformity itself : what was observed, against what requirement | | `recommendation` | text | optional, blank default | Recommended treatment. Labelled *Auditor recommendation*; the label is now source-neutral in the UI. | @@ -46,6 +47,7 @@ Inherited from `BaseModel` : `workflow_state` (default 4-state lifecycle, unchan | `management_review` | Management review | | `monitoring` | Monitoring | | `complaint` | Complaint | +| `control_test` | Control test | `Audit` and `Monitoring` and `Complaint` already exist as bare `msgid`s in the catalogue, so these labels are declared with `pgettext_lazy("finding source", ...)` and a matching `msgctxt`. diff --git a/docs/specs/m3-compliance/requirement.md b/docs/specs/m3-compliance/requirement.md index 15aae2de..fa0e0f47 100644 --- a/docs/specs/m3-compliance/requirement.md +++ b/docs/specs/m3-compliance/requirement.md @@ -32,6 +32,7 @@ Individual requirement extracted from a [Framework](framework.md), the elementar | `linked_assets` | relation | M2M -> EssentialAsset | Essential assets concerned | | `linked_stakeholder_expectations` | relation | M2M -> StakeholderExpectation | Associated stakeholder expectations | | `linked_risks` | reverse M2M | -> Risk via `Risk.linked_requirements` | Associated risks (fed from the Risk side) | +| `controls` | reverse M2M | -> [Control](../m8-controls/control.md) via `Control.requirements` | Controls implementing the requirement; listed in the SoA (RG-CTL-18) | | `mapped_requirements` | relation | M2M via [`RequirementMapping`](requirement-mapping.md) | Requirements from other frameworks mapped | | `status` | enum | required, default `active` | `active`, `deprecated`, `superseded` | | `version` | int | auto-incremented | Bumped on each major change | diff --git a/docs/specs/m4-risks/README.md b/docs/specs/m4-risks/README.md index 79559c13..d382b37e 100644 --- a/docs/specs/m4-risks/README.md +++ b/docs/specs/m4-risks/README.md @@ -60,7 +60,7 @@ The module breaks down into three parts: | Context and Organization | The scope (Scope), the issues and the interested parties feed the risk assessment context. Business activities are the objects of EBIOS RM workshop 1. | | Asset management | Essential assets carry the security needs (CIA) and define the impacted business values. Support assets are the targets of vulnerabilities and operational scenarios. | | Compliance | Non-conformities can generate risks. Compliance requirements can be linked to identified risks. | -| Measures | Security measures reduce the risk level. Risk treatment generates new or reinforced measures. | +| Controls | Security [controls](../m8-controls/README.md) treat risks. Risk treatment plans and EBIOS PACS measures create or reinforce controls (`Control.risks`, `Control.treatment_plans`, `Control.pacs_measures`). | | Suppliers | Suppliers are stakeholders of the ecosystem (EBIOS RM workshop 3) and can be risk vectors. | | Audits | Audit findings can reveal risks or validate the effectiveness of treatments. | | Incidents | Incidents feed the reassessment of risks and validate (or invalidate) the identified scenarios. | diff --git a/docs/specs/m4-risks/ebios-rm/pacs-measure.md b/docs/specs/m4-risks/ebios-rm/pacs-measure.md index ed3fcc4c..cb3ba929 100644 --- a/docs/specs/m4-risks/ebios-rm/pacs-measure.md +++ b/docs/specs/m4-risks/ebios-rm/pacs-measure.md @@ -17,6 +17,7 @@ PACS measure (Plan d'Amélioration Continue de la Sécurité - Continuous Securi | `linked_treatment_plans` | M2M -> [RiskTreatmentPlan](../risk-treatment-plan.md) | optional | Treatment plans carrying the measure | | `linked_baseline_gaps` | M2M -> [BaselineGap](baseline-gap.md) | optional | Baseline gaps addressed by the measure | | `linked_requirements` | M2M -> Requirement | optional | Compliance requirements covered | +| `controls` | reverse M2M -> [Control](../../m8-controls/control.md) via `Control.pacs_measures` | optional | Register controls the measure creates or reinforces | | `owner_id` | relation | FK -> User, required | Measure owner | | `start_date` | date | optional | Start date | | `target_date` | date | required | Target date | diff --git a/docs/specs/m4-risks/risk-treatment-plan.md b/docs/specs/m4-risks/risk-treatment-plan.md index 5f50cb55..69e9c268 100644 --- a/docs/specs/m4-risks/risk-treatment-plan.md +++ b/docs/specs/m4-risks/risk-treatment-plan.md @@ -44,7 +44,7 @@ Represents the treatment actions associated with a risk. | `owner_id` | relation | FK → User, required | Action owner | | `target_date` | date | required | Target date | | `completion_date` | date | optional | Completion date | -| `linked_measures` | relation | M2M → Measure | Associated measures (Measures module) | +| `controls` | relation | reverse M2M ← [Control](../m8-controls/control.md) `treatment_plans` | Controls this plan creates or reinforces | | `status` | enum | required | `planned`, `in_progress`, `completed`, `cancelled` | | `order` | integer | required | Execution order | | `created_at` | datetime | auto | Creation date | diff --git a/docs/specs/m4-risks/risk.md b/docs/specs/m4-risks/risk.md index d2d58932..806a4015 100644 --- a/docs/specs/m4-risks/risk.md +++ b/docs/specs/m4-risks/risk.md @@ -35,7 +35,7 @@ Represents an identified risk, regardless of the originating methodology. It is | `treatment_decision` | enum | optional | `accept`, `mitigate`, `transfer`, `avoid`, `not_decided` | | `treatment_justification` | text | optional | Justification for the treatment decision | | `risk_owner_id` | relation | FK → User, required | Risk owner (decision-maker) | -| `linked_measures` | relation | M2M → Measure | Existing measures covering this risk | +| `controls` | relation | reverse M2M ← [Control](../m8-controls/control.md) `risks` | Controls treating this risk, with their effectiveness (RG-CTL-20) | | `linked_requirements` | relation | M2M → Requirement | Associated compliance requirements | | `linked_incidents` | relation | M2M → Incident | Linked incidents | | `priority` | enum | optional | `low`, `medium`, `high`, `critical` | diff --git a/docs/specs/m7-documents/policy.md b/docs/specs/m7-documents/policy.md index 38dcfff5..0603973c 100644 --- a/docs/specs/m7-documents/policy.md +++ b/docs/specs/m7-documents/policy.md @@ -37,7 +37,7 @@ File: `documents/models/policy.py` | `linked_requirements` | M2M | `compliance.Requirement` | `linked_policies` | The requirements the document answers (A.5.1, clause 5.2, A.5.15...). The requirement detail page lists them. | | `applies_to_roles` | M2M | `context.Role` | `applicable_policies` | Roles the document is primarily addressed to (informative; also the default audience of a new campaign) | -Reverse accessors : `versions` ([PolicyVersion](policy-version.md), `CASCADE` only while draft, see deletion), `children`, `isms_changes` (`reports.IsmsChange.affected_documents`), `trust_center_documents` (`trust_center.TrustCenterDocument.policy`, `PROTECT`). +Reverse accessors : `versions` ([PolicyVersion](policy-version.md), `CASCADE` only while draft, see deletion), `children`, `isms_changes` (`reports.IsmsChange.affected_documents`), `trust_center_documents` (`trust_center.TrustCenterDocument.policy`, `PROTECT`), `controls` ([Control](../m8-controls/control.md) `policies` : the controls the document describes). ### Meta diff --git a/docs/specs/m8-controls/README.md b/docs/specs/m8-controls/README.md new file mode 100644 index 00000000..ab7f93d6 --- /dev/null +++ b/docs/specs/m8-controls/README.md @@ -0,0 +1,314 @@ +# Module 8 : Controls + +## Functional and technical specification + +The register of the organisation's **security controls** (in French, *mesures de sécurité*) and of the tests that prove they work. A control is something the organisation actually does : "multi-factor authentication on every remote access", "quarterly access review of the ERP", "encrypted off-site backups". Each control answers one or more requirements of one or more frameworks (ISO/IEC 27001:2022 Annex A, NIS2, a customer contract), treats one or more risks, protects support assets, and contributes to objectives. Its **effectiveness** is not declared : it is derived from recorded, reviewed test runs with their evidence. + +It answers the questions an auditor asks of the Statement of Applicability and of clause 9.1 : *what does the organisation do for this requirement, is it implemented, who owns it, how do you know it works, and when was it last tested.* + +**Status : proposed for v0.37.0.** + +## Entities + +| Entity | Class | Reference prefix | Lifecycle | +| --- | --- | --- | --- | +| [Control](control.md) | `controls.models.control.Control` | `CTRL` | `control` | +| [ControlTest](control-test.md) | `controls.models.control_test.ControlTest` | `CTST` | `control_test` | +| [ControlTestRun](control-test-run.md) | `controls.models.control_test_run.ControlTestRun` | `CTRN` | `control_test_run` | +| [ControlTestEvidence](control-test-evidence.md) | `controls.models.evidence.ControlTestEvidence` | none | none (write-once record) | + +--- + +## 1. Overview + +### 1.1 Purpose + +Today the treatment of a requirement or a risk is scattered : compliance action plans, risk treatment plans and EBIOS PACS measures all describe *work to do*, but none of them is the durable thing that work produces. The specifications of the risk, treatment plan, objective, support asset and requirement entities all anticipate a `Measure` entity "(Measures module)" that was never built; `Requirement` still carries a commented-out `linked_measures` field. As a result the SoA can only say that a requirement is applicable and which action plans touch it, not which control implements it or whether that control works. + +This module makes the controls a governed register : + +- a **control** has an identity (reference, name, ISO 27001 theme, nature, automation, owner, scopes) and a lifecycle from proposed to planned, implemented and retired; +- it is linked to the **requirements** it satisfies (any framework, many-to-many : one control serves ISO 27001 A.8.5, NIS2 article 21 and a customer clause at once), the **risks** it treats, the **treatment plans** and **PACS measures** that create or reinforce it, the **support assets** it applies to, the **objectives** it contributes to and the **documents** that describe it; +- each control has one or more **tests** (method, procedure, acceptance criteria, frequency, tester); +- each **test run** records who tested, when, over which period, on which sample, with what result and which evidence; it is frozen once submitted and validated by a second person (segregation of duties); +- the control's **effectiveness** is derived from the latest validated run of each of its active tests, and every test has a derived next due date with reminders; +- a failed or partial run can **raise a finding** in the nonconformity register (clause 10.2), pre-filled from the run; +- the **SoA** lists, for every applicable requirement, the controls implementing it, their implementation state and their effectiveness. + +### 1.2 Functional scope + +| Area | In v0.37.0 | +| --- | --- | +| Control register with theme, nature, automation, owner, scopes, tags, implementation date, description of how it operates | yes | +| Links to requirements, risks, treatment plans, PACS measures, support assets, objectives and documents | yes | +| "Create controls from requirements" : draft one control per selected requirement of a framework (bootstraps an Annex A register) | yes | +| Tests with method, procedure, acceptance criteria, frequency, default tester; derived next due date | yes | +| Test runs with result, period, sample, observations, evidence files (SHA-256), submission and independent validation | yes | +| Derived control effectiveness; overdue tests | yes | +| "Raise a finding" from a failed or partial run | yes | +| SoA enriched with implementing controls, their state and their effectiveness | yes | +| Control cards on the requirement, risk, treatment plan, PACS measure, support asset, objective and document detail pages | yes | +| REST API, MCP tools, global search, calendar, dashboard widget, kanban (my tests due, my runs to validate), predefined indicators, help banners, demo seed | yes | +| Automatic adjustment of a risk's residual level from control effectiveness | no (see §11) | +| Automated test runs pushed by an external scanner or CI job | no (see §11) | +| Control maturity scoring (CMMI-like levels) | no (see §11) | + +### 1.3 Dependencies on other modules + +| Target module | Nature of the dependency | +| --- | --- | +| Accounts | `User` for owners, testers and reviewers; `accounts.Notification` for every notification; `PERMISSION_REGISTRY` and `SYSTEM_GROUPS`. | +| Context | `ScopedModel` / `BaseModel`, `Scope` tenancy, `Tag`; `Objective` through `Control.objectives`. | +| Assets | `SupportAsset` through `Control.support_assets`. | +| Compliance | `Requirement` through `Control.requirements`; `Finding.control_test_run` (FK, `SET_NULL`) and a new `FindingSource.CONTROL_TEST` value. | +| Risks | `Risk` through `Control.risks`; `RiskTreatmentPlan` through `Control.treatment_plans`; `PACSMeasure` through `Control.pacs_measures`. | +| Documents | `Policy` through `Control.policies` (the procedure or standard that describes the control). | +| Reports | The SoA generator (`reports.generators.build_soa_frameworks_data`) reads `Requirement.controls`. | + +Every relation is declared **on `Control`**, with the reverse accessor `controls` on the target model. No other app gains a column except `Finding`; the commented-out placeholders (`Requirement.linked_measures`, `SupportAsset.related_measures`, `Objective.linked_measures`) are removed and the entity specifications that named a future `Measure` now point here. + +--- + +## 2. Business rules : the RG-CTL register + +### 2.1 Controls + +| ID | Rule | +| --- | --- | +| RG-CTL-01 | A control is a durable practice, not a task. Work to set it up lives in the plans that link to it (treatment plans, PACS measures, compliance action plans through the requirements); the control's lifecycle says where the practice itself stands : proposed (`draft`), decided but not yet operating (`planned`), operating (`implemented`), retired (`archived`). | +| RG-CTL-02 | Declaring a control implemented stamps `implemented_on` (default today, may be set earlier, never in the future) and is gated by `controls.control.approve`. Sending an implemented control back to `planned` requires a comment (the practice stopped or regressed) and clears nothing : `implemented_on` keeps the original date, and history shows the regression. | +| RG-CTL-03 | A control can be retired only with a comment. Retiring it retires its active tests in the same transaction (RG-CTL-08 mechanics). A retired control stays visible on the pages of the entities it was linked to, flagged as retired, and drops out of the SoA and every KPI. | +| RG-CTL-04 | Every relation picker of the control form offers `linkable_or_linked()` targets : a requirement, risk, plan, asset, objective or document in a linkable step, plus the ones already linked. The reverse cards on the target pages list controls in any step, with their state badge. | +| RG-CTL-05 | "Create controls from requirements" creates, for each selected applicable requirement of a framework that has no linked control yet, one `draft` control named after the requirement, with its description copied as a starting point, the framework's scopes and the requirement linked. It never links an existing control and never runs twice for the same requirement (requirements already covered are skipped and reported). | + +### 2.2 Tests and runs + +| ID | Rule | +| --- | --- | +| RG-CTL-06 | A [test](control-test.md) belongs to one control. It can be activated only when its control is `planned` or `implemented` and it has a procedure and acceptance criteria : a test with no pass criterion proves nothing. | +| RG-CTL-07 | `next_due_date` of an active test is **derived** and stored (indexed, for the calendar, the dashboard and the sweep) : the `performed_on` date of its latest validated run plus `frequency_months`; with no validated run yet, its `first_due_date`, or the activation date when none was given. A test is **overdue** when it is active and `next_due_date < today`; overdue is never a lifecycle step. | +| RG-CTL-08 | Retiring a test requires a comment. A retired test no longer counts towards effectiveness and is no longer due; its runs stay readable. | +| RG-CTL-09 | A [run](control-test-run.md) is recorded against an **active** test. Its content (result, performed by, performed on, period, sample, observations, evidence) is **editable only in `draft`**; from `in_review` onward it is frozen on every surface. Sending it back to draft re-opens it. | +| RG-CTL-10 | Submitting a run requires a result; a `partial` or `fail` result also requires observations. `performed_on` cannot be in the future; when a period is given, `period_start <= period_end <= performed_on`. | +| RG-CTL-11 | **Segregation of duties** : a run is validated by a holder of `controls.test.approve` who is **not** its `performed_by`. The gate applies to superusers too; the only bypass is the system path used by the seed (`enforce_permission=False`). Validation stamps `reviewed_by` / `reviewed_at` from the transition. | +| RG-CTL-12 | Validating a run recomputes, in the same transaction, the test's `next_due_date` (RG-CTL-07) and the control's effectiveness (RG-CTL-14). Withdrawing a validated run (to `archived`, with a comment : the run was found unreliable) recomputes both as well. | +| RG-CTL-13 | Evidence files follow the incident evidence rules : allowed extensions, at most 25 MB each, SHA-256 stored at upload, served only through the permission-checked, scope-filtered download view, never serialized. Files are added or removed only while the run is in `draft`; afterwards an [evidence](control-test-evidence.md) row is write-once. | + +### 2.3 Effectiveness + +| ID | Rule | +| --- | --- | +| RG-CTL-14 | `Control.effectiveness` is **derived** and stored (for filters, the SoA and the KPIs), from the latest validated run of each **active** test of the control : `not_effective` if any of them failed, otherwise `partially_effective` if any is partial, otherwise `effective` if every active test has one, otherwise `not_tested`. A control with no active test is `not_tested`. It is recomputed on every run transition, on test activation and retirement, and by the daily sweep. | +| RG-CTL-15 | `Control.is_test_overdue` is true when any active test of the control is overdue. A control whose effectiveness rests on an overdue test still shows its last effectiveness, flagged as stale; it is never silently downgraded. | +| RG-CTL-16 | "Raise a finding" on a validated `partial` or `fail` run creates a [Finding](../m3-compliance/finding.md) with `source = control_test`, `control_test_run` set, the run's observations as its description, the control's requirements as its related requirements, and the acting user as `assessor`. It is an explicit action (the finding type, minor / major nonconformity or observation, is a judgement), offered once per run and recorded on the run page. | + +### 2.4 Integration + +| ID | Rule | +| --- | --- | +| RG-CTL-17 | Every report, KPI, calendar feed and picker filters through `reportable()` / `linkable()` / `linkable_or_linked()`. Controls in `planned` and `implemented` are reportable and linkable; tests count only when `active`; runs only when `validated`. | +| RG-CTL-18 | The **SoA** gains, for each applicable requirement, the list of its reportable controls with their implementation state and effectiveness, and an *Implementation* column summarising them (*Implemented*, *Partially implemented* when some controls are only planned, *Planned*, *No control*). When at least one control is linked, the justification of an applicable requirement lists the controls before the action plans. Not-applicable requirements keep their stored justification. | +| RG-CTL-19 | `Control` is a `ScopedModel` and carries its own `scopes` (defaulting, on creation from a requirement, to the framework's scopes); `ControlTest`, `ControlTestRun` and `ControlTestEvidence` inherit through `scope_parent_lookup` (`control__scopes`, `test__control__scopes`, `run__test__control__scopes`) in `ScopeFilterMixin`, the API mixin and the MCP scope filter. | +| RG-CTL-20 | A risk's residual level is **not** computed from its controls in v0.37.0 (see §11) : the risk and treatment plan pages show the linked controls and their effectiveness so the assessor can justify the residual level they set. | + +--- + +## 3. Lifecycles + +Each lifecycle is generated from its transition constants in `controls/constants.py` and registered in `controls/lifecycles.py`, imported from `ControlsConfig.ready()`. A test asserts each model's `get_lifecycle().name`. As in the documents module, the exit step of each lifecycle keeps the platform code `archived` with a domain label, so the engine never appends unguarded archive / restore edges. + +### 3.1 `control` + +| Code | Label | Kind | In reports | Linkable | Deletable | Tone | +| --- | --- | --- | --- | --- | --- | --- | +| `draft` | Proposed | DRAFT | no | no | **yes** | neutral | +| `planned` | Planned | INTERMEDIATE | **yes** | **yes** | no | info | +| `implemented` | Implemented | INTERMEDIATE | **yes** | **yes** | no | success | +| `archived` | Retired | ARCHIVED | no | no | no | muted | + +| Verb | Transition | `permission_action` | `requires_comment` | Side effect | +| --- | --- | --- | --- | --- | +| Plan | `draft -> planned` | `approve` | no | | +| Declare implemented | `draft -> implemented`, `planned -> implemented` | `approve` | no | stamps `implemented_on` (RG-CTL-02) | +| Back to planned | `implemented -> planned` | `update` | **yes** | | +| Retire | `planned -> archived`, `implemented -> archived` | `approve` | **yes** | retires the active tests (RG-CTL-03) | +| Retire | `draft -> archived` | `update` | **yes** | | + +`draft -> implemented` exists for the initial inventory : most controls already operate when the register is first filled. + +### 3.2 `control_test` + +| Code | Label | Kind | In reports | Linkable | Deletable | Tone | +| --- | --- | --- | --- | --- | --- | --- | +| `draft` | Draft | DRAFT | no | no | **yes** | neutral | +| `active` | Active | INTERMEDIATE | **yes** | **yes** | no | success | +| `archived` | Retired | ARCHIVED | no | no | no | muted | + +| Verb | Transition | `permission_action` | `requires_comment` | Side effect | +| --- | --- | --- | --- | --- | +| Activate | `draft -> active` | `approve` | no | RG-CTL-06 gate; computes `next_due_date`; recomputes the control's effectiveness | +| Retire | `active -> archived` | `approve` | **yes** | recomputes the control's effectiveness | +| Retire | `draft -> archived` | `update` | **yes** | | + +### 3.3 `control_test_run` + +| Code | Label | Kind | In reports | Linkable | Deletable | Tone | +| --- | --- | --- | --- | --- | --- | --- | +| `draft` | In progress | DRAFT | no | no | **yes** | neutral | +| `in_review` | In review | INTERMEDIATE | no | no | no | info | +| `validated` | Validated | INTERMEDIATE | **yes** | no | no | success | +| `archived` | Withdrawn | ARCHIVED | no | no | no | muted | + +| Verb | Transition | `permission_action` | `requires_comment` | Side effect | +| --- | --- | --- | --- | --- | +| Submit for review | `draft -> in_review` | `update` | no | RG-CTL-10 gate; `notify_lifecycle_submitted` to the test's reviewers | +| Send back | `in_review -> draft` | `approve` | **yes** | | +| Validate | `in_review -> validated` | **`approve`** | no | RG-CTL-11 gate and stamps; RG-CTL-12 recomputation; `CONTROL_TEST_FAILED` when the result is `partial` or `fail` | +| Withdraw | `validated -> archived` | `approve` | **yes** | RG-CTL-12 recomputation | +| Cancel | `draft -> archived` | `update` | **yes** | | + +The gates and side effects live in each model's `transition_to()`, inside one `transaction.atomic()` block that locks the control row, so every surface (stepper, REST, MCP) goes through them. + +--- + +## 4. Permissions and access control + +`PERMISSION_REGISTRY` gains a `controls` module (`MODULE_LABELS["controls"] = _("Controls")`) with two features : + +| Codename | Description | +| --- | --- | +| `controls.control.read` | List and read controls and their links | +| `controls.control.create` | Create a control, including from requirements | +| `controls.control.update` | Edit a control's identity and links, send it back to planned | +| `controls.control.approve` | Plan, declare implemented, retire a control | +| `controls.control.delete` | Delete a proposed control | +| `controls.test.read` | Read tests, runs and evidence | +| `controls.test.create` | Create a test, record a run | +| `controls.test.update` | Edit a draft test or run, add or remove its evidence, submit a run | +| `controls.test.approve` | Activate / retire a test, validate / send back / withdraw a run, raise a finding from a run | +| `controls.test.delete` | Delete a draft test or a draft run | + +System groups : Super Administrateur and Administrateur get every codename; RSSI / DPO get read, create, update, approve; Contributeur gets read, create, update; Auditeur gets read plus `controls.test.create` and `controls.test.update` (auditors record test runs; validation stays with the second line); Lecteur gets read. A migration creates the rows and attaches them. + +--- + +## 5. API + +Base path `/api/v1/controls/`. House pattern (`ModulePermission`, `ScopeFilterAPIMixin`, `LifecycleAPIMixin`, `status` read-only from `workflow_state`). + +| Method | Path | Description | +| --- | --- | --- | +| `GET` / `POST` | `controls/` | List (filters `status`, `theme`, `nature`, `owner_id`, `scope_id`, `effectiveness`, `test_overdue`, `requirement_id`, `risk_id`) / create | +| `GET` / `PUT` / `PATCH` / `DELETE` | `controls/{id}/` | Read / update / delete a proposed control | +| `GET` / `POST` | `controls/{id}/transition/` | Allowed transitions / perform one | +| `GET` | `controls/{id}/history/` | Timeline | +| `POST` | `controls/from-requirements/` | RG-CTL-05 : `framework_id`, optional `requirement_ids`, `scope_ids`; returns created and skipped | +| `GET` / `POST` | `tests/` | List (filters `control_id`, `status`, `overdue`, `due_before`, `tester_id`) / create | +| `GET` / `PATCH` / `DELETE` | `tests/{id}/` | Read / edit a draft / delete a draft | +| `GET` / `POST` | `tests/{id}/transition/` | Activate, retire | +| `GET` / `POST` | `runs/` | List (filters `test_id`, `control_id`, `status`, `result`, `performed_by_id`) / create a draft run | +| `GET` / `PATCH` / `DELETE` | `runs/{id}/` | Read / edit a draft (RG-CTL-09) / delete a draft | +| `GET` / `POST` | `runs/{id}/transition/` | Submit, send back, validate, withdraw, cancel | +| `POST` | `runs/{id}/raise-finding/` | RG-CTL-16 : `finding_type`, optional `recommendation` | + +Evidence files are uploaded through the web form only (binary payloads stay out of the JSON API and MCP, as for contracts and policy PDFs); the run serializer lists its evidence with `file_name`, `size`, `sha256` and `download_url`. + +--- + +## 6. MCP tools + +`_register_controls_tools()` in `mcp/tools.py`, a `TOPIC_CONTROLS` help topic, and the `CTRL` / `CTST` / `CTRN` reference prefixes in `HELP_TEXT`. + +- `_register_crud` for `control`, `control_test` and `control_test_run` (list, get, create, batch create, update, delete, transition, allowed transitions, history), with `m2m_fields` for scopes and every control relation. +- `create_controls_from_requirements(framework_id, requirement_ids=None, scope_ids=None)` (RG-CTL-05). +- `get_control_effectiveness(control_id)` : the effectiveness, each active test with its latest validated run and next due date. +- `list_overdue_control_tests()` and `list_my_control_tests_due()`. +- `raise_finding_from_run(run_id, finding_type, recommendation="")` (RG-CTL-16). +- `get_requirement_coverage(framework_id)` : per applicable requirement, its controls, their state and effectiveness (the data behind the SoA). + +`assistant/catalog.py` gains read-only `ToolSpec` entries for listing controls, reading a control's effectiveness and reading a framework's coverage. + +--- + +## 7. Notifications + +New `NotificationType` values with `notify_*` helpers in `accounts/notifications.py`; an accounts migration alters the choices. + +| Event | Recipients | Channel | +| --- | --- | --- | +| `CONTROL_TEST_DUE` : daily sweep, `next_due_date` within 14 days | The test's tester, else the control owner | In-app, email | +| `CONTROL_TEST_OVERDUE` : same sweep, due date passed (sent once, then weekly) | The tester and the control owner | In-app, email | +| `CONTROL_TEST_FAILED` : a `partial` or `fail` run is validated | The control owner | In-app, email | + +Submission of a run sends the existing `LIFECYCLE_SUBMITTED` notification (RG-LC-06) to holders of `controls.test.approve` in scope, excluding the performer. + +--- + +## 8. User interface + +All pages follow the [UI components](../../sdk/ui-components.md) anatomies. + +- **Sidebar** : a new **Controls** section between Compliance and Documents, with *Controls* and *Tests*. The section sits in the compliance family of the navigation tree. +- **Control list** (`/controls/`) : reference, name, theme, owner, state, effectiveness (badge), next test due (red when overdue), requirement count. Rail : implemented controls, effective rate (effective / tested), overdue tests, controls never tested. Filters on theme, nature, owner, scope, state, effectiveness, overdue. A *Create from requirements* action opens a drawer (framework, then a checklist of uncovered applicable requirements). +- **Control detail** : stepper (`control`), rail (lifecycle stage, effectiveness, next test due, implemented on). Cards : *Description* (how the control operates, theme, nature, automation), *Tests* (flush table : name, method, frequency, latest result, next due; "New test"), *Recent runs*, *Requirements* (grouped by framework), *Risks and treatment* (risks with their residual level, treatment plans, PACS measures), *Applies to* (support assets), *Objectives*, *Documents*, traceability card. +- **Test detail** : stepper (`control_test`), rail (stage, latest result, next due, frequency). Cards : *Procedure* (objective, method, procedure, acceptance criteria), *Runs* (flush table : date, performer, result, state, reviewer; "Record a run"). +- **Run detail** : stepper (`control_test_run`), rail (stage, result, performed on, reviewer). Cards : *Result* (result, period, sample, observations), *Evidence* (files with size and SHA-256, upload / remove while in progress), *Review* (reviewer, date), *Finding* ("Raise a finding" on a validated partial / failed run, or the finding raised). +- **Test register** (`/controls/tests/`) : every test with control, tester, frequency, latest result, next due; filters on overdue, due window, tester, result. +- **Reverse cards** : a *Controls* `section_card` on the requirement, risk, treatment plan, PACS measure, support asset, objective and document detail pages (reference, name, state, effectiveness). +- Forms are drawer modals with their page fallbacks; the run form opens prefilled with the test's default tester as performer and today as date. + +Platform surfaces : global search (controls and tests), calendar (`controls` category : next test due dates), dashboard widget *Controls* (implemented, effective rate, overdue tests, failed runs awaiting a finding), kanban (my tests due within 14 days in *To do*, my runs in progress in *In progress*, runs I can validate in *To review*), predefined indicators `control_effectiveness_rate` and `control_tests_overdue`, help banners on the five pages, the SoA (RG-CTL-18). + +--- + +## 9. Technical considerations + +### 9.1 App wiring checklist + +- [ ] `INSTALLED_APPS` gains `"controls"`; `ControlsConfig.ready()` imports `controls.lifecycles` and the signal handlers. +- [ ] Models, admin (`SimpleHistoryAdmin`), initial migration; `HistoricalRecords(excluded_fields=["file"])` on the evidence. +- [ ] `Finding.control_test_run` (FK `SET_NULL`) and `FindingSource.CONTROL_TEST`, with a compliance migration; the finding page shows the originating run. +- [ ] Removal of the commented-out `linked_measures` / `related_measures` placeholders; the entity specs updated to point at `Control`. +- [ ] `PERMISSION_REGISTRY`, `MODULE_LABELS`, the permission migration attaching the rows to the six system groups. +- [ ] `core/navigation.py` `NAV_TREE` entries; `MODULE_ACCENTS["controls"]` with its tokens in both blocks of `static/css/cairn/00-tokens.css`; the sidebar section. +- [ ] `BADGE_REGISTRY` : an `effectiveness` scale extended with `not_tested`, a `test_result` scale (`pass`, `partial`, `fail`), a `control_theme` scale. +- [ ] Calendar categories, dashboard widget, kanban builder, global search categories, predefined indicators. +- [ ] The SoA generator and its PDF template (RG-CTL-18). +- [ ] REST routes under `/api/v1/controls/`, MCP tools, assistant catalog entries. +- [ ] The daily sweep `controls_sweep` (due / overdue reminders, effectiveness recomputation) scheduled like the documents sweep. +- [ ] `scripts/seed_demo_data.py` : a Voltara control set created from the ISO 27001 Annex A framework, linked to risks, treatment plans, assets, objectives and policies; tests with validated runs (pass, partial, fail with a finding), one overdue test, one run in review. +- [ ] Translations (with `msgctxt "control"` where a label collides, e.g. "Planned", "Implemented", "Active"), help banners, user guide page, README feature table, CHANGELOG. + +### 9.2 History and audit trail + +Every model is history-tracked. A run's content is frozen from `in_review` (RG-CTL-09), so its history after submission records only transitions and review stamps : the result, the evidence and their SHA-256 are the record. Evidence rows are append-only after submission (RG-CTL-13). + +### 9.3 Security + +- The evidence download view checks `controls.test.read` and the control's scopes, and serves the file as an attachment with its stored content type forced to a safe list. +- Segregation of duties (RG-CTL-11) is enforced in the model, so no surface can validate one's own run. +- The SoA keeps filtering through `reportable()`, so a proposed or retired control never appears in an audit deliverable. + +--- + +## 10. Acceptance criteria + +- [ ] A control links to requirements of several frameworks, to risks, plans, PACS measures, assets, objectives and documents, and appears on each of their pages. +- [ ] "Create controls from requirements" creates one proposed control per uncovered applicable requirement and skips covered ones. +- [ ] A run's content cannot change once submitted, through the form, the API or MCP; its performer cannot validate it. +- [ ] Validating or withdrawing a run updates the test's next due date and the control's effectiveness in the same transaction. +- [ ] A validated failed run can raise exactly one finding, linked back to the run and pre-filled. +- [ ] The SoA lists, for each applicable requirement, its controls with their state and effectiveness. +- [ ] Scope tenancy holds on every list, detail, API and MCP surface, including tests, runs and evidence downloads. +- [ ] Every page renders in light and dark, at desktop and mobile widths, with the lifecycle stepper as the only way to change a state. +- [ ] Every string is translated; the generated reference (`generate_docs`) and the wiki check are clean. + +--- + +## 11. Out of scope for v0.37.0 + +- Computing a risk's residual likelihood / impact from the effectiveness of its controls (the assessor keeps setting the residual level; the controls are shown as its justification). +- Automated test runs pushed by a scanner, a CSPM or a CI job through a dedicated ingestion endpoint (the REST API can already create and submit runs). +- Control maturity scoring (CMMI or C2M2 levels) and a maturity target per control. +- Sampling helpers (population size, statistically driven sample sizes). +- A shared control catalogue imported from an external source (the "create from requirements" action covers bootstrapping from a framework already in Cairn). diff --git a/docs/specs/m8-controls/control-test-evidence.md b/docs/specs/m8-controls/control-test-evidence.md new file mode 100644 index 00000000..0ea37768 --- /dev/null +++ b/docs/specs/m8-controls/control-test-evidence.md @@ -0,0 +1,33 @@ +# ControlTestEvidence + +`controls.models.evidence.ControlTestEvidence` + +A file attached to a [test run](control-test-run.md) as evidence : an export, a screenshot, a signed review sheet. A write-once record once its run has left `draft` (RG-CTL-13), in the same family as `documents.PolicyAcknowledgement` : not a governed domain element, so it runs no lifecycle and carries no reference. + +File: `controls/models/evidence.py` + +Plain `models.Model` with a UUID PK and `django-simple-history` (`excluded_fields=["file"]`). Scopes are inherited through `run__test__control__scopes`. + +## Fields + +| Field | Type | Constraints | Description | +| --- | --- | --- | --- | +| `id` | UUID | PK, auto-generated | Unique identifier | +| `run` | relation | FK -> [ControlTestRun](control-test-run.md), `PROTECT` | Reverse accessor `evidence` | +| `file` | file | required, allowed extensions, max 25 MB | Stored under `control_evidence//` | +| `original_filename` | string | max 255 | As uploaded | +| `size` | integer | | Bytes | +| `sha256` | string | 64 hex chars, not editable | Computed at upload | +| `description` | string | optional, max 255 | What the file shows | +| `uploaded_by` | relation | FK -> User, `SET_NULL` | | +| `uploaded_at` | datetime | auto | | + +## Rules + +- Added or deleted only while the run is in `draft`; `save()` and `delete()` refuse otherwise. +- Served only through the download view (`controls.test.read` and the control's scopes), as an attachment. +- Never serialized : the API lists `file_name`, `size`, `sha256` and `download_url`. + +## References + +- [ControlTestRun](control-test-run.md), [README.md](README.md) : RG-CTL-13 diff --git a/docs/specs/m8-controls/control-test-run.md b/docs/specs/m8-controls/control-test-run.md new file mode 100644 index 00000000..9b2824b7 --- /dev/null +++ b/docs/specs/m8-controls/control-test-run.md @@ -0,0 +1,44 @@ +# ControlTestRun + +`controls.models.control_test_run.ControlTestRun` + +One execution of a [test](control-test.md) : who tested, when, over which period, on which sample, with what result and which [evidence](control-test-evidence.md). It is frozen once submitted (RG-CTL-09) and validated by someone other than its performer (RG-CTL-11). Validated runs drive the control's effectiveness (RG-CTL-14). + +File: `controls/models/control_test_run.py` + +`BaseModel` subclass : UUID PK, sequential `reference` (prefix **`CTRN`**), `version`, `created_by`, history. Scopes are inherited through `scope_parent_lookup = "test__control__scopes"`. `LIFECYCLE_NAME = "control_test_run"` (see [README §3.3](README.md#33-control_test_run)). `workflow_perm_namespace = "controls.test"`. + +## Fields + +| Field | Type | Constraints | Description | +| --- | --- | --- | --- | +| `id` | UUID | PK, auto-generated | Unique identifier | +| `reference` | string | auto-generated `CTRN-N`, unique | Business reference | +| `test` | relation | FK -> [ControlTest](control-test.md), `PROTECT` | Must be `active` when the run is created. Reverse accessor `runs` | +| `performed_by` | relation | FK -> User, `PROTECT` | Defaults to the test's tester, else the creator | +| `performed_on` | date | required, not in the future | Date of the test | +| `period_start`, `period_end` | date | optional | Period the test covers (RG-CTL-10) | +| `sample_description` | text | optional | Population and sample tested | +| `result` | enum | required to submit | `pass`, `partial`, `fail` | +| `observations` | text | required to submit when `partial` or `fail` | What was found | +| `reviewed_by` | relation | FK -> User, `SET_NULL`, optional, not editable | Stamped at validation (RG-CTL-11) | +| `reviewed_at` | datetime | optional, not editable | Stamped at validation | +| `workflow_state` | string | indexed, default `draft` | `control_test_run` lifecycle step | + +`FROZEN_FIELDS` : `test`, `performed_by`, `performed_on`, `period_start`, `period_end`, `sample_description`, `result`, `observations`; editable only in `draft` on every surface. + +The reverse accessor `findings` (from `compliance.Finding.control_test_run`) lists the finding raised from the run (RG-CTL-16). + +## Validation + +- RG-CTL-10 on submission; RG-CTL-11 on validation (the reviewer is not the performer). +- A run cannot be created on a test that is not `active`. + +## Deletion + +Only a `draft` run is deletable, with its evidence. Afterwards it is withdrawn (`archived`) with a comment and stays readable. + +## References + +- ISO/IEC 27001:2022 clause 9.1, 9.2 (evidence of monitoring results), 10.2 (nonconformity) +- [ControlTestEvidence](control-test-evidence.md), [Finding](../m3-compliance/finding.md), [README.md](README.md) : RG-CTL-09 to RG-CTL-16 diff --git a/docs/specs/m8-controls/control-test.md b/docs/specs/m8-controls/control-test.md new file mode 100644 index 00000000..aa9e75e1 --- /dev/null +++ b/docs/specs/m8-controls/control-test.md @@ -0,0 +1,50 @@ +# ControlTest + +`controls.models.control_test.ControlTest` + +How a [control](control.md) is tested : the method, the procedure, what a pass looks like, and how often. A control has one or more tests (e.g. "design review" once a year, "operating effectiveness on a sample" every quarter). Each execution is a [run](control-test-run.md). + +File: `controls/models/control_test.py` + +`BaseModel` subclass : UUID PK, sequential `reference` (prefix **`CTST`**), `tags`, `version`, `created_by`, history. Scopes are inherited through `scope_parent_lookup = "control__scopes"` (RG-CTL-19). `LIFECYCLE_NAME = "control_test"` (see [README §3.2](README.md#32-control_test)). `workflow_perm_namespace = "controls.test"`. + +## Fields + +| Field | Type | Constraints | Description | +| --- | --- | --- | --- | +| `id` | UUID | PK, auto-generated | Unique identifier | +| `reference` | string | auto-generated `CTST-N`, unique | Business reference | +| `control` | relation | FK -> [Control](control.md), `PROTECT` | Reverse accessor `tests` | +| `name` | string | required, max 255 | e.g. `Quarterly sample of remote logins` | +| `objective` | text | optional | What the test establishes (design, operating effectiveness) | +| `method` | enum | required, default `inspection` | `inquiry`, `observation`, `inspection`, `reperformance`, `automated` | +| `procedure` | text | required to activate | The steps the tester follows | +| `acceptance_criteria` | text | required to activate | What a pass looks like (RG-CTL-06) | +| `frequency_months` | integer | required, 1 to 36, default 12 | Interval between two runs | +| `tester` | relation | FK -> User, `SET_NULL`, optional | Default performer of runs, recipient of due reminders | +| `first_due_date` | date | optional | Due date of the first run | +| `next_due_date` | date | indexed, optional, not editable | Derived (RG-CTL-07) | +| `activated_on` | date | optional, not editable | Stamped at activation | +| `last_reminded_at` | datetime | optional, not editable | Set by the sweep | +| `workflow_state` | string | indexed, default `draft` | `control_test` lifecycle step | + +## Computed properties + +- `latest_validated_run` : the validated run with the latest `performed_on` (ties : latest `reviewed_at`). +- `latest_result` : its result, or none. +- `is_overdue` : RG-CTL-07. +- `recompute_next_due_date()` : RG-CTL-07, idempotent. + +## Validation + +- Activation is refused unless the control is `planned` or `implemented` and `procedure` and `acceptance_criteria` are set (RG-CTL-06). +- The control cannot change once the test has runs. + +## Deletion + +Only a `draft` test without runs is deletable; otherwise it is retired (RG-CTL-08). + +## References + +- ISO/IEC 27001:2022 clause 9.1 (monitoring, measurement, analysis and evaluation) +- [ControlTestRun](control-test-run.md), [README.md](README.md) : RG-CTL-06 to RG-CTL-08 diff --git a/docs/specs/m8-controls/control.md b/docs/specs/m8-controls/control.md new file mode 100644 index 00000000..168d7edb --- /dev/null +++ b/docs/specs/m8-controls/control.md @@ -0,0 +1,63 @@ +# Control + +`controls.models.control.Control` + +A security control the organisation operates (in French, *mesure de sécurité*) : a durable practice such as "multi-factor authentication on every remote access" or "quarterly access review of the ERP". It satisfies [requirements](../m3-compliance/requirement.md) of any framework, treats [risks](../m4-risks/risk.md), applies to [support assets](../m2-assets/support-asset.md), contributes to [objectives](../m1-context/objective.md) and is described by [documents](../m7-documents/policy.md). Its effectiveness is derived from its [tests](control-test.md) (RG-CTL-14). + +File: `controls/models/control.py` + +`ScopedModel` subclass : UUID PK, sequential `reference` (prefix **`CTRL`**, e.g. `CTRL-1`), `scopes` M2M, `tags`, `version`, `created_by`, `django-simple-history` audit trail. `LIFECYCLE_NAME = "control"` (see [README §3.1](README.md#31-control)); its exit step `archived` is labelled *Retired*. `workflow_perm_namespace = "controls.control"`. + +## Fields + +| Field | Type | Constraints | Description | +| --- | --- | --- | --- | +| `id` | UUID | PK, auto-generated | Unique identifier | +| `reference` | string | auto-generated `CTRL-N`, unique | Business reference | +| `name` | string | required, max 255 | e.g. `Multi-factor authentication on remote access` | +| `description` | text | required | What the control does and how it operates day to day | +| `theme` | enum | required, default `technological` | ISO/IEC 27002:2022 theme : `organizational`, `people`, `physical`, `technological` | +| `nature` | enum | required, default `preventive` | ISO/IEC 27002:2022 control type : `preventive`, `detective`, `corrective` | +| `automation` | enum | required, default `manual` | `manual`, `semi_automated`, `automated` | +| `owner` | relation | FK -> User, `SET_NULL`, optional | Accountable for the control operating | +| `implemented_on` | date | optional | Stamped (default today) when the control is declared implemented (RG-CTL-02); editable, never in the future | +| `effectiveness` | enum | not editable, default `not_tested` | Derived (RG-CTL-14) : `effective`, `partially_effective`, `not_effective`, `not_tested` | +| `effectiveness_updated_at` | datetime | optional, not editable | When `effectiveness` last changed | +| `workflow_state` | string | indexed, default `draft` | `control` lifecycle step | +| `tags`, `version`, `created_by`, `created_at`, `updated_at` | | | From `BaseModel` | + +### Relations + +All declared here, with the reverse accessor `controls` on the target (RG-CTL-04 for the pickers). + +| Name | Type | Target | Description | +| --- | --- | --- | --- | +| `requirements` | M2M | `compliance.Requirement` | Requirements this control satisfies, from any framework | +| `risks` | M2M | `risks.Risk` | Risks this control treats | +| `treatment_plans` | M2M | `risks.RiskTreatmentPlan` | Treatment plans that create or reinforce it | +| `pacs_measures` | M2M | `risks.PACSMeasure` | EBIOS RM workshop 5 measures that create or reinforce it | +| `support_assets` | M2M | `assets.SupportAsset` | Where the control applies | +| `objectives` | M2M | `context.Objective` | Security objectives it contributes to | +| `policies` | M2M | `documents.Policy` | Documents that describe it (procedure, standard) | + +## Computed properties + +- `active_tests` : tests in a reportable step. +- `next_test_due` : the earliest `next_due_date` among active tests. +- `is_test_overdue` : RG-CTL-15. +- `is_effectiveness_stale` : `is_test_overdue` and `effectiveness != not_tested`. +- `recompute_effectiveness()` : RG-CTL-14, idempotent, saves only on change. + +## Validation + +- `implemented_on` cannot be in the future. +- The relation pickers offer `linkable_or_linked()` targets. + +## Deletion + +Only a `draft` (*Proposed*) control without tests is deletable. Otherwise it is retired (RG-CTL-03). + +## References + +- ISO/IEC 27001:2022 clause 6.1.3 (risk treatment, SoA), 8.1, 9.1; ISO/IEC 27002:2022 attributes (control type, theme) +- [ControlTest](control-test.md), [README.md](README.md) : RG-CTL-01 to RG-CTL-05, RG-CTL-14 to RG-CTL-20 From 011ebfa0459b74e596128435c2a638904d308d9f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 11:17:33 +0100 Subject: [PATCH 2/9] feat(controls): the domain model of the controls register A new controls app (module 8) with its four models, three lifecycles, permissions and notifications, implementing the RG-CTL rules of docs/specs/m8-controls/: - Control (CTRL): ISO 27002 theme, nature and automation, owner, links to requirements, risks, treatment plans, PACS measures, support assets, objectives and documents (reverse accessor `controls` on each); proposed / planned / implemented / retired, with the implementation date stamped on declaration and the tests retired with the control. - ControlTest (CTST): method, procedure, acceptance criteria, frequency and tester; activation needs a planned or implemented control and a pass criterion; the next due date is derived from the latest validated run. - ControlTestRun (CTRN): result, period, sample and observations, frozen from submission; validated by someone who neither performed nor recorded it (superusers included); validation and withdrawal recompute the test's due date and the control's derived effectiveness in one transaction; a validated partial or failed run raises one finding on demand. - ControlTestEvidence: files with their SHA-256, added or removed only while the run is in progress. Finding gains a control_test_run link and a "Control test" source. The commented-out Measure placeholders are gone, and EBIOS RM workshop 5 measures become linkable while planned, in progress or overdue, so a control can name the measure that creates it. Ten permission codenames are attached to the system groups and three notification types added. Shared services create proposed controls from a framework's uncovered requirements and compute the per-requirement coverage behind the SoA. The web views are placeholders until the interface lands. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FaEkJnhS3VNDeb6jrdn6MC --- accounts/constants.py | 14 + ...60_alter_notification_notification_type.py | 18 + .../0061_add_controls_permissions.py | 82 +++ assets/models/support_asset.py | 3 +- compliance/constants.py | 1 + .../0044_finding_control_test_run_and_more.py | 35 ++ compliance/models/finding.py | 14 + compliance/models/requirement.py | 3 +- context/models/objective.py | 3 +- controls/__init__.py | 0 controls/admin.py | 46 ++ controls/api/__init__.py | 0 controls/api/urls.py | 5 + controls/apps.py | 18 + controls/constants.py | 147 +++++ controls/lifecycles.py | 29 + controls/management/__init__.py | 0 controls/management/commands/__init__.py | 0 controls/migrations/0001_initial.py | 270 ++++++++++ controls/migrations/__init__.py | 0 controls/models/__init__.py | 10 + controls/models/control.py | 186 +++++++ controls/models/control_test.py | 154 ++++++ controls/models/control_test_run.py | 208 ++++++++ controls/models/evidence.py | 90 ++++ controls/notifications.py | 56 ++ controls/services.py | 102 ++++ controls/templatetags/__init__.py | 0 controls/tests/__init__.py | 0 controls/tests/factories.py | 79 +++ controls/tests/test_domain.py | 505 ++++++++++++++++++ controls/urls.py | 32 ++ controls/views.py | 20 + core/settings.py | 1 + core/templatetags/ui.py | 8 + core/urls.py | 2 + docs/reference/generated/lifecycles.md | 67 ++- docs/reference/generated/models.md | 2 +- docs/reference/generated/permissions.md | 21 +- docs/sdk/ui-components.md | 3 +- docs/specs/m8-controls/README.md | 4 +- risks/lifecycles.py | 8 +- 42 files changed, 2225 insertions(+), 21 deletions(-) create mode 100644 accounts/migrations/0060_alter_notification_notification_type.py create mode 100644 accounts/migrations/0061_add_controls_permissions.py create mode 100644 compliance/migrations/0044_finding_control_test_run_and_more.py create mode 100644 controls/__init__.py create mode 100644 controls/admin.py create mode 100644 controls/api/__init__.py create mode 100644 controls/api/urls.py create mode 100644 controls/apps.py create mode 100644 controls/constants.py create mode 100644 controls/lifecycles.py create mode 100644 controls/management/__init__.py create mode 100644 controls/management/commands/__init__.py create mode 100644 controls/migrations/0001_initial.py create mode 100644 controls/migrations/__init__.py create mode 100644 controls/models/__init__.py create mode 100644 controls/models/control.py create mode 100644 controls/models/control_test.py create mode 100644 controls/models/control_test_run.py create mode 100644 controls/models/evidence.py create mode 100644 controls/notifications.py create mode 100644 controls/services.py create mode 100644 controls/templatetags/__init__.py create mode 100644 controls/tests/__init__.py create mode 100644 controls/tests/factories.py create mode 100644 controls/tests/test_domain.py create mode 100644 controls/urls.py create mode 100644 controls/views.py diff --git a/accounts/constants.py b/accounts/constants.py index 1997e24c..683ad171 100644 --- a/accounts/constants.py +++ b/accounts/constants.py @@ -58,6 +58,9 @@ class NotificationType(models.TextChoices): POLICY_VERSION_IN_FORCE = "policy_version_in_force", _("New document version in force") ACKNOWLEDGEMENT_REQUESTED = "acknowledgement_requested", _("Document to acknowledge") ACKNOWLEDGEMENT_REMINDER = "acknowledgement_reminder", _("Acknowledgement reminder") + CONTROL_TEST_DUE = "control_test_due", _("Control test due") + CONTROL_TEST_OVERDUE = "control_test_overdue", _("Control test overdue") + CONTROL_TEST_FAILED = "control_test_failed", _("Control test failed") # Lockout settings @@ -331,6 +334,16 @@ class NotificationType(models.TextChoices): "label": _("Acknowledgement campaigns"), }, }, + "controls": { + "control": { + "actions": ["create", "read", "update", "delete", "approve"], + "label": pgettext_lazy("control", "Controls"), + }, + "test": { + "actions": ["create", "read", "update", "delete", "approve"], + "label": _("Control tests"), + }, + }, "reports": { "report": { "actions": ["create", "read", "delete"], @@ -444,6 +457,7 @@ class NotificationType(models.TextChoices): "compliance": _("Compliance"), "incidents": _("Incidents"), "documents": _("Documents"), + "controls": pgettext_lazy("control", "Controls"), "reports": _("Reports"), "trust_center": _("Trust Center"), "system": _("System"), diff --git a/accounts/migrations/0060_alter_notification_notification_type.py b/accounts/migrations/0060_alter_notification_notification_type.py new file mode 100644 index 00000000..9365dd72 --- /dev/null +++ b/accounts/migrations/0060_alter_notification_notification_type.py @@ -0,0 +1,18 @@ +# Generated by Django 6.1.3 on 2026-11-04 10:14 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0059_alter_notification_notification_type'), + ] + + operations = [ + migrations.AlterField( + model_name='notification', + name='notification_type', + field=models.CharField(choices=[('lifecycle_submitted', 'Element pending validation'), ('trust_document_requested', 'Trust Center document request'), ('policy_review_due', 'Document review due'), ('policy_review_overdue', 'Document review overdue'), ('policy_version_in_force', 'New document version in force'), ('acknowledgement_requested', 'Document to acknowledge'), ('acknowledgement_reminder', 'Acknowledgement reminder'), ('control_test_due', 'Control test due'), ('control_test_overdue', 'Control test overdue'), ('control_test_failed', 'Control test failed')], db_index=True, max_length=50, verbose_name='Type'), + ), + ] diff --git a/accounts/migrations/0061_add_controls_permissions.py b/accounts/migrations/0061_add_controls_permissions.py new file mode 100644 index 00000000..bb23542f --- /dev/null +++ b/accounts/migrations/0061_add_controls_permissions.py @@ -0,0 +1,82 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""Data migration: add the two permission features of module 8 (controls). + +Mirrors the assignment the ``SYSTEM_GROUPS`` filter rules produce: Super Admin +and Admin get all 10, RSSI / DPO 8 (no delete), Contributeur 6, Auditeur and +Lecteur 2 each. +""" + +from django.db import migrations + +_ACTION_LABELS = {"create": "Create", "read": "Read", "update": "Update", "delete": "Delete", "approve": "Approve"} + +FEATURES = { + "control": ("Controls", ["create", "read", "update", "delete", "approve"]), + "test": ("Control tests", ["create", "read", "update", "delete", "approve"]), +} + +NEW_PERMISSIONS = [ + { + "codename": f"controls.{feature}.{action}", + "name": f"Controls - {label} - {_ACTION_LABELS[action]}", + "module": "controls", + "feature": feature, + "action": action, + } + for feature, (label, actions) in FEATURES.items() + for action in actions +] + +_ALL = [p["codename"] for p in NEW_PERMISSIONS] + + +def _ends(codenames, *suffixes): + return [c for c in codenames if c.rsplit(".", 1)[1] in suffixes] + + +GROUP_PERMISSION_MAP = { + "Super Administrateur": list(_ALL), + "Administrateur": list(_ALL), + "RSSI / DPO": _ends(_ALL, "read", "create", "update", "approve"), + "Auditeur": _ends(_ALL, "read"), + "Contributeur": _ends(_ALL, "read", "create", "update"), + "Lecteur": _ends(_ALL, "read"), +} + + +def populate(apps, schema_editor): + Permission = apps.get_model("accounts", "Permission") + Group = apps.get_model("accounts", "Group") + perm_objects = {} + for data in NEW_PERMISSIONS: + perm, _ = Permission.objects.get_or_create( + codename=data["codename"], + defaults={ + "name": data["name"], "module": data["module"], "feature": data["feature"], + "action": data["action"], "is_system": True, + }, + ) + perm_objects[data["codename"]] = perm + for group_name, codenames in GROUP_PERMISSION_MAP.items(): + try: + group = Group.objects.get(name=group_name, is_system=True) + except Group.DoesNotExist: + continue + group.permissions.add(*[perm_objects[c] for c in codenames if c in perm_objects]) + + +def reverse(apps, schema_editor): + Permission = apps.get_model("accounts", "Permission") + Permission.objects.filter(codename__in=_ALL).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0060_alter_notification_notification_type"), + ] + + operations = [ + migrations.RunPython(populate, reverse), + ] diff --git a/assets/models/support_asset.py b/assets/models/support_asset.py index 9dd77618..358e2209 100644 --- a/assets/models/support_asset.py +++ b/assets/models/support_asset.py @@ -115,8 +115,7 @@ class SupportAsset(LegacyStatusMixin, ScopedModel): related_name="children", verbose_name=_("Parent support asset"), ) - # M2M to Measure omitted - module not yet implemented - # related_measures = models.ManyToManyField("measures.Measure", ...) + # Controls : reverse accessor `controls` from controls.Control.support_assets. review_date = models.DateField(_("Next review date"), null=True, blank=True) history = HistoricalRecords() diff --git a/compliance/constants.py b/compliance/constants.py index f608a9c4..df6da073 100644 --- a/compliance/constants.py +++ b/compliance/constants.py @@ -257,6 +257,7 @@ class FindingSource(models.TextChoices): MANAGEMENT_REVIEW = "management_review", pgettext_lazy("finding source", "Management review") MONITORING = "monitoring", pgettext_lazy("finding source", "Monitoring") COMPLAINT = "complaint", pgettext_lazy("finding source", "Complaint") + CONTROL_TEST = "control_test", pgettext_lazy("finding source", "Control test") class EffectivenessVerdict(models.TextChoices): diff --git a/compliance/migrations/0044_finding_control_test_run_and_more.py b/compliance/migrations/0044_finding_control_test_run_and_more.py new file mode 100644 index 00000000..6f1529a9 --- /dev/null +++ b/compliance/migrations/0044_finding_control_test_run_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 6.1.3 on 2026-11-04 10:14 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('compliance', '0043_add_finding_incident_link'), + ('controls', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='finding', + name='control_test_run', + field=models.ForeignKey(blank=True, help_text='The control test run that raised this nonconformity.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='findings', to='controls.controltestrun', verbose_name='Control test run'), + ), + migrations.AddField( + model_name='historicalfinding', + name='control_test_run', + field=models.ForeignKey(blank=True, db_constraint=False, help_text='The control test run that raised this nonconformity.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='controls.controltestrun', verbose_name='Control test run'), + ), + migrations.AlterField( + model_name='finding', + name='source', + field=models.CharField(choices=[('audit', 'Audit'), ('incident', 'Security incident'), ('management_review', 'Management review'), ('monitoring', 'Monitoring'), ('complaint', 'Complaint'), ('control_test', 'Control test')], db_index=True, default='audit', help_text='What surfaced this nonconformity.', max_length=32, verbose_name='Source'), + ), + migrations.AlterField( + model_name='historicalfinding', + name='source', + field=models.CharField(choices=[('audit', 'Audit'), ('incident', 'Security incident'), ('management_review', 'Management review'), ('monitoring', 'Monitoring'), ('complaint', 'Complaint'), ('control_test', 'Control test')], db_index=True, default='audit', help_text='What surfaced this nonconformity.', max_length=32, verbose_name='Source'), + ), + ] diff --git a/compliance/models/finding.py b/compliance/models/finding.py index 167e6b9d..e3c68fb5 100644 --- a/compliance/models/finding.py +++ b/compliance/models/finding.py @@ -62,6 +62,15 @@ class Finding(BaseModel): verbose_name=_("Incident"), help_text=_("The security incident that raised this nonconformity."), ) + control_test_run = models.ForeignKey( + "controls.ControlTestRun", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="findings", + verbose_name=_("Control test run"), + help_text=_("The control test run that raised this nonconformity."), + ) finding_type = models.CharField( _("Finding type"), max_length=20, @@ -137,6 +146,11 @@ def incident_reference(self): """Incident that raised it, if any (for read-only API / assistant output).""" return self.incident.reference if self.incident_id else "" + @property + def control_test_run_reference(self): + """Control test run that raised it, if any (for read-only API / assistant output).""" + return self.control_test_run.reference if self.control_test_run_id else "" + @property def assessor_name(self): """Who raised it (for read-only API / assistant output).""" diff --git a/compliance/models/requirement.py b/compliance/models/requirement.py index 53438280..2856eac3 100644 --- a/compliance/models/requirement.py +++ b/compliance/models/requirement.py @@ -97,8 +97,7 @@ class Requirement(BaseModel): default="", ) target_date = models.DateField(_("Target date"), null=True, blank=True) - # M2M relations (some to modules not yet implemented - commented out) - # linked_measures = models.ManyToManyField("measures.Measure", blank=True) + # Controls : reverse accessor `controls` from controls.Control.requirements. linked_assets = models.ManyToManyField( "assets.EssentialAsset", blank=True, diff --git a/context/models/objective.py b/context/models/objective.py index 8abe48e1..cd874445 100644 --- a/context/models/objective.py +++ b/context/models/objective.py @@ -79,8 +79,7 @@ class Objective(ScopedModel): related_name="children", verbose_name=_("Parent objective"), ) - # M2M to Measure omitted - module not yet implemented - # linked_measures = models.ManyToManyField("measures.Measure", ...) + # Controls : reverse accessor `controls` from controls.Control.objectives. review_date = models.DateField(_("Next review date"), null=True, blank=True) originating_review = models.ForeignKey( "reports.ManagementReview", diff --git a/controls/__init__.py b/controls/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/controls/admin.py b/controls/admin.py new file mode 100644 index 00000000..8ff7f91e --- /dev/null +++ b/controls/admin.py @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +from django.contrib import admin +from simple_history.admin import SimpleHistoryAdmin + +from controls.models import Control, ControlTest, ControlTestEvidence, ControlTestRun + + +@admin.register(Control) +class ControlAdmin(SimpleHistoryAdmin): + list_display = ("reference", "name", "theme", "owner", "workflow_state", "effectiveness") + list_filter = ("theme", "nature", "workflow_state", "effectiveness") + search_fields = ("reference", "name") + readonly_fields = ("effectiveness", "effectiveness_updated_at") + + +@admin.register(ControlTest) +class ControlTestAdmin(SimpleHistoryAdmin): + list_display = ("reference", "name", "control", "method", "frequency_months", "next_due_date", "workflow_state") + list_filter = ("method", "workflow_state") + search_fields = ("reference", "name", "control__name") + readonly_fields = ("next_due_date", "activated_on", "last_reminded_at") + + +@admin.register(ControlTestRun) +class ControlTestRunAdmin(SimpleHistoryAdmin): + list_display = ("reference", "test", "performed_by", "performed_on", "result", "workflow_state") + list_filter = ("result", "workflow_state") + search_fields = ("reference", "test__name") + readonly_fields = ("reviewed_by", "reviewed_at") + + +@admin.register(ControlTestEvidence) +class ControlTestEvidenceAdmin(SimpleHistoryAdmin): + """Evidence rows : readable, never editable here (RG-CTL-13).""" + + list_display = ("original_filename", "run", "size", "sha256", "uploaded_at") + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + + def has_add_permission(self, request): + return False diff --git a/controls/api/__init__.py b/controls/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/controls/api/urls.py b/controls/api/urls.py new file mode 100644 index 00000000..fcab2c30 --- /dev/null +++ b/controls/api/urls.py @@ -0,0 +1,5 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""REST routes of module 8 under /api/v1/controls/ (README §5).""" + +urlpatterns = [] diff --git a/controls/apps.py b/controls/apps.py new file mode 100644 index 00000000..0bcf2dcb --- /dev/null +++ b/controls/apps.py @@ -0,0 +1,18 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import pgettext_lazy + + +class ControlsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "controls" + verbose_name = pgettext_lazy("control", "Controls") + + def ready(self): + # Register the module's three lifecycles before any model resolves its + # own. Omitting this import FAILS SILENTLY : `lifecycle_name_for` falls + # back to the default 4-state lifecycle, so every gate of the module + # would disappear with no error (tests assert each model's lifecycle). + from controls import lifecycles # noqa: F401 diff --git a/controls/constants.py b/controls/constants.py new file mode 100644 index 00000000..a09fa09c --- /dev/null +++ b/controls/constants.py @@ -0,0 +1,147 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""Constants of module 8 (controls) : choices, lifecycle steps and transitions. + +The step and transition tables are the single source of truth of the three +lifecycles registered in ``controls/lifecycles.py`` (RG-CTL-17). Each table +declares its ``draft`` and ``archived`` steps explicitly : when they are absent, +``lifecycle_from_state_flags`` appends generic bookends whose edges carry no +``permission_action``, which would open an unguarded archive / restore path. +Step tuples are ``(code, label, counts_in_reports, linkable, deletable, +is_initial, is_terminal, tone)``; transitions are ``(source, target, label, +requires_comment, permission_action)``. +""" + +from django.db import models +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import pgettext_lazy + +REFERENCE_PREFIXES = { + "Control": "CTRL", + "ControlTest": "CTST", + "ControlTestRun": "CTRN", +} + + +class ControlTheme(models.TextChoices): + """ISO/IEC 27002:2022 themes.""" + + ORGANIZATIONAL = "organizational", pgettext_lazy("control theme", "Organizational") + PEOPLE = "people", pgettext_lazy("control theme", "People") + PHYSICAL = "physical", pgettext_lazy("control theme", "Physical") + TECHNOLOGICAL = "technological", pgettext_lazy("control theme", "Technological") + + +class ControlNature(models.TextChoices): + """ISO/IEC 27002:2022 control type attribute.""" + + PREVENTIVE = "preventive", pgettext_lazy("control nature", "Preventive") + DETECTIVE = "detective", pgettext_lazy("control nature", "Detective") + CORRECTIVE = "corrective", pgettext_lazy("control nature", "Corrective") + + +class ControlAutomation(models.TextChoices): + MANUAL = "manual", pgettext_lazy("control automation", "Manual") + SEMI_AUTOMATED = "semi_automated", pgettext_lazy("control automation", "Semi-automated") + AUTOMATED = "automated", pgettext_lazy("control automation", "Automated") + + +class ControlEffectiveness(models.TextChoices): + """RG-CTL-14 : derived, never declared.""" + + EFFECTIVE = "effective", pgettext_lazy("finding", "Effective") + PARTIALLY_EFFECTIVE = "partially_effective", pgettext_lazy("finding", "Partially effective") + NOT_EFFECTIVE = "not_effective", pgettext_lazy("finding", "Not effective") + NOT_TESTED = "not_tested", pgettext_lazy("control effectiveness", "Not tested") + + +class ControlTestMethod(models.TextChoices): + INQUIRY = "inquiry", pgettext_lazy("test method", "Inquiry") + OBSERVATION = "observation", pgettext_lazy("test method", "Observation") + INSPECTION = "inspection", pgettext_lazy("test method", "Inspection") + REPERFORMANCE = "reperformance", pgettext_lazy("test method", "Reperformance") + AUTOMATED = "automated", pgettext_lazy("test method", "Automated") + + +class RunResult(models.TextChoices): + PASS = "pass", pgettext_lazy("test result", "Pass") + PARTIAL = "partial", pgettext_lazy("test result", "Partial") + FAIL = "fail", pgettext_lazy("test result", "Fail") + + +# ── Control ───────────────────────────────────────────────── + +CONTROL_DRAFT = "draft" +CONTROL_PLANNED = "planned" +CONTROL_IMPLEMENTED = "implemented" +CONTROL_RETIRED = "archived" + +CONTROL_STATES = [ + (CONTROL_DRAFT, pgettext_lazy("control", "Proposed"), False, False, True, True, False, "neutral"), + (CONTROL_PLANNED, pgettext_lazy("control", "Planned"), True, True, False, False, False, "info"), + (CONTROL_IMPLEMENTED, pgettext_lazy("control", "Implemented"), True, True, False, False, False, "success"), + (CONTROL_RETIRED, pgettext_lazy("control", "Retired"), False, False, False, False, True, "muted"), +] + +CONTROL_TRANSITIONS = [ + (CONTROL_DRAFT, CONTROL_PLANNED, pgettext_lazy("control", "Plan"), False, "approve"), + (CONTROL_DRAFT, CONTROL_IMPLEMENTED, pgettext_lazy("control", "Declare implemented"), False, "approve"), + (CONTROL_PLANNED, CONTROL_IMPLEMENTED, pgettext_lazy("control", "Declare implemented"), False, "approve"), + (CONTROL_IMPLEMENTED, CONTROL_PLANNED, pgettext_lazy("control", "Back to planned"), True, "update"), + (CONTROL_PLANNED, CONTROL_RETIRED, pgettext_lazy("control", "Retire"), True, "approve"), + (CONTROL_IMPLEMENTED, CONTROL_RETIRED, pgettext_lazy("control", "Retire"), True, "approve"), + (CONTROL_DRAFT, CONTROL_RETIRED, pgettext_lazy("control", "Retire"), True, "update"), +] + +# ── ControlTest ───────────────────────────────────────────── + +TEST_DRAFT = "draft" +TEST_ACTIVE = "active" +TEST_RETIRED = "archived" + +TEST_STATES = [ + (TEST_DRAFT, _("Draft"), False, False, True, True, False, "neutral"), + (TEST_ACTIVE, pgettext_lazy("control", "Active"), True, True, False, False, False, "success"), + (TEST_RETIRED, pgettext_lazy("control", "Retired"), False, False, False, False, True, "muted"), +] + +TEST_TRANSITIONS = [ + (TEST_DRAFT, TEST_ACTIVE, _("Activate"), False, "approve"), + (TEST_ACTIVE, TEST_RETIRED, pgettext_lazy("control", "Retire"), True, "approve"), + (TEST_DRAFT, TEST_RETIRED, pgettext_lazy("control", "Retire"), True, "update"), +] + +# ── ControlTestRun ────────────────────────────────────────── + +RUN_DRAFT = "draft" +RUN_IN_REVIEW = "in_review" +RUN_VALIDATED = "validated" +RUN_WITHDRAWN = "archived" + +RUN_STATES = [ + (RUN_DRAFT, pgettext_lazy("control test run", "In progress"), False, False, True, True, False, "neutral"), + (RUN_IN_REVIEW, _("In review"), False, False, False, False, False, "info"), + (RUN_VALIDATED, pgettext_lazy("control test run", "Validated"), True, False, False, False, False, "success"), + (RUN_WITHDRAWN, pgettext_lazy("control test run", "Withdrawn"), False, False, False, False, True, "muted"), +] + +RUN_TRANSITIONS = [ + (RUN_DRAFT, RUN_IN_REVIEW, _("Submit for review"), False, "update"), + (RUN_IN_REVIEW, RUN_DRAFT, pgettext_lazy("control test run", "Send back"), True, "approve"), + (RUN_IN_REVIEW, RUN_VALIDATED, pgettext_lazy("control test run", "Validate"), False, "approve"), + (RUN_VALIDATED, RUN_WITHDRAWN, pgettext_lazy("control test run", "Withdraw"), True, "approve"), + (RUN_DRAFT, RUN_WITHDRAWN, pgettext_lazy("control test run", "Cancel"), True, "update"), +] + +# Tests and reminders +DEFAULT_TEST_FREQUENCY_MONTHS = 12 +MAX_TEST_FREQUENCY_MONTHS = 36 +TEST_DUE_REMINDER_DAYS = 14 +OVERDUE_REMINDER_INTERVAL_DAYS = 7 + +# Evidence (RG-CTL-13) +EVIDENCE_MAX_BYTES = 25 * 1024 * 1024 +ALLOWED_EVIDENCE_EXTENSIONS = [ + "pdf", "doc", "docx", "xls", "xlsx", "csv", "txt", "log", "json", "xml", + "eml", "msg", "png", "jpg", "jpeg", "zip", "gz", "7z", "odt", "ods", +] diff --git a/controls/lifecycles.py b/controls/lifecycles.py new file mode 100644 index 00000000..cba18fbb --- /dev/null +++ b/controls/lifecycles.py @@ -0,0 +1,29 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""The three lifecycles of module 8 (controls), generated from the tables in +``controls/constants.py`` (single source of truth). Imported from +``ControlsConfig.ready()``; omitting the import fails silently, so the tests +assert each model resolves the lifecycle it declares.""" + +from controls.constants import ( + CONTROL_STATES, + CONTROL_TRANSITIONS, + RUN_STATES, + RUN_TRANSITIONS, + TEST_STATES, + TEST_TRANSITIONS, +) +from core.lifecycle import lifecycle_from_state_flags, register_lifecycle + +CONTROL_LIFECYCLE_NAME = "control" +TEST_LIFECYCLE_NAME = "control_test" +RUN_LIFECYCLE_NAME = "control_test_run" + + +def _register(name, states, transitions): + return register_lifecycle(lifecycle_from_state_flags(name, states, transitions, layout="graph")) + + +CONTROL_LIFECYCLE = _register(CONTROL_LIFECYCLE_NAME, CONTROL_STATES, CONTROL_TRANSITIONS) +TEST_LIFECYCLE = _register(TEST_LIFECYCLE_NAME, TEST_STATES, TEST_TRANSITIONS) +RUN_LIFECYCLE = _register(RUN_LIFECYCLE_NAME, RUN_STATES, RUN_TRANSITIONS) diff --git a/controls/management/__init__.py b/controls/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/controls/management/commands/__init__.py b/controls/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/controls/migrations/0001_initial.py b/controls/migrations/0001_initial.py new file mode 100644 index 00000000..99e6ec19 --- /dev/null +++ b/controls/migrations/0001_initial.py @@ -0,0 +1,270 @@ +# Generated by Django 6.1.3 on 2026-11-04 10:14 + +import controls.models.evidence +import django.core.validators +import django.db.models.deletion +import django.utils.timezone +import simple_history.models +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('assets', '0049_historicalsupplier_parent_company_and_more'), + ('compliance', '0043_add_finding_incident_link'), + ('context', '0036_indicator_document_sources'), + ('documents', '0001_initial'), + ('risks', '0033_remove_attackpathstep_approved_at_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Control', + fields=[ + ('reference', models.CharField(blank=True, max_length=50, unique=True, verbose_name='Reference')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('version', models.PositiveIntegerField(default=1, verbose_name='Version')), + ('workflow_state', models.CharField(db_index=True, default='draft', help_text='Current state of the element in its lifecycle.', max_length=32, verbose_name='Lifecycle state')), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('description', models.TextField(verbose_name='Description')), + ('theme', models.CharField(choices=[('organizational', 'Organizational'), ('people', 'People'), ('physical', 'Physical'), ('technological', 'Technological')], default='technological', max_length=20, verbose_name='Theme')), + ('nature', models.CharField(choices=[('preventive', 'Preventive'), ('detective', 'Detective'), ('corrective', 'Corrective')], default='preventive', max_length=20, verbose_name='Nature')), + ('automation', models.CharField(choices=[('manual', 'Manual'), ('semi_automated', 'Semi-automated'), ('automated', 'Automated')], default='manual', max_length=20, verbose_name='Automation')), + ('implemented_on', models.DateField(blank=True, null=True, verbose_name='Implemented on')), + ('effectiveness', models.CharField(choices=[('effective', 'Effective'), ('partially_effective', 'Partially effective'), ('not_effective', 'Not effective'), ('not_tested', 'Not tested')], db_index=True, default='not_tested', editable=False, max_length=20, verbose_name='Effectiveness')), + ('effectiveness_updated_at', models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Effectiveness updated at')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by')), + ('objectives', models.ManyToManyField(blank=True, related_name='controls', to='context.objective', verbose_name='Objectives')), + ('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='owned_controls', to=settings.AUTH_USER_MODEL, verbose_name='Owner')), + ('pacs_measures', models.ManyToManyField(blank=True, related_name='controls', to='risks.pacsmeasure', verbose_name='PACS measures')), + ('policies', models.ManyToManyField(blank=True, related_name='controls', to='documents.policy', verbose_name='Documents')), + ('requirements', models.ManyToManyField(blank=True, related_name='controls', to='compliance.requirement', verbose_name='Requirements')), + ('risks', models.ManyToManyField(blank=True, related_name='controls', to='risks.risk', verbose_name='Risks')), + ('scopes', models.ManyToManyField(blank=True, related_name='%(class)s_set', to='context.scope', verbose_name='Scopes')), + ('support_assets', models.ManyToManyField(blank=True, related_name='controls', to='assets.supportasset', verbose_name='Support assets')), + ('tags', models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_set', to='context.tag', verbose_name='Tags')), + ('treatment_plans', models.ManyToManyField(blank=True, related_name='controls', to='risks.risktreatmentplan', verbose_name='Treatment plans')), + ], + options={ + 'verbose_name': 'Control', + 'verbose_name_plural': 'Controls', + 'ordering': ['reference'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ControlTest', + fields=[ + ('reference', models.CharField(blank=True, max_length=50, unique=True, verbose_name='Reference')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('version', models.PositiveIntegerField(default=1, verbose_name='Version')), + ('workflow_state', models.CharField(db_index=True, default='draft', help_text='Current state of the element in its lifecycle.', max_length=32, verbose_name='Lifecycle state')), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('objective', models.TextField(blank=True, default='', verbose_name='Objective')), + ('method', models.CharField(choices=[('inquiry', 'Inquiry'), ('observation', 'Observation'), ('inspection', 'Inspection'), ('reperformance', 'Reperformance'), ('automated', 'Automated')], default='inspection', max_length=20, verbose_name='Method')), + ('procedure', models.TextField(blank=True, default='', verbose_name='Procedure')), + ('acceptance_criteria', models.TextField(blank=True, default='', verbose_name='Acceptance criteria')), + ('frequency_months', models.PositiveSmallIntegerField(default=12, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(36)], verbose_name='Frequency (months)')), + ('first_due_date', models.DateField(blank=True, null=True, verbose_name='First due date')), + ('next_due_date', models.DateField(blank=True, db_index=True, editable=False, null=True, verbose_name='Next due')), + ('activated_on', models.DateField(blank=True, editable=False, null=True, verbose_name='Activated on')), + ('last_reminded_at', models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Last reminded at')), + ('control', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='tests', to='controls.control', verbose_name='Control')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by')), + ('tags', models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_set', to='context.tag', verbose_name='Tags')), + ('tester', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='control_tests', to=settings.AUTH_USER_MODEL, verbose_name='Tester')), + ], + options={ + 'verbose_name': 'Control test', + 'verbose_name_plural': 'Control tests', + 'ordering': ['control', 'name'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ControlTestRun', + fields=[ + ('reference', models.CharField(blank=True, max_length=50, unique=True, verbose_name='Reference')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('version', models.PositiveIntegerField(default=1, verbose_name='Version')), + ('workflow_state', models.CharField(db_index=True, default='draft', help_text='Current state of the element in its lifecycle.', max_length=32, verbose_name='Lifecycle state')), + ('performed_on', models.DateField(default=django.utils.timezone.localdate, verbose_name='Performed on')), + ('period_start', models.DateField(blank=True, null=True, verbose_name='Period start')), + ('period_end', models.DateField(blank=True, null=True, verbose_name='Period end')), + ('sample_description', models.TextField(blank=True, default='', verbose_name='Sample')), + ('result', models.CharField(blank=True, choices=[('pass', 'Pass'), ('partial', 'Partial'), ('fail', 'Fail')], default='', max_length=10, verbose_name='Result')), + ('observations', models.TextField(blank=True, default='', verbose_name='Observations')), + ('reviewed_at', models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Reviewed at')), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created', to=settings.AUTH_USER_MODEL, verbose_name='Created by')), + ('performed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='control_test_runs', to=settings.AUTH_USER_MODEL, verbose_name='Performed by')), + ('reviewed_by', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='reviewed_control_test_runs', to=settings.AUTH_USER_MODEL, verbose_name='Reviewed by')), + ('tags', models.ManyToManyField(blank=True, related_name='%(app_label)s_%(class)s_set', to='context.tag', verbose_name='Tags')), + ('test', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='runs', to='controls.controltest', verbose_name='Test')), + ], + options={ + 'verbose_name': 'Test run', + 'verbose_name_plural': 'Test runs', + 'ordering': ['-performed_on', '-created_at'], + 'abstract': False, + }, + ), + migrations.CreateModel( + name='ControlTestEvidence', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('file', models.FileField(max_length=400, upload_to=controls.models.evidence._evidence_upload_path, validators=[django.core.validators.FileExtensionValidator(allowed_extensions=['pdf', 'doc', 'docx', 'xls', 'xlsx', 'csv', 'txt', 'log', 'json', 'xml', 'eml', 'msg', 'png', 'jpg', 'jpeg', 'zip', 'gz', '7z', 'odt', 'ods']), controls.models.evidence.validate_evidence_size], verbose_name='File')), + ('original_filename', models.CharField(blank=True, default='', max_length=255, verbose_name='File name')), + ('size', models.PositiveBigIntegerField(default=0, editable=False, verbose_name='Size (bytes)')), + ('sha256', models.CharField(blank=True, default='', editable=False, max_length=64, verbose_name='SHA-256')), + ('description', models.CharField(blank=True, default='', max_length=255, verbose_name='Description')), + ('uploaded_at', models.DateTimeField(auto_now_add=True, verbose_name='Uploaded at')), + ('uploaded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='uploaded_control_evidence', to=settings.AUTH_USER_MODEL, verbose_name='Uploaded by')), + ('run', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='evidence', to='controls.controltestrun', verbose_name='Test run')), + ], + options={ + 'verbose_name': 'Test evidence', + 'verbose_name_plural': 'Test evidence', + 'ordering': ['run', 'uploaded_at'], + }, + ), + migrations.CreateModel( + name='HistoricalControl', + fields=[ + ('reference', models.CharField(blank=True, db_index=True, max_length=50, verbose_name='Reference')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Created at')), + ('updated_at', models.DateTimeField(blank=True, editable=False, verbose_name='Updated at')), + ('version', models.PositiveIntegerField(default=1, verbose_name='Version')), + ('workflow_state', models.CharField(db_index=True, default='draft', help_text='Current state of the element in its lifecycle.', max_length=32, verbose_name='Lifecycle state')), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('description', models.TextField(verbose_name='Description')), + ('theme', models.CharField(choices=[('organizational', 'Organizational'), ('people', 'People'), ('physical', 'Physical'), ('technological', 'Technological')], default='technological', max_length=20, verbose_name='Theme')), + ('nature', models.CharField(choices=[('preventive', 'Preventive'), ('detective', 'Detective'), ('corrective', 'Corrective')], default='preventive', max_length=20, verbose_name='Nature')), + ('automation', models.CharField(choices=[('manual', 'Manual'), ('semi_automated', 'Semi-automated'), ('automated', 'Automated')], default='manual', max_length=20, verbose_name='Automation')), + ('implemented_on', models.DateField(blank=True, null=True, verbose_name='Implemented on')), + ('effectiveness', models.CharField(choices=[('effective', 'Effective'), ('partially_effective', 'Partially effective'), ('not_effective', 'Not effective'), ('not_tested', 'Not tested')], db_index=True, default='not_tested', editable=False, max_length=20, verbose_name='Effectiveness')), + ('effectiveness_updated_at', models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Effectiveness updated at')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('created_by', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Created by')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('owner', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Owner')), + ], + options={ + 'verbose_name': 'historical Control', + 'verbose_name_plural': 'historical Controls', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalControlTest', + fields=[ + ('reference', models.CharField(blank=True, db_index=True, max_length=50, verbose_name='Reference')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Created at')), + ('updated_at', models.DateTimeField(blank=True, editable=False, verbose_name='Updated at')), + ('version', models.PositiveIntegerField(default=1, verbose_name='Version')), + ('workflow_state', models.CharField(db_index=True, default='draft', help_text='Current state of the element in its lifecycle.', max_length=32, verbose_name='Lifecycle state')), + ('name', models.CharField(max_length=255, verbose_name='Name')), + ('objective', models.TextField(blank=True, default='', verbose_name='Objective')), + ('method', models.CharField(choices=[('inquiry', 'Inquiry'), ('observation', 'Observation'), ('inspection', 'Inspection'), ('reperformance', 'Reperformance'), ('automated', 'Automated')], default='inspection', max_length=20, verbose_name='Method')), + ('procedure', models.TextField(blank=True, default='', verbose_name='Procedure')), + ('acceptance_criteria', models.TextField(blank=True, default='', verbose_name='Acceptance criteria')), + ('frequency_months', models.PositiveSmallIntegerField(default=12, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(36)], verbose_name='Frequency (months)')), + ('first_due_date', models.DateField(blank=True, null=True, verbose_name='First due date')), + ('next_due_date', models.DateField(blank=True, db_index=True, editable=False, null=True, verbose_name='Next due')), + ('activated_on', models.DateField(blank=True, editable=False, null=True, verbose_name='Activated on')), + ('last_reminded_at', models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Last reminded at')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('control', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='controls.control', verbose_name='Control')), + ('created_by', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Created by')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('tester', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Tester')), + ], + options={ + 'verbose_name': 'historical Control test', + 'verbose_name_plural': 'historical Control tests', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalControlTestEvidence', + fields=[ + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ('original_filename', models.CharField(blank=True, default='', max_length=255, verbose_name='File name')), + ('size', models.PositiveBigIntegerField(default=0, editable=False, verbose_name='Size (bytes)')), + ('sha256', models.CharField(blank=True, default='', editable=False, max_length=64, verbose_name='SHA-256')), + ('description', models.CharField(blank=True, default='', max_length=255, verbose_name='Description')), + ('uploaded_at', models.DateTimeField(blank=True, editable=False, verbose_name='Uploaded at')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('run', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='controls.controltestrun', verbose_name='Test run')), + ('uploaded_by', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Uploaded by')), + ], + options={ + 'verbose_name': 'historical Test evidence', + 'verbose_name_plural': 'historical Test evidence', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='HistoricalControlTestRun', + fields=[ + ('reference', models.CharField(blank=True, db_index=True, max_length=50, verbose_name='Reference')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ('created_at', models.DateTimeField(blank=True, editable=False, verbose_name='Created at')), + ('updated_at', models.DateTimeField(blank=True, editable=False, verbose_name='Updated at')), + ('version', models.PositiveIntegerField(default=1, verbose_name='Version')), + ('workflow_state', models.CharField(db_index=True, default='draft', help_text='Current state of the element in its lifecycle.', max_length=32, verbose_name='Lifecycle state')), + ('performed_on', models.DateField(default=django.utils.timezone.localdate, verbose_name='Performed on')), + ('period_start', models.DateField(blank=True, null=True, verbose_name='Period start')), + ('period_end', models.DateField(blank=True, null=True, verbose_name='Period end')), + ('sample_description', models.TextField(blank=True, default='', verbose_name='Sample')), + ('result', models.CharField(blank=True, choices=[('pass', 'Pass'), ('partial', 'Partial'), ('fail', 'Fail')], default='', max_length=10, verbose_name='Result')), + ('observations', models.TextField(blank=True, default='', verbose_name='Observations')), + ('reviewed_at', models.DateTimeField(blank=True, editable=False, null=True, verbose_name='Reviewed at')), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField(db_index=True)), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('created_by', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Created by')), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('performed_by', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Performed by')), + ('reviewed_by', models.ForeignKey(blank=True, db_constraint=False, editable=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='Reviewed by')), + ('test', models.ForeignKey(blank=True, db_constraint=False, null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='controls.controltest', verbose_name='Test')), + ], + options={ + 'verbose_name': 'historical Test run', + 'verbose_name_plural': 'historical Test runs', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + ] diff --git a/controls/migrations/__init__.py b/controls/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/controls/models/__init__.py b/controls/models/__init__.py new file mode 100644 index 00000000..1ae2c9d4 --- /dev/null +++ b/controls/models/__init__.py @@ -0,0 +1,10 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""Model package for module 8 (controls). Every model must be imported here.""" + +from .control import Control +from .control_test import ControlTest +from .control_test_run import ControlTestRun +from .evidence import ControlTestEvidence + +__all__ = ["Control", "ControlTest", "ControlTestEvidence", "ControlTestRun"] diff --git a/controls/models/control.py b/controls/models/control.py new file mode 100644 index 00000000..0ea9abf6 --- /dev/null +++ b/controls/models/control.py @@ -0,0 +1,186 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""A security control the organisation operates (ISO/IEC 27001:2022 6.1.3, 8.1, 9.1). + +A durable practice, not a task (RG-CTL-01). Its effectiveness is derived from +the validated runs of its active tests (RG-CTL-14), never declared. +See docs/specs/m8-controls/control.md. +""" + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models, transaction +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import pgettext_lazy +from simple_history.models import HistoricalRecords + +from context.models.base import ScopedModel +from controls.constants import ( + CONTROL_DRAFT, + CONTROL_IMPLEMENTED, + CONTROL_RETIRED, + REFERENCE_PREFIXES, + TEST_ACTIVE, + TEST_RETIRED, + ControlAutomation, + ControlEffectiveness, + ControlNature, + ControlTheme, + RunResult, +) + + +def effectiveness_from_results(results): + """RG-CTL-14 : the verdict for the latest result of each active test. + + ``results`` holds one entry per active test : its latest validated + result, or ``None`` when the test has no validated run yet. + """ + if not results: + return ControlEffectiveness.NOT_TESTED + if RunResult.FAIL in results: + return ControlEffectiveness.NOT_EFFECTIVE + if RunResult.PARTIAL in results: + return ControlEffectiveness.PARTIALLY_EFFECTIVE + if all(result == RunResult.PASS for result in results): + return ControlEffectiveness.EFFECTIVE + return ControlEffectiveness.NOT_TESTED + + +class Control(ScopedModel): + """A control of the register.""" + + REFERENCE_PREFIX = REFERENCE_PREFIXES["Control"] + LIFECYCLE_NAME = "control" + + name = models.CharField(_("Name"), max_length=255) + description = models.TextField(_("Description")) + theme = models.CharField( + _("Theme"), max_length=20, choices=ControlTheme.choices, default=ControlTheme.TECHNOLOGICAL, + ) + nature = models.CharField( + _("Nature"), max_length=20, choices=ControlNature.choices, default=ControlNature.PREVENTIVE, + ) + automation = models.CharField( + _("Automation"), max_length=20, choices=ControlAutomation.choices, default=ControlAutomation.MANUAL, + ) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, + related_name="owned_controls", verbose_name=_("Owner"), + ) + implemented_on = models.DateField(_("Implemented on"), null=True, blank=True) + # Derived (RG-CTL-14), stored for filters, the SoA and the KPIs. + effectiveness = models.CharField( + _("Effectiveness"), max_length=20, choices=ControlEffectiveness.choices, + default=ControlEffectiveness.NOT_TESTED, db_index=True, editable=False, + ) + effectiveness_updated_at = models.DateTimeField( + _("Effectiveness updated at"), null=True, blank=True, editable=False, + ) + + requirements = models.ManyToManyField( + "compliance.Requirement", blank=True, related_name="controls", verbose_name=_("Requirements"), + ) + risks = models.ManyToManyField( + "risks.Risk", blank=True, related_name="controls", verbose_name=_("Risks"), + ) + treatment_plans = models.ManyToManyField( + "risks.RiskTreatmentPlan", blank=True, related_name="controls", verbose_name=_("Treatment plans"), + ) + pacs_measures = models.ManyToManyField( + "risks.PACSMeasure", blank=True, related_name="controls", verbose_name=_("PACS measures"), + ) + support_assets = models.ManyToManyField( + "assets.SupportAsset", blank=True, related_name="controls", verbose_name=_("Support assets"), + ) + objectives = models.ManyToManyField( + "context.Objective", blank=True, related_name="controls", verbose_name=_("Objectives"), + ) + policies = models.ManyToManyField( + "documents.Policy", blank=True, related_name="controls", verbose_name=_("Documents"), + ) + history = HistoricalRecords() + + class Meta(ScopedModel.Meta): + ordering = ["reference"] + verbose_name = pgettext_lazy("control", "Control") + verbose_name_plural = pgettext_lazy("control", "Controls") + + def __str__(self): + return f"{self.reference} : {self.name}" + + @property + def workflow_perm_namespace(self): + return "controls.control" + + # --- Derived state (RG-CTL-14, RG-CTL-15) ----------------------------- + + @property + def active_tests(self): + return self.tests.filter(workflow_state=TEST_ACTIVE) + + @property + def next_test_due(self): + return self.active_tests.exclude(next_due_date=None).aggregate( + due=models.Min("next_due_date") + )["due"] + + @property + def is_test_overdue(self): + due = self.next_test_due + return due is not None and due < timezone.localdate() + + @property + def is_effectiveness_stale(self): + return self.is_test_overdue and self.effectiveness != ControlEffectiveness.NOT_TESTED + + @property + def is_retired(self): + return self.workflow_state == CONTROL_RETIRED + + def compute_effectiveness(self): + results = [test.latest_result for test in self.active_tests] + return effectiveness_from_results(results) + + def recompute_effectiveness(self): + """Idempotent; writes (with a history entry) only on change.""" + value = self.compute_effectiveness() + if value == self.effectiveness: + return False + self.effectiveness = value + self.effectiveness_updated_at = timezone.now() + self.save(update_fields=["effectiveness", "effectiveness_updated_at", "updated_at"]) + return True + + # --- Integrity -------------------------------------------------------- + + def clean(self): + super().clean() + if self.implemented_on and self.implemented_on > timezone.localdate(): + raise ValidationError({"implemented_on": _("The implementation date cannot be in the future.")}) + + # --- Lifecycle side effects (RG-CTL-02, RG-CTL-03) -------------------- + + def transition_to(self, target, user=None, comment=None, *, enforce_permission=False, save=True): + from core.lifecycle import DomainRefusalError + + with transaction.atomic(): + type(self).objects.select_for_update().filter(pk=self.pk).first() + if target == CONTROL_IMPLEMENTED: + if self.implemented_on and self.implemented_on > timezone.localdate(): + raise DomainRefusalError(_("The implementation date cannot be in the future.")) + if self.implemented_on is None: + self.implemented_on = timezone.localdate() + result = super().transition_to(target, user, comment=comment, enforce_permission=enforce_permission, save=save) + if target == CONTROL_IMPLEMENTED and save: + type(self).objects.filter(pk=self.pk).update(implemented_on=self.implemented_on) + if target == CONTROL_RETIRED and save: + # RG-CTL-03 : the active tests retire with the control. + for test in self.tests.exclude(workflow_state=TEST_RETIRED): + test.transition_to( + TEST_RETIRED, user, + comment=str(_("Control %(ref)s retired")) % {"ref": self.reference}, + enforce_permission=False, + ) + return result diff --git a/controls/models/control_test.py b/controls/models/control_test.py new file mode 100644 index 00000000..ff728cc9 --- /dev/null +++ b/controls/models/control_test.py @@ -0,0 +1,154 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""How a control is tested (ISO/IEC 27001:2022 clause 9.1). + +The next due date is derived (RG-CTL-07). See docs/specs/m8-controls/control-test.md. +""" + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models, transaction +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from django.utils.translation import pgettext_lazy +from simple_history.models import HistoricalRecords + +from context.models.base import BaseModel +from controls.constants import ( + CONTROL_IMPLEMENTED, + CONTROL_PLANNED, + DEFAULT_TEST_FREQUENCY_MONTHS, + MAX_TEST_FREQUENCY_MONTHS, + REFERENCE_PREFIXES, + RUN_VALIDATED, + TEST_ACTIVE, + TEST_RETIRED, + ControlTestMethod, +) +from documents.models.policy import add_months + + +class ControlTest(BaseModel): + """A test of a :class:`~controls.models.control.Control`.""" + + REFERENCE_PREFIX = REFERENCE_PREFIXES["ControlTest"] + LIFECYCLE_NAME = "control_test" + scope_parent_lookup = "control__scopes" + + control = models.ForeignKey( + "controls.Control", on_delete=models.PROTECT, related_name="tests", verbose_name=pgettext_lazy("control", "Control"), + ) + name = models.CharField(_("Name"), max_length=255) + objective = models.TextField(_("Objective"), blank=True, default="") + method = models.CharField( + _("Method"), max_length=20, choices=ControlTestMethod.choices, default=ControlTestMethod.INSPECTION, + ) + procedure = models.TextField(_("Procedure"), blank=True, default="") + acceptance_criteria = models.TextField(_("Acceptance criteria"), blank=True, default="") + frequency_months = models.PositiveSmallIntegerField( + _("Frequency (months)"), default=DEFAULT_TEST_FREQUENCY_MONTHS, + validators=[MinValueValidator(1), MaxValueValidator(MAX_TEST_FREQUENCY_MONTHS)], + ) + tester = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, + related_name="control_tests", verbose_name=_("Tester"), + ) + first_due_date = models.DateField(_("First due date"), null=True, blank=True) + # Derived (RG-CTL-07), stored for the calendar, the dashboard and the sweep. + next_due_date = models.DateField(_("Next due"), null=True, blank=True, db_index=True, editable=False) + activated_on = models.DateField(_("Activated on"), null=True, blank=True, editable=False) + last_reminded_at = models.DateTimeField(_("Last reminded at"), null=True, blank=True, editable=False) + history = HistoricalRecords() + + class Meta(BaseModel.Meta): + ordering = ["control", "name"] + verbose_name = _("Control test") + verbose_name_plural = _("Control tests") + + def __str__(self): + return f"{self.reference} : {self.name}" + + @property + def workflow_perm_namespace(self): + return "controls.test" + + # --- Derived state ---------------------------------------------------- + + @property + def is_active(self): + return self.workflow_state == TEST_ACTIVE + + @property + def latest_validated_run(self): + return ( + self.runs.filter(workflow_state=RUN_VALIDATED) + .order_by("-performed_on", "-reviewed_at", "-created_at") + .first() + ) + + @property + def latest_result(self): + run = self.latest_validated_run + return run.result if run else None + + @property + def is_overdue(self): + return self.is_active and self.next_due_date is not None and self.next_due_date < timezone.localdate() + + def compute_next_due_date(self): + """RG-CTL-07.""" + if not self.is_active: + return None + run = self.latest_validated_run + if run is not None: + return add_months(run.performed_on, self.frequency_months) + return self.first_due_date or self.activated_on or timezone.localdate() + + def recompute_next_due_date(self): + value = self.compute_next_due_date() + if value == self.next_due_date: + return False + self.next_due_date = value + type(self).objects.filter(pk=self.pk).update(next_due_date=value) + return True + + # --- Integrity -------------------------------------------------------- + + def clean(self): + super().clean() + if self.pk and self.control_id: + stored = type(self).objects.filter(pk=self.pk).values_list("control_id", flat=True).first() + if stored and stored != self.control_id and self.runs.exists(): + raise ValidationError({"control": _("A test that has runs cannot move to another control.")}) + + def save(self, *args, **kwargs): + if self.pk and not self._state.adding and self.control_id: + stored = type(self).objects.filter(pk=self.pk).values_list("control_id", flat=True).first() + if stored and stored != self.control_id and self.runs.exists(): + raise ValidationError(_("A test that has runs cannot move to another control.")) + # Keep the derived date in step with frequency / first due date edits. + if self.pk and not self._state.adding: + self.next_due_date = self.compute_next_due_date() + super().save(*args, **kwargs) + + # --- Lifecycle side effects (RG-CTL-06, RG-CTL-08) -------------------- + + def transition_to(self, target, user=None, comment=None, *, enforce_permission=False, save=True): + from controls.models.control import Control + from core.lifecycle import DomainRefusalError + + with transaction.atomic(): + control = Control.objects.select_for_update().get(pk=self.control_id) + if target == TEST_ACTIVE: + if control.workflow_state not in (CONTROL_PLANNED, CONTROL_IMPLEMENTED): + raise DomainRefusalError(_("Plan or implement the control before activating its tests.")) + if not (self.procedure or "").strip() or not (self.acceptance_criteria or "").strip(): + raise DomainRefusalError(_("A test needs a procedure and acceptance criteria before it can be activated.")) + self.activated_on = timezone.localdate() + result = super().transition_to(target, user, comment=comment, enforce_permission=enforce_permission, save=save) + if save: + self.recompute_next_due_date() + if target in (TEST_ACTIVE, TEST_RETIRED): + control.recompute_effectiveness() + return result diff --git a/controls/models/control_test_run.py b/controls/models/control_test_run.py new file mode 100644 index 00000000..5c3a546e --- /dev/null +++ b/controls/models/control_test_run.py @@ -0,0 +1,208 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""One execution of a control test (ISO/IEC 27001:2022 clause 9.1, 9.2, 10.2). + +Frozen once submitted (RG-CTL-09), validated by someone other than its +performer (RG-CTL-11); validation drives the test's next due date and the +control's effectiveness (RG-CTL-12). See docs/specs/m8-controls/control-test-run.md. +""" + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import models, transaction +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ +from simple_history.models import HistoricalRecords + +from context.models.base import BaseModel +from controls.constants import ( + REFERENCE_PREFIXES, + RUN_DRAFT, + RUN_IN_REVIEW, + RUN_VALIDATED, + RUN_WITHDRAWN, + TEST_ACTIVE, + RunResult, +) + +FROZEN_FIELDS = ( + "test_id", "performed_by_id", "performed_on", "period_start", "period_end", + "sample_description", "result", "observations", +) + + +class ControlTestRun(BaseModel): + """A run of a :class:`~controls.models.control_test.ControlTest`.""" + + REFERENCE_PREFIX = REFERENCE_PREFIXES["ControlTestRun"] + LIFECYCLE_NAME = "control_test_run" + scope_parent_lookup = "test__control__scopes" + + test = models.ForeignKey( + "controls.ControlTest", on_delete=models.PROTECT, related_name="runs", verbose_name=_("Test"), + ) + performed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="control_test_runs", + verbose_name=_("Performed by"), + ) + performed_on = models.DateField(_("Performed on"), default=timezone.localdate) + period_start = models.DateField(_("Period start"), null=True, blank=True) + period_end = models.DateField(_("Period end"), null=True, blank=True) + sample_description = models.TextField(_("Sample"), blank=True, default="") + result = models.CharField(_("Result"), max_length=10, choices=RunResult.choices, blank=True, default="") + observations = models.TextField(_("Observations"), blank=True, default="") + reviewed_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, + related_name="reviewed_control_test_runs", verbose_name=_("Reviewed by"), editable=False, + ) + reviewed_at = models.DateTimeField(_("Reviewed at"), null=True, blank=True, editable=False) + history = HistoricalRecords() + + class Meta(BaseModel.Meta): + ordering = ["-performed_on", "-created_at"] + verbose_name = _("Test run") + verbose_name_plural = _("Test runs") + + def __str__(self): + return f"{self.reference} : {self.test.name} ({self.performed_on.isoformat()})" + + @property + def workflow_perm_namespace(self): + return "controls.test" + + @property + def control(self): + return self.test.control + + @property + def is_frozen(self): + return self.workflow_state not in (RUN_DRAFT, "", None) + + @property + def needs_finding(self): + """RG-CTL-16 : a validated partial / failed run without its finding yet.""" + return ( + self.workflow_state == RUN_VALIDATED + and self.result in (RunResult.PARTIAL, RunResult.FAIL) + and not self.findings.exists() + ) + + # --- Integrity (RG-CTL-09, RG-CTL-10) --------------------------------- + + def _period_errors(self): + errors = {} + today = timezone.localdate() + if self.performed_on and self.performed_on > today: + errors["performed_on"] = _("The test date cannot be in the future.") + if self.period_start and self.period_end and self.period_start > self.period_end: + errors["period_end"] = _("The period cannot end before it starts.") + if self.period_end and self.performed_on and self.period_end > self.performed_on: + errors["period_end"] = _("The period tested cannot end after the test date.") + return errors + + def clean(self): + super().clean() + errors = self._period_errors() + if self._state.adding and self.test_id and self.test.workflow_state != TEST_ACTIVE: + errors["test"] = _("Runs can only be recorded against an active test.") + if errors: + raise ValidationError(errors) + + def save(self, *args, **kwargs): + if self._state.adding and self.test_id: + from controls.models.control_test import ControlTest + + state = ControlTest.objects.filter(pk=self.test_id).values_list("workflow_state", flat=True).first() + if state != TEST_ACTIVE: + raise ValidationError(_("Runs can only be recorded against an active test.")) + elif self.pk: + stored = type(self).objects.filter(pk=self.pk).values("workflow_state", *FROZEN_FIELDS).first() + if stored and stored["workflow_state"] != RUN_DRAFT: + changed = [f for f in FROZEN_FIELDS if stored[f] != getattr(self, f)] + if changed: + raise ValidationError(_("A submitted test run is frozen : its content can no longer be changed.")) + super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + # Only a draft run is deletable (lifecycle governance); its evidence goes + # with it, file included, while the run is still open (RG-CTL-13). + with transaction.atomic(): + for item in self.evidence.all(): + item.delete() + return super().delete(*args, **kwargs) + + # --- Lifecycle side effects (RG-CTL-10, -11, -12) --------------------- + + def transition_to(self, target, user=None, comment=None, *, enforce_permission=False, save=True): + from controls.models.control import Control + from core.lifecycle import DomainRefusalError + + with transaction.atomic(): + control = Control.objects.select_for_update().get(pk=self.test.control_id) + if target == RUN_IN_REVIEW: + if not self.result: + raise DomainRefusalError(_("Record the result of the test before submitting it.")) + if self.result != RunResult.PASS and not (self.observations or "").strip(): + raise DomainRefusalError(_("Describe what was found : a partial or failed result needs observations.")) + errors = self._period_errors() + if errors: + raise DomainRefusalError(" ".join(str(e) for e in errors.values())) + if target == RUN_VALIDATED: + # RG-CTL-11 : segregation of duties, superusers included. Only the + # system path (no acting user, e.g. the seed) skips it. + if user is None and enforce_permission: + raise DomainRefusalError(_("A test run is validated by a reviewer.")) + # The recorder too : otherwise anyone could record a run in a + # colleague's name and validate it themselves. + if user is not None and user.pk in (self.performed_by_id, self.created_by_id): + raise DomainRefusalError(_("The person who performed or recorded a test cannot validate it.")) + self.reviewed_by = user + self.reviewed_at = timezone.now() + result = super().transition_to(target, user, comment=comment, enforce_permission=enforce_permission, save=save) + if not save: + return result + if target == RUN_IN_REVIEW and user is not None: + from accounts.notifications import notify_lifecycle_submitted + + notify_lifecycle_submitted(self, actor=user) + if target in (RUN_VALIDATED, RUN_WITHDRAWN): + # RG-CTL-12 : recomputed in the same transaction. + self.test.recompute_next_due_date() + control.recompute_effectiveness() + if target == RUN_VALIDATED and self.result in (RunResult.PARTIAL, RunResult.FAIL): + from controls.notifications import notify_test_failed + + notify_test_failed(self, control=control, actor=user) + return result + + # --- Finding (RG-CTL-16) ---------------------------------------------- + + def raise_finding(self, user, finding_type, recommendation=""): + from compliance.constants import FindingSource + from compliance.models import Finding + from core.lifecycle import DomainRefusalError + + if self.workflow_state != RUN_VALIDATED or self.result not in (RunResult.PARTIAL, RunResult.FAIL): + raise DomainRefusalError(_("A finding can only be raised from a validated partial or failed run.")) + with transaction.atomic(): + type(self).objects.select_for_update().filter(pk=self.pk).first() + if self.findings.exists(): + raise DomainRefusalError(_("A finding was already raised from this run.")) + control = self.test.control + finding = Finding( + source=FindingSource.CONTROL_TEST, + control_test_run=self, + finding_type=finding_type, + description=self.observations, + recommendation=recommendation or "", + evidence=str(_("Test run %(ref)s of %(test)s (%(control)s), performed on %(date)s.")) % { + "ref": self.reference, "test": self.test.name, "control": control.reference, + "date": self.performed_on.isoformat(), + }, + assessor=user, + created_by=user, + ) + finding.full_clean(exclude=["reference"]) + finding.save() + finding.requirements.set(control.requirements.all()) + return finding diff --git a/controls/models/evidence.py b/controls/models/evidence.py new file mode 100644 index 00000000..fbb2916c --- /dev/null +++ b/controls/models/evidence.py @@ -0,0 +1,90 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""A file attached to a test run as evidence : a write-once record once the +run has left ``draft`` (RG-CTL-13). Not a governed domain element : no +lifecycle, no reference. See docs/specs/m8-controls/control-test-evidence.md. +""" + +import hashlib +import uuid + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.validators import FileExtensionValidator +from django.db import models +from django.utils.translation import gettext_lazy as _ +from simple_history.models import HistoricalRecords + +from controls.constants import ALLOWED_EVIDENCE_EXTENSIONS, EVIDENCE_MAX_BYTES, RUN_DRAFT + + +def validate_evidence_size(value): + if value and value.size and value.size > EVIDENCE_MAX_BYTES: + raise ValidationError(_("An evidence file must not exceed 25 MB.")) + + +def _evidence_upload_path(instance, filename): + """``control_evidence///`` : never collides, never + exposes another run's identifiers.""" + return f"control_evidence/{instance.run_id}/{instance.pk}/{filename}" + + +class ControlTestEvidence(models.Model): + scope_parent_lookup = "run__test__control__scopes" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + run = models.ForeignKey( + "controls.ControlTestRun", on_delete=models.PROTECT, related_name="evidence", verbose_name=_("Test run"), + ) + file = models.FileField( + _("File"), upload_to=_evidence_upload_path, max_length=400, + validators=[FileExtensionValidator(allowed_extensions=ALLOWED_EVIDENCE_EXTENSIONS), validate_evidence_size], + ) + original_filename = models.CharField(_("File name"), max_length=255, blank=True, default="") + size = models.PositiveBigIntegerField(_("Size (bytes)"), default=0, editable=False) + sha256 = models.CharField(_("SHA-256"), max_length=64, blank=True, default="", editable=False) + description = models.CharField(_("Description"), max_length=255, blank=True, default="") + uploaded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, + related_name="uploaded_control_evidence", verbose_name=_("Uploaded by"), + ) + uploaded_at = models.DateTimeField(_("Uploaded at"), auto_now_add=True) + history = HistoricalRecords(excluded_fields=["file"]) + + class Meta: + ordering = ["run", "uploaded_at"] + verbose_name = _("Test evidence") + verbose_name_plural = _("Test evidence") + + def __str__(self): + return self.original_filename or str(self.pk) + + def _check_run_open(self): + from controls.models.control_test_run import ControlTestRun + + state = ControlTestRun.objects.filter(pk=self.run_id).values_list("workflow_state", flat=True).first() + if state != RUN_DRAFT: + raise ValidationError(_("Evidence can only be added or removed while the test run is in progress.")) + + def save(self, *args, **kwargs): + # RG-CTL-13 : write-once after submission; the file never changes. + if not self._state.adding: + raise ValidationError(_("An evidence file cannot be changed : remove it and upload a new one.")) + self._check_run_open() + if self.file: + digest = hashlib.sha256() + for chunk in self.file.chunks(): + digest.update(chunk) + self.sha256 = digest.hexdigest() + self.size = self.file.size or 0 + if not self.original_filename: + self.original_filename = self.file.name.rsplit("/", 1)[-1][:255] + super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + self._check_run_open() + storage, name = self.file.storage, self.file.name + result = super().delete(*args, **kwargs) + if name: + storage.delete(name) + return result diff --git a/controls/notifications.py b/controls/notifications.py new file mode 100644 index 00000000..d683a672 --- /dev/null +++ b/controls/notifications.py @@ -0,0 +1,56 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""Notifications of module 8 (README §7). + +Same contract as ``documents.notifications`` : one in-app row per recipient, +rendered in the recipient's language, created in the surrounding transaction, +delivered on commit. +""" + +from django.urls import reverse +from django.utils.translation import gettext as _ + +from accounts.constants import NotificationType +from documents.notifications import _send + + +def notify_test_failed(run, control=None, actor=None): + # The caller passes the control it just recomputed : `run.test.control` + # may be a cached instance still holding the previous effectiveness. + control = control or run.test.control + recipients = [control.owner] if control.owner_id else [] + url = reverse("controls:controltestrun-detail", kwargs={"pk": run.pk}) + + def render(): + return ( + _("Control test failed : %(control)s") % {"control": control.name}, + _("Run %(ref)s of %(test)s ended with the result \"%(result)s\". The control is now %(effectiveness)s.") % { + "ref": run.reference, "test": run.test.name, "result": run.get_result_display(), + "effectiveness": control.get_effectiveness_display().lower(), + }, + ) + + return _send(recipients, NotificationType.CONTROL_TEST_FAILED, run, url, render, actor=actor) + + +def notify_test_due(test, overdue): + control = test.control + recipients = [test.tester or control.owner] + if overdue and control.owner_id: + recipients.append(control.owner) + url = reverse("controls:controltest-detail", kwargs={"pk": test.pk}) + kind = NotificationType.CONTROL_TEST_OVERDUE if overdue else NotificationType.CONTROL_TEST_DUE + + def render(): + values = {"test": test.name, "control": control.name, "date": test.next_due_date.isoformat()} + if overdue: + return ( + _("Control test overdue : %(test)s") % values, + _("%(test)s (%(control)s) was due on %(date)s.") % values, + ) + return ( + _("Control test due : %(test)s") % values, + _("%(test)s (%(control)s) is due on %(date)s.") % values, + ) + + return _send(recipients, kind, test, url, render) diff --git a/controls/services.py b/controls/services.py new file mode 100644 index 00000000..4cb3c06c --- /dev/null +++ b/controls/services.py @@ -0,0 +1,102 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""Services of module 8 shared by the web, REST and MCP surfaces. + +- :func:`create_controls_from_requirements` (RG-CTL-05); +- :func:`requirement_coverage` : per applicable requirement of a framework, + its reportable controls with their state and effectiveness (RG-CTL-18). +""" + +from django.db import transaction +from django.utils.translation import gettext as _ + +from controls.constants import CONTROL_IMPLEMENTED, CONTROL_PLANNED +from core.lifecycle import reportable + + +class ImplementationSummary: + """RG-CTL-18 : the *Implementation* column of the SoA.""" + + IMPLEMENTED = "implemented" + PARTIAL = "partial" + PLANNED = "planned" + NONE = "none" + + @classmethod + def label(cls, value): + return { + cls.IMPLEMENTED: _("Implemented"), + cls.PARTIAL: _("Partially implemented"), + cls.PLANNED: _("Planned"), + cls.NONE: _("No control"), + }[value] + + +def summarise_implementation(states): + """``states`` : the lifecycle steps of a requirement's reportable controls.""" + states = list(states) + if not states: + return ImplementationSummary.NONE + implemented = sum(1 for s in states if s == CONTROL_IMPLEMENTED) + if implemented == len(states): + return ImplementationSummary.IMPLEMENTED + if implemented: + return ImplementationSummary.PARTIAL + if all(s == CONTROL_PLANNED for s in states): + return ImplementationSummary.PLANNED + return ImplementationSummary.PARTIAL + + +def create_controls_from_requirements(framework, user, requirement_ids=None, scope_ids=None): + """RG-CTL-05 : one proposed control per applicable requirement with no + control yet. Returns ``(created, skipped)`` lists : the new controls, and + the requirements left out because a control already covers them (any + step, a retired one included : re-covering a requirement is a decision, + not a bulk action) or because they are not applicable. + """ + from controls.models import Control + + requirements = framework.requirements.all().order_by("requirement_number", "created_at") + if requirement_ids is not None: + requirements = requirements.filter(pk__in=list(requirement_ids)) + if scope_ids is None: + scope_ids = list(framework.scopes.values_list("pk", flat=True)) + created, skipped = [], [] + with transaction.atomic(): + for requirement in requirements.select_for_update(): + if not requirement.is_applicable or requirement.controls.exists(): + skipped.append(requirement) + continue + control = Control.objects.create( + name=(requirement.name or requirement.requirement_number)[:255], + description=requirement.description or requirement.name, + owner=requirement.owner, + created_by=user, + ) + control.requirements.add(requirement) + if scope_ids: + control.scopes.set(scope_ids) + created.append(control) + return created, skipped + + +def requirement_coverage(framework): + """RG-CTL-18 : rows for every reportable, applicable requirement of + ``framework``, each with its reportable controls (state, effectiveness) + and the implementation summary.""" + from controls.models import Control + + requirements = reportable(framework.requirements.filter(is_applicable=True)).order_by( + "requirement_number", "created_at" + ) + rows = [] + for requirement in requirements: + controls = list(reportable(Control.objects.filter(requirements=requirement)).order_by("reference")) + summary = summarise_implementation(c.workflow_state for c in controls) + rows.append({ + "requirement": requirement, + "controls": controls, + "implementation": summary, + "implementation_label": ImplementationSummary.label(summary), + }) + return rows diff --git a/controls/templatetags/__init__.py b/controls/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/controls/tests/__init__.py b/controls/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/controls/tests/factories.py b/controls/tests/factories.py new file mode 100644 index 00000000..adf08f90 --- /dev/null +++ b/controls/tests/factories.py @@ -0,0 +1,79 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""factory-boy factories for module 8 (controls). + +Every factory produces a row in its lifecycle's `draft` step. Tests walk rows +forward with the helpers below, through the recorded transitions, never by +assigning `workflow_state`. +""" +import factory + +from accounts.tests.factories import UserFactory +from controls.constants import ( + CONTROL_IMPLEMENTED, + RUN_IN_REVIEW, + RUN_VALIDATED, + TEST_ACTIVE, + RunResult, +) +from controls.models import Control, ControlTest, ControlTestRun + + +class ControlFactory(factory.django.DjangoModelFactory): + class Meta: + model = Control + skip_postgeneration_save = True + + name = factory.Sequence(lambda n: f"Control {n}") + description = "Multi-factor authentication is enforced on every remote access." + owner = factory.SubFactory(UserFactory) + created_by = factory.SelfAttribute("owner") + + +class ControlTestFactory(factory.django.DjangoModelFactory): + class Meta: + model = ControlTest + + control = factory.SubFactory(ControlFactory) + name = factory.Sequence(lambda n: f"Test {n}") + procedure = "Pick 25 remote logins and check each used a second factor." + acceptance_criteria = "Every sampled login used a second factor." + frequency_months = 3 + tester = factory.SubFactory(UserFactory) + created_by = factory.SelfAttribute("tester") + + +class ControlTestRunFactory(factory.django.DjangoModelFactory): + """Needs an active test : build it with `active_test()`.""" + + class Meta: + model = ControlTestRun + + test = factory.LazyFunction(lambda: active_test()) + performed_by = factory.LazyAttribute(lambda o: o.test.tester or UserFactory()) + created_by = factory.SelfAttribute("performed_by") + + +def implemented_control(user=None, **kwargs): + control = ControlFactory(**kwargs) + control.transition_to(CONTROL_IMPLEMENTED, user or control.owner) + control.refresh_from_db() + return control + + +def active_test(control=None, user=None, **kwargs): + control = control or implemented_control() + test = ControlTestFactory(control=control, **kwargs) + test.transition_to(TEST_ACTIVE, user or control.owner) + test.refresh_from_db() + return test + + +def validated_run(test=None, result=RunResult.PASS, reviewer=None, **kwargs): + """Record, submit and validate a run; the reviewer differs from the performer.""" + test = test or active_test() + run = ControlTestRunFactory(test=test, result=result, observations=kwargs.pop("observations", "Observed."), **kwargs) + run.transition_to(RUN_IN_REVIEW, run.performed_by) + run.transition_to(RUN_VALIDATED, reviewer or UserFactory()) + run.refresh_from_db() + return run diff --git a/controls/tests/test_domain.py b/controls/tests/test_domain.py new file mode 100644 index 00000000..3e8ce75e --- /dev/null +++ b/controls/tests/test_domain.py @@ -0,0 +1,505 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""Domain rules of module 8 (controls) : RG-CTL-01 to RG-CTL-16.""" + +import datetime + +import pytest +from django.core.exceptions import ValidationError +from django.core.files.uploadedfile import SimpleUploadedFile +from django.utils import timezone + +from accounts.constants import NotificationType +from accounts.models import Notification +from accounts.tests.factories import UserFactory +from compliance.constants import FindingSource, FindingType +from controls.constants import ( + CONTROL_DRAFT, + CONTROL_IMPLEMENTED, + CONTROL_PLANNED, + CONTROL_RETIRED, + RUN_DRAFT, + RUN_IN_REVIEW, + RUN_VALIDATED, + RUN_WITHDRAWN, + TEST_ACTIVE, + TEST_RETIRED, + ControlEffectiveness, + RunResult, +) +from controls.models import Control, ControlTest, ControlTestEvidence, ControlTestRun +from controls.models.control import effectiveness_from_results +from controls.tests.factories import ( + ControlFactory, + ControlTestFactory, + ControlTestRunFactory, + active_test, + implemented_control, + validated_run, +) +from core.lifecycle import DomainRefusalError, reportable +from documents.models.policy import add_months + +pytestmark = pytest.mark.django_db + +TODAY = timezone.localdate + + +def test_each_model_runs_its_own_lifecycle(): + assert Control().get_lifecycle().name == "control" + assert ControlTest().get_lifecycle().name == "control_test" + assert ControlTestRun().get_lifecycle().name == "control_test_run" + + +def test_lifecycles_declare_no_restore_edge(): + for model in (Control, ControlTest, ControlTestRun): + lifecycle = model().get_lifecycle() + assert not [t for t in lifecycle.transitions if t.source == "archived"], model + + +def test_references_use_the_module_prefixes(): + run = validated_run() + assert run.test.control.reference.startswith("CTRL-") + assert run.test.reference.startswith("CTST-") + assert run.reference.startswith("CTRN-") + + +# --- Controls (RG-CTL-01 to RG-CTL-03) ---------------------------------------- + + +def test_declaring_implemented_stamps_the_date(): + control = ControlFactory() + control.transition_to(CONTROL_IMPLEMENTED, control.owner) + control.refresh_from_db() + assert control.workflow_state == CONTROL_IMPLEMENTED + assert control.implemented_on == TODAY() + + +def test_an_earlier_implementation_date_is_kept(): + control = ControlFactory(implemented_on=datetime.date(2024, 5, 1)) + control.transition_to(CONTROL_IMPLEMENTED, control.owner) + control.refresh_from_db() + assert control.implemented_on == datetime.date(2024, 5, 1) + + +def test_a_future_implementation_date_is_refused(): + control = ControlFactory(implemented_on=TODAY() + datetime.timedelta(days=3)) + with pytest.raises(ValidationError): + control.full_clean() + with pytest.raises(DomainRefusalError): + control.transition_to(CONTROL_IMPLEMENTED, control.owner) + + +def test_planned_and_implemented_controls_are_reportable(): + draft = ControlFactory() + planned = ControlFactory() + planned.transition_to(CONTROL_PLANNED, planned.owner) + implemented = implemented_control() + ids = set(reportable(Control.objects.all()).values_list("pk", flat=True)) + assert ids == {planned.pk, implemented.pk} + assert draft.workflow_state == CONTROL_DRAFT + + +def test_back_to_planned_requires_a_comment(): + control = implemented_control() + with pytest.raises(Exception): + control.transition_to(CONTROL_PLANNED, control.owner) + control.transition_to(CONTROL_PLANNED, control.owner, comment="The MFA provider was removed.") + control.refresh_from_db() + assert control.workflow_state == CONTROL_PLANNED + assert control.implemented_on == TODAY() + + +def test_retiring_a_control_retires_its_tests(): + test = active_test() + draft_test = ControlTestFactory(control=test.control) + control = test.control + control.transition_to(CONTROL_RETIRED, control.owner, comment="Replaced by CTRL-9.") + test.refresh_from_db() + draft_test.refresh_from_db() + assert test.workflow_state == TEST_RETIRED + assert draft_test.workflow_state == TEST_RETIRED + assert test.next_due_date is None + + +def test_relations_expose_the_controls_accessor(): + from compliance.tests.factories import RequirementFactory + + control = ControlFactory() + requirement = RequirementFactory() + control.requirements.add(requirement) + assert list(requirement.controls.all()) == [control] + + +# --- Tests (RG-CTL-06 to RG-CTL-08) ------------------------------------------- + + +def test_a_test_of_a_proposed_control_cannot_be_activated(): + test = ControlTestFactory() + with pytest.raises(DomainRefusalError): + test.transition_to(TEST_ACTIVE, test.control.owner) + + +def test_a_test_needs_a_procedure_and_criteria_to_activate(): + control = implemented_control() + test = ControlTestFactory(control=control, acceptance_criteria="") + with pytest.raises(DomainRefusalError): + test.transition_to(TEST_ACTIVE, control.owner) + + +def test_next_due_date_starts_at_activation_or_first_due_date(): + test = active_test() + assert test.activated_on == TODAY() + assert test.next_due_date == TODAY() + first = TODAY() + datetime.timedelta(days=20) + other = active_test(first_due_date=first) + assert other.next_due_date == first + + +def test_next_due_date_follows_the_latest_validated_run(): + test = active_test(frequency_months=3) + performed = TODAY() - datetime.timedelta(days=10) + validated_run(test=test, performed_on=performed) + test.refresh_from_db() + assert test.next_due_date == add_months(performed, 3) + assert not test.is_overdue + + +def test_changing_the_frequency_recomputes_the_due_date(): + test = active_test(frequency_months=3) + run = validated_run(test=test) + test.refresh_from_db() + test.frequency_months = 6 + test.save() + test.refresh_from_db() + assert test.next_due_date == add_months(run.performed_on, 6) + + +def test_a_tested_test_cannot_move_to_another_control(): + run = validated_run() + test = run.test + test.control = implemented_control() + with pytest.raises(ValidationError): + test.save() + + +# --- Runs (RG-CTL-09 to RG-CTL-13) -------------------------------------------- + + +def test_a_run_needs_an_active_test(): + test = ControlTestFactory() + with pytest.raises(ValidationError): + ControlTestRunFactory(test=test) + + +def test_submitting_needs_a_result(): + run = ControlTestRunFactory() + with pytest.raises(DomainRefusalError): + run.transition_to(RUN_IN_REVIEW, run.performed_by) + + +def test_a_failed_result_needs_observations(): + run = ControlTestRunFactory(result=RunResult.FAIL, observations="") + with pytest.raises(DomainRefusalError): + run.transition_to(RUN_IN_REVIEW, run.performed_by) + + +def test_the_period_must_precede_the_test_date(): + run = ControlTestRunFactory( + result=RunResult.PASS, performed_on=TODAY() - datetime.timedelta(days=5), + period_start=TODAY() - datetime.timedelta(days=30), period_end=TODAY() - datetime.timedelta(days=1), + ) + with pytest.raises(ValidationError): + run.full_clean() + with pytest.raises(DomainRefusalError): + run.transition_to(RUN_IN_REVIEW, run.performed_by) + + +def test_a_submitted_run_is_frozen(): + run = ControlTestRunFactory(result=RunResult.PASS) + run.transition_to(RUN_IN_REVIEW, run.performed_by) + run.refresh_from_db() + run.result = RunResult.FAIL + with pytest.raises(ValidationError): + run.save() + + +def test_sending_back_reopens_the_run(): + run = ControlTestRunFactory(result=RunResult.PASS) + run.transition_to(RUN_IN_REVIEW, run.performed_by) + run.transition_to(RUN_DRAFT, UserFactory(), comment="Attach the login export.") + run.refresh_from_db() + run.observations = "Export attached." + run.save() + + +def test_the_performer_cannot_validate_their_own_run_even_as_superuser(): + performer = UserFactory(is_superuser=True) + run = ControlTestRunFactory(result=RunResult.PASS, performed_by=performer) + run.transition_to(RUN_IN_REVIEW, performer) + with pytest.raises(DomainRefusalError): + run.transition_to(RUN_VALIDATED, performer) + + +def test_validation_stamps_the_reviewer(): + reviewer = UserFactory() + run = validated_run(reviewer=reviewer) + assert run.reviewed_by == reviewer + assert run.reviewed_at is not None + + +def test_submission_notifies_the_reviewers(): + from accounts.models import Group, Permission + + reviewer = UserFactory() + group = Group.objects.create(name="Second line") + group.permissions.add(Permission.objects.get(codename="controls.test.approve")) + group.users.add(reviewer) + run = ControlTestRunFactory(result=RunResult.PASS) + run.transition_to(RUN_IN_REVIEW, run.performed_by) + assert Notification.objects.filter( + recipient=reviewer, notification_type=NotificationType.LIFECYCLE_SUBMITTED, + ).exists() + + +# --- Effectiveness (RG-CTL-14, RG-CTL-15) ------------------------------------- + + +@pytest.mark.parametrize( + ("results", "expected"), + [ + ([], ControlEffectiveness.NOT_TESTED), + ([RunResult.PASS], ControlEffectiveness.EFFECTIVE), + ([RunResult.PASS, None], ControlEffectiveness.NOT_TESTED), + ([RunResult.PASS, RunResult.PARTIAL], ControlEffectiveness.PARTIALLY_EFFECTIVE), + ([RunResult.PARTIAL, RunResult.FAIL, None], ControlEffectiveness.NOT_EFFECTIVE), + ], +) +def test_effectiveness_rule(results, expected): + assert effectiveness_from_results(results) == expected + + +def test_validating_a_run_updates_the_effectiveness(): + test = active_test() + control = test.control + assert control.effectiveness == ControlEffectiveness.NOT_TESTED + validated_run(test=test) + control.refresh_from_db() + assert control.effectiveness == ControlEffectiveness.EFFECTIVE + assert control.effectiveness_updated_at is not None + + +def test_the_latest_validated_run_wins(): + test = active_test() + validated_run(test=test, result=RunResult.FAIL, performed_on=TODAY() - datetime.timedelta(days=40)) + validated_run(test=test, result=RunResult.PASS, performed_on=TODAY() - datetime.timedelta(days=2)) + test.control.refresh_from_db() + assert test.control.effectiveness == ControlEffectiveness.EFFECTIVE + + +def test_withdrawing_a_run_recomputes_the_effectiveness(): + run = validated_run(result=RunResult.FAIL) + control = run.test.control + control.refresh_from_db() + assert control.effectiveness == ControlEffectiveness.NOT_EFFECTIVE + run.transition_to(RUN_WITHDRAWN, UserFactory(), comment="Sample taken from the wrong system.") + control.refresh_from_db() + assert control.effectiveness == ControlEffectiveness.NOT_TESTED + + +def test_retiring_a_test_recomputes_the_effectiveness(): + control = implemented_control() + passing = active_test(control=control) + failing = active_test(control=control) + validated_run(test=passing) + validated_run(test=failing, result=RunResult.FAIL) + control.refresh_from_db() + assert control.effectiveness == ControlEffectiveness.NOT_EFFECTIVE + failing.transition_to(TEST_RETIRED, control.owner, comment="Merged into the other test.") + control.refresh_from_db() + assert control.effectiveness == ControlEffectiveness.EFFECTIVE + + +def test_an_overdue_test_flags_the_effectiveness_as_stale(): + test = active_test(frequency_months=1) + validated_run(test=test, performed_on=TODAY() - datetime.timedelta(days=60)) + control = test.control + control.refresh_from_db() + assert control.is_test_overdue + assert control.is_effectiveness_stale + assert control.effectiveness == ControlEffectiveness.EFFECTIVE + + +def test_a_failed_validated_run_notifies_the_control_owner(): + run = validated_run(result=RunResult.FAIL) + assert Notification.objects.filter( + recipient=run.test.control.owner, notification_type=NotificationType.CONTROL_TEST_FAILED, + ).exists() + + +# --- Findings (RG-CTL-16) ----------------------------------------------------- + + +def test_a_finding_is_raised_once_from_a_failed_run(): + from compliance.tests.factories import RequirementFactory + + run = validated_run(result=RunResult.FAIL, observations="3 of 25 logins had no second factor.") + requirement = RequirementFactory() + run.test.control.requirements.add(requirement) + user = UserFactory() + finding = run.raise_finding(user, FindingType.MINOR_NON_CONFORMITY) + assert finding.source == FindingSource.CONTROL_TEST + assert finding.control_test_run == run + assert finding.description == "3 of 25 logins had no second factor." + assert list(finding.requirements.all()) == [requirement] + assert finding.assessor == user + assert not run.needs_finding + with pytest.raises(DomainRefusalError): + run.raise_finding(user, FindingType.MINOR_NON_CONFORMITY) + + +def test_no_finding_from_a_passing_run(): + run = validated_run() + with pytest.raises(DomainRefusalError): + run.raise_finding(UserFactory(), FindingType.OBSERVATION) + + +# --- Evidence (RG-CTL-13) ----------------------------------------------------- + + +def _upload(run, name="export.csv", data=b"login,mfa\nalice,yes\n"): + return ControlTestEvidence.objects.create( + run=run, file=SimpleUploadedFile(name, data), original_filename=name, uploaded_by=run.performed_by, + ) + + +def test_evidence_records_its_fingerprint(settings, tmp_path): + import hashlib + + settings.MEDIA_ROOT = tmp_path + run = ControlTestRunFactory(result=RunResult.PASS) + item = _upload(run) + assert item.sha256 == hashlib.sha256(b"login,mfa\nalice,yes\n").hexdigest() + assert item.size == 20 + + +def test_evidence_is_frozen_with_the_run(settings, tmp_path): + settings.MEDIA_ROOT = tmp_path + run = ControlTestRunFactory(result=RunResult.PASS) + item = _upload(run) + run.transition_to(RUN_IN_REVIEW, run.performed_by) + with pytest.raises(ValidationError): + _upload(run, "late.csv") + with pytest.raises(ValidationError): + item.delete() + with pytest.raises(ValidationError): + item.save() + + +def test_deleting_a_draft_run_removes_its_evidence(settings, tmp_path): + settings.MEDIA_ROOT = tmp_path + run = ControlTestRunFactory(result=RunResult.PASS) + item = _upload(run) + path = tmp_path / item.file.name + assert path.exists() + run.delete() + assert not ControlTestEvidence.objects.exists() + assert not path.exists() + + +def test_a_disallowed_extension_is_refused(): + run = ControlTestRunFactory(result=RunResult.PASS) + item = ControlTestEvidence(run=run, file=SimpleUploadedFile("tool.exe", b"MZ")) + with pytest.raises(ValidationError): + item.full_clean() + + +# --- Permissions --------------------------------------------------------------- + + +def test_permissions_are_attached_to_the_system_groups(): + from accounts.models import Group + + codenames = lambda name: set( # noqa: E731 + Group.objects.get(name=name).permissions.filter(module="controls").values_list("codename", flat=True) + ) + if not Group.objects.filter(name="Lecteur").exists(): + pytest.skip("System groups are not seeded in this database.") + assert codenames("Lecteur") == {"controls.control.read", "controls.test.read"} + assert "controls.test.approve" in codenames("RSSI / DPO") + assert "controls.control.delete" not in codenames("Contributeur") + assert len(codenames("Administrateur")) == 10 + + +# --- Services (RG-CTL-05, RG-CTL-18) ------------------------------------------ + + +def test_create_controls_from_requirements_skips_covered_and_not_applicable(): + from compliance.tests.factories import FrameworkFactory, RequirementFactory + from context.tests.factories import ScopeFactory + from controls.services import create_controls_from_requirements + + scope = ScopeFactory() + framework = FrameworkFactory() + framework.scopes.add(scope) + free = RequirementFactory(framework=framework, name="Access rights") + covered = RequirementFactory(framework=framework) + out = RequirementFactory(framework=framework, is_applicable=False, applicability_justification="No site.") + ControlFactory().requirements.add(covered) + user = UserFactory() + created, skipped = create_controls_from_requirements(framework, user) + assert [c.name for c in created] == ["Access rights"] + control = created[0] + assert control.workflow_state == CONTROL_DRAFT + assert list(control.requirements.all()) == [free] + assert list(control.scopes.all()) == [scope] + assert control.created_by == user + assert set(skipped) == {covered, out} + again, _ = create_controls_from_requirements(framework, user) + assert again == [] + + +def _validate(obj): + """Walk a default-lifecycle row to its reportable step.""" + lifecycle = obj.get_lifecycle() + for step in ("pending", "validated"): + if step in {s.code for s in lifecycle.steps}: + obj.transition_to(step, None) + + +def test_requirement_coverage_summarises_the_implementation(): + from compliance.tests.factories import FrameworkFactory, RequirementFactory + from controls.services import ImplementationSummary, requirement_coverage + + framework = FrameworkFactory() + full = RequirementFactory(framework=framework, requirement_number="A.1") + partial = RequirementFactory(framework=framework, requirement_number="A.2") + bare = RequirementFactory(framework=framework, requirement_number="A.3") + implemented_control().requirements.add(full, partial) + planned = ControlFactory() + planned.transition_to(CONTROL_PLANNED, planned.owner) + planned.requirements.add(partial) + ControlFactory().requirements.add(bare) # proposed : not reportable + for req in (full, partial, bare): + _validate(req) + rows = {row["requirement"].pk: row for row in requirement_coverage(framework)} + assert rows[full.pk]["implementation"] == ImplementationSummary.IMPLEMENTED + assert rows[partial.pk]["implementation"] == ImplementationSummary.PARTIAL + assert rows[bare.pk]["implementation"] == ImplementationSummary.NONE + assert rows[bare.pk]["controls"] == [] + + +def test_the_recorder_cannot_validate_a_run_recorded_for_someone_else(): + recorder = UserFactory() + run = ControlTestRunFactory(result=RunResult.PASS, created_by=recorder) + run.transition_to(RUN_IN_REVIEW, recorder) + with pytest.raises(DomainRefusalError): + run.transition_to(RUN_VALIDATED, recorder) + run.transition_to(RUN_VALIDATED, UserFactory()) + + +def test_pacs_measures_under_way_are_linkable(): + from core.lifecycle import linkable_states + + assert {"planned", "in_progress", "overdue"} <= set(linkable_states("risks.PACSMeasure")) + diff --git a/controls/urls.py b/controls/urls.py new file mode 100644 index 00000000..883de555 --- /dev/null +++ b/controls/urls.py @@ -0,0 +1,32 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""URL contract of module 8 (controls). Route names are fixed : notifications, +search, the calendar, the kanban and the reverse cards rely on them.""" + +from django.urls import path + +from controls import views + +app_name = "controls" + +urlpatterns = [ + path("", views.ControlListView.as_view(), name="control-list"), + path("create/", views.ControlCreateView.as_view(), name="control-create"), + path("from-requirements/", views.ControlFromRequirementsView.as_view(), name="control-from-requirements"), + path("/", views.ControlDetailView.as_view(), name="control-detail"), + path("/edit/", views.ControlUpdateView.as_view(), name="control-update"), + path("/delete/", views.ControlDeleteView.as_view(), name="control-delete"), + path("/tests/create/", views.ControlTestCreateView.as_view(), name="controltest-create"), + path("tests/", views.ControlTestListView.as_view(), name="controltest-list"), + path("tests//", views.ControlTestDetailView.as_view(), name="controltest-detail"), + path("tests//edit/", views.ControlTestUpdateView.as_view(), name="controltest-update"), + path("tests//delete/", views.ControlTestDeleteView.as_view(), name="controltest-delete"), + path("tests//runs/create/", views.ControlTestRunCreateView.as_view(), name="controltestrun-create"), + path("runs//", views.ControlTestRunDetailView.as_view(), name="controltestrun-detail"), + path("runs//edit/", views.ControlTestRunUpdateView.as_view(), name="controltestrun-update"), + path("runs//delete/", views.ControlTestRunDeleteView.as_view(), name="controltestrun-delete"), + path("runs//raise-finding/", views.RaiseFindingView.as_view(), name="controltestrun-raise-finding"), + path("runs//evidence/upload/", views.EvidenceUploadView.as_view(), name="evidence-upload"), + path("evidence//download/", views.EvidenceDownloadView.as_view(), name="evidence-download"), + path("evidence//delete/", views.EvidenceDeleteView.as_view(), name="evidence-delete"), +] diff --git a/controls/views.py b/controls/views.py new file mode 100644 index 00000000..6c876758 --- /dev/null +++ b/controls/views.py @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""Web views of module 8 (controls). Placeholder until the UI lands.""" + +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import Http404 +from django.views import View + + +class _Pending(LoginRequiredMixin, View): + def get(self, request, *args, **kwargs): + raise Http404 + + +ControlListView = ControlCreateView = ControlFromRequirementsView = ControlDetailView = _Pending +ControlUpdateView = ControlDeleteView = _Pending +ControlTestCreateView = ControlTestListView = ControlTestDetailView = ControlTestUpdateView = _Pending +ControlTestDeleteView = _Pending +ControlTestRunCreateView = ControlTestRunDetailView = ControlTestRunUpdateView = ControlTestRunDeleteView = _Pending +RaiseFindingView = EvidenceUploadView = EvidenceDownloadView = EvidenceDeleteView = _Pending diff --git a/core/settings.py b/core/settings.py index f3b31017..5b4e1aab 100644 --- a/core/settings.py +++ b/core/settings.py @@ -82,6 +82,7 @@ def _detect_version(): "risks", "incidents", "documents", + "controls", "reports", "mcp", "assistant", diff --git a/core/templatetags/ui.py b/core/templatetags/ui.py index 73b1487a..b189f33e 100644 --- a/core/templatetags/ui.py +++ b/core/templatetags/ui.py @@ -289,6 +289,13 @@ def illustration(name, *, size="6rem"): "effective": ("success", "check-circle", pgettext_lazy("finding", "Effective")), "partially_effective": ("warning", "circle-half", pgettext_lazy("finding", "Partially effective")), "not_effective": ("danger", "x-circle", pgettext_lazy("finding", "Not effective")), + "not_tested": ("secondary", "dash-circle", pgettext_lazy("control effectiveness", "Not tested")), + }, + # Result of a control test run (controls module). + "run_result": { + "pass": ("success", "check-circle", pgettext_lazy("test result", "Pass")), + "partial": ("warning", "circle-half", pgettext_lazy("test result", "Partial")), + "fail": ("danger", "x-circle", pgettext_lazy("test result", "Fail")), }, # Access log events and assistant feedback (labels from the choices). "access_event": { @@ -1323,6 +1330,7 @@ def tag_list(tags, *, dash=False): "dic_criterion": "dic", "finding_type": "finding", "effectiveness_verdict": "effectiveness", + "effectiveness": "effectiveness", "tlp": "tlp", "notification_decision": "notification_decision", "triage_decision": "triage_decision", diff --git a/core/urls.py b/core/urls.py index f22fd358..f0b54173 100644 --- a/core/urls.py +++ b/core/urls.py @@ -55,6 +55,7 @@ path("risks/", include("risks.urls")), path("incidents/", include("incidents.urls")), path("documents/", include("documents.urls")), + path("controls/", include("controls.urls")), path("reports/", include("reports.urls")), path("trust/", include("trust_center.urls")), path("trust-center/manage/", include("trust_center.admin_urls")), @@ -65,6 +66,7 @@ path("api/v1/risks/", include("risks.api.urls")), path("api/v1/incidents/", include("incidents.api.urls")), path("api/v1/documents/", include("documents.api.urls")), + path("api/v1/controls/", include("controls.api.urls")), path("api/v1/reports/", include("reports.api.urls")), path("api/v1/trust-center/", include("trust_center.api.urls")), path("api/v1/assistant/", include("assistant.api.urls")), diff --git a/docs/reference/generated/lifecycles.md b/docs/reference/generated/lifecycles.md index 490e77af..7c5adde8 100644 --- a/docs/reference/generated/lifecycles.md +++ b/docs/reference/generated/lifecycles.md @@ -14,7 +14,7 @@ Every domain record runs a registered lifecycle. A lifecycle is an ordered set o The engine is specified in [governance/lifecycle.md](../../specs/governance/lifecycle.md); the governance contract is in [governance/workflow.md](../../specs/governance/workflow.md). -**33 lifecycles** are declared in code. An administrator can override any of them from `/config/lifecycles/`; this page documents the shipped defaults. +**36 lifecycles** are declared in code. An administrator can override any of them from `/config/lifecycles/`; this page documents the shipped defaults. ## Summary @@ -25,6 +25,9 @@ The engine is specified in [governance/lifecycle.md](../../specs/governance/life | [`certificate`](#certificate) | 7 | 7 | graph | `assets.Certificate` | | [`compliance_assessment`](#compliance-assessment) | 7 | 8 | graph | `compliance.ComplianceAssessment` | | [`contract`](#contract) | 7 | 7 | graph | `assets.Contract` | +| [`control`](#control) | 4 | 7 | graph | - | +| [`control_test`](#control-test) | 3 | 3 | graph | - | +| [`control_test_run`](#control-test-run) | 4 | 5 | graph | - | | [`default`](#default) | 4 | 4 | graph | `assets.AssetGroup`, `compliance.Finding`, `compliance.Framework`, `compliance.Requirement`, `context.Activity`, `context.Indicator`, `context.Issue`, `context.Objective`, `context.Role`, `context.Stakeholder`, `context.StakeholderFeedback`, `context.SwotAnalysis`, `incidents.IncidentResponsePlan`, `incidents.ReportingAuthority`, `incidents.ReportingObligationTemplate`, `risks.AttackPathStep`, `risks.AttackTechnique`, `risks.EcosystemStakeholder`, `risks.FearedEvent`, `risks.ISO27005Risk`, `risks.OperationalScenario`, `risks.RiskCriteria`, `risks.RiskSource`, `risks.RiskSourceObjectivePair`, `risks.StrategicScenario`, `risks.TargetedObjective`, `risks.Threat` | | [`ebios_baseline_gap`](#ebios-baseline-gap) | 6 | 7 | graph | `risks.BaselineGap` | | [`ebios_pacs_measure`](#ebios-pacs-measure) | 7 | 12 | graph | `risks.PACSMeasure` | @@ -202,6 +205,68 @@ Run by `assets.Contract`. | `active` | `expired` | Expire | - | - | - | | any | `archived` | Archive | - | - | - | +## control + +### Steps + +| Code | Label | Kind | In reports | Linkable | Deletable | +| --- | --- | --- | --- | --- | --- | +| `draft` | Proposed | draft | - | - | yes | +| `planned` | Planned | intermediate | yes | yes | - | +| `implemented` | Implemented | intermediate | yes | yes | - | +| `archived` | Retired | archived | - | - | - | + +### Transitions + +| From | To | Label | Comment required | Permission | Restricted to roles | +| --- | --- | --- | --- | --- | --- | +| `draft` | `planned` | Plan | - | `approve` | - | +| `draft` | `implemented` | Declare implemented | - | `approve` | - | +| `planned` | `implemented` | Declare implemented | - | `approve` | - | +| `implemented` | `planned` | Back to planned | yes | `update` | - | +| `planned` | `archived` | Retire | yes | `approve` | - | +| `implemented` | `archived` | Retire | yes | `approve` | - | +| `draft` | `archived` | Retire | yes | `update` | - | + +## control_test + +### Steps + +| Code | Label | Kind | In reports | Linkable | Deletable | +| --- | --- | --- | --- | --- | --- | +| `draft` | Draft | draft | - | - | yes | +| `active` | Active | intermediate | yes | yes | - | +| `archived` | Retired | archived | - | - | - | + +### Transitions + +| From | To | Label | Comment required | Permission | Restricted to roles | +| --- | --- | --- | --- | --- | --- | +| `draft` | `active` | Activate | - | `approve` | - | +| `active` | `archived` | Retire | yes | `approve` | - | +| `draft` | `archived` | Retire | yes | `update` | - | + +## control_test_run + +### Steps + +| Code | Label | Kind | In reports | Linkable | Deletable | +| --- | --- | --- | --- | --- | --- | +| `draft` | In progress | draft | - | - | yes | +| `in_review` | In review | intermediate | - | - | - | +| `validated` | Validated | intermediate | yes | - | - | +| `archived` | Withdrawn | archived | - | - | - | + +### Transitions + +| From | To | Label | Comment required | Permission | Restricted to roles | +| --- | --- | --- | --- | --- | --- | +| `draft` | `in_review` | Submit for review | - | `update` | - | +| `in_review` | `draft` | Send back | yes | `approve` | - | +| `in_review` | `validated` | Validate | - | `approve` | - | +| `validated` | `archived` | Withdraw | yes | `approve` | - | +| `draft` | `archived` | Cancel | yes | `update` | - | + ## default Run by `assets.AssetGroup`, `compliance.Finding`, `compliance.Framework`, `compliance.Requirement`, `context.Activity`, `context.Indicator`, `context.Issue`, `context.Objective`, `context.Role`, `context.Stakeholder`, `context.StakeholderFeedback`, `context.SwotAnalysis`, `incidents.IncidentResponsePlan`, `incidents.ReportingAuthority`, `incidents.ReportingObligationTemplate`, `risks.AttackPathStep`, `risks.AttackTechnique`, `risks.EcosystemStakeholder`, `risks.FearedEvent`, `risks.ISO27005Risk`, `risks.OperationalScenario`, `risks.RiskCriteria`, `risks.RiskSource`, `risks.RiskSourceObjectivePair`, `risks.StrategicScenario`, `risks.TargetedObjective`, `risks.Threat`. diff --git a/docs/reference/generated/models.md b/docs/reference/generated/models.md index bb0f5b3c..05e9e650 100644 --- a/docs/reference/generated/models.md +++ b/docs/reference/generated/models.md @@ -48,7 +48,7 @@ The field-by-field contract of each entity lives in its [specification](../../sp | compliance | `AssessmentResult` | `compliance_assessmentresult` | - | - | - | yes | 12 | | compliance | `ComplianceActionPlan` | `compliance_complianceactionplan` | `CAPL` | `action_plan` | yes | yes | 19 | | compliance | `ComplianceAssessment` | `compliance_complianceassessment` | `CAST` | `compliance_assessment` | yes | yes | 24 | -| compliance | `Finding` | `compliance_finding` | - | `default` | - | yes | 18 | +| compliance | `Finding` | `compliance_finding` | - | `default` | - | yes | 19 | | compliance | `Framework` | `compliance_framework` | `FWRK` | `default` | yes | yes | 32 | | compliance | `RequirementMapping` | `compliance_requirementmapping` | - | - | - | yes | 10 | | compliance | `Requirement` | `compliance_requirement` | `REQT` | `default` | - | yes | 27 | diff --git a/docs/reference/generated/permissions.md b/docs/reference/generated/permissions.md index f7addd2e..26015872 100644 --- a/docs/reference/generated/permissions.md +++ b/docs/reference/generated/permissions.md @@ -6,7 +6,7 @@ Cairn does not use Django's per-model `add`/`change`/`delete` permissions. It declares its own flat codenames shaped `module.feature.action`, and every web view, REST endpoint and MCP tool is gated on one of them. -There are **321 permissions** across **9 modules**. They are created by a data migration from the registry below, so adding an entry there and migrating is the whole of adding a permission. +There are **331 permissions** across **10 modules**. They are created by a data migration from the registry below, so adding an entry there and migrating is the whole of adding a permission. ## Actions @@ -112,6 +112,13 @@ There are **321 permissions** across **9 modules**. They are created by a data m | `policy` | Documents | `documents.policy.create`, `documents.policy.read`, `documents.policy.update`, `documents.policy.delete`, `documents.policy.approve` | | `acknowledgement` | Acknowledgement campaigns | `documents.acknowledgement.create`, `documents.acknowledgement.read`, `documents.acknowledgement.update`, `documents.acknowledgement.delete`, `documents.acknowledgement.approve` | +## Controls (`controls`) + +| Feature | Label | Codenames | +| --- | --- | --- | +| `control` | Controls | `controls.control.create`, `controls.control.read`, `controls.control.update`, `controls.control.delete`, `controls.control.approve` | +| `test` | Control tests | `controls.test.create`, `controls.test.read`, `controls.test.update`, `controls.test.delete`, `controls.test.approve` | + ## Reports (`reports`) | Feature | Label | Codenames | @@ -152,9 +159,9 @@ Six groups ship with the platform and are kept in sync by the same data migratio | Group | Permissions | Description | | --- | --- | --- | -| Super Administrateur | 321 | Full technical administration of the platform. All permissions. | -| Administrateur | 320 | Full functional administration. All permissions except Django admin access. | -| RSSI / DPO | 255 | GRC system steering. Read, create, update, approve. No deletion or system configuration. | -| Auditeur | 76 | Platform consultation and audit. Read-only access to all modules. | -| Contributeur | 185 | GRC content contribution. Read, create, update. No deletion or system access. | -| Lecteur | 68 | Read-only access. Read on all modules except system. | +| Super Administrateur | 331 | Full technical administration of the platform. All permissions. | +| Administrateur | 330 | Full functional administration. All permissions except Django admin access. | +| RSSI / DPO | 263 | GRC system steering. Read, create, update, approve. No deletion or system configuration. | +| Auditeur | 78 | Platform consultation and audit. Read-only access to all modules. | +| Contributeur | 191 | GRC content contribution. Read, create, update. No deletion or system access. | +| Lecteur | 70 | Read-only access. Read on all modules except system. | diff --git a/docs/sdk/ui-components.md b/docs/sdk/ui-components.md index 12891fb7..9a062b8b 100644 --- a/docs/sdk/ui-components.md +++ b/docs/sdk/ui-components.md @@ -327,7 +327,8 @@ Every badge is a `.badge-dot` pill, in a table, a card or a header alike. Tones come from `BADGE_REGISTRY` in `core/templatetags/ui.py` : - `level` : priority, criticality, severity and impact share one ordinal scale. -- `finding` and `effectiveness` : audit findings and corrective action effectiveness. +- `finding` and `effectiveness` : audit findings, corrective action effectiveness and control effectiveness (with `not_tested`). +- `run_result` : the result of a control test run (`pass`, `partial`, `fail`). - `tlp`, `notification_decision`, `triage_decision`, `response_action_status`, `timeline_entry_type`, `filing_outcome` : incident response. - `issue_status` and `objective_status` : the context module's own status fields. - `access_event` and `feedback_rating` : the access log and assistant feedback. diff --git a/docs/specs/m8-controls/README.md b/docs/specs/m8-controls/README.md index ab7f93d6..0df3100f 100644 --- a/docs/specs/m8-controls/README.md +++ b/docs/specs/m8-controls/README.md @@ -188,7 +188,7 @@ The gates and side effects live in each model's `transition_to()`, inside one `t | `controls.test.approve` | Activate / retire a test, validate / send back / withdraw a run, raise a finding from a run | | `controls.test.delete` | Delete a draft test or a draft run | -System groups : Super Administrateur and Administrateur get every codename; RSSI / DPO get read, create, update, approve; Contributeur gets read, create, update; Auditeur gets read plus `controls.test.create` and `controls.test.update` (auditors record test runs; validation stays with the second line); Lecteur gets read. A migration creates the rows and attaches them. +System groups : Super Administrateur and Administrateur get every codename; RSSI / DPO get read, create, update, approve; Contributeur gets read, create, update; Auditeur and Lecteur get read (an auditor who records runs is given the Contributeur group as well, so the read-only meaning of the Auditeur group is kept). A migration creates the rows and attaches them. --- @@ -240,7 +240,7 @@ New `NotificationType` values with `notify_*` helpers in `accounts/notifications | `CONTROL_TEST_OVERDUE` : same sweep, due date passed (sent once, then weekly) | The tester and the control owner | In-app, email | | `CONTROL_TEST_FAILED` : a `partial` or `fail` run is validated | The control owner | In-app, email | -Submission of a run sends the existing `LIFECYCLE_SUBMITTED` notification (RG-LC-06) to holders of `controls.test.approve` in scope, excluding the performer. +Submission of a run sends the existing `LIFECYCLE_SUBMITTED` notification (RG-LC-06) through the RG-LC-09 recipient chain, which resolves to the holders of `controls.test.approve`, excluding the submitter. --- diff --git a/risks/lifecycles.py b/risks/lifecycles.py index 9ce52a9f..e805a5ed 100644 --- a/risks/lifecycles.py +++ b/risks/lifecycles.py @@ -202,9 +202,11 @@ ] _EBIOS_PACS_MEASURE_STATE_FLAGS = { - PACSMeasureStatus.PLANNED: (True, False, True, True, False, "info"), - PACSMeasureStatus.IN_PROGRESS: (True, False, False, False, False, "primary"), - PACSMeasureStatus.OVERDUE: (True, False, False, False, False, "danger"), + # Linkable while the measure is under way, like a treatment plan : a + # register control can then name the PACS measure that creates it. + PACSMeasureStatus.PLANNED: (True, True, True, True, False, "info"), + PACSMeasureStatus.IN_PROGRESS: (True, True, False, False, False, "primary"), + PACSMeasureStatus.OVERDUE: (True, True, False, False, False, "danger"), PACSMeasureStatus.COMPLETED: (True, False, False, False, True, "success"), PACSMeasureStatus.CANCELLED: (False, False, False, False, True, "danger"), } From 80e2230ee46d46fae54c2a9d983a2f7a3780ce3e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 11:28:10 +0100 Subject: [PATCH 3/9] docs(specs): specify the training and business continuity modules Module 9 (training and awareness): a course catalogue with validity, competencies required by roles and groups, training campaigns with write-once enrolments (trainer recording, self-attestation, CSV import with a dry run) and the derived competency matrix, on an audience engine extracted from the documents acknowledgement campaigns. Module 10 (business continuity): business impact analyses with impact grids and MTD / RTO / RPO written to the essential assets on validation, continuity plans with ordered recovery steps and a feasibility check against the covered RTOs, exercises that move the next exercise date and can raise a finding, and plan activations from an incident with a write-once log. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FaEkJnhS3VNDeb6jrdn6MC --- docs/specs/README.md | 2 + docs/specs/m10-continuity/README.md | 257 ++++++++++++++++++ docs/specs/m10-continuity/bia-entry.md | 36 +++ .../business-impact-analysis.md | 31 +++ .../m10-continuity/continuity-exercise.md | 36 +++ docs/specs/m10-continuity/continuity-plan.md | 35 +++ docs/specs/m10-continuity/plan-activation.md | 40 +++ docs/specs/m10-continuity/recovery-step.md | 20 ++ docs/specs/m9-training/README.md | 256 +++++++++++++++++ docs/specs/m9-training/audience-engine.md | 42 +++ docs/specs/m9-training/competency.md | 32 +++ docs/specs/m9-training/training-campaign.md | 30 ++ docs/specs/m9-training/training-course.md | 40 +++ docs/specs/m9-training/training-enrolment.md | 35 +++ 14 files changed, 892 insertions(+) create mode 100644 docs/specs/m10-continuity/README.md create mode 100644 docs/specs/m10-continuity/bia-entry.md create mode 100644 docs/specs/m10-continuity/business-impact-analysis.md create mode 100644 docs/specs/m10-continuity/continuity-exercise.md create mode 100644 docs/specs/m10-continuity/continuity-plan.md create mode 100644 docs/specs/m10-continuity/plan-activation.md create mode 100644 docs/specs/m10-continuity/recovery-step.md create mode 100644 docs/specs/m9-training/README.md create mode 100644 docs/specs/m9-training/audience-engine.md create mode 100644 docs/specs/m9-training/competency.md create mode 100644 docs/specs/m9-training/training-campaign.md create mode 100644 docs/specs/m9-training/training-course.md create mode 100644 docs/specs/m9-training/training-enrolment.md diff --git a/docs/specs/README.md b/docs/specs/README.md index 4aa5784f..50d39b37 100644 --- a/docs/specs/README.md +++ b/docs/specs/README.md @@ -16,6 +16,8 @@ This directory is the single source of truth for what each Cairn module does and | [Module 6 : incidents](m6-incidents/README.md) | Events, incidents, evidence and custody, notification obligations and filings, breaches, post-incident reviews | | [Module 7 : documents](m7-documents/README.md) | Policies, procedures, standards and charters : versions, approval, review cycle, acknowledgement campaigns | | [Module 8 : controls](m8-controls/README.md) | Security controls, their links to requirements, risks and assets, effectiveness tests, runs and evidence | +| [Module 9 : training](m9-training/README.md) | Course catalogue, competencies by role, training campaigns and completions, CSV import, the competency matrix | +| [Module 10 : continuity](m10-continuity/README.md) | Business impact analyses, continuity and recovery plans, exercises, plan activations and their log | | [Management review](management-review/README.md) | The ISO 27001 clause 9.3 entities | | [Assistant](assistant/README.md) | Ask Cairn : the optional question mode and its pluggable provider | diff --git a/docs/specs/m10-continuity/README.md b/docs/specs/m10-continuity/README.md new file mode 100644 index 00000000..8697dfcb --- /dev/null +++ b/docs/specs/m10-continuity/README.md @@ -0,0 +1,257 @@ +# Module 10 : Business continuity + +## Functional and technical specification + +The register of the organisation's **business continuity and ICT readiness** (ISO/IEC 27001:2022 A.5.29 information security during disruption, A.5.30 ICT readiness for business continuity, A.8.13 / A.8.14 backup and redundancy; ISO 22301 clause 8.2 to 8.5; NIS2 article 21.2 c; DORA articles 11 and 12). It answers the questions an auditor asks : *which activities are critical and how fast must they come back, which plan brings them back, was the plan exercised and did it meet its objectives, and what happened when it was really used.* + +**Status : proposed for v0.37.0.** + +## Entities + +| Entity | Class | Reference prefix | Lifecycle | +| --- | --- | --- | --- | +| [BusinessImpactAnalysis](business-impact-analysis.md) | `continuity.models.bia.BusinessImpactAnalysis` | `BIAN` | `business_impact_analysis` | +| [BiaEntry](bia-entry.md) | `continuity.models.bia.BiaEntry` | none | none (child of the analysis) | +| [ContinuityPlan](continuity-plan.md) | `continuity.models.plan.ContinuityPlan` | `BCPL` | `continuity_plan` | +| [RecoveryStep](recovery-step.md) | `continuity.models.plan.RecoveryStep` | none | none (child of the plan) | +| [ContinuityExercise](continuity-exercise.md) | `continuity.models.exercise.ContinuityExercise` | `BCEX` | `continuity_exercise` | +| [PlanActivation](plan-activation.md) | `continuity.models.activation.PlanActivation` | `BCAC` | `plan_activation` | +| [ActivationLogEntry](plan-activation.md#activationlogentry) | `continuity.models.activation.ActivationLogEntry` | none | none (write-once record) | + +--- + +## 1. Overview + +### 1.1 Purpose + +Cairn knows the organisation's activities (`context.Activity`), its essential and support assets and their dependencies, its sites and suppliers, and its incidents. Essential assets carry MTD / RTO / RPO, but as free text nobody validated, with no impact analysis behind them and no plan, exercise or activation using them. + +This module adds the continuity cycle : + +- a **business impact analysis** (BIA) assesses, for each critical activity or essential asset, the impact of a disruption over time (financial, operational, legal and regulatory, reputational, safety), and sets its **MTD**, **RTO**, **RPO** and minimum service level, with its dependencies; it is reviewed and validated as a whole, and its validated values become the reference figures of the assets; +- a **continuity plan** (business continuity, disaster recovery, crisis management, ICT continuity) covers activities, assets and sites with a strategy, activation criteria and ordered **recovery steps** owned by roles, each with a target time; it is approved before use and revised through its lifecycle; +- **exercises** test a plan (tabletop, walkthrough, simulation, technical recovery, full interruption), record the achieved recovery time against the objective, the outcome and the lessons learned, and can raise a finding; +- a plan **activation**, possibly from a major incident, opens a timestamped, write-once **activation log** until the plan is stood down. + +### 1.2 Functional scope + +| Area | In v0.37.0 | +| --- | --- | +| BIA with entries per activity or essential asset : impacts per category and time window, MTD / RTO / RPO in hours, minimum service level, dependencies (support assets, suppliers, sites), validation | yes | +| Validated BIA values written to the essential assets' MTD / RTO / RPO, as a recorded change | yes | +| Continuity plans with type, coverage, strategy, activation criteria, ordered recovery steps owned by roles, linked formal document, exercise frequency | yes | +| Feasibility check : a plan whose recovery time exceeds a covered RTO is flagged | yes | +| Exercises with type, scenario, participants, achieved recovery time, outcome, lessons learned; raising a finding | yes | +| Plan activation from a plan or from an incident, write-once activation log, stand-down | yes | +| REST API, MCP tools, global search, calendar, dashboard widget, kanban, predefined indicators, help banners, demo seed | yes | +| Crisis communication tooling (mass notification, call trees) | no (see §11) | +| Per-step exercise results and timings | no (see §11) | +| Automatic import of BIA from a questionnaire campaign | no (see §11) | + +### 1.3 Dependencies on other modules + +| Target module | Nature of the dependency | +| --- | --- | +| Context | `ScopedModel`, `Scope`, `Activity` (BIA entries, plan coverage), `Role` (recovery step owners), `Site`. | +| Assets | `EssentialAsset` (BIA entries, plan coverage, MTD / RTO / RPO written on validation), `SupportAsset` and `Supplier` (dependencies, plan coverage). | +| Documents | `Policy` (of type `plan`) through `ContinuityPlan.document` : the formal, approved plan document. | +| Incidents | `Incident` through `PlanActivation.incident`; the incident page lists and starts activations. | +| Compliance | `Requirement` through `ContinuityPlan.requirements`; `Finding.continuity_exercise` (FK `SET_NULL`) and `FindingSource.CONTINUITY_EXERCISE`. | +| Controls | `Control.continuity_plans` is not added; a control such as *"Daily off-site backups"* links to the support assets the plan covers. | + +--- + +## 2. Business rules : the RG-BCM register + +### 2.1 Business impact analysis + +| ID | Rule | +| --- | --- | +| RG-BCM-01 | A [BIA](business-impact-analysis.md) holds one [entry](bia-entry.md) per assessed activity or essential asset; an entry targets **exactly one** of them, and the same target appears at most once per analysis. | +| RG-BCM-02 | Impacts are scored 1 (negligible) to 4 (critical) for each category (`financial`, `operational`, `legal`, `reputational`, `safety`) at each time window of the analysis (default `4h`, `24h`, `72h`, `1w`, `1m`). A score never decreases as the window grows (an impact gets worse with time, never better); the form and the API refuse a decreasing series. | +| RG-BCM-03 | MTD, RTO and RPO are stored as **hours** (decimal, >= 0). `RTO <= MTD` always; an entry without MTD is incomplete. The *suggested MTD* is the start of the first window where any category reaches 3 (shown, never imposed). | +| RG-BCM-04 | Entries are editable only while the analysis is `draft`. Submitting requires at least one entry and every entry complete (MTD, RTO, RPO set). | +| RG-BCM-05 | Validating an analysis (`approve`) writes, for each entry targeting an essential asset, its MTD / RTO / RPO to the asset's existing fields, formatted (`"24 h"`, `"3 days"`), as a recorded save with the change reason *"From BIAN-n"*. Activities carry no such fields; their figures are read from the latest validated analysis. | +| RG-BCM-06 | Validating an analysis archives the previously validated analysis **of the same scopes** (label *Superseded*), in the same transaction : at most one validated BIA per scope set. | + +### 2.2 Continuity plans + +| ID | Rule | +| --- | --- | +| RG-BCM-07 | A [plan](continuity-plan.md) covers at least one activity, essential asset, support asset or site. Its [recovery steps](recovery-step.md) are ordered; each has a title, an owning role and a target time in minutes from activation. | +| RG-BCM-08 | Steps and coverage are editable only while the plan is `draft`. An `active` plan is changed by **Revise** (`active -> draft`, comment required), then approved again; the history records both. | +| RG-BCM-09 | Approving a plan requires at least one step and its coverage. Approval stamps `approved_by` / `approved_at` and computes `next_exercise_due` = approval date + `exercise_frequency_months` when the plan was never exercised. | +| RG-BCM-10 | **Feasibility** : `recovery_minutes` (the latest step target) is compared with the smallest RTO of the covered targets in the latest validated BIA. A plan whose recovery time exceeds it is flagged *RTO not met* on its page, its list and the dashboard; approval is not refused (the gap is a risk to treat, not a data error). | +| RG-BCM-11 | Retiring a plan requires a comment and is refused while an activation of it is `active`. | + +### 2.3 Exercises + +| ID | Rule | +| --- | --- | +| RG-BCM-12 | An [exercise](continuity-exercise.md) tests one `active` plan. Its results (conducted on, achieved recovery minutes, outcome, lessons learned) are recorded in `planned`, then frozen by **Record results** (`planned -> conducted`); it is then validated by a holder of `continuity.exercise.approve`. | +| RG-BCM-13 | Validating an exercise stamps the plan's `last_exercised_on` and recomputes `next_exercise_due` = conducted on + `exercise_frequency_months`. A plan is **exercise overdue** when it is active and `next_exercise_due < today` (derived, never a step). | +| RG-BCM-14 | `objective_met` is derived : achieved recovery minutes <= the plan's smallest covered RTO (in minutes), when both are known. | +| RG-BCM-15 | "Raise a finding" on a validated exercise whose outcome is `partial` or `fail` creates one [Finding](../m3-compliance/finding.md) with `source = continuity_exercise`, pre-filled from the lessons learned, as RG-CTL-16 does for control tests. | + +### 2.4 Activations + +| ID | Rule | +| --- | --- | +| RG-BCM-16 | A plan is **activated** (an [activation](plan-activation.md) created in `active`) by a holder of `continuity.activation.create`, from the plan page or from an incident's page (`incident` then set). The plan must be `active`. Several plans may be active for one incident; one plan has at most one `active` activation. | +| RG-BCM-17 | The **activation log** is write-once : each entry (timestamp, author, text, optional recovery step reached) is appended and never edited or deleted. Activation, each step reached and stand-down append entries automatically. | +| RG-BCM-18 | **Stand down** (`active -> stood_down`, comment required) stamps `stood_down_at` / `stood_down_by` and the achieved recovery minutes (activation to the last step reached); the activation then counts in the plan's history like an exercise for feasibility reading, but does **not** reset `next_exercise_due`. | +| RG-BCM-19 | Activation notifies the plan owner and the assigned users of every step's owning role (`PLAN_ACTIVATED`). | + +### 2.5 Integration + +| ID | Rule | +| --- | --- | +| RG-BCM-20 | Every report, KPI, calendar feed and picker filters through `reportable()` / `linkable()` / `linkable_or_linked()`. | +| RG-BCM-21 | `BusinessImpactAnalysis` and `ContinuityPlan` are `ScopedModel`s; entries, steps, exercises, activations and log entries inherit through `scope_parent_lookup` (`analysis__scopes`, `plan__scopes`, `activation__plan__scopes`). | +| RG-BCM-22 | The essential asset, activity, support asset, site and incident pages gain a *Continuity* card (latest validated BIA figures, covering plans, activations). | + +--- + +## 3. Lifecycles + +Generated from `continuity/constants.py`, registered in `continuity/lifecycles.py`. Exit steps keep the code `archived`. + +### 3.1 `business_impact_analysis` + +| Code | Label | In reports | Linkable | Deletable | Tone | +| --- | --- | --- | --- | --- | --- | +| `draft` | Draft | no | no | **yes** | neutral | +| `in_review` | In review | no | no | no | info | +| `validated` | Validated | **yes** | **yes** | no | success | +| `archived` | Superseded | no | no | no | muted | + +Submit (`update`), Send back (`approve`, comment), Validate (`approve` : RG-BCM-05 / RG-BCM-06), Archive (`validated -> archived`, `approve`, comment; also the system path of RG-BCM-06). + +### 3.2 `continuity_plan` + +| Code | Label | In reports | Linkable | Deletable | Tone | +| --- | --- | --- | --- | --- | --- | +| `draft` | Draft | no | no | **yes** | neutral | +| `in_review` | In review | no | no | no | info | +| `active` | Active | **yes** | **yes** | no | success | +| `archived` | Retired | no | no | no | muted | + +Submit (`update`, RG-BCM-09 gate), Send back (`approve`, comment), Approve (`approve`, stamps), Revise (`active -> draft`, `update`, comment), Retire (`active -> archived`, `approve`, comment, RG-BCM-11; `draft -> archived`, `update`, comment). + +### 3.3 `continuity_exercise` + +| Code | Label | In reports | Linkable | Deletable | Tone | +| --- | --- | --- | --- | --- | --- | +| `draft` | Planned | no | no | **yes** | neutral | +| `conducted` | Conducted | no | no | no | info | +| `validated` | Validated | **yes** | no | no | success | +| `archived` | Cancelled | no | no | no | muted | + +Record results (`draft -> conducted`, `update` : conducted on and outcome required), Send back (`conducted -> draft`, `approve`, comment), Validate (`approve`, RG-BCM-13), Cancel (`draft -> archived`, `update`, comment). + +### 3.4 `plan_activation` + +| Code | Label | In reports | Linkable | Deletable | Tone | +| --- | --- | --- | --- | --- | --- | +| `active` | Active (initial) | **yes** | no | no | danger | +| `stood_down` | Stood down | **yes** | no | no | success | +| `archived` | Archived | no | no | no | muted | + +Stand down (`active -> stood_down`, `update`, comment), Archive (`stood_down -> archived`, `approve`, comment). There is no draft : an activation exists from the moment it is declared. + +--- + +## 4. Permissions and access control + +`PERMISSION_REGISTRY` gains a `continuity` module (`MODULE_LABELS["continuity"] = _("Continuity")`) with four features, each with `create`, `read`, `update`, `delete`, `approve` : `continuity.bia`, `continuity.plan`, `continuity.exercise`, `continuity.activation`. Activation is its own feature so crisis staff can be allowed to activate and log without being able to change plans. System groups follow the platform filters. + +--- + +## 5. API + +Base path `/api/v1/continuity/`, house pattern. + +| Path | Notes | +| --- | --- | +| `analyses/`, `analyses/{id}/`, `transition/`, `history/` | Filters `status`, `scope_id`, `owner_id` | +| `bia-entries/`, `bia-entries/{id}/` | Filter `analysis_id`; writable only while the analysis is draft | +| `plans/`, `plans/{id}/`, `transition/`, `history/` | Filters `status`, `plan_type`, `scope_id`, `exercise_overdue`, `rto_not_met`, `activity_id`, `essential_asset_id` | +| `recovery-steps/`, `recovery-steps/{id}/` | Filter `plan_id`; writable only while the plan is draft | +| `exercises/`, `exercises/{id}/`, `transition/`, `raise-finding/` | Filters `plan_id`, `status`, `outcome`, `scheduled_before` | +| `activations/`, `activations/{id}/`, `transition/` | Filters `plan_id`, `incident_id`, `status` | +| `activations/{id}/log/` | `GET` the log, `POST` an entry (`text`, optional `step_id`) | + +--- + +## 6. MCP tools + +`_register_continuity_tools()`, a `TOPIC_CONTINUITY` help topic, the `BIAN` / `BCPL` / `BCEX` / `BCAC` prefixes. + +- `_register_crud` for `business_impact_analysis`, `continuity_plan`, `continuity_exercise`; child tools for BIA entries and recovery steps (`add_bia_entry`, `update_bia_entry`, `delete_bia_entry`, `add_recovery_step`, `update_recovery_step`, `delete_recovery_step`, `reorder_recovery_steps`). +- `get_plan_feasibility(plan_id)` (RG-BCM-10), `list_exercise_overdue_plans()`. +- `activate_continuity_plan(plan_id, reason, incident_id=None)`, `add_activation_log_entry(activation_id, text, step_id=None)`, `stand_down_activation(activation_id, comment)`, `get_activation_log(activation_id)`. +- `raise_finding_from_exercise(exercise_id, finding_type, recommendation="")`. + +--- + +## 7. Notifications + +| Event | Recipients | +| --- | --- | +| `PLAN_ACTIVATED` | Plan owner, assigned users of the steps' roles (RG-BCM-19) | +| `EXERCISE_DUE` : daily sweep, `next_exercise_due` within 30 days, then overdue weekly | Plan owner | +| `BIA_REVIEW_DUE` : daily sweep, validated analysis older than its `review_frequency_months` | Analysis owner | + +Submissions use `LIFECYCLE_SUBMITTED` (RG-LC-06). + +--- + +## 8. User interface + +- **Sidebar** : a new **Continuity** section after Training : *Impact analyses*, *Plans*, *Exercises*, *Activations*. +- **BIA detail** : stepper, rail (stage, entries, critical entries, period). The entries card is a flush table (target, MTD, RTO, RPO, worst impact) and each entry opens a drawer with the impact grid (categories x windows, 1 to 4 selects, the suggested MTD shown live). A heat-map rendering of the grid uses the `ebios_scale` badge tones. +- **Plan detail** : stepper, rail (stage, recovery time vs RTO with the *RTO not met* flag, next exercise, active activation). Cards : strategy and activation criteria, recovery steps (ordered table, drag-free *Move up / Move down* buttons while draft), coverage, document, exercises, activations. An **Activate** button (danger) opens a drawer asking for the reason and the optional incident. +- **Exercise detail** : stepper, results, objective met badge, lessons learned, finding. +- **Activation detail** : a live timeline of the log (append form at the top for holders of `continuity.activation.update`), each step with a *Reached* button, the elapsed time since activation, *Stand down*. +- **Reverse cards** on essential asset, activity, support asset, site and incident pages (RG-BCM-22); the incident page has *Activate a plan*. + +Platform surfaces : search, calendar (exercises scheduled, next exercise due, BIA review), dashboard widget *Continuity* (active activations first, plans RTO not met, exercises overdue), kanban (my planned exercises, my steps in active activations), indicators `plans_exercised_on_time` and `critical_activities_covered`, help banners. + +--- + +## 9. Technical considerations + +### 9.1 App wiring checklist + +- [ ] `INSTALLED_APPS` gains `"continuity"`; lifecycles imported in `ready()`; models, admin, migrations, permission migration, notification types. +- [ ] `Finding.continuity_exercise` and `FindingSource.CONTINUITY_EXERCISE`. +- [ ] Navigation, accent, sidebar; reverse cards; incident integration. +- [ ] Calendar, dashboard widget, kanban, search, indicators. +- [ ] REST, MCP, assistant catalogue, generated reference. +- [ ] `continuity_sweep` scheduled with the other sweeps. +- [ ] Seed : a validated Voltara BIA (grid operations, billing, customer portal, SCADA telemetry), a DRP for the data centre and a crisis plan with steps, a validated exercise (partial, with a finding) and a planned one, a stood-down activation from a seed incident with its log. +- [ ] Translations, help banners, user guide, README, CHANGELOG. + +### 9.2 Audit + +- Activation log entries are append-only and history-tracked; the elapsed times are computed from their timestamps, never typed. +- BIA values reach the assets only through RG-BCM-05, with a change reason naming the analysis. + +--- + +## 10. Acceptance criteria + +- [ ] A BIA entry refuses RTO > MTD and a decreasing impact series; a validated BIA writes the asset figures and supersedes the previous one of the same scopes. +- [ ] An active plan cannot have its steps changed without Revise; its feasibility flag follows the covered RTOs. +- [ ] Validating an exercise moves the next exercise date; a failed exercise raises one finding. +- [ ] An activation from an incident appears on both pages; its log cannot be edited; standing down records the achieved time. +- [ ] Scope tenancy, light / dark, desktop / mobile, translations, generated reference. + +--- + +## 11. Out of scope for v0.37.0 + +- Crisis communication (mass notification, call trees, status pages). +- Per-step timings in exercises (the exercise records the overall achieved recovery time). +- Collecting BIA inputs from activity owners through a questionnaire campaign. +- Supplier continuity attestations and third-party exit plans (DORA article 28), which belong with the supplier register. diff --git a/docs/specs/m10-continuity/bia-entry.md b/docs/specs/m10-continuity/bia-entry.md new file mode 100644 index 00000000..81e9ac9b --- /dev/null +++ b/docs/specs/m10-continuity/bia-entry.md @@ -0,0 +1,36 @@ +# BiaEntry + +`continuity.models.bia.BiaEntry` + +One assessed target of a [BIA](business-impact-analysis.md) : an activity or an essential asset, its impact grid, its recovery objectives and its dependencies. A child row : history-tracked, no lifecycle or reference of its own, editable only while the analysis is in `draft` (RG-BCM-04). + +File: `continuity/models/bia.py` + +Plain `models.Model` with UUID PK, `HistoricalRecords`; scopes through `analysis__scopes`. + +## Fields + +| Field | Type | Constraints | Description | +| --- | --- | --- | --- | +| `analysis` | relation | FK -> analysis, `CASCADE` while draft | Reverse `entries` | +| `activity` | relation | FK -> `context.Activity`, `PROTECT`, optional | Exactly one of `activity` / `essential_asset` (RG-BCM-01) | +| `essential_asset` | relation | FK -> `assets.EssentialAsset`, `PROTECT`, optional | | +| `impacts` | JSON object | `{category: [score per window]}`, scores 1 to 4, non-decreasing (RG-BCM-02) | Categories `financial`, `operational`, `legal`, `reputational`, `safety` | +| `mtd_hours`, `rto_hours`, `rpo_hours` | decimal | >= 0, `rto <= mtd` (RG-BCM-03) | | +| `minimum_service_level` | text | optional | Minimum business continuity objective (MBCO) | +| `peak_periods` | text | optional | When a disruption hurts most | +| `support_assets` | M2M | `assets.SupportAsset` | Dependencies | +| `suppliers` | M2M | `assets.Supplier` | Dependencies | +| `sites` | M2M | `context.Site` | Dependencies | +| `notes` | text | optional | | +| `order` | integer | default 0 | | + +`UniqueConstraint(analysis, activity)` and `(analysis, essential_asset)` (conditions on non-null). + +## Computed + +`worst_impact` (max score), `suggested_mtd_hours` (RG-BCM-03), `is_complete`. + +## References + +- [README.md](README.md) : RG-BCM-01 to RG-BCM-05 diff --git a/docs/specs/m10-continuity/business-impact-analysis.md b/docs/specs/m10-continuity/business-impact-analysis.md new file mode 100644 index 00000000..0e64ca3d --- /dev/null +++ b/docs/specs/m10-continuity/business-impact-analysis.md @@ -0,0 +1,31 @@ +# BusinessImpactAnalysis + +`continuity.models.bia.BusinessImpactAnalysis` + +A business impact analysis over one perimeter : which activities and essential assets are critical, how a disruption hurts over time, and how fast each must come back (ISO 22301 clause 8.2.2; ISO/IEC 27001:2022 A.5.30). It holds [entries](bia-entry.md) and is validated as a whole (RG-BCM-04 to RG-BCM-06). + +File: `continuity/models/bia.py` + +`ScopedModel` : `reference` (prefix **`BIAN`**), `scopes`, `tags`, history. `LIFECYCLE_NAME = "business_impact_analysis"`. `workflow_perm_namespace = "continuity.bia"`. + +## Fields + +| Field | Type | Constraints | Description | +| --- | --- | --- | --- | +| `name` | string | required, max 255 | e.g. `BIA 2026 : Voltara operations` | +| `description` | text | optional | Method, participants, assumptions | +| `owner` | relation | FK -> User, `SET_NULL`, optional | | +| `time_windows` | JSON list | default `["4h", "24h", "72h", "1w", "1m"]`, editable in draft only | Ordered windows of the impact grid; each `h`, `d`, `w` or `m` | +| `review_frequency_months` | integer | 1 to 36, default 12 | Drives `BIA_REVIEW_DUE` | +| `validated_by`, `validated_at` | | not editable | Stamped at validation | +| `superseded_by` | relation | FK -> self, `SET_NULL`, not editable | RG-BCM-06 | +| `workflow_state` | string | default `draft` | | + +## Computed + +- `critical_entries` : entries whose MTD is at most 24 hours. +- `next_review_date` : `validated_at` + `review_frequency_months`. + +## References + +- [BiaEntry](bia-entry.md), [README.md](README.md) : RG-BCM-01 to RG-BCM-06 diff --git a/docs/specs/m10-continuity/continuity-exercise.md b/docs/specs/m10-continuity/continuity-exercise.md new file mode 100644 index 00000000..eb2b143c --- /dev/null +++ b/docs/specs/m10-continuity/continuity-exercise.md @@ -0,0 +1,36 @@ +# ContinuityExercise + +`continuity.models.exercise.ContinuityExercise` + +A test of a [plan](continuity-plan.md) (ISO 22301 clause 8.5; ISO/IEC 27001:2022 A.5.30) : the scenario, who took part, how long recovery took against the objective, the outcome and the lessons learned (RG-BCM-12 to RG-BCM-15). + +File: `continuity/models/exercise.py` + +`BaseModel` : `reference` (prefix **`BCEX`**), history; scopes through `plan__scopes`. `LIFECYCLE_NAME = "continuity_exercise"`. `workflow_perm_namespace = "continuity.exercise"`. + +## Fields + +| Field | Type | Constraints | Description | +| --- | --- | --- | --- | +| `plan` | relation | FK -> plan, `PROTECT` | Must be `active` when created | +| `name` | string | required, max 255 | | +| `exercise_type` | enum | required | `tabletop`, `walkthrough`, `simulation`, `technical_recovery`, `full_interruption` | +| `scheduled_on` | date | required | | +| `facilitator` | relation | FK -> User, `SET_NULL`, optional | | +| `participants` | M2M | User | | +| `scenario`, `objectives` | text | optional | | +| `conducted_on` | date | required to record results, not in the future | | +| `achieved_recovery_minutes` | integer | optional | | +| `outcome` | enum | required to record results | `success`, `partial`, `fail` | +| `lessons_learned` | text | required when `partial` or `fail` | | +| `validated_by`, `validated_at` | | not editable | | + +Results are frozen from `conducted` (RG-BCM-12). Reverse `findings` from `Finding.continuity_exercise`. + +## Computed + +`objective_met` (RG-BCM-14), `needs_finding`. + +## References + +- [README.md](README.md) : RG-BCM-12 to RG-BCM-15 diff --git a/docs/specs/m10-continuity/continuity-plan.md b/docs/specs/m10-continuity/continuity-plan.md new file mode 100644 index 00000000..ecad3b8f --- /dev/null +++ b/docs/specs/m10-continuity/continuity-plan.md @@ -0,0 +1,35 @@ +# ContinuityPlan + +`continuity.models.plan.ContinuityPlan` + +A business continuity, disaster recovery, crisis management or ICT continuity plan : what it covers, the strategy, when to activate it and the ordered [recovery steps](recovery-step.md) (ISO 22301 clause 8.4; ISO/IEC 27001:2022 A.5.29, A.5.30). + +File: `continuity/models/plan.py` + +`ScopedModel` : `reference` (prefix **`BCPL`**), `scopes`, `tags`, history. `LIFECYCLE_NAME = "continuity_plan"`. `workflow_perm_namespace = "continuity.plan"`. + +## Fields + +| Field | Type | Constraints | Description | +| --- | --- | --- | --- | +| `name` | string | required, max 255 | | +| `plan_type` | enum | required | `business_continuity`, `disaster_recovery`, `crisis_management`, `ict_continuity` | +| `owner` | relation | FK -> User, `SET_NULL`, optional | | +| `strategy` | text | optional | The recovery strategy (alternate site, manual workaround, failover...) | +| `activation_criteria` | text | optional | When to activate | +| `activities`, `essential_assets`, `support_assets`, `sites` | M2M | reverse `continuity_plans` | Coverage (RG-BCM-07) | +| `requirements` | M2M | `compliance.Requirement`, reverse `continuity_plans` | A.5.29, A.5.30, NIS2 21.2 c | +| `document` | relation | FK -> `documents.Policy`, `SET_NULL`, optional | The formal plan document | +| `exercise_frequency_months` | integer | 1 to 36, default 12 | | +| `approved_by`, `approved_at` | | not editable | RG-BCM-09 | +| `last_exercised_on` | date | not editable | RG-BCM-13 | +| `next_exercise_due` | date | indexed, not editable | RG-BCM-09 / RG-BCM-13 | +| `workflow_state` | string | default `draft` | | + +## Computed + +`recovery_minutes` (latest step target), `smallest_covered_rto_hours` (latest validated BIA), `rto_not_met` (RG-BCM-10), `is_exercise_overdue` (RG-BCM-13), `active_activation`. + +## References + +- [README.md](README.md) : RG-BCM-07 to RG-BCM-11 diff --git a/docs/specs/m10-continuity/plan-activation.md b/docs/specs/m10-continuity/plan-activation.md new file mode 100644 index 00000000..a83eba2f --- /dev/null +++ b/docs/specs/m10-continuity/plan-activation.md @@ -0,0 +1,40 @@ +# PlanActivation + +`continuity.models.activation.PlanActivation` + +A real use of a [plan](continuity-plan.md), declared from the plan or from an incident, with its write-once log (RG-BCM-16 to RG-BCM-19). + +File: `continuity/models/activation.py` + +`BaseModel` : `reference` (prefix **`BCAC`**), history; scopes through `plan__scopes`. `LIFECYCLE_NAME = "plan_activation"` (initial step `active`). `workflow_perm_namespace = "continuity.activation"`. + +## Fields + +| Field | Type | Constraints | Description | +| --- | --- | --- | --- | +| `plan` | relation | FK -> plan, `PROTECT` | `UniqueConstraint(plan)` where `workflow_state = active` | +| `incident` | relation | FK -> `incidents.Incident`, `SET_NULL`, optional | Reverse `plan_activations` | +| `reason` | text | required | | +| `activated_at`, `activated_by` | | not editable | | +| `stood_down_at`, `stood_down_by` | | not editable | RG-BCM-18 | +| `achieved_recovery_minutes` | integer | not editable | RG-BCM-18 | + +## ActivationLogEntry + +`continuity.models.activation.ActivationLogEntry` : a write-once row (RG-BCM-17). + +| Field | Type | Description | +| --- | --- | --- | +| `id` | UUID | PK | +| `activation` | FK -> activation, `PROTECT` | Reverse `log_entries` | +| `logged_at` | datetime, auto | Never typed | +| `author` | FK -> User, `SET_NULL` | | +| `text` | text | required | +| `step` | FK -> [RecoveryStep](recovery-step.md), `SET_NULL`, optional | The step reached, when the entry records one | +| `kind` | enum | `activated`, `note`, `step_reached`, `stood_down` | + +`save()` refuses any change to an existing row; `delete()` is refused. + +## References + +- [README.md](README.md) : RG-BCM-16 to RG-BCM-19 diff --git a/docs/specs/m10-continuity/recovery-step.md b/docs/specs/m10-continuity/recovery-step.md new file mode 100644 index 00000000..bc4f1edb --- /dev/null +++ b/docs/specs/m10-continuity/recovery-step.md @@ -0,0 +1,20 @@ +# RecoveryStep + +`continuity.models.plan.RecoveryStep` + +One ordered step of a [plan](continuity-plan.md) : what to do, who owns it, by when after activation. Child row, history-tracked, editable only while the plan is `draft` (RG-BCM-08). + +## Fields + +| Field | Type | Constraints | Description | +| --- | --- | --- | --- | +| `plan` | relation | FK -> plan, `CASCADE` while draft | Reverse `steps` | +| `order` | integer | | Position | +| `title` | string | required, max 255 | | +| `description` | text | optional | | +| `owner_role` | relation | FK -> `context.Role`, `PROTECT` | Notified on activation (RG-BCM-19) | +| `target_minutes` | integer | >= 0 | Target time from activation; non-decreasing with `order` | + +## References + +- [README.md](README.md) : RG-BCM-07, RG-BCM-08, RG-BCM-10 diff --git a/docs/specs/m9-training/README.md b/docs/specs/m9-training/README.md new file mode 100644 index 00000000..008884b0 --- /dev/null +++ b/docs/specs/m9-training/README.md @@ -0,0 +1,256 @@ +# Module 9 : Training and awareness + +## Functional and technical specification + +The register of the organisation's **competence and awareness** obligations (ISO/IEC 27001:2022 clause 7.2 competence, clause 7.3 awareness, Annex A.6.3 information security awareness, education and training; NIS2 article 20.2 management body training and article 21.2 g basic cyber hygiene and training). It answers the questions an auditor asks of clause 7.2 and 7.3 : *what competence does each role need, who has it, who was trained on what and when, who is out of date, and how do you know.* + +**Status : proposed for v0.37.0.** + +## Entities + +| Entity | Class | Reference prefix | Lifecycle | +| --- | --- | --- | --- | +| [TrainingCourse](training-course.md) | `training.models.course.TrainingCourse` | `CRSE` | `training_course` | +| [Competency](competency.md) | `training.models.competency.Competency` | `CMPT` | `competency` | +| [TrainingCampaign](training-campaign.md) | `training.models.campaign.TrainingCampaign` | `TRCA` | `training_campaign` | +| [TrainingEnrolment](training-enrolment.md) | `training.models.enrolment.TrainingEnrolment` | none | none (write-once record) | +| [Audience engine](audience-engine.md) | `core.audience.AudienceCampaign` / `core.audience.AudienceRow` | n/a | abstract, shared with module 7 | + +--- + +## 1. Overview + +### 1.1 Purpose + +Clause 7.2 asks the organisation to *determine the necessary competence* of the people doing work that affects information security, *ensure they are competent* on the basis of education, training or experience, *take actions to acquire it* and *retain documented evidence*. Clause 7.3 asks that people be aware of the policy, their contribution and the implications of not conforming. Today Cairn records who acknowledged a policy (module 7), but not who was trained, on what, whether it is still valid, nor what each role requires. + +This module adds : + +- a **course catalogue** : awareness modules, phishing simulations, technical and regulatory trainings, with their delivery mode, duration, provider and **validity** (how long a completion counts before renewal); +- **competencies** : what a role requires (for instance *"Secure development"* for the developer role, *"Incident handling"* for the on-call role, *"Annual security awareness"* for everyone), each satisfied by completing one of several courses; +- **training campaigns** : one course delivered to a defined audience (users, groups, roles) by a due date, with one **enrolment** row per person, resolved once at launch; completions recorded by the trainer, self-attested when the course allows it, or **imported** from an external platform (LMS, phishing simulation tool) as CSV; +- the **competency matrix** : for every person in scope, each required competency is *up to date*, *expiring*, *expired* or *missing*, derived from their completions and the courses' validity; +- reminders, a personal *My trainings* page, KPIs, and the management review figures. + +The audience mechanics (audience resolved once at launch into write-once evidence rows, newcomers added on demand, completion rate, reminders) are those of the documents module's acknowledgement campaigns. They are extracted into a shared **audience engine** used by both modules (see [audience-engine.md](audience-engine.md)), with no behaviour change for module 7. + +### 1.2 Functional scope + +| Area | In v0.37.0 | +| --- | --- | +| Course catalogue : type, delivery mode, duration, provider, validity, self-attestation flag, pass mark, linked requirements and policies | yes | +| Competencies : required for roles and groups, satisfied by one of several courses | yes | +| Training campaigns over users, groups and roles; enrolments; trainer recording; self-attestation; excuse with reason; reminders; completion rate | yes | +| CSV import of completions (email, completion date, score, result) into a campaign, with a dry-run report | yes | +| Competency matrix (per person, per role, per competency) with up to date / expiring / expired / missing | yes | +| *My trainings* page : my pending enrolments, my competencies and their validity | yes | +| Shared audience engine, adopted by the documents acknowledgement campaigns | yes | +| Management review : awareness figures (completion rate, competency coverage, overdue renewals) | yes | +| REST API, MCP tools, global search, calendar, dashboard widget, kanban, predefined indicators, help banners, demo seed | yes | +| Hosting course content (videos, quizzes, SCORM packages) | no (see §11) | +| Live integration with an LMS or a phishing platform (API pull) | no (see §11) | +| Individual training plans and budgets | no (see §11) | + +### 1.3 Dependencies on other modules + +| Target module | Nature of the dependency | +| --- | --- | +| Accounts | `User` (addressees, trainers, owners), `Group` (audience, competency holders), `accounts.Notification`, `PERMISSION_REGISTRY`. | +| Context | `ScopedModel`, `Scope`, `Tag`, `Role` (`Role.assigned_users`) as audience and as competency holder. | +| Compliance | `Requirement` through `TrainingCourse.requirements` (A.6.3, clause 7.2 / 7.3, NIS2 article 20.2). | +| Documents | `Policy` through `TrainingCourse.policies` (the charter or policy a course teaches); the acknowledgement campaigns adopt the shared audience engine. | +| Assets | `Supplier` through `TrainingCourse.provider_supplier` (an external training provider). | +| Management review | A *Training and awareness* card on the review (RG-TRN-20). | + +--- + +## 2. Business rules : the RG-TRN register + +### 2.1 Catalogue and competencies + +| ID | Rule | +| --- | --- | +| RG-TRN-01 | A [course](training-course.md) is offered (campaigns can be launched for it) only when `active`. Retiring a course requires a comment; its campaigns and completions stay readable and keep counting until their validity ends. | +| RG-TRN-02 | `validity_months` (optional, 1 to 60) is how long a completion of the course satisfies a competency. Empty means the completion never expires (an onboarding course taken once). | +| RG-TRN-03 | A [competency](competency.md) is **required** for the union of its roles' assigned users and its groups' members (active users only). It is **satisfied** by a completed enrolment, in any campaign, of any of its `satisfied_by` courses. A competency with no satisfying course cannot be activated. | +| RG-TRN-04 | A course may be deleted only while `draft` and without campaigns; a competency only while `draft`. | + +### 2.2 Campaigns and enrolments + +| ID | Rule | +| --- | --- | +| RG-TRN-05 | A [campaign](training-campaign.md) delivers **one course**, which must be `active` at launch. Its audience is resolved **once, at launch**, into one [enrolment](training-enrolment.md) per person (the audience engine, [audience-engine.md](audience-engine.md)). "Add newcomers" re-resolves it on demand. The audience is editable only in `draft`. | +| RG-TRN-06 | A campaign has a `due_date`, and optionally a `session_date` / `session_end` and a `location` (room or meeting link) for a scheduled session. Launch is refused when the due date is past or the audience is empty. | +| RG-TRN-07 | An enrolment leaves `pending` along one of the one-way paths, each stamped and irreversible : **completed** (with `completed_on`, optional `score`), **failed** (attended but below the course's pass mark), **excused** (with a mandatory reason), **expired** (the campaign closed while pending). Once it has left `pending`, nothing about the row changes on any surface (write-once, as RG-DOC-16). | +| RG-TRN-08 | Who can complete a row : a holder of `training.campaign.update` (the trainer or campaign manager) records completions for anyone, with `recorded_by` stamped and the channel `trainer`; the addressee themselves can **self-attest** only when the course has `allows_self_attestation`, by confirming a statement snapshotted on the row (channel `self`). An administrator cannot self-attest on someone's behalf. | +| RG-TRN-09 | When the course has a `pass_mark`, a recorded `score` below it makes the row `failed`; a failed person can be enrolled again in a later campaign. Without a pass mark the score is informative. | +| RG-TRN-10 | `completed_on` cannot be in the future, nor more than `PRIOR_COMPLETION_DAYS` (90) before the campaign's launch : a campaign may record a completion achieved shortly before it was launched, typically through an import, but not an old one. | +| RG-TRN-11 | Closing a campaign expires its pending rows and freezes its completion rate : completed / (all - excused). Failed rows count in the denominator. | + +### 2.3 Import + +| ID | Rule | +| --- | --- | +| RG-TRN-12 | Completions can be **imported** from a CSV (columns `email`, `completed_on`, optional `score`, optional `result` `completed` / `failed`) into a **running** campaign, by a holder of `training.campaign.update`. A dry run lists, for every line, the row it matches and the outcome (complete, fail, already done, unknown person, not in the audience, invalid date). Only the confirmation applies it. | +| RG-TRN-13 | An import completes or fails **pending rows only**, with the channel `import`, `recorded_by` the importer and the source file name and its SHA-256 recorded on each row. A person outside the audience is reported, never added implicitly; "Add newcomers" or the audience is how they are brought in. | + +### 2.4 Competency matrix + +| ID | Rule | +| --- | --- | +| RG-TRN-14 | For a person and a required competency, the status is **derived** : `up_to_date` when their latest completion of a satisfying course is still valid (completed_on + validity_months > today, or no validity), `expiring` when it becomes invalid within 60 days, `expired` when it is no longer valid, `missing` when they have none. Never stored, never edited. | +| RG-TRN-15 | Coverage of a competency = up to date or expiring persons / required persons. The matrix can be read per competency, per role, per person, and filtered by scope : a person is in a scope's matrix when they are a member of a role or group assigned to that scope's competencies (competencies are `ScopedModel`s). | +| RG-TRN-16 | Each person sees their own competencies, enrolments and validity on *My trainings*, whatever their permissions (as with *To acknowledge*). | + +### 2.5 Integration + +| ID | Rule | +| --- | --- | +| RG-TRN-17 | Every report, KPI, calendar feed and picker filters through `reportable()` / `linkable()` / `linkable_or_linked()`. | +| RG-TRN-18 | `TrainingCourse`, `Competency` and `TrainingCampaign` are `ScopedModel`s (a campaign defaults to its course's scopes); enrolments inherit through `campaign__scopes`. A user's own enrolments are visible to them regardless of scopes. | +| RG-TRN-19 | The renewal reminder (RG-TRN-14 `expiring`) notifies the person and suggests the running campaigns of a satisfying course, if any. | +| RG-TRN-20 | The management review detail page gains a *Training and awareness* card : campaigns closed in the review period with their completion rate, competency coverage, persons with expired competencies. The figures are read live. | + +--- + +## 3. Lifecycles + +Generated from the transition constants in `training/constants.py`, registered in `training/lifecycles.py`, imported from `TrainingConfig.ready()`. Exit steps keep the platform code `archived` with a domain label. + +### 3.1 `training_course` and `competency` + +| Code | Label | Kind | In reports | Linkable | Deletable | Tone | +| --- | --- | --- | --- | --- | --- | --- | +| `draft` | Draft | DRAFT | no | no | **yes** | neutral | +| `active` | Active | INTERMEDIATE | **yes** | **yes** | no | success | +| `archived` | Retired | ARCHIVED | no | no | no | muted | + +| Verb | Transition | `permission_action` | `requires_comment` | Gate | +| --- | --- | --- | --- | --- | +| Activate | `draft -> active` | `approve` | no | competency : at least one satisfying course (RG-TRN-03) | +| Retire | `active -> archived` | `approve` | **yes** | | +| Retire | `draft -> archived` | `update` | **yes** | | + +### 3.2 `training_campaign` + +Same steps and transitions as `acknowledgement_campaign` (module 7 §3.3) : `draft` (Draft), `running` (Running, reportable), `closed` (Closed, reportable), `archived` (Archived). Launch (`approve`) resolves the audience and notifies the addressees; Close (`update`) expires pending rows; Archive (`approve`, comment). + +--- + +## 4. Permissions and access control + +`PERMISSION_REGISTRY` gains a `training` module (`MODULE_LABELS["training"] = _("Training")`) with three features, each with `create`, `read`, `update`, `delete`, `approve` : + +| Feature | Covers | +| --- | --- | +| `training.course` | The catalogue : create, edit, activate / retire, delete a draft course | +| `training.competency` | Competencies and the matrix (`read` gives the matrix) | +| `training.campaign` | Campaigns, enrolments, recording completions, imports, excusing (`update`); launch and archive (`approve`) | + +Self-attestation on one's own row needs no permission (RG-TRN-08). System groups follow the platform filters (Super Administrateur / Administrateur : all; RSSI / DPO : all but delete; Contributeur : read, create, update; Auditeur / Lecteur : read). + +--- + +## 5. API + +Base path `/api/v1/training/`. House pattern. + +| Method | Path | Description | +| --- | --- | --- | +| `GET` / `POST` | `courses/` | List (filters `status`, `course_type`, `delivery`, `scope_id`) / create | +| `GET` / `PUT` / `PATCH` / `DELETE` | `courses/{id}/` | | +| `GET` / `POST` | `courses/{id}/transition/` | | +| `GET` / `POST` | `competencies/` | List (filters `status`, `role_id`, `group_id`, `scope_id`) / create | +| `GET` / `PUT` / `PATCH` / `DELETE` | `competencies/{id}/` | | +| `GET` / `POST` | `competencies/{id}/transition/` | | +| `GET` | `competencies/{id}/matrix/` | Per required person : status, latest completion, valid until | +| `GET` | `matrix/` | Whole matrix (filters `scope_id`, `role_id`, `user_id`, `status`) | +| `GET` / `POST` | `campaigns/` | List (filters `status`, `course_id`, `due_before`) / create | +| `GET` / `PATCH` / `DELETE` | `campaigns/{id}/` | Edit / delete a draft | +| `GET` / `POST` | `campaigns/{id}/transition/` | Launch, close, archive | +| `POST` | `campaigns/{id}/add-newcomers/` | RG-TRN-05 | +| `POST` | `campaigns/{id}/import/` | RG-TRN-12 : multipart CSV, `dry_run=true|false` | +| `GET` | `enrolments/` | Rows the caller may read, plus their own | +| `GET` | `enrolments/mine/` | The caller's rows, pending first | +| `POST` | `enrolments/{id}/complete/` | Trainer recording (`completed_on`, `score`) | +| `POST` | `enrolments/{id}/self-attest/` | Addressee, when the course allows it | +| `POST` | `enrolments/{id}/excuse/` | With a reason | + +--- + +## 6. MCP tools + +`_register_training_tools()`, a `TOPIC_TRAINING` help topic, the `CRSE` / `CMPT` / `TRCA` prefixes in `HELP_TEXT`. + +- `_register_crud` for `training_course`, `competency`, `training_campaign` (with `m2m_fields` for scopes, requirements, policies, roles, groups, satisfying courses and the audience). +- `record_training_completion(enrolment_id, completed_on, score=None)`, `excuse_enrolment(enrolment_id, reason)`, `add_campaign_newcomers(campaign_id)`. +- `import_training_completions(campaign_id, csv_text, dry_run=True)` (RG-TRN-12; CSV as text). +- `get_competency_matrix(scope_id=None, role_id=None, user_id=None, status=None)`, `get_campaign_progress(campaign_id)` (shared name space : `get_training_campaign_progress`). +- `list_my_trainings()` and `self_attest_training(enrolment_id)`. + +Assistant catalogue : read-only entries for the catalogue, the matrix and campaign progress. + +--- + +## 7. Notifications + +| Event | Recipients | Channel | +| --- | --- | --- | +| `TRAINING_REQUESTED` : launch, or newcomer added | The addressee | In-app, email | +| `TRAINING_REMINDER` : daily sweep, 7 days before `due_date` and the day after it | Addressees with a pending row | In-app, email | +| `COMPETENCY_EXPIRING` : daily sweep, a competency of the person turns `expiring` (once per completion) | The person, and the owner of the competency | In-app, email | + +--- + +## 8. User interface + +- **Sidebar** : a new **Training** section after Documents : *Courses*, *Competencies*, *Campaigns*, *Matrix*. Everyone sees *My trainings* in the user menu, with a count badge of pending enrolments. +- **Course list / detail** : type, delivery, duration, validity, provider, the competencies it satisfies, its campaigns, linked requirements and policies. +- **Competency list / detail** : required for (roles, groups), satisfied by (courses), validity, coverage KPI, the per-person table (status badge, latest completion, valid until). +- **Campaign list / detail** : the documents campaign anatomy (stepper, rail : audience, completed, pending, rate), the enrolment table with filters, *Record completions* (a drawer listing pending rows with a date and score per row, bulk), *Import CSV* (upload, dry-run table, confirm), *Add newcomers*, *Excuse*. +- **Matrix** (`/training/matrix/`) : rows = persons, columns = competencies, cells = status badge; filters on scope, role, status; CSV export. +- **My trainings** : my pending enrolments (with *I have completed this training* when self-attestation is allowed), my competencies with their validity, my history. + +Platform surfaces : global search, calendar (`training` category : campaign due dates and session dates), dashboard widget *Training* (running campaigns with rate, competency coverage, expired competencies), kanban (my pending enrolments in *To do*), predefined indicators `training_completion_rate` and `competency_coverage`, help banners, the management review card (RG-TRN-20). + +--- + +## 9. Technical considerations + +### 9.1 App wiring checklist + +- [ ] The audience engine in `core/audience.py`, adopted by `documents.AcknowledgementCampaign` / `PolicyAcknowledgement` with no behaviour change (the module 7 tests pass unchanged) and a documents migration limited to related-name changes. +- [ ] `INSTALLED_APPS` gains `"training"`; lifecycles imported in `ready()`; models, admin, migrations; permission migration; notification types. +- [ ] Navigation, module accent, sidebar, user menu entry. +- [ ] Calendar, dashboard widget, kanban, search, indicators, management review card. +- [ ] REST, MCP, assistant catalogue; generated reference. +- [ ] `training_sweep` (reminders, expiring competencies) scheduled with the other sweeps. +- [ ] Seed : a Voltara catalogue (annual awareness, phishing simulation, secure development, incident handling, NIS2 management training), competencies on the seed roles, two closed campaigns, one running with mixed rows, one import, expired and expiring competencies. +- [ ] Translations, help banners, user guide, README, CHANGELOG. + +### 9.2 Security and audit + +- The CSV import parses with the standard `csv` module, rejects files over 2 MB or 10 000 lines, never evaluates formulas, and records the file's SHA-256 on every row it changed. +- Enrolment rows are write-once (RG-TRN-07), history-tracked, never deletable while the campaign exists. +- The matrix CSV export escapes cells starting with `=`, `+`, `-`, `@` (formula injection). + +--- + +## 10. Acceptance criteria + +- [ ] Launching a campaign creates exactly one row per resolved addressee; rows are immutable once completed, failed, excused or expired. +- [ ] A self-attestation is refused when the course does not allow it, and from anyone but the addressee. +- [ ] A score below the pass mark fails the row. +- [ ] An import dry run changes nothing; its confirmation completes only pending rows of persons in the audience, and reports the rest. +- [ ] The matrix status follows the latest valid completion of any satisfying course and the course validity. +- [ ] The documents acknowledgement campaigns behave exactly as before on the shared engine. +- [ ] Scope tenancy, light / dark, desktop / mobile, translations, generated reference. + +--- + +## 11. Out of scope for v0.37.0 + +- Hosting course content (videos, quizzes, SCORM / xAPI packages) : Cairn records that training happened, it does not deliver it. +- Pulling completions from an LMS or a phishing platform through their APIs (the CSV import covers the exchange). +- Individual development plans, training budgets and costs. +- Experience and education records as alternative evidence of competence (clause 7.2 b) beyond training; a later *competence evidence* entity can add them. diff --git a/docs/specs/m9-training/audience-engine.md b/docs/specs/m9-training/audience-engine.md new file mode 100644 index 00000000..dfb23e07 --- /dev/null +++ b/docs/specs/m9-training/audience-engine.md @@ -0,0 +1,42 @@ +# Audience engine + +`core.audience.AudienceCampaign` and `core.audience.AudienceRow` + +The shared mechanics of a campaign that asks a defined audience to do something and keeps one write-once evidence row per person. Extracted from the documents acknowledgement campaigns (RG-DOC-15 to RG-DOC-19) so module 9 reuses them unchanged. Two abstract Django models; no table of their own. + +## `AudienceCampaign` (abstract) + +| Field | Type | Description | +| --- | --- | --- | +| `due_date` | date | Required | +| `launched_at`, `closed_at` | datetime, not editable | Stamped by the lifecycle side effects | +| `audience_users` | M2M User, `related_name="%(app_label)s_%(class)s_audience"` | Named addressees | +| `audience_groups` | M2M `accounts.Group`, same pattern | Members at launch | +| `audience_roles` | M2M `context.Role`, same pattern | Assigned users at launch | + +Class attributes set by the concrete model : `row_model` (the `AudienceRow` subclass), `rows_accessor` (its reverse name, `acknowledgements` / `enrolments`), `done_status` (counted as done in the rate), `excused_status`. + +Methods : + +- `resolve_audience()` : active users named, members of the groups, assignees of the roles, de-duplicated. +- `create_rows(users)` : one row per user not already present, with `bulk_create_with_history`. +- `add_newcomers(user)` : running campaigns only; creates the missing rows, records a change reason, calls the concrete `notify_requested(rows)`. +- `launch_rows()` / `expire_pending_rows()` : called by the concrete `transition_to()` on launch / close. +- `counts`, `completion_rate` (done / (all - excused)), `is_overdue`. + +## `AudienceRow` (abstract) + +| Field | Type | Description | +| --- | --- | --- | +| `id` | UUID | PK | +| `user` | FK User, `PROTECT` | Addressee | +| `status` | string, indexed | Choices set by the concrete model; `pending` is the shared initial value | +| `requested_at` | datetime, auto | | +| `last_reminded_at` | datetime, not editable | Set by the sweeps | +| `excused_at`, `excused_by`, `excuse_reason` | | The shared `excuse(user, reason)` path | + +`save()` refuses any change once `status != pending`; `delete()` is refused; `expire()` moves a pending row to `expired`. + +## Adoption by module 7 + +`AcknowledgementCampaign` and `PolicyAcknowledgement` inherit the engine. Field names, statuses and behaviour are unchanged; the only schema change is the related name of the three audience M2Ms (`acknowledgement_campaigns` becomes `documents_acknowledgementcampaign_audience`), which no code reads. The module 7 tests pass unchanged. diff --git a/docs/specs/m9-training/competency.md b/docs/specs/m9-training/competency.md new file mode 100644 index 00000000..a7c3edd5 --- /dev/null +++ b/docs/specs/m9-training/competency.md @@ -0,0 +1,32 @@ +# Competency + +`training.models.competency.Competency` + +A competence the organisation requires (clause 7.2 a) : *"Annual security awareness"* for everyone, *"Secure development"* for developers, *"Incident handling"* for the on-call rota. It is **required** for the members of its roles and groups and **satisfied** by a valid completion of any of its courses (RG-TRN-03). The [matrix](README.md#24-competency-matrix) is derived from it. + +File: `training/models/competency.py` + +`ScopedModel` subclass : `reference` (prefix **`CMPT`**), `scopes`, `tags`, history. `LIFECYCLE_NAME = "competency"`. `workflow_perm_namespace = "training.competency"`. + +## Fields + +| Field | Type | Constraints | Description | +| --- | --- | --- | --- | +| `name` | string | required, max 255 | | +| `description` | text | optional | What a competent person can do | +| `required_for_roles` | M2M | `context.Role`, reverse `required_competencies` | Assigned users of these roles need it | +| `required_for_groups` | M2M | `accounts.Group`, reverse `required_competencies` | Members of these groups need it | +| `satisfied_by` | M2M | [TrainingCourse](training-course.md), reverse `competencies` | Any of these courses satisfies it; required to activate | +| `owner` | relation | FK -> User, `SET_NULL`, optional | Receives the expiring notifications in copy | +| `workflow_state` | string | default `draft` | `competency` lifecycle | + +## Computed + +- `required_users()` : RG-TRN-03 (active users, de-duplicated). +- `status_for(user)` : RG-TRN-14, with the satisfying completion and its `valid_until`. +- `coverage()` : RG-TRN-15. + +## References + +- ISO/IEC 27001:2022 clause 7.2 +- [README.md](README.md) : RG-TRN-03, RG-TRN-14 to RG-TRN-16 diff --git a/docs/specs/m9-training/training-campaign.md b/docs/specs/m9-training/training-campaign.md new file mode 100644 index 00000000..7230c0b4 --- /dev/null +++ b/docs/specs/m9-training/training-campaign.md @@ -0,0 +1,30 @@ +# TrainingCampaign + +`training.models.campaign.TrainingCampaign` + +One [course](training-course.md) delivered to a defined audience by a due date, optionally as a scheduled session. Built on the shared [audience engine](audience-engine.md) : the audience is resolved once at launch into write-once [enrolments](training-enrolment.md) (RG-TRN-05). + +File: `training/models/campaign.py` + +`ScopedModel` + `core.audience.AudienceCampaign` : `reference` (prefix **`TRCA`**), `scopes` (default : the course's), `tags`, history. `LIFECYCLE_NAME = "training_campaign"`. `workflow_perm_namespace = "training.campaign"`. + +## Fields + +| Field | Type | Constraints | Description | +| --- | --- | --- | --- | +| `name` | string | required, max 255 | e.g. `Awareness 2026 : all staff` | +| `course` | relation | FK -> [TrainingCourse](training-course.md), `PROTECT` | Must be `active` at launch | +| `message` | text | optional | Shown to each addressee | +| `session_date`, `session_end` | datetime | optional | A scheduled session (classroom, webinar) | +| `location` | string | optional, max 500 | Room or meeting link | +| `trainer` | relation | FK -> User, `SET_NULL`, optional | | +| `due_date`, `launched_at`, `closed_at` | | | From the audience engine | +| `audience_users`, `audience_groups`, `audience_roles` | M2M | | From the audience engine | + +## Computed + +`counts`, `completion_rate` (RG-TRN-11), `is_overdue`, from the engine. + +## References + +- [README.md](README.md) : RG-TRN-05, RG-TRN-06, RG-TRN-11 to RG-TRN-13 diff --git a/docs/specs/m9-training/training-course.md b/docs/specs/m9-training/training-course.md new file mode 100644 index 00000000..6845d6ef --- /dev/null +++ b/docs/specs/m9-training/training-course.md @@ -0,0 +1,40 @@ +# TrainingCourse + +`training.models.course.TrainingCourse` + +An entry of the training catalogue : an awareness module, a phishing simulation, a technical or regulatory training. It says what is taught, how, for how long, by whom, and how long a completion stays valid (RG-TRN-02). It is delivered through [campaigns](training-campaign.md) and satisfies [competencies](competency.md). + +File: `training/models/course.py` + +`ScopedModel` subclass : UUID PK, `reference` (prefix **`CRSE`**), `scopes`, `tags`, `version`, `created_by`, history. `LIFECYCLE_NAME = "training_course"`. `workflow_perm_namespace = "training.course"`. + +## Fields + +| Field | Type | Constraints | Description | +| --- | --- | --- | --- | +| `title` | string | required, max 255 | e.g. `Annual security awareness` | +| `description` | text | optional | Objectives and content outline | +| `course_type` | enum | required, default `awareness` | `awareness`, `phishing_simulation`, `technical`, `regulatory`, `onboarding`, `management` | +| `delivery` | enum | required, default `elearning` | `elearning`, `classroom`, `webinar`, `simulation`, `reading` | +| `duration_minutes` | integer | optional, 1 to 6000 | Expected duration | +| `provider` | string | optional, max 255 | Internal team or external organisation | +| `provider_supplier` | relation | FK -> `assets.Supplier`, `SET_NULL`, optional | When the provider is a referenced supplier | +| `validity_months` | integer | optional, 1 to 60 | RG-TRN-02 : empty means never expires | +| `pass_mark` | integer | optional, 0 to 100 | RG-TRN-09 | +| `allows_self_attestation` | boolean | default `false` | RG-TRN-08 : addressees may declare completion themselves | +| `attestation_statement` | string | max 500, default *"I have completed {title}."* | Snapshotted on a self-attested row | +| `owner` | relation | FK -> User, `SET_NULL`, optional | Accountable for the course | +| `requirements` | M2M | `compliance.Requirement`, reverse `training_courses` | A.6.3, clause 7.2 / 7.3, NIS2 article 20.2 | +| `policies` | M2M | `documents.Policy`, reverse `training_courses` | The policies or charters the course teaches | +| `workflow_state` | string | default `draft` | `training_course` lifecycle | + +Reverse accessors : `campaigns`, `competencies` (from `Competency.satisfied_by`). + +## Deletion + +Only a `draft` course without campaigns (RG-TRN-04). + +## References + +- ISO/IEC 27001:2022 clause 7.2, 7.3, A.6.3; NIS2 article 20.2, 21.2 g +- [README.md](README.md) : RG-TRN-01, RG-TRN-02, RG-TRN-08, RG-TRN-09 diff --git a/docs/specs/m9-training/training-enrolment.md b/docs/specs/m9-training/training-enrolment.md new file mode 100644 index 00000000..d0fc59b3 --- /dev/null +++ b/docs/specs/m9-training/training-enrolment.md @@ -0,0 +1,35 @@ +# TrainingEnrolment + +`training.models.enrolment.TrainingEnrolment` + +One addressee of one [campaign](training-campaign.md) : the request to complete the course and, once given, the record of the completion. A write-once evidence row on the shared [audience engine](audience-engine.md) (`core.audience.AudienceRow`), like `documents.PolicyAcknowledgement`. + +File: `training/models/enrolment.py` + +Plain model with UUID PK and history; scopes through `campaign__scopes`; the addressee always sees their own rows. + +## Fields + +| Field | Type | Constraints | Description | +| --- | --- | --- | --- | +| `campaign` | relation | FK -> campaign, `PROTECT` | Reverse `enrolments` | +| `user` | relation | FK -> User, `PROTECT` | From the engine | +| `status` | enum | default `pending` | `pending`, `completed`, `failed`, `excused`, `expired` | +| `requested_at`, `last_reminded_at` | datetime | | From the engine | +| `completed_on` | date | not editable | RG-TRN-10 | +| `score` | integer | optional, 0 to 100, not editable | RG-TRN-09 | +| `channel` | enum | not editable | `trainer`, `self`, `import`, `api`, `mcp` | +| `recorded_by` | relation | FK -> User, `SET_NULL`, not editable | Who recorded it (the addressee when self-attested) | +| `statement` | string | max 500, not editable | The attestation confirmed on self-attestation | +| `import_file_name`, `import_sha256` | string | not editable | RG-TRN-13 | +| `excused_at`, `excused_by`, `excuse_reason` | | | RG-TRN-07 | + +`UniqueConstraint(campaign, user)`. + +## Write paths + +`complete(user, completed_on, score=None, channel)`, `self_attest(user)`, `excuse(user, reason)`, `expire()`; `save()` refuses any change once the row has left `pending`, `delete()` is refused. + +## References + +- [README.md](README.md) : RG-TRN-07 to RG-TRN-13 From 67a2b2134d13b2ad9cd4382222d746f66f6ecc72 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 12:02:10 +0100 Subject: [PATCH 4/9] fix(security): sanitize stored rich text at render time and scope incident findings Rich text written by the pell editor reaches the database as submitted (forms, REST and MCP alike) and some forty detail pages, previews and PDF reports rendered it with |safe : any contributor could store markup that ran in a reader's browser. A new |rich filter in the UI framework renders it through the existing allow-list sanitizer, and every user-authored field now uses it; the Statement of Applicability sanitizes the stored justification it embeds. JSON placed inside ')) + assert html == "

A b

" + + def test_keeps_safe_links_only(self): + from core.templatetags.ui import rich + + assert 'href="https://example.org"' in str(rich('x')) + assert "javascript" not in str(rich('x')) + + def test_blank_renders_nothing(self): + from core.templatetags.ui import rich + + assert rich("") == "" + assert rich(None) == "" + + +def test_script_json_cannot_close_its_element(): + import json + + from core.safe_json import dumps_for_script + + text = dumps_for_script([{"label": " & co"}]) + assert "<" not in text and ">" not in text and "&" not in text + assert json.loads(text) == [{"label": " & co"}] diff --git a/docs/sdk/ui-components.md b/docs/sdk/ui-components.md index 9a062b8b..c47f272d 100644 --- a/docs/sdk/ui-components.md +++ b/docs/sdk/ui-components.md @@ -342,6 +342,20 @@ come from `BADGE_REGISTRY` in `core/templatetags/ui.py` : badge colour : add the value to the registry. A lifecycle state is `{% workflow_badge obj %}`, which reads its tone from the lifecycle. +### rich + +```django +
{{ obj.description|rich }}
+``` + +Renders stored rich text (the pell editor's output) sanitized at render time : +only the editor's formatting tags survive, every attribute but a safe +`` is dropped, and scripts, styles, frames and forms are discarded. +Rich text reaches the database unfiltered through forms, the REST API and MCP, +so a user-authored field is **never** rendered with `|safe`. JSON embedded in a +`