Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand All @@ -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.



Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<app>/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, `<dl>`, 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, `<dl>`, 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 <branch>` 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.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
14 changes: 14 additions & 0 deletions accounts/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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"),
Expand Down
18 changes: 18 additions & 0 deletions accounts/migrations/0060_alter_notification_notification_type.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
82 changes: 82 additions & 0 deletions accounts/migrations/0061_add_controls_permissions.py
Original file line number Diff line number Diff line change
@@ -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),
]
6 changes: 4 additions & 2 deletions accounts/mixins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
3 changes: 1 addition & 2 deletions assets/models/support_asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion assets/templates/assets/certificate_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
{% endif %}

{% if certificate.notes %}
<div class="note-cartouche" role="note">{{ certificate.notes|safe }}</div>
<div class="note-cartouche" role="note">{{ certificate.notes|rich }}</div>
{% endif %}

{% if certificate.is_superseded %}
Expand Down
2 changes: 1 addition & 1 deletion assets/templates/assets/contract_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
{% endif %}

{% if contract.notes %}
<div class="note-cartouche" role="note">{{ contract.notes|safe }}</div>
<div class="note-cartouche" role="note">{{ contract.notes|rich }}</div>
{% endif %}

{% if contract.is_superseded %}
Expand Down
4 changes: 2 additions & 2 deletions assets/templates/assets/essential_asset_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}<div class="ov-rich">{{ asset.description|safe }}</div>{% endif %}{% endsection_card %}
{% section_card _("Description") icon="text-left" hide_empty=True %}{% if asset.description %}<div class="ov-rich">{{ asset.description|rich }}</div>{% endif %}{% endsection_card %}

{% section_card _("General") icon="info-circle" %}
{% meta_list cols=2 %}
Expand Down Expand Up @@ -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 %}<div class="ov-rich">{{ asset.regulatory_constraints|safe }}</div>{% endif %}{% endmeta_item %}
{% meta_item _("Regulatory constraints") wide=True %}{% if asset.regulatory_constraints %}<div class="ov-rich">{{ asset.regulatory_constraints|rich }}</div>{% endif %}{% endmeta_item %}
{% endmeta_list %}
{% endsection_card %}

Expand Down
2 changes: 1 addition & 1 deletion assets/templates/assets/group_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}<div class="ov-rich">{{ group.description|safe }}</div>{% endif %}{% endsection_card %}
{% section_card _("Description") icon="text-left" hide_empty=True %}{% if group.description %}<div class="ov-rich">{{ group.description|rich }}</div>{% endif %}{% endsection_card %}

{% section_card _("General") icon="info-circle" %}
{% meta_list cols=2 %}
Expand Down
2 changes: 1 addition & 1 deletion assets/templates/assets/site_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
<div class="card-body">
<div class="supplier-overview{% if not site.address %} supplier-overview--nomap{% endif %}">
<div class="supplier-overview__info">
{% 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") %}<a href="{% url 'assets:site-detail' site.parent_site.pk %}" class="ov-link">{{ site.parent_site.name }}</a>{% endov_row %}{% endif %}
{% if site.address %}{% ov_row "geo-alt" _("Address") %}{{ site.address|striptags }}{% endov_row %}{% endif %}
Expand Down
Loading
Loading