From 7b6c4036a5731f342ac03d923079904374755fd2 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Fri, 9 Oct 2026 09:20:38 +0100 Subject: [PATCH 1/6] feat(timeline): read the tenant feed as the organisation's journal (#905) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The redesigned Activity page reads "Fatou Ndiaye a créé la mitigation Bastion d'administration (PAM)" and filters by Risques, Incidents, Vulnérabilités, Mitigations, Preuves, Conformité and Rapports. The feed now gives it what that takes: - Each event names its domain and its object. The object is the record's title or name when the audit row carries it, else the name the summary quotes; ids and bare numbers are not names. - GET /timeline?domain=… filters by domain inside the query, so a filtered page is full rather than over-read and thinned. - Mitigations, reports and frameworks have no drawer and were shown only to audit-permission holders. They now follow the feed's own rule (you see an event if you may read what it is about), gated by the permission that guards their pages: mitigations:read, compliance:controls:read, reports:board:read. Governance records stay behind the audit permission. - Technical rows (onboarding celebrations, notification reads, unnamed routes) are left out of the journal for everyone; the audit trail keeps them. - One mutation recorded by both the HTTP middleware and the model hook shows once, from the model row that carries the record. - Actors read by display name when the user has one, email otherwise. Tests: journal Success / NotFound (unknown domain) / Unauthorized (per page permission), domain pushed into the query, twin merge, object labels, names. Existing timeline tests unchanged and green. Signed-off-by: alex-dembele --- .../internal/application/entity/journal.go | 206 ++++++++++++++++++ .../application/entity/journal_test.go | 199 +++++++++++++++++ .../internal/application/entity/timeline.go | 43 ++-- backend/internal/application/entity/types.go | 8 + backend/internal/handler/entity_handler.go | 5 +- .../repository/gorm_user_repository.go | 27 +++ 6 files changed, 474 insertions(+), 14 deletions(-) create mode 100644 backend/internal/application/entity/journal.go create mode 100644 backend/internal/application/entity/journal_test.go diff --git a/backend/internal/application/entity/journal.go b/backend/internal/application/entity/journal.go new file mode 100644 index 000000000..b78dd4d25 --- /dev/null +++ b/backend/internal/application/entity/journal.go @@ -0,0 +1,206 @@ +// Copyright (c) 2026 OpenDefender Contributors +// SPDX-License-Identifier: AGPL-3.0-only +// This program is free software: you can redistribute it and/or modify it under +// the terms of the GNU Affero General Public License v3.0 (see LICENSE). + +package entity + +import ( + "regexp" + "strings" + "time" + + "github.com/google/uuid" + + "github.com/opendefender/openrisk/internal/domain" +) + +// ============================================================================= +// The organisation's journal (#905) +// ============================================================================= +// +// The Activity page reads the tenant feed as a journal: "Fatou a qualifié en P1 +// INC-…". That needs three things the raw audit row does not say plainly: +// +// - which business domain an event belongs to, so the page can filter by +// Risques / Incidents / Vulnérabilités / Mitigations / Preuves / Conformité / +// Rapports; +// - the object's name, so the sentence can end on it; +// - one entry per thing that happened. Two writers record a mutation (the +// HTTP middleware and the model hook), so the same change arrives twice. +// +// Visibility keeps the feed's rule: an event is shown to someone who may read +// what it is about. Types with a drawer use the drawer's permission; mitigations, +// reports and frameworks use the permission that guards their own pages; +// governance records stay behind the audit permission. Technical records +// (onboarding celebrations, notification reads, unnamed routes) are not part of +// the journal for anyone; they remain in the audit trail. + +// Journal domains, the values of the `domain` filter and of TimelineEvent.Domain. +const ( + DomainRisk = "risk" + DomainIncident = "incident" + DomainVulnerability = "vulnerability" + DomainMitigation = "mitigation" + DomainEvidence = "evidence" + DomainCompliance = "compliance" + DomainReport = "report" + DomainAsset = "asset" + DomainGovernance = "governance" +) + +// journalClass says how one audit entity type appears in the journal. +type journalClass struct { + domain string + // drawer is set when the type opens a drawer: its permission and deep link + // come from the registry. + drawer Type + // perm and link serve types without a drawer. + perm string + link func(id string) string +} + +func listLink(path string) func(string) string { return func(string) string { return path } } + +// journalClasses maps every audit entity type the journal shows. A type absent +// from this map is not part of the journal. +var journalClasses = map[string]journalClass{ + "risk": {domain: DomainRisk, drawer: TypeRisk}, + "incident": {domain: DomainIncident, drawer: TypeIncident}, + "incident_post_mortem": {domain: DomainIncident, perm: "incidents:read", link: listLink("/incidents")}, + "vulnerability": {domain: DomainVulnerability, drawer: TypeVulnerability}, + "mitigation": {domain: DomainMitigation, perm: "mitigations:read", link: listLink("/mitigations")}, + "evidence": {domain: DomainEvidence, drawer: TypeEvidence}, + "control_evidence": {domain: DomainEvidence, drawer: TypeEvidence}, + "compliance_control": {domain: DomainCompliance, drawer: TypeControl}, + "control": {domain: DomainCompliance, drawer: TypeControl}, + "compliance_framework": { + domain: DomainCompliance, perm: "compliance:controls:read", + link: func(id string) string { return "/compliance/" + id }, + }, + "report": {domain: DomainReport, perm: "compliance:controls:read", link: listLink("/reports")}, + "report_job": {domain: DomainReport, perm: "compliance:controls:read", link: listLink("/reports")}, + "board": {domain: DomainReport, perm: "reports:board:read", link: listLink("/reports/board")}, + "asset": {domain: DomainAsset, drawer: TypeAsset}, + // Governance: who may approve, delegate or automate. Audit permission only, + // as the feed has always done for entities with no drawer. + "approval_request": {domain: DomainGovernance, perm: AuditPermission, link: listLink("/governance")}, + "approval_workflow": {domain: DomainGovernance, perm: AuditPermission, link: listLink("/governance")}, + "automation_rule": {domain: DomainGovernance, perm: AuditPermission, link: listLink("/automation")}, + "automation_channel_config": {domain: DomainGovernance, perm: AuditPermission, link: listLink("/automation")}, + "delegation": {domain: DomainGovernance, perm: AuditPermission, link: listLink("/governance")}, + "organization_member": {domain: DomainGovernance, perm: AuditPermission, link: listLink("/settings/members")}, + "invitation": {domain: DomainGovernance, perm: AuditPermission, link: listLink("/settings/members")}, + "mfa_policy": {domain: DomainGovernance, perm: AuditPermission, link: listLink("/settings")}, +} + +// journalTypesFor returns the audit entity types of one domain, for the query. +// Unknown domain → nil, ok false. +func journalTypesFor(d string) ([]string, bool) { + var out []string + for et, cls := range journalClasses { + if cls.domain == d { + out = append(out, et) + } + } + return out, len(out) > 0 +} + +// visible reports whether the caller may see an event of this class. +func (cls journalClass) visible(c Caller) bool { + if CanReadAudit(c) { + return true + } + if cls.drawer != "" { + return c.CanRead(cls.drawer) + } + return cls.perm != "" && c.Can(cls.perm) +} + +// journalEvent projects an audit row into a journal entry, or false when the +// row is not part of the journal. +func journalEvent(e domain.AuditEvent) (TimelineEvent, journalClass, bool) { + cls, ok := journalClasses[e.EntityType] + if !ok { + return TimelineEvent{}, cls, false + } + ev := auditToTimeline(e) + ev.Domain = cls.domain + ev.Object = objectLabel(e) + if cls.drawer != "" { + ev.Target = Ref{Type: cls.drawer, ID: e.EntityID} + ev.TargetURL = DeepLink(cls.drawer, e.EntityID) + } else if cls.link != nil { + ev.TargetURL = cls.link(e.EntityID) + } + return ev, cls, true +} + +var ( + affectedSuffix = regexp.MustCompile(`\s*\(\d+ records? affected\)\s*$`) + quoted = regexp.MustCompile(`"([^"]+)"`) + bareNumber = regexp.MustCompile(`^\d+$`) +) + +// objectLabel names the object of an entry: the record's own title or name when +// the row carries it, else the name the summary quotes. Only that one field is +// read from the snapshot; the reader is already cleared for the type, and the +// summary itself states the same name. +func objectLabel(e domain.AuditEvent) string { + for _, snap := range []domain.JSONMap{e.After, e.Before} { + for _, k := range []string{"title", "name", "file_name", "period_label"} { + if v, ok := snap[k].(string); ok && strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + } + s := affectedSuffix.ReplaceAllString(strings.TrimSpace(e.Summary), "") + if m := quoted.FindStringSubmatch(s); m != nil { + return m[1] + } + // "