fix(security): scope workflow target and monitor runner validation to the caller's tags

This commit is contained in:
2026-09-09 08:33:38 +00:00
parent 5fcfb40084
commit 6a48dd5d73
6 changed files with 63 additions and 19 deletions
+12 -7
View File
@@ -146,23 +146,28 @@ func getMonitorByID(monitorID string) (*models.Monitor, error) {
return &m, nil
}
func validateRunner(instanceID, runner string) error {
// validateRunner refuses a monitor whose runner names a server the acting
// credential cannot see. A runner is literally a server ID, so an unscoped
// check here both lets a restricted token push work onto an out-of-scope agent
// and answers a fleet-enumeration question by the difference between "not
// found" and success. GetServerScoped collapses both into not-found.
func validateRunner(instanceID, runner string, tokenScope map[string]string) error {
if runner == "" || runner == models.RunnerServer {
return nil
}
if _, err := GetServer(instanceID, runner); err != nil {
if _, err := GetServerScoped(instanceID, runner, tokenScope); err != nil {
return fmt.Errorf("runner server %s not found", runner)
}
return nil
}
func CreateMonitor(instanceID string, m *models.Monitor) (*models.Monitor, error) {
func CreateMonitor(instanceID string, m *models.Monitor, tokenScope map[string]string) (*models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
if err := validateChannelIDs(instanceID, m.ChannelIDs); err != nil {
return nil, err
}
if err := validateRunner(instanceID, m.Runner); err != nil {
if err := validateRunner(instanceID, m.Runner, tokenScope); err != nil {
return nil, err
}
group, err := normaliseGroup(m.Group)
@@ -189,7 +194,7 @@ func CreateMonitor(instanceID string, m *models.Monitor) (*models.Monitor, error
return m, nil
}
func UpdateMonitor(instanceID, monitorID string, upd bson.M) error {
func UpdateMonitor(instanceID, monitorID string, upd bson.M, tokenScope map[string]string) error {
ctx, cancel := monCtx()
defer cancel()
@@ -218,7 +223,7 @@ func UpdateMonitor(instanceID, monitorID string, upd bson.M) error {
if !ok {
return fmt.Errorf("runner must be a string")
}
if err := validateRunner(instanceID, runner); err != nil {
if err := validateRunner(instanceID, runner, tokenScope); err != nil {
return err
}
@@ -446,5 +451,5 @@ func notifyTransition(m *models.Monitor, newStatus, message string) {
}
}(ch)
}
_ = UpdateMonitor(m.InstanceID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()})
_ = UpdateMonitor(m.InstanceID, m.MonitorID, bson.M{"state.last_notified_at": time.Now()}, nil)
}
+19 -6
View File
@@ -239,7 +239,7 @@ func GetWorkflow(instanceID, id string) (*models.Workflow, error) {
return &w, err
}
func CreateWorkflow(instanceID string, w models.Workflow) (*models.Workflow, error) {
func CreateWorkflow(instanceID string, w models.Workflow, tokenScope map[string]string) (*models.Workflow, error) {
ctx, cancel := wfCtx()
defer cancel()
w.InstanceID = instanceID
@@ -258,7 +258,7 @@ func CreateWorkflow(instanceID string, w models.Workflow) (*models.Workflow, err
if err := ValidateTags(w.TargetTags); err != nil {
return nil, err
}
if err := validateTargetServers(instanceID, w.TargetServerIDs); err != nil {
if err := validateTargetServers(instanceID, w.TargetServerIDs, tokenScope); err != nil {
return nil, err
}
normalizeInlineSteps(&w)
@@ -268,7 +268,7 @@ func CreateWorkflow(instanceID string, w models.Workflow) (*models.Workflow, err
return &w, nil
}
func UpdateWorkflow(instanceID, id string, w models.Workflow) error {
func UpdateWorkflow(instanceID, id string, w models.Workflow, tokenScope map[string]string) error {
ctx, cancel := wfCtx()
defer cancel()
if err := ValidateWorkflow(w); err != nil {
@@ -277,7 +277,7 @@ func UpdateWorkflow(instanceID, id string, w models.Workflow) error {
if err := ValidateTags(w.TargetTags); err != nil {
return err
}
if err := validateTargetServers(instanceID, w.TargetServerIDs); err != nil {
if err := validateTargetServers(instanceID, w.TargetServerIDs, tokenScope); err != nil {
return err
}
normalizeInlineSteps(&w)
@@ -291,9 +291,22 @@ func UpdateWorkflow(instanceID, id string, w models.Workflow) error {
return err
}
func validateTargetServers(instanceID string, serverIDs []string) error {
// validateTargetServers refuses a workflow naming a server the acting
// credential cannot see.
//
// It resolves through GetServerScoped rather than GetServer for two reasons.
// The first is escalation: without it a token restricted to staging could save
// a workflow targeting production and then reach those hosts through the
// scheduler, which fires as the system with no restriction of its own. The
// second is enumeration — "target server X not found" versus a successful save
// is a yes/no oracle over the whole fleet, and the design forbids a restricted
// token learning which IDs exist outside its scope.
//
// Both cases collapse into the same message an ID that genuinely does not
// exist produces, which is what keeps the two indistinguishable.
func validateTargetServers(instanceID string, serverIDs []string, tokenScope map[string]string) error {
for _, sid := range serverIDs {
if _, err := GetServer(instanceID, sid); err != nil {
if _, err := GetServerScoped(instanceID, sid, tokenScope); err != nil {
return fmt.Errorf("target server %s not found", sid)
}
}