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
+22
View File
@@ -53,6 +53,28 @@ func SpecFor(m *models.Monitor) checker.Spec {
}
}
// RedactMonitorRunner replaces m.Runner with models.RunnerRestricted when it
// names a server outside the caller's scope, so GET /monitors and GET
// /monitors/:id can keep listing the monitor itself — name, type, state,
// whether it exists at all — as the first-class object it is, without
// disclosing which out-of-scope server it happens to run on. Omitting the
// monitor entirely was considered and rejected: a restricted operator has a
// legitimate reason to see that a monitor exists and is up or down even when
// they cannot manage the host it checks from, and hiding it wholesale is a
// bigger surprise than one field going neutral.
//
// Runner == models.RunnerServer (control-plane-run) is never touched: it
// names no server at all, so there is nothing to redact.
func RedactMonitorRunner(m models.Monitor, visible map[string]bool, restricted bool) models.Monitor {
if !restricted || m.Runner == models.RunnerServer {
return m
}
if !visible[m.Runner] {
m.Runner = models.RunnerRestricted
}
return m
}
func ListMonitors(instanceID string) ([]models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
+53
View File
@@ -23,6 +23,59 @@ func ServerInTokenScope(srv models.Server, sel map[string]string) bool {
return true
}
// VisibleServerIDs resolves the servers tokenScope admits into a membership
// set, for a caller that needs to test many IDs against the caller's scope in
// one pass — redacting a monitor's runner, filtering a workflow's target list
// — rather than resolving one server at a time the way GetServerScoped does.
//
// restricted is false for an empty tokenScope, matching ServerInTokenScope's
// own rule that an empty selector is unrestricted rather than "sees nothing".
// ids is then nil, and callers must treat (nil, false) as "everything
// visible", never as "nothing visible" — the zero value of a map read is
// false, which would silently invert the rule for every unrestricted caller
// if this contract were not honoured.
func VisibleServerIDs(instanceID string, tokenScope map[string]string) (ids map[string]bool, restricted bool, err error) {
if len(tokenScope) == 0 {
return nil, false, nil
}
servers, err := ListServers(instanceID)
if err != nil {
return nil, true, err
}
ids = make(map[string]bool, len(servers))
for _, s := range servers {
if ServerInTokenScope(s, tokenScope) {
ids[s.ServerID] = true
}
}
return ids, true, nil
}
// FilterVisibleServerIDs narrows ids to the ones visible admits, using the
// (ids, restricted) pair VisibleServerIDs returns. An unrestricted caller
// (restricted false) gets ids back unchanged and hidden is always false.
//
// hidden reports only whether at least one id was dropped — never how many —
// because the point of surfacing it at all is to let a caller say "some
// targets are not visible to you" without the count itself becoming the leak
// this exists to close. A workflow that targets both an in-scope and an
// out-of-scope host should not read as "0 hidden" or "3 hidden"; either
// number is information about a fleet outside the caller's scope.
func FilterVisibleServerIDs(ids []string, visible map[string]bool, restricted bool) (filtered []string, hidden bool) {
if !restricted {
return ids, false
}
filtered = make([]string, 0, len(ids))
for _, id := range ids {
if visible[id] {
filtered = append(filtered, id)
} else {
hidden = true
}
}
return filtered, hidden
}
// IntersectSelectors merges the caller's token restriction with a selector the
// request asked for. ok is false when the two can never both hold, which means
// the request resolves to no servers rather than to an error.