Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 38 additions & 9 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,18 @@ import (
"strings"
"time"

"github.com/jackc/pgx/v5/pgconn"

"github.com/OpenSourceOM/core/internal/export"
"github.com/OpenSourceOM/core/internal/graph"
"github.com/OpenSourceOM/core/internal/rules"
)

const (
schemaNotReadyCode = "schema_not_ready"
schemaNotReadyMessage = "The graph database has not been initialized. Run om migrate, then refresh this page."
)

type Server struct {
store *graph.Store
health healthChecker
Expand Down Expand Up @@ -112,7 +119,7 @@ func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) {
}

if err := s.store.UpsertBatch(r.Context(), batch); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
writeStoreError(w, err)
return
}

Expand All @@ -125,7 +132,7 @@ func (s *Server) handleIngest(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
stats, err := s.store.Stats(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
writeStoreError(w, err)
return
}
writeJSON(w, http.StatusOK, stats)
Expand Down Expand Up @@ -166,7 +173,7 @@ func (s *Server) handleRulesRun(w http.ResponseWriter, r *http.Request) {
result, err = s.rules.Run(r.Context(), ruleID)
}
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
writeFailure(w, err, http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, result)
Expand All @@ -182,14 +189,14 @@ func (s *Server) handleBlastRadius(w http.ResponseWriter, r *http.Request) {
}
node, err := s.store.FindIdentityByName(r.Context(), name)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
writeFailure(w, err, http.StatusBadRequest)
return
}
identityID = node.ID
}
result, err := s.store.BlastRadius(r.Context(), identityID, 6)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
writeFailure(w, err, http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, result)
Expand All @@ -204,7 +211,7 @@ func (s *Server) handleExportSlack(w http.ResponseWriter, r *http.Request) {

records, err := export.LoadFindingRecords(r.Context(), s.store)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
writeStoreError(w, err)
return
}

Expand Down Expand Up @@ -302,13 +309,35 @@ func parsePageLimit(raw string, fallback, max int) (int, error) {
}

func writeListError(w http.ResponseWriter, err error) {
status := http.StatusInternalServerError
if errors.Is(err, graph.ErrInvalidCursor) {
status = http.StatusBadRequest
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeStoreError(w, err)
}

func writeStoreError(w http.ResponseWriter, err error) {
writeFailure(w, err, http.StatusInternalServerError)
}

func writeFailure(w http.ResponseWriter, err error, status int) {
if isSchemaNotReady(err) {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": schemaNotReadyMessage,
"code": schemaNotReadyCode,
})
return
}
writeJSON(w, status, map[string]string{"error": err.Error()})
}

// isSchemaNotReady reports a missing graph table, which is what Postgres
// returns when om migrate has not been applied.
func isSchemaNotReady(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "42P01"
}

func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {
name := r.URL.Query().Get("name")
if name == "" {
Expand All @@ -318,7 +347,7 @@ func (s *Server) handleQuery(w http.ResponseWriter, r *http.Request) {

result, err := s.querier.Run(r.Context(), name)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
writeFailure(w, err, http.StatusBadRequest)
return
}
writeJSON(w, http.StatusOK, result)
Expand Down
48 changes: 48 additions & 0 deletions internal/api/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/jackc/pgx/v5/pgconn"
)

type fakeHealthChecker struct {
Expand Down Expand Up @@ -128,6 +131,51 @@ func TestAPISecretUnsetLeavesRoutesOpen(t *testing.T) {
}
}

func TestWriteStoreErrorSchemaNotReady(t *testing.T) {
err := fmt.Errorf("count nodes: %w", &pgconn.PgError{
Code: "42P01",
Message: `relation "nodes" does not exist`,
})
recorder := httptest.NewRecorder()
writeStoreError(recorder, err)

if recorder.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusServiceUnavailable)
}
var body map[string]string
if decodeErr := json.NewDecoder(recorder.Body).Decode(&body); decodeErr != nil {
t.Fatalf("decode response: %v", decodeErr)
}
if body["code"] != schemaNotReadyCode {
t.Fatalf("code = %q, want %q", body["code"], schemaNotReadyCode)
}
if body["error"] != schemaNotReadyMessage {
t.Fatalf("error = %q, want %q", body["error"], schemaNotReadyMessage)
}
if strings.Contains(body["error"], "SQLSTATE") || strings.Contains(body["error"], "relation") {
t.Fatalf("schema error leaked database details: %q", body["error"])
}
}

func TestWriteStoreErrorKeepsOtherFailures(t *testing.T) {
recorder := httptest.NewRecorder()
writeStoreError(recorder, errors.New("connection reset"))

if recorder.Code != http.StatusInternalServerError {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusInternalServerError)
}
var body map[string]string
if err := json.NewDecoder(recorder.Body).Decode(&body); err != nil {
t.Fatalf("decode response: %v", err)
}
if body["error"] != "connection reset" {
t.Fatalf("error = %q", body["error"])
}
if _, ok := body["code"]; ok {
t.Fatal("unrelated store errors must not use the schema-not-ready code")
}
}

func TestParsePageLimit(t *testing.T) {
const max = 500
tests := []struct {
Expand Down
31 changes: 25 additions & 6 deletions internal/api/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,13 @@ async function runRules() {
btn.textContent = "Running rules…";
try {
const res = await fetch("/v1/rules/run", { method: "POST", headers: apiHeaders() });
if (!res.ok) throw new Error(`rules run returned ${res.status}`);
if (!res.ok) throw await errorFromResponse(res, "/v1/rules/run");
await refresh();
} catch (err) {
if (err.schemaNotReady) {
showConsoleError(err);
return;
}
alert(`CSPM rules require the API secret: ${err.message}`);
} finally {
btn.disabled = false;
Expand Down Expand Up @@ -64,13 +68,25 @@ function apiHeaders() {
return { "X-API-Key": key };
}

async function errorFromResponse(res, path) {
let body = null;
try {
body = await res.json();
} catch (err) {
body = null;
}
const error = new Error(`${path} returned ${res.status}`);
error.status = res.status;
if (body && body.code === "schema_not_ready" && body.error) {
error.schemaNotReady = true;
error.message = body.error;
}
return error;
}

async function fetchJSON(path) {
const res = await fetch(path, { headers: apiHeaders() });
if (!res.ok) {
const error = new Error(`${path} returned ${res.status}`);
error.status = res.status;
throw error;
}
if (!res.ok) throw await errorFromResponse(res, path);
return res.json();
}

Expand All @@ -79,6 +95,9 @@ function isUnauthorized(err) {
}

function consoleLoadError(err) {
if (err && err.schemaNotReady) {
return err.message;
}
if (!isUnauthorized(err)) {
return `Failed to load console: ${err.message}`;
}
Expand Down
Loading