Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2351c75dd | ||
|
|
3695bc9e1a | ||
|
|
9bcec168b9 | ||
|
|
d95f299562 | ||
|
|
ac8e957859 | ||
|
|
6a48dd5d73 | ||
|
|
5fcfb40084 | ||
|
|
705085d3c7 | ||
|
|
67520c677b | ||
|
|
bb698eba8a | ||
|
|
3bdbf33f90 | ||
|
|
e06f9d5670 | ||
|
|
dc6e1b3c29 | ||
|
|
cbf929fe2d | ||
|
|
b3651ab58c | ||
|
|
191a8e9074 | ||
|
|
ed79df4270 |
+14
-11
@@ -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
|
||||
}
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user