feat(grpc): resolve org from server record for agent RPCs

Two instances of the branch's recurring bug class remained in the agent
path: a client-supplied ID accepted as data, then consumed by an
unscoped query.

- ListMonitorsForRunner filtered on `runner` alone, and `runner` is set
  by the client on monitor create/update. Org A could point a monitor at
  org B's server_id and org B's agent would fetch and execute the check.
  Now org-filtered, and `runner` is validated against the caller's org on
  create and update.
- IngestResult resolved the monitor via the unfiltered getMonitorByID
  using a monitor_id from the agent's request body, letting org A's agent
  write state and incidents into org B's monitor and fire its channels.
  Now rejects on org mismatch and on a monitor not assigned to the
  reporting agent.

The in-process scheduler passes an empty orgID as a documented sentinel
for the cross-org server-run sweep.

Install script still emits a single shared GRPC_HOST; the agent path
resolves org from the server record, never from a hostname.
This commit is contained in:
2026-07-22 10:00:30 +01:00
parent 4f512d01f1
commit e2f5f1fa8c
3 changed files with 55 additions and 10 deletions
+47 -5
View File
@@ -3,6 +3,7 @@ package services
import (
"context"
"errors"
"fmt"
"log"
"time"
@@ -51,12 +52,19 @@ func ListMonitors(orgID string) ([]models.Monitor, error) {
}
// ListMonitorsForRunner returns enabled monitors whose Runner matches runner.
// Agent/scheduler path — a cross-org sweep (mirrors MarkOfflineServers), so it
// intentionally has no org filter.
func ListMonitorsForRunner(runner string) ([]models.Monitor, error) {
// Runner is client-supplied at write time, so an agent fetching its own work
// must scope by the org of its authenticated server record — otherwise another
// org could point a monitor at that server_id and have it run their checks.
// An empty orgID means the cross-org server-scheduler sweep (mirrors
// MarkOfflineServers) and is only ever passed with runner == RunnerServer.
func ListMonitorsForRunner(orgID, runner string) ([]models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("monitors").Find(ctx, bson.M{"runner": runner, "enabled": true})
filter := bson.M{"runner": runner, "enabled": true}
if orgID != "" {
filter["org_id"] = orgID
}
cur, err := db.Col("monitors").Find(ctx, filter)
if err != nil {
return nil, err
}
@@ -98,12 +106,29 @@ func getMonitorByID(monitorID string) (*models.Monitor, error) {
return &m, nil
}
// validateRunner rejects a runner that is neither the reserved server-scheduler
// value nor a server in the org. The value is client-supplied and is later
// consumed by an agent's own monitor fetch, so ownership has to be proven at
// the write boundary.
func validateRunner(orgID, runner string) error {
if runner == "" || runner == models.RunnerServer {
return nil
}
if _, err := GetServer(orgID, runner); err != nil {
return fmt.Errorf("runner server %s not found", runner)
}
return nil
}
func CreateMonitor(orgID string, m *models.Monitor) (*models.Monitor, error) {
ctx, cancel := monCtx()
defer cancel()
if err := validateChannelIDs(orgID, m.ChannelIDs); err != nil {
return nil, err
}
if err := validateRunner(orgID, m.Runner); err != nil {
return nil, err
}
m.OrgID = orgID
m.MonitorID = uuid.NewString()
m.CreatedAt = time.Now()
@@ -131,6 +156,11 @@ func UpdateMonitor(orgID, monitorID string, upd bson.M) error {
return err
}
}
if runner, ok := upd["runner"].(string); ok {
if err := validateRunner(orgID, runner); err != nil {
return err
}
}
_, err := db.Col("monitors").UpdateOne(ctx, bson.M{"monitor_id": monitorID, "org_id": orgID}, bson.M{"$set": upd})
return err
}
@@ -191,7 +221,13 @@ func UptimeRollups(monitorID string, since time.Time) ([]models.Rollup, error) {
// incidents on up<->down transitions, rolls up the hourly bucket, and fires
// notifications on transition. Both the server scheduler and agent-reported
// results funnel through here.
func IngestResult(monitorID string, res checker.Result) error {
//
// monitorID is client-supplied on the agent path, so the caller passes the org
// and runner it is authenticated as: orgID is the reporting agent's server org
// and runner is its server_id. A result is only applied to a monitor owned by
// that org and assigned to that runner. An empty orgID is the in-process server
// scheduler, which passes runner == RunnerServer.
func IngestResult(orgID, runner, monitorID string, res checker.Result) error {
ctx, cancel := monCtx()
defer cancel()
@@ -199,6 +235,12 @@ func IngestResult(monitorID string, res checker.Result) error {
if err != nil || m == nil {
return err
}
if orgID != "" && m.OrgID != orgID {
return fmt.Errorf("monitor %s belongs to another org", monitorID)
}
if m.Runner != runner {
return fmt.Errorf("monitor %s is not run by %s", monitorID, runner)
}
now := time.Now()
prev := m.State.Status