fix(security): scope package search, run documents, workloads and vulnerability reads to the token's tags

This commit is contained in:
2026-09-09 08:36:04 +00:00
parent 6a48dd5d73
commit ac8e957859
9 changed files with 208 additions and 17 deletions
+27
View File
@@ -131,6 +131,33 @@ var serverScopedRoutes = map[string]bool{
"POST /api/monitors": true,
"PUT /api/monitors/:id": true,
// getRun and listWorkflowRuns narrow WorkflowRun.ServerRuns — each entry
// of which carries a ServerID and a Hostname — to what the caller's scope
// admits, setting servers_restricted (a boolean, never a count) when any
// entry was dropped. These are the parents of the per-server log routes
// above: scoping the logs but not the document naming the hosts left the
// hostnames readable without the output.
"GET /api/runs/:runId": true,
"GET /api/workflows/:id/runs": true,
// searchPackages passes the caller's selector into services.SearchPackages,
// which drops hits on servers outside it using one VisibleServerIDs
// membership set. The MCP search_fleet tool answers the same question and
// was already scoped; this makes the REST twin agree.
"GET /api/packages/search": true,
// listWorkloads passes the caller's selector into services.SearchWorkloads,
// which drops hits on servers outside it. A WorkloadHit names a server ID,
// so the fleet-wide form enumerated hosts directly.
"GET /api/workloads": true,
// listVulnerabilities passes the selector as FindingFilter.TokenScope,
// narrowing server_id in the same query the Tags selector already narrows,
// and vulnerabilitySummary passes it to CountOpenFindingsBySeverity so the
// summary tiles count only visible servers.
"GET /api/vulnerabilities": true,
"GET /api/vulnerabilities/summary": true,
// Creating a server has no server to filter yet.
"POST /api/servers": false,
// The agent's own enrolment routes authenticate as the agent, not as a
+34
View File
@@ -131,6 +131,40 @@ type WorkflowResponse struct {
TargetsRestricted bool `json:"targets_restricted,omitempty"`
}
// RunResponse is a workflow run with its ServerRuns narrowed to the servers
// the acting token's scope admits. Each models.ServerRun carries both a
// ServerID and a Hostname, so an unfiltered run document names every host it
// touched — the same disclosure WorkflowResponse.TargetServerIDs closes one
// level up, and the parent of the per-server log routes that were already
// scoped.
//
// ServersRestricted follows the targets_restricted precedent exactly: a
// boolean and no count, because how many entries were dropped is itself
// information about a fleet the caller must not be able to size.
type RunResponse struct {
*models.WorkflowRun
ServerRuns []models.ServerRun `json:"server_runs"`
ServersRestricted bool `json:"servers_restricted,omitempty"`
}
// scopeRun narrows one run's ServerRuns using the (visible, restricted) pair
// services.VisibleServerIDs returns.
func scopeRun(r *models.WorkflowRun, visible map[string]bool, restricted bool) RunResponse {
if !restricted {
return RunResponse{WorkflowRun: r, ServerRuns: r.ServerRuns}
}
out := make([]models.ServerRun, 0, len(r.ServerRuns))
hidden := false
for _, sr := range r.ServerRuns {
if visible[sr.ServerID] {
out = append(out, sr)
} else {
hidden = true
}
}
return RunResponse{WorkflowRun: r, ServerRuns: out, ServersRestricted: hidden}
}
type UpdateAgentResponse struct {
Message string `json:"message"`
Version string `json:"version"`
+4 -2
View File
@@ -49,6 +49,8 @@ func listVulnerabilities(c *gin.Context) {
ServerID: c.Query("server"),
Tags: tagsFromQuery(c),
HasFix: hasFixFromQuery(c),
TokenScope: auth.ServerScope(c),
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -143,7 +145,7 @@ func tagsFromQuery(c *gin.Context) map[string]string {
// @Security bearerAuth
// @Router /vulnerabilities/summary [get]
func vulnerabilitySummary(c *gin.Context) {
counts, err := services.CountOpenFindingsBySeverity(auth.InstanceID(c))
counts, err := services.CountOpenFindingsBySeverity(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -361,7 +363,7 @@ func searchPackages(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "name is required"})
return
}
hits, err := services.SearchPackages(auth.InstanceID(c), name)
hits, err := services.SearchPackages(auth.InstanceID(c), name, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+18 -3
View File
@@ -618,7 +618,17 @@ func listWorkflowRuns(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, runs)
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
out := make([]RunResponse, 0, len(runs))
for i := range runs {
out = append(out, scopeRun(&runs[i], visible, restricted))
}
c.JSON(http.StatusOK, out)
}
// getRun godoc
@@ -627,7 +637,7 @@ func listWorkflowRuns(c *gin.Context) {
// @Tags workflows
// @Produce json
// @Param runId path string true "Run ID"
// @Success 200 {object} models.WorkflowRun
// @Success 200 {object} RunResponse
// @Failure 404 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
@@ -638,7 +648,12 @@ func getRun(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, r)
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, scopeRun(r, visible, restricted))
}
// cancelRun godoc
+1 -1
View File
@@ -245,7 +245,7 @@ func getWorkloadLogs(c *gin.Context) {
// @Router /workloads [get]
func listWorkloads(c *gin.Context) {
hits, err := services.SearchWorkloads(auth.InstanceID(c),
c.Query("image"), c.Query("stack"), c.Query("state"))
c.Query("image"), c.Query("stack"), c.Query("state"), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return