diff --git a/CHANGELOG.md b/CHANGELOG.md index f53a63bb..61cd8dbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A documented UI component framework (`detail_layout`, `section_card`, `overview`, `meta_list`, `kpi_tile`, `record_card`, `ref_link`, `tag_list`, `choice_badge`, the dash) with shared page-form and delete shells, catalogued in `docs/sdk/ui-components.md` and on `/styleguide/`. - A Documents module (ISO 27001 clause 5.2, 7.5, A.5.1) : policies, procedures, standards and charters with frozen, approved versions (one in force at a time), a review cycle with reminders, and acknowledgement campaigns whose confirmations are kept as evidence, on the web, the REST API and MCP. +- A Controls module (ISO 27001 clause 6.1.3, 9.1) : security controls linked to requirements, risks, treatment plans, PACS measures, assets, objectives and documents, tests with independently validated runs and evidence, derived effectiveness, findings raised from failed runs, an enriched Statement of Applicability, on the web, the REST API and MCP. ### Changed @@ -21,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Dates follow the active locale everywhere, and short numeric dates put the day first in English as in French. - The lifecycle editor shows its steps, transitions and JSON as stacked cards instead of tabs. - The assessment result toggles read the planning / fieldwork phase from the assessment instead of comparing a status string in the template. +- EBIOS RM workshop 5 measures can be linked while they are planned, in progress or overdue. ### Fixed @@ -40,6 +42,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Tag colours are validated before reaching a style attribute, and Cancel / Back links no longer echo an unchecked Referer header. - The risk scale endpoint no longer answers with the criteria of an assessment outside the user's scopes. - The relation pickers of every list's advanced filter listed related records unscoped, printing the names of records outside the reader's scopes into the page. +- Rich text fields were rendered unsanitized on every detail page and report (stored XSS); they are now sanitized at render time, and JSON embedded in pages can no longer close its script element. +- Nonconformities raised by an incident were visible to every finding reader whatever their scopes, and the MCP finding update and delete tools did not check the caller's scopes. diff --git a/CLAUDE.md b/CLAUDE.md index d0d8ce7e..aef49574 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,7 +137,7 @@ The full mechanics are documented in `docs/technical/documentation.md`. - **Lifecycles govern every domain element**: all `BaseModel` subclasses run a registered lifecycle (`core/lifecycle.py`; default 4-state Draft / Pending / Validated / Archived, specific lifecycles declared per app in `/lifecycles.py` and assigned via `LIFECYCLE_NAME`). Governance is step metadata (`counts_in_reports`, `linkable`, `deletable`): never hardcode status values in reports, pickers or deletion logic - use `reportable()` / `linkable()` / `deletable_states()` and the model properties. New entities with operational stages get a specific lifecycle generated from their transition constants (single source of truth). There is no separate approval axis: validation means reaching a reportable step, gated by the transition's `permission_action`. The canonical spec is `docs/specs/governance/workflow.md` (engine internals in `lifecycle.md`). - **Lifecycle stepper UI for state transitions**: every detail page renders the generic lifecycle stepper: add `LifecycleStepperMixin` (`accounts/mixins.py`) to the DetailView and `{% include "includes/lifecycle_stepper.html" %}` to the template. The context is built from the registered lifecycle (done / current / next / future steps, permission-aware next step, refusal / rework via clickable earlier pills, archived off-ramp, shared comment modal gated by each transition's `requires_comment`). Transitions post to `workflow:transition` by default; views with bespoke side effects set `lifecycle_transition_url_name`. State badges use `{% workflow_badge obj %}`. Never use simple buttons or status selects for lifecycle transitions, and never reintroduce per-page stepper markup. - **Detail page layout - minimize tabs**: Detail pages use `{% detail_layout %}` : a stack of `{% section_card %}` in the main column and a rail of at most four `{% kpi_tile %}` (lifecycle stage first), closed by `{% record_card obj %}`. Tabs hide content and increase cognitive load - use them only when truly necessary (e.g., assessment detail with distinct Planning/Findings/History views); use collapsible section cards instead. Reference `compliance/templates/compliance/action_plan_detail.html` as the canonical example, and `docs/sdk/ui-components.md` for the anatomy. -- **Compose screens from the UI framework**: Templates are assembled from the components in `core/templatetags/ui.py` (`page_header`, `detail_layout`, `section_card`, `overview`/`ov_row`, `meta_list`/`meta_item`, `kpi_tile`, `record_card`, `ref_link`, `tag_list`, `choice_badge`, `dash`/`or_dash`, `back_url`) and the shared shells (`includes/modal_form.html`, `includes/page_form.html`, `includes/confirm_delete_modal.html`, `includes/confirm_delete_page.html`). Never hand-write card, `
`, badge-colour (`{% if status == ... %}` chains) or form-field markup, and never use an inline `style` for a fixed visual choice (use a component or a utility class from `static/css/cairn/10-framework.css`). A missing pattern is added to the framework (tag + template + CSS + test + `docs/sdk/ui-components.md`), then used. The catalogue is `docs/sdk/ui-components.md`; `/styleguide/` renders it live. +- **Compose screens from the UI framework**: Templates are assembled from the components in `core/templatetags/ui.py` (`page_header`, `detail_layout`, `section_card`, `overview`/`ov_row`, `meta_list`/`meta_item`, `kpi_tile`, `record_card`, `ref_link`, `tag_list`, `choice_badge`, `dash`/`or_dash`, `back_url`) and the shared shells (`includes/modal_form.html`, `includes/page_form.html`, `includes/confirm_delete_modal.html`, `includes/confirm_delete_page.html`). Never hand-write card, `
`, badge-colour (`{% if status == ... %}` chains) or form-field markup, and never use an inline `style` for a fixed visual choice (use a component or a utility class from `static/css/cairn/10-framework.css`). Stored rich text is rendered with `|rich` (sanitized at render time), never `|safe`. A missing pattern is added to the framework (tag + template + CSS + test + `docs/sdk/ui-components.md`), then used. The catalogue is `docs/sdk/ui-components.md`; `/styleguide/` renders it live. - **Branch workflow**: All commits must be made on a new branch (never directly on `main`). The only exception is the release version bump: when tagging a release, the CHANGELOG promotion commit goes directly on `main` with the exact message ``Bump version `vX.Y.Z` ``. - **One session = one branch, always**: all the work done in a single session lives on one and the same branch. Create that branch at the first commit and keep committing to it for every subsequent change in the session, even when later requests are unrelated to the first. Never open a second branch or split a session's work across branches/PRs. - **Push as you go**: push the session branch to `origin` after each commit (`git push -u origin ` the first time), so the work is never only local. Do not batch pushes to the end of a session. This is about pushing often, not committing often: keep committing whole, coherent, verified units of work, and never split one into a trail of small tentative commits ("wip", "fix typo", "try again"). If a change is not yet coherent or its tests do not pass, finish it before committing rather than committing a broken step and correcting it in the next one. diff --git a/README.md b/README.md index 6f4c0644..0dedad05 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ the image carries. See the [installation guide](docs/technical/installation.md). | **Compliance** | Frameworks and requirements, audits, an organisation-wide nonconformity register, action plans and inter-framework mappings | | **Incidents** | Event intake, incident handling, sealed evidence under chain of custody, and statutory notification clocks (GDPR, NIS2, DORA) | | **Documents** | Policies, procedures, standards and charters : approved versions with one in force at a time, review reminders, and acknowledgement campaigns kept as evidence | +| **Controls** | The security controls the organisation operates, linked to requirements, risks and assets, with effectiveness derived from independently validated test runs and their evidence, feeding the Statement of Applicability | | **Trust Center** | A public, curated page for your security posture, optionally on its own domain | | **Steering** | A configurable widget dashboard, a unified tasks board, management reviews, and PDF / DOCX / PPTX / XLSX reports | 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/accounts/mixins.py b/accounts/mixins.py index 642adbf1..f758f0fb 100644 --- a/accounts/mixins.py +++ b/accounts/mixins.py @@ -279,8 +279,10 @@ def describe(step, state): }) ctx["lc_layout"] = "graph" # route the template to the new branch - ctx["lc_graph_nodes"] = json.dumps(graph_nodes) - ctx["lc_graph_edges"] = json.dumps(graph_edges) + from core.safe_json import dumps_for_script + + ctx["lc_graph_nodes"] = dumps_for_script(graph_nodes) + ctx["lc_graph_edges"] = dumps_for_script(graph_edges) return ctx 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/assets/templates/assets/certificate_detail.html b/assets/templates/assets/certificate_detail.html index 1ef53d6b..f8029a36 100644 --- a/assets/templates/assets/certificate_detail.html +++ b/assets/templates/assets/certificate_detail.html @@ -17,7 +17,7 @@ {% endif %} {% if certificate.notes %} -
{{ certificate.notes|safe }}
+
{{ certificate.notes|rich }}
{% endif %} {% if certificate.is_superseded %} diff --git a/assets/templates/assets/contract_detail.html b/assets/templates/assets/contract_detail.html index 3b658c65..9dac7227 100644 --- a/assets/templates/assets/contract_detail.html +++ b/assets/templates/assets/contract_detail.html @@ -17,7 +17,7 @@ {% endif %} {% if contract.notes %} -
{{ contract.notes|safe }}
+
{{ contract.notes|rich }}
{% endif %} {% if contract.is_superseded %} diff --git a/assets/templates/assets/essential_asset_detail.html b/assets/templates/assets/essential_asset_detail.html index 1c000827..0637015d 100644 --- a/assets/templates/assets/essential_asset_detail.html +++ b/assets/templates/assets/essential_asset_detail.html @@ -20,7 +20,7 @@ {% kpi_tile _("Associated support assets") dependencies|length icon="diagram-3" tone="neutral" %} {% endslot %} - {% section_card _("Description") icon="text-left" hide_empty=True %}{% if asset.description %}
{{ asset.description|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Description") icon="text-left" hide_empty=True %}{% if asset.description %}
{{ asset.description|rich }}
{% endif %}{% endsection_card %} {% section_card _("General") icon="info-circle" %} {% meta_list cols=2 %} @@ -50,7 +50,7 @@ {% meta_item _("Classification") asset.get_data_classification_display %} {% meta_item _("Personal data") %}{% if asset.personal_data %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}{% endmeta_item %} {% if asset.personal_data and asset.personal_data_categories %}{% meta_item _("GDPR categories") asset.personal_data_categories wide=True %}{% endif %} - {% meta_item _("Regulatory constraints") wide=True %}{% if asset.regulatory_constraints %}
{{ asset.regulatory_constraints|safe }}
{% endif %}{% endmeta_item %} + {% meta_item _("Regulatory constraints") wide=True %}{% if asset.regulatory_constraints %}
{{ asset.regulatory_constraints|rich }}
{% endif %}{% endmeta_item %} {% endmeta_list %} {% endsection_card %} diff --git a/assets/templates/assets/group_detail.html b/assets/templates/assets/group_detail.html index 684dcede..a6c60b5d 100644 --- a/assets/templates/assets/group_detail.html +++ b/assets/templates/assets/group_detail.html @@ -20,7 +20,7 @@ {% kpi_tile _("Members") members|length icon="collection" tone="neutral" %} {% endslot %} - {% section_card _("Description") icon="text-left" hide_empty=True %}{% if group.description %}
{{ group.description|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Description") icon="text-left" hide_empty=True %}{% if group.description %}
{{ group.description|rich }}
{% endif %}{% endsection_card %} {% section_card _("General") icon="info-circle" %} {% meta_list cols=2 %} diff --git a/assets/templates/assets/site_detail.html b/assets/templates/assets/site_detail.html index 519d2c0a..3da1545f 100644 --- a/assets/templates/assets/site_detail.html +++ b/assets/templates/assets/site_detail.html @@ -35,7 +35,7 @@
- {% if site.description %}{% ov_row "card-text" _("Description") lead=True rich=True %}{{ site.description|safe }}{% endov_row %}{% endif %} + {% if site.description %}{% ov_row "card-text" _("Description") lead=True rich=True %}{{ site.description|rich }}{% endov_row %}{% endif %} {% ov_row "building" _("Type") %}{{ site.get_type_display }}{% endov_row %} {% if site.parent_site %}{% ov_row "diagram-2" _("Parent site") %}{{ site.parent_site.name }}{% endov_row %}{% endif %} {% if site.address %}{% ov_row "geo-alt" _("Address") %}{{ site.address|striptags }}{% endov_row %}{% endif %} diff --git a/assets/templates/assets/supplier_detail.html b/assets/templates/assets/supplier_detail.html index e8ea5fcc..db8848e1 100644 --- a/assets/templates/assets/supplier_detail.html +++ b/assets/templates/assets/supplier_detail.html @@ -18,7 +18,7 @@ {% endif %} {% if supplier.notes %} -
{{ supplier.notes|safe }}
+
{{ supplier.notes|rich }}
{% endif %} {% if supplier.is_contract_expired %} @@ -49,7 +49,7 @@
- {% if supplier.description %}{% ov_row "card-text" _("Description") lead=True rich=True %}{{ supplier.description|safe }}{% endov_row %}{% endif %} + {% if supplier.description %}{% ov_row "card-text" _("Description") lead=True rich=True %}{{ supplier.description|rich }}{% endov_row %}{% endif %} {% if supplier.type %}{% ov_row "tag" _("Type") %}{{ supplier.type.name }}{% endov_row %}{% endif %} {% if supplier.website %}{% ov_row "globe" _("Website") %}{{ supplier.website }}{% endov_row %}{% endif %} {% if supplier.address %}{% ov_row "geo-alt" _("Address") %}{{ supplier.address|striptags }}{% endov_row %}{% endif %} @@ -213,7 +213,7 @@ {{ req.title }} - {% if req.requirement %}{{ req.requirement.reference }} {{ req.requirement.name }}{% elif req.description %}{{ req.description|safe }}{% endif %} + {% if req.requirement %}{{ req.requirement.reference }} {{ req.requirement.name }}{% elif req.description %}{{ req.description|rich }}{% endif %} @@ -238,7 +238,7 @@ {{ tr.title }} - {% if tr.description %}{{ tr.description|safe }}{% endif %} + {% if tr.description %}{{ tr.description|rich }}{% endif %} {% trans "Inherited" %} diff --git a/assets/templates/assets/supplier_requirement_detail.html b/assets/templates/assets/supplier_requirement_detail.html index ea3bd407..a2f44077 100644 --- a/assets/templates/assets/supplier_requirement_detail.html +++ b/assets/templates/assets/supplier_requirement_detail.html @@ -20,7 +20,7 @@ {% section_card _("Requirement") icon="list-check" %} {% meta_list cols=2 %} {% if req.requirement %}{% meta_item _("Linked requirement") wide=True %}{% endmeta_item %}{% endif %} - {% meta_item _("Description") wide=True %}{% if req.description %}
{{ req.description|safe }}
{% endif %}{% endmeta_item %} + {% meta_item _("Description") wide=True %}{% if req.description %}
{{ req.description|rich }}
{% endif %}{% endmeta_item %} {% meta_item _("Compliance status") %}{% choice_badge req "compliance_status" %}{% endmeta_item %} {% meta_item _("Due date") req.due_date %} {% if req.verified_by %}{% meta_item _("Last verified") wide=True %} {% trans "by" %} {% user_badge req.verified_by size=24 %}{% endmeta_item %}{% endif %} @@ -39,7 +39,7 @@
{% if review.comment %} {% subhead _("Justification") %} -
{{ review.comment|safe }}
+
{{ review.comment|rich }}
{% endif %} {% if review.evidence_file %} {% subhead _("Supporting evidence") %} diff --git a/assets/templates/assets/supplier_requirement_history_modal.html b/assets/templates/assets/supplier_requirement_history_modal.html index 852be923..519ad34f 100644 --- a/assets/templates/assets/supplier_requirement_history_modal.html +++ b/assets/templates/assets/supplier_requirement_history_modal.html @@ -19,7 +19,7 @@
- {% if review.comment %}
{{ review.comment|safe }}
{% endif %} + {% if review.comment %}
{{ review.comment|rich }}
{% endif %} {% if review.evidence_file %}
{{ review.evidence_filename }} diff --git a/assets/templates/assets/supplier_type_detail.html b/assets/templates/assets/supplier_type_detail.html index 427bc6fe..cd94b48d 100644 --- a/assets/templates/assets/supplier_type_detail.html +++ b/assets/templates/assets/supplier_type_detail.html @@ -15,7 +15,7 @@ {% kpi_tile _("Suppliers") suppliers|length icon="truck" tone="neutral" %} {% endslot %} - {% section_card _("Description") icon="text-left" %}{% if supplier_type.description %}
{{ supplier_type.description|safe }}
{% else %}{% dash %}{% endif %}{% endsection_card %} + {% section_card _("Description") icon="text-left" %}{% if supplier_type.description %}
{{ supplier_type.description|rich }}
{% else %}{% dash %}{% endif %}{% endsection_card %} {% section_card _("Requirements") icon="list-check" count=requirements|length flush=True %} {% slot actions %}{% trans "Add requirement" %}{% endslot %} diff --git a/assets/templates/assets/support_asset_detail.html b/assets/templates/assets/support_asset_detail.html index 8845c0ff..7270fe1f 100644 --- a/assets/templates/assets/support_asset_detail.html +++ b/assets/templates/assets/support_asset_detail.html @@ -29,7 +29,7 @@ {% kpi_tile _("Essential assets") dependencies|length icon="gem" tone="neutral" %} {% endslot %} - {% section_card _("Description") icon="text-left" hide_empty=True %}{% if asset.description %}
{{ asset.description|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Description") icon="text-left" hide_empty=True %}{% if asset.description %}
{{ asset.description|rich }}
{% endif %}{% endsection_card %} {% section_card _("General") icon="info-circle" %} {% meta_list cols=2 %} @@ -92,6 +92,8 @@ {% endmeta_list %} {% endsection_card %} + {% include "controls/includes/controls_card.html" %} + {% section_card _("People") icon="people" %} {% meta_list cols=2 %} {% meta_item _("Owner") %}{% if asset.owner %}{% user_badge asset.owner size=28 link=True block=True %}{% endif %}{% endmeta_item %} diff --git a/assets/views.py b/assets/views.py index 9e2491c7..879dbdae 100644 --- a/assets/views.py +++ b/assets/views.py @@ -22,6 +22,7 @@ from accounts.mixins import HistoryUrlMixin, LifecycleStepperMixin, ScopeFilterMixin from accounts.views import PermissionRequiredMixin +from controls.integrations import linked_controls from core.mixins import ( AdvancedFilterMixin, ColumnPreferenceMixin, @@ -609,6 +610,7 @@ def get_context_data(self, **kwargs): "essential_asset" ) ctx["children"] = self.object.children.all() + ctx["linked_controls"] = linked_controls(self.object, self.request.user) return ctx @@ -1906,8 +1908,10 @@ def get_context_data(self, **kwargs): }) import json - ctx["graph_nodes"] = json.dumps(list(nodes.values())) - ctx["graph_edges"] = json.dumps(edges) + from core.safe_json import dumps_for_script + + ctx["graph_nodes"] = dumps_for_script(list(nodes.values())) + ctx["graph_edges"] = dumps_for_script(edges) ctx["asset_dep_count"] = asset_deps.count() ctx["supplier_dep_count"] = supplier_deps.count() ctx["site_asset_dep_count"] = site_asset_deps.count() diff --git a/assistant/catalog.py b/assistant/catalog.py index 8212fa0f..7bcaeccd 100644 --- a/assistant/catalog.py +++ b/assistant/catalog.py @@ -589,6 +589,44 @@ def _spec(name, label, icon, signature, args, **kwargs): "completion_rate", "is_overdue"), detail_route="documents:acknowledgementcampaign-detail", ), + # Controls (module 8) + _spec( + "list_controls", + _("Controls"), + "bi-shield-check", + "list_controls(search, workflow_state, effectiveness, requirement_id, risk_id, test_overdue, limit):" + " security controls and their derived effectiveness.", + ("search", "workflow_state", "theme", "nature", "automation", "owner_id", "effectiveness", + "scope_id", "requirement_id", "risk_id", "support_asset_id", "policy_id", "objective_id", + "framework_id", "test_overdue", "limit", "offset"), + title_fields=("reference", "name"), + subtitle_field="workflow_state", + summary_fields=("theme", "effectiveness", "next_test_due", "is_test_overdue", "implemented_on"), + detail_route="controls:control-detail", + ), + _spec( + "get_control_effectiveness", + _("Controls"), + "bi-clipboard-check", + "get_control_effectiveness(control_id): why a control is effective or not, test by test.", + ("control_id",), + title_fields=("reference", "name"), + subtitle_field="effectiveness", + summary_fields=("workflow_state", "effectiveness_updated_at", "next_test_due", + "is_effectiveness_stale", "active_tests"), + detail_route="controls:control-detail", + ), + _spec( + "get_requirement_coverage", + _("Coverage"), + "bi-grid-3x3-gap", + "get_requirement_coverage(framework_id): the controls implementing each requirement (SoA view).", + ("framework_id",), + title_fields=("reference", "name"), + subtitle_field="", + summary_fields=("requirement_count", "totals", "rows"), + detail_route="compliance:framework-detail", + ), _spec( "list_site_supplier_dependencys", _("Supplier dependencies"), diff --git a/compliance/api/filters.py b/compliance/api/filters.py index 6e2668c9..cf93efdd 100644 --- a/compliance/api/filters.py +++ b/compliance/api/filters.py @@ -2,9 +2,11 @@ # SPDX-FileCopyrightText: 2026 François Rousselet import django_filters +from compliance.constants import FindingSource, FindingType from compliance.models import ( ComplianceActionPlan, ComplianceAssessment, + Finding, Framework, Requirement, RequirementMapping, @@ -111,3 +113,17 @@ class Meta: "owner": ["exact"], "workflow_state": ["exact"], } + + +class FindingFilter(django_filters.FilterSet): + """``source`` accepts every :class:`FindingSource`, ``control_test`` + included (RG-CTL-16); ``control_test_run_id`` lists the finding raised from + one run.""" + + source = django_filters.ChoiceFilter(choices=FindingSource.choices) + finding_type = django_filters.ChoiceFilter(choices=FindingType.choices) + control_test_run_id = django_filters.UUIDFilter(field_name="control_test_run_id") + + class Meta: + model = Finding + fields = {"effectiveness_verdict": ["exact"]} diff --git a/compliance/api/serializers.py b/compliance/api/serializers.py index 6ecf8652..87d94865 100644 --- a/compliance/api/serializers.py +++ b/compliance/api/serializers.py @@ -152,12 +152,15 @@ class FindingSerializer(serializers.ModelSerializer): assessment_name = serializers.CharField(read_only=True) assessor_name = serializers.CharField(read_only=True) effectiveness_reviewed_by_name = serializers.CharField(read_only=True) + # RG-CTL-16 : set by the run's "raise a finding" action, never by a client. + control_test_run_reference = serializers.CharField(read_only=True) class Meta: model = Finding fields = [ "id", "assessment", "assessment_name", "reference", "source", "source_display", + "control_test_run", "control_test_run_reference", "finding_type", "finding_type_display", "description", "recommendation", "evidence", "assessor", "assessor_name", "requirements", @@ -165,7 +168,9 @@ class Meta: "effectiveness_reviewed_by_name", "effectiveness_verdict", "created_by", "created_at", "updated_at", ] - read_only_fields = ["id", "reference", "created_by", "created_at", "updated_at"] + read_only_fields = [ + "id", "reference", "control_test_run", "created_by", "created_at", "updated_at", + ] class FindingListSerializer(serializers.ModelSerializer): @@ -177,18 +182,20 @@ class FindingListSerializer(serializers.ModelSerializer): ) assessment_name = serializers.CharField(read_only=True) assessor_name = serializers.CharField(read_only=True) + control_test_run_reference = serializers.CharField(read_only=True) class Meta: model = Finding fields = [ "id", "assessment", "assessment_name", "reference", "source", "source_display", + "control_test_run", "control_test_run_reference", "finding_type", "finding_type_display", "description", "assessor", "assessor_name", "effectiveness_verdict", "created_at", ] - read_only_fields = ["id", "reference", "created_at"] + read_only_fields = ["id", "reference", "control_test_run", "created_at"] class ComplianceAssessmentSerializer(serializers.ModelSerializer): diff --git a/compliance/api/views.py b/compliance/api/views.py index 00972b51..687872f3 100644 --- a/compliance/api/views.py +++ b/compliance/api/views.py @@ -23,6 +23,7 @@ from .filters import ( ComplianceActionPlanFilter, ComplianceAssessmentFilter, + FindingFilter, FrameworkFilter, RequirementFilter, RequirementMappingFilter, @@ -297,6 +298,7 @@ def perform_update(self, serializer): class FindingViewSet(ScopeFilterAPIMixin, viewsets.ModelViewSet): serializer_class = FindingSerializer + filterset_class = FindingFilter permission_classes = [CompliancePermission] permission_feature = "finding" scope_parent_lookup = "assessment__scopes" 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/forms.py b/compliance/forms.py index 2bb5ed5c..f7cbb3f7 100644 --- a/compliance/forms.py +++ b/compliance/forms.py @@ -11,6 +11,7 @@ from compliance.constants import ( ASSESSMENT_LOCKED_STATUSES, ComplianceStatus, + FindingSource, ) from helpers.image_utils import generate_image_variants from .models import ( @@ -506,6 +507,13 @@ def __init__(self, *args, raised_by=None, **kwargs): ).order_by("framework__name", "requirement_number") if raised_by and self.instance.assessor_id is None: self.instance.assessor = raised_by + # RG-CTL-16 : a "control test" finding is raised from its run, never + # declared by hand, and keeps that source once raised. + source = self.fields["source"] + if self.instance.control_test_run_id or self.instance.source == FindingSource.CONTROL_TEST: + source.choices = [c for c in source.choices if c[0] == FindingSource.CONTROL_TEST] + else: + source.choices = [c for c in source.choices if c[0] != FindingSource.CONTROL_TEST] class RequirementMappingBaseForm(SteppedFormMixin, forms.ModelForm): 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..c03dae92 100644 --- a/compliance/models/finding.py +++ b/compliance/models/finding.py @@ -35,6 +35,9 @@ class Finding(BaseModel): # Without this it would be dropped by the INNER JOIN and invisible to # every non-superuser, which for a register is data loss in practice. scope_parent_optional = True + # A nonconformity raised by an incident or a control test is filed under + # that parent's scopes, not visible to every finding reader (core.scoping). + scope_parent_alternates = ("incident__scopes", "control_test_run__test__control__scopes") assessment = models.ForeignKey( "compliance.ComplianceAssessment", @@ -62,6 +65,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 +149,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/compliance/templates/compliance/_action_plan_comments.html b/compliance/templates/compliance/_action_plan_comments.html index a0106a35..40f1760a 100644 --- a/compliance/templates/compliance/_action_plan_comments.html +++ b/compliance/templates/compliance/_action_plan_comments.html @@ -24,7 +24,7 @@ {% user_badge comment.author size=32 %}
-
{{ comment.content|safe }}
+
{{ comment.content|rich }}
-
{{ reply.content|safe }}
+
{{ reply.content|rich }}
{% endfor %} diff --git a/compliance/templates/compliance/_assessment_result_fields.html b/compliance/templates/compliance/_assessment_result_fields.html index 7de43cdf..65b4572a 100644 --- a/compliance/templates/compliance/_assessment_result_fields.html +++ b/compliance/templates/compliance/_assessment_result_fields.html @@ -1,4 +1,4 @@ -{% load i18n %} +{% load i18n ui %} {% comment %} The evaluation fields of an assessment result, shared by the drawer form (assessment_result_form_modal.html) and its full-page fallback @@ -13,7 +13,7 @@ {% if requirement_obj.requirement_number %}{{ requirement_obj.requirement_number }} - {% endif %}{{ requirement_obj.name }}
{% if requirement_obj.description %} -
{{ requirement_obj.description|safe }}
+
{{ requirement_obj.description|rich }}
{% endif %} {% if requirement_obj.section %}
{{ requirement_obj.section }}
@@ -36,7 +36,7 @@
{% trans "This requirement is non-applicable. Its compliance status cannot be modified." %} {% if requirement_obj.applicability_justification %} -
{{ requirement_obj.applicability_justification|safe }}
+
{{ requirement_obj.applicability_justification|rich }}
{% endif %}
{% endif %} diff --git a/compliance/templates/compliance/_finding_preview.html b/compliance/templates/compliance/_finding_preview.html index c7f80dca..1be0491a 100644 --- a/compliance/templates/compliance/_finding_preview.html +++ b/compliance/templates/compliance/_finding_preview.html @@ -10,9 +10,9 @@
{% choice_badge finding "finding_type" type="finding" %}
{% meta_list %} - {% meta_item _("Finding") %}{% if finding.description %}
{{ finding.description|safe }}
{% endif %}{% endmeta_item %} - {% if finding.recommendation %}{% meta_item _("Auditor recommendation") %}
{{ finding.recommendation|safe }}
{% endmeta_item %}{% endif %} - {% if finding.evidence %}{% meta_item _("Evidence presented") %}
{{ finding.evidence|safe }}
{% endmeta_item %}{% endif %} + {% meta_item _("Finding") %}{% if finding.description %}
{{ finding.description|rich }}
{% endif %}{% endmeta_item %} + {% if finding.recommendation %}{% meta_item _("Auditor recommendation") %}
{{ finding.recommendation|rich }}
{% endmeta_item %}{% endif %} + {% if finding.evidence %}{% meta_item _("Evidence presented") %}
{{ finding.evidence|rich }}
{% endmeta_item %}{% endif %} {% meta_item _("Assessor") %}{% if finding.assessor %}{% user_badge finding.assessor size=24 %}{% endif %}{% endmeta_item %} {% meta_item _("Assessment") %}{% if finding.assessment %}{{ finding.assessment }}{% else %}{% trans "Raised outside any audit." %}{% endif %}{% endmeta_item %} {% endmeta_list %} diff --git a/compliance/templates/compliance/_risk_preview.html b/compliance/templates/compliance/_risk_preview.html index a174c2ca..cd5f21fe 100644 --- a/compliance/templates/compliance/_risk_preview.html +++ b/compliance/templates/compliance/_risk_preview.html @@ -10,7 +10,7 @@
{{ risk.name }}
{% if risk.description %} -
{{ risk.description|safe }}
+
{{ risk.description|rich }}
{% endif %} {% meta_list %} diff --git a/compliance/templates/compliance/action_plan_detail.html b/compliance/templates/compliance/action_plan_detail.html index 57372e73..b5983842 100644 --- a/compliance/templates/compliance/action_plan_detail.html +++ b/compliance/templates/compliance/action_plan_detail.html @@ -21,13 +21,13 @@ {% kpi_tile _("Target date") action_plan.target_date icon="calendar-event" tone="neutral" %} {% endslot %} - {% section_card _("Description") icon="text-left" hide_empty=True %}{% if action_plan.description %}
{{ action_plan.description|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Description") icon="text-left" hide_empty=True %}{% if action_plan.description %}
{{ action_plan.description|rich }}
{% endif %}{% endsection_card %} {% if action_plan.gap_description or action_plan.remediation_plan %} {% section_card _("Remediation") icon="tools" %} {% meta_list %} - {% if action_plan.gap_description %}{% meta_item _("Gap description") %}
{{ action_plan.gap_description|safe }}
{% endmeta_item %}{% endif %} - {% if action_plan.remediation_plan %}{% meta_item _("Remediation plan") %}
{{ action_plan.remediation_plan|safe }}
{% endmeta_item %}{% endif %} + {% if action_plan.gap_description %}{% meta_item _("Gap description") %}
{{ action_plan.gap_description|rich }}
{% endmeta_item %}{% endif %} + {% if action_plan.remediation_plan %}{% meta_item _("Remediation plan") %}
{{ action_plan.remediation_plan|rich }}
{% endmeta_item %}{% endif %} {% endmeta_list %} {% endsection_card %} {% endif %} diff --git a/compliance/templates/compliance/assessment_detail.html b/compliance/templates/compliance/assessment_detail.html index a2494fa0..f1ca65f4 100644 --- a/compliance/templates/compliance/assessment_detail.html +++ b/compliance/templates/compliance/assessment_detail.html @@ -71,9 +71,9 @@ {% endoverview %} {% endsection_card %} - {% section_card _("Context and objectives") icon="text-left" collapsible=True open=False hide_empty=True %}{% if assessment.description %}
{{ assessment.description|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Context and objectives") icon="text-left" collapsible=True open=False hide_empty=True %}{% if assessment.description %}
{{ assessment.description|rich }}
{% endif %}{% endsection_card %} - {% section_card _("Limitations") icon="exclamation-circle" collapsible=True open=False hide_empty=True %}{% if assessment.limitations %}
{{ assessment.limitations|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Limitations") icon="exclamation-circle" collapsible=True open=False hide_empty=True %}{% if assessment.limitations %}
{{ assessment.limitations|rich }}
{% endif %}{% endsection_card %} {# Planning and Findings are two working modes of the assessment : the evaluation grid, and the findings register of this audit. #}
diff --git a/compliance/templates/compliance/finding_detail.html b/compliance/templates/compliance/finding_detail.html index 86d70b47..00f513a0 100644 --- a/compliance/templates/compliance/finding_detail.html +++ b/compliance/templates/compliance/finding_detail.html @@ -24,9 +24,9 @@ {% section_card _("Nonconformity") icon="exclamation-diamond" %} {% meta_list %} {% meta_item _("Type") %}{% choice_badge finding "finding_type" type="finding" %}{% endmeta_item %} - {% meta_item _("Finding") %}{% if finding.description %}
{{ finding.description|safe }}
{% endif %}{% endmeta_item %} - {% meta_item _("Recommendation") %}{% if finding.recommendation %}
{{ finding.recommendation|safe }}
{% endif %}{% endmeta_item %} - {% meta_item _("Evidence presented") %}{% if finding.evidence %}
{{ finding.evidence|safe }}
{% endif %}{% endmeta_item %} + {% meta_item _("Finding") %}{% if finding.description %}
{{ finding.description|rich }}
{% endif %}{% endmeta_item %} + {% meta_item _("Recommendation") %}{% if finding.recommendation %}
{{ finding.recommendation|rich }}
{% endif %}{% endmeta_item %} + {% meta_item _("Evidence presented") %}{% if finding.evidence %}
{{ finding.evidence|rich }}
{% endif %}{% endmeta_item %} {% endmeta_list %} {% endsection_card %} @@ -35,6 +35,11 @@ {% meta_item _("Source") finding.get_source_display %} {% meta_item _("Raised in") %}{% if finding.assessment %}{{ finding.assessment.name }}{% else %}{% trans "Raised outside any audit." %}{% endif %}{% endmeta_item %} {% meta_item _("Raised by") %}{% if finding.assessor %}{% user_badge finding.assessor size=28 link=True block=True %}{% endif %}{% endmeta_item %} + {% if finding.control_test_run %} + {% with run=finding.control_test_run %} + {% meta_item _("Control test run") wide=True %}{% if can_read_control_tests %}{% url 'controls:controltestrun-detail' run.pk as run_url %}{{ run.test.control.reference }} {{ run.test.control.name }} : {{ run.performed_on|date:"SHORT_DATE_FORMAT" }}{% else %}{{ run.reference }}{% endif %}{% endmeta_item %} + {% endwith %} + {% endif %} {% endmeta_list %} {% endsection_card %} diff --git a/compliance/templates/compliance/findings_table_body.html b/compliance/templates/compliance/findings_table_body.html index 20257257..0a2ae626 100644 --- a/compliance/templates/compliance/findings_table_body.html +++ b/compliance/templates/compliance/findings_table_body.html @@ -3,7 +3,7 @@ {{ finding.reference }} {% choice_badge finding "finding_type" type="finding" %} -
{{ finding.description|truncatewords_html:20|safe }}
+
{{ finding.description|rich|truncatewords_html:20 }}
{% for req in finding.requirements.all %} diff --git a/compliance/templates/compliance/framework_detail.html b/compliance/templates/compliance/framework_detail.html index 8f4e1300..a5a4a718 100644 --- a/compliance/templates/compliance/framework_detail.html +++ b/compliance/templates/compliance/framework_detail.html @@ -23,7 +23,7 @@ {% kpi_tile _("Next review date") framework.review_date icon="calendar-event" tone="neutral" %} {% endslot %} - {% section_card _("Description") icon="text-left" hide_empty=True %}{% if framework.description %}
{{ framework.description|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Description") icon="text-left" hide_empty=True %}{% if framework.description %}
{{ framework.description|rich }}
{% endif %}{% endsection_card %} {% section_card _("General") icon="info-circle" %} {% meta_list cols=2 %} diff --git a/compliance/templates/compliance/mapping_detail.html b/compliance/templates/compliance/mapping_detail.html index 492ff330..c93b5912 100644 --- a/compliance/templates/compliance/mapping_detail.html +++ b/compliance/templates/compliance/mapping_detail.html @@ -37,9 +37,9 @@ {% endmeta_list %} {% endsection_card %} - {% section_card _("Description") icon="text-left" hide_empty=True %}{% if mapping.description %}
{{ mapping.description|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Description") icon="text-left" hide_empty=True %}{% if mapping.description %}
{{ mapping.description|rich }}
{% endif %}{% endsection_card %} - {% section_card _("Justification") icon="journal-text" hide_empty=True %}{% if mapping.justification %}
{{ mapping.justification|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Justification") icon="journal-text" hide_empty=True %}{% if mapping.justification %}
{{ mapping.justification|rich }}
{% endif %}{% endsection_card %} {% record_card mapping %} {% enddetail_layout %} diff --git a/compliance/templates/compliance/requirement_detail.html b/compliance/templates/compliance/requirement_detail.html index 1b4cc957..820c79b4 100644 --- a/compliance/templates/compliance/requirement_detail.html +++ b/compliance/templates/compliance/requirement_detail.html @@ -21,9 +21,9 @@ {% kpi_tile _("Priority") requirement.get_priority_display icon="flag" tone=requirement.priority|badge_variant:"level" %} {% endslot %} - {% section_card _("Description") icon="text-left" hide_empty=True %}{% if requirement.description %}
{{ requirement.description|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Description") icon="text-left" hide_empty=True %}{% if requirement.description %}
{{ requirement.description|rich }}
{% endif %}{% endsection_card %} - {% section_card _("Implementation recommendations") icon="lightbulb" hide_empty=True %}{% if requirement.guidance %}
{{ requirement.guidance|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Implementation recommendations") icon="lightbulb" hide_empty=True %}{% if requirement.guidance %}
{{ requirement.guidance|rich }}
{% endif %}{% endsection_card %} {% section_card _("General") icon="info-circle" %} {% meta_list cols=2 %} @@ -44,7 +44,7 @@ {% meta_list cols=2 %} {% meta_item _("Applicable") %}{% if requirement.is_applicable %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}{% if requirement.framework.applicability_managed_by_risks %} {% badge variant="info" icon="shield-check" label=_("Risk-driven") %}{% endif %}{% endmeta_item %} {% if requirement.framework.applicability_managed_by_risks or not requirement.is_applicable %} - {% meta_item _("Justification") wide=True %}{% if requirement.applicability_justification %}
{{ requirement.applicability_justification|safe }}
{% endif %}{% endmeta_item %} + {% meta_item _("Justification") wide=True %}{% if requirement.applicability_justification %}
{{ requirement.applicability_justification|rich }}
{% endif %}{% endmeta_item %} {% endif %} {% endmeta_list %} {% endsection_card %} @@ -55,11 +55,13 @@ {% meta_item _("Level") %}{% progress_bar requirement.compliance_level scheme="score" %}{% endmeta_item %} {% meta_item _("Last assessment") requirement.last_assessment_date %} {% meta_item _("Last assessor") %}{% if requirement.last_assessed_by %}{% user_badge requirement.last_assessed_by size=28 link=True block=True %}{% endif %}{% endmeta_item %} - {% meta_item _("Evidence") wide=True %}{% if requirement.compliance_evidence %}
{{ requirement.compliance_evidence|safe }}
{% endif %}{% endmeta_item %} - {% meta_item _("Finding") wide=True %}{% if requirement.compliance_finding %}
{{ requirement.compliance_finding|safe }}
{% endif %}{% endmeta_item %} + {% meta_item _("Evidence") wide=True %}{% if requirement.compliance_evidence %}
{{ requirement.compliance_evidence|rich }}
{% endif %}{% endmeta_item %} + {% meta_item _("Finding") wide=True %}{% if requirement.compliance_finding %}
{{ requirement.compliance_finding|rich }}
{% endif %}{% endmeta_item %} {% endmeta_list %} {% endsection_card %} + {% include "controls/includes/controls_card.html" %} + {% with risks=requirement.linked_risks.all %} {% section_card _("Linked risks") icon="shield-exclamation" count=risks|length flush=risks|length %} {% if risks %} diff --git a/compliance/views.py b/compliance/views.py index d7a7a51f..3ec81f8d 100644 --- a/compliance/views.py +++ b/compliance/views.py @@ -27,6 +27,7 @@ from accounts.mixins import HistoryUrlMixin, LifecycleStepperMixin, ScopeFilterMixin from accounts.views import PermissionRequiredMixin +from controls.integrations import linked_controls from core.mixins import ( AdvancedFilterMixin, ColumnPreferenceMixin, @@ -461,6 +462,7 @@ def get_context_data(self, **kwargs): ctx["mappings_target"] = req.mappings_as_target.select_related( "source_requirement__framework" ) + ctx["linked_controls"] = linked_controls(req, self.request.user) return ctx @@ -1567,9 +1569,18 @@ class FindingDetailView( def get_queryset(self): return super().get_queryset().select_related( - "assessment", "assessor", "effectiveness_reviewed_by" + "assessment", "assessor", "effectiveness_reviewed_by", + "control_test_run__test__control", ).prefetch_related("requirements", "action_plans") + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + # RG-CTL-16 : a finding raised from a control test run links back to + # it; the link is offered to the readers of tests and runs only. + user = self.request.user + ctx["can_read_control_tests"] = user.is_superuser or user.has_perm("controls.test.read") + return ctx + class FindingRegisterCreateView(LoginRequiredMixin, PermissionRequiredMixin, HtmxFormMixin, CreatedByMixin, CreateView): """Raise a nonconformity outside any audit.""" diff --git a/context/constants.py b/context/constants.py index 24c9ca8a..58d79b0a 100644 --- a/context/constants.py +++ b/context/constants.py @@ -233,6 +233,8 @@ class PredefinedIndicatorSource(models.TextChoices): MANDATORY_ROLES_COVERAGE = "mandatory_roles_coverage", _("Mandatory roles coverage rate") POLICIES_OVERDUE_REVIEW = "policies_overdue_review", _("Documents overdue for review") ACKNOWLEDGEMENT_RATE = "acknowledgement_rate", _("Document acknowledgement rate") + CONTROL_EFFECTIVENESS_RATE = "control_effectiveness_rate", _("Control effectiveness rate") + CONTROL_TESTS_OVERDUE = "control_tests_overdue", _("Control tests overdue") # Mapping: predefined source -> (format, unit) @@ -245,6 +247,8 @@ class PredefinedIndicatorSource(models.TextChoices): PredefinedIndicatorSource.MANDATORY_ROLES_COVERAGE: ("number", "%"), PredefinedIndicatorSource.POLICIES_OVERDUE_REVIEW: ("number", ""), PredefinedIndicatorSource.ACKNOWLEDGEMENT_RATE: ("number", "%"), + PredefinedIndicatorSource.CONTROL_EFFECTIVENESS_RATE: ("number", "%"), + PredefinedIndicatorSource.CONTROL_TESTS_OVERDUE: ("number", ""), } diff --git a/context/migrations/0037_indicator_control_sources.py b/context/migrations/0037_indicator_control_sources.py new file mode 100644 index 00000000..f32ad458 --- /dev/null +++ b/context/migrations/0037_indicator_control_sources.py @@ -0,0 +1,23 @@ +# Generated by Django 6.1.3 on 2026-11-04 12:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('context', '0036_indicator_document_sources'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalindicator', + name='internal_source', + field=models.CharField(blank=True, choices=[('global_compliance_rate', 'Global compliance rate'), ('framework_compliance_rate', 'Compliance rate by framework'), ('objective_progress', 'Objective progress rate'), ('risk_treatment_rate', 'Risk treatment rate'), ('approved_scopes_rate', 'Approved scopes rate'), ('mandatory_roles_coverage', 'Mandatory roles coverage rate'), ('policies_overdue_review', 'Documents overdue for review'), ('acknowledgement_rate', 'Document acknowledgement rate'), ('control_effectiveness_rate', 'Control effectiveness rate'), ('control_tests_overdue', 'Control tests overdue')], default='', max_length=50, verbose_name='Predefined data source'), + ), + migrations.AlterField( + model_name='indicator', + name='internal_source', + field=models.CharField(blank=True, choices=[('global_compliance_rate', 'Global compliance rate'), ('framework_compliance_rate', 'Compliance rate by framework'), ('objective_progress', 'Objective progress rate'), ('risk_treatment_rate', 'Risk treatment rate'), ('approved_scopes_rate', 'Approved scopes rate'), ('mandatory_roles_coverage', 'Mandatory roles coverage rate'), ('policies_overdue_review', 'Documents overdue for review'), ('acknowledgement_rate', 'Document acknowledgement rate'), ('control_effectiveness_rate', 'Control effectiveness rate'), ('control_tests_overdue', 'Control tests overdue')], default='', max_length=50, verbose_name='Predefined data source'), + ), + ] diff --git a/context/models/indicator.py b/context/models/indicator.py index f8fe1705..f7d8c6f2 100644 --- a/context/models/indicator.py +++ b/context/models/indicator.py @@ -297,6 +297,10 @@ def compute_internal_value(self): return self._compute_policies_overdue_review() elif source == PredefinedIndicatorSource.ACKNOWLEDGEMENT_RATE: return self._compute_acknowledgement_rate() + elif source == PredefinedIndicatorSource.CONTROL_EFFECTIVENESS_RATE: + return self._compute_control_effectiveness_rate() + elif source == PredefinedIndicatorSource.CONTROL_TESTS_OVERDUE: + return self._compute_control_tests_overdue() return None def _compute_global_compliance_rate(self): @@ -393,6 +397,36 @@ def _compute_acknowledgement_rate(self): acknowledged = rows.filter(status=AcknowledgementStatus.ACKNOWLEDGED).count() return str(round(acknowledged / denominator * 100, 1)) + def _compute_control_effectiveness_rate(self): + # RG-CTL-14 / RG-CTL-17 : effective controls over tested controls, + # among the controls that count in reports (planned and implemented; + # a proposed or retired control drops out). "Tested" means an + # effectiveness other than not_tested, read off the stored derived + # value. + from controls.constants import ControlEffectiveness + from controls.models import Control + from core.lifecycle import reportable + + tested = reportable(Control.objects.all()).exclude(effectiveness=ControlEffectiveness.NOT_TESTED) + denominator = tested.count() + if denominator == 0: + return "0" + effective = tested.filter(effectiveness=ControlEffectiveness.EFFECTIVE).count() + return str(round(effective / denominator * 100, 1)) + + def _compute_control_tests_overdue(self): + # RG-CTL-07 : overdue is derived (an active test whose next due date + # has passed), never a lifecycle step. + from django.utils import timezone + + from controls.models import ControlTest + from core.lifecycle import reportable + + overdue = reportable(ControlTest.objects.filter( + next_due_date__isnull=False, next_due_date__lt=timezone.localdate(), + )) + return str(overdue.count()) + def record_measurement(self, value, recorded_by=None, notes=""): """Record a new measurement for this indicator.""" measurement = IndicatorMeasurement.objects.create( 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/context/templates/context/activity_detail.html b/context/templates/context/activity_detail.html index 4dbae996..ff864916 100644 --- a/context/templates/context/activity_detail.html +++ b/context/templates/context/activity_detail.html @@ -22,7 +22,7 @@ {% kpi_tile _("Sub-activities") activity.children.count icon="diagram-3" tone="info" %} {% endslot %} - {% section_card _("Description") icon="text-left" hide_empty=True %}{% if activity.description %}
{{ activity.description|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Description") icon="text-left" hide_empty=True %}{% if activity.description %}
{{ activity.description|rich }}
{% endif %}{% endsection_card %} {% section_card _("General") icon="info-circle" %} {% meta_list cols=2 %} diff --git a/context/templates/context/indicator_detail.html b/context/templates/context/indicator_detail.html index baa450a3..c67bfb45 100644 --- a/context/templates/context/indicator_detail.html +++ b/context/templates/context/indicator_detail.html @@ -31,7 +31,7 @@ {% kpi_tile _("Measurements") measurements|length icon="clock-history" tone="neutral" %} {% endslot %} - {% section_card _("Description") icon="text-left" hide_empty=True %}{% if indicator.description %}
{{ indicator.description|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Description") icon="text-left" hide_empty=True %}{% if indicator.description %}
{{ indicator.description|rich }}
{% endif %}{% endsection_card %} {% section_card _("General") icon="info-circle" %} {% meta_list cols=2 %} diff --git a/context/templates/context/issue_detail.html b/context/templates/context/issue_detail.html index 69dba705..f0e86f76 100644 --- a/context/templates/context/issue_detail.html +++ b/context/templates/context/issue_detail.html @@ -21,7 +21,7 @@ {% kpi_tile _("Next review") issue.review_date icon="calendar-event" tone="neutral" %} {% endslot %} - {% section_card _("Description") icon="text-left" hide_empty=True %}{% if issue.description %}
{{ issue.description|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Description") icon="text-left" hide_empty=True %}{% if issue.description %}
{{ issue.description|rich }}
{% endif %}{% endsection_card %} {% section_card _("Classification") icon="info-circle" %} {% meta_list cols=2 %} diff --git a/context/templates/context/objective_detail.html b/context/templates/context/objective_detail.html index f9a56b48..a7dd2c6a 100644 --- a/context/templates/context/objective_detail.html +++ b/context/templates/context/objective_detail.html @@ -26,7 +26,7 @@ {% kpi_tile _("Next review") objective.review_date icon="arrow-repeat" tone="neutral" %} {% endslot %} - {% section_card _("Description") icon="text-left" hide_empty=True %}{% if objective.description %}
{{ objective.description|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Description") icon="text-left" hide_empty=True %}{% if objective.description %}
{{ objective.description|rich }}
{% endif %}{% endsection_card %} {% section_card _("General") icon="info-circle" %} {% meta_list cols=2 %} @@ -52,6 +52,8 @@ {% endmeta_list %} {% endsection_card %} + {% include "controls/includes/controls_card.html" %} + {% record_card objective %} {% enddetail_layout %} {% endblock %} diff --git a/context/templates/context/role_detail.html b/context/templates/context/role_detail.html index 9c5078bc..ad35506e 100644 --- a/context/templates/context/role_detail.html +++ b/context/templates/context/role_detail.html @@ -27,7 +27,7 @@ {% kpi_tile _("Users") assigned_users|length icon="people" tone="neutral" %} {% endslot %} - {% section_card _("Description") icon="text-left" hide_empty=True %}{% if role.description %}
{{ role.description|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Description") icon="text-left" hide_empty=True %}{% if role.description %}
{{ role.description|rich }}
{% endif %}{% endsection_card %} {% section_card _("Information") icon="info-circle" %} {% meta_list cols=2 %} diff --git a/context/templates/context/scope_detail.html b/context/templates/context/scope_detail.html index a76bb0c6..327f91b2 100644 --- a/context/templates/context/scope_detail.html +++ b/context/templates/context/scope_detail.html @@ -42,12 +42,12 @@
{% with scope_icon=scope.icon|cut:"bi-"|default:"card-text" %} - {% ov_row scope_icon _("Description") lead=True rich=True %}{% if scope.description %}{{ scope.description|safe }}{% endif %}{% endov_row %} + {% ov_row scope_icon _("Description") lead=True rich=True %}{% if scope.description %}{{ scope.description|rich }}{% endif %}{% endov_row %} {% endwith %} {% ov_row "diagram-2" _("Parent scope") %}{% if scope.parent_scope %}{{ scope.parent_scope.name }}{% endif %}{% endov_row %} - {% ov_row "globe" _("Geographic scope") %}{% if scope.geographic_scope %}{{ scope.geographic_scope|safe }}{% endif %}{% endov_row %} - {% ov_row "building" _("Organizational scope") %}{% if scope.organizational_scope %}{{ scope.organizational_scope|safe }}{% endif %}{% endov_row %} - {% ov_row "cpu" _("Technical scope") %}{% if scope.technical_scope %}{{ scope.technical_scope|safe }}{% endif %}{% endov_row %} + {% ov_row "globe" _("Geographic scope") %}{% if scope.geographic_scope %}{{ scope.geographic_scope|rich }}{% endif %}{% endov_row %} + {% ov_row "building" _("Organizational scope") %}{% if scope.organizational_scope %}{{ scope.organizational_scope|rich }}{% endif %}{% endov_row %} + {% ov_row "cpu" _("Technical scope") %}{% if scope.technical_scope %}{{ scope.technical_scope|rich }}{% endif %}{% endov_row %} {% if scope.effective_date %} {% ov_row "calendar-check" _("Effective date") %}{% trans "In force since" %} {{ scope.effective_date }}{% if scope.review_date %} · {% trans "next review" %} {{ scope.review_date }}{% endif %}{% endov_row %} {% elif scope.review_date %} @@ -125,8 +125,8 @@ {% if scope.boundaries or scope.justification_exclusions %} {% section_card _("Boundaries and exclusions") icon="bounding-box" %} {% meta_list %} - {% if scope.boundaries %}{% meta_item _("Boundaries") %}
{{ scope.boundaries|safe }}
{% endmeta_item %}{% endif %} - {% if scope.justification_exclusions %}{% meta_item _("Justification") %}
{{ scope.justification_exclusions|safe }}
{% endmeta_item %}{% endif %} + {% if scope.boundaries %}{% meta_item _("Boundaries") %}
{{ scope.boundaries|rich }}
{% endmeta_item %}{% endif %} + {% if scope.justification_exclusions %}{% meta_item _("Justification") %}
{{ scope.justification_exclusions|rich }}
{% endmeta_item %}{% endif %} {% endmeta_list %} {% endsection_card %} {% endif %} diff --git a/context/templates/context/stakeholder_detail.html b/context/templates/context/stakeholder_detail.html index 05bf4d5a..1669eacd 100644 --- a/context/templates/context/stakeholder_detail.html +++ b/context/templates/context/stakeholder_detail.html @@ -22,7 +22,7 @@ {% kpi_tile _("Expectations & Requirements") expectations|length icon="list-check" tone="info" %} {% endslot %} - {% section_card _("Description") icon="text-left" hide_empty=True %}{% if stakeholder.description %}
{{ stakeholder.description|safe }}
{% endif %}{% endsection_card %} + {% section_card _("Description") icon="text-left" hide_empty=True %}{% if stakeholder.description %}
{{ stakeholder.description|rich }}
{% endif %}{% endsection_card %} {% section_card _("Information") icon="info-circle" %} {% meta_list cols=2 %} diff --git a/context/views.py b/context/views.py index 7eae5cd0..c2bb9836 100644 --- a/context/views.py +++ b/context/views.py @@ -25,6 +25,7 @@ from accounts.mixins import HistoryUrlMixin, LifecycleStepperMixin, ScopeFilterMixin from accounts.views import PermissionRequiredMixin +from controls.integrations import linked_controls from core.mixins import ( AdvancedFilterMixin, ColumnPreferenceMixin, @@ -666,6 +667,11 @@ class ObjectiveDetailView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilt template_name = "context/objective_detail.html" context_object_name = "objective" + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["linked_controls"] = linked_controls(self.object, self.request.user) + return ctx + class ObjectiveCreateView(LoginRequiredMixin, PermissionRequiredMixin, HtmxFormMixin, CreatedByMixin, CreateView): model = Objective 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/filters.py b/controls/api/filters.py new file mode 100644 index 00000000..a9f3a685 --- /dev/null +++ b/controls/api/filters.py @@ -0,0 +1,91 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""FilterSets for module 8 (controls), README §5. + +``status`` is the public alias of ``workflow_state``. Related ids are exposed +under the ``_id`` spelling of the spec. "Overdue" is derived, never a +lifecycle step (RG-CTL-07) : an active test whose next due date has passed, +read through ``reportable_states()`` rather than a step code written down +(RG-CTL-17). +""" + +import django_filters +from django.db.models import Exists, OuterRef, Q +from django.utils import timezone + +from controls.constants import ControlAutomation, ControlEffectiveness, ControlNature, ControlTheme, RunResult +from controls.models import Control, ControlTest, ControlTestRun +from core.lifecycle import reportable_states + + +def overdue_tests_q(prefix=""): + """RG-CTL-07 : an active test with a passed next due date.""" + return Q(**{ + f"{prefix}workflow_state__in": reportable_states(ControlTest), + f"{prefix}next_due_date__isnull": False, + f"{prefix}next_due_date__lt": timezone.localdate(), + }) + + +def filter_controls_test_overdue(queryset, value): + """RG-CTL-15 : controls with (``True``) or without any overdue active test.""" + overdue = Exists(ControlTest.objects.filter(overdue_tests_q(), control=OuterRef("pk"))) + return queryset.filter(overdue) if value else queryset.exclude(overdue) + + +class ControlFilter(django_filters.FilterSet): + status = django_filters.CharFilter(field_name="workflow_state") + theme = django_filters.ChoiceFilter(choices=ControlTheme.choices) + nature = django_filters.ChoiceFilter(choices=ControlNature.choices) + automation = django_filters.ChoiceFilter(choices=ControlAutomation.choices) + effectiveness = django_filters.ChoiceFilter(choices=ControlEffectiveness.choices) + owner_id = django_filters.UUIDFilter(field_name="owner_id") + scope_id = django_filters.UUIDFilter(field_name="scopes__id", distinct=True) + requirement_id = django_filters.UUIDFilter(field_name="requirements__id", distinct=True) + risk_id = django_filters.UUIDFilter(field_name="risks__id", distinct=True) + treatment_plan_id = django_filters.UUIDFilter(field_name="treatment_plans__id", distinct=True) + pacs_measure_id = django_filters.UUIDFilter(field_name="pacs_measures__id", distinct=True) + support_asset_id = django_filters.UUIDFilter(field_name="support_assets__id", distinct=True) + objective_id = django_filters.UUIDFilter(field_name="objectives__id", distinct=True) + policy_id = django_filters.UUIDFilter(field_name="policies__id", distinct=True) + framework_id = django_filters.UUIDFilter(field_name="requirements__framework_id", distinct=True) + test_overdue = django_filters.BooleanFilter(method="filter_test_overdue") + + class Meta: + model = Control + fields = {"workflow_state": ["exact"]} + + def filter_test_overdue(self, queryset, name, value): + return filter_controls_test_overdue(queryset, value) + + +class ControlTestFilter(django_filters.FilterSet): + status = django_filters.CharFilter(field_name="workflow_state") + control_id = django_filters.UUIDFilter(field_name="control_id") + tester_id = django_filters.UUIDFilter(field_name="tester_id") + method = django_filters.CharFilter(field_name="method") + due_before = django_filters.DateFilter(field_name="next_due_date", lookup_expr="lte") + due_after = django_filters.DateFilter(field_name="next_due_date", lookup_expr="gte") + overdue = django_filters.BooleanFilter(method="filter_overdue") + + class Meta: + model = ControlTest + fields = {"workflow_state": ["exact"]} + + def filter_overdue(self, queryset, name, value): + return queryset.filter(overdue_tests_q()) if value else queryset.exclude(overdue_tests_q()) + + +class ControlTestRunFilter(django_filters.FilterSet): + status = django_filters.CharFilter(field_name="workflow_state") + test_id = django_filters.UUIDFilter(field_name="test_id") + control_id = django_filters.UUIDFilter(field_name="test__control_id") + result = django_filters.ChoiceFilter(choices=RunResult.choices) + performed_by_id = django_filters.UUIDFilter(field_name="performed_by_id") + reviewed_by_id = django_filters.UUIDFilter(field_name="reviewed_by_id") + performed_before = django_filters.DateFilter(field_name="performed_on", lookup_expr="lte") + performed_after = django_filters.DateFilter(field_name="performed_on", lookup_expr="gte") + + class Meta: + model = ControlTestRun + fields = {"workflow_state": ["exact"]} diff --git a/controls/api/serializers.py b/controls/api/serializers.py new file mode 100644 index 00000000..97fa7c82 --- /dev/null +++ b/controls/api/serializers.py @@ -0,0 +1,407 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""DRF serializers for module 8 (controls). + +Rules that hold across the file : + +**`workflow_state` is never writable.** Every entity exposes it as +``status = CharField(source="workflow_state", read_only=True)``; a state change +is a transition through the model's ``transition_to()`` (RG-CTL-02, -06, -10, +-11, -12 live there). + +**Derived values are read-only.** ``effectiveness`` / ``effectiveness_updated_at`` +(RG-CTL-14), ``next_due_date`` / ``activated_on`` (RG-CTL-07), ``reviewed_by`` / +``reviewed_at`` (RG-CTL-11) and every evidence attribute (RG-CTL-13) are written +by the domain, never by a client. + +**Links are governed.** Each relation of a control only accepts targets within +the caller's scopes and in a linkable lifecycle step, plus the ones already +linked (RG-CTL-04, ``linkable_or_linked()``), so a client cannot attach a +control to a risk of another perimeter or to a retired document by guessing a +UUID. + +**A frozen run is refused, not ignored.** From ``in_review`` on, a run's +content is frozen (RG-CTL-09) : a ``PATCH`` that would change it is answered +with a 400 carrying the model's own message. + +**Evidence bytes never appear.** The run lists its evidence with ``file_name``, +``size``, ``sha256`` and ``download_url`` (the permission-checked, scope-filtered +download view); files are uploaded through the web form only (README §5). +""" + +import copy + +from django.apps import apps +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import models +from django.db.models import Q +from django.urls import reverse +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers +from rest_framework.settings import api_settings + +from core.lifecycle import linkable_or_linked +from core.scoping import filter_queryset_by_scopes +from controls.models import Control, ControlTest, ControlTestRun +from controls.models.control_test_run import FROZEN_FIELDS + +#: Every relation of a control (README §1.3) : the serializer field, the target +#: model, and the explicit scope path for targets whose model declares none +#: (``None`` lets ``core.scoping`` resolve it from the model). +CONTROL_RELATIONS = ( + ("requirements", "compliance.Requirement", None), + ("risks", "risks.Risk", "assessment__scopes"), + ("treatment_plans", "risks.RiskTreatmentPlan", "risk__assessment__scopes"), + ("pacs_measures", "risks.PACSMeasure", "summary__assessment__scopes"), + ("support_assets", "assets.SupportAsset", None), + ("objectives", "context.Objective", None), + ("policies", "documents.Policy", None), +) + +#: RG-CTL-09 : the run fields a client can write that freeze on submission +#: (the model's ``FROZEN_FIELDS`` minus the ``_id`` suffix of the relations). +RUN_FROZEN_API_FIELDS = tuple(name[:-3] if name.endswith("_id") else name for name in FROZEN_FIELDS) + +RUN_FROZEN_MESSAGE = _("A submitted test run is frozen : its content can no longer be changed.") + + +# --- Shared behaviour -------------------------------------------------------- + + +def _drf_errors(exc): + """Translate a Django ``ValidationError`` into DRF's error shape.""" + if hasattr(exc, "message_dict"): + errors = dict(exc.message_dict) + non_field = errors.pop("__all__", None) + if non_field: + errors[api_settings.NON_FIELD_ERRORS_KEY] = non_field + return errors + return {api_settings.NON_FIELD_ERRORS_KEY: list(exc.messages)} + + +def _edited_instance(serializer): + """The single row being updated, or ``None`` (a list render passes the + queryset as ``instance``).""" + instance = serializer.instance + return instance if isinstance(instance, models.Model) else None + + +def _request_user(serializer): + request = serializer.context.get("request") + return getattr(request, "user", None) + + +def scoped_for(user, queryset, explicit=None): + """Restrict ``queryset`` to what ``user`` may see (scope tenancy).""" + if user is None or not user.is_authenticated or user.is_superuser: + return queryset + scope_ids = user.get_allowed_scope_ids() + if scope_ids is None: + return queryset + return filter_queryset_by_scopes(queryset, scope_ids, explicit=explicit) + + +def _user_name(user): + return user.display_name if user is not None else "" + + +class _CleanedModelSerializer(serializers.ModelSerializer): + """Run the model's own ``clean()`` during validation. + + ``ModelSerializer`` does not call it, so the future implementation date + (RG-CTL-02), the run period (RG-CTL-10) and the active-test rule + (RG-CTL-09) would reach ``save()`` as an exception rather than a field + error. The candidate is built in memory from the non-M2M data. + """ + + def validate(self, attrs): + attrs = super().validate(attrs) + candidate = self._instance_for_clean(attrs) + try: + candidate.clean() + except DjangoValidationError as exc: + raise serializers.ValidationError(_drf_errors(exc)) from exc + return attrs + + def _instance_for_clean(self, attrs): + model = self.Meta.model + many_to_many = {field.name for field in model._meta.many_to_many} + data = {key: value for key, value in attrs.items() if key not in many_to_many} + instance = _edited_instance(self) + if instance is None: + return model(**data) + candidate = copy.copy(instance) + for key, value in data.items(): + setattr(candidate, key, value) + return candidate + + +# --- Control ----------------------------------------------------------------- + + +class _ControlFieldsMixin(serializers.Serializer): + status = serializers.CharField(source="workflow_state", read_only=True) + owner_name = serializers.SerializerMethodField() + next_test_due = serializers.SerializerMethodField() + is_test_overdue = serializers.SerializerMethodField() + is_effectiveness_stale = serializers.SerializerMethodField() + + def get_owner_name(self, obj): + return _user_name(obj.owner) + + def _next_due(self, obj): + # The list queryset annotates it; a single object reads the property. + if hasattr(obj, "_next_test_due"): + return obj._next_test_due + return obj.next_test_due + + def get_next_test_due(self, obj): + due = self._next_due(obj) + return due.isoformat() if due else None + + def get_is_test_overdue(self, obj): + from django.utils import timezone + + due = self._next_due(obj) + return due is not None and due < timezone.localdate() + + def get_is_effectiveness_stale(self, obj): + from controls.constants import ControlEffectiveness + + return self.get_is_test_overdue(obj) and obj.effectiveness != ControlEffectiveness.NOT_TESTED + + +class ControlSerializer(_ControlFieldsMixin, _CleanedModelSerializer): + class Meta: + model = Control + fields = [ + "id", "scopes", "reference", "name", "description", "theme", "nature", "automation", + "owner", "owner_name", "implemented_on", + "effectiveness", "effectiveness_updated_at", "next_test_due", "is_test_overdue", + "is_effectiveness_stale", + "requirements", "risks", "treatment_plans", "pacs_measures", "support_assets", + "objectives", "policies", + "status", "tags", "version", "created_by", "created_at", "updated_at", + ] + read_only_fields = [ + "id", "reference", "version", "created_by", "created_at", "updated_at", + # RG-CTL-14 : derived from the validated runs, never declared. + "effectiveness", "effectiveness_updated_at", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + user = _request_user(self) + instance = _edited_instance(self) + scopes = self.fields.get("scopes") + if scopes is not None and not scopes.read_only: + # The caller's own scopes, plus those the control already carries + # (a co-owned control keeps the perimeter the caller cannot see). + scope_model = apps.get_model("context", "Scope") + allowed = Q(pk__in=scoped_for(user, scope_model.objects.all()).values("pk")) + if instance is not None: + allowed |= Q(pk__in=instance.scopes.values("pk")) + scopes.child_relation.queryset = scope_model.objects.filter(allowed) + for name, label, lookup in CONTROL_RELATIONS: + field = self.fields.get(name) + if field is None or field.read_only: + continue + target = apps.get_model(label) + linked = getattr(instance, name).all() if instance is not None else None + field.child_relation.queryset = scoped_for( + user, linkable_or_linked(target.objects.all(), linked), explicit=lookup, + ) + + +class ControlListSerializer(_ControlFieldsMixin, serializers.ModelSerializer): + requirement_count = serializers.SerializerMethodField() + + class Meta: + model = Control + fields = [ + "id", "scopes", "reference", "name", "theme", "nature", "automation", + "owner", "owner_name", "implemented_on", "effectiveness", "next_test_due", + "is_test_overdue", "is_effectiveness_stale", "requirement_count", "status", "created_at", + ] + read_only_fields = fields + + def get_requirement_count(self, obj): + annotated = getattr(obj, "_requirement_count", None) + return annotated if annotated is not None else obj.requirements.count() + + +# --- ControlTest ------------------------------------------------------------- + + +class ControlTestSerializer(_CleanedModelSerializer): + status = serializers.CharField(source="workflow_state", read_only=True) + control_reference = serializers.CharField(source="control.reference", read_only=True) + control_name = serializers.CharField(source="control.name", read_only=True) + tester_name = serializers.SerializerMethodField() + is_overdue = serializers.BooleanField(read_only=True) + latest_result = serializers.SerializerMethodField() + + class Meta: + model = ControlTest + fields = [ + "id", "reference", "control", "control_reference", "control_name", "name", "objective", + "method", "procedure", "acceptance_criteria", "frequency_months", "tester", "tester_name", + "first_due_date", "next_due_date", "activated_on", "is_overdue", "latest_result", + "status", "tags", "version", "created_by", "created_at", "updated_at", + ] + read_only_fields = [ + "id", "reference", "version", "created_by", "created_at", "updated_at", + # RG-CTL-07 : derived and stamped by the domain. + "next_due_date", "activated_on", + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + field = self.fields.get("control") + if field is None or field.read_only: + return + # The parent of a test, not a link : a proposed control takes tests + # (activation waits for it to be planned, RG-CTL-06); a retired one is + # refused in ``validate``. + field.queryset = scoped_for(_request_user(self), Control.objects.all()) + + def validate(self, attrs): + instance = _edited_instance(self) + control = attrs.get("control") + moved = control is not None and (instance is None or control.pk != instance.control_id) + if moved and control.is_retired: + raise serializers.ValidationError({"control": [_("A retired control takes no new test.")]}) + if moved and instance is not None and instance.runs.exists(): + raise serializers.ValidationError({"control": [_("A test that has runs cannot move to another control.")]}) + return super().validate(attrs) + + def get_tester_name(self, obj): + return _user_name(obj.tester) + + def get_latest_result(self, obj): + return obj.latest_result + + +class ControlTestListSerializer(ControlTestSerializer): + class Meta(ControlTestSerializer.Meta): + fields = [ + "id", "reference", "control", "control_reference", "control_name", "name", "method", + "frequency_months", "tester", "tester_name", "next_due_date", "is_overdue", + "latest_result", "status", "created_at", + ] + read_only_fields = fields + + +# --- ControlTestRun ---------------------------------------------------------- + + +def evidence_payload(item): + """One evidence row as every surface lists it : metadata, never the file.""" + return { + "id": str(item.pk), + "file_name": item.original_filename, + "size": item.size, + "sha256": item.sha256, + "description": item.description, + "uploaded_by": str(item.uploaded_by_id) if item.uploaded_by_id else None, + "uploaded_by_name": _user_name(item.uploaded_by), + "uploaded_at": item.uploaded_at.isoformat() if item.uploaded_at else None, + "download_url": reverse("controls:evidence-download", kwargs={"pk": item.pk}), + } + + +def raised_finding_payload(run): + """The finding raised from ``run`` (RG-CTL-16), or ``None``.""" + finding = next(iter(run.findings.all()), None) + if finding is None: + return None + return {"id": str(finding.pk), "reference": finding.reference, "finding_type": finding.finding_type} + + +class ControlTestRunSerializer(_CleanedModelSerializer): + status = serializers.CharField(source="workflow_state", read_only=True) + test_reference = serializers.CharField(source="test.reference", read_only=True) + test_name = serializers.CharField(source="test.name", read_only=True) + control = serializers.UUIDField(source="test.control_id", read_only=True) + control_reference = serializers.CharField(source="test.control.reference", read_only=True) + performed_by_name = serializers.SerializerMethodField() + reviewed_by_name = serializers.SerializerMethodField() + is_frozen = serializers.BooleanField(read_only=True) + needs_finding = serializers.SerializerMethodField() + finding = serializers.SerializerMethodField() + evidence = serializers.SerializerMethodField() + + class Meta: + model = ControlTestRun + fields = [ + "id", "reference", "test", "test_reference", "test_name", "control", "control_reference", + "performed_by", "performed_by_name", "performed_on", "period_start", "period_end", + "sample_description", "result", "observations", + "reviewed_by", "reviewed_by_name", "reviewed_at", "is_frozen", "needs_finding", "finding", + "evidence", "status", "tags", "version", "created_by", "created_at", "updated_at", + ] + read_only_fields = [ + "id", "reference", "version", "created_by", "created_at", "updated_at", + # RG-CTL-11 : stamped by the validation transition. + "reviewed_by", "reviewed_at", + ] + extra_kwargs = { + # Defaults to the test's tester, else the caller (control-test-run.md). + "performed_by": {"required": False}, + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + field = self.fields.get("test") + if field is None: + return + if _edited_instance(self) is not None: + # Moving a run to another test would move its evidence into another + # control's record and perimeter. + field.read_only = True + field.required = False + else: + field.queryset = scoped_for(_request_user(self), ControlTest.objects.select_related("control")) + + def validate(self, attrs): + instance = _edited_instance(self) + if instance is not None and instance.is_frozen: + errors = { + name: [RUN_FROZEN_MESSAGE] + for name in RUN_FROZEN_API_FIELDS + if name in attrs and attrs[name] != getattr(instance, name) + } + if errors: + raise serializers.ValidationError(errors) + return super().validate(attrs) + + def create(self, validated_data): + if not validated_data.get("performed_by"): + test = validated_data["test"] + validated_data["performed_by"] = test.tester or _request_user(self) + return super().create(validated_data) + + def get_performed_by_name(self, obj): + return _user_name(obj.performed_by) + + def get_reviewed_by_name(self, obj): + return _user_name(obj.reviewed_by) + + def get_needs_finding(self, obj): + return obj.needs_finding + + def get_finding(self, obj): + return raised_finding_payload(obj) + + def get_evidence(self, obj): + return [evidence_payload(item) for item in obj.evidence.all()] + + +class ControlTestRunListSerializer(ControlTestRunSerializer): + class Meta(ControlTestRunSerializer.Meta): + fields = [ + "id", "reference", "test", "test_reference", "test_name", "control", "control_reference", + "performed_by", "performed_by_name", "performed_on", "result", "reviewed_by", + "reviewed_by_name", "reviewed_at", "is_frozen", "status", "created_at", + ] + read_only_fields = fields diff --git a/controls/api/urls.py b/controls/api/urls.py new file mode 100644 index 00000000..9e862a69 --- /dev/null +++ b/controls/api/urls.py @@ -0,0 +1,25 @@ +# 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). + +Registered flat : a test is filtered by its control (``?control_id=``) +and a run by its test (``?test_id=``) rather than nested under them, so +every record keeps one stable URL. Evidence files have no route here : they +are uploaded through the web form and listed, metadata only, on their run. +""" + +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from . import views + +router = DefaultRouter() +router.register(r"controls", views.ControlViewSet) +router.register(r"tests", views.ControlTestViewSet) +router.register(r"runs", views.ControlTestRunViewSet) + +app_name = "controls-api" + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/controls/api/views.py b/controls/api/views.py new file mode 100644 index 00000000..157aca18 --- /dev/null +++ b/controls/api/views.py @@ -0,0 +1,310 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""DRF viewsets for module 8 (controls), mounted at ``/api/v1/controls/``. + +Rules stated once for the whole file : + +**Two permission features.** ``controls.control`` governs controls (and the +"create from requirements" action); ``controls.test`` governs tests, runs and +the "raise a finding" action, which needs ``controls.test.approve`` (README §4). + +**A state change is a transition.** No view writes ``workflow_state``. The +``/transition/`` endpoint comes from ``LifecycleAPIMixin`` and goes through each +model's ``transition_to(enforce_permission=True)`` with the authenticated user +as the actor, so the gates (RG-CTL-06, -10), the segregation of duties +(RG-CTL-11 : a run's performer cannot validate it, superusers included) and the +recomputations (RG-CTL-12) hold on this surface exactly as on the stepper. A +gate refusal reaches the client as a 400 with the model's translated reason; a +refused permission is a 403. + +**Tenancy.** Controls carry their own ``scopes``; tests and runs inherit +theirs through ``scope_parent_lookup`` (RG-CTL-19), which +``ScopeFilterAPIMixin`` resolves. Out-of-scope rows are 404. +""" + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db.models import Count, Min, Prefetch, Q +from django.db.models.deletion import ProtectedError +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers, status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import APIException, PermissionDenied +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from accounts.api.mixins import BatchCreateMixin, HistoryAPIMixin, LifecycleAPIMixin, ScopeFilterAPIMixin +from accounts.api.permissions import ModulePermission +from compliance.constants import FindingType +from compliance.models import Finding, Framework +from controls.constants import TEST_ACTIVE +from controls.models import Control, ControlTest, ControlTestEvidence, ControlTestRun +from controls.services import create_controls_from_requirements +from core.lifecycle import LifecycleError, LifecycleProtectedError, TransitionNotAllowedError +from core.transition_messages import transition_error_detail + +from .filters import ControlFilter, ControlTestFilter, ControlTestRunFilter +from .serializers import ( + ControlListSerializer, + ControlSerializer, + ControlTestListSerializer, + ControlTestRunListSerializer, + ControlTestRunSerializer, + ControlTestSerializer, + scoped_for, +) + + +#: RG-CTL-16 : a failed or partial run is a nonconformity or an observation; +#: improvement opportunities and strengths are not raised from a test. +RAISABLE_FINDING_TYPES = ( + FindingType.MAJOR_NON_CONFORMITY, + FindingType.MINOR_NON_CONFORMITY, + FindingType.OBSERVATION, +) + + +class Conflict(APIException): + """409 : the payload is fine, the register refuses the write (a step that + is not deletable, a row still referenced).""" + + status_code = status.HTTP_409_CONFLICT + default_detail = _("This element cannot be changed in its current lifecycle state.") + default_code = "conflict" + + +def _validation_error(exc): + if hasattr(exc, "message_dict"): + return serializers.ValidationError(exc.message_dict) + return serializers.ValidationError({"non_field_errors": list(exc.messages)}) + + +def _domain_response(exc): + """403 for a refused permission, 400 with the model's reason otherwise.""" + if isinstance(exc, TransitionNotAllowedError): + raise PermissionDenied(transition_error_detail(exc)) + return Response({"detail": transition_error_detail(exc)}, status=status.HTTP_400_BAD_REQUEST) + + +def _id_list(value, name): + """A list of ids from a request body, or ``None`` when absent.""" + if value in (None, ""): + return None + if not isinstance(value, list): + raise serializers.ValidationError({name: [_("Expected a list of identifiers.")]}) + return [str(item) for item in value] + + +class _ControlsViewSet(viewsets.ModelViewSet): + """The shared base : module permission, created-by stamp, governed writes.""" + + permission_classes = [IsAuthenticated, ModulePermission] + permission_module = "controls" + custom_action_map = { + "transition": "update", + "batch_create": "create", + "history": "read", + } + + def _guarded_save(self, serializer, **kwargs): + """Save, turning each governance refusal into its own status code + instead of a 500 : the models refuse a frozen run (RG-CTL-09) and a + run on an inactive test in ``save()``, and that guard is the real one.""" + try: + return serializer.save(**kwargs) + except DjangoValidationError as exc: + raise _validation_error(exc) from exc + except TransitionNotAllowedError as exc: + raise PermissionDenied(transition_error_detail(exc)) from exc + except LifecycleError as exc: + raise serializers.ValidationError({"non_field_errors": [transition_error_detail(exc)]}) from exc + + def perform_create(self, serializer): + self._guarded_save(serializer, created_by=self.request.user) + + def perform_update(self, serializer): + self._guarded_save(serializer) + + def perform_destroy(self, instance): + try: + instance.delete() + except LifecycleProtectedError as exc: + # Never `str(exc)` in a response body (CodeQL py/stack-trace-exposure). + raise Conflict(_("This element cannot be deleted in its current lifecycle state.")) from exc + except ProtectedError as exc: + raise Conflict(_("This element is still referenced and cannot be deleted.")) from exc + except DjangoValidationError as exc: + raise _validation_error(exc) from exc + + +# --- Control ----------------------------------------------------------------- + + +class ControlViewSet(BatchCreateMixin, ScopeFilterAPIMixin, LifecycleAPIMixin, HistoryAPIMixin, _ControlsViewSet): + """The register of controls : identity, links, derived effectiveness.""" + + queryset = ( + Control.objects.select_related("owner") + .prefetch_related( + "scopes", "tags", "requirements", "risks", "treatment_plans", "pacs_measures", + "support_assets", "objectives", "policies", + ) + .annotate( + _next_test_due=Min("tests__next_due_date", filter=Q(tests__workflow_state=TEST_ACTIVE)), + _requirement_count=Count("requirements", distinct=True), + ) + # An aggregated queryset drops Meta.ordering : restate it for stable pages. + .order_by("reference") + ) + filterset_class = ControlFilter + permission_feature = "control" + search_fields = ["reference", "name", "description"] + ordering_fields = [ + "reference", "name", "theme", "nature", "automation", "effectiveness", "workflow_state", + "implemented_on", "created_at", "updated_at", + ] + custom_action_map = {**_ControlsViewSet.custom_action_map, "from_requirements": "create"} + + def get_serializer_class(self): + if self.action == "list": + return ControlListSerializer + return ControlSerializer + + @action(detail=False, methods=["post"], url_path="from-requirements") + def from_requirements(self, request, **kwargs): + """Draft one control per uncovered applicable requirement (RG-CTL-05). + + Body : ``{"framework_id": "", "requirement_ids": [...], + "scope_ids": [...]}``; only ``framework_id`` is required. Without + ``requirement_ids`` every requirement of the framework is considered; + without ``scope_ids`` the controls take the framework's scopes. + Requirements already covered by a control (any step) or not applicable + are skipped and reported. Requires ``controls.control.create`` and the + framework readable by the caller (``compliance.framework.read`` and + its scopes). + """ + user = request.user + framework_id = request.data.get("framework_id") + if not framework_id: + raise serializers.ValidationError({"framework_id": [_("This field is required.")]}) + if not (user.is_superuser or user.has_perm("compliance.framework.read")): + raise PermissionDenied() + try: + framework = scoped_for(user, Framework.objects.all()).get(pk=framework_id) + except (Framework.DoesNotExist, DjangoValidationError, ValueError) as exc: + raise serializers.ValidationError({"framework_id": [_("Framework not found.")]}) from exc + requirement_ids = _id_list(request.data.get("requirement_ids"), "requirement_ids") + scope_ids = _id_list(request.data.get("scope_ids"), "scope_ids") + if scope_ids is not None: + from context.models import Scope + + allowed = set( + str(pk) for pk in scoped_for(user, Scope.objects.filter(pk__in=scope_ids)).values_list("pk", flat=True) + ) + unknown = [pk for pk in scope_ids if pk not in allowed] + if unknown: + raise serializers.ValidationError({"scope_ids": [_("Unknown scope or outside your scopes.")]}) + try: + created, skipped = create_controls_from_requirements( + framework, user, requirement_ids=requirement_ids, scope_ids=scope_ids, + ) + except DjangoValidationError as exc: + raise _validation_error(exc) from exc + context = self.get_serializer_context() + created = Control.objects.filter(pk__in=[c.pk for c in created]).prefetch_related("scopes") + return Response( + { + "framework_id": str(framework.pk), + "created": ControlListSerializer(created, many=True, context=context).data, + "skipped": [ + { + "id": str(requirement.pk), + "requirement_number": requirement.requirement_number, + "name": requirement.name, + "reason": "not_applicable" if not requirement.is_applicable else "already_covered", + } + for requirement in skipped + ], + }, + status=status.HTTP_201_CREATED if created else status.HTTP_200_OK, + ) + + +# --- ControlTest ------------------------------------------------------------- + + +class ControlTestViewSet(BatchCreateMixin, ScopeFilterAPIMixin, LifecycleAPIMixin, HistoryAPIMixin, _ControlsViewSet): + """How each control is tested; ``next_due_date`` is derived (RG-CTL-07).""" + + queryset = ControlTest.objects.select_related("control", "tester").prefetch_related("tags").all() + filterset_class = ControlTestFilter + permission_feature = "test" + http_method_names = ["get", "post", "patch", "delete", "head", "options"] + search_fields = ["reference", "name", "objective", "control__reference", "control__name"] + ordering_fields = [ + "reference", "name", "method", "frequency_months", "next_due_date", "workflow_state", "created_at", + ] + + def get_serializer_class(self): + if self.action == "list": + return ControlTestListSerializer + return ControlTestSerializer + + +# --- ControlTestRun ---------------------------------------------------------- + + +class ControlTestRunViewSet(BatchCreateMixin, ScopeFilterAPIMixin, LifecycleAPIMixin, HistoryAPIMixin, _ControlsViewSet): + """The runs of every test : frozen once submitted (RG-CTL-09), validated by + someone other than their performer (RG-CTL-11). Only a draft is deletable.""" + + queryset = ( + ControlTestRun.objects.select_related("test__control", "performed_by", "reviewed_by") + .prefetch_related( + "tags", + Prefetch("evidence", queryset=ControlTestEvidence.objects.select_related("uploaded_by")), + Prefetch("findings", queryset=Finding.objects.only("id", "reference", "finding_type", "control_test_run")), + ) + .all() + ) + filterset_class = ControlTestRunFilter + permission_feature = "test" + http_method_names = ["get", "post", "patch", "delete", "head", "options"] + search_fields = ["reference", "observations", "sample_description", "test__reference", "test__name"] + ordering_fields = ["reference", "performed_on", "result", "workflow_state", "reviewed_at", "created_at"] + custom_action_map = {**_ControlsViewSet.custom_action_map, "raise_finding": "approve"} + + def get_serializer_class(self): + if self.action == "list": + return ControlTestRunListSerializer + return ControlTestRunSerializer + + @action(detail=True, methods=["post"], url_path="raise-finding") + def raise_finding(self, request, **kwargs): + """Raise a finding from a validated partial or failed run (RG-CTL-16). + + Body : ``{"finding_type": "major_nc" | "minor_nc" | "observation", + "recommendation": "..."}``. Creates a finding with source + ``control_test``, linked to the run, the run's observations as its + description, the control's requirements as its related requirements and + the caller as the person who raised it. Offered once per run : a second + call is refused. Requires ``controls.test.approve``. + """ + run = self.get_object() + finding_type = (request.data.get("finding_type") or "").strip() + if finding_type not in RAISABLE_FINDING_TYPES: + raise serializers.ValidationError({ + "finding_type": [_("Choose a valid finding type : major_nc, minor_nc or observation.")], + }) + recommendation = request.data.get("recommendation") or "" + try: + finding = run.raise_finding(request.user, finding_type, recommendation) + except LifecycleError as exc: + return _domain_response(exc) + except DjangoValidationError as exc: + raise _validation_error(exc) from exc + from compliance.api.serializers import FindingSerializer + + return Response( + FindingSerializer(finding, context=self.get_serializer_context()).data, + status=status.HTTP_201_CREATED, + ) 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/forms.py b/controls/forms.py new file mode 100644 index 00000000..08611d37 --- /dev/null +++ b/controls/forms.py @@ -0,0 +1,530 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""Web forms of module 8 (controls). + +Four rules hold for every form here: + +**No form writes a derived or stamped field** : ``Control.effectiveness``, +``ControlTest.next_due_date`` / ``activated_on``, ``ControlTestRun.reviewed_*`` +and the evidence size and SHA-256 are written by the models +(RG-CTL-07, RG-CTL-11, RG-CTL-13, RG-CTL-14). The state only changes through +the lifecycle stepper. + +**Pickers offer ``linkable_or_linked()`` targets the user may see** +(RG-CTL-04). A link the user cannot see (outside their scopes) is not offered, +and it is kept on save rather than silently dropped. + +**A frozen record renders frozen** : a retired control or test, and a run past +``draft`` (RG-CTL-09), open with every field disabled and refuse to validate. + +**Plain text stays plain** : the procedure, acceptance criteria, sample and +observations are checked for emptiness by the lifecycle gates, so they are +plain text areas (``no-richtext``) rather than rich-text editors. +""" + +from django import forms +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db.models import Q +from django.utils.translation import gettext_lazy as _ + +from compliance.constants import FindingType +from compliance.models import Framework, Requirement +from context.models import Scope +from context.widgets import ScopeTreeWidget +from core.lifecycle import linkable, linkable_or_linked, reportable, resolve_lifecycle +from core.modal_forms import Step, SteppedFormMixin +from core.scoping import filter_queryset_by_scopes + +from .constants import ALLOWED_EVIDENCE_EXTENSIONS, RUN_DRAFT +from .models import Control, ControlTest, ControlTestEvidence, ControlTestRun + +FORM_WIDGET_ATTRS = {"class": "form-control"} +SELECT_ATTRS = {"class": "form-select"} +MULTISELECT_ATTRS = {**SELECT_ATTRS, "size": 5} +RICH_TEXT_ATTRS = {**FORM_WIDGET_ATTRS, "rows": 6, "class": "form-control rich-text"} + +# The finding types that fit a failed or partial test (RG-CTL-16) : a +# strength or an improvement opportunity is not what a failure raises. +RAISABLE_FINDING_TYPES = ( + FindingType.MAJOR_NON_CONFORMITY, + FindingType.MINOR_NON_CONFORMITY, + FindingType.OBSERVATION, +) + + +def _date_widget(): + return forms.DateInput(attrs={**FORM_WIDGET_ATTRS, "type": "date"}, format="%Y-%m-%d") + + +def _textarea(rows=3): + """A plain-text area : the shell turns every other ``textarea.form-control`` + into a rich-text editor, which would store HTML in a plain field.""" + return forms.Textarea(attrs={**FORM_WIDGET_ATTRS, "rows": rows, "class": "form-control no-richtext"}) + + +def _is_new(instance): + """UUID primary keys are set before the first save : ``pk`` is not the test.""" + return instance._state.adding + + +def _user_scope_ids(user): + if user is None or user.is_superuser: + return None + return user.get_allowed_scope_ids() + + +def scoped(qs, user): + """Restrict ``qs`` to what ``user`` may see (scope tenancy).""" + scope_ids = _user_scope_ids(user) + if scope_ids is None: + return qs + return filter_queryset_by_scopes(qs, scope_ids) + + +def _active_users(current_ids=()): + User = get_user_model() + return User.objects.filter(Q(is_active=True) | Q(pk__in=[i for i in current_ids if i])).order_by( + "last_name", "first_name", "email", + ) + + +def _freeze(form, note): + """Disable every field of a frozen record and say why : once at the top of + the drawer (``_frozen_note.html``), and on the first field for the page form.""" + for field in form.fields.values(): + field.disabled = True + first = next(iter(form.fields.values()), None) + if first is not None: + first.help_text = note + form.frozen_note = note + + +class FrozenFormMixin: + """A frozen form never validates : the refusal is a message on the form.""" + + frozen_note = None + + def clean(self): + cleaned = super().clean() + if self.frozen_note: + raise ValidationError(self.frozen_note) + return cleaned + + +class ScopedRelationsMixin: + """Scope-aware relation pickers that keep the links the user cannot see. + + ``relation_fields`` names the many-to-many fields whose picker offers the + linkable targets in the user's scopes plus the targets already linked + (RG-CTL-04). A target already linked but outside the user's scopes is not + offered, and :meth:`_save_m2m` adds it back, so editing a control never + drops a link its editor could not see. + """ + + relation_fields = () + + def __init__(self, *args, user=None, **kwargs): + self.user = user + super().__init__(*args, **kwargs) + self._hidden_links = {} + instance = self.instance + for name in self.relation_fields: + if name not in self.fields: + continue + field = self.fields[name] + linked = None if _is_new(instance) else getattr(instance, name).all() + field.queryset = linkable_or_linked(scoped(field.queryset.model._default_manager.all(), user), linked) + if linked is not None: + visible = set(field.queryset.values_list("pk", flat=True)) + hidden = set(linked.values_list("pk", flat=True)) - visible + if hidden: + self._hidden_links[name] = hidden + if "scopes" in self.fields: + self._init_scopes(user) + + def _init_scopes(self, user): + archived = [step.code for step in resolve_lifecycle(Scope).archived_steps] + qs = Scope.objects.exclude(workflow_state__in=archived) + scope_ids = _user_scope_ids(user) + if scope_ids is not None: + qs = qs.filter(id__in=scope_ids) + field = self.fields["scopes"] + field.queryset = qs + instance = self.instance + selected_ids = [] + if not _is_new(instance): + current = set(instance.scopes.values_list("pk", flat=True)) + selected_ids = list(current) + hidden = current - set(qs.values_list("pk", flat=True)) + if hidden: + self._hidden_links["scopes"] = hidden + if hasattr(self.data, "getlist") and self.is_bound: + selected_ids = self.data.getlist(self.add_prefix("scopes")) + elif self.initial.get("scopes"): + selected_ids = [getattr(s, "pk", s) for s in self.initial["scopes"]] + field.widget.build_tree_data(qs, selected_ids) + + def _save_m2m(self): + super()._save_m2m() + for name, pks in self._hidden_links.items(): + getattr(self.instance, name).add(*pks) + + +# --- Control ------------------------------------------------------------------ + + +class ControlForm(FrozenFormMixin, SteppedFormMixin, ScopedRelationsMixin, forms.ModelForm): + """A control's identity and links (RG-CTL-01, RG-CTL-04). Retired : frozen.""" + + relation_fields = ( + "requirements", "risks", "treatment_plans", "pacs_measures", + "support_assets", "objectives", "policies", + ) + + steps = [ + Step(_("Identity"), "shield-check", [ + "name", + "description", + ["theme", "nature", "automation"], + ["owner", "implemented_on"], + ]), + Step(_("Requirements and risks"), "list-check", [ + "requirements", + "risks", + "treatment_plans", + "pacs_measures", + ]), + Step(_("Coverage"), "diagram-3", [ + "support_assets", + "objectives", + "policies", + "scopes", + "tags", + ]), + ] + + class Meta: + model = Control + fields = [ + "name", "description", "theme", "nature", "automation", "owner", "implemented_on", + "requirements", "risks", "treatment_plans", "pacs_measures", + "support_assets", "objectives", "policies", "scopes", "tags", + ] + widgets = { + "name": forms.TextInput(attrs=FORM_WIDGET_ATTRS), + "description": forms.Textarea(attrs=RICH_TEXT_ATTRS), + "theme": forms.Select(attrs=SELECT_ATTRS), + "nature": forms.Select(attrs=SELECT_ATTRS), + "automation": forms.Select(attrs=SELECT_ATTRS), + "owner": forms.Select(attrs=SELECT_ATTRS), + "implemented_on": _date_widget(), + "requirements": forms.SelectMultiple(attrs=MULTISELECT_ATTRS), + "risks": forms.SelectMultiple(attrs=MULTISELECT_ATTRS), + "treatment_plans": forms.SelectMultiple(attrs=MULTISELECT_ATTRS), + "pacs_measures": forms.SelectMultiple(attrs=MULTISELECT_ATTRS), + "support_assets": forms.SelectMultiple(attrs=MULTISELECT_ATTRS), + "objectives": forms.SelectMultiple(attrs=MULTISELECT_ATTRS), + "policies": forms.SelectMultiple(attrs=MULTISELECT_ATTRS), + "scopes": ScopeTreeWidget(), + "tags": forms.SelectMultiple(attrs={**SELECT_ATTRS, "size": 4}), + } + help_texts = { + "name": _("What the organisation does, e.g. Multi-factor authentication on remote access."), + "description": _("How the control operates day to day : who does what, with which tool, how often."), + "theme": _("The ISO/IEC 27002:2022 theme of the control."), + "nature": _("Whether the control prevents, detects or corrects an event."), + "automation": _("How much of the control runs without a person."), + "owner": _("Accountable for the control operating; receives the test reminders when a test has no tester."), + "implemented_on": _("Set when the control is declared implemented. It can be earlier, never in the future."), + "requirements": _("The requirements this control satisfies, from any framework."), + "risks": _("The risks this control treats."), + "treatment_plans": _("The treatment plans that create or reinforce it."), + "pacs_measures": _("The EBIOS RM security measures that create or reinforce it."), + "support_assets": _("Where the control applies."), + "objectives": _("The objectives it contributes to."), + "policies": _("The documents that describe it : procedure, standard, policy."), + "scopes": _("The parts of the organisation where the control applies."), + "tags": _("Free-form labels for filtering and grouping."), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + instance = self.instance + self.fields["owner"].queryset = _active_users([instance.owner_id]) + self.fields["requirements"].queryset = self.fields["requirements"].queryset.select_related("framework").order_by( + "framework__name", "requirement_number", "name", + ) + self.fields["requirements"].label_from_instance = _requirement_label + if not _is_new(instance) and instance.is_terminal_state: + _freeze(self, _("A retired control is a closed record and can no longer be edited.")) + + +def _requirement_label(requirement): + framework = requirement.framework + prefix = framework.short_name or framework.name + number = f"{requirement.requirement_number} " if requirement.requirement_number else "" + return f"{prefix} : {number}{requirement.name}" + + +def _checklist_label(requirement): + if requirement.requirement_number: + return f"{requirement.requirement_number} {requirement.name}" + return requirement.name + + +class RequirementChecklistWidget(forms.CheckboxSelectMultiple): + """The requirements of a framework as a bounded checklist.""" + + template_name = "controls/widgets/requirement_checklist.html" + option_template_name = "controls/widgets/requirement_checklist_option.html" + + +class FromRequirementsForm(SteppedFormMixin, forms.Form): + """RG-CTL-05 : pick a framework, then the uncovered requirements to bootstrap. + + The checklist lists the framework's linkable, applicable requirements that + no control covers yet; all are ticked by default. The service re-checks + each one, so a requirement covered in the meantime is skipped, not doubled. + """ + + steps = [Step(_("Requirements"), "list-check", ["framework", "requirements"])] + + framework = forms.ModelChoiceField( + label=_("Framework"), queryset=Framework.objects.none(), + widget=forms.Select(attrs=SELECT_ATTRS), + help_text=_("The framework whose requirements the new controls answer. They take its scopes."), + ) + requirements = forms.ModelMultipleChoiceField( + label=_("Requirements without a control"), queryset=Requirement.objects.none(), required=False, + widget=RequirementChecklistWidget(), + help_text=_("One proposed control is created for each ticked requirement, named after it."), + ) + + def __init__(self, *args, user=None, framework=None, reload_url=None, **kwargs): + super().__init__(*args, **kwargs) + self.user = user + frameworks = scoped(reportable(Framework.objects.all()), user).order_by("name") + field = self.fields["framework"] + field.queryset = frameworks + if reload_url: + # In the drawer, choosing a framework reloads the form with its checklist. + field.widget.attrs.update({ + "hx-get": reload_url, "hx-target": "#drawer-form-content", + "hx-swap": "innerHTML", "hx-trigger": "change", "hx-disabled-elt": "this", + }) + if framework is None and self.is_bound: + framework = self._bound_framework(frameworks) + self.framework = framework + requirements = self.fields["requirements"] + if framework is not None: + # Any requirement of the framework validates (one covered in the + # meantime is then reported as skipped by the service); only the + # uncovered ones are listed. + uncovered = list(self.uncovered_requirements(framework)) + requirements.queryset = framework.requirements.all() + requirements.widget.choices = [(r.pk, _checklist_label(r)) for r in uncovered] + self.has_uncovered = bool(uncovered) + if not self.is_bound: + self.initial["framework"] = framework.pk + self.initial["requirements"] = [r.pk for r in uncovered] + else: + requirements.help_text = _("Choose a framework first : its requirements without a control are listed here.") + + def _bound_framework(self, frameworks): + raw = self.data.get(self.add_prefix("framework")) + if not raw: + return None + try: + return frameworks.filter(pk=raw).first() + except (ValueError, ValidationError): + return None + + @staticmethod + def uncovered_requirements(framework): + return ( + linkable(framework.requirements.filter(is_applicable=True)) + .filter(controls__isnull=True) + .order_by("requirement_number", "created_at") + ) + + def clean(self): + cleaned = super().clean() + if cleaned.get("framework") is not None and not cleaned.get("requirements"): + if getattr(self, "has_uncovered", False): + self.add_error("requirements", _("Tick at least one requirement.")) + else: + self.add_error("requirements", _("Every applicable requirement of this framework already has a control.")) + return cleaned + + +# --- ControlTest -------------------------------------------------------------- + + +class ControlTestForm(FrozenFormMixin, SteppedFormMixin, forms.ModelForm): + """How a control is tested (RG-CTL-06). The next due date is derived (RG-CTL-07).""" + + steps = [ + Step(_("Test"), "clipboard-check", [ + "name", + ["method", "frequency_months"], + "objective", + ]), + Step(_("Procedure"), "list-ol", [ + "procedure", + "acceptance_criteria", + ]), + Step(_("Planning"), "calendar-event", [ + ["tester", "first_due_date"], + "tags", + ]), + ] + + class Meta: + model = ControlTest + fields = [ + "name", "method", "frequency_months", "objective", "procedure", "acceptance_criteria", + "tester", "first_due_date", "tags", + ] + widgets = { + "name": forms.TextInput(attrs=FORM_WIDGET_ATTRS), + "method": forms.Select(attrs=SELECT_ATTRS), + "frequency_months": forms.NumberInput(attrs={**FORM_WIDGET_ATTRS, "min": 1, "max": 36}), + "objective": _textarea(2), + "procedure": _textarea(6), + "acceptance_criteria": _textarea(3), + "tester": forms.Select(attrs=SELECT_ATTRS), + "first_due_date": _date_widget(), + "tags": forms.SelectMultiple(attrs={**SELECT_ATTRS, "size": 4}), + } + help_texts = { + "name": _("e.g. Quarterly sample of remote logins."), + "method": _("How the evidence is obtained : inquiry, observation, inspection, reperformance or an automated check."), + "frequency_months": _("Months between two runs, from 1 to 36. The next run falls due this long after the last validated one."), + "objective": _("What the test establishes : the design of the control, or that it operates effectively."), + "procedure": _("The steps the tester follows. Required to activate the test."), + "acceptance_criteria": _("What a pass looks like. Required to activate the test : a test with no pass criterion proves nothing."), + "tester": _("Performs the runs by default and receives the due reminders."), + "first_due_date": _("When the first run is due. Leave empty to make it due on activation."), + "tags": _("Free-form labels for filtering and grouping."), + } + + def __init__(self, *args, user=None, **kwargs): + self.user = user + super().__init__(*args, **kwargs) + self.fields["tester"].queryset = _active_users([self.instance.tester_id]) + if not _is_new(self.instance) and self.instance.is_terminal_state: + _freeze(self, _("A retired test is a closed record and can no longer be edited.")) + + +# --- ControlTestRun ----------------------------------------------------------- + + +class ControlTestRunForm(FrozenFormMixin, SteppedFormMixin, forms.ModelForm): + """A run, editable in ``draft`` only (RG-CTL-09); RG-CTL-10 is checked at submission.""" + + steps = [ + Step(_("Execution"), "person-check", [ + ["performed_by", "performed_on"], + ["period_start", "period_end"], + "sample_description", + ]), + Step(_("Result"), "clipboard-data", [ + "result", + "observations", + ]), + ] + + class Meta: + model = ControlTestRun + fields = [ + "performed_by", "performed_on", "period_start", "period_end", + "sample_description", "result", "observations", + ] + widgets = { + "performed_by": forms.Select(attrs=SELECT_ATTRS), + "performed_on": _date_widget(), + "period_start": _date_widget(), + "period_end": _date_widget(), + "sample_description": _textarea(3), + "result": forms.Select(attrs=SELECT_ATTRS), + "observations": _textarea(5), + } + help_texts = { + "performed_by": _("Who performed the test. They cannot validate their own run."), + "performed_on": _("The date of the test. It cannot be in the future."), + "period_start": _("The start of the period the test covers, if any."), + "period_end": _("The end of the period the test covers. It cannot be after the test date."), + "sample_description": _("The population and the sample tested, e.g. 25 of the 1,240 remote logins of the quarter."), + "result": _("Required to submit the run for review."), + "observations": _("What was found. Required to submit a partial or failed result."), + } + + def __init__(self, *args, user=None, test=None, **kwargs): + self.user = user + super().__init__(*args, **kwargs) + if test is not None and _is_new(self.instance): + self.instance.test = test + self.fields["performed_by"].queryset = _active_users([self.instance.performed_by_id]) + self.fields["result"].choices = [("", _("Not recorded yet"))] + list(self.fields["result"].choices)[1:] + if not _is_new(self.instance) and self.instance.workflow_state != RUN_DRAFT: + _freeze(self, _("A submitted test run is frozen : its content can no longer be changed.")) + + +# --- Evidence ----------------------------------------------------------------- + + +class EvidenceForm(SteppedFormMixin, forms.ModelForm): + """One evidence file (RG-CTL-13). Size and SHA-256 are stamped by the model.""" + + steps = [Step(_("Evidence"), "paperclip", ["file", "description"])] + + class Meta: + model = ControlTestEvidence + fields = ["file", "description"] + widgets = { + "file": forms.ClearableFileInput(attrs={ + **FORM_WIDGET_ATTRS, "accept": ",".join(f".{ext}" for ext in ALLOWED_EVIDENCE_EXTENSIONS), + }), + "description": forms.TextInput(attrs=FORM_WIDGET_ATTRS), + } + help_texts = { + "file": _("An export, a screenshot, a signed review sheet (25 MB max). Its SHA-256 is recorded."), + "description": _("What the file shows, e.g. Login export for the third quarter."), + } + + def __init__(self, *args, user=None, run=None, **kwargs): + self.user = user + super().__init__(*args, **kwargs) + if run is not None: + self.instance.run = run + self.instance.uploaded_by = user + self.fields["file"].required = True + + def save(self, commit=True): + upload = self.cleaned_data.get("file") + if upload is not None and not self.instance.original_filename: + self.instance.original_filename = (getattr(upload, "name", "") or "")[:255] + return super().save(commit=commit) + + +# --- Finding (RG-CTL-16) ------------------------------------------------------ + + +class RaiseFindingForm(SteppedFormMixin, forms.Form): + steps = [Step(_("Finding"), "exclamation-diamond", ["finding_type", "recommendation"])] + + finding_type = forms.ChoiceField( + label=_("Finding type"), widget=forms.Select(attrs=SELECT_ATTRS), + help_text=_("A judgement on the gap : a major or minor nonconformity, or an observation."), + ) + recommendation = forms.CharField( + label=_("Recommendation"), required=False, widget=_textarea(4), + help_text=_("Optional : what should be done to close the gap. The run's observations become the finding's description."), + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["finding_type"].choices = [(value.value, value.label) for value in RAISABLE_FINDING_TYPES] + self.fields["finding_type"].initial = FindingType.MINOR_NON_CONFORMITY + diff --git a/controls/integrations.py b/controls/integrations.py new file mode 100644 index 00000000..00a8581e --- /dev/null +++ b/controls/integrations.py @@ -0,0 +1,103 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""Read helpers behind the *Controls* reverse cards (README §8). + +A requirement, a risk, a treatment plan, a PACS measure, a support asset, an +objective and a document each show the controls linked to them, through the +shared ``controls/includes/controls_card.html``. The card lists controls in +**any** step with their state badge (RG-CTL-04), a retired one flagged and +sorted last (RG-CTL-03), each with its effectiveness (RG-CTL-20 on the risk +and treatment pages), filtered by the reader's scopes and shown only to a +holder of ``controls.control.read``. +""" + +from django.db.models import Case, IntegerField, Min, Q, Value, When +from django.utils import timezone + +from controls.constants import CONTROL_RETIRED, TEST_ACTIVE, ControlEffectiveness + +READ_PERMISSION = "controls.control.read" + + +def can_read_controls(user): + return bool(user and user.is_authenticated and (user.is_superuser or user.has_perm(READ_PERMISSION))) + + +def _scoped(qs, user): + if user.is_superuser: + return qs + scope_ids = user.get_allowed_scope_ids() + if scope_ids is None: + return qs + from core.scoping import filter_queryset_by_scopes + + return filter_queryset_by_scopes(qs, scope_ids) + + +def _decorate(controls): + """Flag a stale effectiveness (RG-CTL-15) from the annotated earliest due + date of the active tests, without one query per row.""" + today = timezone.localdate() + for control in controls: + due = getattr(control, "next_active_due", None) + control.effectiveness_stale = bool( + not control.is_retired + and due is not None + and due < today + and control.effectiveness != ControlEffectiveness.NOT_TESTED + ) + return controls + + +def controls_queryset(base, user): + """``base`` (a ``Control`` queryset) restricted to the reader's scopes, + retired controls last, with what the card needs in a single query.""" + qs = ( + _scoped(base, user) + .select_related("owner") + .annotate( + next_active_due=Min("tests__next_due_date", filter=Q(tests__workflow_state=TEST_ACTIVE)), + retired_rank=Case( + When(workflow_state=CONTROL_RETIRED, then=Value(1)), + default=Value(0), + output_field=IntegerField(), + ), + ) + .order_by("retired_rank", "reference") + ) + return qs + + +def linked_controls(obj, user): + """The controls linked to ``obj`` through its ``controls`` reverse + accessor, or ``None`` when ``user`` may not read controls (the card is + then not rendered at all).""" + if not can_read_controls(user): + return None + return _decorate(list(controls_queryset(obj.controls.all(), user))) + + +def linked_controls_for_measures(measures, user): + """The controls linked to any of ``measures`` (the PACS measures of an + EBIOS RM study, shown together on workshop 5), each carrying in + ``linked_via`` the references of the measures it serves.""" + if not can_read_controls(user): + return None + from controls.models import Control + + measures = list(measures) + if not measures: + return [] + by_pk = {m.pk: m for m in measures} + controls = _decorate(list(controls_queryset( + Control.objects.filter(pacs_measures__in=list(by_pk)).distinct(), user, + ))) + links = Control.pacs_measures.through.objects.filter( + control_id__in=[c.pk for c in controls], pacsmeasure_id__in=list(by_pk), + ).values_list("control_id", "pacsmeasure_id") + via = {} + for control_id, measure_id in links: + via.setdefault(control_id, []).append(by_pk[measure_id].reference) + for control in controls: + control.linked_via = sorted(via.get(control.pk, [])) + return controls 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/management/commands/controls_sweep.py b/controls/management/commands/controls_sweep.py new file mode 100644 index 00000000..2beaf024 --- /dev/null +++ b/controls/management/commands/controls_sweep.py @@ -0,0 +1,127 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""Daily sweep of module 8 (controls) : test reminders and effectiveness. + +Designed to be run once a day by cron, like the other deadline sweeps (see +docs/technical/operations.md). It changes no lifecycle state : overdue is +always derived from the date (RG-CTL-07), so the sweep only tells people and +keeps the derived effectiveness in step (RG-CTL-14). + +- ``CONTROL_TEST_DUE`` : an active test whose next due date falls within + ``TEST_DUE_REMINDER_DAYS``; sent once per cycle to the tester, else the + control owner. +- ``CONTROL_TEST_OVERDUE`` : an active test whose next due date has passed; + sent to the tester and the control owner once, then again every + ``OVERDUE_REMINDER_INTERVAL_DAYS`` while it stays overdue. +- Every control that counts in reports has its effectiveness recomputed + (idempotent, written only on change). + +``ControlTest.last_reminded_at`` records each reminder, written with a +queryset ``update()`` : it is a system stamp, not an edit of the test, so it +neither bumps the version nor writes a history row, and never runs the +model's ``save()`` (which re-derives the due date). "Active" and "reportable" +come from the lifecycles (``reportable()``), never a literal. Idempotent : +running it twice the same day sends nothing the second time. +""" + +from datetime import timedelta + +from django.core.management.base import BaseCommand +from django.utils import timezone + +from controls.constants import OVERDUE_REMINDER_INTERVAL_DAYS, TEST_DUE_REMINDER_DAYS +from controls.models import Control, ControlTest +from controls.notifications import notify_test_due +from core.lifecycle import reportable + + +def due_reminder_needed(test, today): + """Whether the "due soon" reminder of the test's current cycle is owed. + + The cycle is identified by its due date : a reminder sent on or after + the start of this cycle's window already covered it. A validated run + moves the due date forward, which opens a new cycle.""" + window_start = test.next_due_date - timedelta(days=TEST_DUE_REMINDER_DAYS) + last = timezone.localdate(test.last_reminded_at) if test.last_reminded_at else None + return last is None or last < window_start + + +def overdue_reminder_needed(test, today): + """Whether an overdue reminder is owed : the first one (no reminder sent + after the due date yet), then one every ``OVERDUE_REMINDER_INTERVAL_DAYS``.""" + last = timezone.localdate(test.last_reminded_at) if test.last_reminded_at else None + if last is None or last <= test.next_due_date: + return True + return (today - last).days >= OVERDUE_REMINDER_INTERVAL_DAYS + + +def _has_recipient(test): + return bool(test.tester_id or test.control.owner_id) + + +class Command(BaseCommand): + help = ( + "Send the control test reminders (due within " + f"{TEST_DUE_REMINDER_DAYS} days, overdue once then every " + f"{OVERDUE_REMINDER_INTERVAL_DAYS} days) and recompute the effectiveness " + "of every reportable control. Run daily." + ) + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Report the reminders and effectiveness changes without writing anything.", + ) + + def handle(self, *args, **options): + today = timezone.localdate() + dry_run = options["dry_run"] + due, overdue = self._sweep_tests(today, dry_run) + changed = self._recompute_effectiveness(dry_run) + verb = "Would send" if dry_run else "Sent" + self.stdout.write(self.style.SUCCESS( + f"{verb} {due} test-due and {overdue} test-overdue reminder(s); " + f"{changed} control effectiveness value(s) {'would change' if dry_run else 'updated'}." + )) + + # --- Reminders (README §7) --------------------------------------------- + + def _sweep_tests(self, today, dry_run): + due_count = overdue_count = 0 + active = ( + reportable(ControlTest.objects.filter(next_due_date__isnull=False)) + .select_related("control__owner", "tester") + .order_by("next_due_date", "reference") + ) + horizon = today + timedelta(days=TEST_DUE_REMINDER_DAYS) + now = timezone.now() + for test in active.filter(next_due_date__gte=today, next_due_date__lte=horizon): + if not due_reminder_needed(test, today) or not _has_recipient(test): + continue + due_count += 1 + self.stdout.write(f" test due : {test.reference} on {test.next_due_date.isoformat()}") + if not dry_run and notify_test_due(test, overdue=False): + ControlTest.objects.filter(pk=test.pk).update(last_reminded_at=now) + for test in active.filter(next_due_date__lt=today): + if not overdue_reminder_needed(test, today) or not _has_recipient(test): + continue + overdue_count += 1 + self.stdout.write(f" test overdue : {test.reference} since {test.next_due_date.isoformat()}") + if not dry_run and notify_test_due(test, overdue=True): + ControlTest.objects.filter(pk=test.pk).update(last_reminded_at=now) + return due_count, overdue_count + + # --- Effectiveness (RG-CTL-14) ----------------------------------------- + + def _recompute_effectiveness(self, dry_run): + changed = 0 + for control in reportable(Control.objects.all()).order_by("reference"): + if dry_run: + if control.compute_effectiveness() != control.effectiveness: + changed += 1 + continue + if control.recompute_effectiveness(): + changed += 1 + self.stdout.write(f" effectiveness : {control.reference} -> {control.effectiveness}") + return changed 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/templates/controls/_frozen_note.html b/controls/templates/controls/_frozen_note.html new file mode 100644 index 00000000..6156c33e --- /dev/null +++ b/controls/templates/controls/_frozen_note.html @@ -0,0 +1,2 @@ +{% comment %}The reason a record opens read-only (a retired control or test, a submitted run).{% endcomment %} +{% if form.frozen_note %}
{{ form.frozen_note }}
{% endif %} diff --git a/controls/templates/controls/control_confirm_delete_modal.html b/controls/templates/controls/control_confirm_delete_modal.html new file mode 100644 index 00000000..e48bb5bb --- /dev/null +++ b/controls/templates/controls/control_confirm_delete_modal.html @@ -0,0 +1,18 @@ +{% extends delete_shell|default:"includes/confirm_delete_modal.html" %} +{% load i18n %} +{% comment %} + Delete a control. Only a proposed control without tests is deletable; any + other is retired through its lifecycle, which keeps its history and its + tests' runs on record. +{% endcomment %} +{% block delete_title %}{% trans "Delete control" %}{% endblock %} +{% block delete_message %}{% blocktrans with reference=object.reference name=object.name %}Are you sure you want to delete {{ reference }} - {{ name }}?{% endblocktrans %}{% endblock %} +{% block delete_details %} + {% if not object.is_deletable %} +

{% trans "This control is no longer proposed : retire it through its lifecycle instead." %}

+ {% endif %} + {% with n=object.tests.count %}{% if n %} +

{% blocktrans count counter=n %}It has {{ counter }} test : delete the test first, or retire the control.{% plural %}It has {{ counter }} tests : delete them first, or retire the control.{% endblocktrans %}

+ {% endif %}{% endwith %} +{% endblock %} +{% block delete_button_attrs %}{% if not object.is_deletable or object.tests.exists %} disabled{% endif %}{% endblock %} diff --git a/controls/templates/controls/control_detail.html b/controls/templates/controls/control_detail.html new file mode 100644 index 00000000..df0ba9d7 --- /dev/null +++ b/controls/templates/controls/control_detail.html @@ -0,0 +1,218 @@ +{% extends "base.html" %} +{% load i18n ui help_tags workflow_tags accounts_tags controls_tags %} + +{% comment %} +One security control : how it operates, how it is tested and with what result, +which requirements it satisfies, which risks it treats, where it applies and +which documents describe it. Its effectiveness is derived from the validated +runs of its active tests (RG-CTL-14), never declared; the state changes only +through the stepper. +{% endcomment %} + +{% block title %}{{ control.name }} - Cairn{% endblock %} + +{% block content %} +{% has_perm "controls.control.update" as can_update_control %} +{% has_perm "controls.control.delete" as can_delete_control %} +{% has_perm "controls.test.create" as can_create_test %} +{% page_header control.name reference=control.reference nav="controls:control-list" accent="controls" %} + {% include "includes/history_trigger.html" %} + {% if can_delete_control and control.is_deletable %} + + {% endif %} + {% if can_update_control and not control.is_terminal_state %} + {% trans "Edit" %} + {% endif %} +{% endpage_header %} +{% include "includes/history_panel.html" %} +{% help_modal "controls.control_detail" %} + +{% include "includes/lifecycle_stepper.html" %} + +{% if is_stale %} +
{% blocktrans with date=next_test_due|date %}A test of this control was due on {{ date }} : its effectiveness rests on a test that is now overdue.{% endblocktrans %}
+{% elif is_test_overdue %} +
{% blocktrans with date=next_test_due|date %}A test of this control was due on {{ date }}. Record a run.{% endblocktrans %}
+{% endif %} + +{% detail_layout %} + {% slot rail %} + {% kpi_tile _("Lifecycle stage") control.lifecycle_label icon="signpost-split" %} + {% kpi_tile _("Effectiveness") control.get_effectiveness_display icon="patch-check" tone=control.effectiveness|badge_variant:"effectiveness" size="sm" %} + {% if is_test_overdue %} + {% kpi_tile _("Next test due") next_test_due icon="calendar-x" tone="danger" %} + {% else %} + {% kpi_tile _("Next test due") next_test_due icon="calendar-event" tone="neutral" %} + {% endif %} + {% kpi_tile _("Implemented on") control.implemented_on icon="calendar-check" tone="neutral" %} + {% endslot %} + + {# ── How the control operates ── #} + {% section_card _("Description") icon="text-left" %} + {% if control.description %}
{{ control.description|controls_rich }}
{% endif %} + {% meta_list cols=3 %} + {% meta_item _("Theme") control.get_theme_display %} + {% meta_item _("Nature") control.get_nature_display %} + {% meta_item _("Automation") control.get_automation_display %} + {% meta_item _("Owner") %}{% if control.owner %}{% user_badge control.owner size=28 link=True block=True %}{% endif %}{% endmeta_item %} + {% meta_item _("Scopes") %}{% if control.scopes.all %}
{% for scope in control.scopes.all %}{% badge variant="secondary" label=scope.name %}{% endfor %}
{% endif %}{% endmeta_item %} + {% meta_item _("Effectiveness updated at") control.effectiveness_updated_at %} + {% meta_item _("Tags") wide=True hide_empty=True %}{% tag_list control.tags.all %}{% endmeta_item %} + {% endmeta_list %} + {% endsection_card %} + + {# ── How it is tested (RG-CTL-06, RG-CTL-07) ── #} + {% section_card _("Tests") icon="clipboard-check" count=tests|length flush=True %} + {% slot actions %} + {% if can_create_test and not control.is_terminal_state %} + + {% endif %} + {% endslot %} + {% if tests %} +
+ + + + + + + + + + + + + {% for t in tests %} + + + + + + + + + {% endfor %} + +
{% trans "Test" %}{% trans "Method" %}{% trans "Frequency" %}{% trans "Latest result" %}{% trans "Next due" %}{% trans "Status" %}
+ + {{ t.name }} + {{ t.reference }} + + {{ t.get_method_display }}{% blocktrans count counter=t.frequency_months %}Every month{% plural %}Every {{ counter }} months{% endblocktrans %}{% if t.latest_result_value %}{% badge t.latest_result_value type="run_result" %}{% else %}{% dash %}{% endif %}{% if t.is_overdue %}{% badge variant="danger" icon="calendar-x" label=t.next_due_date|date %}{% else %}{{ t.next_due_date|or_dash }}{% endif %}{% workflow_badge t %}
+
+ {% else %} +
{% empty_state compact=True message=_("No test yet. A test says how the control is checked and how often; its validated runs make the control's effectiveness.") %}
+ {% endif %} + {% endsection_card %} + + {# ── Recent runs ── #} + {% section_card _("Recent runs") icon="clock-history" count=run_count flush=True hide_empty=True %} + {% if recent_runs %} +
+ + + + + + + + + + + + {% for r in recent_runs %} + + + + + + + + {% endfor %} + +
{% trans "Run" %}{% trans "Test" %}{% trans "Performed by" %}{% trans "Result" %}{% trans "Status" %}
+ + {{ r.performed_on }} + {{ r.reference }} + + {{ r.test.name }}{% user_badge r.performed_by size=24 %}{% choice_badge r "result" type="run_result" %}{% workflow_badge r %}
+
+ {% endif %} + {% endsection_card %} + + {# ── What it satisfies ── #} + {% section_card _("Requirements") icon="list-check" count=requirement_count hide_empty=True %} + {% for group in requirement_groups %} + {% subhead group.framework.short_name|default:group.framework.name icon="journal-bookmark" count=group.requirements|length %} + + {% endfor %} + {% endsection_card %} + + {# ── Risks and treatment (RG-CTL-20 : shown as the justification of the residual level) ── #} + {% section_card _("Risks and treatment") icon="shield-exclamation" hide_empty=True %} + {% if risks %} + {% subhead _("Risks") icon="shield-exclamation" count=risks|length %} +
+ + + + + + + + + + {% for risk in risks %} + + + + + + {% endfor %} + +
{% trans "Risk" %}{% trans "Residual level" %}{% trans "Status" %}
{% url 'risks:risk-detail' risk.pk as risk_url %}{% ref_link risk url=risk_url %}{{ risk.residual_risk_level|or_dash }}{% workflow_badge risk %}
+
+ {% endif %} + {% if treatment_plans %} + {% subhead _("Treatment plans") icon="clipboard2-pulse" count=treatment_plans|length %} + + {% endif %} + {% if pacs_measures %} + {% subhead _("PACS measures") icon="shield-plus" count=pacs_measures|length %} + + {% endif %} + {% endsection_card %} + + {% section_card _("Applies to") icon="hdd-network" count=support_assets|length hide_empty=True %} + {% if support_assets %} + + {% endif %} + {% endsection_card %} + + {% section_card _("Objectives") icon="bullseye" count=objectives|length hide_empty=True %} + {% if objectives %} + + {% endif %} + {% endsection_card %} + + {% section_card _("Documents") icon="file-earmark-text" count=policies|length hide_empty=True %} + {% if policies %} + + {% endif %} + {% endsection_card %} + + {% record_card control %} +{% enddetail_layout %} +{% endblock %} diff --git a/controls/templates/controls/control_form.html b/controls/templates/controls/control_form.html new file mode 100644 index 00000000..f115e6f1 --- /dev/null +++ b/controls/templates/controls/control_form.html @@ -0,0 +1 @@ +{% extends "includes/page_form.html" %} diff --git a/controls/templates/controls/control_form_modal.html b/controls/templates/controls/control_form_modal.html new file mode 100644 index 00000000..22a786ab --- /dev/null +++ b/controls/templates/controls/control_form_modal.html @@ -0,0 +1,7 @@ +{% extends "includes/modal_form.html" %} +{% comment %} + Create a control or edit its identity and links, in the drawer. Fields and + steps come from ControlForm. A retired control opens frozen. +{% endcomment %} +{% block modal_title_icon %}{% endblock %} +{% block modal_form_fields %}{% include "controls/_frozen_note.html" %}{{ block.super }}{% endblock %} diff --git a/controls/templates/controls/control_from_requirements_form.html b/controls/templates/controls/control_from_requirements_form.html new file mode 100644 index 00000000..f115e6f1 --- /dev/null +++ b/controls/templates/controls/control_from_requirements_form.html @@ -0,0 +1 @@ +{% extends "includes/page_form.html" %} diff --git a/controls/templates/controls/control_from_requirements_form_modal.html b/controls/templates/controls/control_from_requirements_form_modal.html new file mode 100644 index 00000000..e001bf6a --- /dev/null +++ b/controls/templates/controls/control_from_requirements_form_modal.html @@ -0,0 +1,6 @@ +{% extends "includes/modal_form.html" %} +{% comment %} + RG-CTL-05 : create one proposed control per ticked requirement of a + framework. Changing the framework reloads this drawer with its checklist. +{% endcomment %} +{% block modal_title_icon %}{% endblock %} diff --git a/controls/templates/controls/control_list.html b/controls/templates/controls/control_list.html new file mode 100644 index 00000000..788ce10e --- /dev/null +++ b/controls/templates/controls/control_list.html @@ -0,0 +1,60 @@ +{% extends "base.html" %} +{% load i18n ui help_tags table_tags accounts_tags %} + +{% comment %} +The register of security controls (ISO/IEC 27001:2022 6.1.3, 8.1, 9.1). Each +row answers what an auditor asks of the Statement of Applicability : is the +control implemented, who owns it, does it work (effectiveness, derived from +validated test runs) and when is it next tested (red when a test is overdue). +The list view answers its own HTMX table refresh (no table-body route). +{% endcomment %} + +{% block title %}{% trans "Controls" %} - Cairn{% endblock %} + +{% block content %} +{% has_perm "controls.control.create" as can_create_control %} +{% page_header _("Controls") nav="controls:control-list" accent="controls" %} + {% if can_create_control %} + + + {% endif %} +{% endpage_header %} +{% help_modal "controls.control_list" %} + +
+
+
+ {% include "includes/list_toolbar.html" %} +
+ + + + {% sortable_th "reference" "Ref." col="reference" %} + {% sortable_th "name" "Name" col="name" %} + {% sortable_th "owner" "Owner" col="owner" %} + {% sortable_th "status" "Status" col="status" %} + {% sortable_th "effectiveness" "Effectiveness" col="effectiveness" %} + {% sortable_th "next_due" "Next test due" col="next_due" %} + + + + + {% include "controls/control_table_body.html" %} + +
{% trans "Actions" %}
+
+
+ + {% include "includes/pagination.html" %} +
+ +
+ +{% include "includes/list_filter_offcanvas.html" with filter_list_url=request.path %} +{% endblock %} diff --git a/controls/templates/controls/control_table_body.html b/controls/templates/controls/control_table_body.html new file mode 100644 index 00000000..e003d4b0 --- /dev/null +++ b/controls/templates/controls/control_table_body.html @@ -0,0 +1,40 @@ +{% load i18n ui workflow_tags accounts_tags %} +{% has_perm "controls.control.update" as can_update_control %} +{% has_perm "controls.control.delete" as can_delete_control %} +{% for c in controls %} + + + {{ c.reference }} + + + + {{ c.name }} + {{ c.get_theme_display }} · {% blocktrans count counter=c.requirement_count %}{{ counter }} requirement{% plural %}{{ counter }} requirements{% endblocktrans %} + + + + {% if c.owner %}{% user_badge c.owner size=24 link=True %}{% else %}{% dash %}{% endif %} + + {% workflow_badge c %} + {% choice_badge c "effectiveness" %} + + {% if c.next_due %} + {% if c.overdue_flag == "overdue" %} + {% badge variant="danger" icon="calendar-x" label=c.next_due|date %} + {% else %}{{ c.next_due }}{% endif %} + {% else %}{% dash %}{% endif %} + + +
+ {% if can_update_control and not c.is_terminal_state %} + + {% endif %} + {% if can_delete_control and c.is_deletable %} + + {% endif %} +
+ + +{% empty %} +{% empty_state title=_("No control yet.") message=_("Register what the organisation does to meet its requirements and treat its risks, or create proposed controls from a framework's requirements.") colspan=7 %} +{% endfor %} diff --git a/controls/templates/controls/controltest_confirm_delete_modal.html b/controls/templates/controls/controltest_confirm_delete_modal.html new file mode 100644 index 00000000..42fb80ae --- /dev/null +++ b/controls/templates/controls/controltest_confirm_delete_modal.html @@ -0,0 +1,14 @@ +{% extends delete_shell|default:"includes/confirm_delete_modal.html" %} +{% load i18n %} +{% comment %}Delete a test. Only a draft test without runs is deletable; otherwise it is retired (RG-CTL-08).{% endcomment %} +{% block delete_title %}{% trans "Delete test" %}{% endblock %} +{% block delete_message %}{% blocktrans with reference=object.reference name=object.name %}Are you sure you want to delete {{ reference }} - {{ name }}?{% endblocktrans %}{% endblock %} +{% block delete_details %} + {% if not object.is_deletable %} +

{% trans "This test is no longer a draft : retire it through its lifecycle instead." %}

+ {% endif %} + {% if object.runs.exists %} +

{% trans "This test has runs on record : retire it instead." %}

+ {% endif %} +{% endblock %} +{% block delete_button_attrs %}{% if not object.is_deletable or object.runs.exists %} disabled{% endif %}{% endblock %} diff --git a/controls/templates/controls/controltest_detail.html b/controls/templates/controls/controltest_detail.html new file mode 100644 index 00000000..81b2b03b --- /dev/null +++ b/controls/templates/controls/controltest_detail.html @@ -0,0 +1,118 @@ +{% extends "base.html" %} +{% load i18n ui help_tags workflow_tags accounts_tags %} + +{% comment %} +One test of a control : what it establishes, the procedure the tester follows, +what a pass looks like, how often, and every run on record with its result and +its reviewer. The next due date is derived (RG-CTL-07); the state changes only +through the stepper (activation needs a procedure and acceptance criteria). +{% endcomment %} + +{% block title %}{{ test.name }} - Cairn{% endblock %} + +{% block content %} +{% trans "Control" context "control" as control_label %} +{% has_perm "controls.test.update" as can_update_test %} +{% has_perm "controls.test.delete" as can_delete_test %} +{% has_perm "controls.test.create" as can_create_run %} +{% url 'controls:control-detail' control.pk as control_url %} +{% page_header test.name reference=test.reference nav="controls:controltest-list" parent_label=control.reference parent_url=control_url accent="controls" %} + {% include "includes/history_trigger.html" %} + {% if can_delete_test and test.is_deletable and not runs %} + + {% endif %} + {% if can_update_test and not test.is_terminal_state %} + {% trans "Edit" %} + {% endif %} +{% endpage_header %} +{% include "includes/history_panel.html" %} +{% help_modal "controls.test_detail" %} + +{% include "includes/lifecycle_stepper.html" %} + +{% if is_overdue %} +
{% blocktrans with date=test.next_due_date|date %}This test was due on {{ date }}. Record a run.{% endblocktrans %}
+{% endif %} + +{% detail_layout %} + {% slot rail %} + {% kpi_tile _("Lifecycle stage") test.lifecycle_label icon="signpost-split" %} + {% if latest_run %} + {% url 'controls:controltestrun-detail' latest_run.pk as latest_url %} + {% kpi_tile _("Latest result") latest_run.get_result_display icon="clipboard-data" tone=latest_run.result|badge_variant:"run_result" href=latest_url size="sm" %} + {% else %} + {% kpi_tile _("Latest result") None icon="clipboard-data" tone="neutral" %} + {% endif %} + {% if is_overdue %} + {% kpi_tile _("Next due") test.next_due_date icon="calendar-x" tone="danger" %} + {% else %} + {% kpi_tile _("Next due") test.next_due_date icon="calendar-event" tone="neutral" %} + {% endif %} + {% blocktrans asvar frequency count counter=test.frequency_months %}Every month{% plural %}Every {{ counter }} months{% endblocktrans %} + {% kpi_tile _("Frequency") frequency icon="arrow-repeat" tone="neutral" size="sm" %} + {% endslot %} + + {% section_card _("Procedure") icon="list-ol" %} + {% meta_list cols=2 %} + {% meta_item control_label %}{% ref_link control url=control_url %}{% endmeta_item %} + {% meta_item _("Method") test.get_method_display %} + {% meta_item _("Objective") wide=True %}{% if test.objective %}{{ test.objective|linebreaksbr }}{% endif %}{% endmeta_item %} + {% meta_item _("Procedure") wide=True %}{% if test.procedure %}{{ test.procedure|linebreaksbr }}{% endif %}{% endmeta_item %} + {% meta_item _("Acceptance criteria") wide=True %}{% if test.acceptance_criteria %}{{ test.acceptance_criteria|linebreaksbr }}{% endif %}{% endmeta_item %} + {% endmeta_list %} + {% endsection_card %} + + {% section_card _("Planning") icon="calendar-event" %} + {% meta_list cols=2 %} + {% meta_item _("Tester") %}{% if test.tester %}{% user_badge test.tester size=28 link=True block=True %}{% endif %}{% endmeta_item %} + {% meta_item _("First due date") test.first_due_date %} + {% meta_item _("Activated on") test.activated_on %} + {% meta_item _("Next due") test.next_due_date %} + {% meta_item _("Tags") wide=True hide_empty=True %}{% tag_list test.tags.all %}{% endmeta_item %} + {% endmeta_list %} + {% endsection_card %} + + {% section_card _("Runs") icon="clipboard-data" count=runs|length flush=True %} + {% slot actions %} + {% if can_create_run and is_active %} + + {% endif %} + {% endslot %} + {% if runs %} +
+ + + + + + + + + + + + {% for r in runs %} + + + + + + + + {% endfor %} + +
{% trans "Date" %}{% trans "Performed by" %}{% trans "Result" %}{% trans "Status" %}{% trans "Reviewed by" %}
+ + {{ r.performed_on }} + {{ r.reference }} + + {% user_badge r.performed_by size=24 %}{% choice_badge r "result" type="run_result" %}{% workflow_badge r %}{% if r.reviewed_by %}{% user_badge r.reviewed_by size=24 %}{% else %}{% dash %}{% endif %}
+
+ {% else %} +
{% if is_active %}{% empty_state compact=True message=_("No run yet. Record the first run : who tested, when, on which sample, with what result and which evidence.") %}{% else %}{% empty_state compact=True message=_("Runs are recorded once the test is active.") %}{% endif %}
+ {% endif %} + {% endsection_card %} + + {% record_card test %} +{% enddetail_layout %} +{% endblock %} diff --git a/controls/templates/controls/controltest_form.html b/controls/templates/controls/controltest_form.html new file mode 100644 index 00000000..f115e6f1 --- /dev/null +++ b/controls/templates/controls/controltest_form.html @@ -0,0 +1 @@ +{% extends "includes/page_form.html" %} diff --git a/controls/templates/controls/controltest_form_modal.html b/controls/templates/controls/controltest_form_modal.html new file mode 100644 index 00000000..58f13c20 --- /dev/null +++ b/controls/templates/controls/controltest_form_modal.html @@ -0,0 +1,8 @@ +{% extends "includes/modal_form.html" %} +{% comment %} + Create or edit a test of a control, in the drawer. Fields and steps come + from ControlTestForm; the next due date is derived (RG-CTL-07). A retired + test opens frozen. +{% endcomment %} +{% block modal_title_icon %}{% endblock %} +{% block modal_form_fields %}{% include "controls/_frozen_note.html" %}{{ block.super }}{% endblock %} diff --git a/controls/templates/controls/controltest_list.html b/controls/templates/controls/controltest_list.html new file mode 100644 index 00000000..5291d37b --- /dev/null +++ b/controls/templates/controls/controltest_list.html @@ -0,0 +1,55 @@ +{% extends "base.html" %} +{% load i18n ui help_tags table_tags %} + +{% comment %} +The test register (ISO/IEC 27001:2022 clause 9.1) : every test of every +control, who performs it, how often, its latest validated result and when it +is next due (red when overdue, RG-CTL-07). Tests are created from their +control's page; the list view answers its own HTMX table refresh. +{% endcomment %} + +{% block title %}{% trans "Tests" %} - Cairn{% endblock %} + +{% block content %} +{% trans "Control" context "control" as control_label %} +{% page_header _("Tests") nav="controls:controltest-list" accent="controls" %}{% endpage_header %} +{% help_modal "controls.test_list" %} + +
+
+
+ {% include "includes/list_toolbar.html" %} +
+ + + + {% sortable_th "reference" "Ref." col="reference" %} + {% sortable_th "name" "Name" col="name" %} + {% sortable_th "control" control_label col="control" %} + {% sortable_th "tester" "Tester" col="tester" %} + {% sortable_th "latest_result" "Latest result" col="latest_result" %} + {% sortable_th "next_due" "Next due" col="next_due" %} + {% sortable_th "status" "Status" col="status" %} + + + + + {% include "controls/controltest_table_body.html" %} + +
{% trans "Actions" %}
+
+
+ + {% include "includes/pagination.html" %} +
+ +
+ +{% include "includes/list_filter_offcanvas.html" with filter_list_url=request.path %} +{% endblock %} diff --git a/controls/templates/controls/controltest_table_body.html b/controls/templates/controls/controltest_table_body.html new file mode 100644 index 00000000..0752ca98 --- /dev/null +++ b/controls/templates/controls/controltest_table_body.html @@ -0,0 +1,43 @@ +{% load i18n ui workflow_tags accounts_tags %} +{% has_perm "controls.test.update" as can_update_test %} +{% has_perm "controls.test.create" as can_create_run %} +{% for t in tests %} + + + {{ t.reference }} + + + + {{ t.name }} + {{ t.get_method_display }} · {% blocktrans count counter=t.frequency_months %}every month{% plural %}every {{ counter }} months{% endblocktrans %} + + + + {{ t.control.reference }} + + + {% if t.tester %}{% user_badge t.tester size=24 link=True %}{% else %}{% dash %}{% endif %} + + {% if t.latest_result_value %}{% badge t.latest_result_value type="run_result" %}{% else %}{% dash %}{% endif %} + + {% if t.next_due_date %} + {% if t.due_flag == "overdue" %} + {% badge variant="danger" icon="calendar-x" label=t.next_due_date|date %} + {% else %}{{ t.next_due_date }}{% endif %} + {% else %}{% dash %}{% endif %} + + {% workflow_badge t %} + +
+ {% if can_create_run and t.is_active %} + + {% endif %} + {% if can_update_test and not t.is_terminal_state %} + + {% endif %} +
+ + +{% empty %} +{% empty_state title=_("No test yet.") message=_("Tests are created from a control's page : say how the control is checked, what a pass looks like and how often.") colspan=8 %} +{% endfor %} diff --git a/controls/templates/controls/controltestrun_confirm_delete_modal.html b/controls/templates/controls/controltestrun_confirm_delete_modal.html new file mode 100644 index 00000000..3b6ac092 --- /dev/null +++ b/controls/templates/controls/controltestrun_confirm_delete_modal.html @@ -0,0 +1,14 @@ +{% extends delete_shell|default:"includes/confirm_delete_modal.html" %} +{% load i18n %} +{% comment %}Delete a run. Only a run in progress is deletable, with its evidence; afterwards it is withdrawn with a comment and stays readable.{% endcomment %} +{% block delete_title %}{% trans "Delete test run" %}{% endblock %} +{% block delete_message %}{% blocktrans with reference=object.reference date=object.performed_on|date %}Are you sure you want to delete {{ reference }} of {{ date }}?{% endblocktrans %}{% endblock %} +{% block delete_details %} + {% if not object.is_deletable %} +

{% trans "This run was submitted : withdraw it through its lifecycle instead." %}

+ {% endif %} + {% with n=object.evidence.count %}{% if n %} +

{% blocktrans count counter=n %}Its {{ counter }} evidence file is deleted with it.{% plural %}Its {{ counter }} evidence files are deleted with it.{% endblocktrans %}

+ {% endif %}{% endwith %} +{% endblock %} +{% block delete_button_attrs %}{% if not object.is_deletable %} disabled{% endif %}{% endblock %} diff --git a/controls/templates/controls/controltestrun_detail.html b/controls/templates/controls/controltestrun_detail.html new file mode 100644 index 00000000..e0d8a5a5 --- /dev/null +++ b/controls/templates/controls/controltestrun_detail.html @@ -0,0 +1,144 @@ +{% extends "base.html" %} +{% load i18n ui help_tags workflow_tags accounts_tags %} + +{% comment %} +One run of a test : who tested, when, over which period, on which sample, with +what result and which evidence (SHA-256 recorded at upload). The content and the +evidence are editable only while the run is in progress (RG-CTL-09, RG-CTL-13); +it is validated by someone other than its performer (RG-CTL-11). A validated +partial or failed run can raise one finding (RG-CTL-16). +{% endcomment %} + +{% block title %}{{ run.reference }} - Cairn{% endblock %} + +{% block content %} +{% trans "Control" context "control" as control_label %} +{% has_perm "controls.test.update" as can_update_run %} +{% has_perm "controls.test.delete" as can_delete_run %} +{% has_perm "controls.test.approve" as can_approve_run %} +{% url 'controls:controltest-detail' test.pk as test_url %} +{% url 'controls:control-detail' control.pk as control_url %} +{% page_header test.name reference=run.reference nav="controls:controltest-list" parent_label=test.reference parent_url=test_url accent="controls" %} + {% include "includes/history_trigger.html" %} + {% if can_delete_run and run.is_deletable %} + + {% endif %} + {% if can_update_run and is_draft %} + {% trans "Edit" %} + {% endif %} +{% endpage_header %} +{% include "includes/history_panel.html" %} +{% help_modal "controls.run_detail" %} + +{% include "includes/lifecycle_stepper.html" %} + +{% if not is_draft %} +
{% trans "This run was submitted : its result, its observations and its evidence are frozen. Send it back to make a change." %}
+{% endif %} + +{% detail_layout %} + {% slot rail %} + {% kpi_tile _("Lifecycle stage") run.lifecycle_label icon="signpost-split" %} + {% if run.result %} + {% kpi_tile _("Result") run.get_result_display icon="clipboard-data" tone=run.result|badge_variant:"run_result" size="sm" %} + {% else %} + {% kpi_tile _("Result") None icon="clipboard-data" tone="neutral" %} + {% endif %} + {% kpi_tile _("Performed on") run.performed_on icon="calendar-check" tone="neutral" %} + {% kpi_tile _("Reviewed by") run.reviewed_by.display_name icon="person-check" tone="neutral" size="sm" %} + {% endslot %} + + {% section_card _("Result") icon="clipboard-data" %} + {% meta_list cols=2 %} + {% meta_item control_label %}{% ref_link control url=control_url %}{% endmeta_item %} + {% meta_item _("Test") %}{% ref_link test url=test_url %}{% endmeta_item %} + {% meta_item _("Performed by") %}{% user_badge run.performed_by size=28 link=True block=True %}{% endmeta_item %} + {% meta_item _("Result") %}{% choice_badge run "result" type="run_result" %}{% endmeta_item %} + {% meta_item _("Period start") run.period_start %} + {% meta_item _("Period end") run.period_end %} + {% meta_item _("Sample") wide=True %}{% if run.sample_description %}{{ run.sample_description|linebreaksbr }}{% endif %}{% endmeta_item %} + {% meta_item _("Observations") wide=True %}{% if run.observations %}{{ run.observations|linebreaksbr }}{% endif %}{% endmeta_item %} + {% endmeta_list %} + {% endsection_card %} + + {# ── Evidence (RG-CTL-13) : served only through the download view ── #} + {% section_card _("Evidence") icon="paperclip" count=evidence|length flush=True %} + {% slot actions %} + {% if can_update_run and is_draft %} + {% trans "Add evidence" %} + {% endif %} + {% endslot %} + {% if evidence %} +
+ + + + + + + + {% if can_update_run and is_draft %}{% endif %} + + + + {% for item in evidence %} + + + + + + {% if can_update_run and is_draft %} + + {% endif %} + + {% endfor %} + +
{% trans "File" %}{% trans "Size" %}{% trans "SHA-256" %}{% trans "Uploaded by" %}{% trans "Actions" %}
+ + {{ item.original_filename|default:_("File") }} + {% if item.description %}{{ item.description }}{% endif %} + + {{ item.size|filesizeformat }}{{ item.sha256 }} + + {% if item.uploaded_by %}{{ item.uploaded_by.display_name }}{% endif %} + {{ item.uploaded_at }} + + + +
+
+ {% else %} +
{% if is_draft %}{% empty_state compact=True message=_("No evidence yet. Attach the exports, screenshots or signed sheets that support the result before submitting the run.") %}{% else %}{% empty_state compact=True message=_("No evidence was attached to this run.") %}{% endif %}
+ {% endif %} + {% endsection_card %} + + {# ── Review (RG-CTL-11) ── #} + {% section_card _("Review") icon="person-check" %} + {% meta_list cols=2 %} + {% meta_item _("Reviewed by") %}{% if run.reviewed_by %}{% user_badge run.reviewed_by size=28 link=True block=True %}{% endif %}{% endmeta_item %} + {% meta_item _("Reviewed at") run.reviewed_at %} + {% endmeta_list %} +

{% trans "A run is validated by a reviewer who did not perform it." %}

+ {% endsection_card %} + + {# ── Finding (RG-CTL-16) ── #} + {% if findings or needs_finding %} + {% section_card _("Finding") icon="exclamation-diamond" %} + {% slot actions %} + {% if needs_finding and can_approve_run %} + {% trans "Raise a finding" %} + {% endif %} + {% endslot %} + {% if findings %} + + {% else %} + {% empty_state compact=True message=_("This run found a gap and no finding was raised yet. Raise one to track its correction in the nonconformity register.") %} + {% endif %} + {% endsection_card %} + {% endif %} + + {% record_card run %} +{% enddetail_layout %} +{% endblock %} diff --git a/controls/templates/controls/controltestrun_form.html b/controls/templates/controls/controltestrun_form.html new file mode 100644 index 00000000..f115e6f1 --- /dev/null +++ b/controls/templates/controls/controltestrun_form.html @@ -0,0 +1 @@ +{% extends "includes/page_form.html" %} diff --git a/controls/templates/controls/controltestrun_form_modal.html b/controls/templates/controls/controltestrun_form_modal.html new file mode 100644 index 00000000..d580e04e --- /dev/null +++ b/controls/templates/controls/controltestrun_form_modal.html @@ -0,0 +1,8 @@ +{% extends "includes/modal_form.html" %} +{% comment %} + Record or edit a run while it is in progress (RG-CTL-09). Fields and steps + come from ControlTestRunForm; the form opens prefilled with the test's + tester and today. +{% endcomment %} +{% block modal_title_icon %}{% endblock %} +{% block modal_form_fields %}{% include "controls/_frozen_note.html" %}{{ block.super }}{% endblock %} diff --git a/controls/templates/controls/evidence_confirm_delete_modal.html b/controls/templates/controls/evidence_confirm_delete_modal.html new file mode 100644 index 00000000..833fba85 --- /dev/null +++ b/controls/templates/controls/evidence_confirm_delete_modal.html @@ -0,0 +1,11 @@ +{% extends delete_shell|default:"includes/confirm_delete_modal.html" %} +{% load i18n %} +{% comment %}Remove an evidence file. Only while its run is in progress (RG-CTL-13); afterwards the file is part of the record.{% endcomment %} +{% block delete_title %}{% trans "Remove evidence" %}{% endblock %} +{% block delete_message %}{% blocktrans with name=object.original_filename reference=object.run.reference %}Are you sure you want to remove {{ name }} from {{ reference }}?{% endblocktrans %}{% endblock %} +{% block delete_details %} + {% if object.run.is_frozen %} +

{% trans "Evidence can only be added or removed while the test run is in progress." %}

+ {% endif %} +{% endblock %} +{% block delete_button_attrs %}{% if object.run.is_frozen %} disabled{% endif %}{% endblock %} diff --git a/controls/templates/controls/evidence_form.html b/controls/templates/controls/evidence_form.html new file mode 100644 index 00000000..f115e6f1 --- /dev/null +++ b/controls/templates/controls/evidence_form.html @@ -0,0 +1 @@ +{% extends "includes/page_form.html" %} diff --git a/controls/templates/controls/evidence_form_modal.html b/controls/templates/controls/evidence_form_modal.html new file mode 100644 index 00000000..df24994e --- /dev/null +++ b/controls/templates/controls/evidence_form_modal.html @@ -0,0 +1,4 @@ +{% extends "includes/modal_form.html" %} +{% comment %}RG-CTL-13 : attach an evidence file to a run in progress. Sent as multipart; size and SHA-256 are stamped by the model.{% endcomment %} +{% block form_enctype %}enctype="multipart/form-data" hx-encoding="multipart/form-data"{% endblock %} +{% block modal_title_icon %}{% endblock %} diff --git a/controls/templates/controls/includes/controls_card.html b/controls/templates/controls/includes/controls_card.html new file mode 100644 index 00000000..8a2b68fe --- /dev/null +++ b/controls/templates/controls/includes/controls_card.html @@ -0,0 +1,41 @@ +{% load i18n ui workflow_tags %} +{% comment %} +The *Controls* reverse card (module 8, README §8), shared by the requirement, +risk, treatment plan, PACS measure (EBIOS RM workshop 5), support asset, +objective and document detail pages. + +Expects `linked_controls` : a list built by `controls.integrations` +(`linked_controls()` / `linked_controls_for_measures()`), already +scope-filtered, or `None` when the reader may not read controls (the card is +then not rendered). Controls in any step are listed with their state badge +(RG-CTL-04); a retired one is flagged by its badge, sorted last and shows no +effectiveness, since it no longer counts anywhere (RG-CTL-03). An +effectiveness resting on an overdue test is flagged, never downgraded +(RG-CTL-15). A control may carry `linked_via` (references of the element it +is linked through, e.g. the PACS measures of a study). +{% endcomment %} +{% if linked_controls is not None %} +{% trans "Test overdue" as t_stale %} +{% section_card _("Controls") icon="shield-check" count=linked_controls|length id="linked-controls" %} + {% if linked_controls %} + + {% else %} + {% empty_state message=_("No control linked.") %} + {% endif %} +{% endsection_card %} +{% endif %} diff --git a/controls/templates/controls/raise_finding_form.html b/controls/templates/controls/raise_finding_form.html new file mode 100644 index 00000000..f115e6f1 --- /dev/null +++ b/controls/templates/controls/raise_finding_form.html @@ -0,0 +1 @@ +{% extends "includes/page_form.html" %} diff --git a/controls/templates/controls/raise_finding_form_modal.html b/controls/templates/controls/raise_finding_form_modal.html new file mode 100644 index 00000000..f935b2e6 --- /dev/null +++ b/controls/templates/controls/raise_finding_form_modal.html @@ -0,0 +1,8 @@ +{% extends "includes/modal_form.html" %} +{% load i18n %} +{% comment %}RG-CTL-16 : raise a finding from a validated partial or failed run. Fields from RaiseFindingForm.{% endcomment %} +{% block modal_title_icon %}{% endblock %} +{% block modal_form_fields %} +

{% blocktrans with ref=run.reference %}The finding is created in the nonconformity register from run {{ ref }} : its observations become the description, and the control's requirements its related requirements.{% endblocktrans %}

+{{ block.super }} +{% endblock %} diff --git a/controls/templates/controls/widgets/requirement_checklist.html b/controls/templates/controls/widgets/requirement_checklist.html new file mode 100644 index 00000000..ceace34e --- /dev/null +++ b/controls/templates/controls/widgets/requirement_checklist.html @@ -0,0 +1,11 @@ +{% load i18n %}{% comment %} + RG-CTL-05 : the uncovered requirements of a framework as a bounded checklist + (controls.forms.RequirementChecklistWidget). Every row is a form-check. +{% endcomment %}{% with id=widget.attrs.id %} + + {% for group, options, index in widget.optgroups %}{% for option in options %} + {% include option.template_name with widget=option %} + {% endfor %}{% empty %} +

{% trans "No requirement to list." %}

+ {% endfor %} +
{% endwith %} diff --git a/controls/templates/controls/widgets/requirement_checklist_option.html b/controls/templates/controls/widgets/requirement_checklist_option.html new file mode 100644 index 00000000..926039fe --- /dev/null +++ b/controls/templates/controls/widgets/requirement_checklist_option.html @@ -0,0 +1,4 @@ +
+ + +
diff --git a/controls/templatetags/__init__.py b/controls/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/controls/templatetags/controls_tags.py b/controls/templatetags/controls_tags.py new file mode 100644 index 00000000..664a14ad --- /dev/null +++ b/controls/templatetags/controls_tags.py @@ -0,0 +1,20 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""Template tags of module 8 (controls).""" + +from django import template +from django.utils.safestring import mark_safe + +register = template.Library() + + +@register.filter +def controls_rich(value): + """A control's rich-text description, sanitized at render time. + + The description of a control created from a requirement starts as the + requirement's own rich text (RG-CTL-05), so it is never trusted as is. + """ + from trust_center.sanitizers import clean_html + + return mark_safe(clean_html(value or "")) # noqa: S308 - sanitized by clean_html 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_api.py b/controls/tests/test_api.py new file mode 100644 index 00000000..43604308 --- /dev/null +++ b/controls/tests/test_api.py @@ -0,0 +1,495 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""The REST surface of module 8 (README §5) : permissions, tenancy and the +RG-CTL rules as a client meets them.""" +import datetime + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from django.urls import reverse +from django.utils import timezone +from rest_framework.test import APIClient + +from accounts.models import Group, Permission +from accounts.tests.factories import UserFactory +from compliance.api.filters import FindingFilter +from compliance.api.serializers import FindingSerializer +from compliance.constants import FindingSource +from compliance.models import Finding +from compliance.tests.factories import FrameworkFactory, RequirementFactory +from context.tests.factories import ScopeFactory +from controls.constants import ( + CONTROL_DRAFT, + CONTROL_IMPLEMENTED, + CONTROL_RETIRED, + RUN_IN_REVIEW, + RUN_VALIDATED, + TEST_ACTIVE, + ControlEffectiveness, + RunResult, +) +from controls.models import Control, ControlTestEvidence, ControlTestRun +from controls.tests.factories import ( + ControlFactory, + ControlTestFactory, + ControlTestRunFactory, + active_test, + implemented_control, + validated_run, +) +from risks.tests.factories import RiskFactory + +pytestmark = pytest.mark.django_db + +BASE = "/api/v1/controls" + +CONTROL_READ = ("controls.control.read",) +CONTROL_CONTRIBUTOR = CONTROL_READ + ("controls.control.create", "controls.control.update") +CONTROL_APPROVER = CONTROL_CONTRIBUTOR + ("controls.control.approve", "controls.control.delete") +TEST_READ = ("controls.test.read",) +TEST_CONTRIBUTOR = TEST_READ + ("controls.test.create", "controls.test.update") +TEST_APPROVER = TEST_CONTRIBUTOR + ("controls.test.approve", "controls.test.delete") +FROZEN_MESSAGE = "A submitted test run is frozen : its content can no longer be changed." + + +def make_user(*codenames, scopes=()): + """A user whose only group grants ``codenames`` within ``scopes``. + + A group with no allowed scope is unrestricted, so a test that wants a + restricted user always passes at least one scope. + """ + user = UserFactory() + group = Group.objects.create(name=f"g-{user.pk}") + group.permissions.set(Permission.objects.filter(codename__in=codenames)) + if scopes: + group.allowed_scopes.set(scopes) + group.users.add(user) + return user + + +def api(user): + client = APIClient() + client.force_authenticate(user=user) + return client + + +def data(response): + """The payload, unwrapped from the success envelope (a lifecycle entity + carries its own ``status`` key and is left unwrapped).""" + body = response.json() + if isinstance(body, dict) and "data" in body and set(body) <= {"status", "data", "meta"}: + return body["data"] + return body + + +def error(response): + return response.json()["error"] + + +def ids(response): + return {row["id"] for row in data(response)} + + +@pytest.fixture +def superuser(): + return UserFactory(is_superuser=True) + + +@pytest.fixture +def scope(): + return ScopeFactory() + + +def scoped_control(scope, implemented=True, **kwargs): + control = implemented_control(**kwargs) if implemented else ControlFactory(**kwargs) + control.scopes.add(scope) + return control + + +def scoped_requirement(scope, **kwargs): + framework = FrameworkFactory() + framework.scopes.add(scope) + kwargs.setdefault("workflow_state", "validated") + return RequirementFactory(framework=framework, **kwargs) + + +def upload(run, name="export.csv", content=b"login,mfa\nalice,yes\n"): + return ControlTestEvidence.objects.create( + run=run, file=SimpleUploadedFile(name, content), original_filename=name, uploaded_by=run.performed_by, + ) + + +class TestRouting: + def test_the_rest_api_is_mounted(self): + assert reverse("controls-api:control-list") == f"{BASE}/controls/" + assert reverse("controls-api:control-from-requirements") == f"{BASE}/controls/from-requirements/" + assert reverse("controls-api:controltest-list") == f"{BASE}/tests/" + assert reverse("controls-api:controltestrun-list") == f"{BASE}/runs/" + + def test_tests_and_runs_publish_no_put(self, superuser): + test = ControlTestFactory() + assert api(superuser).put(f"{BASE}/tests/{test.pk}/", {}, format="json").status_code == 405 + + +class TestControlEndpoints: + def test_create_is_proposed_with_derived_fields_read_only(self, superuser, scope): + response = api(superuser).post(f"{BASE}/controls/", { + "name": "MFA on remote access", "description": "Every remote login needs a second factor.", + "scopes": [str(scope.pk)], "status": CONTROL_IMPLEMENTED, + "effectiveness": ControlEffectiveness.EFFECTIVE, "effectiveness_updated_at": "2020-01-01T00:00:00Z", + }, format="json") + assert response.status_code == 201, response.content + body = data(response) + assert body["status"] == CONTROL_DRAFT + assert body["reference"].startswith("CTRL-") + assert body["effectiveness"] == ControlEffectiveness.NOT_TESTED + assert body["effectiveness_updated_at"] is None + assert Control.objects.get(pk=body["id"]).created_by == superuser + + def test_a_future_implementation_date_is_a_400(self, superuser): + control = ControlFactory() + future = (timezone.localdate() + datetime.timedelta(days=3)).isoformat() + response = api(superuser).patch(f"{BASE}/controls/{control.pk}/", {"implemented_on": future}, format="json") + assert response.status_code == 400 + assert "implemented_on" in error(response) + + def test_permissions_gate_each_verb(self, scope): + control = scoped_control(scope) + reader = make_user(*CONTROL_READ, scopes=[scope]) + assert api(reader).get(f"{BASE}/controls/").status_code == 200 + assert api(reader).get(f"{BASE}/controls/{control.pk}/history/").status_code == 200 + assert api(reader).post(f"{BASE}/controls/", {"name": "x", "description": "y"}, format="json").status_code == 403 + assert api(reader).patch(f"{BASE}/controls/{control.pk}/", {"name": "y"}, format="json").status_code == 403 + nobody = make_user(scopes=[scope]) + assert api(nobody).get(f"{BASE}/controls/").status_code == 403 + assert api(nobody).get(f"{BASE}/tests/").status_code == 403 + + def test_scope_tenancy_on_controls_tests_and_runs(self, scope): + mine = scoped_control(scope) + theirs = scoped_control(ScopeFactory()) + their_run = validated_run(active_test(theirs)) + reader = make_user(*(CONTROL_READ + TEST_READ), scopes=[scope]) + client = api(reader) + assert ids(client.get(f"{BASE}/controls/")) == {str(mine.pk)} + assert client.get(f"{BASE}/controls/{theirs.pk}/").status_code == 404 + assert client.get(f"{BASE}/tests/{their_run.test_id}/").status_code == 404 + assert client.get(f"{BASE}/runs/{their_run.pk}/").status_code == 404 + assert ids(client.get(f"{BASE}/tests/")) == set() + assert ids(client.get(f"{BASE}/runs/")) == set() + + def test_links_are_scoped_and_linkable(self, scope): + contributor = make_user(*CONTROL_CONTRIBUTOR, scopes=[scope]) + client = api(contributor) + payload = {"name": "Access review", "description": "Quarterly review.", "scopes": [str(scope.pk)]} + out_of_scope = scoped_requirement(ScopeFactory()) + draft = scoped_requirement(scope, workflow_state="draft") + linkable = scoped_requirement(scope) + for requirement in (out_of_scope, draft): + refused = client.post(f"{BASE}/controls/", {**payload, "requirements": [str(requirement.pk)]}, format="json") + assert refused.status_code == 400, requirement.workflow_state + assert "requirements" in error(refused) + refused = client.post(f"{BASE}/controls/", {**payload, "scopes": [str(ScopeFactory().pk)]}, format="json") + assert refused.status_code == 400 + assert "scopes" in error(refused) + response = client.post(f"{BASE}/controls/", {**payload, "requirements": [str(linkable.pk)]}, format="json") + assert response.status_code == 201, response.content + # A link already in place survives its target leaving a linkable step. + type(linkable).objects.filter(pk=linkable.pk).update(workflow_state="archived") + control_id = data(response)["id"] + kept = client.patch( + f"{BASE}/controls/{control_id}/", {"requirements": [str(linkable.pk)], "name": "Renamed"}, format="json", + ) + assert kept.status_code == 200, kept.content + + def test_a_co_owned_control_keeps_the_scopes_the_caller_cannot_see(self, scope): + other = ScopeFactory() + control = scoped_control(scope) + control.scopes.add(other) + contributor = make_user(*CONTROL_CONTRIBUTOR, scopes=[scope]) + url = f"{BASE}/controls/{control.pk}/" + kept = api(contributor).patch(url, {"scopes": [str(scope.pk), str(other.pk)]}, format="json") + assert kept.status_code == 200, kept.content + added = api(contributor).patch(url, {"scopes": [str(scope.pk), str(ScopeFactory().pk)]}, format="json") + assert added.status_code == 400 + + def test_filters(self, superuser): + requirement = RequirementFactory() + risk = RiskFactory() + linked = implemented_control(theme="people") + linked.requirements.add(requirement) + linked.risks.add(risk) + overdue_test = active_test(linked, first_due_date=timezone.localdate() - datetime.timedelta(days=2)) + tested = implemented_control() + validated_run(active_test(tested)) + draft = ControlFactory() + client = api(superuser) + assert ids(client.get(f"{BASE}/controls/?requirement_id={requirement.pk}")) == {str(linked.pk)} + assert ids(client.get(f"{BASE}/controls/?risk_id={risk.pk}")) == {str(linked.pk)} + assert ids(client.get(f"{BASE}/controls/?theme=people")) == {str(linked.pk)} + assert ids(client.get(f"{BASE}/controls/?test_overdue=true")) == {str(linked.pk)} + assert str(linked.pk) not in ids(client.get(f"{BASE}/controls/?test_overdue=false")) + assert ids(client.get(f"{BASE}/controls/?effectiveness=effective")) == {str(tested.pk)} + assert ids(client.get(f"{BASE}/controls/?status={CONTROL_DRAFT}")) == {str(draft.pk)} + row = next(r for r in data(client.get(f"{BASE}/controls/")) if r["id"] == str(linked.pk)) + assert row["is_test_overdue"] is True + assert row["next_test_due"] == overdue_test.next_due_date.isoformat() + assert row["requirement_count"] == 1 + + def test_transitions_go_through_the_lifecycle(self, scope): + control = scoped_control(scope, implemented=False) + contributor = make_user(*CONTROL_CONTRIBUTOR, scopes=[scope]) + approver = make_user(*CONTROL_APPROVER, scopes=[scope]) + url = f"{BASE}/controls/{control.pk}/transition/" + assert api(contributor).post(url, {"target_state": CONTROL_IMPLEMENTED}, format="json").status_code == 403 + allowed = data(api(approver).get(url))["allowed_transitions"] + assert CONTROL_IMPLEMENTED in {t["target"] for t in allowed} + response = api(approver).post(url, {"target_state": CONTROL_IMPLEMENTED}, format="json") + assert response.status_code == 200, response.content + assert data(response)["status"] == CONTROL_IMPLEMENTED + control.refresh_from_db() + assert control.implemented_on == timezone.localdate() + # Retiring requires a comment (RG-CTL-03). + assert api(approver).post(url, {"target_state": CONTROL_RETIRED}, format="json").status_code == 400 + + def test_delete_only_a_proposed_control_without_tests(self, superuser): + client = api(superuser) + proposed = ControlFactory() + assert client.delete(f"{BASE}/controls/{proposed.pk}/").status_code == 204 + with_test = ControlFactory() + ControlTestFactory(control=with_test) + assert client.delete(f"{BASE}/controls/{with_test.pk}/").status_code == 409 + implemented = implemented_control() + assert client.delete(f"{BASE}/controls/{implemented.pk}/").status_code == 409 + + +class TestFromRequirements: + URL = f"{BASE}/controls/from-requirements/" + + def test_creates_one_control_per_uncovered_applicable_requirement(self, superuser, scope): + framework = FrameworkFactory() + framework.scopes.add(scope) + fresh = RequirementFactory(framework=framework) + covered = RequirementFactory(framework=framework) + ControlFactory().requirements.add(covered) + excluded = RequirementFactory(framework=framework, is_applicable=False) + client = api(superuser) + response = client.post(self.URL, {"framework_id": str(framework.pk)}, format="json") + assert response.status_code == 201, response.content + body = data(response) + assert [row["name"] for row in body["created"]] == [fresh.name] + assert {row["id"]: row["reason"] for row in body["skipped"]} == { + str(covered.pk): "already_covered", str(excluded.pk): "not_applicable", + } + control = Control.objects.get(pk=body["created"][0]["id"]) + assert control.workflow_state == CONTROL_DRAFT + assert list(control.scopes.all()) == [scope] + again = client.post(self.URL, {"framework_id": str(framework.pk)}, format="json") + assert again.status_code == 200 + assert data(again)["created"] == [] + + def test_needs_create_and_a_readable_framework(self, scope): + framework = FrameworkFactory() + framework.scopes.add(scope) + RequirementFactory(framework=framework) + body = {"framework_id": str(framework.pk)} + no_create = make_user(*(CONTROL_READ + ("compliance.framework.read",)), scopes=[scope]) + assert api(no_create).post(self.URL, body, format="json").status_code == 403 + no_framework = make_user(*CONTROL_CONTRIBUTOR, scopes=[scope]) + assert api(no_framework).post(self.URL, body, format="json").status_code == 403 + outsider = make_user(*(CONTROL_CONTRIBUTOR + ("compliance.framework.read",)), scopes=[ScopeFactory()]) + refused = api(outsider).post(self.URL, body, format="json") + assert refused.status_code == 400 + assert "framework_id" in error(refused) + insider = make_user(*(CONTROL_CONTRIBUTOR + ("compliance.framework.read",)), scopes=[scope]) + refused = api(insider).post(self.URL, {**body, "scope_ids": [str(ScopeFactory().pk)]}, format="json") + assert refused.status_code == 400 + assert api(insider).post(self.URL, body, format="json").status_code == 201 + + +class TestControlTestEndpoints: + def test_create_activate_and_derive_the_due_date(self, superuser): + control = implemented_control() + client = api(superuser) + response = client.post(f"{BASE}/tests/", { + "control": str(control.pk), "name": "Quarterly sample", "procedure": "Sample 25 logins.", + "acceptance_criteria": "All used MFA.", "frequency_months": 3, + "next_due_date": "2020-01-01", "activated_on": "2020-01-01", + }, format="json") + assert response.status_code == 201, response.content + body = data(response) + assert body["next_due_date"] is None and body["activated_on"] is None + activated = client.post(f"{BASE}/tests/{body['id']}/transition/", {"target_state": TEST_ACTIVE}, format="json") + assert activated.status_code == 200, activated.content + assert data(activated)["next_due_date"] == timezone.localdate().isoformat() + + def test_activation_gate_is_a_400(self, superuser): + test = ControlTestFactory(control=ControlFactory()) + response = api(superuser).post(f"{BASE}/tests/{test.pk}/transition/", {"target_state": TEST_ACTIVE}, format="json") + assert response.status_code == 400 + assert "Plan or implement" in error(response)["detail"] + + def test_a_test_cannot_target_an_out_of_scope_or_retired_control(self, scope): + contributor = make_user(*TEST_CONTRIBUTOR, scopes=[scope]) + theirs = scoped_control(ScopeFactory()) + payload = {"name": "t", "procedure": "p", "acceptance_criteria": "a"} + refused = api(contributor).post(f"{BASE}/tests/", {**payload, "control": str(theirs.pk)}, format="json") + assert refused.status_code == 400 + assert "control" in error(refused) + retired = scoped_control(scope) + retired.transition_to(CONTROL_RETIRED, retired.owner, comment="Gone.") + refused = api(contributor).post(f"{BASE}/tests/", {**payload, "control": str(retired.pk)}, format="json") + assert refused.status_code == 400 + + def test_filters(self, superuser): + late = active_test(first_due_date=timezone.localdate() - datetime.timedelta(days=1)) + later = active_test(first_due_date=timezone.localdate() + datetime.timedelta(days=60)) + draft = ControlTestFactory() + client = api(superuser) + assert ids(client.get(f"{BASE}/tests/?overdue=true")) == {str(late.pk)} + soon = (timezone.localdate() + datetime.timedelta(days=30)).isoformat() + assert ids(client.get(f"{BASE}/tests/?due_before={soon}")) == {str(late.pk)} + assert ids(client.get(f"{BASE}/tests/?control_id={later.control_id}")) == {str(later.pk)} + assert ids(client.get(f"{BASE}/tests/?tester_id={draft.tester_id}")) == {str(draft.pk)} + assert ids(client.get(f"{BASE}/tests/?status=draft")) == {str(draft.pk)} + + +class TestRunEndpoints: + def test_create_defaults_the_performer_and_needs_an_active_test(self, scope): + test = active_test(scoped_control(scope)) + contributor = make_user(*TEST_CONTRIBUTOR, scopes=[scope]) + response = api(contributor).post(f"{BASE}/runs/", {"test": str(test.pk), "result": "pass"}, format="json") + assert response.status_code == 201, response.content + assert data(response)["performed_by"] == str(test.tester_id) + assert data(response)["status"] == "draft" + inactive = ControlTestFactory(control=test.control) + refused = api(contributor).post(f"{BASE}/runs/", {"test": str(inactive.pk)}, format="json") + assert refused.status_code == 400 + + def test_a_run_cannot_target_an_out_of_scope_test(self, scope): + contributor = make_user(*TEST_CONTRIBUTOR, scopes=[scope]) + theirs = active_test(scoped_control(ScopeFactory())) + refused = api(contributor).post(f"{BASE}/runs/", {"test": str(theirs.pk)}, format="json") + assert refused.status_code == 400 + assert "test" in error(refused) + + def test_rg_ctl_09_a_submitted_run_refuses_a_content_patch(self, superuser): + run = ControlTestRunFactory(result=RunResult.PASS) + client = api(superuser) + url = f"{BASE}/runs/{run.pk}/" + assert client.patch(url, {"sample_description": "25 logins"}, format="json").status_code == 200 + submitted = client.post(f"{url}transition/", {"target_state": RUN_IN_REVIEW}, format="json") + assert submitted.status_code == 200, submitted.content + for field, value in (("result", "fail"), ("observations", "Rewritten."), ("performed_on", "2020-01-01")): + refused = client.patch(url, {field: value}, format="json") + assert refused.status_code == 400, field + assert error(refused)[field] == [FROZEN_MESSAGE] + run.refresh_from_db() + assert run.result == RunResult.PASS + # The stored value, replayed, is not a change. + assert client.patch(url, {"result": "pass"}, format="json").status_code == 200 + + def test_a_submitted_run_still_takes_its_tags(self, superuser): + run = ControlTestRunFactory(result=RunResult.PASS) + run.transition_to(RUN_IN_REVIEW, run.performed_by) + response = api(superuser).patch(f"{BASE}/runs/{run.pk}/", {"tags": []}, format="json") + assert response.status_code == 200 + + def test_evidence_is_listed_without_the_file(self, superuser, settings, tmp_path): + settings.MEDIA_ROOT = tmp_path + run = ControlTestRunFactory(result=RunResult.PASS) + item = upload(run) + body = data(api(superuser).get(f"{BASE}/runs/{run.pk}/")) + assert body["evidence"] == [{ + "id": str(item.pk), "file_name": "export.csv", "size": item.size, "sha256": item.sha256, + "description": "", "uploaded_by": str(run.performed_by_id), + "uploaded_by_name": run.performed_by.display_name, "uploaded_at": item.uploaded_at.isoformat(), + "download_url": reverse("controls:evidence-download", kwargs={"pk": item.pk}), + }] + assert "file" not in body["evidence"][0] + + def test_rg_ctl_11_the_performer_cannot_validate(self, scope): + test = active_test(scoped_control(scope)) + performer = make_user(*TEST_APPROVER, scopes=[scope]) + reviewer = make_user(*TEST_APPROVER, scopes=[scope]) + run = ControlTestRunFactory(test=test, performed_by=performer, result=RunResult.PASS) + run.transition_to(RUN_IN_REVIEW, performer) + url = f"{BASE}/runs/{run.pk}/transition/" + refused = api(performer).post(url, {"target_state": RUN_VALIDATED}, format="json") + assert refused.status_code == 400 + assert "cannot validate" in error(refused)["detail"] + response = api(reviewer).post(url, {"target_state": RUN_VALIDATED}, format="json") + assert response.status_code == 200, response.content + body = data(response) + assert body["reviewed_by"] == str(reviewer.pk) + assert body["reviewed_at"] + control = test.control + control.refresh_from_db() + assert control.effectiveness == ControlEffectiveness.EFFECTIVE + + def test_superuser_performer_cannot_validate_either(self, superuser): + run = ControlTestRunFactory(performed_by=superuser, result=RunResult.PASS) + run.transition_to(RUN_IN_REVIEW, superuser) + response = api(superuser).post(f"{BASE}/runs/{run.pk}/transition/", {"target_state": RUN_VALIDATED}, format="json") + assert response.status_code == 400 + + def test_raise_finding_once_from_a_failed_run(self, scope): + run = validated_run(active_test(scoped_control(scope)), result=RunResult.FAIL, observations="3 of 25 without MFA.") + requirement = RequirementFactory() + run.test.control.requirements.add(requirement) + url = f"{BASE}/runs/{run.pk}/raise-finding/" + contributor = make_user(*TEST_CONTRIBUTOR, scopes=[scope]) + assert api(contributor).post(url, {"finding_type": "minor_nc"}, format="json").status_code == 403 + approver = make_user(*TEST_APPROVER, scopes=[scope]) + client = api(approver) + assert client.post(url, {"finding_type": "strength"}, format="json").status_code == 400 + response = client.post(url, {"finding_type": "minor_nc", "recommendation": "Enforce MFA."}, format="json") + assert response.status_code == 201, response.content + body = data(response) + assert body["source"] == FindingSource.CONTROL_TEST + assert body["control_test_run"] == str(run.pk) + assert body["control_test_run_reference"] == run.reference + assert body["requirements"] == [str(requirement.pk)] + assert body["assessor"] == str(approver.pk) + again = client.post(url, {"finding_type": "minor_nc"}, format="json") + assert again.status_code == 400 + assert "already" in error(again)["detail"] + detail = data(client.get(f"{BASE}/runs/{run.pk}/")) + assert detail["finding"]["id"] == body["id"] + assert detail["needs_finding"] is False + + def test_raise_finding_on_a_passed_run_is_refused(self, superuser): + run = validated_run() + response = api(superuser).post(f"{BASE}/runs/{run.pk}/raise-finding/", {"finding_type": "observation"}, format="json") + assert response.status_code == 400 + + def test_filters(self, superuser): + passed = validated_run() + failed = validated_run(result=RunResult.FAIL) + draft = ControlTestRunFactory() + client = api(superuser) + assert ids(client.get(f"{BASE}/runs/?result=fail")) == {str(failed.pk)} + assert ids(client.get(f"{BASE}/runs/?control_id={passed.test.control_id}")) == {str(passed.pk)} + assert ids(client.get(f"{BASE}/runs/?test_id={draft.test_id}")) == {str(draft.pk)} + assert ids(client.get(f"{BASE}/runs/?performed_by_id={failed.performed_by_id}")) == {str(failed.pk)} + assert ids(client.get(f"{BASE}/runs/?status=validated")) == {str(passed.pk), str(failed.pk)} + + def test_delete_only_a_draft_run(self, superuser): + client = api(superuser) + draft = ControlTestRunFactory() + assert client.delete(f"{BASE}/runs/{draft.pk}/").status_code == 204 + assert not ControlTestRun.objects.filter(pk=draft.pk).exists() + assert client.delete(f"{BASE}/runs/{validated_run().pk}/").status_code == 409 + + +class TestFindingExposure: + def test_a_finding_names_its_run_read_only_and_filters_by_source(self, superuser): + run = validated_run(result=RunResult.PARTIAL, observations="Partial.") + finding = run.raise_finding(superuser, "observation") + body = FindingSerializer(finding).data + assert body["control_test_run"] == run.pk + assert body["control_test_run_reference"] == run.reference + assert "control_test_run" in FindingSerializer.Meta.read_only_fields + matched = FindingFilter({"source": "control_test"}, queryset=Finding.objects.all()).qs + assert list(matched) == [finding] + by_run = FindingFilter({"control_test_run_id": str(run.pk)}, queryset=Finding.objects.all()).qs + assert list(by_run) == [finding] 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/tests/test_mcp.py b/controls/tests/test_mcp.py new file mode 100644 index 00000000..30d7c1c2 --- /dev/null +++ b/controls/tests/test_mcp.py @@ -0,0 +1,387 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""The MCP surface of module 8 (README §6) : the same RG-CTL rules as the web +form and the REST API, with the MCP client acting as the authenticated user.""" +import datetime + +import pytest +from django.utils import timezone + +from accounts.tests.factories import UserFactory +from compliance.constants import FindingSource +from compliance.tests.factories import FrameworkFactory, RequirementFactory +from context.tests.factories import ScopeFactory +from controls.constants import ( + CONTROL_DRAFT, + CONTROL_IMPLEMENTED, + CONTROL_PLANNED, + RUN_IN_REVIEW, + RUN_VALIDATED, + TEST_ACTIVE, + ControlEffectiveness, + RunResult, +) +from controls.models import Control, ControlTest, ControlTestRun +from controls.tests.factories import ( + ControlFactory, + ControlTestFactory, + ControlTestRunFactory, + active_test, + implemented_control, + validated_run, +) +from controls.tests.test_api import ( + CONTROL_APPROVER, + CONTROL_CONTRIBUTOR, + CONTROL_READ, + TEST_APPROVER, + TEST_CONTRIBUTOR, + TEST_READ, + make_user, + scoped_control, + scoped_requirement, + upload, +) + +pytestmark = pytest.mark.django_db + + +@pytest.fixture(scope="module") +def server(): + from mcp.server import McpServer + from mcp.tools import register_all_tools + + instance = McpServer() + register_all_tools(instance) + return instance + + +@pytest.fixture +def call(server): + def _call(tool_name, user, /, **arguments): + return server.get_tool(tool_name)["handler"](user, arguments) + return _call + + +@pytest.fixture +def superuser(): + return UserFactory(is_superuser=True) + + +def is_error(result): + return isinstance(result, dict) and result.get("isError") is True + + +def error_text(result): + return result["content"][0]["text"] + + +def item_ids(result): + return {item["id"] for item in result["items"]} + + +CRUD = { + f"{verb}_{entity}" + for entity in ("control", "control_test", "control_test_run") + for verb in ("get", "create", "update", "delete", "transition") +} | { + name + for entity in ("control", "control_test", "control_test_run") + for name in (f"list_{entity}s", f"batch_create_{entity}s", f"{entity}_allowed_transitions", f"get_{entity}_history") +} + + +class TestRegistry: + EXPECTED = CRUD | { + "create_controls_from_requirements", "get_control_effectiveness", "list_overdue_control_tests", + "list_my_control_tests_due", "raise_finding_from_run", "get_requirement_coverage", + } + + def test_every_controls_tool_is_registered(self, server): + names = {tool["name"] for tool in server.iter_tools()} + assert self.EXPECTED <= names, sorted(self.EXPECTED - names) + + def test_every_tool_and_parameter_is_described(self, server): + for name in self.EXPECTED: + tool = server.get_tool(name) + assert tool["description"].strip(), name + for param, schema in tool["inputSchema"].get("properties", {}).items(): + assert schema.get("description"), (name, param) + + def test_derived_fields_are_not_writable(self, server): + for name in ("create_control", "update_control"): + props = server.get_tool(name)["inputSchema"]["properties"] + assert "effectiveness" not in props and "effectiveness_updated_at" not in props + for name in ("create_control_test", "update_control_test"): + props = server.get_tool(name)["inputSchema"]["properties"] + assert "next_due_date" not in props and "activated_on" not in props + props = server.get_tool("update_control_test_run")["inputSchema"]["properties"] + assert "reviewed_by_id" not in props and "test_id" not in props + + def test_help_has_a_controls_topic(self, call, superuser): + guide = call("help", superuser) + assert "CTRL" in guide and "CTST" in guide and "CTRN" in guide + topic = call("help", superuser, topic="controls") + assert "RG-CTL-11" in topic and "raise_finding_from_run" in topic + + +class TestPermissionsAndScopes: + def test_tools_require_their_permission(self, call): + nobody = make_user(scopes=[ScopeFactory()]) + control = ControlFactory() + run = validated_run(result=RunResult.FAIL) + expectations = { + ("list_controls", "controls.control.read"): {}, + ("create_control", "controls.control.create"): {"name": "x", "description": "y"}, + ("get_control_effectiveness", "controls.control.read"): {"control_id": str(control.pk)}, + ("get_requirement_coverage", "controls.control.read"): {"framework_id": str(FrameworkFactory().pk)}, + ("create_controls_from_requirements", "controls.control.create"): {"framework_id": str(FrameworkFactory().pk)}, + ("list_control_tests", "controls.test.read"): {}, + ("list_overdue_control_tests", "controls.test.read"): {}, + ("list_my_control_tests_due", "controls.test.read"): {}, + ("get_control_test_run", "controls.test.read"): {"id": str(run.pk)}, + ("create_control_test_run", "controls.test.create"): {"test_id": str(run.test_id)}, + ("raise_finding_from_run", "controls.test.approve"): {"run_id": str(run.pk), "finding_type": "minor_nc"}, + } + for (tool, codename), arguments in expectations.items(): + result = call(tool, nobody, **arguments) + assert is_error(result) and codename in error_text(result), tool + + def test_lists_and_reads_are_scope_filtered(self, call): + scope = ScopeFactory() + mine = scoped_control(scope) + theirs = scoped_control(ScopeFactory()) + my_run = validated_run(active_test(mine)) + their_run = validated_run(active_test(theirs)) + reader = make_user(*(CONTROL_READ + TEST_READ), scopes=[scope]) + assert item_ids(call("list_controls", reader)) == {str(mine.pk)} + assert item_ids(call("list_control_tests", reader)) == {str(my_run.test_id)} + assert item_ids(call("list_control_test_runs", reader)) == {str(my_run.pk)} + assert is_error(call("get_control", reader, id=str(theirs.pk))) + assert is_error(call("get_control_test_run", reader, id=str(their_run.pk))) + assert is_error(call("get_control_effectiveness", reader, control_id=str(theirs.pk))) + + def test_links_must_be_in_scope_and_linkable(self, call): + scope = ScopeFactory() + contributor = make_user(*CONTROL_CONTRIBUTOR, scopes=[scope]) + base = {"name": "Access review", "description": "Quarterly.", "scope_ids": [str(scope.pk)]} + for requirement in (scoped_requirement(ScopeFactory()), scoped_requirement(scope, workflow_state="draft")): + result = call("create_control", contributor, **base, requirement_ids=[str(requirement.pk)]) + assert is_error(result) and "requirement_ids" in error_text(result) + assert is_error(call("create_control", contributor, **{**base, "scope_ids": [str(ScopeFactory().pk)]})) + linkable = scoped_requirement(scope) + created = call("create_control", contributor, **base, requirement_ids=[str(linkable.pk)]) + assert not is_error(created), created + control = Control.objects.get(pk=created["id"]) + assert list(control.requirements.all()) == [linkable] + batch = call("batch_create_controls", contributor, items=[ + {**base, "requirement_ids": [str(scoped_requirement(ScopeFactory()).pk)]}, + ]) + assert is_error(batch) + outside = scoped_requirement(ScopeFactory()) + refused = call("update_control", contributor, id=created["id"], requirement_ids=[str(outside.pk)]) + assert is_error(refused) + assert list(control.requirements.all()) == [linkable] + + def test_a_co_owned_control_keeps_its_other_scopes(self, call): + scope, other = ScopeFactory(), ScopeFactory() + control = scoped_control(scope) + control.scopes.add(other) + contributor = make_user(*CONTROL_CONTRIBUTOR, scopes=[scope]) + kept = call("update_control", contributor, id=str(control.pk), scope_ids=[str(scope.pk), str(other.pk)]) + assert not is_error(kept), kept + added = call("update_control", contributor, id=str(control.pk), scope_ids=[str(ScopeFactory().pk)]) + assert is_error(added) + + def test_tests_and_runs_cannot_target_out_of_scope_parents(self, call): + scope = ScopeFactory() + contributor = make_user(*TEST_CONTRIBUTOR, scopes=[scope]) + theirs = scoped_control(ScopeFactory()) + assert is_error(call("create_control_test", contributor, control_id=str(theirs.pk), name="t")) + their_test = active_test(theirs) + assert is_error(call("create_control_test_run", contributor, test_id=str(their_test.pk))) + assert not ControlTestRun.objects.filter(test=their_test).exists() + + +class TestControlTools: + def test_full_cycle_through_mcp(self, call): + scope = ScopeFactory() + owner = make_user(*(CONTROL_APPROVER + TEST_APPROVER), scopes=[scope]) + tester = make_user(*(CONTROL_READ + TEST_CONTRIBUTOR), scopes=[scope]) + created = call("create_control", owner, name="Encrypted backups", description="Off-site, AES-256.", + scope_ids=[str(scope.pk)], theme="technological") + assert created["reference"].startswith("CTRL-") and created["workflow_state"] == CONTROL_DRAFT + assert created["effectiveness"] == ControlEffectiveness.NOT_TESTED + assert not is_error(call("transition_control", owner, id=created["id"], target_state=CONTROL_PLANNED)) + test = call("create_control_test", owner, control_id=created["id"], name="Restore drill", + procedure="Restore one backup.", acceptance_criteria="Restore succeeds.", + frequency_months=6, tester_id=str(tester.pk)) + assert test["reference"].startswith("CTST-") + assert not is_error(call("transition_control_test", owner, id=test["id"], target_state=TEST_ACTIVE)) + run = call("create_control_test_run", tester, test_id=test["id"], result="pass") + assert run["performed_by"] == str(tester.pk) and run["reference"].startswith("CTRN-") + assert not is_error(call("transition_control_test_run", tester, id=run["id"], target_state=RUN_IN_REVIEW)) + validated = call("transition_control_test_run", owner, id=run["id"], target_state=RUN_VALIDATED) + assert not is_error(validated), validated + effectiveness = call("get_control_effectiveness", owner, control_id=created["id"]) + assert effectiveness["effectiveness"] == ControlEffectiveness.EFFECTIVE + [active] = effectiveness["active_tests"] + assert active["latest_validated_run"]["id"] == run["id"] + assert active["latest_validated_run"]["reviewed_by_id"] == str(owner.pk) + assert active["next_due_date"] == ControlTest.objects.get(pk=test["id"]).next_due_date.isoformat() + + def test_effectiveness_hides_the_tests_without_test_read(self, call): + scope = ScopeFactory() + control = scoped_control(scope) + active_test(control) + reader = make_user(*CONTROL_READ, scopes=[scope]) + result = call("get_control_effectiveness", reader, control_id=str(control.pk)) + assert result["active_tests"] is None + + def test_derived_effectiveness_cannot_be_written(self, call, superuser): + control = implemented_control() + call("update_control", superuser, id=str(control.pk), effectiveness=ControlEffectiveness.EFFECTIVE, name="New") + control.refresh_from_db() + assert control.name == "New" + assert control.effectiveness == ControlEffectiveness.NOT_TESTED + + def test_list_filters(self, call, superuser): + requirement = RequirementFactory() + late = implemented_control() + late.requirements.add(requirement) + active_test(late, first_due_date=timezone.localdate() - datetime.timedelta(days=3)) + implemented_control() + assert item_ids(call("list_controls", superuser, test_overdue=True)) == {str(late.pk)} + assert item_ids(call("list_controls", superuser, requirement_id=str(requirement.pk))) == {str(late.pk)} + assert len(call("list_controls", superuser, workflow_state=CONTROL_IMPLEMENTED)["items"]) == 2 + + def test_delete_a_control_with_tests_is_a_tool_error(self, call, superuser): + control = ControlFactory() + ControlTestFactory(control=control) + result = call("delete_control", superuser, id=str(control.pk)) + assert is_error(result) and "referenced" in error_text(result) + assert Control.objects.filter(pk=control.pk).exists() + bare = ControlFactory() + assert call("delete_control", superuser, id=str(bare.pk))["deleted"] is True + + def test_create_controls_from_requirements(self, call): + scope = ScopeFactory() + framework = FrameworkFactory() + framework.scopes.add(scope) + fresh = RequirementFactory(framework=framework) + covered = RequirementFactory(framework=framework) + ControlFactory().requirements.add(covered) + user = make_user(*(CONTROL_CONTRIBUTOR + ("compliance.framework.read",)), scopes=[scope]) + result = call("create_controls_from_requirements", user, framework_id=str(framework.pk)) + assert result["created_count"] == 1 and result["skipped_count"] == 1 + assert result["created"][0]["requirement_ids"] == [str(fresh.pk)] + assert result["skipped"][0]["reason"] == "already_covered" + assert result["created"][0]["scopes"] == [str(scope.pk)] + no_framework = make_user(*CONTROL_CONTRIBUTOR, scopes=[scope]) + assert "compliance.framework.read" in error_text( + call("create_controls_from_requirements", no_framework, framework_id=str(framework.pk)) + ) + outsider = make_user(*(CONTROL_CONTRIBUTOR + ("compliance.framework.read",)), scopes=[ScopeFactory()]) + assert is_error(call("create_controls_from_requirements", outsider, framework_id=str(framework.pk))) + + def test_requirement_coverage_lists_only_visible_reportable_controls(self, call): + scope = ScopeFactory() + framework = FrameworkFactory() + framework.scopes.add(scope) + requirement = RequirementFactory(framework=framework, workflow_state="validated") + uncovered = RequirementFactory(framework=framework, workflow_state="validated") + mine = scoped_control(scope) + theirs = scoped_control(ScopeFactory()) + proposed = scoped_control(scope, implemented=False) + for control in (mine, theirs, proposed): + control.requirements.add(requirement) + reader = make_user(*(CONTROL_READ + ("compliance.framework.read",)), scopes=[scope]) + result = call("get_requirement_coverage", reader, framework_id=str(framework.pk)) + rows = {row["requirement_id"]: row for row in result["rows"]} + assert [c["id"] for c in rows[str(requirement.pk)]["controls"]] == [str(mine.pk)] + assert rows[str(requirement.pk)]["implementation"] == "implemented" + assert rows[str(uncovered.pk)]["implementation"] == "none" + assert result["totals"] == {"implemented": 1, "none": 1} + + +class TestTestAndRunTools: + def test_overdue_and_my_due_lists(self, call): + scope = ScopeFactory() + tester = make_user(*TEST_READ, scopes=[scope]) + control = scoped_control(scope) + late = active_test(control, tester=tester, first_due_date=timezone.localdate() - datetime.timedelta(days=1)) + soon = active_test(control, tester=tester, first_due_date=timezone.localdate() + datetime.timedelta(days=10)) + active_test(control, tester=tester, first_due_date=timezone.localdate() + datetime.timedelta(days=90)) + active_test(scoped_control(ScopeFactory()), first_due_date=timezone.localdate() - datetime.timedelta(days=5)) + overdue = call("list_overdue_control_tests", tester) + assert item_ids(overdue) == {str(late.pk)} + assert overdue["items"][0]["is_overdue"] is True + assert [i["id"] for i in call("list_my_control_tests_due", tester)["items"]] == [str(late.pk), str(soon.pk)] + assert len(call("list_my_control_tests_due", tester, within_days=120)["items"]) == 3 + + def test_rg_ctl_09_update_refuses_a_frozen_field(self, call, superuser): + run = ControlTestRunFactory(result=RunResult.PASS) + assert not is_error(call("update_control_test_run", superuser, id=str(run.pk), sample_description="25 logins")) + run.transition_to(RUN_IN_REVIEW, run.performed_by) + for field, value in (("result", "fail"), ("observations", "Rewritten."), ("performed_on", "2020-01-01")): + result = call("update_control_test_run", superuser, id=str(run.pk), **{field: value}) + assert is_error(result) and "RG-CTL-09" in error_text(result), field + run.refresh_from_db() + assert run.result == RunResult.PASS + assert not is_error(call("update_control_test_run", superuser, id=str(run.pk), result="pass")) + moved = call("update_control_test_run", superuser, id=str(run.pk), test_id=str(active_test().pk)) + assert is_error(moved) + + def test_rg_ctl_11_the_performer_cannot_validate_through_mcp(self, call): + scope = ScopeFactory() + performer = make_user(*TEST_APPROVER, scopes=[scope]) + reviewer = make_user(*TEST_APPROVER, scopes=[scope]) + run = ControlTestRunFactory(test=active_test(scoped_control(scope)), performed_by=performer, result=RunResult.PASS) + assert not is_error(call("transition_control_test_run", performer, id=str(run.pk), target_state=RUN_IN_REVIEW)) + refused = call("transition_control_test_run", performer, id=str(run.pk), target_state=RUN_VALIDATED) + assert is_error(refused) and "cannot validate" in error_text(refused) + assert not is_error(call("transition_control_test_run", reviewer, id=str(run.pk), target_state=RUN_VALIDATED)) + run.refresh_from_db() + assert run.reviewed_by == reviewer + + def test_a_superuser_performer_cannot_validate_either(self, call, superuser): + run = ControlTestRunFactory(performed_by=superuser, result=RunResult.PASS) + run.transition_to(RUN_IN_REVIEW, superuser) + refused = call("transition_control_test_run", superuser, id=str(run.pk), target_state=RUN_VALIDATED) + assert is_error(refused) + + def test_run_payload_lists_evidence_metadata(self, call, superuser, settings, tmp_path): + settings.MEDIA_ROOT = tmp_path + run = ControlTestRunFactory(result=RunResult.PASS) + item = upload(run) + payload = call("get_control_test_run", superuser, id=str(run.pk)) + assert payload["evidence"][0]["sha256"] == item.sha256 + assert payload["evidence"][0]["download_url"].endswith(f"/evidence/{item.pk}/download/") + assert "file" not in payload["evidence"][0] + + def test_raise_finding_once(self, call): + scope = ScopeFactory() + run = validated_run(active_test(scoped_control(scope)), result=RunResult.FAIL, observations="3 of 25 without MFA.") + approver = make_user(*TEST_APPROVER, scopes=[scope]) + assert is_error(call("raise_finding_from_run", approver, run_id=str(run.pk), finding_type="strength")) + finding = call("raise_finding_from_run", approver, run_id=str(run.pk), finding_type="major_nc") + assert not is_error(finding), finding + assert finding["source"] == FindingSource.CONTROL_TEST + assert finding["control_test_run_id"] == str(run.pk) + assert finding["assessor_id"] == str(approver.pk) + again = call("raise_finding_from_run", approver, run_id=str(run.pk), finding_type="major_nc") + assert is_error(again) and "already" in error_text(again) + passed = validated_run() + assert is_error(call("raise_finding_from_run", approver, run_id=str(passed.pk), finding_type="observation")) + outsider = make_user(*TEST_APPROVER, scopes=[ScopeFactory()]) + assert is_error(call("raise_finding_from_run", outsider, run_id=str(run.pk), finding_type="observation")) + + def test_findings_list_by_control_test_source(self, call, superuser): + run = validated_run(result=RunResult.PARTIAL, observations="Partial.") + finding = run.raise_finding(superuser, "observation") + listed = call("list_findings", superuser, source="control_test") + assert item_ids(listed) == {str(finding.pk)} + assert listed["items"][0]["control_test_run_reference"] == run.reference + assert item_ids(call("list_findings", superuser, control_test_run_id=str(run.pk))) == {str(finding.pk)} + # A finding raised outside an audit has no assessment to re-apply. + assert call("delete_finding", superuser, id=str(finding.pk))["deleted"] is True diff --git a/controls/tests/test_reverse_cards.py b/controls/tests/test_reverse_cards.py new file mode 100644 index 00000000..0fe3d9a5 --- /dev/null +++ b/controls/tests/test_reverse_cards.py @@ -0,0 +1,233 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""The *Controls* reverse cards (README §8) on the requirement, risk, +treatment plan, PACS measure (EBIOS RM workshop 5), support asset, objective +and document detail pages, and the finding page's link back to its run +(RG-CTL-16).""" + +from datetime import timedelta + +import pytest +from django.test import Client +from django.urls import reverse +from django.utils import timezone + +from accounts.models import Group +from accounts.tests.factories import UserFactory +from assets.tests.factories import SupportAssetFactory +from compliance.constants import FindingSource, FindingType +from compliance.forms import FindingRegisterForm +from compliance.tests.factories import FrameworkFactory, RequirementFactory +from context.tests.factories import ObjectiveFactory, ScopeFactory +from controls.constants import CONTROL_RETIRED, ControlEffectiveness, RunResult +from controls.integrations import linked_controls, linked_controls_for_measures +from controls.models import ControlTest +from controls.tests.factories import ControlFactory, active_test, implemented_control, validated_run +from documents.tests.factories import PolicyFactory +from risks.constants import EbiosWorkshopNumber +from risks.tests.factories import ( + EbiosAssessmentFactory, + PACSMeasureFactory, + RiskFactory, + RiskTreatmentPlanFactory, +) + +pytestmark = pytest.mark.django_db + + +def _get(user, url): + client = Client() + client.force_login(user) + response = client.get(url) + assert response.status_code == 200 + return response + + +def _reader(): + """A Lecteur : reads every register, controls included (README §4).""" + user = UserFactory() + Group.objects.get(name="Lecteur").users.add(user) + return user + + +# --- The shared helper ------------------------------------------------------- + + +class TestLinkedControls: + def test_none_without_the_read_permission(self): + requirement = RequirementFactory() + implemented_control().requirements.add(requirement) + assert linked_controls(requirement, UserFactory()) is None + + def test_any_step_with_retired_last(self): + requirement = RequirementFactory() + retired = implemented_control(name="A retired control") + retired.transition_to(CONTROL_RETIRED, retired.owner, comment="Replaced.") + proposed = ControlFactory(name="B proposed control") + live = implemented_control(name="C live control") + for control in (retired, proposed, live): + control.requirements.add(requirement) + controls = linked_controls(requirement, UserFactory(is_superuser=True)) + assert controls[-1].pk == retired.pk + assert controls[-1].is_retired and not any(c.is_retired for c in controls[:-1]) + assert {c.pk for c in controls} == {retired.pk, proposed.pk, live.pk} + + def test_scope_filtered(self): + mine, other = ScopeFactory(), ScopeFactory() + requirement = RequirementFactory() + visible = implemented_control() + visible.scopes.set([mine]) + hidden = implemented_control() + hidden.scopes.set([other]) + for control in (visible, hidden): + control.requirements.add(requirement) + reader = _reader() + from unittest.mock import patch + + with patch.object(type(reader), "get_allowed_scope_ids", return_value={mine.pk}): + controls = linked_controls(requirement, reader) + assert [c.pk for c in controls] == [visible.pk] + + def test_stale_effectiveness_is_flagged(self): + risk = RiskFactory() + test = active_test() + validated_run(test=test, result=RunResult.PASS) + ControlTest.objects.filter(pk=test.pk).update(next_due_date=timezone.localdate() - timedelta(days=2)) + test.control.risks.add(risk) + (control,) = linked_controls(risk, UserFactory(is_superuser=True)) + assert control.effectiveness == ControlEffectiveness.EFFECTIVE + assert control.effectiveness_stale is True + + def test_measures_carry_the_references_they_are_linked_through(self): + m1, m2 = PACSMeasureFactory(), PACSMeasureFactory() + control = implemented_control() + control.pacs_measures.add(m1, m2) + (row,) = linked_controls_for_measures([m1, m2], UserFactory(is_superuser=True)) + assert row.linked_via == sorted([m1.reference, m2.reference]) + + +# --- The detail pages ---------------------------------------------------------- + + +class TestReverseCards: + def _assert_card(self, body, control): + assert 'id="linked-controls"' in body + assert control.reference in body + assert reverse("controls:control-detail", args=[control.pk]) in body + + def test_requirement(self): + framework = FrameworkFactory() + requirement = RequirementFactory(framework=framework) + control = implemented_control(name="Requirement control") + control.requirements.add(requirement) + body = _get(UserFactory(is_superuser=True), reverse("compliance:requirement-detail", args=[requirement.pk])).content.decode() + self._assert_card(body, control) + + def test_risk_shows_effectiveness(self): + risk = RiskFactory() + run = validated_run(result=RunResult.FAIL) + control = run.test.control + control.risks.add(risk) + body = _get(UserFactory(is_superuser=True), reverse("risks:risk-detail", args=[risk.pk])).content.decode() + self._assert_card(body, control) + assert str(ControlEffectiveness.NOT_EFFECTIVE.label) in body + + def test_treatment_plan(self): + plan = RiskTreatmentPlanFactory() + control = implemented_control() + control.treatment_plans.add(plan) + body = _get(UserFactory(is_superuser=True), reverse("risks:treatment-plan-detail", args=[plan.pk])).content.decode() + self._assert_card(body, control) + + def test_support_asset(self): + asset = SupportAssetFactory() + control = implemented_control() + control.support_assets.add(asset) + body = _get(UserFactory(is_superuser=True), reverse("assets:support-asset-detail", args=[asset.pk])).content.decode() + self._assert_card(body, control) + + def test_objective(self): + objective = ObjectiveFactory() + control = implemented_control() + control.objectives.add(objective) + body = _get(UserFactory(is_superuser=True), reverse("context:objective-detail", args=[objective.pk])).content.decode() + self._assert_card(body, control) + + def test_policy(self): + policy = PolicyFactory() + control = implemented_control() + control.policies.add(policy) + body = _get(UserFactory(is_superuser=True), reverse("documents:policy-detail", args=[policy.pk])).content.decode() + self._assert_card(body, control) + + def test_pacs_measures_on_workshop_5(self): + assessment = EbiosAssessmentFactory() + measure = PACSMeasureFactory(summary=assessment.ebios_summary) + control = implemented_control() + control.pacs_measures.add(measure) + workshop = assessment.ebios_workshops.get(workshop_number=EbiosWorkshopNumber.W5) + url = reverse("risks:ebios-workshop-detail", kwargs={"assessment_pk": assessment.pk, "workshop_pk": workshop.pk}) + body = _get(UserFactory(is_superuser=True), url).content.decode() + self._assert_card(body, control) + assert measure.reference in body + + def test_empty_state(self): + objective = ObjectiveFactory() + body = _get(UserFactory(is_superuser=True), reverse("context:objective-detail", args=[objective.pk])).content.decode() + assert 'id="linked-controls"' in body + assert "No control linked." in body + + def test_retired_control_is_flagged_and_shows_no_effectiveness(self): + requirement = RequirementFactory() + run = validated_run(result=RunResult.PASS) + control = run.test.control + control.transition_to(CONTROL_RETIRED, control.owner, comment="Replaced.") + control.requirements.add(requirement) + body = _get(UserFactory(is_superuser=True), reverse("compliance:requirement-detail", args=[requirement.pk])).content.decode() + self._assert_card(body, control) + control.refresh_from_db() + assert str(control.lifecycle_label) in body + + def test_hidden_without_the_read_permission(self): + """A reader of objectives who may not read controls sees no card.""" + objective = ObjectiveFactory() + implemented_control().objectives.add(objective) + user = UserFactory() + from accounts.models import Permission + + group = Group.objects.create(name="Objectives only") + group.permissions.set(Permission.objects.filter(codename="context.objective.read")) + group.users.add(user) + body = _get(user, reverse("context:objective-detail", args=[objective.pk])).content.decode() + assert 'id="linked-controls"' not in body + + +# --- The finding page ---------------------------------------------------------- + + +class TestFindingFromRun: + def _finding(self): + run = validated_run(result=RunResult.FAIL, observations="Two logins without a second factor.") + return run, run.raise_finding(UserFactory(), FindingType.MINOR_NON_CONFORMITY) + + def test_page_links_back_to_the_run(self): + run, finding = self._finding() + body = _get(UserFactory(is_superuser=True), reverse("compliance:finding-detail", args=[finding.pk])).content.decode() + assert reverse("controls:controltestrun-detail", args=[run.pk]) in body + assert run.reference in body + assert str(FindingSource.CONTROL_TEST.label) in body + + def test_register_filter_accepts_the_source(self): + _run, finding = self._finding() + client = Client() + client.force_login(UserFactory(is_superuser=True)) + response = client.get(reverse("compliance:finding-list"), {"source": FindingSource.CONTROL_TEST}) + assert response.status_code == 200 + assert finding.reference in response.content.decode() + + def test_form_never_offers_the_source_by_hand_and_keeps_it_once_raised(self): + values = [c[0] for c in FindingRegisterForm().fields["source"].choices] + assert FindingSource.CONTROL_TEST not in values + _run, finding = self._finding() + values = [c[0] for c in FindingRegisterForm(instance=finding).fields["source"].choices] + assert values == [FindingSource.CONTROL_TEST] diff --git a/controls/tests/test_sweep.py b/controls/tests/test_sweep.py new file mode 100644 index 00000000..2ef5fcc8 --- /dev/null +++ b/controls/tests/test_sweep.py @@ -0,0 +1,161 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""The daily ``controls_sweep`` command (README §7) : test due / overdue +reminders, each sent once per cycle (overdue : once, then weekly), and the +recomputation of every reportable control's effectiveness.""" + +from datetime import timedelta +from io import StringIO + +import pytest +from django.core.management import call_command +from django.utils import timezone + +from accounts.constants import NotificationType +from accounts.models import Notification +from controls.constants import ( + OVERDUE_REMINDER_INTERVAL_DAYS, + TEST_DUE_REMINDER_DAYS, + TEST_RETIRED, + ControlEffectiveness, + RunResult, +) +from controls.models import Control, ControlTest +from controls.tests.factories import active_test, implemented_control, validated_run + +pytestmark = pytest.mark.django_db + + +def _sweep(*args): + out = StringIO() + call_command("controls_sweep", *args, stdout=out) + return out.getvalue() + + +def _of(kind, user=None): + qs = Notification.objects.filter(notification_type=kind) + return qs.filter(recipient=user) if user is not None else qs + + +def _test_due_in(days, **kwargs): + return active_test(first_due_date=timezone.localdate() + timedelta(days=days), **kwargs) + + +def _shift_reminder(test, days): + """Pretend the last reminder went out ``days`` days ago.""" + ControlTest.objects.filter(pk=test.pk).update( + last_reminded_at=timezone.now() - timedelta(days=days), + ) + + +class TestDueReminders: + def test_due_within_the_window_notifies_the_tester_once(self): + test = _test_due_in(TEST_DUE_REMINDER_DAYS - 1) + _sweep() + assert _of(NotificationType.CONTROL_TEST_DUE, test.tester).count() == 1 + test.refresh_from_db() + assert test.last_reminded_at is not None + _sweep() + assert _of(NotificationType.CONTROL_TEST_DUE).count() == 1 + + def test_outside_the_window_sends_nothing(self): + _test_due_in(TEST_DUE_REMINDER_DAYS + 5) + _sweep() + assert not _of(NotificationType.CONTROL_TEST_DUE).exists() + + def test_falls_back_to_the_control_owner_without_a_tester(self): + test = _test_due_in(3, tester=None) + _sweep() + assert _of(NotificationType.CONTROL_TEST_DUE, test.control.owner).count() == 1 + + def test_a_new_cycle_is_reminded_again(self): + test = _test_due_in(2) + _sweep() + # A validated run moves the due date a full frequency forward; the + # next window opens long after the reminder that was sent. + _shift_reminder(test, 200) + ControlTest.objects.filter(pk=test.pk).update(next_due_date=timezone.localdate() + timedelta(days=5)) + _sweep() + assert _of(NotificationType.CONTROL_TEST_DUE).count() == 2 + + def test_draft_and_retired_tests_are_never_reminded(self): + test = _test_due_in(2) + test.transition_to(TEST_RETIRED, test.control.owner, comment="No longer relevant.") + _sweep() + assert not Notification.objects.filter( + notification_type__in=[NotificationType.CONTROL_TEST_DUE, NotificationType.CONTROL_TEST_OVERDUE], + ).exists() + + +class TestOverdueReminders: + def test_overdue_notifies_tester_and_owner_then_weekly(self): + test = _test_due_in(-30) + _sweep() + overdue = _of(NotificationType.CONTROL_TEST_OVERDUE) + assert set(overdue.values_list("recipient_id", flat=True)) == {test.tester_id, test.control.owner_id} + first = overdue.count() + _sweep() + assert _of(NotificationType.CONTROL_TEST_OVERDUE).count() == first + _shift_reminder(test, OVERDUE_REMINDER_INTERVAL_DAYS - 1) + _sweep() + assert _of(NotificationType.CONTROL_TEST_OVERDUE).count() == first + _shift_reminder(test, OVERDUE_REMINDER_INTERVAL_DAYS) + _sweep() + assert _of(NotificationType.CONTROL_TEST_OVERDUE).count() == first * 2 + + def test_a_due_reminder_does_not_count_as_the_overdue_one(self): + test = _test_due_in(1) + _sweep() + assert _of(NotificationType.CONTROL_TEST_DUE).count() == 1 + # Two days later the test is overdue : the reminder sent before the + # due date was the "due soon" one, so the overdue one is still owed. + ControlTest.objects.filter(pk=test.pk).update( + next_due_date=timezone.localdate() - timedelta(days=1), + last_reminded_at=timezone.now() - timedelta(days=2), + ) + _sweep() + assert _of(NotificationType.CONTROL_TEST_OVERDUE).exists() + + def test_last_reminded_at_is_written_without_a_history_row(self): + test = _test_due_in(-2) + before = test.history.count() + version = test.version + _sweep() + test.refresh_from_db() + assert test.last_reminded_at is not None + assert test.history.count() == before + assert test.version == version + + +class TestEffectivenessAndDryRun: + def test_recomputes_a_stale_effectiveness(self): + run = validated_run(result=RunResult.FAIL) + control = run.test.control + Control.objects.filter(pk=control.pk).update(effectiveness=ControlEffectiveness.EFFECTIVE) + out = _sweep() + control.refresh_from_db() + assert control.effectiveness == ControlEffectiveness.NOT_EFFECTIVE + assert "1 control effectiveness value(s) updated" in out + + def test_proposed_controls_are_left_alone(self): + from controls.tests.factories import ControlFactory + + control = ControlFactory() + Control.objects.filter(pk=control.pk).update(effectiveness=ControlEffectiveness.EFFECTIVE) + _sweep() + control.refresh_from_db() + assert control.effectiveness == ControlEffectiveness.EFFECTIVE + + def test_dry_run_sends_and_writes_nothing(self): + test = _test_due_in(-3) + control = implemented_control() + validated_run(test=active_test(control=control), result=RunResult.PASS) + Control.objects.filter(pk=control.pk).update(effectiveness=ControlEffectiveness.NOT_TESTED) + out = _sweep("--dry-run") + assert "Would send 0 test-due and 1 test-overdue reminder(s)" in out + assert "1 control effectiveness value(s) would change" in out + assert not _of(NotificationType.CONTROL_TEST_OVERDUE).exists() + test.refresh_from_db() + assert test.last_reminded_at is None + control.refresh_from_db() + assert control.effectiveness == ControlEffectiveness.NOT_TESTED diff --git a/controls/tests/test_views.py b/controls/tests/test_views.py new file mode 100644 index 00000000..1b2b723d --- /dev/null +++ b/controls/tests/test_views.py @@ -0,0 +1,590 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""The web interface of module 8 (controls) : pages, acts, tenancy and the frozen run.""" + +import hashlib + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import Client +from django.urls import reverse +from django.utils import timezone + +from accounts.tests.factories import GroupFactory, PermissionFactory, UserFactory +from compliance.constants import FindingSource, FindingType +from compliance.models import Finding +from compliance.tests.factories import FrameworkFactory, RequirementFactory +from context.tests.factories import ScopeFactory +from controls.constants import ( + CONTROL_DRAFT, + RUN_DRAFT, + RUN_IN_REVIEW, + TEST_ACTIVE, + RunResult, +) +from controls.models import Control, ControlTest, ControlTestEvidence, ControlTestRun +from controls.tests.factories import ( + ControlFactory, + ControlTestFactory, + ControlTestRunFactory, + active_test, + implemented_control, + validated_run, +) + +pytestmark = pytest.mark.django_db + +HTMX_DRAWER = {"HTTP_HX_REQUEST": "true", "HTTP_HX_TARGET": "drawer-form-content"} +HTMX_TABLE = {"HTTP_HX_REQUEST": "true", "HTTP_HX_TARGET": "item-table-body"} +READ = ("controls.control.read", "controls.test.read") + + +def _user(*codenames, scopes=()): + user = UserFactory() + group = GroupFactory() + for codename in codenames: + group.permissions.add(PermissionFactory(codename=codename)) + if scopes: + group.allowed_scopes.add(*scopes) + group.users.add(user) + return user + + +def _client(user): + client = Client() + client.force_login(user) + return client + + +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) + + +@pytest.fixture +def admin(): + return UserFactory(is_superuser=True) + + +@pytest.fixture +def admin_client(admin): + return _client(admin) + + +@pytest.fixture +def scope(): + return ScopeFactory() + + +@pytest.fixture +def control(scope): + control = implemented_control(name="Multi-factor authentication") + control.scopes.add(scope) + return control + + +@pytest.fixture +def test(control): + return active_test(control=control, name="Quarterly sample of remote logins") + + +@pytest.fixture +def draft_run(test): + return ControlTestRunFactory(test=test) + + +def _upload(name="export.csv", content=b"login,mfa\nalice,yes\n"): + return SimpleUploadedFile(name, content, content_type="text/csv") + + +# --- Pages ------------------------------------------------------------------ + + +class TestPagesRender: + def test_every_page_renders(self, admin_client, control, test, draft_run): + failed = validated_run(test=test, result=RunResult.FAIL, observations="Two logins without MFA.") + evidence = ControlTestEvidence.objects.create(run=draft_run, file=_upload()) + framework = FrameworkFactory() + urls = [ + reverse("controls:control-list"), + reverse("controls:control-create"), + reverse("controls:control-from-requirements"), + reverse("controls:control-from-requirements") + f"?framework={framework.pk}", + reverse("controls:control-detail", args=[control.pk]), + reverse("controls:control-update", args=[control.pk]), + reverse("controls:control-delete", args=[control.pk]), + reverse("controls:controltest-list"), + reverse("controls:controltest-create", args=[control.pk]), + reverse("controls:controltest-detail", args=[test.pk]), + reverse("controls:controltest-update", args=[test.pk]), + reverse("controls:controltest-delete", args=[test.pk]), + reverse("controls:controltestrun-create", args=[test.pk]), + reverse("controls:controltestrun-detail", args=[draft_run.pk]), + reverse("controls:controltestrun-detail", args=[failed.pk]), + reverse("controls:controltestrun-update", args=[draft_run.pk]), + reverse("controls:controltestrun-delete", args=[draft_run.pk]), + reverse("controls:controltestrun-raise-finding", args=[failed.pk]), + reverse("controls:evidence-upload", args=[draft_run.pk]), + reverse("controls:evidence-delete", args=[evidence.pk]), + ] + for url in urls: + response = admin_client.get(url) + assert response.status_code == 200, url + drawer = admin_client.get(url, **HTMX_DRAWER) + assert drawer.status_code == 200, url + + def test_lists_answer_their_own_table_refresh(self, admin_client, control, test): + for url, needle in ( + (reverse("controls:control-list"), control.reference), + (reverse("controls:controltest-list"), test.reference), + ): + response = admin_client.get(url, **HTMX_TABLE) + html = response.content.decode() + assert response.status_code == 200 + assert needle in html + assert "Daily backups, encrypted, copied off site.

", + "theme": "technological", "nature": "corrective", "automation": "automated", + "requirements": [requirement.pk], "scopes": [scope.pk], + }, **HTMX_DRAWER) + assert response.status_code == 204 + control = Control.objects.get(name="Encrypted off-site backups") + assert control.workflow_state == CONTROL_DRAFT + assert list(control.requirements.all()) == [requirement] + assert control.created_by is not None + + def test_pickers_offer_linkable_targets_only(self, admin_client): + draft = RequirementFactory(name="Draft requirement") + live = RequirementFactory(name="Validated requirement") + _validate(live) + html = admin_client.get(reverse("controls:control-create")).content.decode() + assert "Validated requirement" in html + assert "Draft requirement" not in html + assert draft.pk + + def test_edit_keeps_links_the_editor_cannot_see(self, scope, control): + hidden_scope = ScopeFactory() + hidden_framework = FrameworkFactory() + hidden_framework.scopes.add(hidden_scope) + hidden = RequirementFactory(framework=hidden_framework) + _validate(hidden) + control.requirements.add(hidden) + editor = _client(_user(*READ, "controls.control.update", scopes=[scope])) + response = editor.post(reverse("controls:control-update", args=[control.pk]), { + "name": "Renamed control", "description": control.description, + "theme": control.theme, "nature": control.nature, "automation": control.automation, + "scopes": [scope.pk], + }) + assert response.status_code == 302 + control.refresh_from_db() + assert control.name == "Renamed control" + assert hidden in control.requirements.all() + + def test_derived_effectiveness_is_not_editable(self, admin_client, control): + admin_client.post(reverse("controls:control-update", args=[control.pk]), { + "name": control.name, "description": control.description, "theme": control.theme, + "nature": control.nature, "automation": control.automation, "effectiveness": "effective", + }) + control.refresh_from_db() + assert control.effectiveness == "not_tested" + + def test_a_retired_control_opens_frozen(self, admin, admin_client, control): + control.transition_to("archived", admin, comment="Replaced by a new control.") + response = admin_client.get(reverse("controls:control-update", args=[control.pk])) + assert response.context["form"].frozen_note + response = admin_client.post(reverse("controls:control-update", args=[control.pk]), { + "name": "Changed", "description": "x", "theme": "people", "nature": "preventive", "automation": "manual", + }) + assert response.status_code == 200 + control.refresh_from_db() + assert control.name != "Changed" + + def test_delete_a_proposed_control_only(self, admin_client, control): + proposed = ControlFactory() + response = admin_client.post(reverse("controls:control-delete", args=[proposed.pk])) + assert response.status_code == 302 + assert not Control.objects.filter(pk=proposed.pk).exists() + response = admin_client.post(reverse("controls:control-delete", args=[control.pk])) + assert response.status_code == 302 + assert Control.objects.filter(pk=control.pk).exists() + + +class TestCreateFromRequirements: + def _framework(self, scope): + framework = FrameworkFactory(short_name="ISO 27001") + framework.scopes.add(scope) + _validate(framework) + return framework + + def test_checklist_lists_uncovered_applicable_requirements(self, admin_client, scope): + framework = self._framework(scope) + free = RequirementFactory(framework=framework, name="Access rights") + covered = RequirementFactory(framework=framework, name="Covered one") + excluded = RequirementFactory(framework=framework, name="Not applicable", is_applicable=False, applicability_justification="No site.") + for req in (free, covered, excluded): + _validate(req) + ControlFactory().requirements.add(covered) + url = reverse("controls:control-from-requirements") + f"?framework={framework.pk}" + html = admin_client.get(url, **HTMX_DRAWER).content.decode() + assert "Access rights" in html + assert "Covered one" not in html + assert "Not applicable" not in html + + def test_creates_and_skips(self, admin_client, scope): + framework = self._framework(scope) + first = RequirementFactory(framework=framework, name="Access rights") + second = RequirementFactory(framework=framework, name="Logging") + for req in (first, second): + _validate(req) + # Covered between the drawer opening and the submission : skipped, not doubled. + ControlFactory().requirements.add(second) + response = admin_client.post(reverse("controls:control-from-requirements"), { + "framework": framework.pk, "requirements": [first.pk, second.pk], + }, follow=True) + assert response.status_code == 200 + created = Control.objects.filter(requirements=first) + assert created.count() == 1 + assert created.get().workflow_state == CONTROL_DRAFT + assert list(created.get().scopes.all()) == [scope] + assert Control.objects.filter(requirements=second).count() == 1 + text = " ".join(str(m) for m in response.context["messages"]) + assert "1 proposed control was created" in text + assert "1 requirement was skipped" in text + + def test_drawer_redirects_to_the_list(self, admin_client, scope): + framework = self._framework(scope) + req = RequirementFactory(framework=framework) + _validate(req) + response = admin_client.post(reverse("controls:control-from-requirements"), { + "framework": framework.pk, "requirements": [req.pk], + }, **HTMX_DRAWER) + assert response.status_code == 204 + assert response["HX-Redirect"] == reverse("controls:control-list") + + def test_requires_create_permission(self, scope): + framework = self._framework(scope) + req = RequirementFactory(framework=framework) + _validate(req) + client = _client(_user(*READ)) + client.post(reverse("controls:control-from-requirements"), {"framework": framework.pk, "requirements": [req.pk]}) + assert not Control.objects.filter(requirements=req).exists() + + def test_out_of_scope_framework_is_not_offered(self, scope): + framework = self._framework(ScopeFactory()) + req = RequirementFactory(framework=framework) + _validate(req) + client = _client(_user(*READ, "controls.control.create", scopes=[scope])) + response = client.post(reverse("controls:control-from-requirements"), { + "framework": framework.pk, "requirements": [req.pk], + }) + assert response.status_code == 200 + assert "framework" in response.context["form"].errors + assert not Control.objects.filter(requirements=req).exists() + + +# --- Tests and runs --------------------------------------------------------- + + +class TestTestAndRunForms: + def test_create_a_test_from_its_control(self, admin_client, control): + response = admin_client.post(reverse("controls:controltest-create", args=[control.pk]), { + "name": "Annual design review", "method": "inspection", "frequency_months": 12, + "procedure": "Review the configuration.", "acceptance_criteria": "MFA enforced.", + }) + assert response.status_code == 302 + test = ControlTest.objects.get(name="Annual design review") + assert test.control == control + assert test.next_due_date is None + + def test_a_retired_control_takes_no_new_test(self, admin, admin_client, control): + control.transition_to("archived", admin, comment="Retired.") + response = admin_client.get(reverse("controls:controltest-create", args=[control.pk])) + assert response.status_code == 302 + + def test_run_form_is_prefilled_with_the_tester_and_today(self, admin_client, test): + response = admin_client.get(reverse("controls:controltestrun-create", args=[test.pk]), **HTMX_DRAWER) + form = response.context["form"] + assert form.initial["performed_by"] == test.tester_id + assert form.initial["performed_on"] == timezone.localdate() + + def test_run_form_falls_back_to_the_user_without_tester(self, admin, admin_client, control): + test = active_test(control=control, tester=None) + response = admin_client.get(reverse("controls:controltestrun-create", args=[test.pk])) + assert response.context["form"].initial["performed_by"] == admin.pk + + def test_record_a_run(self, admin, admin_client, test): + response = admin_client.post(reverse("controls:controltestrun-create", args=[test.pk]), { + "performed_by": admin.pk, "performed_on": timezone.localdate().isoformat(), + "result": RunResult.PASS, "sample_description": "25 logins", + }) + assert response.status_code == 302 + run = ControlTestRun.objects.get(test=test) + assert run.workflow_state == RUN_DRAFT + assert run.created_by == admin + + def test_no_run_on_a_draft_test(self, admin_client, control): + draft = ControlTestFactory(control=control) + response = admin_client.get(reverse("controls:controltestrun-create", args=[draft.pk])) + assert response.status_code == 302 + assert not ControlTestRun.objects.filter(test=draft).exists() + + def test_future_date_is_refused(self, admin, admin_client, test): + response = admin_client.post(reverse("controls:controltestrun-create", args=[test.pk]), { + "performed_by": admin.pk, "performed_on": (timezone.localdate() + timezone.timedelta(days=2)).isoformat(), + }) + assert response.status_code == 200 + assert "performed_on" in response.context["form"].errors + + def test_update_of_a_submitted_run_is_refused(self, admin_client, draft_run): + draft_run.result = RunResult.PASS + draft_run.save() + draft_run.transition_to(RUN_IN_REVIEW, draft_run.performed_by) + url = reverse("controls:controltestrun-update", args=[draft_run.pk]) + assert admin_client.get(url).status_code == 302 + response = admin_client.post(url, { + "performed_by": draft_run.performed_by_id, "performed_on": draft_run.performed_on.isoformat(), + "result": RunResult.FAIL, "observations": "Changed after submission.", + }) + assert response.status_code == 302 + draft_run.refresh_from_db() + assert draft_run.result == RunResult.PASS + drawer = admin_client.get(url, **HTMX_DRAWER) + assert drawer.status_code == 204 + assert drawer["HX-Redirect"] == reverse("controls:controltestrun-detail", args=[draft_run.pk]) + + def test_delete_a_draft_run_only(self, admin_client, test, draft_run): + validated = validated_run(test=test) + admin_client.post(reverse("controls:controltestrun-delete", args=[validated.pk])) + assert ControlTestRun.objects.filter(pk=validated.pk).exists() + admin_client.post(reverse("controls:controltestrun-delete", args=[draft_run.pk])) + assert not ControlTestRun.objects.filter(pk=draft_run.pk).exists() + + +# --- Evidence --------------------------------------------------------------- + + +class TestEvidence: + def test_upload_stamps_size_and_sha256(self, admin_client, draft_run): + content = b"login,mfa\nalice,yes\n" + response = admin_client.post( + reverse("controls:evidence-upload", args=[draft_run.pk]), + {"file": _upload(content=content), "description": "Q3 export"}, **HTMX_DRAWER, + ) + assert response.status_code == 204 + item = draft_run.evidence.get() + assert item.sha256 == hashlib.sha256(content).hexdigest() + assert item.size == len(content) + assert item.original_filename == "export.csv" + html = admin_client.get(reverse("controls:controltestrun-detail", args=[draft_run.pk])).content.decode() + assert item.sha256 in html + assert reverse("controls:evidence-download", args=[item.pk]) in html + assert item.file.url not in html + + def test_disallowed_extension_is_refused(self, admin_client, draft_run): + response = admin_client.post( + reverse("controls:evidence-upload", args=[draft_run.pk]), + {"file": SimpleUploadedFile("run.exe", b"MZ", content_type="application/octet-stream")}, + ) + assert response.status_code == 200 + assert not draft_run.evidence.exists() + + def test_upload_refused_once_submitted(self, admin_client, draft_run): + draft_run.result = RunResult.PASS + draft_run.save() + draft_run.transition_to(RUN_IN_REVIEW, draft_run.performed_by) + response = admin_client.post(reverse("controls:evidence-upload", args=[draft_run.pk]), {"file": _upload()}) + assert response.status_code == 302 + assert not draft_run.evidence.exists() + + def test_download_serves_an_attachment(self, scope, draft_run): + item = ControlTestEvidence.objects.create(run=draft_run, file=_upload(name='a"b.csv')) + client = _client(_user(*READ, scopes=[scope])) + response = client.get(reverse("controls:evidence-download", args=[item.pk])) + assert response.status_code == 200 + assert response["Content-Type"] == "application/octet-stream" + assert response["Content-Disposition"].startswith("attachment;") + assert '"' not in response["Content-Disposition"].split('filename="', 1)[1].split('";', 1)[0] + assert response.content == b"login,mfa\nalice,yes\n" + + def test_download_needs_the_read_permission(self, draft_run): + item = ControlTestEvidence.objects.create(run=draft_run, file=_upload()) + response = _client(_user("controls.control.read")).get(reverse("controls:evidence-download", args=[item.pk])) + assert response.status_code == 302 + + def test_delete_only_while_in_progress(self, admin_client, test): + open_run = ControlTestRunFactory(test=test) + item = ControlTestEvidence.objects.create(run=open_run, file=_upload()) + response = admin_client.post(reverse("controls:evidence-delete", args=[item.pk])) + assert response.status_code == 302 + assert not ControlTestEvidence.objects.filter(pk=item.pk).exists() + + frozen = ControlTestRunFactory(test=test, result=RunResult.PASS) + kept = ControlTestEvidence.objects.create(run=frozen, file=_upload()) + frozen.transition_to(RUN_IN_REVIEW, frozen.performed_by) + response = admin_client.post(reverse("controls:evidence-delete", args=[kept.pk]), follow=True) + assert ControlTestEvidence.objects.filter(pk=kept.pk).exists() + assert any("in progress" in str(m) for m in response.context["messages"]) + + +# --- Raise a finding (RG-CTL-16) -------------------------------------------- + + +class TestRaiseFinding: + def test_raise_once_from_a_failed_run(self, admin, admin_client, test): + run = validated_run(test=test, result=RunResult.FAIL, observations="Two logins without MFA.") + assert run.needs_finding + url = reverse("controls:controltestrun-raise-finding", args=[run.pk]) + response = admin_client.post(url, {"finding_type": FindingType.MINOR_NON_CONFORMITY, "recommendation": "Enforce MFA."}) + finding = Finding.objects.get(control_test_run=run) + assert response.status_code == 302 + assert response.url == reverse("compliance:finding-detail", args=[finding.pk]) + assert finding.source == FindingSource.CONTROL_TEST + assert finding.assessor == admin + html = admin_client.get(reverse("controls:controltestrun-detail", args=[run.pk])).content.decode() + assert finding.reference in html + assert url not in html + again = admin_client.post(url, {"finding_type": FindingType.OBSERVATION}) + assert again.status_code == 302 + assert Finding.objects.filter(control_test_run=run).count() == 1 + + def test_not_offered_on_a_passed_run(self, admin_client, test): + run = validated_run(test=test, result=RunResult.PASS) + url = reverse("controls:controltestrun-raise-finding", args=[run.pk]) + assert url not in admin_client.get(reverse("controls:controltestrun-detail", args=[run.pk])).content.decode() + admin_client.post(url, {"finding_type": FindingType.MINOR_NON_CONFORMITY}) + assert not Finding.objects.filter(control_test_run=run).exists() + + def test_requires_the_approve_permission(self, scope, test): + run = validated_run(test=test, result=RunResult.PARTIAL, observations="Partial.") + client = _client(_user(*READ, "controls.test.update", scopes=[scope])) + client.post(reverse("controls:controltestrun-raise-finding", args=[run.pk]), {"finding_type": FindingType.OBSERVATION}) + assert not Finding.objects.filter(control_test_run=run).exists() + + def test_strength_is_not_a_raisable_type(self, admin_client, test): + run = validated_run(test=test, result=RunResult.FAIL, observations="Failed.") + response = admin_client.post( + reverse("controls:controltestrun-raise-finding", args=[run.pk]), {"finding_type": FindingType.STRENGTH}, + ) + assert response.status_code == 200 + assert not Finding.objects.filter(control_test_run=run).exists() + + +def test_test_detail_offers_a_run_only_when_active(admin_client, control): + draft = ControlTestFactory(control=control) + html = admin_client.get(reverse("controls:controltest-detail", args=[draft.pk])).content.decode() + assert reverse("controls:controltestrun-create", args=[draft.pk]) not in html + draft.transition_to(TEST_ACTIVE, control.owner) + html = admin_client.get(reverse("controls:controltest-detail", args=[draft.pk])).content.decode() + assert reverse("controls:controltestrun-create", args=[draft.pk]) in html + + +def test_a_finding_raised_from_markup_observations_renders_escaped(client): + """Regression : observations are plain text; the finding page sanitizes.""" + run = validated_run(result=RunResult.FAIL, observations='Gap') + user = UserFactory(is_superuser=True) + finding = run.raise_finding(user, FindingType.MINOR_NON_CONFORMITY) + client.force_login(user) + response = client.get(reverse("compliance:finding-detail", kwargs={"pk": finding.pk})) + assert response.status_code == 200 + assert b"onerror=\"alert(1)\"" not in response.content + assert b"Gap" in response.content 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..33219a1a --- /dev/null +++ b/controls/views.py @@ -0,0 +1,1017 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""Web views of module 8 (controls). + +**Registers** (controls, tests) run the house list stack, with +``ListSummaryMixin`` to the left of ``ScopeFilterMixin`` so the rail counts the +whole visible list. The URL contract has no table-body route : the list view +itself answers the HTMX refresh of ``#item-table-body`` (filters, search, +pagination) with the rows partial and an out-of-band pager. + +**Detail pages** run ``ScopeFilterMixin, HistoryUrlMixin, LifecycleStepperMixin, +DetailView``. Every state change goes through the generic +``workflow:transition`` endpoint fed by the stepper : the gates and side +effects (implementation stamp, test activation, segregation of duties, the +effectiveness and due-date recomputation) live in each model's +``transition_to()``, so there is no transition view here. + +**Acts that are not transitions** (create controls from requirements, raise a +finding, add or remove evidence) each call the one service or model method +that owns them. + +**Tenancy** : every object resolved from a URL goes through a scope-filtered +queryset. A test, a run and an evidence file inherit their control's scopes +(``scope_parent_lookup``), resolved by ``core.scoping``; the evidence file is +only ever served by the permission-checked download view (RG-CTL-13). +""" + +from datetime import timedelta +from itertools import groupby +from urllib.parse import quote + +from django.contrib import messages +from django.contrib.auth import get_user_model +from django.contrib.auth.mixins import LoginRequiredMixin +from django.core.exceptions import ValidationError +from django.db.models import ( + Case, + CharField, + Count, + Exists, + IntegerField, + OuterRef, + ProtectedError, + Subquery, + Value, + When, +) +from django.db.models.functions import Coalesce +from django.http import Http404, HttpResponse +from django.shortcuts import get_object_or_404, redirect +from django.template.loader import render_to_string +from django.urls import reverse +from django.utils import timezone +from django.utils.functional import cached_property +from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _l +from django.utils.translation import ngettext, pgettext_lazy +from django.views import View +from django.views.generic import CreateView, DeleteView, DetailView, FormView, ListView, UpdateView + +from accounts.mixins import HistoryUrlMixin, LifecycleStepperMixin, ScopeFilterMixin +from accounts.views import PermissionRequiredMixin +from context.models import Scope +from core.lifecycle import DomainRefusalError, LifecycleProtectedError, reportable +from core.mixins import ( + AdvancedFilterMixin, + ColumnPreferenceMixin, + ConfirmDeleteShellMixin, + HtmxFormMixin, + ListSummaryMixin, + PredefinedFilterMixin, + SavedFilterMixin, + SortableListMixin, + is_drawer_request, +) + +from .constants import ( + CONTROL_IMPLEMENTED, + CONTROL_STATES, + RUN_DRAFT, + RUN_VALIDATED, + TEST_ACTIVE, + TEST_DUE_REMINDER_DAYS, + TEST_STATES, + ControlEffectiveness, + ControlNature, + ControlTheme, + RunResult, +) +from .forms import ( + ControlForm, + ControlTestForm, + ControlTestRunForm, + EvidenceForm, + FromRequirementsForm, + RaiseFindingForm, + scoped, +) +from .models import Control, ControlTest, ControlTestEvidence, ControlTestRun +from .services import create_controls_from_requirements + +PAGE_SIZE = 50 +RECENT_RUNS = 10 + + +# ── Shared helpers ───────────────────────────────────────── + + +def _state_options(states): + """``(code, label)`` pairs read off a lifecycle declaration (no DB at import).""" + return [(code, label) for code, label, *_flags in states] + + +def _scoped_get(model, request, pk, qs=None): + """One row by primary key, honouring the caller's scopes, else 404.""" + return get_object_or_404(scoped(qs if qs is not None else model._default_manager.all(), request.user), pk=pk) + + +def _attachment_disposition(filename): + """A safe ``Content-Disposition: attachment`` value (no header injection).""" + safe = "".join(ch for ch in (filename or "") if ch not in '"\\\r\n').strip() or "evidence" + return f"attachment; filename=\"{safe}\"; filename*=UTF-8''{quote(safe)}" + + +def _detail_url(obj): + if isinstance(obj, Control): + return reverse("controls:control-detail", args=[obj.pk]) + if isinstance(obj, ControlTest): + return reverse("controls:controltest-detail", args=[obj.pk]) + if isinstance(obj, ControlTestRun): + return reverse("controls:controltestrun-detail", args=[obj.pk]) + return None + + +def _done(request, url): + """After an act : a drawer gets a 204 + HX-Redirect, a page a redirect.""" + if is_drawer_request(request): + return HttpResponse(status=204, headers={"HX-Redirect": url}) + return redirect(url) + + +def _users_with(related_name): + User = get_user_model() + return User.objects.filter(**{f"{related_name}__isnull": False}).distinct().order_by("last_name", "first_name") + + +class UserFormKwargsMixin: + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + return kwargs + + +class CreatedByMixin: + def form_valid(self, form): + form.instance.created_by = self.request.user + return super().form_valid(form) + + +class FrozenFormGuardMixin: + """A frozen form re-renders with its note; the model's own guard is the last resort.""" + + def form_valid(self, form): + if getattr(form, "frozen_note", None): + form.add_error(None, form.frozen_note) + return self.form_invalid(form) + try: + return super().form_valid(form) + except ValidationError as exc: + form.add_error(None, exc) + return self.form_invalid(form) + + +class ProtectedDeleteMixin: + """A lifecycle, ``PROTECT`` or write-once refusal becomes a message, not a 500.""" + + def form_valid(self, form): + self.object = self.get_object() + success_url = self.get_success_url() + try: + self.object.delete() + except LifecycleProtectedError as exc: + messages.error(self.request, str(exc)) + success_url = self.get_refused_url() + except ProtectedError: + messages.error(self.request, _("This element is still referenced and cannot be deleted.")) + success_url = self.get_refused_url() + except ValidationError as exc: + messages.error(self.request, " ".join(exc.messages)) + success_url = self.get_refused_url() + if self.request.headers.get("HX-Request") == "true": + return HttpResponse(status=204, headers={"HX-Redirect": success_url}) + return redirect(success_url) + + def get_refused_url(self): + return self.get_success_url() + + +class PageFormNavMixin: + """Breadcrumb, Cancel target and accent of the full-page form fallback.""" + + page_form_nav = "controls:control-list" + + def get_form_parent(self): + return None + + def get_page_form_cancel_url(self): + obj = getattr(self, "object", None) + if obj is not None and obj.pk and not obj._state.adding: + return _detail_url(obj) or reverse(self.page_form_nav) + parent = self.get_form_parent() + return (_detail_url(parent) if parent is not None else None) or reverse(self.page_form_nav) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + if not ctx.get("is_modal"): + ctx["cancel_url"] = self.get_page_form_cancel_url() + ctx["form_nav"] = self.page_form_nav + ctx["form_accent"] = "controls" + parent = self.get_form_parent() + if parent is not None: + ctx["form_parent_label"] = parent.reference + ctx["form_parent_url"] = _detail_url(parent) + return ctx + + +class TableBodyRefreshMixin: + """Answer the HTMX refresh of ``#item-table-body`` from the list view itself. + + The rows partial plus an out-of-band pager, as ``TableBodyPaginatedMixin`` + does for the modules that have a separate table-body route. + """ + + table_body_template_name = None + + def _is_table_refresh(self): + headers = self.request.headers + return ( + headers.get("HX-Request") == "true" + and headers.get("HX-Boosted") != "true" + and headers.get("HX-Target") == "item-table-body" + ) + + def render_to_response(self, context, **response_kwargs): + if self._is_table_refresh(): + rows = render_to_string(self.table_body_template_name, context, request=self.request) + pager = render_to_string("includes/pagination.html", {**context, "oob": True}, request=self.request) + return HttpResponse(rows + pager) + return super().render_to_response(context, **response_kwargs) + + +# ══ Control : the register ═════════════════════════════════ + +_OVERDUE = "overdue" +_ON_TIME = "on_time" + +CONTROL_TEXT_FILTERS = [ + {"param": "reference", "field": "reference", "label": _l("Reference")}, + {"param": "name", "field": "name", "label": _l("Name")}, +] +CONTROL_COLUMNS = [ + {"key": "reference", "label": _l("Ref."), "always": True}, + {"key": "name", "label": _l("Name"), "always": True}, + {"key": "owner", "label": _l("Owner")}, + {"key": "status", "label": _l("Status")}, + {"key": "effectiveness", "label": _l("Effectiveness")}, + {"key": "next_due", "label": _l("Next test due")}, + {"key": "actions", "label": _l("Actions"), "always": True}, +] +CONTROL_SORTABLE_FIELDS = { + "reference": "reference", + "name": "name", + "owner": "owner__last_name", + "status": "workflow_state", + "effectiveness": "effectiveness", + "next_due": "next_due", +} +CONTROL_SEARCH_FIELDS = ["reference", "name", "description"] + + +def _annotate_controls(qs): + """Next test due, overdue flag and requirement count, one subquery each. + + The overdue flag is the SQL twin of ``Control.is_test_overdue`` + (RG-CTL-15), so the facet and the red date on the row never disagree. + """ + today = timezone.localdate() + active = ControlTest.objects.filter(control=OuterRef("pk"), workflow_state=TEST_ACTIVE).exclude(next_due_date=None) + requirement_links = ( + Control.requirements.through.objects.filter(control_id=OuterRef("pk")) + .order_by().values("control_id").annotate(n=Count("pk")).values("n") + ) + return qs.annotate( + next_due=Subquery(active.order_by("next_due_date").values("next_due_date")[:1]), + requirement_count=Coalesce(Subquery(requirement_links, output_field=IntegerField()), Value(0)), + overdue_flag=Case( + When(Exists(active.filter(next_due_date__lt=today)), then=Value(_OVERDUE)), + default=Value(_ON_TIME), + output_field=CharField(), + ), + ) + + +class _ControlAnnotationMixin: + """Sits right of ``SortableListMixin`` so the annotations can be sorted on.""" + + def get_queryset(self): + return _annotate_controls(super().get_queryset()).select_related("owner").prefetch_related("scopes", "tags") + + +class _ControlListMixin: + model = Control + permission_required = "controls.control.read" + context_object_name = "controls" + paginate_by = PAGE_SIZE + sortable_fields = CONTROL_SORTABLE_FIELDS + default_sort = "reference" + search_fields = CONTROL_SEARCH_FIELDS + text_filters = CONTROL_TEXT_FILTERS + columns = CONTROL_COLUMNS + template_name = "controls/control_list.html" + table_body_template_name = "controls/control_table_body.html" + + @cached_property + def filter_groups(self): + owners = _users_with("owned_controls") + scopes = scoped(Scope.objects.all(), self.request.user).order_by("name") + return [ + {"param": "status", "field": "workflow_state", "label": _l("Status"), "options": _state_options(CONTROL_STATES)}, + {"param": "theme", "field": "theme", "label": _l("Theme"), "options": ControlTheme.choices}, + {"param": "nature", "field": "nature", "label": _l("Nature"), "options": ControlNature.choices}, + {"param": "effectiveness", "field": "effectiveness", "label": _l("Effectiveness"), "options": ControlEffectiveness.choices}, + {"param": "owner", "field": "owner", "label": _l("Owner"), "options": [(str(u.pk), u.display_name) for u in owners]}, + {"param": "scope", "field": "scopes", "label": _l("Scope"), "options": [(str(s.pk), s.name) for s in scopes]}, + { + "param": "tests", "field": "overdue_flag", "label": _l("Tests"), + "options": [(_OVERDUE, _l("Test overdue")), (_ON_TIME, _l("No test overdue"))], + }, + ] + + def get_queryset(self): + qs = self.filter_queryset_predefined(super().get_queryset()) + return self.filter_queryset_advanced(qs).distinct() + + +class ControlListView(_ControlListMixin, LoginRequiredMixin, PermissionRequiredMixin, TableBodyRefreshMixin, ListSummaryMixin, PredefinedFilterMixin, AdvancedFilterMixin, SavedFilterMixin, ColumnPreferenceMixin, ScopeFilterMixin, SortableListMixin, _ControlAnnotationMixin, ListView): + """The register of security controls (ISO/IEC 27001:2022 6.1.3, 8.1).""" + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + base = getattr(self, "_summary_base_qs", None) + if base is not None: + base = base.order_by() + living = reportable(base) + tested = living.exclude(effectiveness=ControlEffectiveness.NOT_TESTED) + tested_count = tested.count() + effective = tested.filter(effectiveness=ControlEffectiveness.EFFECTIVE).count() + rate = round(100 * effective / tested_count) if tested_count else None + overdue_tests = ControlTest.objects.filter( + control__in=base.values("pk"), workflow_state=TEST_ACTIVE, + next_due_date__lt=timezone.localdate(), + ).count() + validated = ControlTestRun.objects.filter(test__control=OuterRef("pk"), workflow_state=RUN_VALIDATED) + never_tested = living.exclude(Exists(validated)).count() + list_url = reverse("controls:control-list") + ctx["list_kpis"] = [ + { + "label": _("Implemented controls"), "value": base.filter(workflow_state=CONTROL_IMPLEMENTED).count(), + "icon": "shield-check", "tone": "success", + "url": f"{list_url}?status={CONTROL_IMPLEMENTED}", + }, + { + "label": _("Effective rate"), "value": rate, "suffix": "%" if rate is not None else None, + "icon": "patch-check", "tone": "accent", + }, + { + "label": _("Overdue tests"), "value": overdue_tests, "icon": "calendar-x", "tone": "danger", + "url": f"{reverse('controls:controltest-list')}?due={_DUE_OVERDUE}", + }, + {"label": _("Never tested"), "value": never_tested, "icon": "dash-circle", "tone": "warning"}, + ] + ctx["today"] = timezone.localdate() + return ctx + + +# ══ Control : one record ═══════════════════════════════════ + + +class ControlDetailView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, HistoryUrlMixin, LifecycleStepperMixin, DetailView): + model = Control + permission_required = "controls.control.read" + template_name = "controls/control_detail.html" + context_object_name = "control" + + def get_queryset(self): + return super().get_queryset().select_related("owner").prefetch_related("scopes", "tags") + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + control = self.object + user = self.request.user + validated = ControlTestRun.objects.filter(test=OuterRef("pk"), workflow_state=RUN_VALIDATED).order_by( + "-performed_on", "-reviewed_at", "-created_at", + ) + tests = list( + control.tests.select_related("tester") + .annotate(latest_result_value=Subquery(validated.values("result")[:1])) + .order_by("name") + ) + runs = list( + ControlTestRun.objects.filter(test__control=control) + .select_related("test", "performed_by", "reviewed_by") + .order_by("-performed_on", "-created_at")[:RECENT_RUNS] + ) + requirements = list( + scoped(control.requirements.all(), user).select_related("framework") + .order_by("framework__name", "requirement_number", "name") + ) + requirement_groups = [ + {"framework": framework, "requirements": list(items)} + for framework, items in groupby(requirements, key=lambda r: r.framework) + ] + ctx.update({ + "tests": tests, + "recent_runs": runs, + "run_count": ControlTestRun.objects.filter(test__control=control).count(), + "requirement_groups": requirement_groups, + "requirement_count": len(requirements), + "risks": list(scoped(control.risks.all(), user).order_by("reference")), + "treatment_plans": list(scoped(control.treatment_plans.all(), user).order_by("reference")), + "pacs_measures": list(scoped(control.pacs_measures.all(), user).order_by("reference")), + "support_assets": list(scoped(control.support_assets.all(), user).order_by("reference")), + "objectives": list(scoped(control.objectives.all(), user).order_by("reference")), + "policies": list(scoped(control.policies.all(), user).order_by("reference")), + "next_test_due": control.next_test_due, + "is_test_overdue": control.is_test_overdue, + "is_stale": control.is_effectiveness_stale, + "today": timezone.localdate(), + }) + return ctx + + +class ControlCreateView(LoginRequiredMixin, PermissionRequiredMixin, PageFormNavMixin, HtmxFormMixin, UserFormKwargsMixin, CreatedByMixin, CreateView): + model = Control + permission_required = "controls.control.create" + form_class = ControlForm + template_name = "controls/control_form.html" + modal_template_name = "controls/control_form_modal.html" + modal_title_create = _l("New control") + modal_title_update = _l("Edit control") + + def get_success_url(self): + return reverse("controls:control-detail", args=[self.object.pk]) + + +class ControlUpdateView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, PageFormNavMixin, HtmxFormMixin, UserFormKwargsMixin, FrozenFormGuardMixin, UpdateView): + """Edit a control's identity and links. A retired control renders frozen (RG-CTL-03).""" + + model = Control + permission_required = "controls.control.update" + form_class = ControlForm + template_name = "controls/control_form.html" + modal_template_name = "controls/control_form_modal.html" + modal_title_create = _l("New control") + modal_title_update = _l("Edit control") + + def get_success_url(self): + return reverse("controls:control-detail", args=[self.object.pk]) + + +class ControlDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, ScopeFilterMixin, ProtectedDeleteMixin, DeleteView): + """Only a proposed control without tests is deletable; otherwise it is retired.""" + + model = Control + permission_required = "controls.control.delete" + template_name = "controls/control_confirm_delete_modal.html" + + def get_success_url(self): + return reverse("controls:control-list") + + def get_refused_url(self): + return reverse("controls:control-detail", args=[self.object.pk]) + + +class ControlFromRequirementsView(LoginRequiredMixin, PermissionRequiredMixin, HtmxFormMixin, FormView): + """RG-CTL-05 : one proposed control per ticked, uncovered requirement of a framework. + + Choosing a framework in the drawer reloads the form (HTMX GET with + ``?framework=``) with its checklist; on the page fallback, saving with a + framework and no requirement re-renders the form with the checklist. + """ + + permission_required = "controls.control.create" + form_class = FromRequirementsForm + template_name = "controls/control_from_requirements_form.html" + modal_template_name = "controls/control_from_requirements_form_modal.html" + modal_title_create = _l("Create controls from requirements") + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["user"] = self.request.user + if is_drawer_request(self.request): + kwargs["reload_url"] = self.request.path + if self.request.method == "GET": + kwargs["framework"] = self._requested_framework() + return kwargs + + def _requested_framework(self): + from compliance.models import Framework + + raw = self.request.GET.get("framework") + if not raw: + return None + try: + return scoped(reportable(Framework.objects.all()), self.request.user).filter(pk=raw).first() + except (ValueError, ValidationError): + return None + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + if not ctx.get("is_modal"): + ctx["cancel_url"] = reverse("controls:control-list") + ctx["form_nav"] = "controls:control-list" + ctx["form_accent"] = "controls" + return ctx + + def form_valid(self, form): + framework = form.cleaned_data["framework"] + requirement_ids = [r.pk for r in form.cleaned_data["requirements"]] + created, skipped = create_controls_from_requirements(framework, self.request.user, requirement_ids=requirement_ids) + if created: + messages.success(self.request, ngettext( + "%(n)d proposed control was created from %(framework)s.", + "%(n)d proposed controls were created from %(framework)s.", + len(created), + ) % {"n": len(created), "framework": framework.short_name or framework.name}) + if skipped: + messages.info(self.request, ngettext( + "%(n)d requirement was skipped : it already has a control or is not applicable.", + "%(n)d requirements were skipped : they already have a control or are not applicable.", + len(skipped), + ) % {"n": len(skipped)}) + if not created and not skipped: + messages.info(self.request, _("No control was created.")) + return _done(self.request, reverse("controls:control-list")) + + +# ══ ControlTest : the register ═════════════════════════════ + +_DUE_OVERDUE = "overdue" +_DUE_SOON = "soon" +_DUE_LATER = "later" +_DUE_NONE = "none" + +TEST_TEXT_FILTERS = [ + {"param": "reference", "field": "reference", "label": _l("Reference")}, + {"param": "name", "field": "name", "label": _l("Name")}, +] +TEST_COLUMNS = [ + {"key": "reference", "label": _l("Ref."), "always": True}, + {"key": "name", "label": _l("Name"), "always": True}, + {"key": "control", "label": pgettext_lazy("control", "Control")}, + {"key": "tester", "label": _l("Tester")}, + {"key": "latest_result", "label": _l("Latest result")}, + {"key": "next_due", "label": _l("Next due")}, + {"key": "status", "label": _l("Status")}, + {"key": "actions", "label": _l("Actions"), "always": True}, +] +TEST_SORTABLE_FIELDS = { + "reference": "reference", + "name": "name", + "control": "control__reference", + "tester": "tester__last_name", + "latest_result": "latest_result_value", + "next_due": "next_due_date", + "status": "workflow_state", +} +TEST_SEARCH_FIELDS = ["reference", "name", "control__reference", "control__name"] + + +def _annotate_tests(qs): + """The latest validated result and the due window, the SQL twins of + ``ControlTest.latest_result`` and ``ControlTest.is_overdue`` (RG-CTL-07).""" + today = timezone.localdate() + soon = today + timedelta(days=TEST_DUE_REMINDER_DAYS) + validated = ControlTestRun.objects.filter(test=OuterRef("pk"), workflow_state=RUN_VALIDATED).order_by( + "-performed_on", "-reviewed_at", "-created_at", + ) + return qs.annotate( + latest_result_value=Subquery(validated.values("result")[:1]), + due_flag=Case( + When(workflow_state=TEST_ACTIVE, next_due_date__lt=today, then=Value(_DUE_OVERDUE)), + When(workflow_state=TEST_ACTIVE, next_due_date__lte=soon, then=Value(_DUE_SOON)), + When(workflow_state=TEST_ACTIVE, next_due_date__isnull=False, then=Value(_DUE_LATER)), + default=Value(_DUE_NONE), + output_field=CharField(), + ), + ) + + +class _TestAnnotationMixin: + def get_queryset(self): + return _annotate_tests(super().get_queryset()).select_related("control", "tester").prefetch_related("tags") + + +class _TestListMixin: + model = ControlTest + permission_required = "controls.test.read" + context_object_name = "tests" + paginate_by = PAGE_SIZE + sortable_fields = TEST_SORTABLE_FIELDS + default_sort = "next_due" + search_fields = TEST_SEARCH_FIELDS + text_filters = TEST_TEXT_FILTERS + columns = TEST_COLUMNS + template_name = "controls/controltest_list.html" + table_body_template_name = "controls/controltest_table_body.html" + + @cached_property + def filter_groups(self): + testers = _users_with("control_tests") + scopes = scoped(Scope.objects.all(), self.request.user).order_by("name") + return [ + {"param": "status", "field": "workflow_state", "label": _l("Status"), "options": _state_options(TEST_STATES)}, + { + "param": "due", "field": "due_flag", "label": _l("Due"), + "options": [ + (_DUE_OVERDUE, _l("Overdue")), + (_DUE_SOON, _l("Due within 14 days")), + (_DUE_LATER, _l("Due later")), + (_DUE_NONE, _l("Not scheduled")), + ], + }, + {"param": "tester", "field": "tester", "label": _l("Tester"), "options": [(str(u.pk), u.display_name) for u in testers]}, + {"param": "result", "field": "latest_result_value", "label": _l("Latest result"), "options": RunResult.choices}, + {"param": "scope", "field": "control__scopes", "label": _l("Scope"), "options": [(str(s.pk), s.name) for s in scopes]}, + ] + + def get_queryset(self): + qs = self.filter_queryset_predefined(super().get_queryset()) + return self.filter_queryset_advanced(qs).distinct() + + +class ControlTestListView(_TestListMixin, LoginRequiredMixin, PermissionRequiredMixin, TableBodyRefreshMixin, ListSummaryMixin, PredefinedFilterMixin, AdvancedFilterMixin, SavedFilterMixin, ColumnPreferenceMixin, ScopeFilterMixin, SortableListMixin, _TestAnnotationMixin, ListView): + """Every test, its tester, its frequency, its latest result and when it is next due.""" + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + base = getattr(self, "_summary_base_qs", None) + if base is not None: + base = base.order_by() + list_url = reverse("controls:controltest-list") + active = base.filter(workflow_state=TEST_ACTIVE) + failing = active.filter(latest_result_value__in=[RunResult.PARTIAL, RunResult.FAIL]).count() + ctx["list_kpis"] = [ + { + "label": _("Active tests"), "value": active.count(), "icon": "clipboard-check", "tone": "success", + "url": f"{list_url}?status={TEST_ACTIVE}", + }, + { + "label": _("Overdue tests"), "value": active.filter(due_flag=_DUE_OVERDUE).count(), + "icon": "calendar-x", "tone": "danger", "url": f"{list_url}?due={_DUE_OVERDUE}", + }, + { + "label": _("Due within 14 days"), "value": active.filter(due_flag=_DUE_SOON).count(), + "icon": "calendar-event", "tone": "warning", "url": f"{list_url}?due={_DUE_SOON}", + }, + {"label": _("Latest result not a pass"), "value": failing, "icon": "x-circle", "tone": "danger"}, + ] + return ctx + + +# ══ ControlTest : one record ═══════════════════════════════ + + +class ControlTestDetailView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, HistoryUrlMixin, LifecycleStepperMixin, DetailView): + model = ControlTest + permission_required = "controls.test.read" + template_name = "controls/controltest_detail.html" + context_object_name = "test" + + def get_queryset(self): + return super().get_queryset().select_related("control", "tester").prefetch_related("tags") + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + test = self.object + ctx.update({ + "control": test.control, + "runs": list(test.runs.select_related("performed_by", "reviewed_by").order_by("-performed_on", "-created_at")), + "latest_run": test.latest_validated_run, + "is_active": test.is_active, + "is_overdue": test.is_overdue, + }) + return ctx + + +class ControlTestCreateView(LoginRequiredMixin, PermissionRequiredMixin, PageFormNavMixin, HtmxFormMixin, UserFormKwargsMixin, CreatedByMixin, CreateView): + """A new test of a control (``controls.test.create``). A retired control takes no new test.""" + + model = ControlTest + permission_required = "controls.test.create" + form_class = ControlTestForm + template_name = "controls/controltest_form.html" + modal_template_name = "controls/controltest_form_modal.html" + modal_title_create = _l("New test") + modal_title_update = _l("Edit test") + + @cached_property + def control(self): + return _scoped_get(Control, self.request, self.kwargs["control_pk"]) + + def dispatch(self, request, *args, **kwargs): + if request.user.is_authenticated and request.user.has_perm(self.permission_required): + if self.control.is_terminal_state: + messages.error(request, _("A retired control takes no new test.")) + return _done(request, _detail_url(self.control)) + return super().dispatch(request, *args, **kwargs) + + def get_initial(self): + initial = super().get_initial() + initial["tester"] = self.control.owner_id + return initial + + def get_form_parent(self): + return self.control + + def form_valid(self, form): + form.instance.control = self.control + return super().form_valid(form) + + def get_success_url(self): + return reverse("controls:controltest-detail", args=[self.object.pk]) + + +class ControlTestUpdateView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, PageFormNavMixin, HtmxFormMixin, UserFormKwargsMixin, FrozenFormGuardMixin, UpdateView): + """Edit a test while it lives; the next due date follows (RG-CTL-07). Retired : frozen.""" + + model = ControlTest + permission_required = "controls.test.update" + form_class = ControlTestForm + template_name = "controls/controltest_form.html" + modal_template_name = "controls/controltest_form_modal.html" + page_form_nav = "controls:controltest-list" + modal_title_create = _l("New test") + modal_title_update = _l("Edit test") + + def get_form_parent(self): + return self.object.control + + def get_success_url(self): + return reverse("controls:controltest-detail", args=[self.object.pk]) + + +class ControlTestDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, ScopeFilterMixin, ProtectedDeleteMixin, DeleteView): + """Only a draft test without runs is deletable; otherwise it is retired (RG-CTL-08).""" + + model = ControlTest + permission_required = "controls.test.delete" + template_name = "controls/controltest_confirm_delete_modal.html" + + def get_success_url(self): + return reverse("controls:control-detail", args=[self.object.control_id]) + + def get_refused_url(self): + return reverse("controls:controltest-detail", args=[self.object.pk]) + + +# ══ ControlTestRun ═════════════════════════════════════════ + + +class ControlTestRunDetailView(LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, HistoryUrlMixin, LifecycleStepperMixin, DetailView): + model = ControlTestRun + permission_required = "controls.test.read" + template_name = "controls/controltestrun_detail.html" + context_object_name = "run" + + def get_queryset(self): + return super().get_queryset().select_related("test", "test__control", "performed_by", "reviewed_by") + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + run = self.object + ctx.update({ + "test": run.test, + "control": run.test.control, + "evidence": list(run.evidence.select_related("uploaded_by").order_by("uploaded_at")), + "findings": list(scoped(run.findings.all(), self.request.user).order_by("created_at")), + "is_draft": run.workflow_state == RUN_DRAFT, + "needs_finding": run.needs_finding, + }) + return ctx + + +class _RunDraftOnlyMixin: + """RG-CTL-09 : a run's content is editable only in ``draft``.""" + + def dispatch(self, request, *args, **kwargs): + if request.user.is_authenticated and request.user.has_perm(self.permission_required): + run = _scoped_get(ControlTestRun, request, kwargs["pk"]) + if run.workflow_state != RUN_DRAFT: + messages.error(request, _("A submitted test run is frozen : its content can no longer be changed.")) + return _done(request, _detail_url(run)) + return super().dispatch(request, *args, **kwargs) + + +class ControlTestRunCreateView(LoginRequiredMixin, PermissionRequiredMixin, PageFormNavMixin, HtmxFormMixin, UserFormKwargsMixin, CreatedByMixin, FrozenFormGuardMixin, CreateView): + """Record a run against an active test (RG-CTL-09), prefilled with the tester and today.""" + + model = ControlTestRun + permission_required = "controls.test.create" + form_class = ControlTestRunForm + template_name = "controls/controltestrun_form.html" + modal_template_name = "controls/controltestrun_form_modal.html" + page_form_nav = "controls:controltest-list" + modal_title_create = _l("Record a run") + modal_title_update = _l("Edit run") + + @cached_property + def test(self): + return _scoped_get(ControlTest, self.request, self.kwargs["test_pk"], qs=ControlTest.objects.select_related("control")) + + def dispatch(self, request, *args, **kwargs): + if request.user.is_authenticated and request.user.has_perm(self.permission_required): + if self.test.workflow_state != TEST_ACTIVE: + messages.error(request, _("Runs can only be recorded against an active test.")) + return _done(request, _detail_url(self.test)) + return super().dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["test"] = self.test + return kwargs + + def get_initial(self): + initial = super().get_initial() + initial["performed_by"] = self.test.tester_id or self.request.user.pk + initial["performed_on"] = timezone.localdate() + return initial + + def get_form_parent(self): + return self.test + + def get_success_url(self): + return reverse("controls:controltestrun-detail", args=[self.object.pk]) + + +class ControlTestRunUpdateView(_RunDraftOnlyMixin, LoginRequiredMixin, PermissionRequiredMixin, ScopeFilterMixin, PageFormNavMixin, HtmxFormMixin, UserFormKwargsMixin, FrozenFormGuardMixin, UpdateView): + model = ControlTestRun + permission_required = "controls.test.update" + form_class = ControlTestRunForm + template_name = "controls/controltestrun_form.html" + modal_template_name = "controls/controltestrun_form_modal.html" + page_form_nav = "controls:controltest-list" + modal_title_create = _l("Record a run") + modal_title_update = _l("Edit run") + + def get_form_parent(self): + return self.object.test + + def get_success_url(self): + return reverse("controls:controltestrun-detail", args=[self.object.pk]) + + +class ControlTestRunDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, ScopeFilterMixin, ProtectedDeleteMixin, DeleteView): + """Only a draft run is deletable, with its evidence; afterwards it is withdrawn.""" + + model = ControlTestRun + permission_required = "controls.test.delete" + template_name = "controls/controltestrun_confirm_delete_modal.html" + + def get_success_url(self): + return reverse("controls:controltest-detail", args=[self.object.test_id]) + + def get_refused_url(self): + return reverse("controls:controltestrun-detail", args=[self.object.pk]) + + +class RaiseFindingView(LoginRequiredMixin, PermissionRequiredMixin, HtmxFormMixin, FormView): + """RG-CTL-16 : raise a finding from a validated partial or failed run, once.""" + + permission_required = "controls.test.approve" + form_class = RaiseFindingForm + template_name = "controls/raise_finding_form.html" + modal_template_name = "controls/raise_finding_form_modal.html" + modal_title_create = _l("Raise a finding") + + @cached_property + def run(self): + qs = ControlTestRun.objects.select_related("test", "test__control") + return _scoped_get(ControlTestRun, self.request, self.kwargs["pk"], qs=qs) + + def dispatch(self, request, *args, **kwargs): + if request.user.is_authenticated and request.user.has_perm(self.permission_required): + if not self.run.needs_finding: + messages.error(request, _("A finding can only be raised once, from a validated partial or failed run.")) + return _done(request, _detail_url(self.run)) + return super().dispatch(request, *args, **kwargs) + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["run"] = self.run + if not ctx.get("is_modal"): + ctx["cancel_url"] = _detail_url(self.run) + ctx["form_nav"] = "controls:controltest-list" + ctx["form_accent"] = "controls" + ctx["form_parent_label"] = self.run.reference + ctx["form_parent_url"] = _detail_url(self.run) + return ctx + + def form_valid(self, form): + try: + finding = self.run.raise_finding( + self.request.user, form.cleaned_data["finding_type"], form.cleaned_data.get("recommendation", ""), + ) + except (DomainRefusalError, ValidationError) as exc: + form.add_error(None, exc.messages if isinstance(exc, ValidationError) else str(exc)) + return self.form_invalid(form) + messages.success(self.request, _("Finding %(ref)s was raised from run %(run)s.") % { + "ref": finding.reference, "run": self.run.reference, + }) + return _done(self.request, reverse("compliance:finding-detail", args=[finding.pk])) + + +# ══ Evidence (RG-CTL-13) ═══════════════════════════════════ + + +class EvidenceUploadView(LoginRequiredMixin, PermissionRequiredMixin, HtmxFormMixin, UserFormKwargsMixin, CreateView): + """Attach a file to a run while it is in progress. Size and SHA-256 are stamped by the model.""" + + model = ControlTestEvidence + permission_required = "controls.test.update" + form_class = EvidenceForm + template_name = "controls/evidence_form.html" + modal_template_name = "controls/evidence_form_modal.html" + modal_title_create = _l("Add evidence") + + @cached_property + def run(self): + return _scoped_get(ControlTestRun, self.request, self.kwargs["pk"], qs=ControlTestRun.objects.select_related("test")) + + def dispatch(self, request, *args, **kwargs): + if request.user.is_authenticated and request.user.has_perm(self.permission_required): + if self.run.workflow_state != RUN_DRAFT: + messages.error(request, _("Evidence can only be added or removed while the test run is in progress.")) + return _done(request, _detail_url(self.run)) + return super().dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs["run"] = self.run + return kwargs + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx["run"] = self.run + if not ctx.get("is_modal"): + ctx["cancel_url"] = _detail_url(self.run) + ctx["form_nav"] = "controls:controltest-list" + ctx["form_accent"] = "controls" + ctx["form_parent_label"] = self.run.reference + ctx["form_parent_url"] = _detail_url(self.run) + return ctx + + def form_valid(self, form): + try: + self.object = form.save() + except ValidationError as exc: + form.add_error(None, exc) + return self.form_invalid(form) + messages.success(self.request, _("The evidence %(name)s was added.") % {"name": self.object.original_filename}) + if is_drawer_request(self.request): + return HttpResponse(status=204, headers={"HX-Trigger": "formSaved"}) + return redirect(self.get_success_url()) + + def get_success_url(self): + return _detail_url(self.run) + + +class EvidenceDeleteView(LoginRequiredMixin, ConfirmDeleteShellMixin, PermissionRequiredMixin, ScopeFilterMixin, ProtectedDeleteMixin, DeleteView): + """Remove an evidence file while its run is in progress (RG-CTL-13).""" + + model = ControlTestEvidence + permission_required = "controls.test.update" + template_name = "controls/evidence_confirm_delete_modal.html" + + def get_queryset(self): + return super().get_queryset().select_related("run") + + def get_success_url(self): + return reverse("controls:controltestrun-detail", args=[self.object.run_id]) + + +class EvidenceDownloadView(LoginRequiredMixin, PermissionRequiredMixin, View): + """Stream an evidence file, permission- and scope-checked (RG-CTL-13). + + Never a media URL : the path is guessable from the run and row ids, and a + test's evidence may hold personal data or security configuration. + """ + + permission_required = "controls.test.read" + + def get(self, request, pk): + item = _scoped_get(ControlTestEvidence, request, pk) + if not item.file: + raise Http404() + try: + handle = item.file.open("rb") + except (FileNotFoundError, OSError): + # A missing file is a 404, never a traceback naming the storage path. + raise Http404() from None + with handle: + data = handle.read() + response = HttpResponse(data, content_type="application/octet-stream") + response["Content-Disposition"] = _attachment_disposition(item.original_filename or f"{item.pk}.bin") + response["X-Content-Type-Options"] = "nosniff" + return response diff --git a/core/dashboard.py b/core/dashboard.py index 5bfa0d30..133070f9 100644 --- a/core/dashboard.py +++ b/core/dashboard.py @@ -167,6 +167,7 @@ def _sanitize_sort_params(raw) -> dict: CATEGORY_ACTIVITY = _("Activity") CATEGORY_INCIDENTS = _("Incidents") CATEGORY_DOCUMENTS = _("Documents") +CATEGORY_CONTROLS = _("Controls") CATEGORY_LAYOUT = _("Layout") @@ -390,6 +391,20 @@ def height(self, size: str) -> int: " and the acknowledgement campaigns running with their completion rate." ), ), + DashboardWidget( + id="controls", + title=_("Controls"), + icon="shield-check", + template="dashboard/widgets/controls.html", + category=CATEGORY_CONTROLS, + sizes=("2x2", "2x3", "3x2", "4x2"), + default_size="2x2", + default_order=68, + description=_( + "Implemented controls and their effective rate, the control tests" + " overdue, and the failed runs still awaiting a finding." + ), + ), DashboardWidget( id="risk_treatment_flow", title=_("Risk treatment flow"), diff --git a/core/kanban.py b/core/kanban.py index 01aed146..ca4a1b67 100644 --- a/core/kanban.py +++ b/core/kanban.py @@ -12,6 +12,8 @@ - Risk assessments (``risks.RiskAssessment``) - Incidents (``incidents.Incident``) - The current user's pending acknowledgements (``documents.PolicyAcknowledgement``) +- The current user's control tests due and test runs, in progress or to + review (``controls.ControlTest``, ``controls.ControlTestRun``) The board is intentionally read-only (no drag-and-drop) and omits the terminal "removed from tracking" states (cancelled / archived): a card disappears from the @@ -58,6 +60,8 @@ "risk_assessment": "risks.assessment.read", "incident": "incidents.incident.read", "acknowledgement": None, + "control_test": "controls.test.read", + "control_test_run": "controls.test.read", } # Bootstrap Icons identifying each type (brand: neutral type marker, icon only, @@ -69,6 +73,8 @@ "risk_assessment": "bi-shield-exclamation", "incident": "bi-fire", "acknowledgement": "bi-pen", + "control_test": "bi-clipboard-pulse", + "control_test_run": "bi-clipboard-check", } TYPE_LABELS = { @@ -78,6 +84,8 @@ "risk_assessment": pgettext_lazy("kanban", "Risk assessment"), "incident": pgettext_lazy("kanban", "Incident"), "acknowledgement": pgettext_lazy("kanban", "Acknowledgement"), + "control_test": pgettext_lazy("kanban", "Control test"), + "control_test_run": pgettext_lazy("kanban", "Test run"), } # Lifecycle ``Step.tone`` -> Bootstrap badge context, for the entities whose @@ -444,8 +452,89 @@ def _build_acknowledgements(scope_ids, user=None): return cards +def _build_control_tests(scope_ids, user=None): + """The current user's control tests due within ``TEST_DUE_REMINDER_DAYS`` + (overdue ones included), in To do. + + "Mine" is whoever the due reminder goes to (README §7) : the test's + tester, else the control owner when the test names no tester. Only an + active test is ever due (RG-CTL-07, read off ``reportable()``). + """ + if user is None or not user.is_authenticated: + return [] + from datetime import timedelta + + from django.db.models import Q + + from controls.constants import TEST_DUE_REMINDER_DAYS + from controls.models import ControlTest + from core.lifecycle import reportable + + horizon = timezone.localdate() + timedelta(days=TEST_DUE_REMINDER_DAYS) + tests = _scope_filter( + reportable(ControlTest.objects.filter( + Q(tester=user) | Q(tester__isnull=True, control__owner=user), + next_due_date__isnull=False, next_due_date__lte=horizon, + )).select_related("control", "tester"), + scope_ids, + ) + label = pgettext_lazy("kanban", "Test due") + return [ + _make_card( + column=TODO, + type_key="control_test", + reference=test.reference, + title=test.name, + url=reverse("controls:controltest-detail", kwargs={"pk": test.pk}), + owner=test.tester.display_name if test.tester_id else "", + due_date=test.next_due_date, + status_label=label, + status_tone="warning", + ) + for test in tests + ] + + +def _build_control_test_runs(scope_ids, user=None): + """Test runs waiting on the current user. + + - Their own runs still in progress (``draft``), in Doing, due when their + test is due. + - Runs in review they may validate, in To do : only for a holder of + ``controls.test.approve``, and **never their own** (RG-CTL-11, which + binds superusers too), since the model would refuse the validation. + """ + if user is None or not user.is_authenticated: + return [] + from controls.constants import RUN_DRAFT, RUN_IN_REVIEW + from controls.models import ControlTestRun + from core.lifecycle import resolve_lifecycle + + lifecycle = resolve_lifecycle(ControlTestRun) + base = ControlTestRun.objects.select_related("test__control", "performed_by") + rows = [(DOING, run) for run in _scope_filter(base.filter(workflow_state=RUN_DRAFT, performed_by=user), scope_ids)] + if user.is_superuser or user.has_perm("controls.test.approve"): + to_review = base.filter(workflow_state=RUN_IN_REVIEW).exclude(performed_by=user) + rows += [(TODO, run) for run in _scope_filter(to_review, scope_ids)] + cards = [] + for column, run in rows: + step = lifecycle.step(run.workflow_state) + cards.append(_make_card( + column=column, + type_key="control_test_run", + reference=run.reference, + title=run.test.name, + url=reverse("controls:controltestrun-detail", kwargs={"pk": run.pk}), + owner=run.performed_by.display_name if run.performed_by_id else "", + due_date=run.test.next_due_date if column == DOING else None, + status_label=step.label, + status_tone=_LIFECYCLE_TONE_CLASSES.get(step.tone, "secondary"), + )) + return cards + + # Builders whose cards depend on the user themselves, not only on their scopes. -_PERSONAL_BUILDERS = {"acknowledgement"} +_PERSONAL_BUILDERS = {"acknowledgement", "control_test", "control_test_run"} _BUILDERS = { "action_plan": _build_action_plans, @@ -454,6 +543,8 @@ def _build_acknowledgements(scope_ids, user=None): "risk_assessment": _build_risk_assessments, "incident": _build_incidents, "acknowledgement": _build_acknowledgements, + "control_test": _build_control_tests, + "control_test_run": _build_control_test_runs, } diff --git a/core/lifecycle_admin_views.py b/core/lifecycle_admin_views.py index c6517852..249027b8 100644 --- a/core/lifecycle_admin_views.py +++ b/core/lifecycle_admin_views.py @@ -114,7 +114,9 @@ def get_context_data(self, **kwargs): source = json.loads(raw) except json.JSONDecodeError: source = self.object.definition - ctx["definition_json"] = json.dumps(source) + from core.safe_json import dumps_for_script + + ctx["definition_json"] = dumps_for_script(source) ctx["step_kinds"] = ["draft", "intermediate", "archived"] ctx["tones"] = [ "neutral", "secondary", "info", "primary", "success", "warning", "danger", "dark", "muted", diff --git a/core/management/commands/generate_docs.py b/core/management/commands/generate_docs.py index 80e988c4..2a172936 100644 --- a/core/management/commands/generate_docs.py +++ b/core/management/commands/generate_docs.py @@ -31,7 +31,7 @@ # Apps whose contents are part of Cairn (as opposed to Django's own or a # third-party dependency). Used to keep the generated inventories on-topic. PROJECT_APPS = ( - "accounts", "assets", "assistant", "compliance", "context", "core", + "accounts", "assets", "assistant", "compliance", "context", "controls", "core", "documents", "helpers", "incidents", "mcp", "reports", "risks", "trust_center", ) @@ -261,6 +261,7 @@ def _tool_permission(handler) -> str: "assets": "Assets", "compliance": "Compliance", "context": "Governance and context", + "controls": "Controls", "documents": "Documents", "general": "General", "incidents": "Incidents", diff --git a/core/navigation.py b/core/navigation.py index a5fee97b..0b58469c 100644 --- a/core/navigation.py +++ b/core/navigation.py @@ -67,6 +67,9 @@ "compliance:finding-detail": ("Compliance", None, "Nonconformities"), "compliance:mapping-list": ("Compliance", None, "Mappings"), "compliance:action-plan-list": ("Compliance", None, "Action plans"), + # ── Controls (compliance family) ───────────────────────────── + "controls:control-list": ("Controls", None, "Controls"), + "controls:controltest-list": ("Controls", None, "Tests"), # ── Documents ──────────────────────────────────────────────── "documents:policy-list": ("Documents", None, "Policies"), "documents:campaign-list": ("Documents", None, "Acknowledgement campaigns"), diff --git a/core/safe_json.py b/core/safe_json.py new file mode 100644 index 00000000..e1525f00 --- /dev/null +++ b/core/safe_json.py @@ -0,0 +1,17 @@ +# SPDX-License-Identifier: AGPL-3.0-or-later +# SPDX-FileCopyrightText: 2026 François Rousselet +"""JSON that is safe to place inside a ``')) + 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/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/core/views.py b/core/views.py index e71ffd36..9fd1ce65 100644 --- a/core/views.py +++ b/core/views.py @@ -58,6 +58,7 @@ ) from context.models import Activity, Indicator, Issue, Objective, Role, Scope, Site, Stakeholder, SwotAnalysis from context.views import build_indicator_slot, get_dashboard_indicator_slots +from controls.models import Control, ControlTest from documents.models import Policy, PolicyVersion from incidents.models import Incident, SecurityEvent from risks.models import ( @@ -413,6 +414,9 @@ def _audit_progress(audit): # ── Documents (module 7) ───────────────────────── ctx.update(documents_dashboard_context(user, today)) + # ── Controls (module 8) ────────────────────────── + ctx.update(controls_dashboard_context(user, today)) + # ── Today's actions ─────────────────────────── # Presented as a prioritized to-do list rather than alarms: each # entry carries an action verb, a count and a link to the page @@ -604,6 +608,13 @@ def _action(count, label, url_name, icon): or ctx["documents_without_version_count"] or ctx["documents_running_campaigns"] ), + # Shown once the register holds a planned or implemented control, + # or while a test is overdue or a failed run awaits its finding. + "controls": bool( + ctx["controls_reportable_count"] + or ctx["controls_overdue_test_count"] + or ctx["controls_runs_awaiting_finding_count"] + ), "risk_treatment_flow": bool(ctx.get("risk_treatment_flow")), "risk_matrix_current": bool(ctx.get("matrix_current")), "risk_matrix_residual": bool(ctx.get("matrix_residual")), @@ -1040,7 +1051,7 @@ def _filter_scoped(qs, user): "treatment_plan", "scope", "objective", "framework", "swot", "acceptance", "supplier_review", "notification_deadline", "post_incident_review", "evidence_retention", - "documents", + "documents", "controls", ] # Category label, shared by the upcoming-deadlines list and the iCal feed so a @@ -1060,6 +1071,7 @@ def _filter_scoped(qs, user): "post_incident_review": gettext_lazy("Post-incident reviews"), "evidence_retention": gettext_lazy("Evidence retention"), "documents": gettext_lazy("Documents"), + "controls": gettext_lazy("Controls"), } @@ -1364,6 +1376,15 @@ def acceptance_done(o): done=lambda o: o.workflow_state != CAMPAIGN_RUNNING, kind=_("Due date"), responsible="created_by") + # ── Controls (module 8) ────────────────────────────── + # The next due date of every active test (`reportable()` keeps the active + # ones : a draft or retired test is never due, RG-CTL-08), within the + # reader's scopes through the control, for the readers of tests only. + if "controls" in categories and (user.is_superuser or user.has_perm("controls.test.read")): + add(_filter_scoped(ControlTest.objects.select_related("control"), user), + "next_due_date", "controls", "#65a30d", "controls:controltest-detail", + _("Control test: "), deadline=True, kind=_("Due date"), responsible="tester") + return events @@ -1422,6 +1443,64 @@ def documents_dashboard_context(user, today): return ctx +def controls_dashboard_context(user, today): + """Context of the *Controls* dashboard widget (module 8, README §8). + + The control figures need ``controls.control.read``, the test and run + figures ``controls.test.read``; every queryset goes through + ``core.scoping``. Steps are read off the lifecycles (``reportable()`` and + the step constants), overdue is derived from the date (RG-CTL-07) and the + effectiveness from the stored derived value (RG-CTL-14). + """ + from controls.constants import CONTROL_IMPLEMENTED, ControlEffectiveness, RunResult + from controls.models import ControlTestRun + from core.lifecycle import reportable + + ctx = { + "controls_reportable_count": 0, + "controls_implemented_count": 0, + "controls_tested_count": 0, + "controls_effective_count": 0, + "controls_effective_rate": None, + "controls_overdue_tests": [], + "controls_overdue_test_count": 0, + "controls_runs_awaiting_finding": [], + "controls_runs_awaiting_finding_count": 0, + } + if user.is_superuser or user.has_perm("controls.control.read"): + controls = _filter_scoped(reportable(Control.objects.all()), user) + ctx["controls_reportable_count"] = controls.count() + ctx["controls_implemented_count"] = controls.filter(workflow_state=CONTROL_IMPLEMENTED).count() + tested = controls.exclude(effectiveness=ControlEffectiveness.NOT_TESTED) + ctx["controls_tested_count"] = tested.count() + ctx["controls_effective_count"] = tested.filter(effectiveness=ControlEffectiveness.EFFECTIVE).count() + if ctx["controls_tested_count"]: + ctx["controls_effective_rate"] = round( + ctx["controls_effective_count"] / ctx["controls_tested_count"] * 100 + ) + if user.is_superuser or user.has_perm("controls.test.read"): + overdue = _filter_scoped( + reportable(ControlTest.objects.filter(next_due_date__isnull=False, next_due_date__lt=today)) + .select_related("control", "tester"), + user, + ).order_by("next_due_date", "reference") + ctx["controls_overdue_test_count"] = overdue.count() + ctx["controls_overdue_tests"] = [ + {"test": t, "days_late": (today - t.next_due_date).days} for t in overdue[:5] + ] + # RG-CTL-16 : a validated partial / failed run nobody has raised the + # finding of yet. + awaiting = _filter_scoped( + reportable(ControlTestRun.objects.filter( + result__in=[RunResult.PARTIAL, RunResult.FAIL], findings__isnull=True, + )).select_related("test__control"), + user, + ).order_by("-performed_on", "reference") + ctx["controls_runs_awaiting_finding_count"] = awaiting.count() + ctx["controls_runs_awaiting_finding"] = list(awaiting[:5]) + return ctx + + def open_incidents(): """Incidents still being handled, whatever step they are on. @@ -1913,6 +1992,8 @@ def _search_model(self, model, fields, q, url_name, icon): ("documents:policy-list", gettext_lazy("Policies"), "bi-file-earmark-text", None), ("documents:campaign-list", gettext_lazy("Acknowledgement campaigns"), "bi-patch-check", None), ("documents:acknowledge-list", gettext_lazy("To acknowledge"), "bi-pen", None), + ("controls:control-list", gettext_lazy("Controls"), "bi-shield-check", None), + ("controls:controltest-list", gettext_lazy("Control tests"), "bi-clipboard-pulse", None), ("calendar", gettext_lazy("Calendar"), "bi-calendar3", None), ("kanban", gettext_lazy("Tasks"), "bi-kanban", None), ] @@ -1928,6 +2009,7 @@ def _search_model(self, model, fields, q, url_name, icon): ("incidents:incident-create", gettext_lazy("Declare an incident"), "bi-plus-circle", "incidents.incident.create"), ("incidents:event-create", gettext_lazy("Report a security event"), "bi-plus-circle", "incidents.event.create"), ("documents:policy-create", gettext_lazy("Create a document"), "bi-plus-circle", "documents.policy.create"), + ("controls:control-create", gettext_lazy("Create a control"), "bi-plus-circle", "controls.control.create"), ("styleguide", gettext_lazy("Open styleguide"), "bi-palette", None), ] @@ -2156,6 +2238,25 @@ def get(self, request): "icon": "bi-files", "perm": "documents.policy.read", }, + # Module 8. A control by its reference, name or description; a + # test by its reference, name or the control it belongs to. Gated + # on each read permission : the palette returns names. + { + "label": _("Controls"), + "model": Control, + "fields": ["reference", "name", "description"], + "url": "controls:control-detail", + "icon": "bi-shield-check", + "perm": "controls.control.read", + }, + { + "label": _("Control tests"), + "model": ControlTest, + "fields": ["reference", "name", "control__reference", "control__name"], + "url": "controls:controltest-detail", + "icon": "bi-clipboard-pulse", + "perm": "controls.test.read", + }, ] user = request.user diff --git a/docs/reference/generated/dashboard-widgets.md b/docs/reference/generated/dashboard-widgets.md index 432366c3..7517c898 100644 --- a/docs/reference/generated/dashboard-widgets.md +++ b/docs/reference/generated/dashboard-widgets.md @@ -10,7 +10,7 @@ Sizes are `WxH` tokens : width in quarter-columns (1..4) and height in row units To add one, follow [sdk/dashboard-widget.md](../../sdk/dashboard-widget.md). -**14 widgets** ship with the platform. +**15 widgets** ship with the platform. ## Catalogue @@ -26,6 +26,7 @@ To add one, follow [sdk/dashboard-widget.md](../../sdk/dashboard-widget.md). | `priority_risks` | Priority risks | Risks | `1x2` `1x3` `2x2` | `1x2` | `rail_top` | - | - | Top untreated risks by residual level. Designed for the right rail. | | `notification_deadlines` | Notification deadlines | Incidents | `2x2` `2x3` `3x2` `4x2` | `2x2` | `main` | - | - | Statutory notification obligations that are late or still running, with the incidents left open behind them. | | `documents` | Documents | Documents | `2x2` `2x3` `3x2` `4x2` | `2x2` | `main` | - | - | Document reviews overdue, documents without a version in force, and the acknowledgement campaigns running with their completion rate. | +| `controls` | Controls | Controls | `2x2` `2x3` `3x2` `4x2` | `2x2` | `main` | - | - | Implemented controls and their effective rate, the control tests overdue, and the failed runs still awaiting a finding. | | `risk_treatment_flow` | Risk treatment flow | Risks | `2x2` `3x2` `4x2` `4x3` | `4x2` | `main` | - | - | How treatment moves risks from their current to their residual level. | | `risk_matrix_current` | Current risks | Risks | `2x2` `2x3` | `2x2` | `main` | - | - | Probability x impact heatmap, before treatment. | | `risk_matrix_residual` | Residual risks | Risks | `2x2` `2x3` | `2x2` | `main` | - | - | Probability x impact heatmap, after treatment. | @@ -45,6 +46,7 @@ To add one, follow [sdk/dashboard-widget.md](../../sdk/dashboard-widget.md). | `priority_risks` | `dashboard/widgets/priority_risks.html` | `bi-fire` | 60 | yes | - | | `notification_deadlines` | `dashboard/widgets/notification_deadlines.html` | `bi-hourglass-split` | 65 | yes | - | | `documents` | `dashboard/widgets/documents.html` | `bi-file-earmark-text` | 67 | yes | - | +| `controls` | `dashboard/widgets/controls.html` | `bi-shield-check` | 68 | yes | - | | `risk_treatment_flow` | `dashboard/widgets/risk_treatment_flow.html` | `bi-diagram-3` | 70 | yes | - | | `risk_matrix_current` | `dashboard/widgets/risk_matrix_current.html` | `bi-grid-3x3` | 80 | yes | - | | `risk_matrix_residual` | `dashboard/widgets/risk_matrix_residual.html` | `bi-grid-3x3` | 81 | yes | - | diff --git a/docs/reference/generated/lifecycles.md b/docs/reference/generated/lifecycles.md index 490e77af..9c46ed2a 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 | `controls.Control` | +| [`control_test`](#control-test) | 3 | 3 | graph | `controls.ControlTest` | +| [`control_test_run`](#control-test-run) | 4 | 5 | graph | `controls.ControlTestRun` | | [`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,74 @@ Run by `assets.Contract`. | `active` | `expired` | Expire | - | - | - | | any | `archived` | Archive | - | - | - | +## control + +Run by `controls.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 + +Run by `controls.ControlTest`. + +### 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 + +Run by `controls.ControlTestRun`. + +### 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`. @@ -260,11 +331,11 @@ Run by `risks.PACSMeasure`. | Code | Label | Kind | In reports | Linkable | Deletable | | --- | --- | --- | --- | --- | --- | | `draft` | Draft | draft | - | - | yes | -| `planned` | Planned | intermediate | yes | - | yes | -| `in_progress` | In progress | intermediate | yes | - | - | +| `planned` | Planned | intermediate | yes | yes | yes | +| `in_progress` | In progress | intermediate | yes | yes | - | | `completed` | Completed | archived | yes | - | - | | `cancelled` | Cancelled | archived | - | - | - | -| `overdue` | Overdue | intermediate | yes | - | - | +| `overdue` | Overdue | intermediate | yes | yes | - | | `archived` | Archived | archived | - | - | - | ### Transitions diff --git a/docs/reference/generated/management-commands.md b/docs/reference/generated/management-commands.md index 51f5bac6..f3ce7db2 100644 --- a/docs/reference/generated/management-commands.md +++ b/docs/reference/generated/management-commands.md @@ -12,6 +12,7 @@ The ones meant to run on a schedule are covered in [../../technical/operations.m | Command | App | Purpose | | --- | --- | --- | +| `controls_sweep` | controls | Send the control test reminders (due within 14 days, overdue once then every 7 days) and recompute the effectiveness of every reportable control. Run daily. | | `detect_spof` | assets | Detect Single Points of Failure (SPOF) in the dependency graph | | `documents_sweep` | documents | Send the document review reminders (due within 30 days, overdue) and the acknowledgement reminders (7 days before a campaign's due date and the day after it). Run daily. | | `expire_risk_acceptances` | risks | Set RiskAcceptance.status to EXPIRED for any active acceptance whose valid_until date has passed; print upcoming expirations within --reminder-days (default 30). | diff --git a/docs/reference/generated/mcp-tools-compliance.md b/docs/reference/generated/mcp-tools-compliance.md index a86dee4d..71626b8b 100644 --- a/docs/reference/generated/mcp-tools-compliance.md +++ b/docs/reference/generated/mcp-tools-compliance.md @@ -600,7 +600,8 @@ Requires `compliance.finding.read`. | `offset` | `integer` | - | Offset for pagination | | `assessment_id` | `string` | - | Filter by assessment_id | | `finding_type` | `string` | - | Filter by finding_type | -| `source` | `string` | - | Filter by source | +| `source` | `string` | - | Filter by what surfaced the nonconformity; 'control_test' lists the findings raised from control test runs (raise_finding_from_run). | +| `control_test_run_id` | `string` | - | Filter by the control test run the finding was raised from | | `effectiveness_verdict` | `string` | - | Filter by effectiveness_verdict | ## `list_frameworks` diff --git a/docs/reference/generated/mcp-tools-controls.md b/docs/reference/generated/mcp-tools-controls.md new file mode 100644 index 00000000..a7d292ca --- /dev/null +++ b/docs/reference/generated/mcp-tools-controls.md @@ -0,0 +1,453 @@ + + +# MCP tool parameters : Controls + +Input schemas for the 33 `controls` tools. The index of every module is in [mcp-tools.md](mcp-tools.md); a live server answers the same thing authoritatively through the `tools/list` JSON-RPC method. + +## `batch_create_control_test_runs` + +Record several runs (in progress) in one call (max 500). Every test_id must be an active test within the caller's scopes, or nothing is created. + +Requires `controls.test.create`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `items` | `array` | yes | Run objects: test_id (required), performed_by_id, performed_on, period_start, period_end, sample_description, result, observations. | +| `match_on` | `array` | - | Optional business key (writable field names) used to find an existing run to update; a submitted run refuses any change. | + +## `batch_create_control_tests` + +Create or upsert several draft control tests in one call (max 500). Every control_id must be a control within the caller's scopes and not retired, or nothing is created. + +Requires `controls.test.create`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `items` | `array` | yes | Test objects: control_id, name (required), objective, method, procedure, acceptance_criteria, frequency_months, tester_id, first_due_date. | +| `match_on` | `array` | - | Optional business key (writable field names, e.g. ["control_id", "name"]) used to find an existing test to update. | + +## `batch_create_controls` + +Create or upsert several proposed controls in one call (max 500). Every link must be within the caller's scopes and linkable, or nothing is created. Pass 'match_on' (e.g. ["name"]) to update matching controls instead of duplicating them. + +Requires `controls.control.create`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `items` | `array` | yes | Control objects: name, description (required), theme, nature, automation, owner_id, implemented_on, scope_ids and the *_ids link arrays. | +| `match_on` | `array` | - | Optional business key (writable scalar field names) used to find an existing control to update. | + +## `control_allowed_transitions` + +List the lifecycle transitions the caller may perform on a control from its current state. + +Requires `controls.control.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the object | + +## `control_test_allowed_transitions` + +List the lifecycle transitions the caller may perform on a control test from its current state. + +Requires `controls.test.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the object | + +## `control_test_run_allowed_transitions` + +List the lifecycle transitions the caller may perform on a control test run from its current state. + +Requires `controls.test.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the object | + +## `create_control` + +Create a proposed (draft) control: a durable practice the organisation operates, with its theme, nature, owner, scopes and links to requirements, risks, treatment plans, PACS measures, support assets, objectives and documents. Effectiveness is derived from validated test runs and cannot be set. Plan it or declare it implemented with transition_control. To bootstrap a register from a framework, prefer create_controls_from_requirements. + +Requires `controls.control.create`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `name` | `string` | yes | Control name, e.g. 'Multi-factor authentication on remote access'. | +| `description` | `string` | yes | How the control operates: what is done, by whom, on what (required). | +| `theme` | `string` | - | ISO/IEC 27002:2022 theme (default 'technological'). | +| `nature` | `string` | - | Control type attribute (default 'preventive'). | +| `automation` | `string` | - | How the control is operated (default 'manual'). | +| `owner_id` | `string` | - | UUID of the accountable owner (receives the failed-test notifications). Use list_users. | +| `implemented_on` | `string` | - | Date the control started operating (ISO 8601 date), never in the future. Stamped with today when declared implemented without one (RG-CTL-02). | +| `scope_ids` | `array` | - | Scopes of the control (within your own). Its tests and runs inherit them. | +| `requirement_ids` | `array` | - | Requirements the control satisfies, from any framework. Use list_requirements. Only targets within your scopes and in a linkable lifecycle step are accepted (RG-CTL-04); the full list replaces the current links. | +| `risk_ids` | `array` | - | Risks the control treats. Use list_risks. Only targets within your scopes and in a linkable lifecycle step are accepted (RG-CTL-04); the full list replaces the current links. | +| `treatment_plan_ids` | `array` | - | Risk treatment plans that create or reinforce the control. Use list_risk_treatment_plans. Only targets within your scopes and in a linkable lifecycle step are accepted (RG-CTL-04); the full list replaces the current links. | +| `pacs_measure_ids` | `array` | - | EBIOS RM PACS measures that create or reinforce the control. Only targets within your scopes and in a linkable lifecycle step are accepted (RG-CTL-04); the full list replaces the current links. | +| `support_asset_ids` | `array` | - | Support assets the control applies to. Use list_support_assets. Only targets within your scopes and in a linkable lifecycle step are accepted (RG-CTL-04); the full list replaces the current links. | +| `objective_ids` | `array` | - | Objectives the control contributes to. Use list_objectives. Only targets within your scopes and in a linkable lifecycle step are accepted (RG-CTL-04); the full list replaces the current links. | +| `policy_ids` | `array` | - | Documents (policies, procedures, standards) that describe the control. Use list_policys. Only targets within your scopes and in a linkable lifecycle step are accepted (RG-CTL-04); the full list replaces the current links. | + +## `create_control_test` + +Create a draft test of a control: method, procedure, acceptance criteria, frequency and default tester. Activate it with transition_control_test(target_state='active') once the control is planned or implemented and the procedure and acceptance criteria are written (RG-CTL-06); the next due date is then derived (RG-CTL-07). + +Requires `controls.test.create`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `control_id` | `string` | yes | UUID of the control this test belongs to (not a retired one). Cannot change once the test has runs. Use list_controls. | +| `name` | `string` | yes | Test name, e.g. 'Quarterly sample of remote logins'. | +| `objective` | `string` | - | What the test establishes (design, operating effectiveness). | +| `method` | `string` | - | Test method (default 'inspection'). | +| `procedure` | `string` | - | The steps the tester follows. Required to activate the test (RG-CTL-06). | +| `acceptance_criteria` | `string` | - | What a pass looks like. Required to activate the test (RG-CTL-06). | +| `frequency_months` | `integer` | - | Interval between two runs in months, 1 to 36 (default 12). Drives the next due date. | +| `tester_id` | `string` | - | UUID of the default performer of the runs, who receives the due reminders. Use list_users. | +| `first_due_date` | `string` | - | Due date of the first run (ISO 8601 date). Defaults to the activation date. | + +## `create_control_test_run` + +Record a run (in progress) of an active control test. Fill the result and observations, attach evidence through the web form, then submit it with transition_control_test_run(target_state='in_review'): from then on its content is frozen (RG-CTL-09) and someone other than its performer validates it (RG-CTL-11). + +Requires `controls.test.create`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `test_id` | `string` | yes | UUID of the active test this run executes (RG-CTL-09). Cannot change afterwards. Use list_control_tests. | +| `performed_by_id` | `string` | - | UUID of the person who performed the test. Defaults to the test's tester, else the caller. That person can never validate the run (RG-CTL-11). | +| `performed_on` | `string` | - | Date of the test (ISO 8601 date), never in the future. Defaults to today. | +| `period_start` | `string` | - | Start of the period the test covers (ISO 8601 date). | +| `period_end` | `string` | - | End of the period the test covers (ISO 8601 date), between period_start and performed_on (RG-CTL-10). | +| `sample_description` | `string` | - | Population and sample tested, e.g. '25 of 1,240 remote logins, March'. | +| `result` | `string` | - | Outcome against the acceptance criteria. Required to submit (RG-CTL-10). | +| `observations` | `string` | - | What was found. Required to submit a partial or failed result (RG-CTL-10); becomes the description of a raised finding. | + +## `create_controls_from_requirements` + +Bootstrap a control register from a framework (RG-CTL-05): for each selected applicable requirement that no control covers yet, create one proposed (draft) control named after the requirement, with its description as a starting point, the requirement linked and the framework's scopes. Requirements already covered (by a control in any step) or not applicable are skipped and reported; existing controls are never linked. Needs controls.control.create and compliance.framework.read on a framework within your scopes. + +Requires `controls.control.create`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `framework_id` | `string` | yes | UUID of the framework, e.g. ISO/IEC 27001:2022 Annex A. Use list_frameworks. | +| `requirement_ids` | `array` | - | Only these requirements of the framework (default: all of them). Ids of other frameworks are ignored. | +| `scope_ids` | `array` | - | Scopes of the new controls (within your own). Default: the framework's scopes. | + +## `delete_control` + +Delete a proposed (draft) control that has no test. A control past draft, or with tests, is retired instead with transition_control(target_state='archived', comment=...). + +Requires `controls.control.delete`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the object | + +## `delete_control_test` + +Delete a draft control test that has no run. An active test is retired instead with transition_control_test(target_state='archived', comment=...). + +Requires `controls.test.delete`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the object | + +## `delete_control_test_run` + +Delete a run that is still in progress (draft), with its evidence. A submitted or validated run is withdrawn instead with transition_control_test_run(target_state='archived', comment=...) and stays readable. + +Requires `controls.test.delete`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the object | + +## `get_control` + +Get a control by ID + +Requires `controls.control.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the object | + +## `get_control_effectiveness` + +Explain a control's effectiveness (RG-CTL-14): the derived verdict (effective, partially_effective, not_effective, not_tested) and its date, and, for each active test, its latest validated run (result, date, reviewer) and next due date. 'is_effectiveness_stale' is true when the verdict rests on an overdue test (RG-CTL-15): the last verdict is kept, never silently downgraded. + +Requires `controls.control.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `control_id` | `string` | yes | UUID of the control. Use list_controls. | + +## `get_control_history` + +Return the change history of a control: field-level diffs, approval events and lifecycle transitions (with comments where recorded) merged into one reverse-chronological timeline. + +Requires `controls.control.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the control | +| `limit` | `integer` | - | Max entries (default 100, max 500). | +| `offset` | `integer` | - | Entries to skip (pagination). | + +## `get_control_test` + +Get a control test by ID + +Requires `controls.test.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the object | + +## `get_control_test_history` + +Return the change history of a control test: field-level diffs, approval events and lifecycle transitions (with comments where recorded) merged into one reverse-chronological timeline. + +Requires `controls.test.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the control test | +| `limit` | `integer` | - | Max entries (default 100, max 500). | +| `offset` | `integer` | - | Entries to skip (pagination). | + +## `get_control_test_run` + +Get one control test run by ID: result, period, sample, observations, performer, review stamps, the finding raised from it, and its evidence files (name, size, SHA-256 and the permission-checked download URL; the bytes themselves are not returned). + +Requires `controls.test.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the object | + +## `get_control_test_run_history` + +Return the change history of a control test run: field-level diffs, approval events and lifecycle transitions (with comments where recorded) merged into one reverse-chronological timeline. + +Requires `controls.test.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the control test run | +| `limit` | `integer` | - | Max entries (default 100, max 500). | +| `offset` | `integer` | - | Entries to skip (pagination). | + +## `get_requirement_coverage` + +Read how a framework is covered by controls, the data behind the Statement of Applicability (RG-CTL-18): for each applicable, reportable requirement, its planned or implemented controls with their state and effectiveness, and an implementation summary (implemented, partial, planned, none). Proposed and retired controls never count; only the controls within your scopes are listed. 'totals' counts requirements per summary. + +Requires `controls.control.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `framework_id` | `string` | yes | UUID of the framework. Use list_frameworks. | + +## `list_control_test_runs` + +List control test runs with optional search and filters + +Requires `controls.test.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `search` | `string` | - | Text search query | +| `limit` | `integer` | - | Max items to return (default 25, max 100) | +| `offset` | `integer` | - | Offset for pagination | +| `test_id` | `string` | - | Filter by test_id | +| `workflow_state` | `string` | - | Filter by workflow_state | +| `result` | `string` | - | Filter by result. | +| `performed_by_id` | `string` | - | Filter by performed_by_id | +| `reviewed_by_id` | `string` | - | Filter by reviewed_by_id | +| `control_id` | `string` | - | Restrict to the runs of the tests of one control. | + +## `list_control_tests` + +List control tests with optional search and filters + +Requires `controls.test.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `search` | `string` | - | Text search query | +| `limit` | `integer` | - | Max items to return (default 25, max 100) | +| `offset` | `integer` | - | Offset for pagination | +| `control_id` | `string` | - | Filter by control_id | +| `workflow_state` | `string` | - | Filter by workflow_state | +| `tester_id` | `string` | - | Filter by tester_id | +| `method` | `string` | - | Filter by method | +| `overdue` | `boolean` | - | true: active tests whose next due date has passed (RG-CTL-07). | +| `due_before` | `string` | - | Tests due on or before this date (ISO 8601 date). | + +## `list_controls` + +List controls with optional search and filters + +Requires `controls.control.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `search` | `string` | - | Text search query | +| `limit` | `integer` | - | Max items to return (default 25, max 100) | +| `offset` | `integer` | - | Offset for pagination | +| `workflow_state` | `string` | - | Filter by workflow_state | +| `theme` | `string` | - | Filter by theme | +| `nature` | `string` | - | Filter by nature | +| `automation` | `string` | - | Filter by automation | +| `owner_id` | `string` | - | Filter by owner_id | +| `effectiveness` | `string` | - | Filter by derived effectiveness (RG-CTL-14). | +| `scope_id` | `string` | - | Restrict to the controls of one scope. | +| `requirement_id` | `string` | - | Restrict to the controls implementing one requirement. | +| `risk_id` | `string` | - | Restrict to the controls treating one risk. | +| `support_asset_id` | `string` | - | Restrict to the controls applying to one support asset. | +| `policy_id` | `string` | - | Restrict to the controls described by one document. | +| `objective_id` | `string` | - | Restrict to the controls contributing to one objective. | +| `framework_id` | `string` | - | Restrict to the controls implementing a requirement of one framework. | +| `test_overdue` | `boolean` | - | true: controls with at least one overdue active test (RG-CTL-15); false: none overdue. | + +## `list_my_control_tests_due` + +List the active control tests the authenticated user has to perform soon: those they are the tester of, or whose control they own when the test has no tester (the reminder recipients). Includes overdue tests; soonest due first. Record a run with create_control_test_run(test_id=...). + +Requires `controls.test.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `within_days` | `integer` | - | Horizon in days from today (default 14, the reminder window). | +| `limit` | `integer` | - | Max items to return (default 25, max 100) | +| `offset` | `integer` | - | Offset for pagination | + +## `list_overdue_control_tests` + +List the active control tests whose next due date has passed (RG-CTL-07), oldest due first, within your scopes: the control, the tester, the due date and the latest validated run. Overdue is derived, never a lifecycle step. + +Requires `controls.test.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `control_id` | `string` | - | Restrict to the tests of one control. | +| `tester_id` | `string` | - | Restrict to the tests of one tester. | +| `limit` | `integer` | - | Max items to return (default 25, max 100) | +| `offset` | `integer` | - | Offset for pagination | + +## `raise_finding_from_run` + +Raise a finding in the nonconformity register from a validated partial or failed test run (RG-CTL-16): source 'control_test', linked to the run, the run's observations as its description, the control's requirements as its related requirements, and the authenticated user as the person who raised it. Offered once per run: a second call is refused. The finding type is a judgement the caller makes. + +Requires `controls.test.approve`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `run_id` | `string` | yes | UUID of the validated partial or failed run. Use list_control_test_runs(result='fail'). | +| `finding_type` | `string` | yes | major_nc (major nonconformity), minor_nc (minor nonconformity) or observation. | +| `recommendation` | `string` | - | Optional recommendation recorded on the finding. | + +## `transition_control` + +Change the lifecycle state of a control (e.g. draft -> pending -> validated -> archived). The transition is validated against the entity's workflow: required permission, mandatory comment, and side effects (owner notification on submit, validation stamping). + +Requires `controls.control.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the control | +| `target_state` | `string` | yes | Target lifecycle state code (see _allowed_transitions). | +| `comment` | `string` | - | Comment, mandatory for transitions that require one. | + +## `transition_control_test` + +Change the lifecycle state of a control test (e.g. draft -> pending -> validated -> archived). The transition is validated against the entity's workflow: required permission, mandatory comment, and side effects (owner notification on submit, validation stamping). + +Requires `controls.test.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the control test | +| `target_state` | `string` | yes | Target lifecycle state code (see _allowed_transitions). | +| `comment` | `string` | - | Comment, mandatory for transitions that require one. | + +## `transition_control_test_run` + +Move a test run through its lifecycle, as the authenticated user: 'in_review' (submit; needs a result, and observations for a partial or failed one, RG-CTL-10), 'draft' (send back, approve, comment required), 'validated' (approve; refused to the run's performer, superusers included, RG-CTL-11; recomputes the test's next due date and the control's effectiveness, RG-CTL-12), 'archived' (withdraw a validated run or cancel a draft, comment required). + +Requires `controls.test.read`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the test run. | +| `target_state` | `string` | yes | Target step (see control_test_run_allowed_transitions). | +| `comment` | `string` | - | Comment, mandatory to send back, withdraw or cancel. | + +## `update_control` + +Update a control's identity and links (any step but retired). Each *_ids array replaces the current links; links already in place survive their target leaving a linkable step. Effectiveness, its date and the lifecycle step are not writable: use transition_control. + +Requires `controls.control.update`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the control to update. | +| `name` | `string` | - | Control name, e.g. 'Multi-factor authentication on remote access'. | +| `description` | `string` | - | How the control operates: what is done, by whom, on what (required). | +| `theme` | `string` | - | ISO/IEC 27002:2022 theme (default 'technological'). | +| `nature` | `string` | - | Control type attribute (default 'preventive'). | +| `automation` | `string` | - | How the control is operated (default 'manual'). | +| `owner_id` | `string` | - | UUID of the accountable owner (receives the failed-test notifications). Use list_users. | +| `implemented_on` | `string` | - | Date the control started operating (ISO 8601 date), never in the future. Stamped with today when declared implemented without one (RG-CTL-02). | +| `scope_ids` | `array` | - | Scopes of the control (within your own). Its tests and runs inherit them. | +| `requirement_ids` | `array` | - | Requirements the control satisfies, from any framework. Use list_requirements. Only targets within your scopes and in a linkable lifecycle step are accepted (RG-CTL-04); the full list replaces the current links. | +| `risk_ids` | `array` | - | Risks the control treats. Use list_risks. Only targets within your scopes and in a linkable lifecycle step are accepted (RG-CTL-04); the full list replaces the current links. | +| `treatment_plan_ids` | `array` | - | Risk treatment plans that create or reinforce the control. Use list_risk_treatment_plans. Only targets within your scopes and in a linkable lifecycle step are accepted (RG-CTL-04); the full list replaces the current links. | +| `pacs_measure_ids` | `array` | - | EBIOS RM PACS measures that create or reinforce the control. Only targets within your scopes and in a linkable lifecycle step are accepted (RG-CTL-04); the full list replaces the current links. | +| `support_asset_ids` | `array` | - | Support assets the control applies to. Use list_support_assets. Only targets within your scopes and in a linkable lifecycle step are accepted (RG-CTL-04); the full list replaces the current links. | +| `objective_ids` | `array` | - | Objectives the control contributes to. Use list_objectives. Only targets within your scopes and in a linkable lifecycle step are accepted (RG-CTL-04); the full list replaces the current links. | +| `policy_ids` | `array` | - | Documents (policies, procedures, standards) that describe the control. Use list_policys. Only targets within your scopes and in a linkable lifecycle step are accepted (RG-CTL-04); the full list replaces the current links. | + +## `update_control_test` + +Update a control test. Changing the frequency or the first due date recomputes the derived next due date. The control cannot change once the test has runs. The lifecycle step is not writable: use transition_control_test. + +Requires `controls.test.update`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the test to update. | +| `control_id` | `string` | - | UUID of the control this test belongs to (not a retired one). Cannot change once the test has runs. Use list_controls. | +| `name` | `string` | - | Test name, e.g. 'Quarterly sample of remote logins'. | +| `objective` | `string` | - | What the test establishes (design, operating effectiveness). | +| `method` | `string` | - | Test method (default 'inspection'). | +| `procedure` | `string` | - | The steps the tester follows. Required to activate the test (RG-CTL-06). | +| `acceptance_criteria` | `string` | - | What a pass looks like. Required to activate the test (RG-CTL-06). | +| `frequency_months` | `integer` | - | Interval between two runs in months, 1 to 36 (default 12). Drives the next due date. | +| `tester_id` | `string` | - | UUID of the default performer of the runs, who receives the due reminders. Use list_users. | +| `first_due_date` | `string` | - | Due date of the first run (ISO 8601 date). Defaults to the activation date. | + +## `update_control_test_run` + +Update a run while it is in progress (draft). From 'in_review' on its content (performer, date, period, sample, result, observations) is frozen and any change is refused (RG-CTL-09). The test a run belongs to never changes. + +Requires `controls.test.update`. + +| Parameter | Type | Required | Description | +| --- | --- | --- | --- | +| `id` | `string` | yes | UUID of the run to update. | +| `performed_by_id` | `string` | - | UUID of the person who performed the test. Defaults to the test's tester, else the caller. That person can never validate the run (RG-CTL-11). | +| `performed_on` | `string` | - | Date of the test (ISO 8601 date), never in the future. Defaults to today. | +| `period_start` | `string` | - | Start of the period the test covers (ISO 8601 date). | +| `period_end` | `string` | - | End of the period the test covers (ISO 8601 date), between period_start and performed_on (RG-CTL-10). | +| `sample_description` | `string` | - | Population and sample tested, e.g. '25 of 1,240 remote logins, March'. | +| `result` | `string` | - | Outcome against the acceptance criteria. Required to submit (RG-CTL-10). | +| `observations` | `string` | - | What was found. Required to submit a partial or failed result (RG-CTL-10); becomes the description of a raised finding. | diff --git a/docs/reference/generated/mcp-tools-general.md b/docs/reference/generated/mcp-tools-general.md index ba363558..84fd5dd5 100644 --- a/docs/reference/generated/mcp-tools-general.md +++ b/docs/reference/generated/mcp-tools-general.md @@ -62,11 +62,11 @@ No parameters. ## `help` -Get usage documentation for the Cairn MCP server. Call without arguments for the full guide, or with a topic for focused help. Topics: context, assets, compliance, risks, incidents, documents, batch, workflow, permissions, examples, users +Get usage documentation for the Cairn MCP server. Call without arguments for the full guide, or with a topic for focused help. Topics: context, assets, compliance, risks, incidents, documents, controls, batch, workflow, permissions, examples, users | Parameter | Type | Required | Description | | --- | --- | --- | --- | -| `topic` | `string` | - | Optional topic: context, assets, compliance, risks, incidents, documents, batch, workflow, permissions, examples, users | +| `topic` | `string` | - | Optional topic: context, assets, compliance, risks, incidents, documents, controls, batch, workflow, permissions, examples, users | ## `kanban_board` diff --git a/docs/reference/generated/mcp-tools.md b/docs/reference/generated/mcp-tools.md index ff50789e..26f949ae 100644 --- a/docs/reference/generated/mcp-tools.md +++ b/docs/reference/generated/mcp-tools.md @@ -8,7 +8,7 @@ Cairn's MCP server exposes the whole platform to AI assistants and scripts over Transport, authentication and client setup are in [../mcp-server.md](../mcp-server.md). -**772 tools** are registered, across **10 modules**. Most entities carry the same surface : `list_*`, `get_*`, `create_*`, `batch_create_*`, `update_*`, `delete_*`, plus `*_transition`, `*_allowed_transitions` and `*_history` when the entity runs a lifecycle. +**805 tools** are registered, across **11 modules**. Most entities carry the same surface : `list_*`, `get_*`, `create_*`, `batch_create_*`, `update_*`, `delete_*`, plus `*_transition`, `*_allowed_transitions` and `*_history` when the entity runs a lifecycle. ## Modules @@ -17,6 +17,7 @@ Transport, authentication and client setup are in [../mcp-server.md](../mcp-serv | Assets | 136 | [mcp-tools-assets.md](mcp-tools-assets.md) | | Compliance | 64 | [mcp-tools-compliance.md](mcp-tools-compliance.md) | | Governance and context | 126 | [mcp-tools-context.md](mcp-tools-context.md) | +| Controls | 33 | [mcp-tools-controls.md](mcp-tools-controls.md) | | Documents | 33 | [mcp-tools-documents.md](mcp-tools-documents.md) | | General | 18 | [mcp-tools-general.md](mcp-tools-general.md) | | Incidents | 103 | [mcp-tools-incidents.md](mcp-tools-incidents.md) | @@ -366,6 +367,44 @@ Transport, authentication and client setup are in [../mcp-server.md](../mcp-serv | `update_swot_item` | `context.swot.update` | Update an existing swot item | | `update_swot_strategy` | `context.swot.update` | Update an existing swot strategy | +## Controls + +| Tool | Permission | Description | +| --- | --- | --- | +| `batch_create_control_test_runs` | `controls.test.create` | Record several runs (in progress) in one call (max 500). Every test_id must be an active test within the caller's scopes, or nothing is created. | +| `batch_create_control_tests` | `controls.test.create` | Create or upsert several draft control tests in one call (max 500). Every control_id must be a control within the caller's scopes and not retired, or nothing is created. | +| `batch_create_controls` | `controls.control.create` | Create or upsert several proposed controls in one call (max 500). Every link must be within the caller's scopes and linkable, or nothing is created. Pass 'match_on' (e.g. ["name"]) to update matching controls instead of duplicating them. | +| `control_allowed_transitions` | `controls.control.read` | List the lifecycle transitions the caller may perform on a control from its current state. | +| `control_test_allowed_transitions` | `controls.test.read` | List the lifecycle transitions the caller may perform on a control test from its current state. | +| `control_test_run_allowed_transitions` | `controls.test.read` | List the lifecycle transitions the caller may perform on a control test run from its current state. | +| `create_control` | `controls.control.create` | Create a proposed (draft) control: a durable practice the organisation operates, with its theme, nature, owner, scopes and links to requirements, risks, treatment plans, PACS measures, support assets, objectives and documents. Effectiveness is derived from validated test runs and cannot be set. Plan it or declare it implemented with transition_control. To bootstrap a register from a framework, prefer create_controls_from_requirements. | +| `create_control_test` | `controls.test.create` | Create a draft test of a control: method, procedure, acceptance criteria, frequency and default tester. Activate it with transition_control_test(target_state='active') once the control is planned or implemented and the procedure and acceptance criteria are written (RG-CTL-06); the next due date is then derived (RG-CTL-07). | +| `create_control_test_run` | `controls.test.create` | Record a run (in progress) of an active control test. Fill the result and observations, attach evidence through the web form, then submit it with transition_control_test_run(target_state='in_review'): from then on its content is frozen (RG-CTL-09) and someone other than its performer validates it (RG-CTL-11). | +| `create_controls_from_requirements` | `controls.control.create` | Bootstrap a control register from a framework (RG-CTL-05): for each selected applicable requirement that no control covers yet, create one proposed (draft) control named after the requirement, with its description as a starting point, the requirement linked and the framework's scopes. Requirements already covered (by a control in any step) or not applicable are skipped and reported; existing controls are never linked. Needs controls.control.create and compliance.framework.read on a framework within your scopes. | +| `delete_control` | `controls.control.delete` | Delete a proposed (draft) control that has no test. A control past draft, or with tests, is retired instead with transition_control(target_state='archived', comment=...). | +| `delete_control_test` | `controls.test.delete` | Delete a draft control test that has no run. An active test is retired instead with transition_control_test(target_state='archived', comment=...). | +| `delete_control_test_run` | `controls.test.delete` | Delete a run that is still in progress (draft), with its evidence. A submitted or validated run is withdrawn instead with transition_control_test_run(target_state='archived', comment=...) and stays readable. | +| `get_control` | `controls.control.read` | Get a control by ID | +| `get_control_effectiveness` | `controls.control.read` | Explain a control's effectiveness (RG-CTL-14): the derived verdict (effective, partially_effective, not_effective, not_tested) and its date, and, for each active test, its latest validated run (result, date, reviewer) and next due date. 'is_effectiveness_stale' is true when the verdict rests on an overdue test (RG-CTL-15): the last verdict is kept, never silently downgraded. | +| `get_control_history` | `controls.control.read` | Return the change history of a control: field-level diffs, approval events and lifecycle transitions (with comments where recorded) merged into one reverse-chronological timeline. | +| `get_control_test` | `controls.test.read` | Get a control test by ID | +| `get_control_test_history` | `controls.test.read` | Return the change history of a control test: field-level diffs, approval events and lifecycle transitions (with comments where recorded) merged into one reverse-chronological timeline. | +| `get_control_test_run` | `controls.test.read` | Get one control test run by ID: result, period, sample, observations, performer, review stamps, the finding raised from it, and its evidence files (name, size, SHA-256 and the permission-checked download URL; the bytes themselves are not returned). | +| `get_control_test_run_history` | `controls.test.read` | Return the change history of a control test run: field-level diffs, approval events and lifecycle transitions (with comments where recorded) merged into one reverse-chronological timeline. | +| `get_requirement_coverage` | `controls.control.read` | Read how a framework is covered by controls, the data behind the Statement of Applicability (RG-CTL-18): for each applicable, reportable requirement, its planned or implemented controls with their state and effectiveness, and an implementation summary (implemented, partial, planned, none). Proposed and retired controls never count; only the controls within your scopes are listed. 'totals' counts requirements per summary. | +| `list_control_test_runs` | `controls.test.read` | List control test runs with optional search and filters | +| `list_control_tests` | `controls.test.read` | List control tests with optional search and filters | +| `list_controls` | `controls.control.read` | List controls with optional search and filters | +| `list_my_control_tests_due` | `controls.test.read` | List the active control tests the authenticated user has to perform soon: those they are the tester of, or whose control they own when the test has no tester (the reminder recipients). Includes overdue tests; soonest due first. Record a run with create_control_test_run(test_id=...). | +| `list_overdue_control_tests` | `controls.test.read` | List the active control tests whose next due date has passed (RG-CTL-07), oldest due first, within your scopes: the control, the tester, the due date and the latest validated run. Overdue is derived, never a lifecycle step. | +| `raise_finding_from_run` | `controls.test.approve` | Raise a finding in the nonconformity register from a validated partial or failed test run (RG-CTL-16): source 'control_test', linked to the run, the run's observations as its description, the control's requirements as its related requirements, and the authenticated user as the person who raised it. Offered once per run: a second call is refused. The finding type is a judgement the caller makes. | +| `transition_control` | `controls.control.read` | Change the lifecycle state of a control (e.g. draft -> pending -> validated -> archived). The transition is validated against the entity's workflow: required permission, mandatory comment, and side effects (owner notification on submit, validation stamping). | +| `transition_control_test` | `controls.test.read` | Change the lifecycle state of a control test (e.g. draft -> pending -> validated -> archived). The transition is validated against the entity's workflow: required permission, mandatory comment, and side effects (owner notification on submit, validation stamping). | +| `transition_control_test_run` | `controls.test.read` | Move a test run through its lifecycle, as the authenticated user: 'in_review' (submit; needs a result, and observations for a partial or failed one, RG-CTL-10), 'draft' (send back, approve, comment required), 'validated' (approve; refused to the run's performer, superusers included, RG-CTL-11; recomputes the test's next due date and the control's effectiveness, RG-CTL-12), 'archived' (withdraw a validated run or cancel a draft, comment required). | +| `update_control` | `controls.control.update` | Update a control's identity and links (any step but retired). Each *_ids array replaces the current links; links already in place survive their target leaving a linkable step. Effectiveness, its date and the lifecycle step are not writable: use transition_control. | +| `update_control_test` | `controls.test.update` | Update a control test. Changing the frequency or the first due date recomputes the derived next due date. The control cannot change once the test has runs. The lifecycle step is not writable: use transition_control_test. | +| `update_control_test_run` | `controls.test.update` | Update a run while it is in progress (draft). From 'in_review' on its content (performer, date, period, sample, result, observations) is frozen and any change is refused (RG-CTL-09). The test a run belongs to never changes. | + ## Documents | Tool | Permission | Description | @@ -415,7 +454,7 @@ Transport, authentication and client setup are in [../mcp-server.md](../mcp-serv | `delete_saved_filter` | - | Delete one of the current user's saved list filters by id. | | `get_dashboard_layout` | - | Get the currently authenticated user's home-dashboard widget layout (ordered list of {key, id, size, visible, zone, params}) and the catalogue of available widgets with their allowed sizes. `id` is the widget type and `key` is the per-instance id. A size is a 'WxH' tile token: width W in 1..4 quarter-columns (1=1/4 .. 4=full width) by height H in 1..4 fixed row units, e.g. '2x1' or '4x2'. A widget with `multiple: true` (e.g. 'indicator') can appear several times, each instance carrying its own `params` (the indicator widget takes `{indicator: , show_chart: bool}`). | | `get_me` | - | Get information about the currently authenticated user, including capability flags: 'can_override_import_dates' (may set created_at / updated_at on import) and 'can_create_users'. | -| `help` | - | Get usage documentation for the Cairn MCP server. Call without arguments for the full guide, or with a topic for focused help. Topics: context, assets, compliance, risks, incidents, documents, batch, workflow, permissions, examples, users | +| `help` | - | Get usage documentation for the Cairn MCP server. Call without arguments for the full guide, or with a topic for focused help. Topics: context, assets, compliance, risks, incidents, documents, controls, batch, workflow, permissions, examples, users | | `kanban_board` | - | Get the unified To do / Doing / Done board aggregating action plans, treatment actions, audits and risk assessments (read-only) | | `list_dependencies` | - | List the third-party open source components this Cairn instance is built on : name, owner (the organisation publishing it), resolved version, official repository URL and what each one is used for. Same registry as the About modal. Optionally filtered by group. | | `list_my_pending_acknowledgements` | - | List the documents the authenticated user has been asked to acknowledge and has not yet, soonest deadline first: the campaign, the document and version, the due date, and the exact statement to confirm. Needs no permission: every user sees their own requests. Read the text with get_policy_version(policy_version_id) before acknowledging. | diff --git a/docs/reference/generated/models.md b/docs/reference/generated/models.md index bb0f5b3c..fa476511 100644 --- a/docs/reference/generated/models.md +++ b/docs/reference/generated/models.md @@ -8,7 +8,7 @@ Every persisted model in the project, with the platform-wide behaviours it inher The field-by-field contract of each entity lives in its [specification](../../specs/README.md); this page is the inventory. -**118 models** are registered. +**122 models** are registered. ## Inventory @@ -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 | @@ -69,6 +69,10 @@ The field-by-field contract of each entity lives in its [specification](../../sp | context | `SwotItem` | `context_swotitem` | - | - | - | yes | 8 | | context | `SwotStrategy` | `context_swotstrategy` | - | - | - | yes | 7 | | context | `Tag` | `context_tag` | - | - | - | - | 4 | +| controls | `ControlTestEvidence` | `controls_controltestevidence` | - | - | - | yes | 9 | +| controls | `ControlTestRun` | `controls_controltestrun` | `CTRN` | `control_test_run` | - | yes | 17 | +| controls | `ControlTest` | `controls_controltest` | `CTST` | `control_test` | - | yes | 19 | +| controls | `Control` | `controls_control` | `CTRL` | `control` | yes | yes | 16 | | core | `LifecycleDefinition` | `core_lifecycledefinition` | - | - | - | - | 8 | | core | `LifecycleEvent` | `core_lifecycleevent` | - | - | - | - | 10 | | documents | `AcknowledgementCampaign` | `documents_acknowledgementcampaign` | `ACKC` | `acknowledgement_campaign` | yes | yes | 14 | 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/reference/generated/rest-endpoints.md b/docs/reference/generated/rest-endpoints.md index 73b3b3d5..2c00e034 100644 --- a/docs/reference/generated/rest-endpoints.md +++ b/docs/reference/generated/rest-endpoints.md @@ -6,7 +6,7 @@ The complete route table under `/api/v1/`. Authentication, pagination, filtering and the error contract are documented in [../rest-api.md](../rest-api.md); the field-level contract of each resource is in its [specification](../../specs/README.md). -**453 routes** are published across **23 groups**. Every route also answers at a `.` suffix (`.json`, `.api`), which is omitted here. +**471 routes** are published across **24 groups**. Every route also answers at a `.` suffix (`.json`, `.api`), which is omitted here. ## Groups @@ -19,6 +19,7 @@ The complete route table under `/api/v1/`. Authentication, pagination, filtering | [`company-settings`](#company-settings) | 1 | | [`compliance`](#compliance) | 36 | | [`context`](#context) | 68 | +| [`controls`](#controls) | 18 | | [`dashboard-layout`](#dashboard-layout) | 1 | | [`dependencies`](#dependencies) | 1 | | [`documents`](#documents) | 24 | @@ -257,6 +258,29 @@ The complete route table under `/api/v1/`. Authentication, pagination, filtering | `/api/v1/context/tags/` | GET, POST | `context.api.views.TagViewSet` | `tag-list` | | `/api/v1/context/tags//` | DELETE, GET, PATCH, PUT | `context.api.views.TagViewSet` | `tag-detail` | +## controls + +| Path | Methods | View | URL name | +| --- | --- | --- | --- | +| `/api/v1/controls/` | GET, OPTIONS | `rest_framework.routers.APIRootView` | `api-root` | +| `/api/v1/controls/controls/` | GET, POST | `controls.api.views.ControlViewSet` | `control-list` | +| `/api/v1/controls/controls//` | DELETE, GET, PATCH, PUT | `controls.api.views.ControlViewSet` | `control-detail` | +| `/api/v1/controls/controls//history/` | GET | `controls.api.views.ControlViewSet` | `control-history` | +| `/api/v1/controls/controls//transition/` | GET, POST | `controls.api.views.ControlViewSet` | `control-transition` | +| `/api/v1/controls/controls/batch/` | POST | `controls.api.views.ControlViewSet` | `control-batch-create` | +| `/api/v1/controls/controls/from-requirements/` | POST | `controls.api.views.ControlViewSet` | `control-from-requirements` | +| `/api/v1/controls/runs/` | GET, POST | `controls.api.views.ControlTestRunViewSet` | `controltestrun-list` | +| `/api/v1/controls/runs//` | DELETE, GET, PATCH, PUT | `controls.api.views.ControlTestRunViewSet` | `controltestrun-detail` | +| `/api/v1/controls/runs//history/` | GET | `controls.api.views.ControlTestRunViewSet` | `controltestrun-history` | +| `/api/v1/controls/runs//raise-finding/` | POST | `controls.api.views.ControlTestRunViewSet` | `controltestrun-raise-finding` | +| `/api/v1/controls/runs//transition/` | GET, POST | `controls.api.views.ControlTestRunViewSet` | `controltestrun-transition` | +| `/api/v1/controls/runs/batch/` | POST | `controls.api.views.ControlTestRunViewSet` | `controltestrun-batch-create` | +| `/api/v1/controls/tests/` | GET, POST | `controls.api.views.ControlTestViewSet` | `controltest-list` | +| `/api/v1/controls/tests//` | DELETE, GET, PATCH, PUT | `controls.api.views.ControlTestViewSet` | `controltest-detail` | +| `/api/v1/controls/tests//history/` | GET | `controls.api.views.ControlTestViewSet` | `controltest-history` | +| `/api/v1/controls/tests//transition/` | GET, POST | `controls.api.views.ControlTestViewSet` | `controltest-transition` | +| `/api/v1/controls/tests/batch/` | POST | `controls.api.views.ControlTestViewSet` | `controltest-batch-create` | + ## dashboard-layout | Path | Methods | View | URL name | diff --git a/docs/screenshots/control-detail.png b/docs/screenshots/control-detail.png new file mode 100644 index 00000000..37d34c90 Binary files /dev/null and b/docs/screenshots/control-detail.png differ diff --git a/docs/screenshots/control-tests.png b/docs/screenshots/control-tests.png new file mode 100644 index 00000000..fc57edb6 Binary files /dev/null and b/docs/screenshots/control-tests.png differ diff --git a/docs/screenshots/controls.png b/docs/screenshots/controls.png new file mode 100644 index 00000000..d29ee7c2 Binary files /dev/null and b/docs/screenshots/controls.png differ diff --git a/docs/sdk/ui-components.md b/docs/sdk/ui-components.md index 12891fb7..c47f272d 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. @@ -341,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 +`