fix: audit refused and failed mcp write calls, scope key assignment to token

Every early return from a write-tool handler skipped both the tool's own
LogCall and transport.go's gated LogCall (which only fires for reads), so a
blocked mutation attempt left no audit trail. registerSDKTool now routes
every write-tool error through LogDenied (fan-out and tag-scope refusals,
by gate name) or LogFailure (everything else), keeping the successful-write
path logging its own resolved server count exactly as before.

Also close a live scope gap surfaced while reviewing this: POST
/api/keys/:id/assign called services.AssignKey with an unscoped GetServer
lookup, so a tag-restricted token could assign a key to a server outside
its restriction. The handler now resolves the target through
GetServerScoped first, matching its sibling revoke route, and the route is
recorded in serverScopedRoutes.
This commit is contained in:
2026-09-09 07:46:45 +00:00
parent b3651ab58c
commit cbf929fe2d
6 changed files with 213 additions and 22 deletions
+7 -1
View File
@@ -623,6 +623,7 @@ func deleteKey(c *gin.Context) {
// @Router /keys/{id}/assign [post]
func assignKey(c *gin.Context) {
keyID := c.Param("id")
instanceID := auth.InstanceID(c)
var body struct {
ServerID string `json:"server_id" binding:"required"`
}
@@ -631,7 +632,12 @@ func assignKey(c *gin.Context) {
return
}
a, err := services.AssignKey(auth.InstanceID(c), keyID, body.ServerID)
if _, err := services.GetServerScoped(instanceID, body.ServerID, auth.ServerScope(c)); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
return
}
a, err := services.AssignKey(instanceID, keyID, body.ServerID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
+10
View File
@@ -50,6 +50,16 @@ var serverScopedRoutes = map[string]bool{
// assignment on a server outside its scope.
"DELETE /api/keys/:id/assign/:serverId": true,
// assignKey resolves body.ServerID through GetServerScoped before calling
// services.AssignKey, which itself uses the unscoped GetServer — so a
// restricted token can no longer assign a key to a server outside its
// scope by naming it in the request body. This route's path carries
// neither "server" nor "console", so it is not swept in by
// serverTouchingRoutes and this entry is not boot-enforced; it is kept
// here anyway as the record of a considered decision, matching its
// sibling revoke route.
"POST /api/keys/:id/assign": 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