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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions apps/backend/api/v1/dashboard.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
"""
``/v1/dashboard`` — portfolio overview aggregate.

A single read-only endpoint backing the app-root Dashboard page::
Read-only endpoints backing the app-root Dashboard page::

GET /v1/dashboard/summary → DashboardSummary
GET /v1/dashboard/action-queue → ActionQueue
GET /v1/dashboard/trends → DashboardTrends

Auth: requires :func:`get_current_user` (JWT). There is no ``team_id`` /
``project_id`` path parameter — the caller's identity *is* the scope. The
Expand All @@ -21,7 +22,9 @@

from __future__ import annotations

from fastapi import APIRouter, Depends, Request, status
from typing import Annotated

from fastapi import APIRouter, Depends, Query, Request, status
from sqlalchemy.ext.asyncio import AsyncSession

from core.config import api_read_rate_limit
Expand All @@ -30,8 +33,10 @@
from core.security import CurrentUser, get_current_user
from schemas.action_queue import ActionQueue
from schemas.dashboard import DashboardSummary
from schemas.dashboard_trends import DashboardTrends, TrendWindow
from services.action_queue_service import get_action_queue
from services.dashboard_service import get_dashboard_summary
from services.dashboard_trends_service import get_dashboard_trends

router = APIRouter(prefix="/v1/dashboard", tags=["dashboard"])

Expand Down Expand Up @@ -79,3 +84,39 @@ async def get_action_queue_endpoint(
on a large deployment.
"""
return await get_action_queue(session, actor=actor)


@router.get(
"/trends",
response_model=DashboardTrends,
status_code=status.HTTP_200_OK,
summary="Daily risk series for the caller's accessible projects (auth required)",
)
@limiter.limit(api_read_rate_limit, key_func=_authenticated_user_key)
async def get_dashboard_trends_endpoint(
request: Request,
days: Annotated[
TrendWindow,
Query(description="Window length in days, inclusive of today."),
] = TrendWindow.MONTH,
session: AsyncSession = Depends(get_db),
actor: CurrentUser = Depends(get_current_user),
) -> DashboardTrends:
"""New and resolved exposures per day, plus the standing critical / KEV
counts, over the caller's accessible projects.

``days`` is a closed set rather than a free integer: an arbitrary window
would let one request walk years of scan history, and the service raises
on anything outside it, so a widened query parameter cannot quietly become
an unbounded scan. It is an ``IntEnum`` rather than a literal of ints
because a query string arrives as ``"7"`` and a literal refuses to coerce
it — the literal spelling rejected every window a caller asked for while
still honouring the default, which is a shape no test of the default can
see.

Same scoping contract as ``/summary`` — the caller's identity is the
scope, enforced in the service through the shared accessible-projects
helper. Rate limited per actor for the same reason as the action queue:
query count is fixed but the exposure sets read grow with the portfolio.
"""
return await get_dashboard_trends(session, actor=actor, days=days)
130 changes: 130 additions & 0 deletions apps/backend/schemas/dashboard_trends.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Response models for ``GET /v1/dashboard/trends``.

Two kinds of number share one series, and the difference matters when reading
the chart:

* ``new_findings`` / ``resolved_findings`` are **flows** — what changed on that
day, attributed to the day a scan finished. A day with no scan has zero flow
because nothing was observed, not because nothing changed.
* ``critical_open`` / ``kev_open`` are **levels** — the portfolio's standing
exposure on that day, carried forward from each project's most recent
succeeded scan on or before it. A day with no scan repeats the previous
level rather than dropping to zero.

``scan_count`` is what lets the UI tell those two apart honestly: it says
whether the level on that day was measured or inherited.
"""

from __future__ import annotations

import datetime
from enum import IntEnum

from pydantic import BaseModel, Field


class TrendWindow(IntEnum):
"""The windows the UI offers, and the only ones the route accepts.

