diff --git a/server/cmd/main.go b/server/cmd/main.go index 21b64b2..079cb7b 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -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 +} diff --git a/server/internal/api/console.go b/server/internal/api/console.go index 656fb85..89a0e93 100644 --- a/server/internal/api/console.go +++ b/server/internal/api/console.go @@ -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"}) diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index 5484a18..18d5ac2 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -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 diff --git a/server/internal/api/serverscope.go b/server/internal/api/serverscope.go new file mode 100644 index 0000000..e67a582 --- /dev/null +++ b/server/internal/api/serverscope.go @@ -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 +} diff --git a/server/internal/api/workloads.go b/server/internal/api/workloads.go index 8256cb7..0655522 100644 --- a/server/internal/api/workloads.go +++ b/server/internal/api/workloads.go @@ -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 diff --git a/server/internal/services/servers.go b/server/internal/services/servers.go index 291e17f..51d86fc 100644 --- a/server/internal/services/servers.go +++ b/server/internal/services/servers.go @@ -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() diff --git a/server/internal/services/targets.go b/server/internal/services/targets.go index 741311e..992b563 100644 --- a/server/internal/services/targets.go +++ b/server/internal/services/targets.go @@ -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 +} diff --git a/server/internal/services/tokenscope_test.go b/server/internal/services/tokenscope_test.go index 8ba3f39..feb4804 100644 --- a/server/internal/services/tokenscope_test.go +++ b/server/internal/services/tokenscope_test.go @@ -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) + } +}