Skip to content

Commit 489f301

Browse files
authored
alertmanager: Implement /silence/{silenceID} endpoint (#904)
* alertmanager: Implement /silence/silenceID endpoint This commit adds silence/ endpoints for individually getting and deleting silences. Uses similar prom-label-proxy approach from https://github.com/prometheus-community/prom-label-proxy/blob/main/injectproxy/silences.go * Refactor Signed-off-by: Saswata Mukherjee <saswataminsta@yahoo.com> --------- Signed-off-by: Saswata Mukherjee <saswataminsta@yahoo.com>
1 parent c81b3a9 commit 489f301

4 files changed

Lines changed: 412 additions & 14 deletions

File tree

api/metrics/v1/alertmanager_enforcer.go

Lines changed: 86 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,21 @@ package v1
22

33
import (
44
"bytes"
5+
"context"
56
"encoding/json"
7+
"errors"
68
"fmt"
79
"io"
810
"net/http"
11+
"net/url"
12+
"path"
913
"strconv"
1014

15+
"github.com/go-chi/chi/v5"
16+
runtimeclient "github.com/go-openapi/runtime/client"
17+
"github.com/go-openapi/strfmt"
18+
"github.com/prometheus/alertmanager/api/v2/client"
19+
"github.com/prometheus/alertmanager/api/v2/client/silence"
1120
"github.com/prometheus/alertmanager/api/v2/models"
1221
amlabels "github.com/prometheus/alertmanager/pkg/labels"
1322
"github.com/prometheus/prometheus/model/labels"
@@ -64,10 +73,11 @@ func WithEnforceTenancyOnFilter(label string) func(http.Handler) http.Handler {
6473
}
6574
}
6675

67-
// WithEnforceTenancyOnFilter returns a middleware that ensures that every filter has a tenant label enforced.
76+
// WithEnforceTenancyOnSilenceMatchers returns a middleware that ensures POST silence requests
77+
// include the tenant label matcher.
6878
func WithEnforceTenancyOnSilenceMatchers(label string) func(http.Handler) http.Handler {
6979
return func(next http.Handler) http.Handler {
70-
// https://github.com/prometheus-community/prom-label-proxy/
80+
// https://github.com/prometheus-community/prom-label-proxy/injectproxy/silences.go
7181
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
7282
id, ok := authentication.GetTenantID(r.Context())
7383
if !ok {
@@ -90,6 +100,7 @@ func WithEnforceTenancyOnSilenceMatchers(label string) func(http.Handler) http.H
90100
if sil.ID != "" {
91101
// This is an update for an existing silence.
92102
httperr.PrometheusAPIError(w, "updates to silence by ID not allowed", http.StatusUnprocessableEntity)
103+
return
93104
}
94105

95106
var falsy bool
@@ -126,3 +137,76 @@ func WithEnforceTenancyOnSilenceMatchers(label string) func(http.Handler) http.H
126137
})
127138
}
128139
}
140+
141+
// WithEnforceTenancyOnSilenceID ensures the silence in the path belongs to the tenant
142+
// before proxying GET or DELETE /api/v2/silence/{id} to Alertmanager.
143+
// Adapted from https://github.com/prometheus-community/prom-label-proxy/injectproxy/silences.go
144+
func WithEnforceTenancyOnSilenceID(label string, upstream *url.URL, transport http.RoundTripper) func(http.Handler) http.Handler {
145+
return func(next http.Handler) http.Handler {
146+
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
147+
silID := chi.URLParam(r, "silenceID")
148+
if silID == "" {
149+
httperr.PrometheusAPIError(w, "bad request", http.StatusBadRequest)
150+
return
151+
}
152+
153+
tenantID, ok := authentication.GetTenantID(r.Context())
154+
if !ok {
155+
httperr.PrometheusAPIError(w, "error finding tenant ID", http.StatusInternalServerError)
156+
return
157+
}
158+
159+
sil, err := getSilenceByID(r.Context(), upstream, transport, silID)
160+
if err != nil {
161+
var notFound *silence.GetSilenceNotFound
162+
if errors.As(err, &notFound) {
163+
w.WriteHeader(http.StatusNotFound)
164+
return
165+
}
166+
httperr.PrometheusAPIError(w, fmt.Sprintf("proxy error: %v", err), http.StatusBadGateway)
167+
return
168+
}
169+
170+
if !hasMatcherForLabel(sil.Matchers, label, tenantID) {
171+
httperr.PrometheusAPIError(w, "forbidden", http.StatusForbidden)
172+
return
173+
}
174+
175+
r.URL.RawQuery = ""
176+
next.ServeHTTP(w, r)
177+
})
178+
}
179+
}
180+
181+
func getSilenceByID(ctx context.Context, upstream *url.URL, transport http.RoundTripper, id string) (*models.GettableSilence, error) {
182+
if transport == nil {
183+
transport = http.DefaultTransport
184+
}
185+
186+
rt := runtimeclient.NewWithClient(
187+
upstream.Host,
188+
path.Join(upstream.Path, "/api/v2"),
189+
[]string{upstream.Scheme},
190+
&http.Client{Transport: transport},
191+
)
192+
amc := client.New(rt, strfmt.Default)
193+
194+
params := silence.NewGetSilenceParams().WithContext(ctx)
195+
params.SetSilenceID(strfmt.UUID(id))
196+
197+
res, err := amc.Silence.GetSilence(params)
198+
if err != nil {
199+
return nil, err
200+
}
201+
202+
return res.Payload, nil
203+
}
204+
205+
func hasMatcherForLabel(matchers models.Matchers, name, value string) bool {
206+
for _, m := range matchers {
207+
if *m.Name == name && !*m.IsRegex && *m.Value == value {
208+
return true
209+
}
210+
}
211+
return false
212+
}
Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
1+
package v1
2+
3+
import (
4+
"context"
5+
"net/http"
6+
"net/http/httptest"
7+
"net/url"
8+
"testing"
9+
10+
"github.com/go-chi/chi/v5"
11+
"github.com/prometheus/alertmanager/api/v2/models"
12+
13+
"github.com/observatorium/api/authentication"
14+
)
15+
16+
func TestHasMatcherForLabel(t *testing.T) {
17+
t.Parallel()
18+
19+
label := "tenant_id"
20+
tenantA := "1610b0c3-c509-4592-a256-a1871353dbfa"
21+
falsy := false
22+
truthy := true
23+
24+
matchersFor := func(tenantID string) models.Matchers {
25+
return models.Matchers{
26+
{
27+
Name: strPtr(label),
28+
Value: strPtr(tenantID),
29+
IsRegex: &falsy,
30+
},
31+
{
32+
Name: strPtr("severity"),
33+
Value: strPtr("critical"),
34+
IsRegex: &falsy,
35+
},
36+
}
37+
}
38+
39+
tests := []struct {
40+
name string
41+
matchers models.Matchers
42+
want bool
43+
}{
44+
{
45+
name: "tenant matcher present",
46+
matchers: matchersFor(tenantA),
47+
want: true,
48+
},
49+
{
50+
name: "different tenant",
51+
matchers: matchersFor("tenant-b"),
52+
want: false,
53+
},
54+
{
55+
name: "regex tenant matcher",
56+
matchers: models.Matchers{
57+
{
58+
Name: strPtr(label),
59+
Value: strPtr(tenantA),
60+
IsRegex: &truthy,
61+
},
62+
},
63+
want: false,
64+
},
65+
{
66+
name: "no tenant matcher",
67+
matchers: matchersFor(tenantA)[1:],
68+
want: false,
69+
},
70+
}
71+
72+
for _, tc := range tests {
73+
t.Run(tc.name, func(t *testing.T) {
74+
t.Parallel()
75+
if got := hasMatcherForLabel(tc.matchers, label, tenantA); got != tc.want {
76+
t.Fatalf("hasMatcherForLabel() = %v, want %v", got, tc.want)
77+
}
78+
})
79+
}
80+
}
81+
82+
func TestWithEnforceTenancyOnSilenceID(t *testing.T) {
83+
t.Parallel()
84+
85+
const (
86+
label = "tenant_id"
87+
tenantName = "test-oidc"
88+
tenantID = "1610b0c3-c509-4592-a256-a1871353dbfa"
89+
silID = "802146e0-1f7a-42a6-ab0e-1e631479970b"
90+
)
91+
92+
silenceJSON := func(tenant string) string {
93+
t.Helper()
94+
return `{
95+
"id": "` + silID + `",
96+
"status": {
97+
"state": "active"
98+
},
99+
"updatedAt": "2020-01-15T09:06:23.419Z",
100+
"comment": "comment",
101+
"createdBy": "author",
102+
"endsAt": "2020-02-13T13:00:02.084Z",
103+
"matchers": [
104+
{
105+
"isRegex": false,
106+
"name": "` + label + `",
107+
"value": "` + tenant + `"
108+
}
109+
],
110+
"startsAt": "2020-02-13T12:02:01.000Z"
111+
}`
112+
}
113+
114+
newRouter := func(t *testing.T, upstream http.Handler, next http.Handler) *chi.Mux {
115+
t.Helper()
116+
117+
srv := httptest.NewServer(upstream)
118+
t.Cleanup(srv.Close)
119+
120+
upstreamURL, err := url.Parse(srv.URL)
121+
if err != nil {
122+
t.Fatal(err)
123+
}
124+
125+
r := chi.NewRouter()
126+
r.Use(authentication.WithTenant)
127+
r.Use(authentication.WithTenantID(map[string]string{tenantName: tenantID}))
128+
r.With(WithEnforceTenancyOnSilenceID(label, upstreamURL, srv.Client().Transport)).Method(
129+
http.MethodGet,
130+
"/{tenant}/am/api/v2/silence/{silenceID}",
131+
next,
132+
)
133+
r.With(WithEnforceTenancyOnSilenceID(label, upstreamURL, srv.Client().Transport)).Method(
134+
http.MethodDelete,
135+
"/{tenant}/am/api/v2/silence/{silenceID}",
136+
next,
137+
)
138+
139+
return r
140+
}
141+
142+
newRequest := func(method string) *http.Request {
143+
req := httptest.NewRequest(method, "/"+tenantName+"/am/api/v2/silence/"+silID, nil)
144+
rctx := chi.NewRouteContext()
145+
rctx.URLParams.Add("tenant", tenantName)
146+
rctx.URLParams.Add("silenceID", silID)
147+
return req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
148+
}
149+
150+
t.Run("proxies get when silence belongs to tenant", func(t *testing.T) {
151+
t.Parallel()
152+
153+
var proxyCalled bool
154+
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
155+
if r.Method == http.MethodGet && r.URL.Path == "/api/v2/silence/"+silID {
156+
w.Header().Set("Content-Type", "application/json")
157+
w.WriteHeader(http.StatusOK)
158+
_, _ = w.Write([]byte(silenceJSON(tenantID)))
159+
return
160+
}
161+
http.NotFound(w, r)
162+
})
163+
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
164+
proxyCalled = true
165+
w.WriteHeader(http.StatusOK)
166+
})
167+
168+
rec := httptest.NewRecorder()
169+
newRouter(t, upstream, next).ServeHTTP(rec, newRequest(http.MethodGet))
170+
171+
if rec.Code != http.StatusOK {
172+
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
173+
}
174+
if !proxyCalled {
175+
t.Fatal("expected request to be proxied")
176+
}
177+
})
178+
179+
t.Run("proxies delete when silence belongs to tenant", func(t *testing.T) {
180+
t.Parallel()
181+
182+
var deleteCalled bool
183+
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
184+
switch {
185+
case r.Method == http.MethodGet && r.URL.Path == "/api/v2/silence/"+silID:
186+
w.Header().Set("Content-Type", "application/json")
187+
w.WriteHeader(http.StatusOK)
188+
_, _ = w.Write([]byte(silenceJSON(tenantID)))
189+
case r.Method == http.MethodDelete && r.URL.Path == "/api/v2/silence/"+silID:
190+
deleteCalled = true
191+
w.WriteHeader(http.StatusOK)
192+
default:
193+
http.NotFound(w, r)
194+
}
195+
})
196+
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
197+
deleteCalled = true
198+
w.WriteHeader(http.StatusOK)
199+
})
200+
201+
rec := httptest.NewRecorder()
202+
newRouter(t, upstream, next).ServeHTTP(rec, newRequest(http.MethodDelete))
203+
204+
if rec.Code != http.StatusOK {
205+
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
206+
}
207+
if !deleteCalled {
208+
t.Fatal("expected delete to be proxied")
209+
}
210+
})
211+
212+
t.Run("forbidden when silence belongs to another tenant", func(t *testing.T) {
213+
t.Parallel()
214+
215+
upstream := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
216+
if r.Method == http.MethodGet && r.URL.Path == "/api/v2/silence/"+silID {
217+
w.Header().Set("Content-Type", "application/json")
218+
w.WriteHeader(http.StatusOK)
219+
_, _ = w.Write([]byte(silenceJSON("other-tenant")))
220+
return
221+
}
222+
http.NotFound(w, r)
223+
})
224+
225+
rec := httptest.NewRecorder()
226+
newRouter(t, upstream, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
227+
t.Fatal("request should not be proxied")
228+
})).ServeHTTP(rec, newRequest(http.MethodGet))
229+
230+
if rec.Code != http.StatusForbidden {
231+
t.Fatalf("expected status %d, got %d", http.StatusForbidden, rec.Code)
232+
}
233+
})
234+
235+
t.Run("not found when silence is missing", func(t *testing.T) {
236+
t.Parallel()
237+
238+
upstream := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
239+
w.WriteHeader(http.StatusNotFound)
240+
})
241+
242+
rec := httptest.NewRecorder()
243+
newRouter(t, upstream, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
244+
t.Fatal("request should not be proxied")
245+
})).ServeHTTP(rec, newRequest(http.MethodDelete))
246+
247+
if rec.Code != http.StatusNotFound {
248+
t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
249+
}
250+
})
251+
}
252+
253+
func strPtr(s string) *string {
254+
return &s
255+
}

0 commit comments

Comments
 (0)