feat: enforce token tag restrictions at the server resolution chokepoints
This commit is contained in:
@@ -282,6 +282,10 @@ func serve() {
|
||||
log.Fatalf("api scope map: %v", err)
|
||||
}
|
||||
|
||||
if err := api.AssertServerScopeMapComplete(serverTouchingRoutes(r)); err != nil {
|
||||
log.Fatalf("api server scope map: %v", err)
|
||||
}
|
||||
|
||||
srv := &http.Server{Addr: ":8080", Handler: r}
|
||||
go func() {
|
||||
log.Println("REST server listening on :8080")
|
||||
@@ -343,3 +347,22 @@ 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user