fix: scope monitor runner and workflow targets to the caller's fleet

GET /api/monitors and GET /api/monitors/:id returned Monitor.Runner
unfiltered; for an agent-pushed monitor that field is literally a server
ID, so a restricted token learned which out-of-scope server a monitor
runs on directly, not merely that one exists. services.RedactMonitorRunner
replaces Runner with models.RunnerRestricted when it names a server
outside the caller's scope, resolved once via the new
services.VisibleServerIDs rather than per monitor. The monitor itself is
still returned — a restricted operator may legitimately need to see that
it exists and is up or down — only the runner field goes neutral; omitting
the monitor entirely was considered and rejected as more surprising than
one field changing. Runner "server" (control-plane-run) is never
touched. The MCP list_monitors/get_monitor_status projections never had a
Runner field to begin with, so REST and the tool surface already agreed;
a comment now records why.

GET /api/workflows and GET /api/workflows/:id returned
Workflow.TargetServerIDs unfiltered — directly naming out-of-scope
servers, worse than a count. services.FilterVisibleServerIDs narrows the
list to what VisibleServerIDs admits and reports hidden (no count) when
at least one target was dropped; WorkflowResponse wraps *models.Workflow
with a scoped TargetServerIDs and a TargetsRestricted flag. TargetTags is
left untouched — the tag vocabulary is already ruled acceptable to
expose. The MCP list_workflows/get_workflow tools get the identical
treatment: list_workflows' target count is now based on the filtered ID
list, and get_workflow's workflowDetail carries the same
TargetsRestricted flag, so a model that sees a filtered target list and
then has run_workflow refuse the same workflow for out-of-scope targets
is not left concluding the refusal invented a problem the list never
mentioned.

All four routes recorded in serverScopedRoutes as true; none is
boot-enforced, for the same substring-filter reason as the key routes
added in the previous round.
This commit is contained in:
2026-09-09 08:03:26 +00:00
parent e06f9d5670
commit 3bdbf33f90
9 changed files with 215 additions and 14 deletions
+19 -1
View File
@@ -39,6 +39,16 @@ func listMonitors(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
for i := range monitors {
monitors[i] = services.RedactMonitorRunner(monitors[i], visible, restricted)
}
c.JSON(http.StatusOK, monitors)
}
@@ -103,7 +113,15 @@ func getMonitor(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
return
}
c.JSON(http.StatusOK, m)
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
redacted := services.RedactMonitorRunner(*m, visible, restricted)
c.JSON(http.StatusOK, &redacted)
}
// updateMonitor godoc
+29
View File
@@ -87,6 +87,35 @@ var serverScopedRoutes = map[string]bool{
// declare-by-default inversion already recorded as a follow-up above.
"GET /api/keys": true,
// listMonitors/getMonitor redact models.Monitor.Runner to
// models.RunnerRestricted via services.RedactMonitorRunner when it names
// a server outside the caller's scope — Runner is literally a server ID
// for an agent-pushed monitor, so left unfiltered it discloses one
// directly, worse than the key list's count. The monitor itself is still
// returned: a restricted operator may legitimately need to see that it
// exists and is up or down, so only the runner field goes neutral rather
// than the whole monitor disappearing from the list. Runner "server"
// (control-plane-run) is never touched — it names no server. Neither
// path carries any of serverTouchingRoutes' substrings, so like the two
// key routes above these entries are not boot-enforced.
"GET /api/monitors": true,
"GET /api/monitors/:id": true,
// listWorkflows/getWorkflow narrow Workflow.TargetServerIDs to what the
// caller's scope admits via services.VisibleServerIDs +
// FilterVisibleServerIDs, wrapped in WorkflowResponse so the JSON field
// name is unchanged. TargetTags is left untouched — the tag vocabulary
// itself is ruled acceptable to expose, unlike a resolved server ID.
// TargetsRestricted is set (with no count) whenever at least one target
// was dropped, so a caller is told some targets are hidden rather than
// silently shown a shorter list that happens to match what
// run_workflow's all-or-nothing scope refusal then also acts on — the
// two cannot appear to disagree with each other. Neither path carries
// any of serverTouchingRoutes' substrings, so like the routes above
// these entries are not boot-enforced.
"GET /api/workflows": true,
"GET /api/workflows/:id": 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
+21 -4
View File
@@ -114,6 +114,23 @@ type AgentVersionResponse struct {
Version string `json:"version"`
}
// WorkflowResponse is a workflow with its TargetServerIDs narrowed to what
// the acting token's scope admits — the explicit field shadows the embedded
// one for JSON marshalling, matching the pattern KeyDetailResponse already
// uses. TargetTags is not filtered: the tag vocabulary itself is ruled
// acceptable to expose, and only the resolved ID list can name a specific
// out-of-scope server.
//
// TargetsRestricted is set, with no count, when at least one target was
// dropped, so a caller reading this alongside run_workflow's all-or-nothing
// out-of-scope refusal sees why: the refusal is not inventing a problem the
// list never mentioned.
type WorkflowResponse struct {
*models.Workflow
TargetServerIDs []string `json:"target_server_ids"`
TargetsRestricted bool `json:"targets_restricted,omitempty"`
}
type UpdateAgentResponse struct {
Message string `json:"message"`
Version string `json:"version"`
@@ -121,14 +138,14 @@ type UpdateAgentResponse struct {
type AuditEventsResponse struct {
Events []models.AuditEvent `json:"events"`
Total int64 `json:"total"`
Total int64 `json:"total"`
}
// --- tokens ---
type ListTokensResponse struct {
Tokens []models.APIToken `json:"tokens"`
All bool `json:"all"`
All bool `json:"all"`
}
type TokenScopesResponse struct {
@@ -215,8 +232,8 @@ type RunWorkflowResponse struct {
}
type ScheduleResponse struct {
Schedule models.Schedule `json:"schedule"`
NextRunAt *time.Time `json:"next_run_at"`
Schedule models.Schedule `json:"schedule"`
NextRunAt *time.Time `json:"next_run_at"`
}
type OccurrencesResponse struct {
+23 -2
View File
@@ -442,7 +442,20 @@ func listWorkflows(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, wfs)
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([]WorkflowResponse, 0, len(wfs))
for i := range wfs {
w := wfs[i]
ids, hidden := services.FilterVisibleServerIDs(w.TargetServerIDs, visible, restricted)
out = append(out, WorkflowResponse{Workflow: &w, TargetServerIDs: ids, TargetsRestricted: hidden})
}
c.JSON(http.StatusOK, out)
}
// createWorkflow godoc
@@ -490,7 +503,15 @@ func getWorkflow(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, w)
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ids, hidden := services.FilterVisibleServerIDs(w.TargetServerIDs, visible, restricted)
c.JSON(http.StatusOK, WorkflowResponse{Workflow: w, TargetServerIDs: ids, TargetsRestricted: hidden})
}
// updateWorkflow godoc