feat: enforce token tag restrictions at the server resolution chokepoints

This commit is contained in:
2026-09-08 13:43:38 +00:00
parent 2481974b3a
commit f87986b4f7
8 changed files with 208 additions and 13 deletions
+2 -2
View File
@@ -46,7 +46,7 @@ func consoleConnect(c *gin.Context) {
return
}
srv, err := services.GetServer(auth.InstanceID(c), body.ServerID)
srv, err := services.GetServerScoped(auth.InstanceID(c), body.ServerID, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -168,7 +168,7 @@ func consoleTunnel(c *gin.Context) {
return
}
srv, err := services.GetServer(auth.InstanceID(c), sess.ServerID)
srv, err := services.GetServerScoped(auth.InstanceID(c), sess.ServerID, auth.ServerScope(c))
if err != nil {
tlog("reject: server %s not found: %v", sess.ServerID, err)
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
+18 -7
View File
@@ -185,11 +185,18 @@ func RegisterRoutes(r *gin.Engine) {
// @Security bearerAuth
// @Router /servers [get]
func listServers(c *gin.Context) {
sel, err := services.ParseTagFilters(c.QueryArray("tag"))
requested, err := services.ParseTagFilters(c.QueryArray("tag"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
sel, ok := services.IntersectSelectors(auth.ServerScope(c), requested)
if !ok {
// The token's own restriction and the requested filter can never both
// hold, so this resolves to nothing rather than an error.
c.JSON(http.StatusOK, []models.Server{})
return
}
servers, err := services.ListServersFiltered(auth.InstanceID(c), sel)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -246,7 +253,7 @@ func putServerTags(c *gin.Context) {
instanceID := auth.InstanceID(c)
serverID := c.Param("id")
before, err := services.GetServer(instanceID, serverID)
before, err := services.GetServerScoped(instanceID, serverID, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -352,7 +359,7 @@ func newServer(c *gin.Context) {
// @Router /servers/{id} [get]
func getServer(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(auth.InstanceID(c), id)
s, err := services.GetServerScoped(auth.InstanceID(c), id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -379,7 +386,11 @@ func getServer(c *gin.Context) {
// @Router /servers/{id} [delete]
func deleteServer(c *gin.Context) {
id := c.Param("id")
s, _ := services.GetServer(auth.InstanceID(c), id)
s, err := services.GetServerScoped(auth.InstanceID(c), id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
if err := services.DeleteServer(auth.InstanceID(c), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
@@ -422,7 +433,7 @@ func generateKey(c *gin.Context) {
body.Label = "generated"
}
s, err := services.GetServer(auth.InstanceID(c), id)
s, err := services.GetServerScoped(auth.InstanceID(c), id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -670,7 +681,7 @@ func getLatestAgentVersion(c *gin.Context) {
// @Router /servers/{id}/update-agent [post]
func updateAgent(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(auth.InstanceID(c), id)
s, err := services.GetServerScoped(auth.InstanceID(c), id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -703,7 +714,7 @@ func updateAgent(c *gin.Context) {
// @Router /servers/{id}/apply-updates [post]
func applyUpdates(c *gin.Context) {
id := c.Param("id")
s, err := services.GetServer(auth.InstanceID(c), id)
s, err := services.GetServerScoped(auth.InstanceID(c), id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
+89
View File
@@ -0,0 +1,89 @@
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.
//
// 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.
//
// false means "deliberately fleet-wide" and requires a comment saying why.
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,
// 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,
// Creating a server has no server to filter yet.
"POST /api/servers": false,
// The agent's own enrolment routes authenticate as the agent, not as a
// user token, so no session selector exists to apply.
"GET /api/servers/new": false,
"POST /api/servers/new": false,
// KnownTags aggregates the tag *vocabulary* in use across the fleet —
// keys and the values seen for them — never a server identifier or any
// other server attribute, so it does not let a restricted token enumerate
// which hosts exist. Scoping it would need ListServersFiltered-style
// plumbing through KnownTags for a leak this narrow; left fleet-wide for
// now.
"GET /api/servers/tags": false,
// listServerVulnerabilities and getServerPackages read findings/package
// data keyed by server ID without checking the token's tag restriction —
// this task's interfaces (GetServerScoped, ResolveTargetsScoped) only
// wrap services.GetServer/ListServers/ResolveTargets, and these two
// handlers call services.ListFindings/ListPackages directly, so they are
// out of this task's chokepoints. This is a known, unresolved gap: a
// restricted token can currently read vulnerability/package data for a
// server outside its scope by ID. Flagged for a follow-up task rather
// than silently left off this map.
"GET /api/servers/:id/vulnerabilities": false,
"GET /api/servers/:id/packages": false,
// getServerRunLog/streamServerRunLog read a run's per-server log by
// (runId, serverId) via services.HasServerRunLog/ReadServerRunLog, not
// through GetServer/ListServers/ResolveTargets, so — same as the
// vulnerabilities/packages routes above — they are outside this task's
// chokepoints. Known gap: a restricted token that already knows a runId
// can currently read log output for a serverId outside its scope.
// Flagged for a follow-up task.
"GET /api/runs/:runId/servers/:serverId/logs": false,
"GET /api/runs/:runId/servers/:serverId/logs/stream": false,
// revokeAssignment calls services.RevokeAssignment(instanceID, keyID,
// serverID) directly and never resolves the server through GetServer, so
// it is also outside this task's chokepoints. Known gap: a restricted
// token could revoke a key assignment naming a serverId outside its
// scope. Flagged for a follow-up task.
"DELETE /api/keys/:id/assign/:serverId": false,
}
// AssertServerScopeMapComplete refuses to boot when a route touching server
// data is missing from serverScopedRoutes.
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 nil
}
+4 -4
View File
@@ -35,7 +35,7 @@ func getServerWorkloads(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
if _, err := services.GetServer(instanceID, id); err != nil {
if _, err := services.GetServerScoped(instanceID, id, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
@@ -77,7 +77,7 @@ func refreshServerWorkloads(c *gin.Context) {
instanceID := auth.InstanceID(c)
id := c.Param("id")
s, err := services.GetServer(instanceID, id)
s, err := services.GetServerScoped(instanceID, id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -139,7 +139,7 @@ func controlWorkload(c *gin.Context) {
return
}
s, err := services.GetServer(instanceID, id)
s, err := services.GetServerScoped(instanceID, id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
@@ -205,7 +205,7 @@ func getWorkloadLogs(c *gin.Context) {
tail = services.MaxWorkloadLogLines
}
s, err := services.GetServer(instanceID, id)
s, err := services.GetServerScoped(instanceID, id, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
+19
View File
@@ -71,6 +71,25 @@ func GetServer(instanceID, serverID string) (*models.Server, error) {
return &s, nil
}
// GetServerScoped is GetServer narrowed by the acting credential's tag
// restriction. An out-of-scope server reads as not-found, never as forbidden:
// a restricted token must not be able to enumerate the fleet it cannot see by
// noticing which IDs answer differently.
//
// mongo.ErrNoDocuments is GetServer's own not-found identifier — reused here
// rather than introducing a second one, so a caller checking for one keeps
// working against a server that exists but is out of the token's scope.
func GetServerScoped(instanceID, serverID string, tokenScope map[string]string) (*models.Server, error) {
srv, err := GetServer(instanceID, serverID)
if err != nil {
return nil, err
}
if !ServerInTokenScope(*srv, tokenScope) {
return nil, mongo.ErrNoDocuments
}
return srv, nil
}
func getServerByID(serverID string) (*models.Server, error) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
+30
View File
@@ -66,3 +66,33 @@ func ResolveTargets(instanceID string, ids []string, sel map[string]string) ([]m
}
return matched, nil
}
// ResolveTargetsScoped is ResolveTargets narrowed by the acting credential's
// tag restriction.
//
// This is the chokepoint that matters: workflow runs, console connections and
// update application all resolve targets through here, so filtering once here
// covers the mutating surface rather than each handler remembering.
//
// A request naming an out-of-scope server by ID resolves to nothing rather than
// to an error, which is what makes an out-of-scope host indistinguishable from
// one that does not exist.
func ResolveTargetsScoped(instanceID string, ids []string, sel, tokenScope map[string]string) ([]models.Server, error) {
all, err := ListServers(instanceID)
if err != nil {
return nil, err
}
visible := make([]models.Server, 0, len(all))
for _, s := range all {
if ServerInTokenScope(s, tokenScope) {
visible = append(visible, s)
}
}
matched := UnionTargets(visible, ids, sel)
if len(matched) == 0 {
return nil, ErrNoTargets
}
return matched, nil
}
@@ -88,3 +88,26 @@ func TestSelectorNarrowerOrEqual(t *testing.T) {
t.Error("restricted child of an unrestricted parent rejected")
}
}
// UnionTargets is the pure core of target resolution, so scoping can be proved
// without a database by filtering its input the way ResolveTargetsScoped does.
func TestScopedTargetsExcludeOutOfScopeServers(t *testing.T) {
all := []models.Server{
{ServerID: "a", Tags: map[string]string{"env": "staging"}},
{ServerID: "b", 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)
}
}
// Naming an out-of-scope server by ID must not reach it.
got := UnionTargets(visible, []string{"a", "b"}, nil)
if len(got) != 1 || got[0].ServerID != "a" {
t.Errorf("scoped targets = %v, want only a", got)
}
}