fix: bind scope tests to real code, close time-of-write gap, fail closed
The previous tests reimplemented the target-scope rule instead of calling validateWorkflowTargetScope, so they proved nothing about CreateWorkflow or UpdateWorkflow's actual enforcement. Split the check into a pure decideWorkflowTargetScope (tested directly, no database) and a thin wrapper behind an overridable listServersForScope seam, so tests can invoke the real CreateWorkflow/UpdateWorkflow without a live database and fail if the call sites are removed. Close the time-of-write/time-of-fire gap: a restricted caller could previously save target_tags matching no server today (a selector aimed at hosts not yet provisioned or not yet tagged), pass validation on an empty set, and have the scheduler fire on those hosts the moment they appeared. Now a restricted caller specifying targets that resolve to nothing is refused with the same message as an out-of-scope match; a workflow with no targets at all, and an unrestricted caller, are unaffected. A database error while resolving the fleet now surfaces as an error instead of folding into a pass. Correct three comments that overstated what the code does: the create/update route comment now mentions the tag-scope check, not only validateTargetServers; the schedule route comment explains its safety holds only for workflows written after this check existed, not for rows already in the database under the old rule.
This commit is contained in:
@@ -187,11 +187,16 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
"GET /api/workflows/:id": scoped,
|
||||
|
||||
// createWorkflow/updateWorkflow validate target_server_ids through
|
||||
// services.validateTargetServers, which resolves each named ID with
|
||||
// GetServerScoped — so a restricted token can neither save a workflow
|
||||
// targeting a host outside its scope (which the scheduler, firing as the
|
||||
// system, would otherwise run there) nor learn which IDs exist by the
|
||||
// difference between "target server not found" and a successful save.
|
||||
// services.validateTargetServers (GetServerScoped per ID) and separately
|
||||
// validate the ID-union-tags target set as a whole through
|
||||
// services.validateWorkflowTargetScope, which resolves the workflow's
|
||||
// targets both unscoped and scoped and refuses to save unless they match
|
||||
// — the same all-or-nothing rule the MCP create_workflow tool applies.
|
||||
// Together these mean a restricted token can neither save a workflow
|
||||
// targeting a host or tag outside its scope (which the scheduler, firing
|
||||
// as the system, would otherwise run there) nor learn which IDs or tags
|
||||
// resolve to something by the difference between a refusal and a
|
||||
// successful save.
|
||||
"POST /api/workflows": scoped,
|
||||
"PUT /api/workflows/:id": scoped,
|
||||
|
||||
@@ -233,12 +238,18 @@ var serverScopedRoutes = map[string]scopeDecl{
|
||||
// UpdateWorkflow (internal/services/workflows.go) already refuse to save
|
||||
// a workflow whose resolved targets — TargetServerIDs union TargetTags —
|
||||
// reach outside the acting credential's scope, the same all-or-nothing
|
||||
// rule the MCP create_workflow tool applies. So by the time a workflow
|
||||
// exists to be scheduled, its targets were already constrained to
|
||||
// whichever scope wrote them. The scheduler later fires it with a nil
|
||||
// token scope, acting as the system rather than as any caller, and that
|
||||
// is fine precisely because the targets were fixed at write time, not at
|
||||
// fire time.
|
||||
// rule the MCP create_workflow tool applies. So a workflow written after
|
||||
// this check existed had its targets constrained to whichever scope wrote
|
||||
// it, and the scheduler firing it later with a nil token scope — acting
|
||||
// as the system, not as any caller — reaches nothing that write didn't
|
||||
// already allow.
|
||||
//
|
||||
// This holds only for workflows written after the check was added. Rows
|
||||
// already in the database were saved under the old, unvalidated rule and
|
||||
// are never re-validated — neither this route nor the writers re-check an
|
||||
// existing row's targets after the fact. A workflow saved before this fix
|
||||
// with an out-of-scope tag selector still schedules and fires exactly as
|
||||
// it did before.
|
||||
"PUT /api/workflows/:id/schedule": fleetWide,
|
||||
"GET /api/workflows/:id/schedule/preview": exempt,
|
||||
|
||||
|
||||
Reference in New Issue
Block a user