From 6419870dde01318731c6a15fc3f74892ee7122f0 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 8 Oct 2026 13:28:27 +0100 Subject: [PATCH 1/3] feat(vulnerabilities): a remediation deadline on every finding (#902) The redesigned queue and the action center show how long is left to fix each finding, and nothing held that date. sla_due_at is the first-seen date plus the owner's default window for the severity (critical 7 days, high 14, medium 30, low 60), capped at 14 days for a KEV-listed flaw as in CISA BOD 22-01. A save hook recomputes it, so a severity change moves it; migration 0070 adds the column and backfills existing rows. Informational findings get no deadline unless they are on the KEV list. Signed-off-by: alex-dembele --- backend/internal/domain/vulnerability.go | 51 +++++++++++++++- .../internal/domain/vulnerability_sla_test.go | 59 +++++++++++++++++++ .../0070_vulnerability_sla_due.down.sql | 2 + migrations/0070_vulnerability_sla_due.up.sql | 25 ++++++++ 4 files changed, 134 insertions(+), 3 deletions(-) create mode 100644 backend/internal/domain/vulnerability_sla_test.go create mode 100644 migrations/0070_vulnerability_sla_due.down.sql create mode 100644 migrations/0070_vulnerability_sla_due.up.sql diff --git a/backend/internal/domain/vulnerability.go b/backend/internal/domain/vulnerability.go index 3206471a..e95ca3ab 100644 --- a/backend/internal/domain/vulnerability.go +++ b/backend/internal/domain/vulnerability.go @@ -7,6 +7,7 @@ package domain import ( "fmt" + "strings" "time" "github.com/google/uuid" @@ -175,9 +176,13 @@ type Vulnerability struct { TicketKey string `gorm:"size:64" json:"ticket_key,omitempty"` // e.g. VULN-123 / INC0012345 TicketURL string `gorm:"size:512" json:"ticket_url,omitempty"` - FirstSeen time.Time `json:"first_seen"` - LastSeen time.Time `gorm:"index" json:"last_seen"` - RawData datatypes.JSON `gorm:"type:jsonb" json:"raw_data,omitempty"` + FirstSeen time.Time `json:"first_seen"` + // SLADueAt is when the fix is due under the remediation policy (#902): + // computed from FirstSeen, the severity and the KEV flag by VulnSLADue on + // every save, so a severity change moves it. Nil until FirstSeen is known. + SLADueAt *time.Time `gorm:"index" json:"sla_due_at"` + LastSeen time.Time `gorm:"index" json:"last_seen"` + RawData datatypes.JSON `gorm:"type:jsonb" json:"raw_data,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -216,3 +221,43 @@ type VulnStats struct { KEVOpen int64 `json:"kev_open"` ExploitCount int64 `json:"exploit_count"` } + +// Default remediation windows (#902, owner decision 2026-10-08), counted from +// the day the finding was first seen. A KEV-listed flaw gets at most +// KEVRemediationWindow, the CISA BOD 22-01 deadline. Tenant-level overrides +// come with the settings screen. +var vulnSLAWindows = map[VulnSeverity]time.Duration{ + VulnSeverityCritical: 7 * 24 * time.Hour, + VulnSeverityHigh: 14 * 24 * time.Hour, + VulnSeverityMedium: 30 * 24 * time.Hour, + VulnSeverityLow: 60 * 24 * time.Hour, +} + +// KEVRemediationWindow caps the window of a known-exploited flaw. +const KEVRemediationWindow = 14 * 24 * time.Hour + +// VulnSLADue returns when a finding must be fixed, or nil when it has no +// detection date or a severity outside the policy (informational findings). +func VulnSLADue(sev VulnSeverity, kev bool, firstSeen time.Time) *time.Time { + if firstSeen.IsZero() { + return nil + } + window, ok := vulnSLAWindows[VulnSeverity(strings.ToLower(string(sev)))] + if !ok { + if !kev { + return nil + } + window = KEVRemediationWindow + } + if kev && window > KEVRemediationWindow { + window = KEVRemediationWindow + } + due := firstSeen.Add(window) + return &due +} + +// BeforeSave keeps SLADueAt in step with severity, KEV and FirstSeen. +func (v *Vulnerability) BeforeSave(_ *gorm.DB) error { + v.SLADueAt = VulnSLADue(v.Severity, v.KEV, v.FirstSeen) + return nil +} diff --git a/backend/internal/domain/vulnerability_sla_test.go b/backend/internal/domain/vulnerability_sla_test.go new file mode 100644 index 00000000..171da7e5 --- /dev/null +++ b/backend/internal/domain/vulnerability_sla_test.go @@ -0,0 +1,59 @@ +// 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 domain + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var seen = time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC) + +func days(d *time.Time) int { + return int(d.Sub(seen).Hours() / 24) +} + +// The owner's default policy (#902): 7 / 14 / 30 / 60 days by severity. +func TestVulnSLADue_Success(t *testing.T) { + for sev, want := range map[VulnSeverity]int{ + VulnSeverityCritical: 7, + VulnSeverityHigh: 14, + VulnSeverityMedium: 30, + VulnSeverityLow: 60, + "HIGH": 14, // casing from a connector does not change the policy + } { + due := VulnSLADue(sev, false, seen) + require.NotNil(t, due, sev) + assert.Equal(t, want, days(due), sev) + } +} + +// A known-exploited flaw gets at most 14 days, and an informational KEV finding +// still gets one. +func TestVulnSLADue_KEVCap(t *testing.T) { + assert.Equal(t, 7, days(VulnSLADue(VulnSeverityCritical, true, seen))) + assert.Equal(t, 14, days(VulnSLADue(VulnSeverityMedium, true, seen))) + assert.Equal(t, 14, days(VulnSLADue(VulnSeverityInfo, true, seen))) +} + +// No detection date, or an informational finding: no deadline at all. +func TestVulnSLADue_NotFound(t *testing.T) { + assert.Nil(t, VulnSLADue(VulnSeverityHigh, false, time.Time{})) + assert.Nil(t, VulnSLADue(VulnSeverityInfo, false, seen)) +} + +// The save hook moves the deadline when the severity changes. +func TestVulnerability_BeforeSaveRecomputes(t *testing.T) { + v := &Vulnerability{Severity: VulnSeverityLow, FirstSeen: seen} + require.NoError(t, v.BeforeSave(nil)) + assert.Equal(t, 60, days(v.SLADueAt)) + v.Severity = VulnSeverityCritical + require.NoError(t, v.BeforeSave(nil)) + assert.Equal(t, 7, days(v.SLADueAt)) +} diff --git a/migrations/0070_vulnerability_sla_due.down.sql b/migrations/0070_vulnerability_sla_due.down.sql new file mode 100644 index 00000000..95a3713e --- /dev/null +++ b/migrations/0070_vulnerability_sla_due.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_vulnerabilities_sla_due_at; +ALTER TABLE vulnerabilities DROP COLUMN IF EXISTS sla_due_at; diff --git a/migrations/0070_vulnerability_sla_due.up.sql b/migrations/0070_vulnerability_sla_due.up.sql new file mode 100644 index 00000000..2cc5d889 --- /dev/null +++ b/migrations/0070_vulnerability_sla_due.up.sql @@ -0,0 +1,25 @@ +-- #902 — remediation deadline per vulnerability. +-- +-- The redesigned vulnerability queue and the action center show how long is +-- left to fix each finding. The deadline is the first-seen date plus the +-- default window for its severity (critical 7 days, high 14, medium 30, +-- low 60), capped at 14 days for a KEV-listed flaw (owner decision, +-- 2026-10-08). The application recomputes it on every save; this backfills +-- the rows that exist. Idempotent: AutoMigrate adds the column on boot too. + +ALTER TABLE vulnerabilities ADD COLUMN IF NOT EXISTS sla_due_at timestamptz; + +UPDATE vulnerabilities + SET sla_due_at = first_seen + ( + CASE + WHEN kev AND LOWER(severity) IN ('medium', 'low', 'info', '') THEN interval '14 days' + WHEN LOWER(severity) = 'critical' THEN interval '7 days' + WHEN LOWER(severity) = 'high' THEN interval '14 days' + WHEN LOWER(severity) = 'medium' THEN interval '30 days' + WHEN LOWER(severity) = 'low' THEN interval '60 days' + END) + WHERE sla_due_at IS NULL + AND first_seen IS NOT NULL + AND (kev OR LOWER(severity) IN ('critical', 'high', 'medium', 'low')); + +CREATE INDEX IF NOT EXISTS idx_vulnerabilities_sla_due_at ON vulnerabilities (sla_due_at); From 47a902529d55b9f382351f40707aa1eea94746ac Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 8 Oct 2026 13:31:46 +0100 Subject: [PATCH 2/3] feat(action-center): vulnerability deadlines, vendor follow-ups and reviews (#902) The redesigned action center lists the work an RSSI actually chases, and three kinds of it had no source here: - vulnerability_sla: open findings whose remediation deadline has passed or falls within the week, earliest first, KEV first at equal deadline; - vendor_followup: questionnaires sent and unanswered past or near their due date, titled with the vendor's name (the vendor is matched on the tenant too); - mitigation_review: plans waiting for someone to validate them. Organisation admins and owners now see every category of the tenant (owner decision); before, an admin with no business role saw only their own approvals, an empty list. Security analysts and the DSI get the vulnerability deadlines, risk managers and compliance officers the vendor follow-ups. The ratchet that no role is shown a category it cannot open covers the three new ones. Each item now carries a meta map of facts (status, severity, CVE, asset, KEV) so the row can word them, and the status is what lets the client complete and undo. Open incidents now include the in_progress status the incident API writes; only 'investigating', which nothing writes, was read before. Signed-off-by: alex-dembele --- .../actioncenter/authorization_test.go | 6 +- .../application/actioncenter/deeplinks.go | 10 + .../application/actioncenter/usecase.go | 181 ++++++++++++++++-- .../actioncenter/usecase_redesign_test.go | 115 +++++++++++ .../application/actioncenter/usecase_test.go | 16 ++ .../handler/action_center_handler_test.go | 9 + .../actioncenter_redesign_repository_test.go | 90 +++++++++ .../repository/actioncenter_repository.go | 81 +++++++- .../actioncenter_repository_test.go | 6 + docs/openapi.yaml | 18 +- 10 files changed, 512 insertions(+), 20 deletions(-) create mode 100644 backend/internal/application/actioncenter/usecase_redesign_test.go create mode 100644 backend/internal/infrastructure/repository/actioncenter_redesign_repository_test.go diff --git a/backend/internal/application/actioncenter/authorization_test.go b/backend/internal/application/actioncenter/authorization_test.go index f889feb8..244e1b35 100644 --- a/backend/internal/application/actioncenter/authorization_test.go +++ b/backend/internal/application/actioncenter/authorization_test.go @@ -21,6 +21,9 @@ var categoryReadPermission = map[int]domain.PermissionKey{ RankOpenIncident: "incidents:read", RankExpiringEvidence: "compliance:evidences:read", RankOverdueRemediation: "compliance:remediations:read", + RankVulnerabilitySLA: "vulnerabilities:read", + RankVendorFollowUp: "vendors:read", + RankMitigationReview: "mitigations:read", // RankPendingApproval is absent on purpose: approvals are not gated by this // map at all. Eligibility is decided per request by domain.CanSign, so there // is no role→category grant here to check. @@ -68,11 +71,12 @@ func TestRoleCategoryMapUsesDeclaredRanksOnly(t *testing.T) { valid := map[int]bool{ RankOverdueMitigation: true, RankCriticalRisk: true, RankPendingApproval: true, RankOpenIncident: true, RankExpiringEvidence: true, RankOverdueRemediation: true, + RankVulnerabilitySLA: true, RankVendorFollowUp: true, RankMitigationReview: true, } for role, ranks := range roleCategories { for _, r := range ranks { require.Truef(t, valid[r], "role %q is granted unknown category rank %d", role, r) - require.Truef(t, r >= 1 && r <= 6, "rank %d for %q is outside the published 1..6 contract", r, role) + require.Truef(t, r >= 1 && r <= 9, "rank %d for %q is outside the published 1..9 contract", r, role) } } } diff --git a/backend/internal/application/actioncenter/deeplinks.go b/backend/internal/application/actioncenter/deeplinks.go index dd2bdc34..64841627 100644 --- a/backend/internal/application/actioncenter/deeplinks.go +++ b/backend/internal/application/actioncenter/deeplinks.go @@ -41,6 +41,10 @@ const ( routeEvidenceLibrary = "/compliance/evidence" // routeRemediationDetail — /compliance/remediation/:planId (routeModel.ts). routeRemediationDetail = "/compliance/remediation/" + // routeVulnerabilities — /vulnerabilities, opened with the drawer params. + routeVulnerabilities = "/vulnerabilities" + // routeVendorAssessment — /vendors/:vendorId/assessments/:assessmentId. + routeVendors = "/vendors/" ) // drawerQuery builds the entity-drawer query string. Kept in one function so @@ -55,3 +59,9 @@ func ApprovalLink() string { return routeGovernance } func IncidentLink(id string) string { return routeIncidentWarRoom + id + "/war-room" } func EvidenceLink(id string) string { return routeEvidenceLibrary + drawerQuery("evidence", id) } func RemediationLink(id string) string { return routeRemediationDetail + id } +func VulnerabilityLink(id string) string { + return routeVulnerabilities + drawerQuery("vulnerability", id) +} +func VendorAssessmentLink(vendorID, assessmentID string) string { + return routeVendors + vendorID + "/assessments/" + assessmentID +} diff --git a/backend/internal/application/actioncenter/usecase.go b/backend/internal/application/actioncenter/usecase.go index 3674f2b4..8c3d5201 100644 --- a/backend/internal/application/actioncenter/usecase.go +++ b/backend/internal/application/actioncenter/usecase.go @@ -46,6 +46,10 @@ const ( ItemTypeOpenIncident ItemType = "open_incident" ItemTypeExpiringEvidence ItemType = "expiring_evidence" ItemTypeOverdueRemediation ItemType = "overdue_remediation" + // #902 — the redesign's vulnerability, third-party and review work. + ItemTypeVulnerabilitySLA ItemType = "vulnerability_sla" + ItemTypeVendorFollowUp ItemType = "vendor_followup" + ItemTypeMitigationReview ItemType = "mitigation_review" ) // Category ranks. Lower sorts first. These are the priority order itself, not a @@ -59,8 +63,18 @@ const ( RankOpenIncident = 4 RankExpiringEvidence = 5 RankOverdueRemediation = 6 + RankVulnerabilitySLA = 7 + RankVendorFollowUp = 8 + RankMitigationReview = 9 ) +// vulnSLAHorizon is how far ahead a remediation deadline becomes an action: +// overdue findings and those due within the week. +const vulnSLAHorizon = 7 * 24 * time.Hour + +// vendorHorizon is the same window for a questionnaire still unanswered. +const vendorHorizon = 7 * 24 * time.Hour + // CriticalScoreThreshold is the frozen `critical` cut from the Score Engine // (CLAUDE.md: critical >= 7.0). Read here, never recomputed — pkg/scoring owns // the formula and this package must not drift from it. @@ -90,6 +104,10 @@ type ActionItem struct { DueAt *time.Time `json:"due_at"` CategoryRank int `json:"category_rank"` TenantID uuid.UUID `json:"tenant_id"` + // Meta carries facts about the record that the row shows beside its title + // (a CVE, an asset, the parent risk, a vendor, the record's status), keyed + // by name. Facts, never a generated sentence: the client words them. + Meta map[string]string `json:"meta,omitempty"` // sortA/sortB are the per-category secondary keys, both ascending. They are // unexported so the ordering rule cannot be contradicted by a client that @@ -125,6 +143,17 @@ type Repository interface { OpenIncidents(tenantID uuid.UUID, limit int) ([]domain.Incident, error) ExpiringEvidence(tenantID uuid.UUID, now time.Time, limit int) ([]domain.Evidence, error) OverdueRemediationPlans(tenantID uuid.UUID, now time.Time, limit int) ([]domain.RemediationPlan, error) + + // #902. Each keyed on the tenant like the six above. + VulnerabilitiesDueBy(tenantID uuid.UUID, by time.Time, limit int) ([]domain.Vulnerability, error) + VendorAssessmentsDueBy(tenantID uuid.UUID, by time.Time, limit int) ([]VendorFollowUp, error) + MitigationsInReview(tenantID uuid.UUID, limit int) ([]domain.Mitigation, error) +} + +// VendorFollowUp is an unanswered questionnaire and the vendor it was sent to. +type VendorFollowUp struct { + Assessment domain.VendorAssessment + VendorName string } // Caller is everything the use case needs to know about who is asking. Built by @@ -148,17 +177,29 @@ type Caller struct { // granting approvals to people who are not the approver and implying that // someone whose role is missing from this map cannot be one. // -// A role that is not listed — asset_owner, executive, viewer, dsi, -// internal_control, risk_owner, security_analyst, or a member with no business -// role at all — sees ONLY their own pending approvals. That default is +// A role that is not listed — asset_owner, executive, viewer, +// internal_control, risk_owner, or a member with no business role at all — +// sees ONLY their own pending approvals, unless they are an organisation +// admin, who sees every category (allCategories). That default is // intentional and least-privilege: showing someone an "action" they have no // permission to complete is worse than showing them nothing, because the item // looks like a task and behaves like a locked door. var roleCategories = map[domain.BusinessRoleKey][]int{ - domain.BusinessRoleRSSI: {RankCriticalRisk, RankOpenIncident}, - domain.BusinessRoleRiskManager: {RankOverdueMitigation, RankCriticalRisk}, + domain.BusinessRoleRSSI: {RankCriticalRisk, RankOpenIncident, RankVulnerabilitySLA, RankMitigationReview}, + domain.BusinessRoleRiskManager: {RankOverdueMitigation, RankCriticalRisk, RankVendorFollowUp, RankMitigationReview}, domain.BusinessRoleAuditor: {RankExpiringEvidence}, - domain.BusinessRoleComplianceOfficer: {RankExpiringEvidence, RankOverdueRemediation}, + domain.BusinessRoleComplianceOfficer: {RankExpiringEvidence, RankOverdueRemediation, RankVendorFollowUp}, + domain.BusinessRoleSecurityAnalyst: {RankVulnerabilitySLA, RankOpenIncident}, + domain.BusinessRoleDSI: {RankVulnerabilitySLA}, +} + +// allCategories is what an organisation admin or owner sees (#902, owner +// decision 2026-10-08): the whole tenant's outstanding work, as the redesign's +// RSSI view draws it. An admin can already open every one of these records, so +// none of them is a locked door. +var allCategories = []int{ + RankOverdueMitigation, RankCriticalRisk, RankOpenIncident, RankExpiringEvidence, + RankOverdueRemediation, RankVulnerabilitySLA, RankVendorFollowUp, RankMitigationReview, } // UseCase is the read-only aggregation. It writes nothing, so it needs no @@ -206,7 +247,11 @@ func (uc *UseCase) GetActionCenter(caller Caller, limit, offset int) (*Result, e return nil, err } - items, err := uc.gather(caller, role, now) + cats := roleCategories[role] + if caller.IsAdmin { + cats = allCategories + } + items, err := uc.gather(caller, role, cats, now) if err != nil { return nil, err } @@ -267,8 +312,8 @@ func paginate(items []ActionItem, limit, offset int) []ActionItem { return page } -func allows(role domain.BusinessRoleKey, rank int) bool { - for _, r := range roleCategories[role] { +func allows(cats []int, rank int) bool { + for _, r := range cats { if r == rank { return true } @@ -280,10 +325,10 @@ func allows(role domain.BusinessRoleKey, rank int) bool { // officer never triggers the incident scan, which keeps the endpoint's cost // proportional to what the caller is allowed to see rather than to the size of // the tenant. -func (uc *UseCase) gather(caller Caller, role domain.BusinessRoleKey, now time.Time) ([]ActionItem, error) { +func (uc *UseCase) gather(caller Caller, role domain.BusinessRoleKey, cats []int, now time.Time) ([]ActionItem, error) { items := make([]ActionItem, 0, 32) - if allows(role, RankOverdueMitigation) { + if allows(cats, RankOverdueMitigation) { rows, err := uc.repo.OverdueMitigations(caller.TenantID, now, perSourceFetchLimit) if err != nil { return nil, err @@ -293,7 +338,7 @@ func (uc *UseCase) gather(caller Caller, role domain.BusinessRoleKey, now time.T } } - if allows(role, RankCriticalRisk) { + if allows(cats, RankCriticalRisk) { rows, err := uc.repo.CriticalRisksWithoutActiveMitigation(caller.TenantID, CriticalScoreThreshold, perSourceFetchLimit) if err != nil { return nil, err @@ -333,7 +378,7 @@ func (uc *UseCase) gather(caller Caller, role domain.BusinessRoleKey, now time.T items = append(items, approvalItem(req)) } - if allows(role, RankOpenIncident) { + if allows(cats, RankOpenIncident) { rows, err := uc.repo.OpenIncidents(caller.TenantID, perSourceFetchLimit) if err != nil { return nil, err @@ -343,7 +388,7 @@ func (uc *UseCase) gather(caller Caller, role domain.BusinessRoleKey, now time.T } } - if allows(role, RankExpiringEvidence) { + if allows(cats, RankExpiringEvidence) { rows, err := uc.repo.ExpiringEvidence(caller.TenantID, now, perSourceFetchLimit) if err != nil { return nil, err @@ -360,7 +405,7 @@ func (uc *UseCase) gather(caller Caller, role domain.BusinessRoleKey, now time.T } } - if allows(role, RankOverdueRemediation) { + if allows(cats, RankOverdueRemediation) { rows, err := uc.repo.OverdueRemediationPlans(caller.TenantID, now, perSourceFetchLimit) if err != nil { return nil, err @@ -370,6 +415,36 @@ func (uc *UseCase) gather(caller Caller, role domain.BusinessRoleKey, now time.T } } + if allows(cats, RankVulnerabilitySLA) { + rows, err := uc.repo.VulnerabilitiesDueBy(caller.TenantID, now.Add(vulnSLAHorizon), perSourceFetchLimit) + if err != nil { + return nil, err + } + for i := range rows { + items = append(items, vulnerabilityItem(&rows[i])) + } + } + + if allows(cats, RankVendorFollowUp) { + rows, err := uc.repo.VendorAssessmentsDueBy(caller.TenantID, now.Add(vendorHorizon), perSourceFetchLimit) + if err != nil { + return nil, err + } + for i := range rows { + items = append(items, vendorItem(&rows[i])) + } + } + + if allows(cats, RankMitigationReview) { + rows, err := uc.repo.MitigationsInReview(caller.TenantID, perSourceFetchLimit) + if err != nil { + return nil, err + } + for i := range rows { + items = append(items, reviewItem(&rows[i])) + } + } + return items, nil } @@ -396,6 +471,7 @@ func mitigationItem(m *domain.Mitigation, now time.Time) ActionItem { SubjectResourceID: m.ID.String(), DeepLink: MitigationLink(m.ID.String()), DueAt: m.DueDate, + Meta: map[string]string{"status": string(m.Status)}, CategoryRank: RankOverdueMitigation, TenantID: m.TenantID, sortA: -overdue, // most overdue first @@ -465,6 +541,7 @@ func incidentItem(i *domain.Incident) ActionItem { SubjectResourceType: "incident", SubjectResourceID: id, DeepLink: IncidentLink(id), + Meta: map[string]string{"status": i.Status, "severity": i.Severity}, CategoryRank: RankOpenIncident, // Incident.TenantID is a string on this table, unlike every other model // here. Parsed rather than reinterpreted so a malformed value surfaces as @@ -521,8 +598,82 @@ func remediationItem(p *domain.RemediationPlan, now time.Time) ActionItem { SubjectResourceID: p.ID.String(), DeepLink: RemediationLink(p.ID.String()), DueAt: p.DueDate, + Meta: map[string]string{"status": string(p.Status)}, CategoryRank: RankOverdueRemediation, TenantID: p.TenantID, sortA: -overdue, } } + +func vulnerabilityItem(v *domain.Vulnerability) ActionItem { + due := 0.0 + if v.SLADueAt != nil { + due = float64(v.SLADueAt.Unix()) + } + kev := 1.0 + if v.KEV { + kev = 0 // KEV first at equal deadline + } + meta := map[string]string{"status": string(v.Status), "severity": string(v.Severity)} + if v.CVEID != "" { + meta["cve_id"] = v.CVEID + } + if v.AssetName != "" { + meta["asset_name"] = v.AssetName + } + if v.KEV { + meta["kev"] = "true" + } + return ActionItem{ + ID: "vulnerability:" + v.ID.String(), + Type: ItemTypeVulnerabilitySLA, + Title: v.Title, + SubjectResourceType: "vulnerability", + SubjectResourceID: v.ID.String(), + DeepLink: VulnerabilityLink(v.ID.String()), + DueAt: v.SLADueAt, + CategoryRank: RankVulnerabilitySLA, + TenantID: v.TenantID, + Meta: meta, + sortA: due, // earliest deadline first + sortB: kev, + } +} + +func vendorItem(f *VendorFollowUp) ActionItem { + a := &f.Assessment + due := a.DueAt + return ActionItem{ + ID: "vendor_assessment:" + a.ID.String(), + Type: ItemTypeVendorFollowUp, + Title: f.VendorName, + SubjectResourceType: "vendor_assessment", + SubjectResourceID: a.ID.String(), + DeepLink: VendorAssessmentLink(a.VendorAssetID.String(), a.ID.String()), + DueAt: &due, + CategoryRank: RankVendorFollowUp, + TenantID: a.TenantID, + Meta: map[string]string{ + "status": string(a.Status), + "sent_at": a.SentAt.UTC().Format(time.RFC3339), + }, + sortA: float64(a.DueAt.Unix()), + } +} + +func reviewItem(m *domain.Mitigation) ActionItem { + updated := float64(m.UpdatedAt.Unix()) + return ActionItem{ + ID: "mitigation_review:" + m.ID.String(), + Type: ItemTypeMitigationReview, + Title: m.Title, + SubjectResourceType: "mitigation", + SubjectResourceID: m.ID.String(), + DeepLink: MitigationLink(m.ID.String()), + DueAt: m.DueDate, + CategoryRank: RankMitigationReview, + TenantID: m.TenantID, + Meta: map[string]string{"status": string(m.Status)}, + sortA: updated, // waiting longest first + } +} diff --git a/backend/internal/application/actioncenter/usecase_redesign_test.go b/backend/internal/application/actioncenter/usecase_redesign_test.go new file mode 100644 index 00000000..aa9f605e --- /dev/null +++ b/backend/internal/application/actioncenter/usecase_redesign_test.go @@ -0,0 +1,115 @@ +// 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 actioncenter + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/opendefender/openrisk/internal/domain" +) + +// seedRedesign adds one item of each #902 category. +func seedRedesign(s *stubRepo) { + seedAll(s) + s.vulns = []domain.Vulnerability{{ + ID: uuid.New(), TenantID: tenantA, Title: "SSL-VPN RCE", CVEID: "CVE-2026-21873", + Severity: domain.VulnSeverityCritical, KEV: true, AssetName: "fw-edge-01", + Status: domain.VulnStatusOpen, SLADueAt: ptime(testNow.AddDate(0, 0, 1)), + }} + s.vendors = []VendorFollowUp{{ + Assessment: domain.VendorAssessment{ + ID: uuid.New(), TenantID: tenantA, VendorAssetID: uuid.New(), + Status: domain.VendorAssessmentSent, DueAt: testNow.AddDate(0, 0, -2), SentAt: testNow.AddDate(0, 0, -9), + }, + VendorName: "PayLink Afrique", + }} + s.reviews = []domain.Mitigation{{ + ID: uuid.New(), TenantID: tenantA, Title: "Immutable backups", + Status: domain.MitigationReview, DueDate: ptime(testNow.AddDate(0, 0, 2)), + }} +} + +// An organisation admin sees every category of the tenant (owner decision, +// 2026-10-08) even without a business role. +func TestActionCenter_AdminSeesEverything_Success(t *testing.T) { + s := newStub() + seedRedesign(s) + c := caller() + c.IsAdmin = true + res, err := newUC(s).GetActionCenter(c, 50, 0) + require.NoError(t, err) + got := types(res.Data) + for _, want := range []ItemType{ + ItemTypeOverdueMitigation, ItemTypeCriticalRisk, ItemTypeOpenIncident, ItemTypeExpiringEvidence, + ItemTypeOverdueRemediation, ItemTypeVulnerabilitySLA, ItemTypeVendorFollowUp, ItemTypeMitigationReview, + } { + assert.Contains(t, got, want) + } +} + +// The new rows carry the facts the row prints and a route that exists. +func TestActionCenter_RedesignItems_MetaAndLinks(t *testing.T) { + s := newStub() + seedRedesign(s) + c := caller() + c.IsAdmin = true + res, err := newUC(s).GetActionCenter(c, 50, 0) + require.NoError(t, err) + by := map[ItemType]ActionItem{} + for _, it := range res.Data { + by[it.Type] = it + } + v := by[ItemTypeVulnerabilitySLA] + assert.Equal(t, "CVE-2026-21873", v.Meta["cve_id"]) + assert.Equal(t, "fw-edge-01", v.Meta["asset_name"]) + assert.Equal(t, "true", v.Meta["kev"]) + assert.Equal(t, "open", v.Meta["status"]) + assert.Contains(t, v.DeepLink, "/vulnerabilities?drawer=vulnerability&entity=") + + vd := by[ItemTypeVendorFollowUp] + assert.Equal(t, "PayLink Afrique", vd.Title) + assert.Contains(t, vd.DeepLink, "/vendors/") + assert.Contains(t, vd.DeepLink, "/assessments/") + + rv := by[ItemTypeMitigationReview] + assert.Equal(t, "REVIEW", rv.Meta["status"]) + assert.Contains(t, rv.DeepLink, "/risks/mitigations/") +} + +// A role keeps its own perimeter: an auditor never triggers the vulnerability, +// vendor or review scans. +func TestActionCenter_RedesignCategories_Unauthorized(t *testing.T) { + s := newStub() + seedRedesign(s) + s.role = domain.BusinessRoleAuditor + res, err := newUC(s).GetActionCenter(caller(), 50, 0) + require.NoError(t, err) + assert.False(t, s.called["vulns"]) + assert.False(t, s.called["vendors"]) + assert.False(t, s.called["reviews"]) + assert.NotContains(t, types(res.Data), ItemTypeVulnerabilitySLA) +} + +// A security analyst gets the vulnerability deadlines; nothing new for a +// member with no role and no admin right. +func TestActionCenter_RedesignCategories_ByRole(t *testing.T) { + s := newStub() + seedRedesign(s) + s.role = domain.BusinessRoleSecurityAnalyst + res, err := newUC(s).GetActionCenter(caller(), 50, 0) + require.NoError(t, err) + assert.Contains(t, types(res.Data), ItemTypeVulnerabilitySLA) + + s2 := newStub() + seedRedesign(s2) + res2, err := newUC(s2).GetActionCenter(caller(), 50, 0) + require.NoError(t, err) + assert.Empty(t, res2.Data, "no role, not admin: only own approvals, none seeded") +} diff --git a/backend/internal/application/actioncenter/usecase_test.go b/backend/internal/application/actioncenter/usecase_test.go index 507492a8..5e811270 100644 --- a/backend/internal/application/actioncenter/usecase_test.go +++ b/backend/internal/application/actioncenter/usecase_test.go @@ -35,6 +35,9 @@ type stubRepo struct { incidents []domain.Incident evidence []domain.Evidence remediation []domain.RemediationPlan + vulns []domain.Vulnerability + vendors []VendorFollowUp + reviews []domain.Mitigation called map[string]bool err error @@ -72,6 +75,19 @@ func (s *stubRepo) OverdueRemediationPlans(_ uuid.UUID, _ time.Time, _ int) ([]d return s.remediation, s.err } +func (s *stubRepo) VulnerabilitiesDueBy(_ uuid.UUID, _ time.Time, _ int) ([]domain.Vulnerability, error) { + s.mark("vulns") + return s.vulns, s.err +} +func (s *stubRepo) VendorAssessmentsDueBy(_ uuid.UUID, _ time.Time, _ int) ([]VendorFollowUp, error) { + s.mark("vendors") + return s.vendors, s.err +} +func (s *stubRepo) MitigationsInReview(_ uuid.UUID, _ int) ([]domain.Mitigation, error) { + s.mark("reviews") + return s.reviews, s.err +} + var ( tenantA = uuid.MustParse("11111111-1111-1111-1111-111111111111") userA = uuid.MustParse("22222222-2222-2222-2222-222222222222") diff --git a/backend/internal/handler/action_center_handler_test.go b/backend/internal/handler/action_center_handler_test.go index 0f5a6266..092c08db 100644 --- a/backend/internal/handler/action_center_handler_test.go +++ b/backend/internal/handler/action_center_handler_test.go @@ -44,6 +44,15 @@ func (s *acStubRepo) ExpiringEvidence(_ uuid.UUID, _ time.Time, _ int) ([]domain func (s *acStubRepo) OverdueRemediationPlans(_ uuid.UUID, _ time.Time, _ int) ([]domain.RemediationPlan, error) { return nil, nil } +func (s *acStubRepo) VulnerabilitiesDueBy(_ uuid.UUID, _ time.Time, _ int) ([]domain.Vulnerability, error) { + return nil, nil +} +func (s *acStubRepo) VendorAssessmentsDueBy(_ uuid.UUID, _ time.Time, _ int) ([]actioncenterapp.VendorFollowUp, error) { + return nil, nil +} +func (s *acStubRepo) MitigationsInReview(_ uuid.UUID, _ int) ([]domain.Mitigation, error) { + return nil, nil +} // setupActionCenterApp mounts the route behind a stand-in for the auth // middleware. `authenticated=false` populates nothing, which is exactly what the diff --git a/backend/internal/infrastructure/repository/actioncenter_redesign_repository_test.go b/backend/internal/infrastructure/repository/actioncenter_redesign_repository_test.go new file mode 100644 index 00000000..29487409 --- /dev/null +++ b/backend/internal/infrastructure/repository/actioncenter_redesign_repository_test.go @@ -0,0 +1,90 @@ +// 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 repository + +import ( + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/opendefender/openrisk/internal/domain" +) + +// #902: the three new action-center sources, each read for one tenant only. + +func TestActionCenterRepo_VulnerabilitiesDueBy(t *testing.T) { + db := newActionCenterTestDB(t) + repo := NewActionCenterRepository(db) + now := acNow() + add := func(tenant uuid.UUID, title string, sev domain.VulnSeverity, seen time.Time, status domain.VulnStatus) { + v := domain.Vulnerability{ID: uuid.New(), TenantID: tenant, Title: title, Severity: sev, + FirstSeen: seen, LastSeen: seen, Status: status, Source: "manual"} + require.NoError(t, db.Create(&v).Error) + } + add(tA, "overdue critical", domain.VulnSeverityCritical, now.AddDate(0, 0, -10), domain.VulnStatusOpen) + add(tA, "due in 4 days", domain.VulnSeverityHigh, now.AddDate(0, 0, -10), domain.VulnStatusTriaged) + add(tA, "due in a month", domain.VulnSeverityLow, now.AddDate(0, 0, -20), domain.VulnStatusOpen) + add(tA, "fixed", domain.VulnSeverityCritical, now.AddDate(0, 0, -10), domain.VulnStatusRemediated) + add(tB, "other tenant", domain.VulnSeverityCritical, now.AddDate(0, 0, -10), domain.VulnStatusOpen) + + rows, err := repo.VulnerabilitiesDueBy(tA, now.AddDate(0, 0, 7), 50) + require.NoError(t, err) + titles := []string{} + for _, r := range rows { + titles = append(titles, r.Title) + } + assert.Equal(t, []string{"overdue critical", "due in 4 days"}, titles, "deadline order, open work only, this tenant only") + + _, err = repo.VulnerabilitiesDueBy(uuid.Nil, now, 50) + assert.ErrorIs(t, err, domain.ErrForbidden) +} + +func TestActionCenterRepo_VendorAssessmentsDueBy(t *testing.T) { + db := newActionCenterTestDB(t) + repo := NewActionCenterRepository(db) + now := acNow() + vendorA := domain.Asset{ID: uuid.New(), TenantID: tA, Name: "PayLink Afrique"} + vendorB := domain.Asset{ID: uuid.New(), TenantID: tB, Name: "Not yours"} + require.NoError(t, db.Create(&vendorA).Error) + require.NoError(t, db.Create(&vendorB).Error) + add := func(tenant, vendor uuid.UUID, status domain.VendorAssessmentStatus, due time.Time) { + a := domain.VendorAssessment{ID: uuid.New(), TenantID: tenant, VendorAssetID: vendor, TemplateID: uuid.New(), + TemplateVersion: 1, Status: status, OwnerUserID: uuid.New(), ContactEmail: "x@y.z", ContactLanguage: "fr", + DueAt: due, SentBy: uuid.New(), SentAt: now.AddDate(0, 0, -9)} + require.NoError(t, db.Create(&a).Error) + } + add(tA, vendorA.ID, domain.VendorAssessmentSent, now.AddDate(0, 0, -2)) + add(tA, vendorA.ID, domain.VendorAssessmentSubmitted, now.AddDate(0, 0, -2)) + add(tA, vendorA.ID, domain.VendorAssessmentInProgress, now.AddDate(0, 0, 30)) + // A row of tenant A pointing at tenant B's vendor must not surface B's name. + add(tA, vendorB.ID, domain.VendorAssessmentSent, now.AddDate(0, 0, -1)) + add(tB, vendorB.ID, domain.VendorAssessmentSent, now.AddDate(0, 0, -1)) + + rows, err := repo.VendorAssessmentsDueBy(tA, now.AddDate(0, 0, 7), 50) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "PayLink Afrique", rows[0].VendorName) +} + +func TestActionCenterRepo_MitigationsInReview(t *testing.T) { + db := newActionCenterTestDB(t) + repo := NewActionCenterRepository(db) + add := func(tenant uuid.UUID, title string, st domain.MitigationStatus) { + m := domain.Mitigation{ID: uuid.New(), TenantID: tenant, RiskID: uuid.New(), Title: title, Status: st, CreatedBy: uuid.New()} + require.NoError(t, db.Create(&m).Error) + } + add(tA, "review me", domain.MitigationReview) + add(tA, "doing", domain.MitigationInProgress) + add(tB, "theirs", domain.MitigationReview) + + rows, err := repo.MitigationsInReview(tA, 50) + require.NoError(t, err) + require.Len(t, rows, 1) + assert.Equal(t, "review me", rows[0].Title) +} diff --git a/backend/internal/infrastructure/repository/actioncenter_repository.go b/backend/internal/infrastructure/repository/actioncenter_repository.go index 7bd1792b..4758e845 100644 --- a/backend/internal/infrastructure/repository/actioncenter_repository.go +++ b/backend/internal/infrastructure/repository/actioncenter_repository.go @@ -155,7 +155,9 @@ func (r *ActionCenterRepository) OpenIncidents(tenantID uuid.UUID, limit int) ([ var rows []domain.Incident err := r.db. Where("tenant_id = ?", tenantID.String()). - Where("status IN ?", []string{"open", "investigating"}). + // "in_progress" is the status the incident API actually writes for an + // incident under way; "investigating" is kept for older rows (#902). + Where("status IN ?", []string{"open", "in_progress", "investigating"}). Order("created_at ASC"). Limit(limit). Find(&rows).Error @@ -198,4 +200,81 @@ func (r *ActionCenterRepository) OverdueRemediationPlans(tenantID uuid.UUID, now // Compile-time proof that the GORM implementation still satisfies the port the // use case declares. Without this the two drift apart silently until main.go // fails to build, which is a worse place to find out. +// VulnerabilitiesDueBy returns findings still to fix whose remediation deadline +// falls on or before `by` — overdue ones included (#902). +func (r *ActionCenterRepository) VulnerabilitiesDueBy(tenantID uuid.UUID, by time.Time, limit int) ([]domain.Vulnerability, error) { + if err := guard(tenantID); err != nil { + return nil, err + } + var rows []domain.Vulnerability + err := r.db. + Where("tenant_id = ?", tenantID). + Where("status IN ?", []string{ + string(domain.VulnStatusOpen), string(domain.VulnStatusTriaged), string(domain.VulnStatusInRemediation), + }). + Where("sla_due_at IS NOT NULL AND sla_due_at <= ?", by). + Order("sla_due_at ASC"). + Limit(limit). + Find(&rows).Error + return rows, err +} + +// VendorAssessmentsDueBy returns questionnaires sent and not answered whose +// due date falls on or before `by`, with the vendor's name (#902). The vendor +// is an asset of this tenant; the join repeats the tenant on both sides. +func (r *ActionCenterRepository) VendorAssessmentsDueBy(tenantID uuid.UUID, by time.Time, limit int) ([]actioncenter.VendorFollowUp, error) { + if err := guard(tenantID); err != nil { + return nil, err + } + var rows []domain.VendorAssessment + err := r.db. + Where("tenant_id = ?", tenantID). + Where("status IN ?", []string{string(domain.VendorAssessmentSent), string(domain.VendorAssessmentInProgress)}). + Where("due_at <= ?", by). + Order("due_at ASC"). + Limit(limit). + Find(&rows).Error + if err != nil || len(rows) == 0 { + return nil, err + } + ids := make([]uuid.UUID, 0, len(rows)) + for _, a := range rows { + ids = append(ids, a.VendorAssetID) + } + var vendors []domain.Asset + if err := r.db.Select("id", "name"). + Where("tenant_id = ? AND id IN ?", tenantID, ids). + Find(&vendors).Error; err != nil { + return nil, err + } + names := make(map[uuid.UUID]string, len(vendors)) + for _, v := range vendors { + names[v.ID] = v.Name + } + out := make([]actioncenter.VendorFollowUp, 0, len(rows)) + for _, a := range rows { + name, ok := names[a.VendorAssetID] + if !ok { + continue // vendor gone or not this tenant's: no row to send anyone to + } + out = append(out, actioncenter.VendorFollowUp{Assessment: a, VendorName: name}) + } + return out, nil +} + +// MitigationsInReview returns plans waiting for someone to validate them (#902). +func (r *ActionCenterRepository) MitigationsInReview(tenantID uuid.UUID, limit int) ([]domain.Mitigation, error) { + if err := guard(tenantID); err != nil { + return nil, err + } + var rows []domain.Mitigation + err := r.db. + Where("tenant_id = ?", tenantID). + Where("status = ?", string(domain.MitigationReview)). + Order("updated_at ASC"). + Limit(limit). + Find(&rows).Error + return rows, err +} + var _ actioncenter.Repository = (*ActionCenterRepository)(nil) diff --git a/backend/internal/infrastructure/repository/actioncenter_repository_test.go b/backend/internal/infrastructure/repository/actioncenter_repository_test.go index d907073b..1576d6e7 100644 --- a/backend/internal/infrastructure/repository/actioncenter_repository_test.go +++ b/backend/internal/infrastructure/repository/actioncenter_repository_test.go @@ -55,6 +55,9 @@ func newActionCenterTestDB(t *testing.T) *gorm.DB { // fixture carries the table rather than disabling the hook, so seeding // exercises the same write path production does. `CREATE TABLE risk_histories (id TEXT PRIMARY KEY)`, + `CREATE TABLE vulnerabilities (id TEXT PRIMARY KEY)`, + `CREATE TABLE vendor_assessments (id TEXT PRIMARY KEY)`, + `CREATE TABLE assets (id TEXT PRIMARY KEY)`, } { if err := db.Exec(ddl).Error; err != nil { t.Fatalf("create table: %v", err) @@ -72,6 +75,9 @@ func newActionCenterTestDB(t *testing.T) *gorm.DB { {"remediation_plans", &domain.RemediationPlan{}}, {"organization_members", &domain.OrganizationMember{}}, {"risk_histories", &domain.RiskHistory{}}, + {"vulnerabilities", &domain.Vulnerability{}}, + {"vendor_assessments", &domain.VendorAssessment{}}, + {"assets", &domain.Asset{}}, } { if err := sqliteschema.Reconcile(db, m.table, m.model); err != nil { t.Fatalf("reconcile %s: %v", m.table, err) diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 137ea348..9767ca35 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -3627,12 +3627,15 @@ components: - open_incident - expiring_evidence - overdue_remediation + - vulnerability_sla + - vendor_followup + - mitigation_review title: type: string description: The underlying record's own title, never a generated sentence. subject_resource_type: type: string - enum: [mitigation, risk, approval_request, incident, evidence, remediation_plan] + enum: [mitigation, risk, approval_request, incident, evidence, remediation_plan, vulnerability, vendor_assessment] subject_resource_id: type: string description: The record's own id. A uuid for every type except incident, which is an integer rendered as a string. @@ -3652,15 +3655,24 @@ components: category_rank: type: integer minimum: 1 - maximum: 6 + maximum: 9 description: > 1 overdue_mitigation, 2 critical_risk, 3 pending_approval, - 4 open_incident, 5 expiring_evidence, 6 overdue_remediation. + 4 open_incident, 5 expiring_evidence, 6 overdue_remediation, + 7 vulnerability_sla, 8 vendor_followup, 9 mitigation_review. This is the primary sort key, exposed so a client can group without re-deriving the rule. tenant_id: type: string format: uuid + meta: + type: object + additionalProperties: + type: string + description: > + Facts about the record shown beside its title, keyed by name: + status, severity, cve_id, asset_name, kev ("true"), sent_at. + Facts only, never a generated sentence; absent keys are unknown. ActionCenterResponse: type: object required: [data, generated_at, limit, offset, total] From 091a6c5e6ec9e7ceb0f84cb6ff054700350fac53 Mon Sep 17 00:00:00 2001 From: alex-dembele Date: Thu, 8 Oct 2026 13:43:07 +0100 Subject: [PATCH 3/3] feat(action-center): rebuild the action center on the October 2026 redesign (#902) One column at 1000 px: how much is waiting and how much is late, kind chips with their counts (approvals, vulnerabilities, risks and plans, compliance, third parties, and incidents when there are some), then the work grouped by deadline: overdue, this week, later, no date. Within a group the server's order stands. Each row has the type's tile, the record's title, a line of facts worded from the server's meta (KEV, SLA breached, CVE, asset, days without a vendor answer), the due pill, one verb that opens the record and, when the work can be finished from the list, a tick. The tick does the real work after a 5 s undo window, through the record's own endpoint (mitigation to Done, finding to Remediated, incident to Resolved, remediation plan to Completed); approvals, critical risks, evidence and vendors have none (owner decision). The page reads the API's full 100-item page so the chips and groups count everything; the pager takes over past that. The deep-link guard and its tests are unchanged, and the dashboard's priority list shares the icons, wording and due pill. useSoftDelete takes a failure message, so a failed completion no longer says the delete failed. The generated API types are regenerated from docs/openapi.yaml, which also brings in schema changes already on master. Signed-off-by: alex-dembele --- .../action-center/ActionCenterPage.tsx | 281 +++-- .../features/action-center/ActionItemRow.tsx | 124 +- .../src/features/action-center/DuePill.tsx | 35 + .../__tests__/actionCenterPage.test.tsx | 36 +- .../__tests__/actionCenterRedesign.test.tsx | 178 +++ .../src/features/action-center/actionFacts.ts | 67 + .../src/features/action-center/actionKinds.ts | 98 ++ .../src/features/action-center/actionLinks.ts | 5 + .../src/features/action-center/dueDate.ts | 10 + .../features/action-center/useActionItems.ts | 4 +- .../__tests__/consoleDashboard.test.tsx | 2 +- .../dashboard/console/PriorityList.tsx | 56 +- .../src/features/dashboard/console/dates.ts | 12 - frontend/src/locales/en.json | 84 +- frontend/src/locales/fr.json | 84 +- frontend/src/shared/useSoftDelete.ts | 10 +- frontend/src/types/openapi.generated.ts | 1124 ++++++++++++++++- 17 files changed, 1911 insertions(+), 299 deletions(-) create mode 100644 frontend/src/features/action-center/DuePill.tsx create mode 100644 frontend/src/features/action-center/__tests__/actionCenterRedesign.test.tsx create mode 100644 frontend/src/features/action-center/actionFacts.ts create mode 100644 frontend/src/features/action-center/actionKinds.ts delete mode 100644 frontend/src/features/dashboard/console/dates.ts diff --git a/frontend/src/features/action-center/ActionCenterPage.tsx b/frontend/src/features/action-center/ActionCenterPage.tsx index f4e4770a..143099de 100644 --- a/frontend/src/features/action-center/ActionCenterPage.tsx +++ b/frontend/src/features/action-center/ActionCenterPage.tsx @@ -1,93 +1,153 @@ // Copyright (c) 2026 OpenDefender Contributors // SPDX-License-Identifier: AGPL-3.0-only // -// The full Action Center, at /action-center (#433). +// The Action Center of the October 2026 redesign (#902). // -// The dashboard panel shows the top of the queue. This is the whole of it. It -// exists because the panel could show eight rows and then tell a user with -// forty outstanding items that thirty-two more were waiting somewhere they -// could not go — a true statement the user was unable to act on. +// One column at 1000 px: a sentence that says how much is waiting and how much +// of it is late, filter chips by kind with their counts, then the work grouped +// by deadline — overdue, this week, later, no date. Within a group the order +// is the server's (category rank, then each category's own key); the client +// only buckets by date and never re-sorts. // -// THE PAGE LIVES IN THE URL. `?page=3` is a real, shareable, back-button-able -// address, not component state. That is the route tree's own rule (see the -// header of shared/routeModel.ts: "every sub-view is a route, never component -// state"), and it is why the pager reads and writes search params rather than -// useState. -// -// It does NOT use DataTable, deliberately. DataTable sorts, filters and pages -// over rows it holds in memory; this list is ordered by the server, paged by the -// server, and must not be re-sorted at any point (#430 AC1, #433 AC4). Handing -// it to a component whose job is to reorder rows would be arguing with the -// server about the one thing the server is authoritative on. +// The page reads up to 100 items, the API's maximum page, so the chips and +// groups count the whole list. Past 100 the pager takes over. -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { useSearchParams } from 'react-router'; +import { useQueryClient } from '@tanstack/react-query'; import { ChevronLeft, ChevronRight, CircleCheck } from 'lucide-react'; import { useI18n, interpolate } from '../../hooks/useI18n'; -import { EmptyState, ErrorState, PageFrame, PageHeader, Skeleton } from '../../shared/ui'; -import { useActionItems, PAGE_LIMIT } from './useActionItems'; -import { linkableItems } from './actionLinks'; +import { ErrorState, PageHeader, Skeleton } from '../../shared/ui'; +import { useSoftDelete } from '../../shared/useSoftDelete'; +import { useActionItems, PAGE_LIMIT as PAGE_SIZE } from './useActionItems'; +import { linkableItems, type LinkableActionItem } from './actionLinks'; import { pageFromParam } from './paging'; import { ActionItemRow } from './ActionItemRow'; +import { FILTER_ORDER, completionFor, filterOf, type ActionFilter } from './actionKinds'; +import { dueGroup, type DueGroup } from './actionFacts'; -const SKELETON_ROWS = 8; +const GROUPS: DueGroup[] = ['late', 'week', 'later', 'none']; export function ActionCenterPage() { const { t } = useI18n(); + const qc = useQueryClient(); const [params, setParams] = useSearchParams(); + const [filter, setFilter] = useState('all'); const page = pageFromParam(params.get('page')); - const offset = (page - 1) * PAGE_LIMIT; - - const { items, total, isLoading, isError, refetch } = useActionItems({ - limit: PAGE_LIMIT, - offset, + const offset = (page - 1) * PAGE_SIZE; + const { items, total, isLoading, isError, refetch } = useActionItems({ limit: PAGE_SIZE, offset }); + const linkable = useMemo(() => linkableItems(items), [items]); + + const done = useSoftDelete({ + idOf: (row) => row.item.id, + message: (row) => interpolate(t('actionCenter.doneToast'), { title: row.item.title }), + failureMessage: () => t('actionCenter.doneFailed'), + onCommit: async (id) => { + const row = linkable.find((r) => r.item.id === id); + const complete = row ? completionFor(row.item) : undefined; + if (!complete) return; + await complete(); + // Wait for the lists to re-read before the row is un-hidden, so it does + // not flash back between the call and the refetch. + await Promise.all([ + qc.invalidateQueries({ queryKey: ['action-center'] }), + qc.invalidateQueries({ queryKey: ['nav-counts'] }), + ]); + }, }); - // Filtering, never sorting: an item whose deep_link does not resolve is - // dropped and logged rather than rendered as a row that goes nowhere. Order - // is the server's, on this page as on every other. - const rows = useMemo(() => linkableItems(items), [items]); + const rows = useMemo( + () => linkable.filter((r) => !done.pending.has(r.item.id)), + [linkable, done.pending], + ); - const pageCount = Math.max(1, Math.ceil(total / PAGE_LIMIT)); - const firstOnPage = total === 0 ? 0 : offset + 1; - const lastOnPage = Math.min(offset + rows.length, total); + const counts = useMemo(() => { + const c: Record = { + all: rows.length, + approval: 0, + vuln: 0, + plans: 0, + incident: 0, + compliance: 0, + vendor: 0, + }; + for (const r of rows) { + const f = filterOf(r.item); + if (f) c[f] += 1; + } + return c; + }, [rows]); + + const shown = filter === 'all' ? rows : rows.filter((r) => filterOf(r.item) === filter); + const grouped = GROUPS.map((g) => ({ g, rows: shown.filter((r) => dueGroup(r.item) === g) })).filter( + (x) => x.rows.length > 0, + ); + const late = rows.filter((r) => dueGroup(r.item) === 'late').length; + const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE)); const goTo = (next: number) => { const clamped = Math.min(Math.max(1, next), pageCount); const updated = new URLSearchParams(params); - // Page 1 is the bare URL. Carrying ?page=1 around makes two addresses for - // one view, which is the sort of thing that ends up in a shared link. if (clamped === 1) updated.delete('page'); else updated.set('page', String(clamped)); setParams(updated); }; - - // The pager stays mounted while a page change is in flight. Gating it on - // `!isLoading` made the control disappear under the cursor on every click: - // moving to page 2 is a new query key, so React Query reports isLoading again - // and the whole pager unmounted mid-interaction. It stays put and goes - // disabled instead — and while the count is unknown it renders a dash rather - // than a number it does not have, because "2/1" would be worse than "2/—". - const countKnown = !isLoading && !isError; - const showPager = !isError && (total > PAGE_LIMIT || page > 1); + const ready = !isLoading && !isError; + const nothingAtAll = rows.length === 0 && page === 1; return ( - - 0 ? String(total) : null} - /> - -

{t('actionCenter.subtitle')}

+
+
+ + {t('actionCenter.summary', { count: total })} + {late > 0 && ` · ${t('actionCenter.summaryLate', { count: late })}`} + + ) : ( +
) : ( shown.map(({ item: a, href }) => { - const Icon = ICON[a.type] ?? ShieldAlert; + const Icon = iconFor(a.type); return ( {a.title} - {a.type in ICON - ? t(`dashboard.priorities.type.${a.type}`) - : t('dashboard.priorities.unknownType')} + {kindOf(a.type) + ? t(`actionCenter.kind.${a.type}`) + : t('actionCenter.types.unknown')} {a.due_at && } @@ -103,28 +86,3 @@ export function PriorityList({ ); } - -function DuePill({ iso }: { iso: string }) { - const { t, locale } = useI18n(); - const n = daysUntil(iso); - let label: string; - let style: { background: string; color: string }; - if (n < 0) { - label = t('dashboard.priorities.overdue', { count: -n }); - style = { background: 'var(--danger-surface)', color: 'var(--danger-text)' }; - } else if (n === 0) { - label = t('dashboard.priorities.today'); - style = { background: 'var(--warning-surface)', color: 'var(--warning-text)' }; - } else if (n <= 7) { - label = n === 1 ? t('dashboard.priorities.tomorrow') : t('dashboard.priorities.inDays', { count: n }); - style = { background: 'var(--surface-3)', color: 'var(--fg-secondary)' }; - } else { - label = formatDate(locale, iso, { day: 'numeric', month: 'short' }); - style = { background: 'var(--surface-3)', color: 'var(--fg-muted)' }; - } - return ( - - {label} - - ); -} diff --git a/frontend/src/features/dashboard/console/dates.ts b/frontend/src/features/dashboard/console/dates.ts deleted file mode 100644 index 5b46219f..00000000 --- a/frontend/src/features/dashboard/console/dates.ts +++ /dev/null @@ -1,12 +0,0 @@ -// Copyright (c) 2026 OpenDefender Contributors -// SPDX-License-Identifier: AGPL-3.0-only - -const DAY = 86_400_000; - -/** Whole calendar days from today to `iso`, negative when past. */ -export function daysUntil(iso: string, now = new Date()): number { - const d = new Date(iso); - const a = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate()); - const b = Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()); - return Math.round((b - a) / DAY); -} diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 44f124ab..95c0cfc4 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -391,8 +391,77 @@ "open_incident": "Open incident", "expiring_evidence": "Expiring evidence", "overdue_remediation": "Overdue remediation", - "unknown": "Action required" - } + "unknown": "Action required", + "vulnerability_sla": "Vulnerability deadline", + "vendor_followup": "Vendor follow-up", + "mitigation_review": "Mitigation to validate" + }, + "summary": { + "exact": { + "0": "Nothing is waiting for you" + }, + "one": "{count} action is waiting for you", + "other": "{count} actions are waiting for you" + }, + "summaryLate": "{count} overdue", + "filters": { + "all": "All", + "approval": "Approvals", + "vuln": "Vulnerabilities", + "plans": "Risks and plans", + "incident": "Incidents", + "compliance": "Compliance", + "vendor": "Third parties", + "label": "Filter by type" + }, + "groups": { + "late": "Overdue", + "week": "This week", + "later": "Later", + "none": "No due date" + }, + "kind": { + "overdue_mitigation": "Mitigation", + "mitigation_review": "Mitigation", + "critical_risk": "Risk", + "pending_approval": "Approval", + "open_incident": "Incident", + "expiring_evidence": "Evidence", + "overdue_remediation": "Remediation", + "vulnerability_sla": "Vulnerability", + "vendor_followup": "Third party" + }, + "cta": { + "overdue_mitigation": "Open", + "mitigation_review": "Validate", + "critical_risk": "Review", + "pending_approval": "Examine", + "open_incident": "Open", + "expiring_evidence": "Renew", + "overdue_remediation": "Open", + "vulnerability_sla": "Open", + "vendor_followup": "Follow up", + "unknown": "Open" + }, + "fact": { + "kev": "KEV", + "slaBreached": "SLA breached", + "slaDue": "SLA running", + "overdue": "past due", + "inReview": "waiting for your validation", + "noPlan": "no active mitigation", + "expired": "expired", + "expiring": "expiring soon", + "unanswered": { + "one": "no answer for {count} day", + "other": "no answer for {count} days" + } + }, + "done": "Mark as done", + "doneToast": "Action done: {title}", + "doneFailed": "The action could not be completed. It is back in the list.", + "emptyFilterTitle": "Nothing waiting in this category", + "emptyFilterBody": "New requests show up here as soon as they are assigned to you." }, "savedViews": { "loading": "Loading your views…", @@ -1113,22 +1182,13 @@ "exec": "Decisions and risks to follow", "link": "Action center", "empty": "Nothing urgent. Actions show up here as soon as a deadline gets close.", - "type": { - "overdue_mitigation": "Mitigation", - "critical_risk": "Risk", - "pending_approval": "Approval", - "open_incident": "Incident", - "expiring_evidence": "Evidence", - "overdue_remediation": "Remediation" - }, "overdue": { "one": "{count} d overdue", "other": "{count} d overdue" }, "today": "Today", "tomorrow": "Tomorrow", - "inDays": "In {count} d", - "unknownType": "Action required" + "inDays": "In {count} d" }, "activity": { "title": "Recent activity", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 5b8dfba2..c0536646 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -391,8 +391,77 @@ "open_incident": "Incident ouvert", "expiring_evidence": "Preuve bientôt expirée", "overdue_remediation": "Remédiation en retard", - "unknown": "Action requise" - } + "unknown": "Action requise", + "vulnerability_sla": "Échéance de vulnérabilité", + "vendor_followup": "Relance fournisseur", + "mitigation_review": "Mitigation à valider" + }, + "summary": { + "exact": { + "0": "Aucune action ne vous attend" + }, + "one": "{count} action vous attend", + "other": "{count} actions vous attendent" + }, + "summaryLate": "{count} en retard", + "filters": { + "all": "Tout", + "approval": "Approbations", + "vuln": "Vulnérabilités", + "plans": "Risques et plans", + "incident": "Incidents", + "compliance": "Conformité", + "vendor": "Tiers", + "label": "Filtrer par type" + }, + "groups": { + "late": "En retard", + "week": "Cette semaine", + "later": "Plus tard", + "none": "Sans échéance" + }, + "kind": { + "overdue_mitigation": "Mitigation", + "mitigation_review": "Mitigation", + "critical_risk": "Risque", + "pending_approval": "Approbation", + "open_incident": "Incident", + "expiring_evidence": "Preuve", + "overdue_remediation": "Remédiation", + "vulnerability_sla": "Vulnérabilité", + "vendor_followup": "Tiers" + }, + "cta": { + "overdue_mitigation": "Ouvrir", + "mitigation_review": "Valider", + "critical_risk": "Revoir", + "pending_approval": "Examiner", + "open_incident": "Ouvrir", + "expiring_evidence": "Renouveler", + "overdue_remediation": "Ouvrir", + "vulnerability_sla": "Ouvrir", + "vendor_followup": "Relancer", + "unknown": "Ouvrir" + }, + "fact": { + "kev": "KEV", + "slaBreached": "SLA dépassé", + "slaDue": "SLA en cours", + "overdue": "échéance dépassée", + "inReview": "attend votre validation", + "noPlan": "aucune mitigation active", + "expired": "expirée", + "expiring": "expire bientôt", + "unanswered": { + "one": "sans réponse depuis {count} jour", + "other": "sans réponse depuis {count} jours" + } + }, + "done": "Marquer comme fait", + "doneToast": "Action terminée : {title}", + "doneFailed": "L'action n'a pas pu être terminée. Elle est revenue dans la liste.", + "emptyFilterTitle": "Rien en attente dans cette catégorie", + "emptyFilterBody": "Les nouvelles demandes apparaîtront ici dès qu'elles vous seront assignées." }, "savedViews": { "loading": "Chargement de vos vues…", @@ -1113,22 +1182,13 @@ "exec": "Décisions et risques à suivre", "link": "Centre d'actions", "empty": "Rien d'urgent. Les actions apparaissent ici dès qu'une échéance approche.", - "type": { - "overdue_mitigation": "Mitigation", - "critical_risk": "Risque", - "pending_approval": "Approbation", - "open_incident": "Incident", - "expiring_evidence": "Preuve", - "overdue_remediation": "Remédiation" - }, "overdue": { "one": "En retard de {count} j", "other": "En retard de {count} j" }, "today": "Aujourd'hui", "tomorrow": "Demain", - "inDays": "Dans {count} j", - "unknownType": "Action requise" + "inDays": "Dans {count} j" }, "activity": { "title": "Activité récente", diff --git a/frontend/src/shared/useSoftDelete.ts b/frontend/src/shared/useSoftDelete.ts index 68e73e93..05b87b38 100644 --- a/frontend/src/shared/useSoftDelete.ts +++ b/frontend/src/shared/useSoftDelete.ts @@ -26,10 +26,12 @@ interface SoftDeleteOptions { message: (item: T, lang: LocaleCode) => string; /** Milliseconds the undo window stays open (default 5000). */ delayMs?: number; + /** Error toast when the commit fails. Defaults to the delete wording. */ + failureMessage?: (lang: LocaleCode) => string; } export function useSoftDelete(opts: SoftDeleteOptions) { - const { onCommit, idOf, message, delayMs = 5000 } = opts; + const { onCommit, idOf, message, delayMs = 5000, failureMessage } = opts; const lang = useUIStore((s) => s.lang); const [pending, setPending] = useState>(new Set()); // Track live timers so we can flush/cleanup on unmount without losing a delete. @@ -55,7 +57,9 @@ export function useSoftDelete(opts: SoftDeleteOptions) { Promise.resolve(onCommit(id)) .then(() => unhide(id)) .catch(() => { - toast.error(lang === 'fr' ? 'Suppression échouée' : 'Delete failed'); + toast.error( + failureMessage ? failureMessage(lang) : lang === 'fr' ? 'Suppression échouée' : 'Delete failed', + ); unhide(id); }); }, delayMs); @@ -73,7 +77,7 @@ export function useSoftDelete(opts: SoftDeleteOptions) { }, }); }, - [idOf, onCommit, message, delayMs, lang, unhide], + [idOf, onCommit, message, delayMs, lang, unhide, failureMessage], ); // On unmount, fire any still-pending deletes so nothing is silently dropped. diff --git a/frontend/src/types/openapi.generated.ts b/frontend/src/types/openapi.generated.ts index e64db27b..7d57b6cc 100644 --- a/frontend/src/types/openapi.generated.ts +++ b/frontend/src/types/openapi.generated.ts @@ -66,8 +66,185 @@ export interface paths { path?: never; cookie?: never; }; - /** Get current user profile */ - get: operations["getCurrentUser"]; + /** Get the caller's own profile and effective preferences */ + get: operations["getMyProfile"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Update the caller's own profile and preferences + * @description Partial update of the session's own user; no field selects another account. An omitted field is unchanged, an empty string clears it (full_name cannot be cleared). Email, username, role and status are not writable here. + */ + patch: operations["updateMyProfile"]; + trace?: never; + }; + "/users/me/avatar": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Upload the caller's avatar + * @description PNG, JPEG or WebP, at most 1 MB, decided by the file's bytes. Replaces and deletes any previous avatar. + */ + put: operations["uploadMyAvatar"]; + post?: never; + /** Remove the caller's avatar */ + delete: operations["deleteMyAvatar"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/{id}/avatar": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read a user's avatar + * @description Served only when the user is an active member of the caller's organization; otherwise 404, exactly as for a user that does not exist. + */ + get: operations["getUserAvatar"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/password/change": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Change the signed-in user's own password + * @description Requires the current password; the new one must pass the password policy. On success every session of the user ends and the calling device is given a fresh one (new session cookies, and the pair in the body), and a notice is emailed. If no new session can be minted, `reauthenticate` is true. Rate limited with the other authentication routes. No password is ever logged or echoed. + */ + post: operations["changePassword"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * The signed-in user, their MFA state and how they can prove who they are + * @description Re-read on every call, never taken from the token alone. `mfa` is omitted when the server cannot resolve it (read that as unknown, not as "fine"). `has_password` is false for an account that signs in through an identity provider; such an account confirms sensitive changes, like turning MFA off, with an authenticator code instead (#754, D-061). It is a boolean, never the hash. + */ + get: operations["getMe"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/auth/mfa/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Turn off two-factor authentication for the signed-in user + * @description An open session is not enough (#754). The body carries the current `password`, or, when `/auth/me` says `has_password: false`, a current six-digit `code` from the authenticator app; backup codes are not accepted, and a code never replaces the password of an account that has one. Refused for roles the deployment requires MFA for. Each attempt counts against a per-account budget of 5 per 15 minutes (on top of the per-IP limit). On success the TOTP secret and every backup code are deleted in one transaction, the attempt is audited, and the owner is notified by email and in-app. Neither the password nor the code is ever logged or echoed. Errors carry a stable `code` (see DisableMFAError). + */ + post: operations["disableMfa"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organization": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the caller's organization profile + * @description Requires organization:read or organization:members:read. The organization is the session's tenant. + */ + get: operations["getOrganization"]; + /** + * Update the caller's organization profile and regional settings + * @description Requires organization:update. Partial update — an omitted field is left unchanged, an empty string clears an optional field. The organization is the session's tenant; there is no id to choose. Settings keys this endpoint does not own (such as the display currency) are preserved. + */ + put: operations["updateOrganization"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organization/logo": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read the caller's organization logo + * @description Any member. There is no id; only the session's organization is readable. + */ + get: operations["getOrganizationLogo"]; + /** + * Upload the organization logo + * @description Requires organization:update. PNG, JPEG or WebP, at most 1 MB, decided by the file's bytes. Replaces and deletes any previous logo. + */ + put: operations["uploadOrganizationLogo"]; + post?: never; + /** + * Remove the organization logo + * @description Requires organization:update. + */ + delete: operations["deleteOrganizationLogo"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/organization/branding": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Read the caller's organization branding + * @description Any member — every member's interface wears the name, logo and accent. + */ + get: operations["getOrganizationBranding"]; put?: never; post?: never; delete?: never; @@ -190,6 +367,66 @@ export interface paths { patch?: never; trace?: never; }; + "/risks/{id}/transfer-owner": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Transfer the ownership of a risk to another member + * @description Hands the owner slot to another active member of the caller's organisation, and changes nothing else. The previous owner is captured before the write, the audit trail records a `transfer` entry (previous owner → new owner, actor), and the new owner receives an in-app notification unless they performed the transfer themselves. Requires `risks:update`. A risk of another organisation reads as not found. + */ + post: operations["transferRiskOwner"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/mitigations/{id}/transfer-owner": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Transfer the ownership of a mitigation to another member + * @description Hands the owner slot to another active member of the caller's organisation, and changes nothing else. The previous owner is captured before the write, the audit trail records a `transfer` entry (previous owner → new owner, actor), and the new owner receives an in-app notification unless they performed the transfer themselves. Requires `mitigations:update`. A mitigation of another organisation reads as not found. + */ + post: operations["transferMitigationOwner"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/incidents/{id}/transfer-owner": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Transfer the ownership of an incident to another member + * @description Hands the owner slot to another active member of the caller's organisation, and changes nothing else. The previous owner is captured before the write, the audit trail records a `transfer` entry (previous owner → new owner, actor), and the new owner receives an in-app notification unless they performed the transfer themselves. Requires `incidents:update`. An incident of another organisation reads as not found. + */ + post: operations["transferIncidentOwner"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/risks/{id}/smart-score": { parameters: { query?: never; @@ -1749,11 +1986,11 @@ export interface components { */ id: string; /** @enum {string} */ - type: "overdue_mitigation" | "critical_risk" | "pending_approval" | "open_incident" | "expiring_evidence" | "overdue_remediation"; + type: "overdue_mitigation" | "critical_risk" | "pending_approval" | "open_incident" | "expiring_evidence" | "overdue_remediation" | "vulnerability_sla" | "vendor_followup" | "mitigation_review"; /** @description The underlying record's own title, never a generated sentence. */ title: string; /** @enum {string} */ - subject_resource_type: "mitigation" | "risk" | "approval_request" | "incident" | "evidence" | "remediation_plan"; + subject_resource_type: "mitigation" | "risk" | "approval_request" | "incident" | "evidence" | "remediation_plan" | "vulnerability" | "vendor_assessment"; /** @description The record's own id. A uuid for every type except incident, which is an integer rendered as a string. */ subject_resource_id: string; /** @@ -1766,10 +2003,14 @@ export interface components { * @description Due date, expiry or approval deadline, depending on the type. Null when the category has no date. */ due_at?: string | null; - /** @description 1 overdue_mitigation, 2 critical_risk, 3 pending_approval, 4 open_incident, 5 expiring_evidence, 6 overdue_remediation. This is the primary sort key, exposed so a client can group without re-deriving the rule. */ + /** @description 1 overdue_mitigation, 2 critical_risk, 3 pending_approval, 4 open_incident, 5 expiring_evidence, 6 overdue_remediation, 7 vulnerability_sla, 8 vendor_followup, 9 mitigation_review. This is the primary sort key, exposed so a client can group without re-deriving the rule. */ category_rank: number; /** Format: uuid */ tenant_id: string; + /** @description Facts about the record shown beside its title, keyed by name: status, severity, cve_id, asset_name, kev ("true"), sent_at. Facts only, never a generated sentence; absent keys are unknown. */ + meta?: { + [key: string]: string; + }; }; ActionCenterResponse: { /** @description Always an array. Empty when nothing is outstanding. */ @@ -3184,6 +3425,153 @@ export interface components { state?: components["schemas"]["SavedViewState"]; columns?: components["schemas"]["SavedViewColumns"]; }; + OrganizationView: { + /** Format: uuid */ + id: string; + name: string; + slug: string; + logo_url?: string; + industry?: string; + /** @enum {string} */ + size?: "1-50" | "51-200" | "201-1000" | "1000+"; + plan: string; + is_active: boolean; + /** Format: uuid */ + owner_id: string; + owner_name?: string; + /** @description IANA time zone */ + timezone?: string; + /** Format: uri */ + website?: string; + description?: string; + /** @enum {string} */ + default_locale?: "fr" | "en"; + /** @enum {string} */ + date_format?: "DD/MM/YYYY" | "MM/DD/YYYY" | "YYYY-MM-DD"; + has_logo?: boolean; + /** @enum {string} */ + accent?: "azure" | "iris"; + /** Format: date-time */ + created_at: string; + /** Format: date-time */ + updated_at: string; + counts: { + [key: string]: number; + }; + can_edit: boolean; + }; + OrganizationProfilePatch: { + name?: string; + industry?: string; + /** @enum {string} */ + size?: "" | "1-50" | "51-200" | "201-1000" | "1000+"; + /** @description Absolute https:// URL, or empty to clear */ + website?: string; + description?: string; + /** @description IANA time zone, or empty to clear */ + timezone?: string; + /** @enum {string} */ + default_locale?: "" | "fr" | "en"; + /** @enum {string} */ + date_format?: "" | "DD/MM/YYYY" | "MM/DD/YYYY" | "YYYY-MM-DD"; + /** + * @description A design-system accent preset; free colours are refused + * @enum {string} + */ + accent?: "" | "azure" | "iris"; + }; + OrganizationBranding: { + name: string; + has_logo: boolean; + /** @enum {string} */ + accent?: "azure" | "iris"; + }; + MyProfile: { + /** Format: uuid */ + id: string; + email: string; + username: string; + full_name: string; + job_title: string; + phone: string; + bio: string; + timezone: string; + locale: string; + date_format: string; + theme_mode: string; + has_avatar: boolean; + avatar_url?: string; + effective: { + timezone?: string; + locale?: string; + date_format?: string; + }; + /** Format: date-time */ + updated_at: string; + }; + UserProfilePatch: { + full_name?: string; + job_title?: string; + phone?: string; + bio?: string; + /** @description IANA time zone, or empty to follow the organization */ + timezone?: string; + /** @enum {string} */ + locale?: "" | "fr" | "en"; + /** @enum {string} */ + date_format?: "" | "DD/MM/YYYY" | "MM/DD/YYYY" | "YYYY-MM-DD"; + /** @enum {string} */ + theme_mode?: "" | "light" | "dark" | "system"; + }; + TransferOwnerRequest: { + /** + * Format: uuid + * @description User id of the new owner. Must be an active member of the organisation. + */ + new_owner_id: string; + }; + TransferOwnershipResult: { + /** @enum {string} */ + entity_type: "risk" | "mitigation" | "incident"; + /** @description The entity id as it appears in the path (a uuid, or an integer for incidents). */ + entity_id: string; + /** + * Format: uuid + * @description Null when the entity had no owner. + */ + previous_owner_id: string | null; + /** Format: uuid */ + owner_id: string; + /** Format: date-time */ + transferred_at: string; + }; + MeResponse: { + user: components["schemas"]["User"]; + /** Format: uuid */ + organization_id: string; + /** @description False for an identity-provider account with no local password. */ + has_password: boolean; + mfa?: components["schemas"]["MFAStatus"]; + /** @description The caller's current business role in this organization, when they have one. */ + business_role?: string; + }; + DisableMFAInput: { + /** + * Format: password + * @description Required when the account has a password. + */ + password?: string; + /** @description Current authenticator code. Required instead of `password` when `has_password` is false. */ + code?: string; + /** @enum {string} */ + locale?: "fr" | "en"; + }; + DisableMFAError: { + /** @description Localised message, safe to show as is. */ + error: string; + /** @enum {string} */ + code: "wrong_password" | "wrong_code" | "mfa_required_by_role" | "no_local_password" | "not_enrolled" | "too_many_attempts" | "rejected" | "internal"; + }; ErrorResponse: { /** @example Invalid input */ error?: string; @@ -3369,7 +3757,7 @@ export interface operations { }; }; }; - getCurrentUser: { + getMyProfile: { parameters: { query?: never; header?: never; @@ -3378,13 +3766,13 @@ export interface operations { }; requestBody?: never; responses: { - /** @description Current user profile */ + /** @description Own profile. `effective` resolves each preference as own choice, else the organization default. */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["User"]; + "application/json": components["schemas"]["MyProfile"]; }; }; /** @description Unauthorized */ @@ -3398,18 +3786,523 @@ export interface operations { }; }; }; - listRisks: { + updateMyProfile: { parameters: { - query?: { - page?: number; - limit?: number; - sort_by?: string; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + "application/json": components["schemas"]["UserProfilePatch"]; + }; + }; + responses: { + /** @description Updated profile */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MyProfile"]; + }; + }; + /** @description Invalid field; the message names it */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + uploadMyAvatar: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description Updated profile */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MyProfile"]; + }; + }; + /** @description Not an accepted image, or larger than 1 MB */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + deleteMyAvatar: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Updated profile */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MyProfile"]; + }; + }; + }; + }; + getUserAvatar: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The image */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "image/png": string; + "image/jpeg": string; + "image/webp": string; + }; + }; + /** @description No avatar visible to the caller */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + changePassword: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** Format: password */ + current_password: string; + /** Format: password */ + new_password: string; + /** @enum {string} */ + locale?: "fr" | "en"; + }; + }; + }; + responses: { + /** @description Password changed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message?: string; + reauthenticate?: boolean; + token_pair?: { + access_token?: string; + refresh_token?: string; + expires_in?: number; + }; + csrf_token?: string; + }; + }; + }; + /** @description `weak_password` (with `assessment`) or `same_password` */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No session */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `wrong_current_password` */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `no_local_password`: the account signs in through an identity provider */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Rate limited */ + 429: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + getMe: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The current session's user */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MeResponse"]; + }; + }; + /** @description No session */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description The session's user no longer exists */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + disableMfa: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["DisableMFAInput"]; + }; + }; + responses: { + /** @description MFA is off */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + message: string; + }; + }; + }; + /** @description The body is not JSON */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description `wrong_password` or `wrong_code`. Not a session error: the session stays valid. */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DisableMFAError"]; + }; + }; + /** @description `mfa_required_by_role` */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DisableMFAError"]; + }; + }; + /** @description `not_enrolled` */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DisableMFAError"]; + }; + }; + /** @description `no_local_password`: no password and no way to check a code on this deployment */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DisableMFAError"]; + }; + }; + /** @description `too_many_attempts`, or the per-IP limit */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DisableMFAError"]; + }; + }; + }; + }; + getOrganization: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Organization profile with live membership counts */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrganizationView"]; + }; + }; + /** @description Missing permission */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + updateOrganization: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OrganizationProfilePatch"]; + }; + }; + responses: { + /** @description Updated organization profile */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrganizationView"]; + }; + }; + /** @description Invalid field; the message names it */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Missing organization:update */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getOrganizationLogo: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description The image */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "image/png": string; + "image/jpeg": string; + "image/webp": string; + }; + }; + /** @description No logo */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + uploadOrganizationLogo: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description Updated organization profile */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrganizationView"]; + }; + }; + /** @description Not an accepted image, or larger than 1 MB */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Missing organization:update */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + deleteOrganizationLogo: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Updated organization profile */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrganizationView"]; + }; + }; + /** @description Missing organization:update */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + getOrganizationBranding: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Branding */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OrganizationBranding"]; + }; + }; + }; + }; + listRisks: { + parameters: { + query?: { + page?: number; + limit?: number; + sort_by?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; responses: { /** @description List of risks */ 200: { @@ -3749,6 +4642,207 @@ export interface operations { }; }; }; + transferRiskOwner: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TransferOwnerRequest"]; + }; + }; + responses: { + /** @description Ownership transferred */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TransferOwnershipResult"]; + }; + }; + /** @description Malformed id or new_owner_id, or the new owner is not an active member of the organisation */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing `risks:update` */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found in the caller's organisation */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The new owner already owns it */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + transferMitigationOwner: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TransferOwnerRequest"]; + }; + }; + responses: { + /** @description Ownership transferred */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TransferOwnershipResult"]; + }; + }; + /** @description Malformed id or new_owner_id, or the new owner is not an active member of the organisation */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing `mitigations:update` */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found in the caller's organisation */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The new owner already owns it */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + transferIncidentOwner: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["TransferOwnerRequest"]; + }; + }; + responses: { + /** @description Ownership transferred */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["TransferOwnershipResult"]; + }; + }; + /** @description Malformed id or new_owner_id, or the new owner is not an active member of the organisation */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Missing `incidents:update` */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not found in the caller's organisation */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description The new owner already owns it */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; getRiskSmartScore: { parameters: { query?: never;