diff --git a/server/internal/grpc/server.go b/server/internal/grpc/server.go index f60c461..8e7f9e7 100644 --- a/server/internal/grpc/server.go +++ b/server/internal/grpc/server.go @@ -112,7 +112,7 @@ func (s *vantageServer) SyncMonitors(ctx context.Context, req *pb.SyncMonitorsRe if err != nil { return nil, status.Errorf(codes.Unauthenticated, "invalid agent token") } - monitors, err := services.ListMonitorsForRunner(srv.ServerID) + monitors, err := services.ListMonitorsForRunner(srv.OrgID, srv.ServerID) if err != nil { return nil, status.Errorf(codes.Internal, "list monitors") } @@ -137,7 +137,8 @@ func (s *vantageServer) SyncMonitors(ctx context.Context, req *pb.SyncMonitorsRe } func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRequest) (*pb.ReportChecksResponse, error) { - if _, err := services.ValidateAgentToken(req.ServerId, req.AgentToken); err != nil { + srv, err := services.ValidateAgentToken(req.ServerId, req.AgentToken) + if err != nil { return nil, status.Errorf(codes.Unauthenticated, "invalid agent token") } for _, r := range req.Results { @@ -146,7 +147,9 @@ func (s *vantageServer) ReportChecks(ctx context.Context, req *pb.ReportChecksRe t := time.Unix(r.CertExpiryUnix, 0) res.CertExpiry = &t } - if err := services.IngestResult(r.MonitorId, res); err != nil { + // A rejected monitor (wrong org, or not run by this agent) is skipped, + // not fatal — the rest of the batch is still legitimate. + if err := services.IngestResult(srv.OrgID, srv.ServerID, r.MonitorId, res); err != nil { log.Printf("ingest check %s: %v", r.MonitorId, err) } } diff --git a/server/internal/monitorsched/scheduler.go b/server/internal/monitorsched/scheduler.go index f3b33a8..214f4c9 100644 --- a/server/internal/monitorsched/scheduler.go +++ b/server/internal/monitorsched/scheduler.go @@ -35,7 +35,7 @@ func loop(ctx context.Context) { var mu sync.Mutex sync := func() { - monitors, err := services.ListMonitorsForRunner(models.RunnerServer) + monitors, err := services.ListMonitorsForRunner("", models.RunnerServer) if err != nil { log.Printf("monitorsched: list monitors: %v", err) return @@ -88,7 +88,7 @@ func runMonitor(ctx context.Context, m models.Monitor) { run := func() { res := checker.Run(ctx, spec) - if err := services.IngestResult(m.MonitorID, res); err != nil { + if err := services.IngestResult("", models.RunnerServer, m.MonitorID, res); err != nil { log.Printf("monitorsched: ingest %s: %v", m.MonitorID, err) } } diff --git a/server/internal/services/monitors.go b/server/internal/services/monitors.go index 4de9ec6..38427bc 100644 --- a/server/internal/services/monitors.go +++ b/server/internal/services/monitors.go @@ -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