fix: bind scope tests to real code, close time-of-write gap, fail closed
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 3m41s

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:
2026-09-09 11:56:15 +00:00
parent 2e3d2a33f9
commit 5377a1e585
3 changed files with 207 additions and 87 deletions
+22 -11
View File
@@ -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,