Compare commits

..
17 Commits
Author SHA1 Message Date
mrhid6 a2351c75dd feat:runscope test
Chart Release / chart (push) Successful in 23s
Server Deploy / deploy (push) Failing after 58s
2026-09-09 08:43:55 +00:00
mrhid6 3695bc9e1a feat(mcp): declare an input schema and a server-data flag for every tool 2026-09-09 08:41:16 +00:00
mrhid6 9bcec168b9 refactor(api): require every /api route to declare its server-scope status 2026-09-09 08:38:53 +00:00
mrhid6 d95f299562 feat(tokens): refuse minting mcp scopes without the MCP licence feature 2026-09-09 08:36:26 +00:00
mrhid6 ac8e957859 fix(security): scope package search, run documents, workloads and vulnerability reads to the token's tags 2026-09-09 08:36:04 +00:00
mrhid6 6a48dd5d73 fix(security): scope workflow target and monitor runner validation to the caller's tags 2026-09-09 08:33:38 +00:00
mrhid6 5fcfb40084 fix(security): scope workflow run dispatch and MCP run logs to the token's tags 2026-09-09 08:32:32 +00:00
mrhid6 705085d3c7 feat: Fixed spacing 2026-09-09 08:16:06 +00:00
mrhid6 67520c677b feat: show mcp feature row on the licence page 2026-09-09 08:09:46 +00:00
mrhid6 bb698eba8a feat: let an agent create steps, workflows and monitors, inert until a human arms them 2026-09-09 08:07:26 +00:00
mrhid6 3bdbf33f90 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.
2026-09-09 08:03:26 +00:00
mrhid6 e06f9d5670 fix: scope the assignment count leaked by the key list
GET /api/keys returned each key's AssignedCount as a raw
CountDocuments over every non-revoked assignment, with no scope filter —
a tag-restricted token reading the list saw a nonzero count for a key it
can see nothing assigned to in its own scope, which is enough to tell it
an assignment exists on a host it must not know about. Same class of leak
getKey's assignment-list filter closed on the detail route, surviving on
the list route through a count instead of a server object.

services.ListKeys now takes the caller's tokenScope. An unrestricted
caller (empty scope) takes the original unfiltered per-key
CountDocuments with no extra work, so the common case is not slower. A
restricted caller resolves the visible fleet once via ListServers before
the per-key loop, then counts each key's assignments with an added
server_id $in filter — one extra query total, not one per key.
ListKeys had exactly one caller (listKeys), so the parameter went there
rather than adding a second entry point.

Recorded GET /api/keys in serverScopedRoutes as true; its path, like GET
/api/keys/:id, matches none of serverTouchingRoutes' substrings, so the
entry is not boot-enforced. Deliberately did not widen the filter to
catch "keys" — that would sweep in create/delete/private-key routes with
no server data at all. The real fix for this shape of gap is the
declare-by-default inversion already recorded as a follow-up.
2026-09-09 07:54:42 +00:00
mrhid6 dc6e1b3c29 fix: widen server-scope boot check, filter out-of-scope key assignments
serverTouchingRoutes in cmd/main.go filtered on "server"/"console"/an exact
workflows-run match, which is how POST /api/keys/:id/assign reached
production with no scope check and no boot-time signal at all: its path
names neither. Widen the filter to also match ":serverId" and "assign",
and document at the filter why a substring match is the weak part of this
design — a route that acts on a server without saying so in its path stays
invisible to it — noting that inverting the model (every /api route
declares itself, with an exemption list) would be the stronger fix and is
left as a follow-up. Re-running the mechanical check against the widened
filter swept in no route beyond what serverScopedRoutes already declared.

GET /api/keys/:id also leaked out-of-scope hostnames: it returned every
assignment for a key, server attached, unfiltered by the caller's tag
restriction. getKey now drops any assignment whose server fails
services.ServerInTokenScope before returning the list — silently, so the
response carries no count of what was removed — while still returning the
key itself, since a restricted token may legitimately hold a key also
assigned outside its scope. GetAssignmentsWithServers has exactly one
caller (getKey), so the filtering is done in the handler rather than
threaded into the service. Recorded in serverScopedRoutes; its path
matches none of the filter's substrings either, so it is not boot-enforced
and is kept as a considered decision, same as the assign/revoke entries.
2026-09-09 07:51:04 +00:00
mrhid6 cbf929fe2d fix: audit refused and failed mcp write calls, scope key assignment to token
Every early return from a write-tool handler skipped both the tool's own
LogCall and transport.go's gated LogCall (which only fires for reads), so a
blocked mutation attempt left no audit trail. registerSDKTool now routes
every write-tool error through LogDenied (fan-out and tag-scope refusals,
by gate name) or LogFailure (everything else), keeping the successful-write
path logging its own resolved server count exactly as before.