An ``IntEnum`` rather than ``Literal[7, 30, 90]``: a query string arrives
as ``"7"``, and a literal of ints refuses to coerce it, so the literal
version rejected every window a caller actually asked for while quietly
accepting the default. This coerces the string and still refuses anything
outside the set.
"""

WEEK = 7
MONTH = 30
QUARTER = 90


class TrendPoint(BaseModel):
"""One day of the portfolio series."""

date: datetime.date = Field(
description=(
"The UTC day, by scan start time. The last point of a series is "
"today and therefore covers only the elapsed part of it — its "
"flows keep rising until midnight."
)
)
new_findings: int = Field(
ge=0,
description=(
"Exposures open in a scan that finished this day and not open in "
"the previous scan of the same lineage — same project, same scan "
"kind, same git ref. A scan with no predecessor contributes "
"nothing: scan retention deletes superseded scans, so 'no earlier "
"scan on record' does not mean 'nothing came before', and "
"counting its whole open set would fabricate a spike for exposure "
"that had been there for months."
),
)
resolved_findings: int = Field(
ge=0,
description=(
"The converse: open in the previous scan of the lineage and no "
"longer open in this day's. Includes findings closed by triage or "
"VEX, not only ones an upgrade removed."
),
)
critical_open: int = Field(
ge=0,
description=(
"Open critical findings across every accessible project, counted "
"from each project's latest succeeded scan on or before this day."
),
)
kev_open: int = Field(
ge=0,
description="Open findings on CISA known-exploited CVEs, same anchoring.",
)
scan_count: int = Field(
ge=0,
description=(
"Succeeded scans that finished this day. Zero means the levels "
"above were carried forward, not re-measured."
),
)


class TrendTotals(BaseModel):
"""Flow sums over the whole window — the levels are not summable."""

new_findings: int = Field(ge=0)
resolved_findings: int = Field(ge=0)


class DashboardTrends(BaseModel):
"""Portfolio risk over time, scoped to the caller's accessible projects.

The series is recomputed from current data on every request, not stored as
it was observed. Two consequences are worth knowing before reading a point
as history: a finding triaged today drops out of the days it was open on,
and a project archived today is subtracted from every day of the window,
including the days it was live. Both keep the chart consistent with what
the rest of the product reports *now*, at the cost of yesterday's chart
and today's disagreeing about the same past day.
"""

period_days: int = Field(
description="The requested window, in days. One of 7, 30, or 90."
)
start_date: datetime.date
end_date: datetime.date = Field(
description="Today, in UTC. The series is inclusive of both ends."
)
points: list[TrendPoint] = Field(
default_factory=list,
description="One entry per day, oldest first, with no gaps.",
)
totals: TrendTotals
project_count: int = Field(
ge=0,
description=(
"Accessible projects the series was computed over. Zero means the "
"caller belongs to no team with projects — the series is all "
"zeros rather than absent, so the widget renders an empty state "
"instead of an error."
),
)
15 changes: 13 additions & 2 deletions apps/backend/schemas/project_diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,9 @@
- vulnerabilities use the OPEN/active finding set (a finding suppressed/
resolved via VEX — ``not_affected`` / ``false_positive`` / ``suppressed`` /
``fixed`` — counts as NOT open). introduced = open-in-target minus
open-in-base, keyed by (cve_id, component_version); resolved = the converse.
open-in-base, keyed by (vulnerability_id, component_version_id) — the
exposure's identity, so one CVE against two packages that share a version
string is two entries; resolved = the converse.
- summary reuses the SAME per-scan aggregations the Releases table / Overview
tab use, so the diff can never disagree with them for the same pinned scan.

Expand Down Expand Up @@ -211,7 +213,7 @@ class DiffComponents(BaseModel):


class DiffVulnerability(BaseModel):
"""One (cve, component_version) finding that changed open-status between snapshots."""
"""One exposure — a CVE against a package version — that changed open-status."""

model_config = ConfigDict(from_attributes=True)

Expand All @@ -221,6 +223,15 @@ class DiffVulnerability(BaseModel):
)
component_name: str
component_version: str
purl: str = Field(
description=(
"The versioned package URL. Name and version alone do not identify "
"a package: Maven artefacts differing only by classifier share both "
"(the UNIQUE (component_id, version) constraint was dropped for "
"exactly that reason), so without the purl two distinct exposures "
"render as one line repeated."
)
)


class DiffVulnerabilities(BaseModel):
Expand Down
Loading
Loading