Alerting: Use new image TokenProvider and send image url in annotation (#99989)

* Send new annotation containing image url

* Use new image TokenProvider with TokenStore

New abstraction GetImage no longer needs to support parsing both token and
url from annotations, as remote AM will use the new URLProvider. Instead, we
use the new generic TokenProvider and give it a TokenStore backed by the
grafana database.

That means we revert back to always using token simplifying code and security
considerations.

* Upgrade grafana/alerting to merged commit SHA
This commit is contained in:
Matthew Jacobson
2025-02-20 12:47:40 -05:00
committed by GitHub
parent b1b5b4766c
commit b78a63b0ad
12 changed files with 148 additions and 277 deletions
+1 -1
View File
@@ -71,7 +71,7 @@ require (
github.com/googleapis/gax-go/v2 v2.14.1 // @grafana/grafana-backend-group
github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group
github.com/gorilla/websocket v1.5.3 // @grafana/grafana-app-platform-squad
github.com/grafana/alerting v0.0.0-20250219142948-d43046431703 // @grafana/alerting-backend
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 // @grafana/alerting-backend
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 // @grafana/identity-access-team
github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 // @grafana/identity-access-team
github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics
+2 -2
View File
@@ -1511,8 +1511,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/alerting v0.0.0-20250219142948-d43046431703 h1:Z53rmFhUeif1/cFjOQqxHyv+YfXac4vzyl9yzdjxOoY=
github.com/grafana/alerting v0.0.0-20250219142948-d43046431703/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 h1:LGH+tVzHCDrR9hsltmkP4jmNRg5IreQw5CNFbJKlnts=
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 h1:NTMmow+74I3Jb033xhbRgWQS7A//5TDhiM4tl7bsVP4=
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7/go.mod h1:T3X4z0ejGfJOiOmZLFeKCRT/yxWJq/RtclAc/PHj/w4=
github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 h1:EokLC5grHwLPs4tXW8T6E8187H1e5G9AP0QQ5B60HbA=
+4 -1
View File
@@ -6,8 +6,11 @@ import (
)
var (
// ErrImageNotFound is returned when the image does not exist.
// ErrImageNotFound is returned when the image does not exist or is expired.
ErrImageNotFound = errors.New("image not found")
// ErrImageDataUnavailable is returned when image data is unavailable. Usually because the image is missing a path.
ErrImageDataUnavailable = errors.New("image data is unavailable")
)
type Image struct {
+31 -109
View File
@@ -6,145 +6,61 @@ import (
"io"
"os"
"path/filepath"
"strings"
alertingImages "github.com/grafana/alerting/images"
alertingModels "github.com/grafana/alerting/models"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/store"
)
type imageProvider struct {
type tokenStore struct {
store store.ImageStore
logger log.Logger
}
var _ alertingImages.TokenStore = (*tokenStore)(nil)
func newImageProvider(store store.ImageStore, logger log.Logger) alertingImages.Provider {
return &imageProvider{
return alertingImages.NewTokenProvider(&tokenStore{
store: store,
logger: logger,
}
}, newLogWrapper(logger))
}
func (i imageProvider) GetImage(ctx context.Context, uri string) (*alertingImages.Image, error) {
image, err := i.getImageFromURI(ctx, uri)
func (t tokenStore) GetImage(ctx context.Context, token string) (*alertingImages.Image, error) {
image, err := t.store.GetImage(ctx, token)
if err != nil {
if errors.Is(err, models.ErrImageNotFound) {
i.logger.Info("Image not found in database", "uri", uri)
return nil, alertingImages.ErrImageNotFound
}
return nil, err
}
return &alertingImages.Image{
Token: image.Token,
Path: image.Path,
URL: image.URL,
CreatedAt: image.CreatedAt,
URL: image.URL,
RawData: func(_ context.Context) (alertingImages.ImageContent, error) {
if image.Path == "" {
return alertingImages.ImageContent{}, models.ErrImageDataUnavailable
}
b, err := readImage(image.Path, t.logger)
if err != nil {
return alertingImages.ImageContent{}, err
}
return alertingImages.ImageContent{
Name: filepath.Base(image.Path),
Content: b,
}, nil
},
}, nil
}
func (i imageProvider) GetImageURL(ctx context.Context, alert *alertingNotify.Alert) (string, error) {
uri, err := getImageURI(alert)
if err != nil {
return "", err
}
// If the identifier is a URL, validate that it corresponds to a stored, non-expired image.
if strings.HasPrefix(uri, "http") {
i.logger.Debug("Received an image URL in annotations", "alert", alert)
exists, err := i.store.URLExists(ctx, uri)
if err != nil {
return "", err
}
if !exists {
i.logger.Info("Image URL not found in database", "alert", alert)
return "", alertingImages.ErrImageNotFound
}
return uri, nil
}
// If the identifier is a token, remove the prefix, get the image and return the URL.
token := strings.TrimPrefix(uri, "token://")
i.logger.Debug("Received an image token in annotations", "alert", alert, "token", token)
return i.getImageURLFromToken(ctx, token)
}
// getImageURLFromToken takes a token and returns the URL of the image that token belongs to.
func (i imageProvider) getImageURLFromToken(ctx context.Context, token string) (string, error) {
image, err := i.store.GetImage(ctx, token)
if err != nil {
if errors.Is(err, models.ErrImageNotFound) {
i.logger.Info("Image not found in database", "token", token)
return "", alertingImages.ErrImageNotFound
}
return "", err
}
if !image.HasURL() {
return "", alertingImages.ErrImagesNoURL
}
return image.URL, nil
}
func (i imageProvider) GetRawImage(ctx context.Context, alert *alertingNotify.Alert) (io.ReadCloser, string, error) {
uri, err := getImageURI(alert)
if err != nil {
return nil, "", err
}
image, err := i.getImageFromURI(ctx, uri)
if err != nil {
if errors.Is(err, models.ErrImageNotFound) {
i.logger.Info("Image not found in database", "alert", alert)
return nil, "", alertingImages.ErrImageNotFound
}
return nil, "", err
}
if !image.HasPath() {
return nil, "", alertingImages.ErrImagesNoPath
}
// Return image bytes and filename.
readCloser, err := openImage(image.Path)
if err != nil {
i.logger.Error("Error looking for image on disk", "alert", alert, "path", image.Path, "error", err)
return nil, "", err
}
filename := filepath.Base(image.Path)
return readCloser, filename, nil
}
func (i imageProvider) getImageFromURI(ctx context.Context, uri string) (*models.Image, error) {
// Check whether the uri is a URL or a token to know how to query the DB.
if strings.HasPrefix(uri, "http") {
i.logger.Debug("Received an image URL in annotations")
return i.store.GetImageByURL(ctx, uri)
}
token := strings.TrimPrefix(uri, "token://")
i.logger.Debug("Received an image token in annotations", "token", token)
return i.store.GetImage(ctx, token)
}
// getImageURI is a helper function to retrieve the image URI from the alert annotations as a string.
func getImageURI(alert *alertingNotify.Alert) (string, error) {
uri, ok := alert.Annotations[alertingModels.ImageTokenAnnotation]
if !ok {
return "", alertingImages.ErrNoImageForAlert
}
return string(uri), nil
}
// openImage returns an the io representation of an image from the given path.
func openImage(path string) (io.ReadCloser, error) {
// readImage returns an image from the given path.
func readImage(path string, logger log.Logger) ([]byte, error) {
fp := filepath.Clean(path)
_, err := os.Stat(fp)
if os.IsNotExist(err) || os.IsPermission(err) {
return nil, alertingImages.ErrImageNotFound
return nil, models.ErrImageNotFound
}
f, err := os.Open(fp)
@@ -152,5 +68,11 @@ func openImage(path string) (io.ReadCloser, error) {
return nil, err
}
return f, nil
defer func() {
if err := f.Close(); err != nil {
logger.Error("Failed to close image file", "error", err)
}
}()
return io.ReadAll(f)
}
+78 -152
View File
@@ -2,7 +2,6 @@ package notifier
import (
"context"
"io"
"os"
"path/filepath"
"testing"
@@ -11,177 +10,98 @@ import (
alertingImages "github.com/grafana/alerting/images"
alertingModels "github.com/grafana/alerting/models"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/store"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
)
func TestGetImage(t *testing.T) {
fakeImageStore := store.NewFakeImageStore(t)
store := newImageProvider(fakeImageStore, log.NewNopLogger())
testBytes := []byte("some test bytes")
testPath := generateTestFile(t, testBytes)
t.Run("queries by token when it gets a token", func(tt *testing.T) {
img := models.Image{
Token: "test",
URL: "http://localhost:1234",
Path: "test.png",
}
err := fakeImageStore.SaveImage(context.Background(), &img)
require.NoError(tt, err)
// nolint:staticcheck
savedImg, err := store.GetImage(context.Background(), "token://"+img.Token)
require.NoError(tt, err)
require.Equal(tt, savedImg.Token, img.Token)
require.Equal(tt, savedImg.URL, img.URL)
require.Equal(tt, savedImg.Path, img.Path)
})
t.Run("queries by URL when it gets a URL", func(tt *testing.T) {
img := models.Image{
Token: "test",
Path: "test.png",
URL: "https://test.com/test.png",
}
err := fakeImageStore.SaveImage(context.Background(), &img)
require.NoError(tt, err)
// nolint:staticcheck
savedImg, err := store.GetImage(context.Background(), img.URL)
require.NoError(tt, err)
require.Equal(tt, savedImg.Token, img.Token)
require.Equal(tt, savedImg.URL, img.URL)
require.Equal(tt, savedImg.Path, img.Path)
})
}
func TestGetImageURL(t *testing.T) {
var (
imageWithoutURL = models.Image{
Token: "test-no-url",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
}
testImage = models.Image{
Token: "test",
imageWithoutPath = models.Image{
Token: "test-token-no-path",
URL: "https://test.com",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
}
)
fakeImageStore := store.NewFakeImageStore(t, &imageWithoutURL, &testImage)
store := newImageProvider(fakeImageStore, log.NewNopLogger())
tests := []struct {
name string
uri string
expURL string
expErr error
}{
{
"URL does not exist",
"https://invalid.com/test",
"",
alertingImages.ErrImageNotFound,
}, {
"existing URL",
testImage.URL,
testImage.URL,
nil,
}, {
"token does not exist",
"token://invalid",
"",
alertingImages.ErrImageNotFound,
}, {
"existing token",
"token://" + testImage.Token,
testImage.URL,
nil,
}, {
"image has no URL",
"token://" + imageWithoutURL.Token,
"",
alertingImages.ErrImagesNoURL,
},
}
for _, test := range tests {
t.Run(test.name, func(tt *testing.T) {
alert := alertingNotify.Alert{
Alert: model.Alert{
Annotations: model.LabelSet{alertingModels.ImageTokenAnnotation: model.LabelValue(test.uri)},
},
}
url, err := store.GetImageURL(context.Background(), &alert)
require.ErrorIs(tt, err, test.expErr)
require.Equal(tt, test.expURL, url)
})
}
}
func TestGetRawImage(t *testing.T) {
var (
testBytes = []byte("some test bytes")
testPath = generateTestFile(t, testBytes)
imageWithoutPath = models.Image{
Token: "test-no-path",
URL: "https://test-no-path.com",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
}
testImage = models.Image{
Token: "test",
Token: "test-token",
URL: "https://test.com",
Path: testPath,
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
}
testImageMissingFile = models.Image{
Token: "test-token-missing-file",
URL: "https://test.com",
Path: "/tmp/missing/1234asdf.png",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
}
)
fakeImageStore := store.NewFakeImageStore(t, &imageWithoutPath, &testImage)
fakeImageStore := store.NewFakeImageStore(t, &imageWithoutPath, &testImage, &testImageMissingFile)
store := newImageProvider(fakeImageStore, log.NewNopLogger())
tests := []struct {
name string
uri string
expFilename string
expBytes []byte
expErr error
name string
token string
url string
expImage *alertingImages.Image
expImageContent *alertingImages.ImageContent
expRawDataErr error
}{
{
"URL does not exist",
"https://invalid.com/test",
"",
nil,
alertingImages.ErrImageNotFound,
name: "Given existing raw token, expect image",
token: testImage.Token,
expImage: &alertingImages.Image{
URL: testImage.URL,
},
}, {
"existing URL",
testImage.URL,
filepath.Base(testPath),
testBytes,
nil,
name: "Given existing token and url, expect image",
token: testImage.Token,
url: testImage.URL,
expImage: &alertingImages.Image{
URL: testImage.URL,
},
}, {
"token does not exist",
"token://invalid",
"",
nil,
alertingImages.ErrImageNotFound,
name: "Given existing with just url, expect nil",
token: "",
url: testImage.URL,
expImage: nil,
}, {
"existing token",
"token://" + testImage.Token,
filepath.Base(testPath),
testBytes,
nil,
name: "Given missing raw token, expect nil",
token: "invalid",
expImage: nil,
}, {
"image has no path",
"token://" + imageWithoutPath.Token,
"",
nil,
alertingImages.ErrImagesNoPath,
name: "Given image with Path, expect RawData",
token: testImage.Token,
expImage: &alertingImages.Image{
URL: testImage.URL,
},
expImageContent: &alertingImages.ImageContent{
Name: filepath.Base(testImage.Path),
Content: testBytes,
},
}, {
name: "Given image with Path but file doesn't exist, expect RawData error",
token: testImageMissingFile.Token,
expImage: &alertingImages.Image{
URL: testImageMissingFile.URL,
},
expRawDataErr: models.ErrImageNotFound,
}, {
name: "Given image without Path, expect RawData error",
token: imageWithoutPath.Token,
expImage: &alertingImages.Image{
URL: imageWithoutPath.URL,
},
expRawDataErr: models.ErrImageDataUnavailable,
},
}
@@ -189,18 +109,24 @@ func TestGetRawImage(t *testing.T) {
t.Run(test.name, func(tt *testing.T) {
alert := alertingNotify.Alert{
Alert: model.Alert{
Annotations: model.LabelSet{alertingModels.ImageTokenAnnotation: model.LabelValue(test.uri)},
Annotations: model.LabelSet{alertingModels.ImageTokenAnnotation: model.LabelValue(test.token)},
},
}
readCloser, filename, err := store.GetRawImage(context.Background(), &alert)
require.ErrorIs(tt, err, test.expErr)
require.Equal(tt, test.expFilename, filename)
if test.expBytes != nil {
b, err := io.ReadAll(readCloser)
image, err := store.GetImage(context.Background(), alert)
require.NoError(tt, err)
if test.expImage == nil {
require.Nil(tt, image)
return
}
require.Equal(tt, test.expImage.URL, image.URL)
if test.expImageContent != nil {
ic, err := image.RawData(context.Background())
require.NoError(tt, err)
require.Equal(tt, test.expBytes, b)
require.NoError(t, readCloser.Close())
require.Equal(tt, *test.expImageContent, ic)
}
if test.expRawDataErr != nil {
_, err := image.RawData(context.Background())
require.ErrorIs(tt, err, test.expRawDataErr)
}
})
}
+4
View File
@@ -10,6 +10,10 @@ var LoggerFactory alertingLogging.LoggerFactory = func(logger string, ctx ...any
return &logWrapper{log.New(append([]any{logger}, ctx...)...)}
}
func newLogWrapper(logger log.Logger, ctx ...any) alertingLogging.Logger {
return &logWrapper{logger.New(ctx...)}
}
type logWrapper struct {
*log.ConcreteLogger
}
+13 -2
View File
@@ -17,6 +17,7 @@ import (
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/eval"
ngModels "github.com/grafana/grafana/pkg/services/ngalert/models"
)
const (
@@ -48,8 +49,8 @@ func StateToPostableAlert(transition StateTransition, appURL *url.URL) *models.P
nA[alertingModels.ValueStringAnnotation] = alertState.LastEvaluationString
}
if alertState.Image != nil && alertState.Image.Token != "" {
nA[alertingModels.ImageTokenAnnotation] = alertState.Image.Token
if alertState.Image != nil {
attachImageAnnotations(alertState.Image, nA)
}
if alertState.StateReason != "" {
@@ -153,3 +154,13 @@ func FromAlertsStateToStoppedAlert(firingStates []StateTransition, appURL *url.U
}
return alerts
}
// attachImageAnnotations attaches image annotations to the alert.
func attachImageAnnotations(image *ngModels.Image, a data.Labels) {
if image.Token != "" {
a[alertingModels.ImageTokenAnnotation] = image.Token
}
if image.URL != "" {
a[alertingModels.ImageURLAnnotation] = image.URL
}
}
+9 -4
View File
@@ -120,10 +120,10 @@ func Test_StateToPostableAlert(t *testing.T) {
require.Equal(t, expected, result.Annotations)
})
t.Run("add __alertImageToken__ if there is an image token", func(t *testing.T) {
t.Run("add both annotations if there is an image token and url", func(t *testing.T) {
alertState := randomTransition(eval.Normal, tc.state)
alertState.Annotations = randomMapOfStrings()
alertState.Image = &ngModels.Image{Token: "test_token"}
alertState.Image = &ngModels.Image{Token: "test_token", URL: "test_url"}
result := StateToPostableAlert(alertState, appURL)
@@ -131,12 +131,17 @@ func Test_StateToPostableAlert(t *testing.T) {
for k, v := range alertState.Annotations {
expected[k] = v
}
expected["__alertImageToken__"] = alertState.Image.Token
expected[alertingModels.ImageTokenAnnotation] = alertState.Image.Token
expected[alertingModels.ImageURLAnnotation] = alertState.Image.URL
// Sanity check that the annotation is correct.
require.Contains(t, result.Annotations[alertingModels.ImageTokenAnnotation], alertState.Image.Token)
require.Contains(t, result.Annotations[alertingModels.ImageURLAnnotation], alertState.Image.URL)
require.Equal(t, expected, result.Annotations)
})
t.Run("don't add __alertImageToken__ if there's no image token", func(t *testing.T) {
t.Run("don't add annotations if there's no image token or url", func(t *testing.T) {
alertState := randomTransition(eval.Normal, tc.state)
alertState.Annotations = randomMapOfStrings()
alertState.Image = &ngModels.Image{}
+1 -1
View File
@@ -192,7 +192,7 @@ require (
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/grafana/alerting v0.0.0-20250219142948-d43046431703 // indirect
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 // indirect
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect
github.com/grafana/dskit v0.0.0-20241105154643-a6b453a88040 // indirect
+2 -2
View File
@@ -566,8 +566,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grafana/alerting v0.0.0-20250219142948-d43046431703 h1:Z53rmFhUeif1/cFjOQqxHyv+YfXac4vzyl9yzdjxOoY=
github.com/grafana/alerting v0.0.0-20250219142948-d43046431703/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 h1:LGH+tVzHCDrR9hsltmkP4jmNRg5IreQw5CNFbJKlnts=
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 h1:NTMmow+74I3Jb033xhbRgWQS7A//5TDhiM4tl7bsVP4=
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7/go.mod h1:T3X4z0ejGfJOiOmZLFeKCRT/yxWJq/RtclAc/PHj/w4=
github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 h1:EokLC5grHwLPs4tXW8T6E8187H1e5G9AP0QQ5B60HbA=
+1 -1
View File
@@ -117,7 +117,7 @@ require (
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/googleapis/gax-go/v2 v2.14.1 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/grafana/alerting v0.0.0-20250219142948-d43046431703 // indirect
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 // indirect
github.com/grafana/dataplane/sdata v0.0.9 // indirect
github.com/grafana/grafana-app-sdk/logging v0.30.0 // indirect
github.com/grafana/grafana-aws-sdk v0.31.5 // indirect
+2 -2
View File
@@ -397,8 +397,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/grafana/alerting v0.0.0-20250219142948-d43046431703 h1:Z53rmFhUeif1/cFjOQqxHyv+YfXac4vzyl9yzdjxOoY=
github.com/grafana/alerting v0.0.0-20250219142948-d43046431703/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0 h1:LGH+tVzHCDrR9hsltmkP4jmNRg5IreQw5CNFbJKlnts=
github.com/grafana/alerting v0.0.0-20250219153626-c475b1a572b0/go.mod h1:hdGB3dSl8Ma9Rjo2YiAEAjMkZ5HiNJbNDqRKDefRZrM=
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7 h1:NTMmow+74I3Jb033xhbRgWQS7A//5TDhiM4tl7bsVP4=
github.com/grafana/authlib v0.0.0-20250219100139-6a3b1bbb50e7/go.mod h1:T3X4z0ejGfJOiOmZLFeKCRT/yxWJq/RtclAc/PHj/w4=
github.com/grafana/authlib/types v0.0.0-20250219092154-21ce22b49f31 h1:EokLC5grHwLPs4tXW8T6E8187H1e5G9AP0QQ5B60HbA=