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/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/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] 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; 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);