diff --git a/apps/python-app/services/dashboard-web/src/dashboard_web/clients.py b/apps/python-app/services/dashboard-web/src/dashboard_web/clients.py
index 7cfd041..390b451 100644
--- a/apps/python-app/services/dashboard-web/src/dashboard_web/clients.py
+++ b/apps/python-app/services/dashboard-web/src/dashboard_web/clients.py
@@ -18,6 +18,8 @@ def readiness(self) -> dict[str, Any]: ...
def neighborhood(self, ref: str, depth: int = 3) -> dict[str, Any]: ...
+ def referrers(self, ref: str, depth: int = 3) -> dict[str, Any]: ...
+
class PackagesServiceClient:
def __init__(
@@ -105,3 +107,11 @@ def neighborhood(self, ref: str, depth: int = 3) -> dict[str, Any]:
)
response.raise_for_status()
return response.json()
+
+ def referrers(self, ref: str, depth: int = 3) -> dict[str, Any]:
+ response = self._client.get(
+ f"{self._base}/artifacts/referrers",
+ params={"ref": ref, "depth": depth, "format": "json"},
+ )
+ response.raise_for_status()
+ return response.json()
diff --git a/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/_graph_neighborhood.html b/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/_graph_neighborhood.html
index 7614e0b..ca16986 100644
--- a/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/_graph_neighborhood.html
+++ b/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/_graph_neighborhood.html
@@ -38,7 +38,7 @@
{{ subgraph.nodes | length }} node(s)
{% for node in subgraph.nodes %}
- {{ node.ref or node.key }}{% if node.digest %} {{ node.digest[:19] }}…{% endif %}
+ {{ node.ref or node.key }}{% if node.digest %} {{ node.digest[:19] }}…{% endif %}{% if node.deletedAt %} deleted{% endif %}
{% endfor %}
diff --git a/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/_graph_referrers.html b/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/_graph_referrers.html
new file mode 100644
index 0000000..7267202
--- /dev/null
+++ b/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/_graph_referrers.html
@@ -0,0 +1,30 @@
+{% if error %}
+{{ error }}
+{% elif not ref %}
+Enter an image reference to list its referrer artifacts.
+{% elif not subgraph or not subgraph.edges %}
+No referrers found for {{ ref }}.
+{% else %}
+
+ Referrers of {{ ref }}:
+ {{ subgraph.edges | length }} referrer(s)
+
+
+
+
+ | Artifact type |
+ Referrer |
+ Subject |
+
+
+
+ {% for edge in subgraph.edges %}
+
+ | {{ edge.artifactType or edge.type }} |
+ {{ edge.from }} |
+ {{ edge.to }} |
+
+ {% endfor %}
+
+
+{% endif %}
diff --git a/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/observability.html b/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/observability.html
index 6ef53ca..f20cfdc 100644
--- a/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/observability.html
+++ b/apps/python-app/services/dashboard-web/src/dashboard_web/templates/stages/observability.html
@@ -28,4 +28,17 @@
+
+
+
+
diff --git a/apps/python-app/services/dashboard-web/src/dashboard_web/web/routes.py b/apps/python-app/services/dashboard-web/src/dashboard_web/web/routes.py
index f7ed4b0..f6123b8 100644
--- a/apps/python-app/services/dashboard-web/src/dashboard_web/web/routes.py
+++ b/apps/python-app/services/dashboard-web/src/dashboard_web/web/routes.py
@@ -99,3 +99,20 @@ def graph_neighborhood(request: Request, ref: str = "", depth: int = 3) -> HTMLR
context={"ref": ref, "subgraph": subgraph, "error": error},
)
+ @app.get("/graph/referrers", response_class=HTMLResponse)
+ def graph_referrers(request: Request, ref: str = "", depth: int = 3) -> HTMLResponse:
+ ref = ref.strip()
+ subgraph = None
+ error = None
+ if ref:
+ try:
+ subgraph = graph.referrers(ref, depth=depth)
+ except Exception: # log details server-side, show a generic message
+ logger.exception("Failed to load referrers for %s", ref)
+ error = "the referrers could not be loaded for that reference"
+ return templates.TemplateResponse(
+ request=request,
+ name="stages/_graph_referrers.html",
+ context={"ref": ref, "subgraph": subgraph, "error": error},
+ )
+
diff --git a/apps/python-app/services/dashboard-web/tests/test_graph.py b/apps/python-app/services/dashboard-web/tests/test_graph.py
index 20dfa42..30eeb32 100644
--- a/apps/python-app/services/dashboard-web/tests/test_graph.py
+++ b/apps/python-app/services/dashboard-web/tests/test_graph.py
@@ -33,11 +33,27 @@
],
}
+REFERRERS_SUBGRAPH = {
+ "nodes": [
+ {"key": "ghcr.io/toddysm/golden/python@sha256:bb", "ref": "ghcr.io/toddysm/golden/python", "digest": "sha256:bb"},
+ {"key": "ghcr.io/toddysm/golden/python@sha256:cc", "ref": "ghcr.io/toddysm/golden/python", "digest": "sha256:cc"},
+ ],
+ "edges": [
+ {
+ "type": "REFERS_TO",
+ "from": "ghcr.io/toddysm/golden/python@sha256:cc",
+ "to": "ghcr.io/toddysm/golden/python@sha256:bb",
+ "artifactType": "application/vnd.in-toto+json",
+ }
+ ],
+}
+
class FakeGraph:
- def __init__(self, ready: bool = True, records: int = 3, by_kind=None, subgraph=None):
+ def __init__(self, ready: bool = True, records: int = 3, by_kind=None, subgraph=None, referrers=None):
self._readiness = {"ready": ready, "records": records, "by_kind": by_kind or {"ArtifactBuilt": 2}}
self._subgraph = subgraph if subgraph is not None else SUBGRAPH
+ self._referrers = referrers if referrers is not None else REFERRERS_SUBGRAPH
def readiness(self):
return self._readiness
@@ -45,6 +61,9 @@ def readiness(self):
def neighborhood(self, ref, depth=3):
return self._subgraph
+ def referrers(self, ref, depth=3):
+ return self._referrers
+
def _app(graph: FakeGraph):
registry = StageRegistry()
@@ -90,6 +109,24 @@ def test_neighborhood_route_empty_subgraph():
assert "No graph found" in body
+def test_referrers_route_renders_artifact_type():
+ client = TestClient(_app(FakeGraph()))
+ resp = client.get("/graph/referrers", params={"ref": "ghcr.io/toddysm/golden/python:3.14-slim"})
+ assert resp.status_code == 200
+ assert "application/vnd.in-toto+json" in resp.text
+
+
+def test_referrers_route_without_ref_prompts():
+ client = TestClient(_app(FakeGraph()))
+ assert "list its referrer artifacts" in client.get("/graph/referrers").text
+
+
+def test_referrers_route_empty():
+ client = TestClient(_app(FakeGraph(referrers={"nodes": [], "edges": []})))
+ body = client.get("/graph/referrers", params={"ref": "ghcr.io/none:0"}).text
+ assert "No referrers found" in body
+
+
def test_graph_client_readiness_ready():
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/readyz"
@@ -116,3 +153,13 @@ def handler(request: httpx.Request) -> httpx.Response:
client = GraphServiceClient("http://graph", client=httpx.Client(transport=httpx.MockTransport(handler)))
assert client.neighborhood("ghcr.io/x:1")["edges"][0]["type"] == "BUILT_FROM"
+
+
+def test_graph_client_referrers():
+ def handler(request: httpx.Request) -> httpx.Response:
+ assert request.url.path == "/artifacts/referrers"
+ assert request.url.params["format"] == "json"
+ return httpx.Response(200, json=REFERRERS_SUBGRAPH)
+
+ client = GraphServiceClient("http://graph", client=httpx.Client(transport=httpx.MockTransport(handler)))
+ assert client.referrers("ghcr.io/x:1")["edges"][0]["type"] == "REFERS_TO"
diff --git a/apps/python-app/services/graph-service/src/graph_service/app.py b/apps/python-app/services/graph-service/src/graph_service/app.py
index 15b9567..ce22d2c 100644
--- a/apps/python-app/services/graph-service/src/graph_service/app.py
+++ b/apps/python-app/services/graph-service/src/graph_service/app.py
@@ -111,6 +111,25 @@ def artifact_show(ref: str = Query(...)) -> dict[str, Any]:
raise HTTPException(status_code=404, detail=f"no occurrence for '{ref}'")
return result
+ @app.get("/artifacts/referrers")
+ def artifact_referrers(
+ ref: str | None = Query(None),
+ digest: str | None = Query(None),
+ depth: int = Query(3, ge=0),
+ format: str = Query("json", pattern="^(json|cytoscape|mermaid)$"),
+ ) -> Any:
+ if not ref and not digest:
+ raise HTTPException(status_code=400, detail="provide ref or digest")
+ with reading() as store:
+ subgraph = queries.referrers(
+ store, digest=digest, ref=ref, depth=min(depth, settings.max_depth)
+ )
+ if format == "cytoscape":
+ return queries.to_cytoscape(subgraph)
+ if format == "mermaid":
+ return Response(content=queries.to_mermaid(subgraph), media_type="text/plain")
+ return subgraph
+
@app.get("/repositories/tags/history")
def tag_history(ref: str = Query(...), tag: str = Query(...)) -> dict[str, Any]:
with reading() as store:
diff --git a/apps/python-app/services/graph-service/tests/test_app.py b/apps/python-app/services/graph-service/tests/test_app.py
index f3e002d..f8faa1f 100644
--- a/apps/python-app/services/graph-service/tests/test_app.py
+++ b/apps/python-app/services/graph-service/tests/test_app.py
@@ -27,6 +27,7 @@
DIGEST_APP = "sha256:" + "a" * 64
DIGEST_GOLDEN = "sha256:" + "b" * 64
DIGEST_UP = "sha256:" + "c" * 64
+DIGEST_REF = "sha256:" + "d" * 64
SOURCE = {
"type": "github-actions",
@@ -84,6 +85,19 @@
"chart": {"name": "cssc-dashboard", "version": "0.1.2"},
},
),
+ (
+ "referrer-observed",
+ {
+ "schemaVersion": 1,
+ "kind": "ReferrerObserved",
+ "recordedAt": "2026-08-10T00:00:00Z",
+ "source": SOURCE,
+ "occurrence": {"registry": "ghcr.io", "repository": APP_REPO},
+ "subject": {"digest": DIGEST_APP, "tag": "0.1.0"},
+ "referrer": {"digest": DIGEST_REF, "artifactType": "application/vnd.in-toto+json"},
+ "observedAt": "2026-08-10T00:00:00Z",
+ },
+ ),
]
@@ -193,6 +207,18 @@ def test_neighborhood_cytoscape(client: TestClient) -> None:
assert "elements" in body
+def test_referrers_endpoint(client: TestClient) -> None:
+ body = client.get("/artifacts/referrers", params={"ref": f"{APP_REF}@{DIGEST_APP}"}).json()
+ assert any(
+ e["type"] == "REFERS_TO" and e.get("artifactType") == "application/vnd.in-toto+json"
+ for e in body["edges"]
+ )
+
+
+def test_referrers_requires_selector(client: TestClient) -> None:
+ assert client.get("/artifacts/referrers").status_code == 400
+
+
def test_depth_is_capped(client: TestClient, tmp_path: Path) -> None:
# A max_depth of 1 must stop bases traversal at the first hop.
root = _write_data_root(tmp_path / "capped", RECORDS)