Also close a live scope gap surfaced while reviewing this: POST
/api/keys/:id/assign called services.AssignKey with an unscoped GetServer
lookup, so a tag-restricted token could assign a key to a server outside
its restriction. The handler now resolves the target through
GetServerScoped first, matching its sibling revoke route, and the route is
recorded in serverScopedRoutes.
2026-09-09 07:46:45 +00:00
mrhid6 b3651ab58c feat: add mcp write tools with a fan-out guard 2026-09-09 07:28:48 +00:00
mrhid6 191a8e9074 fix(mcp): correct fleet online status, unsafe version filter, audit totals
summariseServer compared Status against "online", a value never assigned
anywhere (the real vocabulary is pending/active/offline), so every server
misreported as offline. search_fleet's version_below used a lexicographic
comparison across dpkg/rpm/apk version schemes with no common ordering, so
it refuses that filter now and returns all matches instead of a wrong
answer. listAuditResult's Total carried the "shown" JSON tag and the
capped count; it now reports the real total alongside shown.
2026-09-09 07:24:09 +00:00
mrhid6 ed79df4270 fix: restore the vantage-shared go.sum entries dropped by go mod tidy 2026-09-09 07:17:53 +00:00
38 changed files with 2583 additions and 191 deletions
+14 -11
View File
@@ -293,7 +293,7 @@ func serve() {
log.Fatalf("api scope map: %v", err)
}
if err := api.AssertServerScopeMapComplete(serverTouchingRoutes(r)); err != nil {
if err := api.AssertServerScopeMapComplete(apiRoutes(r)); err != nil {
log.Fatalf("api server scope map: %v", err)
}
@@ -359,21 +359,24 @@ func boolEnv(key string) bool {
return false
}
// serverTouchingRoutes restricts AssertServerScopeMapComplete to routes whose
// pattern names server-derived data — "server", "console" or
// "workflows/:id/run" — rather than every routeScopes entry, so unrelated
// routes are never swept in and boot never fails for no reason.
func serverTouchingRoutes(r *gin.Engine) []string {
// apiRoutes lists every registered /api route as "METHOD /path", which is the
// whole input AssertServerScopeMapComplete now takes.
//
// It replaces a substring filter that fed in only routes whose path contained
// "server", ":serverId", "console" or "assign". That filter could only ever
// catch a route whose *path* named a server, and a route can act on one named
// in its body, in a query parameter, or derived by the handler — it caught one
// of the leaks found in the final review of the MCP feature, and none of the
// eleven found during implementation. Declaring every route is more typing
// once and no maintenance after: a new route fails boot until somebody answers
// "does this touch server data?" for it.
func apiRoutes(r *gin.Engine) []string {
var out []string
for _, route := range r.Routes() {
if !strings.HasPrefix(route.Path, "/api/") {
continue
}
if strings.Contains(route.Path, "server") ||
strings.Contains(route.Path, "console") ||
route.Path == "/api/workflows/:id/run" {
out = append(out, route.Method+" "+route.Path)
}
out = append(out, route.Method+" "+route.Path)
}
return out
}
+2
View File
@@ -1,3 +1,5 @@
gitea.hostxtra.co.uk/vantage/vantage-shared v0.2.1 h1:rPzXSRwU+4+F2pdkmDrIxKsIzqz3S6feJEWalGmKqfU=
gitea.hostxtra.co.uk/vantage/vantage-shared v0.2.1/go.mod h1:dWjeOFLltQ8sv9Pnn1xRxGfWGgqa2fkG0esuaJLoPXQ=
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986 h1:2a30xLN2sUZcMXl50hg+PJCIDdJgIvIbVcKqLJ/ZrtM=
github.com/aquasecurity/bolt-fixtures v0.0.0-20200903104109-d34e7f983986/go.mod h1:NT+jyeCzXk6vXR5MTkdn4z64TgGfE5HMLC8qfj5unl8=
github.com/aquasecurity/trivy-db v0.0.0-20260813095258-0e0340a01b57 h1:A3Lz/9ip/qigafSxqBWcu7S8i+tJbQS7DB2V0XibOKs=
+24 -3
View File
@@ -489,7 +489,7 @@ func generateKey(c *gin.Context) {
// @Security bearerAuth
// @Router /keys [get]
func listKeys(c *gin.Context) {
keys, err := services.ListKeys(auth.InstanceID(c))
keys, err := services.ListKeys(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -573,7 +573,22 @@ func getKey(c *gin.Context) {
return
}
assignments, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
all, _ := services.GetAssignmentsWithServers(auth.InstanceID(c), id)
// A tag-restricted token may legitimately hold a key that is also
// assigned to a server outside its restriction — the key itself is
// still returned above. Only the assignment list is filtered, and
// silently: an assignment whose Server is nil or out of scope is
// dropped rather than kept with the hostname redacted, so the response
// gives no signal — not even a count — of what was removed.
scope := auth.ServerScope(c)
assignments := make([]services.AssignmentWithServer, 0, len(all))
for _, a := range all {
if a.Server != nil && !services.ServerInTokenScope(*a.Server, scope) {
continue
}
assignments = append(assignments, a)
}
c.JSON(http.StatusOK, KeyDetailResponse{
Key: key,
@@ -623,6 +638,7 @@ func deleteKey(c *gin.Context) {
// @Router /keys/{id}/assign [post]
func assignKey(c *gin.Context) {
keyID := c.Param("id")
instanceID := auth.InstanceID(c)
var body struct {
ServerID string `json:"server_id" binding:"required"`
}
@@ -631,7 +647,12 @@ func assignKey(c *gin.Context) {
return
}
a, err := services.AssignKey(auth.InstanceID(c), keyID, body.ServerID)
if _, err := services.GetServerScoped(instanceID, body.ServerID, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
a, err := services.AssignKey(instanceID, keyID, body.ServerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+21 -3
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)
}
@@ -73,7 +83,7 @@ func createMonitor(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
created, err := services.CreateMonitor(auth.InstanceID(c), &m)
created, err := services.CreateMonitor(auth.InstanceID(c), &m, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -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
@@ -168,7 +186,7 @@ func updateMonitor(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "no fields to update"})
return
}
if err := services.UpdateMonitor(auth.InstanceID(c), c.Param("id"), upd); err != nil {
if err := services.UpdateMonitor(auth.InstanceID(c), c.Param("id"), upd, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
+55
View File
@@ -0,0 +1,55 @@
package api
import (
"testing"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
)
func runFixture() *models.WorkflowRun {
return &models.WorkflowRun{
RunID: "r1",
ServerRuns: []models.ServerRun{
{ServerID: "stg-1", Hostname: "staging-web"},
{ServerID: "prod-1", Hostname: "prod-db"},
},
}
}
// A run document names every host it touched, hostname included. A restricted
// caller must see only its own, and must be told some entries are missing
// without being told how many — the targets_restricted precedent.
func TestScopeRunHidesOutOfScopeServerRuns(t *testing.T) {
got := scopeRun(runFixture(), map[string]bool{"stg-1": true}, true)
if len(got.ServerRuns) != 1 || got.ServerRuns[0].ServerID != "stg-1" {
t.Fatalf("server_runs = %v, want only stg-1", got.ServerRuns)
}
for _, sr := range got.ServerRuns {
if sr.Hostname == "prod-db" {
t.Error("out-of-scope hostname survived filtering")
}
}
if !got.ServersRestricted {
t.Error("servers_restricted = false with an entry dropped")
}
}
func TestScopeRunLeavesUnrestrictedCallerWhole(t *testing.T) {
got := scopeRun(runFixture(), nil, false)
if len(got.ServerRuns) != 2 {
t.Fatalf("server_runs = %v, want both", got.ServerRuns)
}
if got.ServersRestricted {
t.Error("unrestricted caller told entries were restricted")
}
}
// A restricted caller whose scope happens to cover the whole run must not be
// told anything was hidden — the flag is about disclosure, not about being
// restricted in general.
func TestScopeRunNoFlagWhenNothingDropped(t *testing.T) {
got := scopeRun(runFixture(), map[string]bool{"stg-1": true, "prod-1": true}, true)
if got.ServersRestricted {
t.Error("servers_restricted set with nothing dropped")
}
}
+365 -65
View File
@@ -2,60 +2,71 @@ package api
import "fmt"
// serverScopedRoutes declares, for every route that returns or acts on
// server-derived data, whether it honours the acting token's tag restriction.
// scopeDecl is one route's declaration about server-derived data.
type scopeDecl int
const (
// scoped: the handler honours the acting token's tag restriction.
scoped scopeDecl = iota
// fleetWide: the route deliberately reaches the whole fleet. Every
// fleetWide entry carries a comment giving the reason. It must never mean
// "not scoped yet" — an unresolved gap belongs on a fix list, not here,
// because this value is read as a considered decision.
fleetWide
// exempt: the route touches no server-derived data at all. Every exempt
// entry carries a comment saying why, because "this reads no server data"
// is exactly the claim that turns out to be wrong when a handler later
// grows a server lookup.
exempt
)
// serverScopedRoutes declares, for EVERY registered /api route, whether it
// honours the acting token's tag restriction.
//
// It is a maintained map for the same reason routeScopes is: a route added
// tomorrow that reads server data without filtering would leak a restricted
// token's blind spot silently, and this turns that into a failure at boot.
// The declaration is inverted from what it used to be. It was once a partial
// map checked only against routes whose path contained "server", "console" or
// "assign"; that filter caught one of the routes found leaking in the final
// review of this feature, and none of the eleven found during implementation,
// because a route can act on a server named in its body, in a query parameter
// or derived by the handler, with a path saying nothing about it. Every route
// must now appear here with an explicit value and boot fails on an undeclared
// one, so the question "does this touch server data?" is asked once per route
// by construction rather than when someone thinks to widen a pattern.
//
// false means the route is deliberately fleet-wide and requires a comment
// explaining the deliberate reason. It must never mean "not scoped yet" —
// an unresolved gap belongs on the fix list, not in this map, because a false
// entry here is read as a considered decision, not a placeholder.
var serverScopedRoutes = map[string]bool{
"GET /api/servers": true,
"GET /api/servers/:id": true,
"DELETE /api/servers/:id": true,
"POST /api/servers/:id/apply-updates": true,
"POST /api/servers/:id/update-agent": true,
"PUT /api/servers/:id/tags": true,
"POST /api/servers/:id/generate-key": true,
"POST /api/console/connect": true,
"GET /api/console/tunnel": true,
"POST /api/workflows/:id/run": true,
// Two things this assertion cannot do, and one of them has already bitten:
//
// 1. It can only ever check that a DECLARATION EXISTS, never that the handler
// honours it. "POST /api/workflows/:id/run" was declared scoped here while
// services.TriggerWorkflow resolved its targets through the unscoped
// ResolveTargets — a true entry that lied, boot-enforced, for the whole
// life of the feature. A declaration is a claim a reviewer must verify,
// not a property this file establishes.
//
// 2. /api/mcp is exempt at route level, and that is the honest answer rather
// than an omission. One route serves roughly twenty tools of very
// different shapes — some read no server data at all, some resolve one
// host, some enumerate the fleet — so no single route-level value could
// be true of all of them. The decision genuinely lives per tool, where
// each tool that touches server data applies auth.ServerScope's selector
// itself, and the registry's own tests are where that is enforced.
var serverScopedRoutes = map[string]scopeDecl{
// Workload routes all resolve the server through GetServerScoped before
// touching anything.
"GET /api/servers/:id/workloads": true,
"POST /api/servers/:id/workloads/refresh": true,
"POST /api/servers/:id/workloads/:wid/action": true,
"GET /api/servers/:id/workloads/:wid/logs": true,
// ---- servers ----
// listServerVulnerabilities and getServerPackages now resolve the server
// through GetServerScoped before calling ListFindings/ListPackages, so an
// out-of-scope server ID reads as not-found before either function runs.
"GET /api/servers/:id/vulnerabilities": true,
"GET /api/servers/:id/packages": true,
// getServerRunLog/streamServerRunLog resolve :serverId through
// GetServerScoped before reading anything from the log store, so a
// restricted token holding a valid runId still cannot read output from a
// server outside its scope.
"GET /api/runs/:runId/servers/:serverId/logs": true,
"GET /api/runs/:runId/servers/:serverId/logs/stream": true,
// revokeAssignment resolves :serverId through GetServerScoped before
// calling RevokeAssignment, so a restricted token cannot mutate an
// assignment on a server outside its scope.
"DELETE /api/keys/:id/assign/:serverId": true,
"GET /api/servers": scoped,
"GET /api/servers/:id": scoped,
"DELETE /api/servers/:id": scoped,
"POST /api/servers/:id/apply-updates": scoped,
"POST /api/servers/:id/update-agent": scoped,
"PUT /api/servers/:id/tags": scoped,
"POST /api/servers/:id/generate-key": scoped,
// Creating a server has no server to filter yet.
"POST /api/servers": false,
"POST /api/servers": fleetWide,
// The agent's own enrolment routes authenticate as the agent, not as a
// user token, so no session selector exists to apply.
"GET /api/servers/new": false,
"POST /api/servers/new": false,
"GET /api/servers/new": fleetWide,
"POST /api/servers/new": fleetWide,
// KnownTags aggregates the tag *vocabulary* in use across the fleet — keys
// and the values seen for them — never a server identifier or any other
@@ -63,30 +74,319 @@ var serverScopedRoutes = map[string]bool{
// hosts exist. Filtering it would mean plumbing a selector through an
// aggregation query for a leak that carries no server identity; ruled
// acceptable to leave fleet-wide rather than take that on for this.
"GET /api/servers/tags": false,
"GET /api/servers/tags": fleetWide,
// ---- console ----
"POST /api/console/connect": scoped,
"GET /api/console/tunnel": scoped,
// ---- workloads ----
// Workload routes all resolve the server through GetServerScoped before
// touching anything.
"GET /api/servers/:id/workloads": scoped,
"POST /api/servers/:id/workloads/refresh": scoped,
"POST /api/servers/:id/workloads/:wid/action": scoped,
"GET /api/servers/:id/workloads/:wid/logs": scoped,
// 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": scoped,
// ---- vulnerabilities and packages ----
// listServerVulnerabilities and getServerPackages resolve the server
// through GetServerScoped before calling ListFindings/ListPackages, so an
// out-of-scope server ID reads as not-found before either function runs.
"GET /api/servers/:id/vulnerabilities": scoped,
"GET /api/servers/:id/packages": scoped,
// 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": scoped,
"GET /api/vulnerabilities/summary": scoped,
// 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": scoped,
// Rescan flags the whole fleet and returns a count of servers flagged, not
// their identities. Scanning is a control-plane background job with no
// caller-visible per-server effect, and a partial rescan would leave the
// findings a restricted token *can* see computed against a stale database.
// Owner|admin only in any case.
"POST /api/vulnerabilities/rescan": fleetWide,
// Accepting or reopening a finding names the finding, not a server, but a
// finding does belong to one — so a restricted token can accept a finding
// on a host outside its scope if it learns the finding ID. It cannot learn
// one through this API any more (every listing is now scoped), so this is
// left fleet-wide rather than given a lookup of its own. Owner|admin only.
"POST /api/vulnerabilities/:id/accept": fleetWide,
"DELETE /api/vulnerabilities/:id/accept": fleetWide,
// Vuln alert rules carry severities and tag selectors, never server IDs.
"GET /api/vuln-rules": exempt,
"POST /api/vuln-rules": exempt,
"PUT /api/vuln-rules/:id": exempt,
"DELETE /api/vuln-rules/:id": exempt,
// ---- keys ----
// getKey filters services.GetAssignmentsWithServers' result down to
// assignments whose server passes services.ServerInTokenScope before
// returning it, so a restricted token cannot learn the hostname of an
// out-of-scope server through a key it happens to also hold there. The
// key document itself is still returned unfiltered — a token restricted
// to staging may legitimately hold a key that is also assigned in prod,
// and only the assignment list, not the key's existence, is the leak
// this closes.
"GET /api/keys/:id": scoped,
// listKeys' services.ListKeys narrows each key's AssignedCount to
// assignments on servers ServerInTokenScope admits, for the same reason
// as getKey above: a nonzero count on a key a restricted token sees
// nothing assigned to in its own scope is itself the leak — it tells the
// token an assignment exists on a host it must not know about, without
// naming the host.
"GET /api/keys": scoped,
// assignKey resolves body.ServerID through GetServerScoped before calling
// services.AssignKey, and revokeAssignment resolves :serverId the same way
// before calling RevokeAssignment.
"POST /api/keys/:id/assign": scoped,
"DELETE /api/keys/:id/assign/:serverId": scoped,
// Uploading a key and reading its stored private half touch no server:
// a key exists in the library before it is assigned anywhere.
"POST /api/keys": exempt,
"GET /api/keys/:id/private-key": exempt,
// Deleting a key removes it everywhere it is assigned, including on hosts
// outside a restricted token's scope — the delete is of the key, not of a
// server, and there is no partial delete that leaves a key half-revoked.
// Nothing about which hosts held it is disclosed by the call.
"DELETE /api/keys/:id": fleetWide,
// ---- workflows, steps and runs ----
// 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.
"GET /api/workflows": scoped,
"GET /api/workflows/:id": scoped,
// createWorkflow/updateWorkflow validate target_server_ids through
// services.validateTargetServers, which resolves each named ID with
// GetServerScoped — so a restricted token can neither save a workflow
// targeting a host outside its scope (which the scheduler, firing as the
// system, would otherwise run there) nor learn which IDs exist by the
// difference between "target server not found" and a successful save.
"POST /api/workflows": scoped,
"PUT /api/workflows/:id": scoped,
// runWorkflow passes auth.ServerScope into services.TriggerWorkflow, which
// resolves through ResolveTargetsScoped. Note the history: this entry read
// scoped for the whole life of the feature while TriggerWorkflow called
// the UNSCOPED ResolveTargets — see this file's header on what this
// assertion can and cannot prove.
"POST /api/workflows/:id/run": scoped,
// 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.
"GET /api/runs/:runId": scoped,
"GET /api/workflows/:id/runs": scoped,
// getServerRunLog/streamServerRunLog resolve :serverId through
// GetServerScoped before reading anything from the log store, so a
// restricted token holding a valid runId still cannot read output from a
// server outside its scope.
"GET /api/runs/:runId/servers/:serverId/logs": scoped,
"GET /api/runs/:runId/servers/:serverId/logs/stream": scoped,
// Deleting a workflow, cancelling a run and arming a schedule all act on a
// definition rather than on a server, and none of them returns server
// data. Each can nevertheless reach a definition whose targets a
// restricted token cannot see — a cancel stops work on out-of-scope hosts,
// a schedule arms it there. That reach is real but bounded: the caller
// learns nothing about which hosts are involved (both /workflows listings
// are scoped), and a scope-narrowed variant of "cancel this run" would
// have to either half-cancel a run or refuse one whose targets are mixed,
// neither of which is a better answer than the current one. Recorded as a
// deliberate choice, not an oversight.
"DELETE /api/workflows/:id": fleetWide,
"POST /api/runs/:runId/cancel": fleetWide,
"PUT /api/workflows/:id/schedule": fleetWide,
"GET /api/workflows/:id/schedule/preview": exempt,
// A step is a script with declared inputs and outputs. It names no server
// and is not bound to one; targeting happens at the workflow level.
"GET /api/steps": exempt,
"POST /api/steps": exempt,
"PUT /api/steps/:id": exempt,
"DELETE /api/steps/:id": exempt,
"GET /api/steps/:id/export": exempt,
"POST /api/steps/import": exempt,
"POST /api/steps/parse": exempt,
"POST /api/steps/seed-defaults": exempt,
// StepUsageCounts counts workflows per step, never servers.
"GET /api/steps/usage": exempt,
// ---- monitors ----
// 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. 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. Runner "server" (control-plane-run) is
// never touched — it names no server.
"GET /api/monitors": scoped,
"GET /api/monitors/:id": scoped,
// createMonitor/updateMonitor validate the runner — which is a server ID
// for an agent-pushed monitor — through services.validateRunner, resolving
// with GetServerScoped so a restricted token can neither point a check at
// an out-of-scope agent nor use the not-found answer as an oracle.
"POST /api/monitors": scoped,
"PUT /api/monitors/:id": scoped,
// Deleting a monitor removes the check, not a server, and returns nothing
// about where it ran. The runner field it might have named is already
// redacted on every read path, so a restricted token cannot learn one to
// then act on.
"DELETE /api/monitors/:id": fleetWide,
// A monitor's incidents, uptime rollups and recent samples are all about
// the monitored endpoint — status, latency, timestamps — and carry no
// server identifier at all; the runner is a field of the monitor
// document, which these do not return.
"GET /api/monitors/:id/incidents": exempt,
"GET /api/monitors/:id/uptime": exempt,
"GET /api/monitors/:id/samples": exempt,
// ---- notification channels ----
// A channel is an outbound destination — a webhook URL, an SMTP account.
// Nothing about a server reaches these routes.
"GET /api/channels": exempt,
"POST /api/channels": exempt,
"PUT /api/channels/:id": exempt,
"DELETE /api/channels/:id": exempt,
"POST /api/channels/:id/test": exempt,
// ---- secrets ----
// Vault secrets are key/value pairs grouped by name, consumed by workflow
// steps at execution time. No secret is bound to a server, and no server
// attribute is returned by any of these.
"GET /api/secrets": exempt,
"POST /api/secrets": exempt,
"GET /api/secrets/:group": exempt,
"PUT /api/secrets/:group": exempt,
"DELETE /api/secrets/:group": exempt,
"DELETE /api/secrets/:group/:key": exempt,
"POST /api/secrets/:group/reveal": exempt,
// ---- status pages ----
// A status page pairs monitor IDs with per-page display names, and every
// public read goes through services.assembleSnapshot, which is the
// redaction boundary — its PublicComponent vocabulary has no field for a
// host, URL or runner. These authoring routes handle the page document
// itself and never a server.
"GET /api/status-pages": exempt,
"POST /api/status-pages": exempt,
"GET /api/status-pages/:pageId": exempt,
"PUT /api/status-pages/:pageId": exempt,
"DELETE /api/status-pages/:pageId": exempt,
"GET /api/status-pages/:pageId/incidents": exempt,
"POST /api/status-pages/:pageId/incidents": exempt,
"PUT /api/status-pages/:pageId/incidents/:incidentId": exempt,
"DELETE /api/status-pages/:pageId/incidents/:incidentId": exempt,
"POST /api/status-pages/:pageId/incidents/:incidentId/updates": exempt,
// ---- audit ----
// Audit rows are a record of what people and tokens did, and a row's free
// text detail can name a host in passing ("run <id> triggered", "key
// assigned to web-01"). Filtering the log by tag would mean parsing those
// strings, or dropping every row whose target this token cannot resolve —
// which would hide a restricted token's own actions from itself the
// moment a server is renamed or deleted. The log is left whole and
// deliberately so: an audit trail with holes in it is worth less than the
// disclosure is worth avoiding, and the route is settings:read.
"GET /api/audit": fleetWide,
// ---- instance administration ----
// Members, roles, single sign-on, settings and the licence are all
// instance-level configuration. None reads the servers collection.
"GET /api/instance/users": exempt,
"POST /api/instance/users": exempt,
"PUT /api/instance/users/:id/role": exempt,
"DELETE /api/instance/users/:id": exempt,
"GET /api/auth/providers": exempt,
"POST /api/auth/providers": exempt,
"PUT /api/auth/providers/:id": exempt,
"DELETE /api/auth/providers/:id": exempt,
"POST /api/auth/providers/:id/test": exempt,
"POST /api/auth/providers/:id/ack-notice": exempt,
"GET /api/auth/presets": exempt,
"GET /api/settings": exempt,
"PUT /api/settings": exempt,
"POST /api/settings/secrets-token": exempt,
"GET /api/license": exempt,
"POST /api/license": exempt,
// A token document carries a tag selector but no server: minting one
// checks the selector is no wider than the caller's own
// (services.SelectorNarrowerOrEqual), which reads the caller's session,
// not the fleet.
"GET /api/tokens": exempt,
"GET /api/tokens/scopes": exempt,
"POST /api/tokens": exempt,
"DELETE /api/tokens/:id": exempt,
// Reference documentation and the agent version lookup are static or read
// from a release feed.
"GET /api/openapi.json": exempt,
"GET /api/docs": exempt,
"GET /api/docs/scalar.js": exempt,
"GET /api/agent/latest-version": exempt,
// ---- MCP ----
// Exempt at route level, for the reason set out in this file's header:
// one route serves many tools, so the answer genuinely lives per tool.
// Each tool touching server data applies the caller's selector itself.
"POST /api/mcp": exempt,
"GET /api/mcp": exempt,
}
// POST/GET /api/mcp is deliberately absent from this map. main.go's
// serverTouchingRoutes only feeds in routes whose path contains "server" or
// "console" (or the one named workflow-run exception), and /api/mcp matches
// none of those, so it is never presented to AssertServerScopeMapComplete —
// there is nothing to declare true or false here. That is the right outcome:
// the single MCP route fronts many tools of very different shapes, several of
// which touch no server data at all, so a route-level entry could not say
// anything meaningful about tag scoping. Each tool that does read or act on
// server data applies auth.ServerScope itself, the same selector the REST
// handlers for those resources already apply, which is where this kind of
// scoping decision belongs for a many-operations-per-route endpoint.
// AssertServerScopeMapComplete refuses to boot when a route touching server
// data is missing from serverScopedRoutes.
// AssertServerScopeMapComplete refuses to boot when any registered /api route
// is missing from serverScopedRoutes. routes is every /api route the engine
// registered — not a filtered subset — which is the whole point of the
// inversion: a new route is checked by default rather than only when its path
// happens to match a pattern somebody remembered to add.
func AssertServerScopeMapComplete(routes []string) error {
for _, r := range routes {
if _, ok := serverScopedRoutes[r]; !ok {
if _, guarded := routeScopes[r]; !guarded {
continue
}
return fmt.Errorf("route %q is not declared in serverScopedRoutes", r)
return fmt.Errorf("route %q is not declared in serverScopedRoutes "+
"(declare it scoped, fleetWide with a reason, or exempt with a reason)", r)
}
}
return nil
+32
View File
@@ -0,0 +1,32 @@
package api
import "testing"
// The two maps must name exactly the same routes. AssertScopeMapComplete
// already fails boot on an /api route missing from routeScopes, so making
// serverScopedRoutes agree with routeScopes is what makes the inverted
// server-scope assertion total without needing a running engine to check it.
func TestServerScopeMapCoversEveryScopedRoute(t *testing.T) {
for r := range routeScopes {
if _, ok := serverScopedRoutes[r]; !ok {
t.Errorf("route %q is in routeScopes but not declared in serverScopedRoutes", r)
}
}
for r := range serverScopedRoutes {
if _, ok := routeScopes[r]; !ok {
t.Errorf("route %q is declared in serverScopedRoutes but is not a registered route", r)
}
}
}
// A route that vanished from the engine but stayed here would make the
// assertion pass while declaring nothing real, so the assertion itself is
// tested for the one thing it does promise: an undeclared route fails.
func TestAssertServerScopeMapCompleteRejectsUndeclaredRoute(t *testing.T) {
if err := AssertServerScopeMapComplete([]string{"GET /api/servers"}); err != nil {
t.Fatalf("declared route rejected: %v", err)
}
if err := AssertServerScopeMapComplete([]string{"GET /api/brand-new"}); err == nil {
t.Fatal("undeclared route accepted; boot would not fail on it")
}
}
+28 -2
View File
@@ -4,10 +4,12 @@ import (
"errors"
"fmt"
"net/http"
"strings"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
"gitea.hostxtra.co.uk/vantage/vantage-shared/license"
"github.com/gin-gonic/gin"
)
@@ -72,8 +74,8 @@ func listTokenScopes(c *gin.Context) {
// @Router /tokens [post]
func createToken(c *gin.Context) {
var body struct {
Name string `json:"name" binding:"required"`
Role string `json:"role" binding:"required"`
Name string `json:"name" binding:"required"`
Role string `json:"role" binding:"required"`
Scopes []string `json:"scopes" binding:"required"`
ExpiresInDays *int `json:"expires_in_days"`
TagSelector map[string]string `json:"tag_selector"`
@@ -109,6 +111,30 @@ func createToken(c *gin.Context) {
}
}
// The MCP scopes are refused without the licence feature, matching the
// guard-at-source thinking in services/packages.go rather than relying on
// RequireFeature at the /api/mcp group alone. That gate is a runtime one:
// without this, a licence downgrade leaves live agent credentials that
// authenticate, list as MCP tokens in the UI, and then fail mid-
// conversation with a 403 the model cannot explain. Refusing at minting
// means a token carrying mcp:* only ever existed while the feature did.
//
// Existing tokens are deliberately untouched by a downgrade: the route
// gate already stops them reaching the endpoint, and silently revoking
// credentials on a billing change is worse than refusing new ones.
if !services.GetLicenseState(auth.InstanceID(c)).Feature(license.FeatureMCP) {
for _, s := range body.Scopes {
if strings.HasPrefix(s, "mcp:") {
c.JSON(http.StatusForbidden, gin.H{
"error": "feature_unavailable",
"feature": license.FeatureMCP,
"code": "feature_unavailable",
})
return
}
}
}
if !services.SelectorNarrowerOrEqual(body.TagSelector, auth.ServerScope(c)) {
c.JSON(http.StatusForbidden, gin.H{
"error": "a token cannot reach servers its creator cannot reach",
+55 -4
View File
@@ -114,6 +114,57 @@ 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"`
}
// 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"`
@@ -121,14 +172,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 +266,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 {
+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
+44 -8
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
@@ -464,7 +477,7 @@ func createWorkflow(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
out, err := services.CreateWorkflow(auth.InstanceID(c), w)
out, err := services.CreateWorkflow(auth.InstanceID(c), w, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -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
@@ -513,7 +534,7 @@ func updateWorkflow(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := services.UpdateWorkflow(auth.InstanceID(c), c.Param("id"), w); err != nil {
if err := services.UpdateWorkflow(auth.InstanceID(c), c.Param("id"), w, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
@@ -560,7 +581,7 @@ func deleteWorkflow(c *gin.Context) {
// @Security bearerAuth
// @Router /workflows/{id}/run [post]
func runWorkflow(c *gin.Context) {
runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c))
runID, err := services.TriggerWorkflow(auth.InstanceID(c), c.Param("id"), actorFromCtx(c), auth.ServerScope(c))
if err != nil {
if errors.Is(err, services.ErrNoTargets) {
c.JSON(http.StatusBadRequest, gin.H{"error": "this workflow matches no servers"})
@@ -597,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
@@ -606,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
@@ -617,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
+45 -2
View File
@@ -14,10 +14,32 @@ import (
// the whole fleet on one badly phrased instruction.
const FanOutLimit = 25
// Gate names for a write tool's own policy refusals, on top of GateMCPScope
// and GateResourceScope in registry.go. These name a decision this package
// made deliberately, so a human reading audit_logs can tell "the agent was
// stopped by policy" from "the agent tried and the machine failed".
const (
GateFanOut = "fan_out"
GateTagScope = "tag_selector"
)
// ErrConfirmRequired is returned to the model as a tool error it can act on:
// it says what would have happened and how to proceed deliberately.
var ErrConfirmRequired = errors.New("confirmation required")
// ErrOutOfScope wraps a write tool's refusal to act because the resolved (or,
// for run_workflow, the workflow's configured) targets are not entirely
// within the calling token's tag restriction. Handlers wrap this rather than
// returning a bare error so registerSDKTool can tell a scope refusal apart
// from an ordinary service failure and audit it as GateTagScope.
var ErrOutOfScope = errors.New("targets outside token scope")
// logEvent is services.LogEvent behind a package variable so tests can
// observe what would have been audited without a live database connection —
// services.LogEvent talks straight to Mongo via db.Col, which panics on a nil
// client outside a real boot.
var logEvent = services.LogEvent
// CheckFanOut refuses a write that would touch more servers than FanOutLimit
// unless the call passed confirm:true.
func CheckFanOut(count int, args map[string]any) error {
@@ -84,12 +106,33 @@ func LogCall(c Caller, t Tool, args map[string]any, servers int) {
if servers > 0 {
detail += fmt.Sprintf(", %d server(s) affected", servers)
}
services.LogEvent(c.InstanceID, "mcp.tool_call", c.TokenName, "", "", detail)
logEvent(c.InstanceID, "mcp.tool_call", c.TokenName, "", "", detail)
}
// LogDenied records a refusal and which gate refused, which is what turns "the
// agent said it could not" into a diagnosable event.
func LogDenied(c Caller, toolName, gate string) {
services.LogEvent(c.InstanceID, "mcp.tool_denied", c.TokenName, "", "",
logEvent(c.InstanceID, "mcp.tool_denied", c.TokenName, "", "",
fmt.Sprintf("tool %s refused by %s", toolName, gate))
}
// LogFailure records a write tool call that reached a service and that
// service returned an error — as opposed to LogDenied, which records a
// policy refusal that never reached one. Distinguishing the two in
// audit_logs is what lets a human reading it tell "the agent was stopped"
// from "the agent tried and the machine failed".
func LogFailure(c Caller, t Tool, args map[string]any, err error) {
logEvent(c.InstanceID, "mcp.tool_failed", c.TokenName, "", "",
fmt.Sprintf("tool %s (%s) failed: %v", t.Name, SummariseArgs(args), err))
}
// LogCreated records a definition an agent added.
//
// It is a distinct event type rather than another mcp.tool_call row because of
// the question a human will actually ask, which is "what has this agent added
// to my instance" — an answer buried among hundreds of read rows is not an
// answer.
func LogCreated(c Caller, kind, id, name string) {
logEvent(c.InstanceID, "mcp.created", c.TokenName, "", "",
fmt.Sprintf("created %s %q (%s)", kind, name, id))
}
+93 -2
View File
@@ -34,6 +34,42 @@ type Caller struct {
TokenName string
}
// ArgType is the JSON type of one declared tool argument. The set is closed
// deliberately: these are the only shapes the argument helpers in this package
// (stringArg, stringSliceArg, tagArg, pageLimit) can actually decode, so a
// schema promising anything else would advertise an argument no handler could
// read.
type ArgType string
const (
ArgString ArgType = "string"
ArgInteger ArgType = "integer"
ArgBoolean ArgType = "boolean"
ArgStringArray ArgType = "string_array"
// ArgTagMap is a flat object of string tag keys to string values, which is
// what tagArg decodes.
ArgTagMap ArgType = "tag_map"
// ArgObject is a free-form object whose inner shape the tool documents in
// the argument description — create_monitor's target, whose fields differ
// per monitor type.
ArgObject ArgType = "object"
)
// ToolArg declares one argument a tool actually reads.
//
// Without this, a tool's arguments existed only in prose inside its
// Description and in the handler's args[...] lookups: no client could discover
// limit, tags, cursor, confirm, server_ids or any of the rest, so a model had
// to guess them from the description or not use them at all. Declaring them
// here also gets the SDK to validate and reject a malformed call before the
// handler runs, which is where a required-argument check belongs.
type ToolArg struct {
Name string
Type ArgType
Description string
Required bool
}
// Tool is one registered capability.
type Tool struct {
Name string
@@ -44,8 +80,63 @@ type Tool struct {
Scope string
// Write marks a tool that changes something. A write tool is omitted from
// the listing for a caller without mcp:write.
Write bool
Handler ToolFunc
Write bool
// Args declares every argument the handler reads, in the order a client
// should see them. A tool taking none declares an empty slice, which is
// distinct from "nobody has written the schema yet" — see the registry
// tests, which require the declaration to be deliberate.
Args []ToolArg
// TouchesServers marks a tool that returns or acts on server-derived data:
// a hostname, a server ID, a package list, a run's per-server output. Such
// a tool must apply Caller.TokenScope, through GetServerScoped,
// ResolveTargetsScoped, ListServersFiltered or VisibleServerIDs.
//
// Like serverScopedRoutes in the api package, this can only ever assert
// that a declaration exists, never that the handler honours it — get_run_logs
// proved the run's instance and the server's membership in the run and then
// read production stdout for a staging token. What it does buy is that
// adding a tool forces an answer to "does this touch server data?", and the
// registry test names every tool that says yes, so the set cannot grow
// without a reviewer seeing it.
TouchesServers bool
Handler ToolFunc
}
// InputSchema renders the tool's declared arguments as a JSON Schema object,
// which is what a client reads from tools/list to know what to send.
//
// It returns a map rather than a typed schema so this file stays free of the
// MCP SDK; the transport hands it straight to the SDK, which remarshals it.
// additionalProperties is left open: several handlers accept confirm on top of
// their own arguments through CheckFanOut, and a strict object would refuse a
// call the fan-out guard is there to handle.
func (t Tool) InputSchema() map[string]any {
props := map[string]any{}
var required []string
for _, a := range t.Args {
p := map[string]any{"description": a.Description}
switch a.Type {
case ArgStringArray:
p["type"] = "array"
p["items"] = map[string]any{"type": "string"}
case ArgTagMap:
p["type"] = "object"
p["additionalProperties"] = map[string]any{"type": "string"}
case ArgObject:
p["type"] = "object"
default:
p["type"] = string(a.Type)
}
props[a.Name] = p
if a.Required {
required = append(required, a.Name)
}
}
schema := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
schema["required"] = required
}
return schema
}
// Registry holds the tool set in registration order, which is the order a
+148
View File
@@ -0,0 +1,148 @@
package mcp
import (
"encoding/json"
"strings"
"testing"
)
// Every tool must declare its arguments. A nil Args is "nobody wrote the
// schema", which is what the whole tool set looked like before: descriptions
// promised limit, tags, confirm, server_ids and the rest, and tools/list
// advertised none of them, so no client could discover an argument and a model
// had to guess. An empty (but non-nil) slice is the deliberate "takes none".
func TestEveryToolDeclaresArgs(t *testing.T) {
for _, tool := range All().Tools() {
if tool.Args == nil {
t.Errorf("tool %q declares no Args; use []ToolArg{} if it truly takes none", tool.Name)
}
}
}
func TestToolArgsAreWellFormed(t *testing.T) {
valid := map[ArgType]bool{
ArgString: true, ArgInteger: true, ArgBoolean: true,
ArgStringArray: true, ArgTagMap: true, ArgObject: true,
}
for _, tool := range All().Tools() {
seen := map[string]bool{}
for _, a := range tool.Args {
if a.Name == "" {
t.Errorf("tool %q has an argument with no name", tool.Name)
}
if seen[a.Name] {
t.Errorf("tool %q declares argument %q twice", tool.Name, a.Name)
}
seen[a.Name] = true
if !valid[a.Type] {
t.Errorf("tool %q argument %q has unknown type %q", tool.Name, a.Name, a.Type)
}
if strings.TrimSpace(a.Description) == "" {
t.Errorf("tool %q argument %q has no description; the description is what a model reads", tool.Name, a.Name)
}
}
}
}
// The schema has to survive JSON marshalling, because that is the only form a
// client ever sees it in.
func TestInputSchemaMarshals(t *testing.T) {
for _, tool := range All().Tools() {
schema := tool.InputSchema()
if schema["type"] != "object" {
t.Errorf("tool %q schema is not an object", tool.Name)
}
b, err := json.Marshal(schema)
if err != nil {
t.Errorf("tool %q schema does not marshal: %v", tool.Name, err)
continue
}
props, _ := schema["properties"].(map[string]any)
for _, a := range tool.Args {
if _, ok := props[a.Name]; !ok {
t.Errorf("tool %q declares argument %q but the schema omits it", tool.Name, a.Name)
}
}
if len(tool.Args) > 0 && !strings.Contains(string(b), tool.Args[0].Name) {
t.Errorf("tool %q schema lost argument %q in marshalling", tool.Name, tool.Args[0].Name)
}
}
}
// serverTouchingTools names every tool that returns or acts on server-derived
// data. The test below pins the registry against it, so a tool added that
// reads a hostname, a server ID, a package list or a run's per-server output
// fails until somebody declares TouchesServers and — the point of the exercise
// — decides how it applies Caller.TokenScope.
//
// This is the assertion that would have caught get_run_logs, which proved the
// run's instance and the named server's membership in the run and then read
// production stdout for a token restricted to staging. Declaring the flag does
// not prove the handler is scoped; it puts the question in front of a reviewer
// at the moment the tool is written, which is the same bargain
// api.serverScopedRoutes makes.
var serverTouchingTools = map[string]bool{
"list_servers": true,
"get_server": true,
"list_monitors": true, // Runner is a server ID; redacted out of scope.
"get_monitor_status": true, // same.
"list_workflows": true, // target server IDs.
"get_workflow": true, // same.
"get_run": true, // per-server run status.
"get_run_logs": true, // a named server's stdout.
"list_pending_updates": true,
"list_vulnerabilities": true, // affected-host counts.
"get_server_packages": true,
"search_fleet": true,
"run_workflow": true,
"apply_updates": true,
"update_agent": true,
"assign_key": true,
"create_workflow": true, // saves a target server list.
}
func TestServerTouchingToolsAreDeclared(t *testing.T) {
for _, tool := range All().Tools() {
want := serverTouchingTools[tool.Name]
if tool.TouchesServers != want {
if want {
t.Errorf("tool %q is listed as touching server data but does not declare TouchesServers", tool.Name)
} else {
t.Errorf("tool %q declares TouchesServers but is not in serverTouchingTools; "+
"add it there, having first checked it applies Caller.TokenScope", tool.Name)
}
}
}
registered := map[string]bool{}
for _, tool := range All().Tools() {
registered[tool.Name] = true
}
for name := range serverTouchingTools {
if !registered[name] {
t.Errorf("serverTouchingTools names %q, which is not a registered tool", name)
}
}
}
// A tool that touches server data and takes a server_ids or tags selector must
// also offer confirm, or the fan-out guard has no way to be satisfied and a
// legitimate fleet-wide call is unrefusable rather than merely confirmed.
func TestFanOutToolsOfferConfirm(t *testing.T) {
for _, tool := range All().Tools() {
if !tool.Write {
continue
}
selector, confirm := false, false
for _, a := range tool.Args {
switch a.Name {
case "server_ids", "tags":
selector = true
case "confirm":
confirm = true
}
}
if selector && !confirm {
t.Errorf("write tool %q takes a server selector but declares no confirm argument", tool.Name)
}
}
}
+288
View File
@@ -0,0 +1,288 @@
package mcp
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// SourceMCP marks a definition an agent wrote. models.WorkflowStep already
// carries a Source field for exactly this kind of provenance, so the UI can
// badge agent-authored steps without a schema change.
const SourceMCP = "mcp"
// buildStep validates the arguments and returns the step to create.
//
// It is pure so that every refusal below is testable without a database, and
// separate from the handler so the handler is only plumbing.
func buildStep(args map[string]any) (models.WorkflowStep, error) {
name := stringArg(args, "name")
interpreter := stringArg(args, "interpreter")
script := stringArg(args, "script")
if name == "" {
return models.WorkflowStep{}, fmt.Errorf("name is required")
}
if interpreter == "" {
return models.WorkflowStep{}, fmt.Errorf("interpreter is required, for example bash or powershell")
}
if script == "" {
return models.WorkflowStep{}, fmt.Errorf("script is required")
}
if len(stringSliceArg(args, "secret_refs")) > 0 {
return models.WorkflowStep{}, fmt.Errorf(
"a step created through MCP cannot reference secrets; " +
"add the secret reference in the Vantage UI after reviewing the script")
}
return models.WorkflowStep{
Name: name,
Description: stringArg(args, "description"),
Interpreter: interpreter,
Script: script,
Source: SourceMCP,
}, nil
}
// buildWorkflow validates the arguments and returns the workflow to create.
func buildWorkflow(args map[string]any) (models.Workflow, error) {
name := stringArg(args, "name")
if name == "" {
return models.Workflow{}, fmt.Errorf("name is required")
}
if _, scheduled := args["schedule"]; scheduled {
return models.Workflow{}, fmt.Errorf(
"a workflow created through MCP cannot be scheduled; " +
"create it, review it, then set a schedule in the Vantage UI")
}
stepIDs := stringSliceArg(args, "step_ids")
if len(stepIDs) == 0 {
return models.Workflow{}, fmt.Errorf("step_ids must name at least one existing step; create steps first with create_step")
}
// Order comes from the array order rather than from a field, because step
// order is the whole meaning of a workflow and is not worth asking a model
// to restate correctly. OnFailure defaults to "stop", the same default the
// workflow runner falls back to when a saved ref leaves it blank (see
// resolveInlineStep/resolveLibStep in workflow_runner.go).
steps := make([]models.WorkflowStepRef, 0, len(stepIDs))
for i, id := range stepIDs {
steps = append(steps, models.WorkflowStepRef{
StepID: id,
Order: i,
OnFailure: "stop",
})
}
return models.Workflow{
Name: name,
TargetServerIDs: stringSliceArg(args, "server_ids"),
TargetTags: tagArg(args),
Steps: steps,
}, nil
}
// buildMonitor validates the arguments and returns the monitor to create.
func buildMonitor(args map[string]any) (models.Monitor, error) {
name := stringArg(args, "name")
monitorType := stringArg(args, "type")
if name == "" {
return models.Monitor{}, fmt.Errorf("name is required")
}
if monitorType == "" {
return models.Monitor{}, fmt.Errorf("type is required")
}
rawTarget, ok := args["target"].(map[string]any)
if !ok || len(rawTarget) == 0 {
return models.Monitor{}, fmt.Errorf("target is required")
}
target := models.MonitorTarget{
URL: stringArg(rawTarget, "url"),
Host: stringArg(rawTarget, "host"),
Method: stringArg(rawTarget, "method"),
Keyword: stringArg(rawTarget, "keyword"),
}
if n, ok := rawTarget["port"].(float64); ok {
target.Port = int(n)
}
if n, ok := rawTarget["expected_status"].(float64); ok {
target.ExpectedStatus = int(n)
}
if n, ok := rawTarget["tls_warn_days"].(float64); ok {
target.TLSWarnDays = int(n)
}
if b, ok := rawTarget["insecure"].(bool); ok {
target.Insecure = b
}
switch monitorType {
case models.MonitorHTTP, models.MonitorTLS:
if target.URL == "" {
return models.Monitor{}, fmt.Errorf("target.url is required for a %s monitor", monitorType)
}
case models.MonitorTCP, models.MonitorICMP:
if target.Host == "" {
return models.Monitor{}, fmt.Errorf("target.host is required for a %s monitor", monitorType)
}
if monitorType == models.MonitorTCP && target.Port == 0 {
return models.Monitor{}, fmt.Errorf("target.port is required for a tcp monitor")
}
default:
return models.Monitor{}, fmt.Errorf("unknown monitor type %q", monitorType)
}
// Runner is deliberately not settable from a tool call, and this refusal
// makes that explicit rather than leaving it safe by omission. A runner
// is a server ID: accepting one would let an agent push a check onto a
// named agent, and a silently ignored argument would leave a model
// believing it had. services.CreateMonitor now validates a runner
// through GetServerScoped as well, so this is a second line rather than
// the only one — but the clearer answer belongs here.
if _, present := args["runner"]; present {
return models.Monitor{}, fmt.Errorf("runner cannot be set from here; monitors created this way always run on the control plane")
}
interval := 60
if n, ok := args["interval_sec"].(float64); ok && int(n) > 0 {
interval = int(n)
}
return models.Monitor{
Name: name,
Group: stringArg(args, "group"),
Type: monitorType,
Target: target,
IntervalSec: interval,
// Never armed on creation. A monitor that started enabled would begin
// alerting real people the moment a model invented it, and creating
// must stay a separate decision from acting.
Enabled: false,
}, nil
}
func init() {
All().Register(Tool{
Name: "create_step",
Args: []ToolArg{
{Name: "name", Type: ArgString, Description: "Name for the step.", Required: true},
{Name: "interpreter", Type: ArgString, Description: "Interpreter to run the script with, e.g. bash or powershell.", Required: true},
{Name: "script", Type: ArgString, Description: "The script body. It is parsed and scanned exactly as the UI does; secret_refs are refused.", Required: true},
{Name: "description", Type: ArgString, Description: "What the step does, for a human reading the library later."},
},
Write: true,
Scope: "workflows:write",
Description: "Create a reusable workflow step: a named script with an interpreter. " +
"The step is SAVED to this Vantage instance but is not run by creating it — " +
"add it to a workflow with create_workflow, then run that with run_workflow. " +
"Steps created this way cannot reference secrets.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
step, err := buildStep(args)
if err != nil {
return nil, err
}
created, err := services.CreateStep(c.InstanceID, step)
if err != nil {
return nil, fmt.Errorf("could not create the step: %w", err)
}
LogCreated(c, "step", created.StepID, created.Name)
return map[string]any{
"step_id": created.StepID,
"name": created.Name,
"note": "Saved but not run. Reference this step_id from create_workflow.",
}, nil
},
})
All().Register(Tool{
Name: "create_workflow",
Args: []ToolArg{
{Name: "name", Type: ArgString, Description: "Name for the workflow.", Required: true},
{Name: "step_ids", Type: ArgStringArray, Description: "IDs of existing steps, in the order they should run.", Required: true},
{Name: "server_ids", Type: ArgStringArray, Description: "Server IDs to target. Combined with tags as a union; at least one of the two is required."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
{Name: "confirm", Type: ArgBoolean, Description: "Set true to proceed when this would affect more servers than the fan-out limit (25)."},
},
TouchesServers: true,
Write: true,
Scope: "workflows:write",
Description: "Create a workflow from existing step IDs, in the order given, targeting " +
"servers by ID or by tags. The workflow is SAVED but not run and cannot be " +
"created with a schedule; run it explicitly with run_workflow.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
wf, err := buildWorkflow(args)
if err != nil {
return nil, err
}
// Targets are stored, not resolved at run time, so a workflow
// cannot be saved pointing at servers this token cannot reach.
// run_workflow later refuses to run a saved workflow unless the
// scoped view of its targets covers every server the unscoped
// resolution would touch; this mirrors that same all-or-nothing
// check at creation time so a workflow this token could not run
// is never created in the first place.
if len(wf.TargetServerIDs) > 0 || len(wf.TargetTags) > 0 {
allTargets, err := services.ResolveTargets(c.InstanceID, wf.TargetServerIDs, wf.TargetTags)
if err != nil {
return nil, fmt.Errorf("this workflow matches no servers")
}
scopedTargets, err := services.ResolveTargetsScoped(c.InstanceID, wf.TargetServerIDs, wf.TargetTags, c.TokenScope)
if err != nil || len(scopedTargets) != len(allTargets) {
return nil, fmt.Errorf("%w: no servers visible to this token matched the requested targets", ErrOutOfScope)
}
if err := CheckFanOut(len(scopedTargets), args); err != nil {
return nil, err
}
}
created, err := services.CreateWorkflow(c.InstanceID, wf, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not create the workflow: %w", err)
}
LogCreated(c, "workflow", created.WorkflowID, created.Name)
return map[string]any{
"workflow_id": created.WorkflowID,
"name": created.Name,
"steps": len(created.Steps),
"note": "Saved but not run and not scheduled. Call run_workflow to run it.",
}, nil
},
})
All().Register(Tool{
Name: "create_monitor",
Args: []ToolArg{
{Name: "name", Type: ArgString, Description: "Name for the monitor.", Required: true},
{Name: "type", Type: ArgString, Description: "Check type: http, tcp, icmp or tls.", Required: true},
{Name: "target", Type: ArgObject, Description: "What to check. http/tls take url; tcp/icmp take host, and tcp also port. Optional: method, keyword, expected_status, tls_warn_days, insecure.", Required: true},
{Name: "group", Type: ArgString, Description: "Optional group name to file the monitor under."},
{Name: "interval_sec", Type: ArgInteger, Description: "Seconds between checks; defaults to 60."},
},
Write: true,
Scope: "monitors:write",
Description: "Create a monitor. It is SAVED DISABLED and will not check anything or " +
"send any alert until a human enables it in the Vantage UI, so proposing a " +
"monitor is safe.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
m, err := buildMonitor(args)
if err != nil {
return nil, err
}
created, err := services.CreateMonitor(c.InstanceID, &m, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not create the monitor: %w", err)
}
LogCreated(c, "monitor", created.MonitorID, created.Name)
return map[string]any{
"monitor_id": created.MonitorID,
"name": created.Name,
"enabled": false,
"note": "Created disabled. Enable it in Vantage to start checking.",
}, nil
},
})
}
+228
View File
@@ -0,0 +1,228 @@
package mcp
import (
"strings"
"testing"
)
func TestCreationToolsAreRegisteredAsWrites(t *testing.T) {
for _, name := range []string{"create_step", "create_workflow", "create_monitor"} {
tool, ok := All().Lookup(name)
if !ok {
t.Errorf("tool %q is not registered", name)
continue
}
if !tool.Write {
t.Errorf("tool %q is not marked as a write", name)
}
}
}
// An agent may add a definition. It may never alter or remove one a human
// wrote, and the cheapest guard against that is the tool simply not existing.
func TestNoUpdateOrDeleteTools(t *testing.T) {
for _, tool := range All().Tools() {
n := tool.Name
if strings.HasPrefix(n, "update_") && n != "update_agent" {
t.Errorf("tool %q edits an existing definition", n)
}
if strings.HasPrefix(n, "delete_") || strings.HasPrefix(n, "remove_") {
t.Errorf("tool %q deletes a definition", n)
}
}
}
// Composing a script around a secret reference is how a credential ends up
// echoed into a log.
func TestCreateStepRejectsSecretRefs(t *testing.T) {
step, err := buildStep(map[string]any{
"name": "leaky",
"interpreter": "bash",
"script": "echo hello",
"secret_refs": []any{"prod/db"},
})
if err == nil {
t.Fatalf("buildStep accepted secret_refs, got %+v", step)
}
if !strings.Contains(err.Error(), "secret") {
t.Errorf("error %q does not explain the refusal", err)
}
}
func TestCreateStepRequiresScriptAndInterpreter(t *testing.T) {
if _, err := buildStep(map[string]any{"name": "x", "script": "echo hi"}); err == nil {
t.Error("buildStep accepted a step with no interpreter")
}
if _, err := buildStep(map[string]any{"name": "x", "interpreter": "bash"}); err == nil {
t.Error("buildStep accepted a step with no script")
}
}
// Steps a model wrote are badged in the UI, so a human can tell at a glance
// what came from an agent.
func TestCreatedStepIsMarkedAgentAuthored(t *testing.T) {
step, err := buildStep(map[string]any{
"name": "patch", "interpreter": "bash", "script": "apt-get update",
})
if err != nil {
t.Fatal(err)
}
if step.Source != "mcp" {
t.Errorf("Source = %q, want %q", step.Source, "mcp")
}
}
// Creating and acting stay two decisions: a created workflow cannot arrive
// already scheduled.
func TestCreateWorkflowRefusesASchedule(t *testing.T) {
_, err := buildWorkflow(map[string]any{
"name": "nightly",
"step_ids": []any{"step-1"},
"schedule": map[string]any{"cron": "0 3 * * *"},
})
if err == nil {
t.Fatal("buildWorkflow accepted a schedule")
}
if !strings.Contains(err.Error(), "schedule") {
t.Errorf("error %q does not explain the refusal", err)
}
}
func TestCreateWorkflowRequiresSteps(t *testing.T) {
if _, err := buildWorkflow(map[string]any{"name": "empty"}); err == nil {
t.Error("buildWorkflow accepted a workflow with no steps")
}
}
// Step order is the whole meaning of a workflow, so it comes from the array
// order rather than from a field a model has to get right.
func TestBuildWorkflowNumbersStepsInOrder(t *testing.T) {
wf, err := buildWorkflow(map[string]any{
"name": "three",
"step_ids": []any{"a", "b", "c"},
})
if err != nil {
t.Fatal(err)
}
if len(wf.Steps) != 3 {
t.Fatalf("got %d steps, want 3", len(wf.Steps))
}
for i, ref := range wf.Steps {
if ref.Order != i {
t.Errorf("step %d has Order %d", i, ref.Order)
}
}
if wf.Steps[0].StepID != "a" || wf.Steps[2].StepID != "c" {
t.Errorf("step order does not follow the argument order: %+v", wf.Steps)
}
}
// A monitor that starts enabled would begin alerting real people the moment a
// model invented it.
func TestCreatedMonitorIsDisabled(t *testing.T) {
m, err := buildMonitor(map[string]any{
"name": "api health", "type": "http", "target": map[string]any{"url": "https://example.com"},
})
if err != nil {
t.Fatal(err)
}
if m.Enabled {
t.Error("created monitor is enabled; it must wait for a human")
}
}
func TestCreateMonitorRequiresNameAndType(t *testing.T) {
if _, err := buildMonitor(map[string]any{"type": "http"}); err == nil {
t.Error("buildMonitor accepted a monitor with no name")
}
if _, err := buildMonitor(map[string]any{"name": "x"}); err == nil {
t.Error("buildMonitor accepted a monitor with no type")
}
}
// A monitor with no target argument at all cannot be checked, so it is
// refused the same way a missing name or type is.
func TestCreateMonitorRequiresTarget(t *testing.T) {
if _, err := buildMonitor(map[string]any{"name": "x", "type": "http"}); err == nil {
t.Error("buildMonitor accepted a monitor with no target")
}
}
// The target argument decodes into the real models.MonitorTarget shape, not
// a passthrough map, so an http monitor without a URL is rejected here rather
// than surfacing a confusing failure the first time it is checked.
func TestCreateMonitorHTTPRequiresURL(t *testing.T) {
if _, err := buildMonitor(map[string]any{
"name": "x", "type": "http", "target": map[string]any{"method": "GET"},
}); err == nil {
t.Error("buildMonitor accepted an http monitor with no target url")
}
}
func TestCreateMonitorTCPRequiresHostAndPort(t *testing.T) {
if _, err := buildMonitor(map[string]any{
"name": "x", "type": "tcp", "target": map[string]any{"host": "example.com"},
}); err == nil {
t.Error("buildMonitor accepted a tcp monitor with no port")
}
}
func TestCreateMonitorDecodesTargetFields(t *testing.T) {
m, err := buildMonitor(map[string]any{
"name": "api health", "type": "http",
"target": map[string]any{
"url": "https://example.com/health",
"method": "GET",
"expected_status": float64(200),
"keyword": "ok",
},
})
if err != nil {
t.Fatal(err)
}
if m.Target.URL != "https://example.com/health" {
t.Errorf("Target.URL = %q", m.Target.URL)
}
if m.Target.Method != "GET" {
t.Errorf("Target.Method = %q", m.Target.Method)
}
if m.Target.ExpectedStatus != 200 {
t.Errorf("Target.ExpectedStatus = %d", m.Target.ExpectedStatus)
}
if m.Target.Keyword != "ok" {
t.Errorf("Target.Keyword = %q", m.Target.Keyword)
}
}
// A runner is a server ID. buildMonitor must refuse one outright rather than
// dropping it silently, or a model would believe it had pinned a check to an
// agent it never reached.
func TestBuildMonitorRefusesRunner(t *testing.T) {
args := map[string]any{
"name": "api health",
"type": "http",
"target": map[string]any{"url": "https://example.com"},
"runner": "some-server-id",
}
if _, err := buildMonitor(args); err == nil {
t.Fatal("buildMonitor accepted a runner argument")
}
}
// Without a runner it still builds, and never arms itself.
func TestBuildMonitorWithoutRunnerIsDisabled(t *testing.T) {
m, err := buildMonitor(map[string]any{
"name": "api health",
"type": "http",
"target": map[string]any{"url": "https://example.com"},
})
if err != nil {
t.Fatalf("buildMonitor: %v", err)
}
if m.Runner != "" {
t.Errorf("Runner = %q, want empty so CreateMonitor defaults it to the control plane", m.Runner)
}
if m.Enabled {
t.Error("monitor created enabled")
}
}
+15 -6
View File
@@ -20,14 +20,14 @@ type serverSummary struct {
}
// models.Server has no Online bool: it stores Status as one of "pending",
// "online" or "offline" (see internal/services/servers.go). Online here
// "active" or "offline" (see internal/services/servers.go). Online here
// mirrors that string the same way the REST layer treats it.
func summariseServer(s models.Server) serverSummary {
return serverSummary{
ID: s.ServerID,
Hostname: s.Hostname,
OS: s.OSInfo,
Online: s.Status == "online",
Online: s.Status == "active",
Tags: s.Tags,
}
}
@@ -78,8 +78,13 @@ type listServersResult struct {
func init() {
All().Register(Tool{
Name: "list_servers",
Scope: "servers:read",
Name: "list_servers",
Args: []ToolArg{
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
},
TouchesServers: true,
Scope: "servers:read",
Description: "List the servers in this Vantage fleet, optionally filtered by tags. " +
"Returns a compact summary per server; use get_server for full detail on one.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
@@ -108,8 +113,12 @@ func init() {
})
All().Register(Tool{
Name: "get_server",
Scope: "servers:read",
Name: "get_server",
Args: []ToolArg{
{Name: "server_id", Type: ArgString, Description: "The server's ID.", Required: true},
},
TouchesServers: true,
Scope: "servers:read",
Description: "Get detail for one server by ID: OS, online state and tags. " +
"Use list_pending_updates for that server's outstanding package updates.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
+31 -6
View File
@@ -12,6 +12,14 @@ import (
// monitorSummary carries state and identity. A model asking "what is broken"
// needs the state and the name; the target URL, expected status, keyword,
// runner and channel list are configuration it did not ask for.
//
// Runner in particular is not merely omitted as noise: for an agent-pushed
// monitor it is literally a server ID, and REST's listMonitors/getMonitor
// redact it to models.RunnerRestricted when that server is outside the
// caller's scope. This projection never had a runner field to redact — the
// same outcome, reached by never including it rather than by filtering it
// out, so this tool and get_monitor_status cannot disagree with the REST
// surface about what a restricted token learns.
type monitorSummary struct {
ID string `json:"id"`
Name string `json:"name"`
@@ -84,8 +92,13 @@ const maxSampleLimit = 500
func init() {
All().Register(Tool{
Name: "list_monitors",
Scope: "monitors:read",
Name: "list_monitors",
Args: []ToolArg{
{Name: "state", Type: ArgString, Description: "Only monitors in this state: up, down or pending."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "monitors:read",
Description: "List the monitors on this instance with their current state. " +
"Pass state:\"down\" to see only what is currently failing.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
@@ -117,8 +130,12 @@ func init() {
})
All().Register(Tool{
Name: "get_monitor_status",
Scope: "monitors:read",
Name: "get_monitor_status",
Args: []ToolArg{
{Name: "monitor_id", Type: ArgString, Description: "The monitor's ID.", Required: true},
},
TouchesServers: true,
Scope: "monitors:read",
Description: "Get one monitor's current state: up, down or pending, the last check " +
"time, and the last error message if it is failing.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
@@ -142,7 +159,11 @@ func init() {
})
All().Register(Tool{
Name: "list_incidents",
Name: "list_incidents",
Args: []ToolArg{
{Name: "monitor_id", Type: ArgString, Description: "Only incidents for this monitor; omit for every monitor."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
Scope: "monitors:read",
Description: "List monitor incidents (outages), most recent first. Pass monitor_id to " +
"scope to one monitor, or omit it to see incidents across every monitor.",
@@ -203,7 +224,11 @@ func init() {
})
All().Register(Tool{
Name: "get_monitor_samples",
Name: "get_monitor_samples",
Args: []ToolArg{
{Name: "monitor_id", Type: ArgString, Description: "The monitor's ID.", Required: true},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
Scope: "monitors:read",
Description: "Get one monitor's recent raw check results (timestamp, ok/fail, latency). " +
"Samples are numerous and expire after 48 hours; use list_incidents for a longer view.",
+18
View File
@@ -58,3 +58,21 @@ func TestServerSummaryStaysSmall(t *testing.T) {
t.Errorf("30 servers serialise to %d bytes, want at most 8000", len(out))
}
}
// The real status vocabulary is "pending" / "active" / "offline" (see
// internal/services/servers.go) — "online" is never assigned anywhere. A
// server carrying the live status ("active") must project as Online: true,
// or list_servers/get_server misreport the entire fleet as down.
func TestSummariseServerReportsActiveAsOnline(t *testing.T) {
active := summariseServer(models.Server{ServerID: "srv-active", Status: "active"})
if !active.Online {
t.Errorf("server with status %q should be online, got Online=false", "active")
}
for _, status := range []string{"pending", "offline"} {
s := summariseServer(models.Server{ServerID: "srv-" + status, Status: status})
if s.Online {
t.Errorf("server with status %q should not be online, got Online=true", status)
}
}
}
+161 -40
View File
@@ -3,6 +3,7 @@ package mcp
import (
"context"
"fmt"
"sort"
"strings"
"time"
@@ -37,6 +38,12 @@ type workflowDetail struct {
Targets []string `json:"target_server_ids,omitempty"`
Tags map[string]string `json:"target_tags,omitempty"`
Schedule string `json:"schedule,omitempty"`
// TargetsRestricted is set, with no count, when Targets omits at least
// one server ID outside this token's scope — mirroring
// WorkflowResponse's REST field, so a model reading this alongside a
// run_workflow refusal for the same workflow is not left to conclude the
// refusal invented a problem this tool never mentioned.
TargetsRestricted bool `json:"targets_restricted,omitempty"`
}
// ---- runs ----
@@ -133,7 +140,8 @@ type auditEventSummary struct {
type listAuditResult struct {
Events []auditEventSummary `json:"events"`
Total int `json:"shown"`
Total int64 `json:"total"`
Shown int `json:"shown"`
}
// ---- secrets ----
@@ -149,8 +157,12 @@ type listSecretNamesResult struct {
func init() {
All().Register(Tool{
Name: "list_workflows",
Scope: "workflows:read",
Name: "list_workflows",
Args: []ToolArg{
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "workflows:read",
Description: "List the workflows defined on this instance: step count, target count, " +
"and whether each is on a schedule. Use get_workflow for the ordered step list.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
@@ -158,13 +170,24 @@ func init() {
if err != nil {
return nil, fmt.Errorf("could not list workflows: %w", err)
}
visible, restricted, err := services.VisibleServerIDs(c.InstanceID, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not resolve this token's server scope: %w", err)
}
limit := pageLimit(args)
out := make([]workflowSummary, 0, limit)
for _, w := range workflows {
if len(out) == limit {
break
}
targets := len(w.TargetServerIDs)
// The tag count is exposed as-is (the tag vocabulary is not
// restricted); the ID count is narrowed to what this token
// can see so it cannot itself disclose that out-of-scope
// targets exist, the same leak the REST list closes.
ids, _ := services.FilterVisibleServerIDs(w.TargetServerIDs, visible, restricted)
targets := len(ids)
if len(w.TargetTags) > 0 {
targets = len(w.TargetTags)
}
@@ -181,8 +204,12 @@ func init() {
})
All().Register(Tool{
Name: "get_workflow",
Scope: "workflows:read",
Name: "get_workflow",
Args: []ToolArg{
{Name: "workflow_id", Type: ArgString, Description: "The workflow's ID.", Required: true},
},
TouchesServers: true,
Scope: "workflows:read",
Description: "Get one workflow's full definition: ordered steps, targets and schedule. " +
"Use get_run for what happened the last time it ran.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
@@ -206,20 +233,32 @@ func init() {
if w.Schedule != nil && w.Schedule.Enabled {
schedule = w.Schedule.Cron
}
visible, restricted, err := services.VisibleServerIDs(c.InstanceID, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not resolve this token's server scope: %w", err)
}
targets, hidden := services.FilterVisibleServerIDs(w.TargetServerIDs, visible, restricted)
return workflowDetail{
ID: w.WorkflowID,
Name: w.Name,
Steps: steps,
Targets: w.TargetServerIDs,
Tags: w.TargetTags,
Schedule: schedule,
ID: w.WorkflowID,
Name: w.Name,
Steps: steps,
Targets: targets,
Tags: w.TargetTags,
Schedule: schedule,
TargetsRestricted: hidden,
}, nil
},
})
All().Register(Tool{
Name: "get_run",
Scope: "workflows:read",
Name: "get_run",
Args: []ToolArg{
{Name: "run_id", Type: ArgString, Description: "The run's ID.", Required: true},
},
TouchesServers: true,
Scope: "workflows:read",
Description: "Get one workflow run's status: overall state, start/finish time, and a " +
"count of servers by their per-server status. Use get_run_logs for the output of one server.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
@@ -258,8 +297,14 @@ func init() {
})
All().Register(Tool{
Name: "get_run_logs",
Scope: "workflows:read",
Name: "get_run_logs",
Args: []ToolArg{
{Name: "run_id", Type: ArgString, Description: "The run's ID.", Required: true},
{Name: "server_id", Type: ArgString, Description: "Which server within the run to read output for.", Required: true},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "workflows:read",
Description: "Get the ordered log lines for one server within one workflow run. " +
"Capped at 200 lines by default; ask for a higher limit if you need more.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
@@ -288,6 +333,20 @@ func init() {
return nil, fmt.Errorf("server %q is not part of run %q", serverID, runID)
}
// Membership in the run is not scope: a run started before this
// token was restricted, or by an unrestricted credential, names
// servers this token must not read. The stdout of an
// out-of-scope host is exactly the data the tag restriction
// exists to withhold.
//
// The refusal reuses the membership message verbatim so that
// "in the run but out of your scope" and "not in the run at all"
// are indistinguishable — otherwise the difference between the
// two answers enumerates hosts the token cannot see.
if _, err := services.GetServerScoped(c.InstanceID, serverID, c.TokenScope); err != nil {
return nil, fmt.Errorf("server %q is not part of run %q", serverID, runID)
}
limit := defaultLogLimit
if raw, ok := args["limit"].(float64); ok && int(raw) > 0 {
limit = int(raw)
@@ -305,8 +364,14 @@ func init() {
})
All().Register(Tool{
Name: "list_pending_updates",
Scope: "servers:read",
Name: "list_pending_updates",
Args: []ToolArg{
{Name: "server_id", Type: ArgString, Description: "One server to report on; omit to report across the fleet."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "servers:read",
Description: "List outstanding package updates across the fleet, or for one server. " +
"Pass server_id for one server, or tags to filter by, respecting the token's own scope.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
@@ -355,14 +420,29 @@ func init() {
})
All().Register(Tool{
Name: "list_vulnerabilities",
Scope: "vulns:read",
Name: "list_vulnerabilities",
Args: []ToolArg{
{Name: "severity", Type: ArgString, Description: "Only this severity: critical, high, medium or low."},
{Name: "status", Type: ArgString, Description: "Only findings in this state: open (default) or accepted."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "vulns:read",
Description: "List known CVEs affecting this fleet, one row per CVE/package pair with " +
"how many servers are affected. Filter by severity or status (open/accepted).",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
f := services.FindingFilter{
Severity: stringArg(args, "severity"),
State: stringArg(args, "status"),
// The service layer drops findings on servers outside this
// token's scope, so the affected-host count below is over
// visible servers only. An unfiltered count is the same
// aggregate leak ListKeys.AssignedCount was fixed for: it
// says something exists on a machine the caller must not
// know about. A CVE affecting only out-of-scope hosts
// disappears from the list rather than showing a zero.
TokenScope: c.TokenScope,
}
findings, err := services.ListInstanceFindings(c.InstanceID, f)
if err != nil {
@@ -380,12 +460,29 @@ func init() {
}
}
// Go randomises map iteration order, so truncating a ranged map
// to a page made two identical calls return different CVEs — a
// model comparing its own two answers would see the fleet change
// under it. Sorting by CVE ID (then package, since the key is a
// pair) makes the page deterministic.
keys := make([]key, 0, len(meta))
for k := range meta {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if keys[i].cve != keys[j].cve {
return keys[i].cve < keys[j].cve
}
return keys[i].pkg < keys[j].pkg
})
limit := pageLimit(args)
out := make([]vulnSummary, 0, limit)
for k, v := range meta {
for _, k := range keys {
if len(out) == limit {
break
}
v := meta[k]
v.AffectedNum = counts[k]
out = append(out, v)
}
@@ -394,8 +491,14 @@ func init() {
})
All().Register(Tool{
Name: "get_server_packages",
Scope: "vulns:read",
Name: "get_server_packages",
Args: []ToolArg{
{Name: "server_id", Type: ArgString, Description: "The server's ID.", Required: true},
{Name: "name", Type: ArgString, Description: "Substring match on the package name. A host can carry ~2000 packages, so pass this."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "vulns:read",
Description: "List installed packages on one server, optionally filtered by name. " +
"A server can carry ~2000 packages, so pass name to search rather than listing them all.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
@@ -430,19 +533,33 @@ func init() {
})
All().Register(Tool{
Name: "search_fleet",
Scope: "vulns:read",
Name: "search_fleet",
Args: []ToolArg{
{Name: "name", Type: ArgString, Description: "Exact package name to search for across the fleet.", Required: true},
{Name: "version_below", Type: ArgString, Description: "Not supported and refused if supplied: version ordering is per-distribution and cannot be resolved here."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
TouchesServers: true,
Scope: "vulns:read",
Description: "Search every server's installed packages by name across the whole fleet — " +
"answers questions like \"which hosts still run OpenSSL 1.1\". Pass version_below to " +
"further narrow to versions that sort earlier than the given string.",
"answers questions like \"which hosts still run OpenSSL 1.1\". version_below is not " +
"currently supported: filtering package versions correctly requires knowing each " +
"distribution's own version-ordering scheme (dpkg/rpm/apk), which this tool cannot " +
"determine, so it refuses rather than guess with a lexicographic comparison. Every " +
"matching install is returned; compare versions yourself.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
name := stringArg(args, "name")
if name == "" {
return nil, fmt.Errorf("name is required")
}
versionBelow := stringArg(args, "version_below")
if stringArg(args, "version_below") != "" {
return nil, fmt.Errorf("version_below is not supported: correct version ordering " +
"depends on each host's distribution (dpkg/rpm/apk each order differently), " +
"which this tool cannot resolve here — omit version_below and every matching " +
"install is returned instead")
}
hits, err := services.SearchPackages(c.InstanceID, name)
hits, err := services.SearchPackages(c.InstanceID, name, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not search packages: %w", err)
}
@@ -453,13 +570,11 @@ func init() {
if len(out) == limit {
break
}
if versionBelow != "" && h.Version >= versionBelow {
continue
}
// A hit's server must be resolved through the token's own
// scope: SearchPackages runs unscoped across the instance,
// so a server outside the token's tag restriction is
// dropped here rather than named to the caller.
// SearchPackages now filters by the token's scope itself,
// so this resolve is how the hostname is obtained rather
// than the only scope check. It stays scoped anyway: this
// loop is what turns a server ID into a name the model
// sees, and a second check costs nothing.
srv, err := services.GetServerScoped(c.InstanceID, h.ServerID, c.TokenScope)
if err != nil {
continue
@@ -471,13 +586,17 @@ func init() {
})
All().Register(Tool{
Name: "list_audit_events",
Name: "list_audit_events",
Args: []ToolArg{
{Name: "event_type", Type: ArgString, Description: "Event type prefix to filter by, e.g. \"workflow\", \"key\", \"server\"."},
{Name: "limit", Type: ArgInteger, Description: "Maximum rows to return (default 50, capped at 200)."},
},
Scope: "settings:read",
Description: "List recent audit log events on this instance: who did what, and when. " +
"Filter by event_type prefix (e.g. \"workflow\", \"key\", \"server\").",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
limit := int64(pageLimit(args))
events, _, err := services.ListAuditEvents(c.InstanceID, services.AuditFilter{
events, total, err := services.ListAuditEvents(c.InstanceID, services.AuditFilter{
Category: stringArg(args, "event_type"),
Limit: limit,
})
@@ -488,12 +607,14 @@ func init() {
for _, e := range events {
out = append(out, auditEventSummary{At: e.CreatedAt, Type: e.EventType, Actor: e.Actor, Detail: e.Details})
}
return listAuditResult{Events: out, Total: len(out)}, nil
return listAuditResult{Events: out, Total: total, Shown: len(out)}, nil
},
})
All().Register(Tool{
Name: "list_secret_names",
Name: "list_secret_names",
// This tool reads no arguments at all.
Args: []ToolArg{},
Scope: "secrets:read",
Description: "List secret group and key names on this instance. Metadata only — no " +
"tool ever returns a secret's plaintext value to a model.",
+334
View File
@@ -0,0 +1,334 @@
package mcp
import (
"context"
"fmt"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
)
// stringSliceArg reads a JSON array-of-strings argument, ignoring any element
// that is not a string. Missing or wrongly-typed input decodes to nil, which
// every caller here treats as "no targets named this way".
func stringSliceArg(args map[string]any, key string) []string {
raw, ok := args[key].([]any)
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, v := range raw {
if s, ok := v.(string); ok {
out = append(out, s)
}
}
return out
}
func mustLookup(name string) Tool {
t, ok := All().Lookup(name)
if !ok {
panic("mcp: unknown tool " + name)
}
return t
}
type runStartedResult struct {
RunID string `json:"run_id"`
Note string `json:"note"`
}
// run_workflow. The real REST run route (internal/api/workflows.go's
// runWorkflow) does not take an ad-hoc target list at all: it calls
// services.TriggerWorkflow(instanceID, workflowID, actor), which resolves the
// workflow's own configured target_server_ids/target_tags via
// services.ResolveTargets (unscoped) and runs against exactly that set. There
// is no per-call server_ids/tags override to plumb through, so this tool takes
// only workflow_id. To keep the token's scope meaningful — TriggerWorkflow
// itself does not consult it — this handler first loads the workflow and
// resolves its configured targets through ResolveTargetsScoped with the
// caller's TokenScope, and refuses the run outright if that scoped view does
// not cover every server the unscoped resolution would touch. That is the
// fan-out and scope check; the actual dispatch is the same single call the
// REST route makes, so there is exactly one path that starts a run.
func init() {
All().Register(Tool{
Name: "run_workflow",
Args: []ToolArg{
{Name: "workflow_id", Type: ArgString, Description: "The workflow to run. Its saved targets are used; this call cannot pick different ones.", Required: true},
{Name: "confirm", Type: ArgBoolean, Description: "Set true to proceed when this would affect more servers than the fan-out limit (25)."},
},
TouchesServers: true,
Write: true,
Scope: "workflows:write",
Description: "Run a workflow against the servers it is already configured to target " +
"(its saved server list and tags — this call does not let you pick different " +
"targets). This EXECUTES COMMANDS on real machines and cannot be undone from " +
"here. Returns a run ID immediately; poll get_run for progress and get_run_logs " +
"for output. Refused if the workflow's targets reach outside this token's own " +
"server scope, or if it would affect more than the fan-out limit without confirm:true.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
workflowID := stringArg(args, "workflow_id")
if workflowID == "" {
return nil, fmt.Errorf("workflow_id is required")
}
wf, err := services.GetWorkflow(c.InstanceID, workflowID)
if err != nil {
return nil, fmt.Errorf("workflow not found")
}
allTargets, err := services.ResolveTargets(c.InstanceID, wf.TargetServerIDs, wf.TargetTags)
if err != nil {
return nil, fmt.Errorf("this workflow matches no servers")
}
// This all-or-nothing pre-check is no longer the only guard:
// services.TriggerWorkflow now resolves through
// ResolveTargetsScoped itself, so a run started with this token
// can never touch a server outside its scope regardless of what
// happens here. It is kept because its refusal is the clearer
// answer for a model: the service layer would silently run
// against the in-scope subset, while a partially out-of-scope
// workflow is documented here as refused outright, which is
// behaviour a caller relies on.
scopedTargets, err := services.ResolveTargetsScoped(c.InstanceID, wf.TargetServerIDs, wf.TargetTags, c.TokenScope)
if err != nil || len(scopedTargets) != len(allTargets) {
return nil, fmt.Errorf("%w: no servers visible to this token matched the request", ErrOutOfScope)
}
if err := CheckFanOut(len(scopedTargets), args); err != nil {
return nil, err
}
runID, err := services.TriggerWorkflow(c.InstanceID, workflowID, c.TokenName, c.TokenScope)
if err != nil {
return nil, fmt.Errorf("could not start the run: %w", err)
}
LogCall(c, mustLookup("run_workflow"), args, len(scopedTargets))
return runStartedResult{
RunID: runID,
Note: "The run is in progress. Poll get_run with this run_id; do not assume it succeeded.",
}, nil
},
})
}
type cancelledResult struct {
Cancelled bool `json:"cancelled"`
}
// cancel_run. The REST cancel route (workflows.go's cancelRun) calls
// services.CancelRun(instanceID, runID) directly; that call is already scoped
// to the caller's instance by instanceID, which is what "verifies the run
// belongs to the caller's instance" reduces to here — there is no separate
// per-server scope to check, since cancelling touches the run record, not a
// server.
func init() {
All().Register(Tool{
Name: "cancel_run",
Args: []ToolArg{
{Name: "run_id", Type: ArgString, Description: "The run to cancel.", Required: true},
},
Write: true,
Scope: "workflows:write",
Description: "Cancel an in-progress workflow run. This stops further steps from " +
"being dispatched to real machines but cannot undo steps that already ran, and " +
"cannot be undone from here.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
runID := stringArg(args, "run_id")
if runID == "" {
return nil, fmt.Errorf("run_id is required")
}
if err := services.CancelRun(c.InstanceID, runID); err != nil {
return nil, fmt.Errorf("could not cancel the run: %w", err)
}
LogCall(c, mustLookup("cancel_run"), args, 0)
return cancelledResult{Cancelled: true}, nil
},
})
}
type updateBatchResult struct {
Servers int `json:"servers"`
Succeeded []string `json:"succeeded"`
Failed map[string]string `json:"failed,omitempty"`
}
// apply_updates. The REST route (internal/api/handlers.go's applyUpdates) is
// per-server: POST /servers/:id/apply-updates resolves one server with
// services.GetServerScoped and calls services.DispatchApplyUpdates(serverID).
// There is no fleet-wide variant of that service call to invoke once, so this
// tool resolves the requested targets through ResolveTargetsScoped exactly as
// the brief describes, then calls the same DispatchApplyUpdates the REST route
// calls, once per resolved server — the identical dispatch, just looped
// instead of hardcoded to one server_id from the URL.
func init() {
All().Register(Tool{
Name: "apply_updates",
Args: []ToolArg{
{Name: "server_ids", Type: ArgStringArray, Description: "Server IDs to target. Combined with tags as a union; at least one of the two is required."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
{Name: "confirm", Type: ArgBoolean, Description: "Set true to proceed when this would affect more servers than the fan-out limit (25)."},
},
TouchesServers: true,
Write: true,
Scope: "servers:write",
Description: "Apply pending OS package updates on real servers, selected by " +
"server_ids and/or tags. This installs packages on real machines right now and " +
"cannot be undone from here. A server may need a reboot afterward, which this " +
"tool does not do.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
ids := stringSliceArg(args, "server_ids")
targets, err := services.ResolveTargetsScoped(c.InstanceID, ids, tagArg(args), c.TokenScope)
if err != nil {
return nil, fmt.Errorf("%w: no servers visible to this token matched the request", ErrOutOfScope)
}
if err := CheckFanOut(len(targets), args); err != nil {
return nil, err
}
result := updateBatchResult{Servers: len(targets), Failed: map[string]string{}}
for _, srv := range targets {
if err := services.DispatchApplyUpdates(srv.ServerID); err != nil {
result.Failed[srv.ServerID] = err.Error()
continue
}
result.Succeeded = append(result.Succeeded, srv.ServerID)
}
if len(result.Failed) == 0 {
result.Failed = nil
}
LogCall(c, mustLookup("apply_updates"), args, len(targets))
return result, nil
},
})
}
type agentUpdateResult struct {
Servers int `json:"servers"`
Succeeded map[string]string `json:"succeeded,omitempty"`
Failed map[string]string `json:"failed,omitempty"`
}
// update_agent. Same shape as apply_updates: the REST route
// (handlers.go's updateAgent) resolves one server and calls
// services.DispatchUpdateAgent(serverID), so this tool loops the same call
// over the resolved, scoped target set.
func init() {
All().Register(Tool{
Name: "update_agent",
Args: []ToolArg{
{Name: "server_ids", Type: ArgStringArray, Description: "Server IDs to target. Combined with tags as a union; at least one of the two is required."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
{Name: "confirm", Type: ArgBoolean, Description: "Set true to proceed when this would affect more servers than the fan-out limit (25)."},
},
TouchesServers: true,
Write: true,
Scope: "servers:write",
Description: "Trigger the Vantage agent on real servers, selected by server_ids " +
"and/or tags, to download and replace itself with the latest version. This " +
"restarts the agent process on real machines and cannot be undone from here.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
ids := stringSliceArg(args, "server_ids")
targets, err := services.ResolveTargetsScoped(c.InstanceID, ids, tagArg(args), c.TokenScope)
if err != nil {
return nil, fmt.Errorf("%w: no servers visible to this token matched the request", ErrOutOfScope)
}
if err := CheckFanOut(len(targets), args); err != nil {
return nil, err
}
result := agentUpdateResult{Servers: len(targets), Succeeded: map[string]string{}, Failed: map[string]string{}}
for _, srv := range targets {
version, err := services.DispatchUpdateAgent(srv.ServerID)
if err != nil {
result.Failed[srv.ServerID] = err.Error()
continue
}
result.Succeeded[srv.ServerID] = version
}
if len(result.Succeeded) == 0 {
result.Succeeded = nil
}
if len(result.Failed) == 0 {
result.Failed = nil
}
LogCall(c, mustLookup("update_agent"), args, len(targets))
return result, nil
},
})
}
type assignKeyResult struct {
Servers int `json:"servers"`
Succeeded []string `json:"succeeded"`
Failed map[string]string `json:"failed,omitempty"`
}
// assign_key. The REST route (handlers.go's assignKey) takes one server_id in
// the body and calls services.AssignKey(instanceID, keyID, serverID) directly
// — AssignKey itself resolves the server with the unscoped services.GetServer,
// not GetServerScoped, so the REST route carries no token-scope check of its
// own (session auth has no server-scope restriction; only API tokens do). For
// the MCP surface, this tool resolves every named target through
// ResolveTargetsScoped first — the same chokepoint every other target-
// resolving write tool goes through — so a token whose scope excludes a server
// cannot reach it here even though the REST handler's own server lookup would
// not have stopped it. Then it calls the identical AssignKey once per resolved
// server.
func init() {
All().Register(Tool{
Name: "assign_key",
Args: []ToolArg{
{Name: "key_id", Type: ArgString, Description: "The SSH key to assign.", Required: true},
{Name: "server_ids", Type: ArgStringArray, Description: "Server IDs to target. Combined with tags as a union; at least one of the two is required."},
{Name: "tags", Type: ArgTagMap, Description: "Tag key/value pairs a server must carry to be included; ANDed across keys."},
{Name: "confirm", Type: ArgBoolean, Description: "Set true to proceed when this would affect more servers than the fan-out limit (25)."},
},
TouchesServers: true,
Write: true,
Scope: "keys:write",
Description: "Assign an SSH key to real servers, selected by server_ids and/or " +
"tags. The agent rewrites /root/.ssh/authorized_keys on each targeted machine " +
"and this cannot be undone from here — use revoke to remove it afterward.",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
keyID := stringArg(args, "key_id")
if keyID == "" {
return nil, fmt.Errorf("key_id is required")
}
ids := stringSliceArg(args, "server_ids")
targets, err := services.ResolveTargetsScoped(c.InstanceID, ids, tagArg(args), c.TokenScope)
if err != nil {
return nil, fmt.Errorf("%w: no servers visible to this token matched the request", ErrOutOfScope)
}
if err := CheckFanOut(len(targets), args); err != nil {
return nil, err
}
result := assignKeyResult{Servers: len(targets), Failed: map[string]string{}}
for _, srv := range targets {
if _, err := services.AssignKey(c.InstanceID, keyID, srv.ServerID); err != nil {
result.Failed[srv.ServerID] = err.Error()
continue
}
result.Succeeded = append(result.Succeeded, srv.ServerID)
}
if len(result.Failed) == 0 {
result.Failed = nil
}
LogCall(c, mustLookup("assign_key"), args, len(targets))
return result, nil
},
})
}
+44
View File
@@ -0,0 +1,44 @@
package mcp
import "testing"
func TestWriteToolsAreMarkedAsWrites(t *testing.T) {
want := []string{"run_workflow", "cancel_run", "apply_updates", "update_agent", "assign_key"}
for _, name := range want {
tool, ok := All().Lookup(name)
if !ok {
t.Errorf("tool %q is not registered", name)
continue
}
if !tool.Write {
t.Errorf("tool %q is not marked as a write, so it would be listed to a read-only agent", name)
}
}
}
// A tool description is prompt text. A model choosing between tools must be
// told which ones touch real machines.
func TestWriteToolDescriptionsStateBlastRadius(t *testing.T) {
for _, tool := range All().Tools() {
if !tool.Write {
continue
}
if len(tool.Description) < 40 {
t.Errorf("tool %q has a %d-char description; write tools must state what they affect",
tool.Name, len(tool.Description))
}
}
}
// A caller holding every resource scope but not mcp:write must still see no
// write tools at all.
func TestWriteToolsHiddenWithoutMCPWrite(t *testing.T) {
c := Caller{Scopes: []string{
"mcp:read", "servers:write", "workflows:write", "keys:write",
}}
for _, tool := range All().Visible(c) {
if tool.Write {
t.Errorf("write tool %q visible without mcp:write", tool.Name)
}
}
}
+53 -11
View File
@@ -67,24 +67,66 @@ func Handler() gin.HandlerFunc {
// trusted from Visible, because listing and calling are separate requests and a
// token's scopes are re-read on each.
func registerSDKTool(srv *sdk.Server, tool Tool, caller Caller) {
// InputSchema is set explicitly rather than inferred from the handler's
// argument type. The SDK can infer one from a typed In parameter, which is
// cleaner where it fits — but every ToolFunc here takes map[string]any, and
// inference over that yields a bare open object saying nothing. Giving each
// tool its own Go argument struct would mean twenty-odd structs and a
// generic registry that could no longer hold them in one map, losing the
// gate logic and the audit wrapper this function exists to apply. The
// declared Args are the same information without that cost, and the SDK
// validates against the schema either way.
sdk.AddTool(srv, &sdk.Tool{
Name: tool.Name,
Description: tool.Description,
InputSchema: tool.InputSchema(),
}, func(ctx context.Context, req *sdk.CallToolRequest, args map[string]any) (*sdk.CallToolResult, any, error) {
if ok, gate := Allowed(tool, caller); !ok {
LogDenied(caller, tool.Name, gate)
return nil, nil, toolError(gate, tool)
}
out, err := tool.Handler(ctx, caller, args)
if err != nil {
return nil, nil, err
}
LogCall(caller, tool, args, 0)
return nil, out, nil
return callTool(ctx, tool, caller, args)
})
}
// callTool is the gate check, dispatch and audit write registerSDKTool wraps
// onto the SDK's call signature. It is a separate function — rather than the
// closure body inline — so it can be exercised directly in tests without
// standing up an sdk.Server and driving a real MCP request through it.
func callTool(ctx context.Context, tool Tool, caller Caller, args map[string]any) (*sdk.CallToolResult, any, error) {
if ok, gate := Allowed(tool, caller); !ok {
LogDenied(caller, tool.Name, gate)
return nil, nil, toolError(gate, tool)
}
out, err := tool.Handler(ctx, caller, args)
if err != nil {
// A write tool's own handler never gets a chance to audit its own
// refusal or failure — it returns before reaching its LogCall, and
// unlike a successful write, this layer does not know a resolved
// server count to pass along anyway. So every write failure is
// audited here instead: a policy refusal (fan-out or tag scope) as
// mcp.tool_denied naming the gate, everything else as
// mcp.tool_failed, so a human reading audit_logs can tell "the agent
// was stopped" from "the agent tried and the machine failed". Read
// tools are unaffected — a failed read was never going to change
// anything and carries no gate to name.
if tool.Write {
switch {
case errors.Is(err, ErrConfirmRequired):
LogDenied(caller, tool.Name, GateFanOut)
case errors.Is(err, ErrOutOfScope):
LogDenied(caller, tool.Name, GateTagScope)
default:
LogFailure(caller, tool, args, err)
}
}
return nil, nil, err
}
if !tool.Write {
// Write tools log their own call with a resolved server count, which
// this layer cannot know.
LogCall(caller, tool, args, 0)
}
return nil, out, nil
}
// toolError explains a refusal in words the model can act on. A transport-level
// failure would be invisible to it; a tool error is something it can read and
// relay to its user.
@@ -0,0 +1,25 @@
package mcp
import (
"testing"
sdk "github.com/modelcontextprotocol/go-sdk/mcp"
)
// sdk.AddTool panics on a schema it cannot resolve, and the only place that
// would otherwise happen is inside a live request. Registering every tool onto
// a real server here moves that failure to the test run.
func TestEveryToolRegistersWithTheSDK(t *testing.T) {
srv := sdk.NewServer(&sdk.Implementation{Name: "vantage", Version: "test"}, nil)
caller := Caller{InstanceID: "i", Scopes: []string{"mcp:write"}}
for _, tool := range All().Tools() {
func() {
defer func() {
if r := recover(); r != nil {
t.Errorf("tool %q: SDK rejected its input schema: %v", tool.Name, r)
}
}()
registerSDKTool(srv, tool, caller)
}()
}
}
+115
View File
@@ -0,0 +1,115 @@
package mcp
import (
"context"
"testing"
)
// TestRefusedWriteIsAudited exercises the real dispatch path (callTool, which
// registerSDKTool wraps) for a write tool whose handler refuses the call
// before it ever reaches its own LogCall — a fan-out refusal, in this case,
// which run_workflow, apply_updates, update_agent and assign_key all reach
// the same way via CheckFanOut. The refusal must still produce an audit row:
// a blocked mutation attempt is the single most audit-worthy event a write
// tool produces, and until this test the only thing recording it was the
// tool's own success path.
func TestRefusedWriteIsAudited(t *testing.T) {
var got []string
restore := logEvent
logEvent = func(instanceID, eventType, actor, serverID, keyID, details string) {
got = append(got, eventType+": "+details)
}
defer func() { logEvent = restore }()
tool := Tool{
Name: "test_write_tool",
Write: true,
Scope: "servers:write",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
return nil, CheckFanOut(200, args)
},
}
caller := Caller{Scopes: []string{"mcp:write", "servers:write"}}
_, _, err := callTool(context.Background(), tool, caller, nil)
if err == nil {
t.Fatal("callTool() = nil error, want the fan-out refusal")
}
if len(got) != 1 {
t.Fatalf("logEvent called %d times, want exactly 1 audit row for the refusal; got %v", len(got), got)
}
if want := "mcp.tool_denied: "; len(got[0]) < len(want) || got[0][:len(want)] != want {
t.Errorf("audit row %q does not record a denial", got[0])
}
}
// TestOutOfScopeWriteIsAudited covers the other write refusal shape: a
// service-layer ErrOutOfScope wrap, as apply_updates/update_agent/assign_key
// return when ResolveTargetsScoped finds nothing this token may touch.
func TestOutOfScopeWriteIsAudited(t *testing.T) {
var got []string
restore := logEvent
logEvent = func(instanceID, eventType, actor, serverID, keyID, details string) {
got = append(got, eventType)
}
defer func() { logEvent = restore }()
tool := Tool{
Name: "test_scoped_tool",
Write: true,
Scope: "servers:write",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
return nil, ErrOutOfScope
},
}
caller := Caller{Scopes: []string{"mcp:write", "servers:write"}}
if _, _, err := callTool(context.Background(), tool, caller, nil); err == nil {
t.Fatal("callTool() = nil error, want the scope refusal")
}
if len(got) != 1 || got[0] != "mcp.tool_denied" {
t.Errorf("audit events = %v, want exactly one mcp.tool_denied row", got)
}
}
// TestServiceFailureIsAuditedDistinctly makes sure a write tool failing for a
// reason that is not a policy refusal — the underlying service call itself
// erroring — is still audited, but as mcp.tool_failed rather than
// mcp.tool_denied, so a human reading audit_logs can tell the two apart.
func TestServiceFailureIsAuditedDistinctly(t *testing.T) {
var events []string
restore := logEvent
logEvent = func(instanceID, eventType, actor, serverID, keyID, details string) {
events = append(events, eventType)
}
defer func() { logEvent = restore }()
tool := Tool{
Name: "test_failing_tool",
Write: true,
Scope: "servers:write",
Handler: func(ctx context.Context, c Caller, args map[string]any) (any, error) {
return nil, errFakeServiceFailure
},
}
caller := Caller{Scopes: []string{"mcp:write", "servers:write"}}
if _, _, err := callTool(context.Background(), tool, caller, nil); err == nil {
t.Fatal("callTool() = nil error, want the service failure")
}
if len(events) != 1 || events[0] != "mcp.tool_failed" {
t.Errorf("audit events = %v, want exactly one mcp.tool_failed row", events)
}
}
var errFakeServiceFailure = &fakeError{"the dispatcher refused the command"}
type fakeError struct{ msg string }
func (e *fakeError) Error() string { return e.msg }
+8
View File
@@ -21,6 +21,14 @@ const (
const RunnerServer = "server"
// RunnerRestricted replaces a monitor's runner in an API response when the
// real value is a server ID the acting token's scope does not admit. The
// monitor itself is still returned — a restricted operator may legitimately
// need to see its name and state — only where it runs is hidden, the same
// way a workflow's target list can omit an ID without the whole workflow
// disappearing from a list.
const RunnerRestricted = "restricted"
type MonitorTarget struct {
URL string `bson:"url,omitempty" json:"url,omitempty"`
Host string `bson:"host,omitempty" json:"host,omitempty"`
+57 -3
View File
@@ -119,6 +119,11 @@ type FindingFilter struct {
// patchable"; false is the unfixable set — remove the package, disable the
// service, or accept it, but do not wait for an update.
HasFix *bool
// TokenScope is the acting credential's tag restriction, nil meaning
// unrestricted. A finding on a server outside it is dropped: a CVE row
// names a server ID and a hostname, and a count that includes invisible
// hosts is itself a statement about a fleet the caller must not see.
TokenScope map[string]string
}
// ListInstanceFindings returns findings across the whole fleet.
@@ -166,6 +171,36 @@ func ListInstanceFindings(instanceID string, f FindingFilter) ([]models.VulnFind
filter["server_id"] = bson.M{"$in": ids}
}
// The token restriction is applied the same way the Tags selector above
// is — by narrowing server_id — rather than by a post-pass, so the two
// cannot disagree and the query keeps one shape. IntersectSelectors is
// not used here because Tags has already been resolved to IDs by this
// point; intersecting the ID sets is the same operation one level down.
if len(f.TokenScope) > 0 {
visible, restricted, err := VisibleServerIDs(instanceID, f.TokenScope)
if err != nil {
return nil, err
}
if restricted {
ids := make([]string, 0, len(visible))
for id := range visible {
ids = append(ids, id)
}
if len(ids) == 0 {
return []models.VulnFinding{}, nil
}
if existing, ok := filter["server_id"]; ok {
filter["$and"] = bson.A{
bson.M{"server_id": existing},
bson.M{"server_id": bson.M{"$in": ids}},
}
delete(filter, "server_id")
} else {
filter["server_id"] = bson.M{"$in": ids}
}
}
}
cur, err := db.Col("vuln_findings").Find(ctx, filter)
if err != nil {
return nil, err
@@ -182,12 +217,31 @@ func ListInstanceFindings(instanceID string, f FindingFilter) ([]models.VulnFind
// CountOpenFindingsBySeverity powers the summary tiles. Accepted findings are
// excluded: they are suppressed from counts until their expiry, which is the
// whole point of accepting one.
func CountOpenFindingsBySeverity(instanceID string) (map[string]int, error) {
// tokenScope is the acting credential's tag restriction, nil meaning
// unrestricted: a summary tile counting findings on hosts the caller cannot
// see is the same aggregate leak as an unfiltered affected-host count.
func CountOpenFindingsBySeverity(instanceID string, tokenScope map[string]string) (map[string]int, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
match := bson.M{"instance_id": instanceID, "state": models.FindingOpen}
visible, restricted, err := VisibleServerIDs(instanceID, tokenScope)
if err != nil {
return nil, err
}
if restricted {
ids := make([]string, 0, len(visible))
for id := range visible {
ids = append(ids, id)
}
if len(ids) == 0 {
return map[string]int{}, nil
}
match["server_id"] = bson.M{"$in": ids}
}
cur, err := db.Col("vuln_findings").Aggregate(ctx, []bson.M{
{"$match": bson.M{"instance_id": instanceID, "state": models.FindingOpen}},
{"$match": match},
{"$group": bson.M{"_id": "$severity", "n": bson.M{"$sum": 1}}},
})
if err != nil {
@@ -464,4 +518,4 @@ func sweepFixedFindings(ctx context.Context) {
log.Printf("vuln sweeper: removed %d fixed findings for %s", res.DeletedCount, instanceID)
}
}
}
}
+39 -3
View File
@@ -118,7 +118,20 @@ type KeyWithCount struct {
AssignedCount int `bson:"-" json:"assigned_count"`
}
func ListKeys(instanceID string) ([]KeyWithCount, error) {
// ListKeys returns every key for instanceID together with its assignment
// count, narrowed by tokenScope: AssignedCount only counts assignments on
// servers ServerInTokenScope admits. Without this, a restricted token reading
// the key list would see a nonzero count for a key it cannot see a single
// assignment of in its own scope — the same hostname-existence leak
// getKey's scope filter closes on the detail route, reachable here through a
// count instead of a server object.
//
// An empty tokenScope (an unrestricted token, or a cookie session) means "see
// everything", matching ServerInTokenScope elsewhere, and takes the original,
// unfiltered per-key CountDocuments query with no extra work: the visible-
// fleet resolution below is skipped entirely, so the common unrestricted case
// costs exactly what it cost before this scope filter existed.
func ListKeys(instanceID string, tokenScope map[string]string) ([]KeyWithCount, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@@ -133,14 +146,37 @@ func ListKeys(instanceID string) ([]KeyWithCount, error) {
return nil, err
}
// Resolve the visible fleet once, outside the per-key loop, so a
// restricted token's count costs one extra query total rather than one
// per key — the same reasoning ResolveTargetsScoped already applies to
// target resolution.
scoped := len(tokenScope) > 0
var visibleIDs []string
if scoped {
servers, err := ListServers(instanceID)
if err != nil {
return nil, err
}
visibleIDs = make([]string, 0, len(servers))
for _, s := range servers {
if ServerInTokenScope(s, tokenScope) {
visibleIDs = append(visibleIDs, s.ServerID)
}
}
}
result := make([]KeyWithCount, 0, len(keys))
for _, k := range keys {
setKeyMeta(&k)
count, _ := db.Col("assignments").CountDocuments(ctx, bson.M{
filter := bson.M{
"instance_id": instanceID,
"key_id": k.KeyID,
"revoked_at": nil,
})
}
if scoped {
filter["server_id"] = bson.M{"$in": visibleIDs}
}
count, _ := db.Col("assignments").CountDocuments(ctx, filter)
result = append(result, KeyWithCount{Key: k, AssignedCount: int(count)})
}
return result, nil
+34 -7
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()
@@ -124,23 +146,28 @@ func getMonitorByID(monitorID string) (*models.Monitor, error) {
return &m, nil
}
func validateRunner(instanceID, runner string) error {
// validateRunner refuses a monitor whose runner names a server the acting
// credential cannot see. A runner is literally a server ID, so an unscoped
// check here both lets a restricted token push work onto an out-of-scope agent
// and answers a fleet-enumeration question by the difference between "not
// found" and success. GetServerScoped collapses both into not-found.
func validateRunner(instanceID, runner string, tokenScope map[string]string) error {
if runner == "" || runner == models.RunnerServer {
return nil
}
if _, err := GetServer(instanceID, runner); err != nil {
if _, err := GetServerScoped(instanceID, runner, tokenScope); err != nil {
return fmt.Errorf("runner server %s not found", runner)
}
return nil
}
func CreateMonitor(instanceID string, m *models.Monitor) (*models.Monitor, error) {
func CreateMonitor(instanceID string, m *models.Monitor, tokenScope map[string]string) (*models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
if err := validateChannelIDs(instanceID, m.ChannelIDs); err != nil {
return nil, err
}
if err := validateRunner(instanceID, m.Runner); err != nil {
if err := validateRunner(instanceID, m.Runner, tokenScope); err != nil {
return nil, err
}
group, err := normaliseGroup(m.Group)
@@ -167,7 +194,7 @@ func CreateMonitor(instanceID string, m *models.Monitor) (*models.Monitor, error
return m, nil
}
func UpdateMonitor(instanceID, monitorID string, upd bson.M) error {
func UpdateMonitor(instanceID, monitorID string, upd bson.M, tokenScope map[string]string) error {
ctx, cancel := monCtx()
defer cancel()
@@ -196,7 +223,7 @@ func UpdateMonitor(instanceID, monitorID string, upd bson.M) error {
if !ok {
return fmt.Errorf("runner must be a string")
}
if err := validateRunner(instanceID, runner); err != nil {
if err := validateRunner(instanceID, runner, tokenScope); err != nil {
return err
}
@@ -424,5 +451,5 @@ func notifyTransition(m *models.Monitor, newStatus, message string) {
}
}(ch)
}
_ = UpdateMonitor(m.InstanceID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()})
_ = UpdateMonitor(m.InstanceID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()}, nil)
}
+18 -1
View File
@@ -93,7 +93,16 @@ type PackageHit struct {
// The Mongo filter narrows to documents containing the name; the second pass is
// needed because a multikey match returns the whole document, not the matching
// array element.
func SearchPackages(instanceID, name string) ([]PackageHit, error) {
//
// tokenScope is the acting credential's tag restriction, nil meaning
// unrestricted; a hit on a server outside it is dropped before it is returned.
// The filtering is done with VisibleServerIDs — one membership set resolved
// once — rather than by resolving each hit's server individually the way
// search_fleet does, because a package search can return one hit per host in
// the fleet and the query shape must not depend on how many matched. The Mongo
// query itself is unchanged: server_packages carries no tags to filter on, so
// the narrowing is necessarily a second pass either way.
func SearchPackages(instanceID, name string, tokenScope map[string]string) ([]PackageHit, error) {
ctx := context.Background()
cur, err := db.Col("server_packages").Find(ctx, bson.M{
"instance_id": instanceID,
@@ -109,8 +118,16 @@ func SearchPackages(instanceID, name string) ([]PackageHit, error) {
return nil, err
}
visible, restricted, err := VisibleServerIDs(instanceID, tokenScope)
if err != nil {
return nil, err
}
hits := []PackageHit{}
for _, d := range docs {
if restricted && !visible[d.ServerID] {
continue
}
for _, p := range d.Packages {
if p.Name == name {
hits = append(hits, PackageHit{ServerID: d.ServerID, Name: p.Name, Version: p.Version})
+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.
@@ -111,3 +111,77 @@ func TestScopedTargetsExcludeOutOfScopeServers(t *testing.T) {
t.Errorf("scoped targets = %v, want only a", got)
}
}
// A workflow run is dispatched to whatever ResolveTargetsScoped returns, so
// the property Critical 1 restores is that a run of a production-targeting
// workflow, started by a staging token, reaches nothing. Proved over
// UnionTargets for the same reason as the test above: no database.
func TestScopedRunOfOutOfScopeWorkflowReachesNothing(t *testing.T) {
all := []models.Server{
{ServerID: "prod-1", Tags: map[string]string{"env": "prod"}},
{ServerID: "prod-2", Tags: map[string]string{"env": "prod"}},
}
scope := map[string]string{"env": "staging"}
visible := []models.Server{}
for _, s := range all {
if ServerInTokenScope(s, scope) {
visible = append(visible, s)
}
}
// The workflow's own saved targets, both by ID and by tag.
got := UnionTargets(visible, []string{"prod-1", "prod-2"}, map[string]string{"env": "prod"})
if len(got) != 0 {
t.Errorf("staging-scoped run resolved %v, want nothing", got)
}
// ResolveTargetsScoped turns that empty set into ErrNoTargets, which is
// the same answer a workflow targeting no servers at all gives — so the
// refusal does not tell the caller that production hosts exist.
}
// The scheduler passes a nil scope because it acts as the system. That must
// keep meaning "the whole fleet", never "nothing", or every scheduled workflow
// would silently stop firing.
func TestNilScopeIsUnrestrictedNotEmpty(t *testing.T) {
all := []models.Server{
{ServerID: "prod-1", Tags: map[string]string{"env": "prod"}},
{ServerID: "stg-1", Tags: map[string]string{"env": "staging"}},
}
visible := []models.Server{}
for _, s := range all {
if ServerInTokenScope(s, nil) {
visible = append(visible, s)
}
}
if len(visible) != 2 {
t.Errorf("nil scope admitted %d servers, want all %d", len(visible), len(all))
}
}
// FilterVisibleServerIDs is what narrows a run document's ServerRuns and a
// workflow's target list. The property that matters is that the caller is told
// something was hidden without being told how much.
func TestFilterVisibleServerIDsReportsHiddenWithoutCount(t *testing.T) {
visible := map[string]bool{"a": true}
got, hidden := FilterVisibleServerIDs([]string{"a", "b", "c"}, visible, true)
if len(got) != 1 || got[0] != "a" {
t.Errorf("filtered = %v, want [a]", got)
}
if !hidden {
t.Error("hidden = false with two ids dropped")
}
// Dropping one and dropping ten are indistinguishable: hidden is a bool.
_, hiddenOne := FilterVisibleServerIDs([]string{"a", "b"}, visible, true)
if hiddenOne != hidden {
t.Error("hidden distinguishes how many were dropped")
}
// An unrestricted caller is never told anything was hidden.
got, hidden = FilterVisibleServerIDs([]string{"a", "b"}, nil, false)
if len(got) != 2 || hidden {
t.Errorf("unrestricted filter = %v, %v; want everything and no hidden flag", got, hidden)
}
}
+14 -2
View File
@@ -17,12 +17,24 @@ import (
const stepDispatchGrace = 15 * time.Second
func TriggerWorkflow(instanceID, workflowID, actor string) (string, error) {
// TriggerWorkflow starts a run of workflow workflowID.
//
// tokenScope is the acting credential's tag restriction, nil meaning
// unrestricted. It is threaded down to ResolveTargetsScoped rather than being
// applied by the caller, because this is the one place a run's target set is
// decided: a handler that resolved targets itself and then called an unscoped
// trigger would leave the dispatch reaching further than the readout.
//
// A run whose configured targets fall entirely outside the caller's scope
// resolves to nothing and returns ErrNoTargets — the same answer a workflow
// targeting no servers at all gives, so an out-of-scope host stays
// indistinguishable from one that does not exist.
func TriggerWorkflow(instanceID, workflowID, actor string, tokenScope map[string]string) (string, error) {
wf, err := GetWorkflow(instanceID, workflowID)
if err != nil {
return "", err
}
targets, err := ResolveTargets(instanceID, wf.TargetServerIDs, wf.TargetTags)
targets, err := ResolveTargetsScoped(instanceID, wf.TargetServerIDs, wf.TargetTags, tokenScope)
if err != nil {
return "", err
}
+19 -6
View File
@@ -239,7 +239,7 @@ func GetWorkflow(instanceID, id string) (*models.Workflow, error) {
return &w, err
}
func CreateWorkflow(instanceID string, w models.Workflow) (*models.Workflow, error) {
func CreateWorkflow(instanceID string, w models.Workflow, tokenScope map[string]string) (*models.Workflow, error) {
ctx, cancel := wfCtx()
defer cancel()
w.InstanceID = instanceID
@@ -258,7 +258,7 @@ func CreateWorkflow(instanceID string, w models.Workflow) (*models.Workflow, err
if err := ValidateTags(w.TargetTags); err != nil {
return nil, err
}
if err := validateTargetServers(instanceID, w.TargetServerIDs); err != nil {
if err := validateTargetServers(instanceID, w.TargetServerIDs, tokenScope); err != nil {
return nil, err
}
normalizeInlineSteps(&w)
@@ -268,7 +268,7 @@ func CreateWorkflow(instanceID string, w models.Workflow) (*models.Workflow, err
return &w, nil
}
func UpdateWorkflow(instanceID, id string, w models.Workflow) error {
func UpdateWorkflow(instanceID, id string, w models.Workflow, tokenScope map[string]string) error {
ctx, cancel := wfCtx()
defer cancel()
if err := ValidateWorkflow(w); err != nil {
@@ -277,7 +277,7 @@ func UpdateWorkflow(instanceID, id string, w models.Workflow) error {
if err := ValidateTags(w.TargetTags); err != nil {
return err
}
if err := validateTargetServers(instanceID, w.TargetServerIDs); err != nil {
if err := validateTargetServers(instanceID, w.TargetServerIDs, tokenScope); err != nil {
return err
}
normalizeInlineSteps(&w)
@@ -291,9 +291,22 @@ func UpdateWorkflow(instanceID, id string, w models.Workflow) error {
return err
}
func validateTargetServers(instanceID string, serverIDs []string) error {
// validateTargetServers refuses a workflow naming a server the acting
// credential cannot see.
//
// It resolves through GetServerScoped rather than GetServer for two reasons.
// The first is escalation: without it a token restricted to staging could save
// a workflow targeting production and then reach those hosts through the
// scheduler, which fires as the system with no restriction of its own. The
// second is enumeration — "target server X not found" versus a successful save
// is a yes/no oracle over the whole fleet, and the design forbids a restricted
// token learning which IDs exist outside its scope.
//
// Both cases collapse into the same message an ID that genuinely does not
// exist produces, which is what keeps the two indistinguishable.
func validateTargetServers(instanceID string, serverIDs []string, tokenScope map[string]string) error {
for _, sid := range serverIDs {
if _, err := GetServer(instanceID, sid); err != nil {
if _, err := GetServerScoped(instanceID, sid, tokenScope); err != nil {
return fmt.Errorf("target server %s not found", sid)
}
}
+15 -1
View File
@@ -93,7 +93,13 @@ type WorkloadHit struct {
// SearchWorkloads answers "which servers run image X" — the reason the snapshot
// is stored rather than fetched on demand and discarded.
func SearchWorkloads(instanceID, image, stack, state string) ([]WorkloadHit, error) {
//
// tokenScope is the acting credential's tag restriction, nil meaning
// unrestricted. A WorkloadHit names a server ID, so an unfiltered fleet-wide
// search enumerates hosts a restricted token must not see. server_workloads
// carries no tags of its own, so the narrowing is a membership test against
// VisibleServerIDs resolved once — the same shape SearchPackages uses.
func SearchWorkloads(instanceID, image, stack, state string, tokenScope map[string]string) ([]WorkloadHit, error) {
ctx := context.Background()
filter := bson.M{"instance_id": instanceID}
@@ -112,8 +118,16 @@ func SearchWorkloads(instanceID, image, stack, state string) ([]WorkloadHit, err
return nil, err
}
visible, restricted, err := VisibleServerIDs(instanceID, tokenScope)
if err != nil {
return nil, err
}
hits := []WorkloadHit{}
for _, d := range docs {
if restricted && !visible[d.ServerID] {
continue
}
for _, w := range d.Workloads {
if image != "" && w.Image != image {
continue
+8 -2
View File
@@ -17,7 +17,7 @@ const tickInterval = 30 * time.Second
// imported because services already imports this package for NextOccurrence,
// and a package cannot import its own importer.
type Deps struct {
TriggerWorkflow func(instanceID, workflowID, actor string) (string, error)
TriggerWorkflow func(instanceID, workflowID, actor string, tokenScope map[string]string) (string, error)
LogEvent func(instanceID, eventType, actor, serverID, keyID, details string)
}
@@ -105,7 +105,13 @@ func process(ctx context.Context, deps Deps, wf models.Workflow, now time.Time)
case SkipRunning:
recordSkip(ctx, deps, wf, string(SkipRunning), dueAt, now)
case Fire:
if _, err := deps.TriggerWorkflow(wf.InstanceID, wf.WorkflowID, "schedule"); err != nil {
// nil tokenScope: the scheduler acts as the system, not as any user.
// A schedule fires the workflow's own saved targets, and there is no
// acting credential whose tag restriction could narrow them — the
// person who armed the schedule is not present at fire time, and
// inheriting a restriction from whoever last saved the workflow would
// make a run's reach depend on an editor's credential.
if _, err := deps.TriggerWorkflow(wf.InstanceID, wf.WorkflowID, "schedule", nil); err != nil {
log.Printf("workflowsched: trigger %s: %v", wf.WorkflowID, err)
recordSkip(ctx, deps, wf, "error: "+err.Error(), dueAt, now)
return
+1
View File
@@ -262,6 +262,7 @@ export default function LicensePage() {
<Feature label="Single sign-on" included={Boolean(license.features.oidc)} />
<Feature label="Vulnerability Scanning" included={Boolean(license.features.vuln_scanning)} />
<Feature label="Status Pages" included={Boolean(license.features.status_pages)} />
<Feature label="Agent Access (MCP)" included={Boolean(license.features.mcp)} />
</div>
</Card>
</Group>