feat: enforce token tag restrictions at the server resolution chokepoints
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user