fix: scope the assignment count leaked by the key list

GET /api/keys returned each key's AssignedCount as a raw
CountDocuments over every non-revoked assignment, with no scope filter —
a tag-restricted token reading the list saw a nonzero count for a key it
can see nothing assigned to in its own scope, which is enough to tell it
an assignment exists on a host it must not know about. Same class of leak
getKey's assignment-list filter closed on the detail route, surviving on
the list route through a count instead of a server object.

services.ListKeys now takes the caller's tokenScope. An unrestricted
caller (empty scope) takes the original unfiltered per-key
CountDocuments with no extra work, so the common case is not slower. A
restricted caller resolves the visible fleet once via ListServers before
the per-key loop, then counts each key's assignments with an added
server_id $in filter — one extra query total, not one per key.
ListKeys had exactly one caller (listKeys), so the parameter went there
rather than adding a second entry point.

Recorded GET /api/keys in serverScopedRoutes as true; its path, like GET
/api/keys/:id, matches none of serverTouchingRoutes' substrings, so the
entry is not boot-enforced. Deliberately did not widen the filter to
catch "keys" — that would sweep in create/delete/private-key routes with
no server data at all. The real fix for this shape of gap is the
declare-by-default inversion already recorded as a follow-up.
This commit is contained in:
2026-09-09 07:54:42 +00:00
parent dc6e1b3c29
commit e06f9d5670
3 changed files with 55 additions and 4 deletions
+1 -1
View File
@@ -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
+15
View File
@@ -72,6 +72,21 @@ var serverScopedRoutes = map[string]bool{
// their substrings were added to the filter.
"GET /api/keys/:id": true,
// 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. This route's path carries none of "server",
// ":serverId", "console" or "assign" either, so — like GET
// /api/keys/:id — it is not swept in by serverTouchingRoutes and this
// entry is not boot-enforced. Widening the filter to catch "keys" was
// deliberately not done: it would sweep in every other /keys route
// (create, delete, private-key) that has nothing to do with servers.
// The real fix for routes like this and GET /api/keys/:id is the
// declare-by-default inversion already recorded as a follow-up above.
"GET /api/keys": 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