Merge branch 'main' of https://github.com/grafana/grafana into kristina/rtk-corr

# Conflicts:
#	packages/grafana-api-clients/package.json
This commit is contained in:
Kristina Durivage
2025-11-12 09:43:13 -06:00
1912 changed files with 155924 additions and 24824 deletions
+7 -7
View File
@@ -118,18 +118,18 @@ require (
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053 // indirect
golang.org/x/term v0.35.0 // indirect
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.13.0 // indirect
golang.org/x/tools v0.37.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.38.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect
+14 -14
View File
@@ -314,15 +314,15 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -330,8 +330,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
@@ -355,23 +355,23 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053 h1:dHQOQddU4YHS5gY33/6klKjq7Gp3WwMyOXGNp5nzRj8=
golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU=
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+1 -1
View File
@@ -273,7 +273,7 @@ func setupSimpleHTTPServer(features featuremgmt.FeatureToggles) *HTTPServer {
AccessControl: acimpl.ProvideAccessControl(featuremgmt.WithFeatures()),
annotationsRepo: annotationstest.NewFakeAnnotationsRepo(),
authInfoService: &authinfotest.FakeService{
ExpectedLabels: map[int64]string{int64(1): login.GetAuthProviderLabel(login.LDAPAuthModule)},
ExpectedRecentlyUsedLabel: map[int64]string{int64(1): login.GetAuthProviderLabel(login.LDAPAuthModule)},
},
tracer: tracing.InitializeTracerForTest(),
}
-7
View File
@@ -438,7 +438,6 @@ func (hs *HTTPServer) postDashboard(c *contextmodel.ReqContext, cmd dashboards.S
}
ctx = c.Req.Context()
var err error
var userID int64
if id, err := identity.UserIdentifier(c.GetID()); err == nil {
@@ -518,12 +517,6 @@ func (hs *HTTPServer) postDashboard(c *contextmodel.ReqContext, cmd dashboards.S
return apierrors.ToDashboardErrorResponse(ctx, hs.pluginStore, saveErr)
}
// connect library panels for this dashboard after the dashboard is stored and has an ID
err = hs.LibraryPanelService.ConnectLibraryPanelsForDashboard(ctx, c.SignedInUser, dashboard)
if err != nil {
return response.Error(http.StatusInternalServerError, "Error while connecting library panels", err)
}
c.TimeRequest(metrics.MApiDashboardSave)
return response.JSON(http.StatusOK, util.DynMap{
"status": "success",
-21
View File
@@ -20,7 +20,6 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/db/dbtest"
@@ -39,7 +38,6 @@ import (
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/folder/foldertest"
libraryelementsfake "github.com/grafana/grafana/pkg/services/libraryelements/fake"
"github.com/grafana/grafana/pkg/services/librarypanels"
"github.com/grafana/grafana/pkg/services/licensing/licensingtest"
"github.com/grafana/grafana/pkg/services/live"
"github.com/grafana/grafana/pkg/services/org"
@@ -265,7 +263,6 @@ func TestHTTPServer_DeleteDashboardByUID_AccessControl(t *testing.T) {
hs.AccessControl = acimpl.ProvideAccessControl(featuremgmt.WithFeatures())
hs.starService = startest.NewStarServiceFake()
hs.LibraryPanelService = &mockLibraryPanelService{}
hs.LibraryElementService = &libraryelementsfake.LibraryElementService{}
middleware := publicdashboards.NewFakePublicDashboardMiddleware(t)
@@ -791,7 +788,6 @@ func TestIntegrationDashboardAPIEndpoint(t *testing.T) {
ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()),
Live: newTestLive(t, db.InitTestDB(t)),
QuotaService: quotatest.New(false, nil),
LibraryPanelService: &mockLibraryPanelService{},
LibraryElementService: &libraryelementsfake.LibraryElementService{},
DashboardService: dashboardService,
SQLStore: dbtest.NewFakeDB(),
@@ -853,7 +849,6 @@ func TestIntegrationDashboardAPIEndpoint(t *testing.T) {
hs := &HTTPServer{
Cfg: setting.NewCfg(),
ProvisioningService: fakeProvisioningService,
LibraryPanelService: &mockLibraryPanelService{},
LibraryElementService: &libraryelementsfake.LibraryElementService{},
dashboardProvisioningService: dashboardProvisioningService,
SQLStore: mockSQLStore,
@@ -887,7 +882,6 @@ func TestIntegrationDashboardAPIEndpoint(t *testing.T) {
hs := &HTTPServer{
Cfg: setting.NewCfg(),
ProvisioningService: fakeProvisioningService,
LibraryPanelService: &mockLibraryPanelService{},
LibraryElementService: &libraryelementsfake.LibraryElementService{},
dashboardProvisioningService: dashboardProvisioningService,
SQLStore: mockSQLStore,
@@ -928,7 +922,6 @@ func TestIntegrationDashboardAPIEndpoint(t *testing.T) {
loggedInUserScenarioWithRole(t, "When calling GET on", "GET", "/api/dashboards/uid/dash", "/api/dashboards/uid/:uid", org.RoleEditor, func(sc *scenarioContext) {
hs := &HTTPServer{
Cfg: setting.NewCfg(),
LibraryPanelService: &mockLibraryPanelService{},
LibraryElementService: &libraryelementsfake.LibraryElementService{},
SQLStore: mockSQLStore,
AccessControl: actest.FakeAccessControl{ExpectedEvaluate: true},
@@ -1087,7 +1080,6 @@ func postDashboardScenario(t *testing.T, desc string, url string, routePattern s
Live: newTestLive(t, db.InitTestDB(t)),
QuotaService: quotatest.New(false, nil),
pluginStore: &pluginstore.FakePluginStore{},
LibraryPanelService: &mockLibraryPanelService{},
LibraryElementService: &libraryelementsfake.LibraryElementService{},
DashboardService: dashboardService,
folderService: folderService,
@@ -1127,7 +1119,6 @@ func restoreDashboardVersionScenario(t *testing.T, desc string, url string, rout
ProvisioningService: provisioning.NewProvisioningServiceMock(context.Background()),
Live: newTestLive(t, db.InitTestDB(t)),
QuotaService: quotatest.New(false, nil),
LibraryPanelService: &mockLibraryPanelService{},
LibraryElementService: &libraryelementsfake.LibraryElementService{},
DashboardService: mock,
SQLStore: sqlStore,
@@ -1177,15 +1168,3 @@ func (s mockDashboardProvisioningService) GetProvisionedDashboardDataByDashboard
) {
return nil, nil
}
type mockLibraryPanelService struct{}
var _ librarypanels.Service = (*mockLibraryPanelService)(nil)
func (m *mockLibraryPanelService) ConnectLibraryPanelsForDashboard(c context.Context, signedInUser identity.Requester, dash *dashboards.Dashboard) error {
return nil
}
func (m *mockLibraryPanelService) ImportLibraryPanelsForDashboard(c context.Context, signedInUser identity.Requester, libraryPanels *simplejson.Json, panels []any, folderID int64, folderUID string) error {
return nil
}
+2
View File
@@ -76,6 +76,8 @@ func (hs *HTTPServer) QueryMetricsV2(c *contextmodel.ReqContext) response.Respon
var resp *backend.QueryDataResponse
var err error
hs.log.Debug("QueryMetricsV2: request received", "time_in_query", handleTimeInQuery)
if handleTimeInQuery {
resp, err = hs.queryDataService.QueryDataNew(c.Req.Context(), c.SignedInUser, c.SkipDSCache, reqDTO)
} else {
+3
View File
@@ -16,6 +16,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/infra/db/dbtest"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/backendplugin"
pluginClient "github.com/grafana/grafana/pkg/plugins/manager/client"
@@ -86,6 +87,7 @@ func TestAPIEndpoint_Metrics_QueryMetricsV2(t *testing.T) {
server := SetupAPITestServer(t, func(hs *HTTPServer) {
hs.queryDataService = qds
hs.QuotaService = quotatest.New(false, nil)
hs.log = log.New("test-logger")
})
t.Run("Status code is 400 when data source response has an error", func(t *testing.T) {
@@ -252,6 +254,7 @@ func TestDataSourceQueryError(t *testing.T) {
err := r.Add(context.Background(), p)
require.NoError(t, err)
ds := &fakeDatasources.FakeDataSourceService{}
hs.log = log.New("test-logger")
hs.queryDataService = query.ProvideService(
cfg,
&fakeDatasources.FakeCacheService{},
+48 -45
View File
@@ -78,7 +78,8 @@ type FrontendSettingsAzureDTO struct {
}
type FrontendSettingsCachingDTO struct {
Enabled bool `json:"enabled"`
Enabled bool `json:"enabled"`
CleanCacheEnabled bool `json:"cleanCacheEnabled"`
}
type FrontendSettingsRecordedQueriesDTO struct {
@@ -207,27 +208,28 @@ type FrontendSettingsDTO struct {
DashboardPerformanceMetrics []string `json:"dashboardPerformanceMetrics"`
PanelSeriesLimit int `json:"panelSeriesLimit"`
FeedbackLinksEnabled bool `json:"feedbackLinksEnabled"`
ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString"`
ApplicationInsightsEndpointUrl string `json:"applicationInsightsEndpointUrl"`
DisableLoginForm bool `json:"disableLoginForm"`
DisableUserSignUp bool `json:"disableUserSignUp"`
LoginHint string `json:"loginHint"`
PasswordHint string `json:"passwordHint"`
ExternalUserMngInfo string `json:"externalUserMngInfo"`
ExternalUserMngLinkUrl string `json:"externalUserMngLinkUrl"`
ExternalUserMngLinkName string `json:"externalUserMngLinkName"`
ExternalUserMngAnalytics bool `json:"externalUserMngAnalytics"`
ExternalUserMngAnalyticsParams string `json:"externalUserMngAnalyticsParams"`
ViewersCanEdit bool `json:"viewersCanEdit"`
DisableSanitizeHtml bool `json:"disableSanitizeHtml"`
TrustedTypesDefaultPolicyEnabled bool `json:"trustedTypesDefaultPolicyEnabled"`
CSPReportOnlyEnabled bool `json:"cspReportOnlyEnabled"`
EnableFrontendSandboxForPlugins []string `json:"enableFrontendSandboxForPlugins"`
PluginRestrictedAPIsAllowList map[string][]string `json:"pluginRestrictedAPIsAllowList"`
PluginRestrictedAPIsBlockList map[string][]string `json:"pluginRestrictedAPIsBlockList"`
ExploreDefaultTimeOffset string `json:"exploreDefaultTimeOffset"`
ExploreHideLogsDownload bool `json:"exploreHideLogsDownload"`
FeedbackLinksEnabled bool `json:"feedbackLinksEnabled"`
ApplicationInsightsConnectionString string `json:"applicationInsightsConnectionString"`
ApplicationInsightsEndpointUrl string `json:"applicationInsightsEndpointUrl"`
ApplicationInsightsAutoRouteTracking bool `json:"applicationInsightsAutoRouteTracking"`
DisableLoginForm bool `json:"disableLoginForm"`
DisableUserSignUp bool `json:"disableUserSignUp"`
LoginHint string `json:"loginHint"`
PasswordHint string `json:"passwordHint"`
ExternalUserMngInfo string `json:"externalUserMngInfo"`
ExternalUserMngLinkUrl string `json:"externalUserMngLinkUrl"`
ExternalUserMngLinkName string `json:"externalUserMngLinkName"`
ExternalUserMngAnalytics bool `json:"externalUserMngAnalytics"`
ExternalUserMngAnalyticsParams string `json:"externalUserMngAnalyticsParams"`
ViewersCanEdit bool `json:"viewersCanEdit"`
DisableSanitizeHtml bool `json:"disableSanitizeHtml"`
TrustedTypesDefaultPolicyEnabled bool `json:"trustedTypesDefaultPolicyEnabled"`
CSPReportOnlyEnabled bool `json:"cspReportOnlyEnabled"`
EnableFrontendSandboxForPlugins []string `json:"enableFrontendSandboxForPlugins"`
PluginRestrictedAPIsAllowList map[string][]string `json:"pluginRestrictedAPIsAllowList"`
PluginRestrictedAPIsBlockList map[string][]string `json:"pluginRestrictedAPIsBlockList"`
ExploreDefaultTimeOffset string `json:"exploreDefaultTimeOffset"`
ExploreHideLogsDownload bool `json:"exploreHideLogsDownload"`
Auth FrontendSettingsAuthDTO `json:"auth"`
@@ -235,29 +237,30 @@ type FrontendSettingsDTO struct {
LicenseInfo FrontendSettingsLicenseInfoDTO `json:"licenseInfo"`
FeatureToggles map[string]bool `json:"featureToggles"`
AnonymousEnabled bool `json:"anonymousEnabled"`
AnonymousDeviceLimit int64 `json:"anonymousDeviceLimit"`
RendererAvailable bool `json:"rendererAvailable"`
RendererVersion string `json:"rendererVersion"`
RendererDefaultImageWidth int `json:"rendererDefaultImageWidth"`
RendererDefaultImageHeight int `json:"rendererDefaultImageHeight"`
RendererDefaultImageScale float64 `json:"rendererDefaultImageScale"`
Http2Enabled bool `json:"http2Enabled"`
GrafanaJavascriptAgent setting.GrafanaJavascriptAgent `json:"grafanaJavascriptAgent"`
PluginCatalogURL string `json:"pluginCatalogURL"`
PluginAdminEnabled bool `json:"pluginAdminEnabled"`
PluginAdminExternalManageEnabled bool `json:"pluginAdminExternalManageEnabled"`
PluginCatalogHiddenPlugins []string `json:"pluginCatalogHiddenPlugins"`
PluginCatalogManagedPlugins []string `json:"pluginCatalogManagedPlugins"`
PluginCatalogPreinstalledPlugins []setting.InstallPlugin `json:"pluginCatalogPreinstalledPlugins"`
ExpressionsEnabled bool `json:"expressionsEnabled"`
AwsAllowedAuthProviders []string `json:"awsAllowedAuthProviders"`
AwsAssumeRoleEnabled bool `json:"awsAssumeRoleEnabled"`
SupportBundlesEnabled bool `json:"supportBundlesEnabled"`
SnapshotEnabled bool `json:"snapshotEnabled"`
SecureSocksDSProxyEnabled bool `json:"secureSocksDSProxyEnabled"`
ReportingStaticContext map[string]string `json:"reportingStaticContext"`
FeatureToggles map[string]bool `json:"featureToggles"`
AnonymousEnabled bool `json:"anonymousEnabled"`
AnonymousDeviceLimit int64 `json:"anonymousDeviceLimit"`
RendererAvailable bool `json:"rendererAvailable"`
RendererVersion string `json:"rendererVersion"`
RendererDefaultImageWidth int `json:"rendererDefaultImageWidth"`
RendererDefaultImageHeight int `json:"rendererDefaultImageHeight"`
RendererDefaultImageScale float64 `json:"rendererDefaultImageScale"`
Http2Enabled bool `json:"http2Enabled"`
GrafanaJavascriptAgent setting.GrafanaJavascriptAgent `json:"grafanaJavascriptAgent"`
PluginCatalogURL string `json:"pluginCatalogURL"`
PluginAdminEnabled bool `json:"pluginAdminEnabled"`
PluginAdminExternalManageEnabled bool `json:"pluginAdminExternalManageEnabled"`
PluginCatalogHiddenPlugins []string `json:"pluginCatalogHiddenPlugins"`
PluginCatalogManagedPlugins []string `json:"pluginCatalogManagedPlugins"`
PluginCatalogPreinstalledPlugins []setting.InstallPlugin `json:"pluginCatalogPreinstalledPlugins"`
PluginCatalogPreinstalledAutoUpdate bool `json:"pluginCatalogPreinstalledAutoUpdate"`
ExpressionsEnabled bool `json:"expressionsEnabled"`
AwsAllowedAuthProviders []string `json:"awsAllowedAuthProviders"`
AwsAssumeRoleEnabled bool `json:"awsAssumeRoleEnabled"`
SupportBundlesEnabled bool `json:"supportBundlesEnabled"`
SnapshotEnabled bool `json:"snapshotEnabled"`
SecureSocksDSProxyEnabled bool `json:"secureSocksDSProxyEnabled"`
ReportingStaticContext map[string]string `json:"reportingStaticContext"`
Azure FrontendSettingsAzureDTO `json:"azure"`
+3 -14
View File
@@ -271,26 +271,15 @@ func (hs *HTTPServer) UpdateFolder(c *contextmodel.ReqContext) response.Response
// 403: forbiddenError
// 404: notFoundError
// 500: internalServerError
func (hs *HTTPServer) DeleteFolder(c *contextmodel.ReqContext) response.Response { // temporarily adding this function to HTTPServer, will be removed from HTTPServer when librarypanels featuretoggle is removed
err := hs.LibraryElementService.DeleteLibraryElementsInFolder(c.Req.Context(), c.SignedInUser, web.Params(c.Req)[":uid"])
func (hs *HTTPServer) DeleteFolder(c *contextmodel.ReqContext) response.Response {
uid := web.Params(c.Req)[":uid"]
err := hs.folderService.Delete(c.Req.Context(), &folder.DeleteFolderCommand{UID: uid, OrgID: c.GetOrgID(), ForceDeleteRules: c.QueryBool("forceDeleteRules"), SignedInUser: c.SignedInUser})
if err != nil {
if errors.Is(err, model.ErrFolderHasConnectedLibraryElements) {
return response.Error(http.StatusForbidden, "Folder could not be deleted because it contains library elements in use", err)
}
return apierrors.ToFolderErrorResponse(err)
}
/* TODO: after a decision regarding folder deletion permissions has been made
(https://github.com/grafana/grafana-enterprise/issues/5144),
remove the previous call to hs.LibraryElementService.DeleteLibraryElementsInFolder
and remove "user" from the signature of DeleteInFolder in the folder RegistryService.
Context: https://github.com/grafana/grafana/pull/69149#discussion_r1235057903
*/
uid := web.Params(c.Req)[":uid"]
err = hs.folderService.Delete(c.Req.Context(), &folder.DeleteFolderCommand{UID: uid, OrgID: c.GetOrgID(), ForceDeleteRules: c.QueryBool("forceDeleteRules"), SignedInUser: c.SignedInUser})
if err != nil {
return apierrors.ToFolderErrorResponse(err)
}
return response.JSON(http.StatusOK, util.DynMap{
"message": "Folder deleted",
+71 -68
View File
@@ -197,53 +197,54 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
featureToggles["topnav"] = true
frontendSettings := &dtos.FrontendSettingsDTO{
DefaultDatasource: defaultDS,
Datasources: dataSources,
MinRefreshInterval: hs.Cfg.MinRefreshInterval,
Panels: panels,
Apps: apps,
AppUrl: hs.Cfg.AppURL,
AppSubUrl: hs.Cfg.AppSubURL,
AllowOrgCreate: (hs.Cfg.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin,
AuthProxyEnabled: hs.Cfg.AuthProxy.Enabled,
LdapEnabled: hs.Cfg.LDAPAuthEnabled,
JwtHeaderName: hs.Cfg.JWTAuth.HeaderName,
JwtUrlLogin: hs.Cfg.JWTAuth.URLLogin,
LiveEnabled: hs.Cfg.LiveMaxConnections != 0,
LiveMessageSizeLimit: hs.Cfg.LiveMessageSizeLimit,
AutoAssignOrg: hs.Cfg.AutoAssignOrg,
VerifyEmailEnabled: hs.Cfg.VerifyEmailEnabled,
SigV4AuthEnabled: hs.Cfg.SigV4AuthEnabled,
AzureAuthEnabled: hs.Cfg.AzureAuthEnabled,
RbacEnabled: true,
ExploreEnabled: hs.Cfg.ExploreEnabled,
HelpEnabled: hs.Cfg.HelpEnabled,
ProfileEnabled: hs.Cfg.ProfileEnabled,
NewsFeedEnabled: hs.Cfg.NewsFeedEnabled,
QueryHistoryEnabled: hs.Cfg.QueryHistoryEnabled,
GoogleAnalyticsId: hs.Cfg.GoogleAnalyticsID,
GoogleAnalytics4Id: hs.Cfg.GoogleAnalytics4ID,
GoogleAnalytics4SendManualPageViews: hs.Cfg.GoogleAnalytics4SendManualPageViews,
RudderstackWriteKey: hs.Cfg.RudderstackWriteKey,
RudderstackDataPlaneUrl: hs.Cfg.RudderstackDataPlaneURL,
RudderstackSdkUrl: hs.Cfg.RudderstackSDKURL,
RudderstackConfigUrl: hs.Cfg.RudderstackConfigURL,
RudderstackIntegrationsUrl: hs.Cfg.RudderstackIntegrationsURL,
AnalyticsConsoleReporting: hs.Cfg.FrontendAnalyticsConsoleReporting,
DashboardPerformanceMetrics: hs.Cfg.DashboardPerformanceMetrics,
PanelSeriesLimit: hs.Cfg.PanelSeriesLimit,
FeedbackLinksEnabled: hs.Cfg.FeedbackLinksEnabled,
ApplicationInsightsConnectionString: hs.Cfg.ApplicationInsightsConnectionString,
ApplicationInsightsEndpointUrl: hs.Cfg.ApplicationInsightsEndpointUrl,
DisableLoginForm: hs.Cfg.DisableLoginForm,
DisableUserSignUp: !hs.Cfg.AllowUserSignUp,
LoginHint: hs.Cfg.LoginHint,
PasswordHint: hs.Cfg.PasswordHint,
ExternalUserMngInfo: hs.Cfg.ExternalUserMngInfo,
ExternalUserMngLinkUrl: hs.Cfg.ExternalUserMngLinkUrl,
ExternalUserMngLinkName: hs.Cfg.ExternalUserMngLinkName,
ExternalUserMngAnalytics: hs.Cfg.ExternalUserMngAnalytics,
ExternalUserMngAnalyticsParams: hs.Cfg.ExternalUserMngAnalyticsParams,
DefaultDatasource: defaultDS,
Datasources: dataSources,
MinRefreshInterval: hs.Cfg.MinRefreshInterval,
Panels: panels,
Apps: apps,
AppUrl: hs.Cfg.AppURL,
AppSubUrl: hs.Cfg.AppSubURL,
AllowOrgCreate: (hs.Cfg.AllowUserOrgCreate && c.IsSignedIn) || c.IsGrafanaAdmin,
AuthProxyEnabled: hs.Cfg.AuthProxy.Enabled,
LdapEnabled: hs.Cfg.LDAPAuthEnabled,
JwtHeaderName: hs.Cfg.JWTAuth.HeaderName,
JwtUrlLogin: hs.Cfg.JWTAuth.URLLogin,
LiveEnabled: hs.Cfg.LiveMaxConnections != 0,
LiveMessageSizeLimit: hs.Cfg.LiveMessageSizeLimit,
AutoAssignOrg: hs.Cfg.AutoAssignOrg,
VerifyEmailEnabled: hs.Cfg.VerifyEmailEnabled,
SigV4AuthEnabled: hs.Cfg.SigV4AuthEnabled,
AzureAuthEnabled: hs.Cfg.AzureAuthEnabled,
RbacEnabled: true,
ExploreEnabled: hs.Cfg.ExploreEnabled,
HelpEnabled: hs.Cfg.HelpEnabled,
ProfileEnabled: hs.Cfg.ProfileEnabled,
NewsFeedEnabled: hs.Cfg.NewsFeedEnabled,
QueryHistoryEnabled: hs.Cfg.QueryHistoryEnabled,
GoogleAnalyticsId: hs.Cfg.GoogleAnalyticsID,
GoogleAnalytics4Id: hs.Cfg.GoogleAnalytics4ID,
GoogleAnalytics4SendManualPageViews: hs.Cfg.GoogleAnalytics4SendManualPageViews,
RudderstackWriteKey: hs.Cfg.RudderstackWriteKey,
RudderstackDataPlaneUrl: hs.Cfg.RudderstackDataPlaneURL,
RudderstackSdkUrl: hs.Cfg.RudderstackSDKURL,
RudderstackConfigUrl: hs.Cfg.RudderstackConfigURL,
RudderstackIntegrationsUrl: hs.Cfg.RudderstackIntegrationsURL,
AnalyticsConsoleReporting: hs.Cfg.FrontendAnalyticsConsoleReporting,
DashboardPerformanceMetrics: hs.Cfg.DashboardPerformanceMetrics,
PanelSeriesLimit: hs.Cfg.PanelSeriesLimit,
FeedbackLinksEnabled: hs.Cfg.FeedbackLinksEnabled,
ApplicationInsightsConnectionString: hs.Cfg.ApplicationInsightsConnectionString,
ApplicationInsightsEndpointUrl: hs.Cfg.ApplicationInsightsEndpointUrl,
ApplicationInsightsAutoRouteTracking: hs.Cfg.ApplicationInsightsAutoRouteTracking,
DisableLoginForm: hs.Cfg.DisableLoginForm,
DisableUserSignUp: !hs.Cfg.AllowUserSignUp,
LoginHint: hs.Cfg.LoginHint,
PasswordHint: hs.Cfg.PasswordHint,
ExternalUserMngInfo: hs.Cfg.ExternalUserMngInfo,
ExternalUserMngLinkUrl: hs.Cfg.ExternalUserMngLinkUrl,
ExternalUserMngLinkName: hs.Cfg.ExternalUserMngLinkName,
ExternalUserMngAnalytics: hs.Cfg.ExternalUserMngAnalytics,
ExternalUserMngAnalyticsParams: hs.Cfg.ExternalUserMngAnalyticsParams,
//nolint:staticcheck // ViewersCanEdit is deprecated but still used for backward compatibility
ViewersCanEdit: hs.Cfg.ViewersCanEdit,
DisableSanitizeHtml: hs.Cfg.DisableSanitizeHtml,
@@ -290,26 +291,27 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
EnabledFeatures: hs.License.EnabledFeatures(),
},
FeatureToggles: featureToggles,
AnonymousEnabled: hs.Cfg.Anonymous.Enabled,
AnonymousDeviceLimit: hs.Cfg.Anonymous.DeviceLimit,
RendererAvailable: hs.RenderService.IsAvailable(c.Req.Context()),
RendererVersion: hs.RenderService.Version(),
RendererDefaultImageWidth: hs.Cfg.RendererDefaultImageWidth,
RendererDefaultImageHeight: hs.Cfg.RendererDefaultImageHeight,
RendererDefaultImageScale: hs.Cfg.RendererDefaultImageScale,
Http2Enabled: hs.Cfg.Protocol == setting.HTTP2Scheme,
GrafanaJavascriptAgent: hs.Cfg.GrafanaJavascriptAgent,
PluginCatalogURL: hs.Cfg.PluginCatalogURL,
PluginAdminEnabled: hs.Cfg.PluginAdminEnabled,
PluginAdminExternalManageEnabled: hs.Cfg.PluginAdminEnabled && hs.Cfg.PluginAdminExternalManageEnabled,
PluginCatalogHiddenPlugins: hs.Cfg.PluginCatalogHiddenPlugins,
PluginCatalogManagedPlugins: hs.managedPluginsService.ManagedPlugins(c.Req.Context()),
PluginCatalogPreinstalledPlugins: append(hs.Cfg.PreinstallPluginsAsync, hs.Cfg.PreinstallPluginsSync...),
ExpressionsEnabled: hs.Cfg.ExpressionsEnabled,
AwsAllowedAuthProviders: hs.Cfg.AWSAllowedAuthProviders,
AwsAssumeRoleEnabled: hs.Cfg.AWSAssumeRoleEnabled,
SupportBundlesEnabled: isSupportBundlesEnabled(hs),
FeatureToggles: featureToggles,
AnonymousEnabled: hs.Cfg.Anonymous.Enabled,
AnonymousDeviceLimit: hs.Cfg.Anonymous.DeviceLimit,
RendererAvailable: hs.RenderService.IsAvailable(c.Req.Context()),
RendererVersion: hs.RenderService.Version(),
RendererDefaultImageWidth: hs.Cfg.RendererDefaultImageWidth,
RendererDefaultImageHeight: hs.Cfg.RendererDefaultImageHeight,
RendererDefaultImageScale: hs.Cfg.RendererDefaultImageScale,
Http2Enabled: hs.Cfg.Protocol == setting.HTTP2Scheme,
GrafanaJavascriptAgent: hs.Cfg.GrafanaJavascriptAgent,
PluginCatalogURL: hs.Cfg.PluginCatalogURL,
PluginAdminEnabled: hs.Cfg.PluginAdminEnabled,
PluginAdminExternalManageEnabled: hs.Cfg.PluginAdminEnabled && hs.Cfg.PluginAdminExternalManageEnabled,
PluginCatalogHiddenPlugins: hs.Cfg.PluginCatalogHiddenPlugins,
PluginCatalogManagedPlugins: hs.managedPluginsService.ManagedPlugins(c.Req.Context()),
PluginCatalogPreinstalledPlugins: append(hs.Cfg.PreinstallPluginsAsync, hs.Cfg.PreinstallPluginsSync...),
PluginCatalogPreinstalledAutoUpdate: hs.Cfg.PreinstallAutoUpdate,
ExpressionsEnabled: hs.Cfg.ExpressionsEnabled,
AwsAllowedAuthProviders: hs.Cfg.AWSAllowedAuthProviders,
AwsAssumeRoleEnabled: hs.Cfg.AWSAssumeRoleEnabled,
SupportBundlesEnabled: isSupportBundlesEnabled(hs),
Azure: dtos.FrontendSettingsAzureDTO{
Cloud: hs.Cfg.Azure.Cloud,
@@ -322,7 +324,8 @@ func (hs *HTTPServer) getFrontendSettings(c *contextmodel.ReqContext) (*dtos.Fro
},
Caching: dtos.FrontendSettingsCachingDTO{
Enabled: hs.Cfg.SectionWithEnvOverrides("caching").Key("enabled").MustBool(true),
Enabled: hs.Cfg.SectionWithEnvOverrides("caching").Key("enabled").MustBool(true),
CleanCacheEnabled: hs.Cfg.SectionWithEnvOverrides("caching").Key("clean_cache_enabled").MustBool(true),
},
RecordedQueries: dtos.FrontendSettingsRecordedQueriesDTO{
Enabled: hs.Cfg.SectionWithEnvOverrides("recorded_queries").Key("enabled").MustBool(true),
-2
View File
@@ -164,7 +164,6 @@ type HTTPServer struct {
LoggerMiddleware loggermw.Logger
SQLStore db.DB
AlertNG *ngalert.AlertNG
LibraryPanelService librarypanels.Service
LibraryElementService libraryelements.Service
SocialService social.Service
Listener net.Listener
@@ -319,7 +318,6 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi
ContextHandler: contextHandler,
LoggerMiddleware: loggerMiddleware,
AlertNG: alertNG,
LibraryPanelService: libraryPanelService,
LibraryElementService: libraryElementService,
QuotaService: quotaService,
tracer: tracer,
+4 -5
View File
@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
"fmt"
"html/template"
"net/http"
"strings"
@@ -184,12 +185,12 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
NewGrafanaVersionExists: hs.grafanaUpdateChecker.UpdateAvailable(),
AppName: setting.ApplicationName,
AppNameBodyClass: "app-grafana",
FavIcon: "public/img/fav32.png",
AppleTouchIcon: "public/img/apple-touch-icon.png",
FavIcon: template.URL(assets.ContentDeliveryURL + "public/build/img/fav32.png"), // #nosec G203
AppleTouchIcon: template.URL(assets.ContentDeliveryURL + "public/build/img/apple-touch-icon.png"), // #nosec G203
AppTitle: "Grafana",
NavTree: navTree,
Nonce: c.RequestNonce,
LoadingLogo: "public/img/grafana_icon.svg",
LoadingLogo: template.URL(assets.ContentDeliveryURL + "public/build/img/grafana_icon.svg"), // #nosec G203
IsDevelopmentEnv: hs.Cfg.Env == setting.Dev,
Assets: assets,
}
@@ -215,8 +216,6 @@ func (hs *HTTPServer) setIndexViewData(c *contextmodel.ReqContext) (*dtos.IndexV
hs.HooksService.RunIndexDataHooks(&data, c)
data.NavTree.ApplyCostManagementIA()
data.NavTree.ApplyHelpVersion(data.Settings.BuildInfo.VersionString) // RunIndexDataHooks can modify the version string
data.NavTree.Sort()
return &data, nil
+1 -1
View File
@@ -314,7 +314,7 @@ func (hs *HTTPServer) searchOrgUsersHelper(c *contextmodel.ReqContext, query *or
filteredUsers = append(filteredUsers, user)
}
modules, err := hs.authInfoService.GetUserLabels(c.Req.Context(), login.GetUserLabelsQuery{
modules, err := hs.authInfoService.GetUsersRecentlyUsedLabel(c.Req.Context(), login.GetUserLabelsQuery{
UserIDs: authLabelsUserIDs,
})
+1 -6
View File
@@ -144,13 +144,8 @@ type playlistK8sHandler struct {
//-----------------------------------------------------------------------------------------
func newPlaylistK8sHandler(hs *HTTPServer) *playlistK8sHandler {
gvr := schema.GroupVersionResource{
Group: v0alpha1.PlaylistKind().Group(),
Version: v0alpha1.PlaylistKind().Version(),
Resource: v0alpha1.PlaylistKind().Plural(),
}
return &playlistK8sHandler{
gvr: gvr,
gvr: v0alpha1.PlaylistKind().GroupVersionResource(),
namespacer: request.GetNamespaceMapper(hs.Cfg),
clientConfigProvider: hs.clientConfigProvider,
}
+1 -1
View File
@@ -173,7 +173,7 @@ func TestIntegrationCallResource(t *testing.T) {
Backend: true,
},
}))
middlewares := pluginsintegration.CreateMiddlewares(cfg, &oauthtokentest.Service{}, tracing.InitializeTracerForTest(), &caching.OSSCachingService{}, featuremgmt.WithFeatures(), prometheus.DefaultRegisterer, pluginRegistry)
middlewares := pluginsintegration.CreateMiddlewares(cfg, &oauthtokentest.Service{}, tracing.InitializeTracerForTest(), caching.ProvideCachingServiceClient(&caching.OSSCachingService{}, nil), featuremgmt.WithFeatures(), prometheus.DefaultRegisterer, pluginRegistry)
pc, err := backend.HandlerFromMiddlewares(&pluginfakes.FakePluginClient{
CallResourceHandlerFunc: backend.CallResourceHandlerFunc(func(ctx context.Context,
req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
+6 -11
View File
@@ -11,7 +11,7 @@ import (
"k8s.io/client-go/dynamic"
"k8s.io/client-go/kubernetes"
"github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1alpha1"
"github.com/grafana/grafana/apps/shorturl/pkg/apis/shorturl/v1beta1"
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
@@ -119,13 +119,8 @@ type shortURLK8sHandler struct {
}
func newShortURLK8sHandler(hs *HTTPServer) *shortURLK8sHandler {
gvr := schema.GroupVersionResource{
Group: v1alpha1.ShortURLKind().Group(),
Version: v1alpha1.ShortURLKind().Version(),
Resource: v1alpha1.ShortURLKind().Plural(),
}
return &shortURLK8sHandler{
gvr: gvr,
gvr: v1beta1.ShortURLKind().GroupVersionResource(),
namespacer: request.GetNamespaceMapper(hs.Cfg),
clientConfigProvider: hs.clientConfigProvider,
cfg: hs.Cfg,
@@ -169,9 +164,9 @@ func (sk8s *shortURLK8sHandler) getKubernetesRedirectFromShortURL(c *contextmode
}
result := client.RESTClient().Get().
Prefix("apis", v1alpha1.APIGroup, v1alpha1.APIVersion).
Prefix("apis", v1beta1.APIGroup, v1beta1.APIVersion).
Namespace(sk8s.namespacer(c.OrgID)).
Resource(v1alpha1.ShortURLKind().Plural()).
Resource(v1beta1.ShortURLKind().Plural()).
Name(uid).
SubResource("goto").
Param("redirect", "false"). // returns the URL and then we will do the redirect
@@ -188,7 +183,7 @@ func (sk8s *shortURLK8sHandler) getKubernetesRedirectFromShortURL(c *contextmode
return
}
value := &v1alpha1.GetGoto{}
value := &v1beta1.GetGoto{}
if err = json.Unmarshal(body, value); err != nil {
c.JsonApiErr(500, "unmarshal", err)
return
@@ -217,7 +212,7 @@ func (sk8s *shortURLK8sHandler) createKubernetesShortURLsHandler(c *contextmodel
c.Logger.Debug("Creating short URL", "path", cmd.Path)
obj := shorturl.LegacyCreateCommandToUnstructured(cmd)
obj.SetGenerateName("u") // becomes a prefix
obj.SetGenerateName("s") // becomes a prefix
out, err := client.Create(c.Req.Context(), &obj, v1.CreateOptions{})
if err != nil {
+5 -4
View File
@@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/api/dtos"
"github.com/grafana/grafana/pkg/api/response"
"github.com/grafana/grafana/pkg/api/routing"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/log"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/services/shorturls"
@@ -31,7 +32,7 @@ func TestShortURLAPIEndpoint(t *testing.T) {
Path: cmd.Path,
}
service := &fakeShortURLService{
createShortURLFunc: func(ctx context.Context, user *user.SignedInUser, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) {
createShortURLFunc: func(ctx context.Context, user identity.Requester, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) {
return createResp, nil
},
createConvertShortURLToDTO: func(shortURL *shorturls.ShortUrl, appURL string) *dtos.ShortURL {
@@ -81,7 +82,7 @@ func createShortURLScenario(t *testing.T, desc string, url string, routePattern
}
type fakeShortURLService struct {
createShortURLFunc func(ctx context.Context, user *user.SignedInUser, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error)
createShortURLFunc func(ctx context.Context, user identity.Requester, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error)
createConvertShortURLToDTO func(shortURL *shorturls.ShortUrl, appURL string) *dtos.ShortURL
}
@@ -89,11 +90,11 @@ func (s *fakeShortURLService) List(ctx context.Context, orgID int64) ([]*shortur
return nil, nil
}
func (s *fakeShortURLService) GetShortURLByUID(ctx context.Context, user *user.SignedInUser, uid string) (*shorturls.ShortUrl, error) {
func (s *fakeShortURLService) GetShortURLByUID(ctx context.Context, user identity.Requester, uid string) (*shorturls.ShortUrl, error) {
return nil, nil
}
func (s *fakeShortURLService) CreateShortURL(ctx context.Context, user *user.SignedInUser, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) {
func (s *fakeShortURLService) CreateShortURL(ctx context.Context, user identity.Requester, cmd *dtos.CreateShortURLCmd) (*shorturls.ShortUrl, error) {
if s.createShortURLFunc != nil {
return s.createShortURLFunc(ctx, user, cmd)
}
+6
View File
@@ -115,6 +115,7 @@ func (hs *HTTPServer) GetUserByLoginOrEmail(c *contextmodel.ReqContext) response
}
return response.Error(http.StatusInternalServerError, "Failed to get user", err)
}
result := user.UserProfileDTO{
ID: usr.ID,
UID: usr.UID,
@@ -128,6 +129,11 @@ func (hs *HTTPServer) GetUserByLoginOrEmail(c *contextmodel.ReqContext) response
UpdatedAt: usr.Updated,
CreatedAt: usr.Created,
}
// Populate AuthLabels using all historically used auth modules ordered by most recent.
if modules, err := hs.authInfoService.GetUserAuthModuleLabels(c.Req.Context(), usr.ID); err == nil {
result.AuthLabels = modules
}
return response.JSON(http.StatusOK, &result)
}
+38
View File
@@ -185,6 +185,44 @@ func TestIntegrationUserAPIEndpoint_userLoggedIn(t *testing.T) {
require.NoError(t, err)
}, mock)
// Multiple historical auth labels should appear ordered by recency
loggedInUserScenario(t, "When calling GET returns with multiple auth labels", "/api/users/lookup", "/api/users/lookup", func(sc *scenarioContext) {
createUserCmd := user.CreateUserCommand{
Email: fmt.Sprint("multi", "@test.com"),
Name: "multi",
Login: "multi",
IsAdmin: true,
}
orgSvc, err := orgimpl.ProvideService(sqlStore, sc.cfg, quotatest.New(false, nil))
require.NoError(t, err)
userSvc, err := userimpl.ProvideService(
sqlStore, orgSvc, sc.cfg, nil, nil, tracing.InitializeTracerForTest(),
quotatest.New(false, nil), supportbundlestest.NewFakeBundleService(),
)
require.NoError(t, err)
usr, err := userSvc.Create(context.Background(), &createUserCmd)
require.Nil(t, err)
sc.handlerFunc = hs.GetUserByLoginOrEmail
userMock := usertest.NewUserServiceFake()
userMock.ExpectedUser = &user.User{ID: usr.ID, Email: usr.Email, Login: usr.Login, Name: usr.Name}
sc.userService = userMock
hs.userService = userMock
fakeAuth := &authinfotest.FakeService{ExpectedAuthModuleLabels: []string{login.GetAuthProviderLabel(login.OktaAuthModule), login.GetAuthProviderLabel(login.LDAPAuthModule), login.GetAuthProviderLabel(login.SAMLAuthModule)}}
hs.authInfoService = fakeAuth
sc.fakeReqWithParams("GET", sc.url, map[string]string{"loginOrEmail": usr.Email}).exec()
var resp user.UserProfileDTO
require.Equal(t, http.StatusOK, sc.resp.Code)
err = json.Unmarshal(sc.resp.Body.Bytes(), &resp)
require.NoError(t, err)
expected := []string{login.GetAuthProviderLabel(login.OktaAuthModule), login.GetAuthProviderLabel(login.LDAPAuthModule), login.GetAuthProviderLabel(login.SAMLAuthModule)}
require.Equal(t, expected, resp.AuthLabels)
}, mock)
loggedInUserScenario(t, "When calling GET on", "/api/users", "/api/users", func(sc *scenarioContext) {
userMock.ExpectedSearchUsers = mockResult
@@ -52,7 +52,7 @@ func (u *Unstructured) SetUnstructuredContent(content map[string]any) {
// MarshalJSON ensures that the unstructured object produces proper
// JSON when passed to Go's standard JSON library.
func (u *Unstructured) MarshalJSON() ([]byte, error) {
func (u Unstructured) MarshalJSON() ([]byte, error) {
return json.Marshal(u.Object)
}
+2 -2
View File
@@ -45,8 +45,8 @@ require (
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect
+4 -4
View File
@@ -96,16 +96,16 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
+19
View File
@@ -74,6 +74,21 @@ func newInternalIdentity(name string, namespace string, orgID int64, opts ...Ide
return staticRequester
}
// WithServiceIdentityForSingleNamespace sets an identity representing the service itself in provided namespace and store it in context.
// This is useful for background tasks that has to communicate with other services in the same namespace. It also returns a Requester with
// static permissions so it can be used in legacy code paths.
func WithServiceIdentityForSingleNamespace(ctx context.Context, namespace string, opts ...IdentityOpts) (context.Context, Requester) {
r := newInternalIdentity(serviceName, namespace, 1, opts...)
return WithRequester(ctx, r), r
}
// WithServiceIdentityForSingleNamespaceContext sets an identity representing the service itself in context, restricted to a namespace.
// Use when using a middleware that signs tokens with the same restriction.
func WithServiceIdentityForSingleNamespaceContext(ctx context.Context, namespace string, opts ...IdentityOpts) context.Context {
ctx, _ = WithServiceIdentityForSingleNamespace(ctx, namespace, opts...)
return ctx
}
// WithServiceIdentity sets an identity representing the service itself in provided org and store it in context.
// This is useful for background tasks that has to communicate with unfied storage. It also returns a Requester with
// static permissions so it can be used in legacy code paths.
@@ -125,6 +140,10 @@ var serviceIdentityPermissions = getWildcardPermissions(
"datasources:query",
"datasources:read",
"datasources:delete",
"library.panels:create", // ActionLibraryPanelsCreate
"library.panels:read", // ActionLibraryPanelsRead
"library.panels:write", // ActionLibraryPanelsWrite
"library.panels:delete", // ActionLibraryPanelsDelete
"alert.provisioning:write",
"alert.provisioning.secrets:read",
"users:read", // accesscontrol.ActionUsersRead,
+9
View File
@@ -7,6 +7,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
"github.com/grafana/grafana/pkg/expr"
)
// Generic query request with shared time across all values
@@ -28,6 +29,14 @@ type QueryDataResponse struct {
backend.QueryDataResponse `json:",inline"`
}
// +k8s:deepcopy-gen:interfaces=k8s.io/apimachinery/pkg/runtime.Object
type SQLSchemas struct {
metav1.TypeMeta `json:",inline"`
// Backend wrapper (external dependency)
expr.SQLSchemas `json:"sqlSchemas,inline"`
}
// GetResponseCode return the right status code for the response by checking the responses.
func GetResponseCode(rsp *backend.QueryDataResponse) int {
if rsp == nil {
@@ -262,3 +262,29 @@ func (in *QueryTypeDefinitionList) DeepCopyObject() runtime.Object {
}
return nil
}
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *SQLSchemas) DeepCopyInto(out *SQLSchemas) {
*out = *in
out.TypeMeta = in.TypeMeta
out.SQLSchemas = in.SQLSchemas.DeepCopy()
return
}
// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SQLSchemas.
func (in *SQLSchemas) DeepCopy() *SQLSchemas {
if in == nil {
return nil
}
out := new(SQLSchemas)
in.DeepCopyInto(out)
return out
}
// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
func (in *SQLSchemas) DeepCopyObject() runtime.Object {
if c := in.DeepCopy(); c != nil {
return c
}
return nil
}
@@ -23,6 +23,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryDataResponse": schema_pkg_apis_query_v0alpha1_QueryDataResponse(ref),
"github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinition": schema_pkg_apis_query_v0alpha1_QueryTypeDefinition(ref),
"github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinitionList": schema_pkg_apis_query_v0alpha1_QueryTypeDefinitionList(ref),
"github.com/grafana/grafana/pkg/apis/query/v0alpha1.SQLSchemas": schema_pkg_apis_query_v0alpha1_SQLSchemas(ref),
}
}
@@ -482,3 +483,29 @@ func schema_pkg_apis_query_v0alpha1_QueryTypeDefinitionList(ref common.Reference
"github.com/grafana/grafana/pkg/apis/query/v0alpha1.QueryTypeDefinition", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"},
}
}
func schema_pkg_apis_query_v0alpha1_SQLSchemas(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"kind": {
SchemaProps: spec.SchemaProps{
Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
Type: []string{"string"},
Format: "",
},
},
"apiVersion": {
SchemaProps: spec.SchemaProps{
Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
Type: []string{"string"},
Format: "",
},
},
},
},
},
}
}
+6 -5
View File
@@ -5,7 +5,7 @@ go 1.25.3
require (
github.com/google/go-cmp v0.7.0
github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37
github.com/grafana/grafana-app-sdk/logging v0.46.0
github.com/grafana/grafana-app-sdk/logging v0.48.1
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e
github.com/prometheus/client_golang v1.23.2
github.com/stretchr/testify v1.11.1
@@ -84,14 +84,15 @@ require (
go.uber.org/zap v1.27.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.42.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/term v0.35.0 // indirect
golang.org/x/term v0.36.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.13.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect
google.golang.org/grpc v1.76.0 // indirect
+12 -12
View File
@@ -71,8 +71,8 @@ github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 h1:qEwZ+7MbP
github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw=
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI=
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4=
github.com/grafana/grafana-app-sdk/logging v0.46.0 h1:JhQ+ZK5orcmM+dZ3YZdT9uCizJEFU2I6JBNUSFWvCC8=
github.com/grafana/grafana-app-sdk/logging v0.46.0/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA=
github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng=
github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA=
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h1:BTKk7LHuG1kmAkucwTA7DuMbKpKvJTKrGdBmUNO4dfQ=
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:IA4SOwun8QyST9c5UNs/fN37XL6boXXDvRYFcFwbipg=
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o=
@@ -207,8 +207,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -219,8 +219,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
@@ -237,21 +237,21 @@ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -15,5 +15,6 @@ func ValidatePackage(ctx context.Context, d *dagger.Client, service *dagger.Serv
return c.WithServiceBinding("grafana", service).
WithEnvVariable("GRAFANA_URL", "http://grafana:3000").
WithEnvVariable("PW_TEST_HTML_REPORT_OPEN", "never").
WithExec([]string{"yarn", "e2e:acceptance"}), nil
}
+3 -3
View File
@@ -5,7 +5,7 @@ go 1.25.3
// Override docker/docker to avoid:
// go: github.com/drone-runners/drone-runner-docker@v1.8.2 requires
// github.com/docker/docker@v0.0.0-00010101000000-000000000000: invalid version: unknown revision 000000000000
replace github.com/docker/docker => github.com/moby/moby v27.5.1+incompatible
replace github.com/docker/docker => github.com/moby/moby v28.0.1+incompatible
require (
github.com/google/uuid v1.6.0 // indirect; @grafana/grafana-backend-group
@@ -13,7 +13,7 @@ require (
go.opentelemetry.io/otel v1.38.0 // indirect; @grafana/grafana-backend-group
go.opentelemetry.io/otel/sdk v1.38.0 // indirect; @grafana/grafana-backend-group
go.opentelemetry.io/otel/trace v1.38.0 // indirect; @grafana/grafana-backend-group
golang.org/x/net v0.45.0 // indirect; @grafana/oss-big-tent @grafana/partner-datasources
golang.org/x/net v0.46.0 // indirect; @grafana/oss-big-tent @grafana/partner-datasources
golang.org/x/sync v0.17.0 // @grafana/alerting-backend
golang.org/x/text v0.30.0 // indirect; @grafana/grafana-backend-group
google.golang.org/grpc v1.76.0 // indirect; @grafana/plugins-platform-backend
@@ -38,7 +38,7 @@ require (
dagger.io/dagger v0.18.8
github.com/Masterminds/semver v1.5.0
github.com/quasilyte/go-ruleguard/dsl v0.3.22
github.com/urfave/cli/v3 v3.4.1
github.com/urfave/cli/v3 v3.5.0
)
require (
+4 -4
View File
@@ -53,8 +53,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/urfave/cli/v2 v2.27.7 h1:bH59vdhbjLv3LAvIu6gd0usJHgoTTPhCFib8qqOwXYU=
github.com/urfave/cli/v2 v2.27.7/go.mod h1:CyNAG/xg+iAOg0N4MPGZqVmv2rCoP267496AOXUZjA4=
github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM=
github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo=
github.com/urfave/cli/v3 v3.5.0 h1:qCuFMmdayTF3zmjG8TSsoBzrDqszNrklYg2x3g4MSgw=
github.com/urfave/cli/v3 v3.5.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=
github.com/vektah/gqlparser/v2 v2.5.27 h1:RHPD3JOplpk5mP5JGX8RKZkt2/Vwj/PZv0HxTdwFp0s=
github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
@@ -95,8 +95,8 @@ go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOV
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+2 -2
View File
@@ -6,10 +6,10 @@ require (
github.com/google/go-cmp v0.7.0
github.com/google/subcommands v1.2.0
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2
golang.org/x/tools v0.37.0
golang.org/x/tools v0.38.0
)
require (
golang.org/x/mod v0.28.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/sync v0.17.0 // indirect
)
+4 -4
View File
@@ -4,9 +4,9 @@ github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
+4 -4
View File
@@ -6,10 +6,10 @@ require (
cuelang.org/go v0.11.1
github.com/dave/dst v0.27.3
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d
github.com/grafana/cog v0.0.43
github.com/grafana/cog v0.0.44
github.com/grafana/cuetsy v0.1.11
github.com/matryer/is v1.4.1
golang.org/x/tools v0.37.0
golang.org/x/tools v0.38.0
)
require (
@@ -47,8 +47,8 @@ require (
github.com/woodsbury/decimal128 v1.3.0 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
github.com/yalue/merged_fs v1.3.0 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.30.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+8 -8
View File
@@ -31,8 +31,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d h1:hrXbGJ5jgp6yNITzs5o+zXq0V5yT3siNJ+uM8LGwWKk=
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s=
github.com/grafana/cog v0.0.43 h1:6EDzJVc8hbP3+AjPnRnAK6mKfyWgqLKbi/jmiStilFk=
github.com/grafana/cog v0.0.43/go.mod h1:TDunc7TYF7EfzjwFOlC5AkMe3To/U2KqyyG3QVvrF38=
github.com/grafana/cog v0.0.44 h1:N8UP7g6XBHZXf1wY7AOOWC2HdqlTPBJPJiAZQrkf4XQ=
github.com/grafana/cog v0.0.44/go.mod h1:TDunc7TYF7EfzjwFOlC5AkMe3To/U2KqyyG3QVvrF38=
github.com/grafana/cue v0.0.0-20230926092038-971951014e3f h1:TmYAMnqg3d5KYEAaT6PtTguL2GjLfvr6wnAX8Azw6tQ=
github.com/grafana/cue v0.0.0-20230926092038-971951014e3f/go.mod h1:okjJBHFQFer+a41sAe2SaGm1glWS8oEb6CmJvn5Zdws=
github.com/grafana/cuetsy v0.1.11 h1:I3IwBhF+UaQxRM79HnImtrAn8REGdb5M3+C4QrYHoWk=
@@ -100,16 +100,16 @@ github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
github.com/yalue/merged_fs v1.3.0 h1:qCeh9tMPNy/i8cwDsQTJ5bLr6IRxbs6meakNE5O+wyY=
github.com/yalue/merged_fs v1.3.0/go.mod h1:WqqchfVYQyclV2tnR7wtRhBddzBvLVR83Cjw9BKQw0M=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-12
View File
@@ -278,18 +278,6 @@ func TestNaNBecomesNull(t *testing.T) {
require.NoError(t, err)
}
func TestErrorsFromGoMySQLServerAreFlagged(t *testing.T) {
const GmsNotImplemented = "TRUNCATE" // not implemented in go-mysql-server as of 2025-04-11
db := DB{}
query := `SELECT ` + GmsNotImplemented + `(123.456, 2);`
_, err := db.QueryFrames(context.Background(), &testTracer{}, "sqlExpressionRefId", query, nil)
require.Error(t, err)
require.Contains(t, err.Error(), "error from the sql expression engine")
}
func TestFrameToSQLAndBack_JSONRoundtrip(t *testing.T) {
expectedFrame := &data.Frame{
RefID: "json_test",
+19
View File
@@ -0,0 +1,19 @@
//go:build arm
package sql
import (
"errors"
mysql "github.com/dolthub/go-mysql-server/sql"
"github.com/grafana/grafana-plugin-sdk-go/data"
)
// TODO: Implement for 32-bit arm
func MySQLColToFieldType(col *mysql.Column) (data.FieldType, error) {
return data.FieldTypeUnknown, errors.New("arm not implemented")
}
func SchemaFromFrame(frame *data.Frame) mysql.Schema {
return mysql.Schema{}
}
+2 -2
View File
@@ -30,7 +30,7 @@ func (ft *FrameTable) String() string {
return ft.Name()
}
func schemaFromFrame(frame *data.Frame) mysql.Schema {
func SchemaFromFrame(frame *data.Frame) mysql.Schema {
schema := make(mysql.Schema, len(frame.Fields))
for i, field := range frame.Fields {
@@ -48,7 +48,7 @@ func schemaFromFrame(frame *data.Frame) mysql.Schema {
// Schema implements the mysql.Table interface
func (ft *FrameTable) Schema() mysql.Schema {
if ft.schema == nil {
ft.schema = schemaFromFrame(ft.Frame)
ft.schema = SchemaFromFrame(ft.Frame)
}
return ft.schema
}
+42 -12
View File
@@ -183,19 +183,25 @@ func allowedFunction(f *sqlparser.FuncExpr) (b bool) {
// Conditional functions
case "if", "coalesce", "ifnull", "nullif":
return
case "least":
return
// Aggregation functions
case "sum", "avg", "count", "min", "max":
return
case "stddev", "std", "stddev_pop":
case "stddev", "std", "stddev_pop", "stddev_sample":
return
case "variance", "var_pop":
case "variance", "var_pop", "var_samp":
return
case "group_concat":
return
case "row_number", "rank", "dense_rank", "lead", "lag":
// Window Functions
case "row_number", "rank", "dense_rank", "percent_rank":
return
case "first_value", "last_value":
case "first_value", "last_value", "ntile":
return
case "lead", "lag":
return
// Mathematical functions
@@ -205,14 +211,16 @@ func allowedFunction(f *sqlparser.FuncExpr) (b bool) {
return
case "sqrt", "pow", "power":
return
case "mod", "log", "log10", "exp":
case "mod", "log", "log2", "log10", "exp":
return
case "sign", "ln", "truncate":
return
case "sin", "cos", "tan":
case "sin", "cos", "tan", "cot":
return
case "asin", "acos", "atan", "atan2":
return
case "conv", "degrees", "radians":
return
case "rand", "pi":
return
@@ -235,23 +243,29 @@ func allowedFunction(f *sqlparser.FuncExpr) (b bool) {
return
case "ascii", "ord", "char":
return
case "regexp_substr":
case "elt", "quote":
return
case "from_base64", "format":
return
case "regexp_substr", "regexp_replace", "regexp_instr", "regexp_like":
return
// Date functions
case "str_to_date":
return
case "date_format":
case "date_format", "get_format":
return
case "date_add", "date_sub":
case "date_add", "adddate", "date_sub", "subdate":
return
case "year", "month", "day", "weekday":
case "year", "month", "day", "weekday", "last_day":
return
case "yearweek", "weekofyear":
return
case "datediff":
return
case "unix_timestamp", "from_unixtime":
return
case "extract", "hour", "minute", "second":
case "extract", "hour", "minute", "second", "microsecond":
return
case "dayname", "monthname", "dayofweek", "dayofmonth", "dayofyear":
return
@@ -259,20 +273,36 @@ func allowedFunction(f *sqlparser.FuncExpr) (b bool) {
return
case "timestampdiff", "timestampadd":
return
case "from_days", "to_days":
return
case "time_format", "time", "timediff":
return
// Type conversion
case "cast", "convert":
return
// JSON functions
case "json_extract", "json_object", "json_array", "json_merge_patch", "json_valid":
case "json_extract", "json_object", "json_array", "json_valid":
return
case "json_merge", "json_merge_patch", "json_merge_preserve":
return
case "json_contains", "json_length", "json_type", "json_keys":
return
case "json_contains_path", "json_depth":
return
case "json_search", "json_quote", "json_unquote":
return
case "json_set", "json_insert", "json_replace", "json_remove":
return
case "json_array_append", "json_array_insert":
return
case "json_objectagg", "json_arrayagg":
return
case "json_overlaps":
return
case "json_pretty", "json_value":
return
default:
return false
+53 -2
View File
@@ -92,6 +92,16 @@ func TestAllowQuery(t *testing.T) {
q: "SELECT * FROM mockGitHubIssuesDSResponse, JSON_TABLE(labels, '$[*]' COLUMNS(val VARCHAR(255) PATH '$')) AS jt WHERE CAST(jt.val AS CHAR) LIKE 'type%'",
err: nil,
},
{
name: "json aggregation",
q: `SELECT JSON_ARRAYAGG(JSON_OBJECT('color', color, 'value', value)) AS result
FROM (
SELECT 'red' AS color, 10 AS value UNION ALL
SELECT 'blue', 20 UNION ALL
SELECT 'green', 30
) AS t;`,
err: nil,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
@@ -215,9 +225,11 @@ SELECT
STDDEV(value) AS agg_stddev,
STD(value) AS agg_std,
STDDEV_POP(value) AS agg_stddev_pop,
STDDEV_SAMPLE(value) AS agg_stddev_sample,
VARIANCE(value) AS agg_variance,
VAR_POP(value) AS agg_var_pop,
VAR_SAMP(value) AS agg_var_samp,
-- Mathematical functions
ABS(value) AS math_abs,
ROUND(value, 2) AS math_round,
@@ -229,6 +241,7 @@ SELECT
POWER(value, 2) AS math_power,
MOD(value, 10) AS math_mod,
LOG(value) AS math_log,
LOG2(value) AS math_log2,
LOG10(value) AS math_log10,
EXP(value) AS math_exp,
SIGN(value) AS math_sign,
@@ -245,13 +258,25 @@ SELECT
-- Date functions
STR_TO_DATE('2023-01-01', '%Y-%m-%d') AS date_str_to_date,
DATE_FORMAT('2025-01-01 00:00:00', '%Y-%m-%d') AS date_format,
DATE_FORMAT('2003-10-03',GET_FORMAT(DATE,'EUR')) AS date_format_eur,
'2025-01-01 00:00:00' AS date_now,
DATE_ADD(created_at, INTERVAL 1 DAY) AS date_add,
DATE_SUB(created_at, INTERVAL 1 DAY) AS date_sub,
ADDDATE(created_at, INTERVAL 1 DAY) AS date_adddate,
SUBDATE(created_at, INTERVAL 1 DAY) AS date_subdate,
YEAR(created_at) AS date_year,
MONTH(created_at) AS date_month,
DAY(created_at) AS date_day,
MICROSECOND(created_at) AS date_microsecond,
FROM_DAYS(738123) AS date_from_days,
TO_DAYS(created_at) AS date_to_days,
TIME(created_at) AS date_time,
TIME_FORMAT(created_at, '%H:%i:%s') AS date_time_format,
TIMEDIFF(created_at, '2025-01-01 00:00:00') AS date_timediff,
WEEKDAY(created_at) AS date_weekday,
LAST_DAY(created_at) AS date_last_day,
YEARWEEK(created_at) AS date_yearweek,
WEEKOFYEAR(created_at) AS date_weekofyear,
DATEDIFF('2025-01-01 00:00:00', created_at) AS date_datediff,
UNIX_TIMESTAMP(created_at) AS date_unix_timestamp,
FROM_UNIXTIME(1634567890) AS date_from_unixtime,
@@ -266,6 +291,8 @@ LIMIT 10`
var example_json_functions = `SELECT
JSON_OBJECT('key1', 'value1', 'key2', 10) AS json_obj,
JSON_ARRAY(1, 'abc', NULL, TRUE) AS json_arr,
JSON_ARRAY_APPEND('{"a": 1}', '$.b', 2) AS json_array_append,
JSON_ARRAY_INSERT('{"a": 1}', '$.b', 2) AS json_array_insert,
JSON_EXTRACT('{"id": 123, "name": "test"}', '$.id') AS json_ext,
JSON_UNQUOTE(JSON_EXTRACT('{"name": "test"}', '$.name')) AS json_unq,
JSON_CONTAINS('{"a": 1, "b": 2}', '{"a": 1}') AS json_contains,
@@ -273,6 +300,13 @@ var example_json_functions = `SELECT
JSON_REMOVE('{"a": 1, "b": 2}', '$.b') AS json_remove,
JSON_LENGTH('{"a": 1, "b": {"c": 3}}') AS json_len,
JSON_SEARCH('{"a": "xyz", "b": "abc"}', 'one', 'abc') AS json_search,
JSON_MERGE('{"a": 1}', '{"b": 2}') AS json_merge,
JSON_MERGE_PRESERVE('{"a": 1}', '{"b": 2}') AS json_merge_preserve,
JSON_CONTAINS_PATH('{"a": 1, "b": 2}', 'one', '$.a') AS json_contains_path,
JSON_DEPTH('{"a": 1, "b": {"c": 2}}') AS json_depth,
JSON_OVERLAPS('{"a": 1, "b": 2}', '{"b": 2, "c": 3}') AS json_overlaps,
JSON_PRETTY('{"a": 1, "b": 2}') AS json_pretty,
JSON_VALUE('{"a": 1, "b": 2}', '$.a') AS json_value,
JSON_TYPE('{"a": 1}') AS json_type`
var example_many_more_allowed_functions = `
@@ -280,13 +314,17 @@ SELECT
-- Math functions
LN(10) as ln_val,
TRUNCATE(12.345, 2) as truncate_val,
CONV('a',16,2) as conv_val,
SIN(0.5) as sin_val,
COS(0.5) as cos_val,
COT(0.5) as cot_val,
TAN(0.5) as tan_val,
ASIN(0.5) as asin_val,
ACOS(0.5) as acos_val,
ATAN(0.5) as atan_val,
ATAN2(1, 2) as atan2_val,
DEGREES(0.5) as degrees_val,
RADIANS(0.5) as radians_val,
RAND() as rand_val,
PI() as pi_val,
@@ -307,8 +345,19 @@ SELECT
ASCII('A') as ascii_val,
ORD('A') as ord_val,
CHAR(65) as char_val,
ELT(2, 'one', 'two', 'three') as elt_val,
FROM_BASE64('SGVsbG8sIFdvcmxkIQ==') as from_base64_val,
FORMAT(12332.123456, 4) as format_val,
QUOTE('hello') as quote_val,
-- Regex
'a' REGEXP '^[a-d]' AS regexp_val,
REGEXP_SUBSTR('hello world', 'world') as regexp_substr_val,
REGEXP_REPLACE('hello world', 'world', 'gopher') as regexp_replace_val,
REGEXP_INSTR('dog cat dog', 'dog') as regexp_instr_val,
REGEXP_LIKE('Michael!', '.*') as regexp_like_val,
-- Date functions
EXTRACT(YEAR FROM '2023-01-01') as extract_val,
HOUR('12:34:56') as hour_val,
@@ -350,8 +399,10 @@ SELECT
ROW_NUMBER() OVER (ORDER BY val) as row_num,
RANK() OVER (ORDER BY val) as rank_val,
DENSE_RANK() OVER (ORDER BY val) as dense_rank_val,
PERCENT_RANK() OVER (ORDER BY val) as percent_rank_val,
LEAD(val) OVER (ORDER BY val) as lead_val,
LAG(val) OVER (ORDER BY val) as lag_val,
FIRST_VALUE(val) OVER (ORDER BY val) as first_val,
NTILE(2) OVER (ORDER BY val) as ntile_val,
LAST_VALUE(val) OVER (ORDER BY val ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING) as last_val
FROM dummy_data;`
+231
View File
@@ -0,0 +1,231 @@
package expr
import (
"context"
"reflect"
"time"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/expr/mathexp"
"github.com/grafana/grafana/pkg/expr/sql"
)
// BasicColumn represents the column type for data that is input to a SQL expression.
type BasicColumn struct {
Name string `json:"name"`
MySQLType string `json:"mysqlType"`
Nullable bool `json:"nullable"`
DataFrameFieldType data.FieldType `json:"dataFrameFieldType"`
}
// SchemaInfo provides information and some sample data for data that could be an input
// to a SQL expression.
type SchemaInfo struct {
Columns []BasicColumn `json:"columns"`
SampleRows [][]any `json:"sampleRows"`
Error string `json:"error,omitempty"`
}
// SQLSchemas returns info about what the Schema for a DS query will be like if the
// query were to be used an input to SQL expressions. So effectively post SQL expressions input
// conversion.
// There is a a manual DeepCopy at the end of this file that will need to be updated when this our the
// underlying structs are change. The hack script will also need to be run to update the Query service API
// generated types.
type SQLSchemas map[string]SchemaInfo
// GetSQLSchemas returns what the schemas are for SQL expressions for all DS queries
// in the request. It executes the queries to get the schemas.
// Intended use is for autocomplete and AI, so used during the authoring/editing experience only.
func (s *Service) GetSQLSchemas(ctx context.Context, req Request) (SQLSchemas, error) {
// Extract DS Nodes and Execute Them
// Building the pipeline is maybe not best, as it can have more errors.
filtered := make([]Query, 0, len(req.Queries))
for _, q := range req.Queries {
if NodeTypeFromDatasourceUID(q.DataSource.UID) == TypeDatasourceNode {
filtered = append(filtered, q)
}
}
req.Queries = filtered
pipeline, err := s.buildPipeline(ctx, &req)
if err != nil {
return nil, err
}
var schemas = make(SQLSchemas)
for _, node := range pipeline {
// For now, execute calls convert at the end, so we are being lazy and running the full conversion. Longer run we want to run without
// full conversion and just get the schema. Maybe conversion should be
dsNode := node.(*DSNode)
// Make all input to SQL
dsNode.isInputToSQLExpr = true
// TODO: check where time is coming from, don't recall
res, err := dsNode.Execute(ctx, time.Now(), mathexp.Vars{}, s)
if err != nil {
schemas[dsNode.RefID()] = SchemaInfo{Error: err.Error()}
continue
// we want to continue and get the schemas we can
}
if res.Error != nil {
schemas[dsNode.RefID()] = SchemaInfo{Error: res.Error.Error()}
continue
// we want to continue and get the schemas we can
}
frames := res.Values.AsDataFrames(dsNode.RefID())
if len(frames) == 0 {
schemas[dsNode.RefID()] = SchemaInfo{Error: "no data"}
}
frame := frames[0]
schema := sql.SchemaFromFrame(frame)
columns := make([]BasicColumn, 0, len(schema))
for _, col := range schema {
fT, _ := sql.MySQLColToFieldType(col)
columns = append(columns, BasicColumn{
Name: col.Name,
MySQLType: col.Type.String(),
Nullable: col.Nullable,
DataFrameFieldType: fT,
})
}
// Cap at 3 rows.
const maxRows = 3
n := frame.Rows()
if n > maxRows {
n = maxRows
}
sampleRows := make([][]any, 0, n)
for i := 0; i < n; i++ {
sampleRows = append(sampleRows, frame.RowCopy(i))
}
schemas[dsNode.RefID()] = SchemaInfo{Columns: columns, SampleRows: sampleRows}
}
return schemas, nil
}
// DeepCopy returns a deep copy of the schema.
// Used AI to make it, the kubernetes one doesn't like any or interface{}
func (s SQLSchemas) DeepCopy() SQLSchemas {
if s == nil {
return nil
}
out := make(SQLSchemas, len(s))
for k, v := range s {
out[k] = SchemaInfo{
Columns: copyColumns(v.Columns),
SampleRows: deepCopySampleRows2D(v.SampleRows),
Error: v.Error,
}
}
return out
}
func copyColumns(in []BasicColumn) []BasicColumn {
if in == nil {
return nil
}
out := make([]BasicColumn, len(in))
copy(out, in) // BasicColumn is value-only, so this suffices
return out
}
// Deep-copy [][]any preserving nil vs empty slices and cloning elements.
func deepCopySampleRows2D(in [][]any) [][]any {
if in == nil {
return nil
}
out := make([][]any, len(in))
for i, row := range in {
if row == nil {
// preserve nil inner slice
continue
}
newRow := make([]any, len(row))
for j, v := range row {
newRow[j] = deepCopyAny(v)
}
out[i] = newRow
}
return out
}
// Recursively clone pointers, maps, slices, arrays, and interfaces.
// Structs are copied by value (shallow for their internals).
func deepCopyAny(v any) any {
if v == nil {
return nil
}
return deepCopyRV(reflect.ValueOf(v)).Interface()
}
func deepCopyRV(rv reflect.Value) reflect.Value {
if !rv.IsValid() {
return rv
}
switch rv.Kind() {
case reflect.Ptr:
if rv.IsNil() {
return rv
}
elemCopy := deepCopyRV(rv.Elem())
newPtr := reflect.New(rv.Type().Elem())
if elemCopy.Type().AssignableTo(newPtr.Elem().Type()) {
newPtr.Elem().Set(elemCopy)
} else if elemCopy.Type().ConvertibleTo(newPtr.Elem().Type()) {
newPtr.Elem().Set(elemCopy.Convert(newPtr.Elem().Type()))
} else {
newPtr.Elem().Set(rv.Elem()) // fallback: shallow
}
return newPtr
case reflect.Interface:
if rv.IsNil() {
return rv
}
return deepCopyRV(rv.Elem())
case reflect.Map:
if rv.IsNil() {
return reflect.Zero(rv.Type())
}
newMap := reflect.MakeMapWithSize(rv.Type(), rv.Len())
for _, k := range rv.MapKeys() {
newMap.SetMapIndex(deepCopyRV(k), deepCopyRV(rv.MapIndex(k)))
}
return newMap
case reflect.Slice:
if rv.IsNil() {
return reflect.Zero(rv.Type())
}
n := rv.Len()
newSlice := reflect.MakeSlice(rv.Type(), n, n)
for i := 0; i < n; i++ {
newSlice.Index(i).Set(deepCopyRV(rv.Index(i)))
}
return newSlice
case reflect.Array:
n := rv.Len()
newArr := reflect.New(rv.Type()).Elem()
for i := 0; i < n; i++ {
newArr.Index(i).Set(deepCopyRV(rv.Index(i)))
}
return newArr
case reflect.Struct:
// Value copy (OK unless the struct contains references you also want deep-copied).
return rv
default:
// Scalars (string, bool, numbers), etc.
return rv
}
}
+1
View File
@@ -58,4 +58,5 @@ import (
_ "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1"
_ "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1"
_ "github.com/testcontainers/testcontainers-go"
)
+12 -1
View File
@@ -191,6 +191,9 @@ type Panel struct {
TimeShift *string `json:"timeShift,omitempty"`
// Controls if the timeFrom or timeShift overrides are shown in the panel header
HideTimeOverride *bool `json:"hideTimeOverride,omitempty"`
// Compare the current time range with a previous period
// For example "1d" to compare current period but shifted back 1 day
TimeCompare *string `json:"timeCompare,omitempty"`
// Dynamically load the panel
LibraryPanel *LibraryPanelRef `json:"libraryPanel,omitempty"`
// Sets panel queries cache timeout.
@@ -206,7 +209,8 @@ type Panel struct {
// NewPanel creates a new Panel object.
func NewPanel() *Panel {
return &Panel{
Transparent: (func(input bool) *bool { return &input })(false),
Transparent: (func(input bool) *bool { return &input })(false),
RepeatDirection: (func(input PanelRepeatDirection) *PanelRepeatDirection { return &input })(PanelRepeatDirectionH),
}
}
@@ -964,6 +968,8 @@ type AnnotationQuery struct {
Type *string `json:"type,omitempty"`
// Set to 1 for the standard annotation query all dashboards have by default.
BuiltIn *float64 `json:"builtIn,omitempty"`
// Placement can be used to display the annotation query somewhere else on the dashboard other than the default location.
Placement *string `json:"placement,omitempty"`
}
// NewAnnotationQuery creates a new AnnotationQuery object.
@@ -973,6 +979,7 @@ func NewAnnotationQuery() *AnnotationQuery {
Enable: true,
Hide: (func(input bool) *bool { return &input })(false),
BuiltIn: (func(input float64) *float64 { return &input })(0),
Placement: (func(input string) *string { return &input })(AnnotationQueryPlacement),
}
}
@@ -1015,6 +1022,10 @@ func NewAnnotationTarget() *AnnotationTarget {
}
}
// Annotation Query placement. Defines where the annotation query should be displayed.
// - "inControlsMenu" renders the annotation query in the dashboard controls dropdown menu
const AnnotationQueryPlacement = "inControlsMenu"
// A dashboard snapshot shares an interactive dashboard publicly.
// It is a read-only version of a dashboard, and is not editable.
// It is possible to create a snapshot of a snapshot.
+13
View File
@@ -3,11 +3,22 @@ package middleware
import (
"strings"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/web"
)
var (
hostRedirectCounter = promauto.NewCounter(prometheus.CounterOpts{
Name: "host_redirect_total",
Help: "Number of requests redirected due to host header mismatch",
Namespace: "grafana",
})
)
func ValidateHostHeader(cfg *setting.Cfg) web.Handler {
return func(c *contextmodel.ReqContext) {
// ignore local render calls
@@ -21,6 +32,8 @@ func ValidateHostHeader(cfg *setting.Cfg) web.Handler {
}
if !strings.EqualFold(h, cfg.Domain) {
hostRedirectCounter.Inc()
c.Logger.Info("Enforcing Host header", "hosted", c.Req.Host, "expected", cfg.Domain)
c.Redirect(strings.TrimSuffix(cfg.AppURL, "/")+c.Req.RequestURI, 301)
return
}
@@ -90,6 +90,7 @@ func RunRepoController(deps server.OperatorDependencies) error {
statusPatcher,
deps.Registerer,
tracer,
controllerCfg.parallelOperations,
)
if err != nil {
return fmt.Errorf("failed to create repository controller: %w", err)
@@ -107,6 +108,7 @@ func RunRepoController(deps server.OperatorDependencies) error {
type repoControllerConfig struct {
provisioningControllerConfig
workerCount int
parallelOperations int
allowedTargets []string
allowImageRendering bool
minSyncInterval time.Duration
@@ -128,6 +130,7 @@ func getRepoControllerConfig(cfg *setting.Cfg, registry prometheus.Registerer) (
provisioningControllerConfig: *controllerCfg,
allowedTargets: allowedTargets,
workerCount: cfg.SectionWithEnvOverrides("operator").Key("worker_count").MustInt(1),
parallelOperations: cfg.SectionWithEnvOverrides("operator").Key("parallel_operations").MustInt(10),
allowImageRendering: cfg.SectionWithEnvOverrides("provisioning").Key("allow_image_rendering").MustBool(false),
minSyncInterval: cfg.SectionWithEnvOverrides("provisioning").Key("min_sync_interval").MustDuration(1 * time.Minute),
}, nil
+4 -4
View File
@@ -7,7 +7,7 @@ replace github.com/grafana/grafana/pkg/codegen => ../../codegen
require (
cuelang.org/go v0.11.1
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d
github.com/grafana/cog v0.0.43
github.com/grafana/cog v0.0.44
github.com/grafana/cuetsy v0.1.11
github.com/grafana/grafana/pkg/codegen v0.0.0-20250514132646-acbc7b54ed9e
)
@@ -43,11 +43,11 @@ require (
github.com/woodsbury/decimal128 v1.3.0 // indirect
github.com/xlab/treeprint v1.2.0 // indirect
github.com/yalue/merged_fs v1.3.0 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/tools v0.37.0 // indirect
golang.org/x/tools v0.38.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+10 -10
View File
@@ -30,8 +30,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d h1:hrXbGJ5jgp6yNITzs5o+zXq0V5yT3siNJ+uM8LGwWKk=
github.com/grafana/codejen v0.0.4-0.20230321061741-77f656893a3d/go.mod h1:zmwwM/DRyQB7pfuBjTWII3CWtxcXh8LTwAYGfDfpR6s=
github.com/grafana/cog v0.0.43 h1:6EDzJVc8hbP3+AjPnRnAK6mKfyWgqLKbi/jmiStilFk=
github.com/grafana/cog v0.0.43/go.mod h1:TDunc7TYF7EfzjwFOlC5AkMe3To/U2KqyyG3QVvrF38=
github.com/grafana/cog v0.0.44 h1:N8UP7g6XBHZXf1wY7AOOWC2HdqlTPBJPJiAZQrkf4XQ=
github.com/grafana/cog v0.0.44/go.mod h1:TDunc7TYF7EfzjwFOlC5AkMe3To/U2KqyyG3QVvrF38=
github.com/grafana/cuetsy v0.1.11 h1:I3IwBhF+UaQxRM79HnImtrAn8REGdb5M3+C4QrYHoWk=
github.com/grafana/cuetsy v0.1.11/go.mod h1:Ix97+CPD8ws9oSSxR3/Lf4ahU1I4Np83kjJmDVnLZvc=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
@@ -94,20 +94,20 @@ github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=
github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0=
github.com/yalue/merged_fs v1.3.0 h1:qCeh9tMPNy/i8cwDsQTJ5bLr6IRxbs6meakNE5O+wyY=
github.com/yalue/merged_fs v1.3.0/go.mod h1:WqqchfVYQyclV2tnR7wtRhBddzBvLVR83Cjw9BKQw0M=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -35,6 +35,11 @@ func NewPatternListInspector(detectorsProvider angulardetector.DetectorsProvider
}
func (i *PatternsListInspector) Inspect(ctx context.Context, p *plugins.Plugin) (isAngular bool, err error) {
// CDN plugins are ignored because they should not be using Angular
if p.Class == plugins.ClassCDN {
return false, nil
}
f, err := p.FS.Open("module.js")
if err != nil {
if errors.Is(err, plugins.ErrFileNotExist) {
@@ -27,17 +27,17 @@ func (d *fakeDetector) String() string {
}
func TestPatternsListInspector(t *testing.T) {
plugin := &plugins.Plugin{
FS: plugins.NewInMemoryFS(map[string][]byte{"module.js": nil}),
}
for _, tc := range []struct {
name string
plugin *plugins.Plugin
fakeDetectors []*fakeDetector
exp func(t *testing.T, r bool, err error, fakeDetectors []*fakeDetector)
}{
{
name: "calls the detectors in sequence until true is returned",
plugin: &plugins.Plugin{
FS: plugins.NewInMemoryFS(map[string][]byte{"module.js": nil}),
},
fakeDetectors: []*fakeDetector{
{returns: false},
{returns: true},
@@ -53,6 +53,9 @@ func TestPatternsListInspector(t *testing.T) {
},
{
name: "calls the detectors in sequence and returns false as default",
plugin: &plugins.Plugin{
FS: plugins.NewInMemoryFS(map[string][]byte{"module.js": nil}),
},
fakeDetectors: []*fakeDetector{
{returns: false},
{returns: false},
@@ -65,13 +68,30 @@ func TestPatternsListInspector(t *testing.T) {
},
},
{
name: "empty detectors should return false",
name: "empty detectors should return false",
plugin: &plugins.Plugin{
FS: plugins.NewInMemoryFS(map[string][]byte{"module.js": nil}),
},
fakeDetectors: nil,
exp: func(t *testing.T, r bool, err error, fakeDetectors []*fakeDetector) {
require.NoError(t, err)
require.False(t, r, "inspector should return false")
},
},
{
name: "CDN plugins return false without calling detectors",
plugin: &plugins.Plugin{
Class: plugins.ClassCDN,
},
fakeDetectors: []*fakeDetector{
{returns: true},
},
exp: func(t *testing.T, r bool, err error, fakeDetectors []*fakeDetector) {
require.NoError(t, err)
require.False(t, r, "inspector should return false for CDN plugins")
require.Equal(t, 0, fakeDetectors[0].calls, "detectors should not be called for CDN plugins")
},
},
} {
t.Run(tc.name, func(t *testing.T) {
detectors := make([]angulardetector.AngularDetector, 0, len(tc.fakeDetectors))
@@ -79,7 +99,7 @@ func TestPatternsListInspector(t *testing.T) {
detectors = append(detectors, angulardetector.AngularDetector(d))
}
inspector := NewPatternListInspector(&angulardetector.StaticDetectorsProvider{Detectors: detectors})
r, err := inspector.Inspect(context.Background(), plugin)
r, err := inspector.Inspect(context.Background(), tc.plugin)
tc.exp(t, r, err, tc.fakeDetectors)
})
}
+8 -6
View File
@@ -18,6 +18,7 @@ require (
)
require (
cloud.google.com/go/auth v0.16.3 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.12.0 // indirect
github.com/apache/arrow-go/v18 v18.4.1 // indirect
@@ -47,6 +48,7 @@ require (
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/googleapis/gax-go/v2 v2.15.0 // indirect
github.com/grafana/grafana/pkg/apimachinery v0.0.0-20251007081214-26e147d01f0a // indirect
github.com/grafana/otel-profiling-go v0.5.1 // indirect
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect
@@ -97,16 +99,16 @@ require (
go.yaml.in/yaml/v2 v2.4.3 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect
golang.org/x/mod v0.28.0 // indirect
golang.org/x/net v0.45.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053 // indirect
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect
golang.org/x/text v0.30.0 // indirect
golang.org/x/time v0.13.0 // indirect
golang.org/x/tools v0.37.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.38.0 // indirect
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
google.golang.org/api v0.235.0 // indirect
google.golang.org/api v0.242.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect
google.golang.org/grpc v1.76.0 // indirect
+18 -18
View File
@@ -1,6 +1,6 @@
cloud.google.com/go v0.121.0 h1:pgfwva8nGw7vivjZiRfrmglGWiCJBP+0OmDpenG/Fwg=
cloud.google.com/go/auth v0.16.1 h1:XrXauHMd30LhQYVRHLGvJiYeczweKQXZxsTbV9TiguU=
cloud.google.com/go/auth v0.16.1/go.mod h1:1howDHJ5IETh/LwYs3ZxvlkXF48aSqqJUM+5o02dNOI=
cloud.google.com/go/auth v0.16.3 h1:kabzoQ9/bobUmnseYnBO6qQG7q4a/CffFRlJSxv2wCc=
cloud.google.com/go/auth v0.16.3/go.mod h1:NucRGjaXfzP1ltpcQ7On/VTZ0H4kWB5Jy+Y9Dnm76fA=
cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc=
cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c=
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
@@ -126,8 +126,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4=
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo=
github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc=
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI=
github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4=
github.com/grafana/grafana-plugin-sdk-go v0.281.0 h1:V8dGyatzcOLQeivFhBV2JWMwTSZH/clDnpfKG9p3dTA=
@@ -319,20 +319,20 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 h1:TQwNpfvNkxAVlItJf6Cr5JTsVZoC/Sj7K3OZv2Pc14A=
golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -352,20 +352,20 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053 h1:dHQOQddU4YHS5gY33/6klKjq7Gp3WwMyOXGNp5nzRj8=
golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE=
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU=
golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -374,8 +374,8 @@ golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhS
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.235.0 h1:C3MkpQSRxS1Jy6AkzTGKKrpSCOd2WOGrezZ+icKSkKo=
google.golang.org/api v0.235.0/go.mod h1:QpeJkemzkFKe5VCE/PMv7GsUfn9ZF+u+q1Q7w6ckxTg=
google.golang.org/api v0.242.0 h1:7Lnb1nfnpvbkCiZek6IXKdJ0MFuAZNAJKQfA1ws62xg=
google.golang.org/api v0.242.0/go.mod h1:cOVEm2TpdAGHL2z+UwyS+kmlGr3bVWQQ6sYEqkKje50=
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090 h1:d8Nakh1G+ur7+P3GcMjpRDEkoLUcLW2iU92XVqR+XMQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 h1:CirRxTOwnRWVLKzDNrs0CXAaVozJoR4G9xvdRecrdpk=
+1
View File
@@ -30,6 +30,7 @@ func ProvideRegistryServiceSink(
_ *provisioning.APIBuilder,
_ *ofrep.APIBuilder,
_ *secret.DependencyRegisterer,
_ *provisioning.DependencyRegisterer,
) *Service {
return &Service{}
}
+15 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/datasources"
)
@@ -11,9 +12,20 @@ type datasourceInfoProvider struct {
datasourceService datasources.DataSourceService
}
func (d *datasourceInfoProvider) GetDataSourceInfo(_ context.Context) []schemaversion.DataSourceInfo {
query := datasources.GetAllDataSourcesQuery{}
dataSources, err := d.datasourceService.GetAllDataSources(context.Background(), &query)
func (d *datasourceInfoProvider) GetDataSourceInfo(ctx context.Context) []schemaversion.DataSourceInfo {
// Extract namespace info from context to get OrgID
nsInfo, err := request.NamespaceInfoFrom(ctx, true)
if err != nil {
// If namespace info is not available, return empty list
return []schemaversion.DataSourceInfo{}
}
// Use GetDataSources with OrgID query instead of GetAllDataSources
// This ensures tenant-aware datasource retrieval
query := datasources.GetDataSourcesQuery{
OrgID: nsInfo.OrgID,
}
dataSources, err := d.datasourceService.GetDataSources(ctx, &query)
if err != nil {
return []schemaversion.DataSourceInfo{}
@@ -10,6 +10,7 @@ import (
"sync"
"time"
"go.opentelemetry.io/otel"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/utils/ptr"
@@ -40,7 +41,8 @@ import (
)
var (
_ DashboardAccess = (*dashboardSqlAccess)(nil)
_ DashboardAccess = (*dashboardSqlAccess)(nil)
tracer = otel.Tracer("github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy")
)
type dashboardRow struct {
@@ -105,6 +107,9 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider,
}
func (a *dashboardSqlAccess) getRows(ctx context.Context, sql *legacysql.LegacyDatabaseHelper, query *DashboardQuery) (*rowsWrapper, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.getRows")
defer span.End()
if len(query.Labels) > 0 {
return nil, fmt.Errorf("labels not yet supported")
// if query.Requirements.Folder != nil {
@@ -416,6 +421,9 @@ func getUserID(v sql.NullString, id sql.NullInt64) string {
// DeleteDashboard implements DashboardAccess.
func (a *dashboardSqlAccess) DeleteDashboard(ctx context.Context, orgId int64, uid string) (*dashboardV1.Dashboard, bool, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.DeleteDashboard")
defer span.End()
dash, _, err := a.GetDashboard(ctx, orgId, uid, 0)
if err != nil {
return nil, false, err
@@ -432,6 +440,9 @@ func (a *dashboardSqlAccess) DeleteDashboard(ctx context.Context, orgId int64, u
}
func (a *dashboardSqlAccess) buildSaveDashboardCommand(ctx context.Context, orgId int64, dash *dashboardV1.Dashboard) (*dashboards.SaveDashboardCommand, bool, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.buildSaveDashboardCommand")
defer span.End()
created := false
user, ok := claims.AuthInfoFrom(ctx)
if !ok || user == nil {
@@ -495,6 +506,9 @@ func (a *dashboardSqlAccess) buildSaveDashboardCommand(ctx context.Context, orgI
}
func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, dash *dashboardV1.Dashboard, failOnExisting bool) (*dashboardV1.Dashboard, bool, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.SaveDashboard")
defer span.End()
user, ok := claims.AuthInfoFrom(ctx)
if !ok || user == nil {
return nil, false, fmt.Errorf("no user found in context")
@@ -565,6 +579,9 @@ type panel struct {
}
func (a *dashboardSqlAccess) GetLibraryPanels(ctx context.Context, query LibraryPanelQuery) (*dashboardV0.LibraryPanelList, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.GetLibraryPanels")
defer span.End()
limit := int(query.Limit)
query.Limit += 1 // for continue
if query.OrgID == 0 {
@@ -78,6 +78,9 @@ func isDashboardKey(key *resourcepb.ResourceKey, requireName bool) error {
}
func (a *dashboardSqlAccess) WriteEvent(ctx context.Context, event resource.WriteEvent) (rv int64, err error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.WriteEvent")
defer span.End()
info, err := claims.ParseNamespace(event.Key.Namespace)
if err == nil {
err = isDashboardKey(event.Key, true)
@@ -170,6 +173,9 @@ func (a *dashboardSqlAccess) WriteEvent(ctx context.Context, event resource.Writ
}
func (a *dashboardSqlAccess) GetDashboard(ctx context.Context, orgId int64, uid string, v int64) (*dashboard.Dashboard, int64, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.GetDashboard")
defer span.End()
sql, err := a.sql(ctx)
if err != nil {
return nil, 0, err
@@ -197,6 +203,9 @@ func (a *dashboardSqlAccess) GetDashboard(ctx context.Context, orgId int64, uid
// Read implements ResourceStoreServer.
func (a *dashboardSqlAccess) ReadResource(ctx context.Context, req *resourcepb.ReadRequest) *resource.BackendReadResponse {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.ReadResource")
defer span.End()
rsp := &resource.BackendReadResponse{}
info, err := claims.ParseNamespace(req.Key.Namespace)
if err == nil {
@@ -238,16 +247,25 @@ func (a *dashboardSqlAccess) ReadResource(ctx context.Context, req *resourcepb.R
// ListHistory implements StorageBackend.
func (a *dashboardSqlAccess) ListHistory(ctx context.Context, req *resourcepb.ListRequest, cb func(resource.ListIterator) error) (int64, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.ListHistory")
defer span.End()
return a.ListIterator(ctx, req, cb)
}
func (a *dashboardSqlAccess) ListModifiedSince(ctx context.Context, key resource.NamespacedResource, sinceRv int64) (int64, iter.Seq2[*resource.ModifiedResource, error]) {
_, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.ListModifiedSince")
defer span.End()
return 0, func(yield func(*resource.ModifiedResource, error) bool) {
yield(nil, errors.New("not implemented"))
}
}
func (a *dashboardSqlAccess) GetResourceLastImportTimes(ctx context.Context) iter.Seq2[resource.ResourceLastImportTime, error] {
_, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.GetResourceLastImportTimes")
defer span.End()
return func(yield func(resource.ResourceLastImportTime, error) bool) {
yield(resource.ResourceLastImportTime{}, errors.New("not implemented"))
}
@@ -255,6 +273,9 @@ func (a *dashboardSqlAccess) GetResourceLastImportTimes(ctx context.Context) ite
// List implements StorageBackend.
func (a *dashboardSqlAccess) ListIterator(ctx context.Context, req *resourcepb.ListRequest, cb func(resource.ListIterator) error) (int64, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.ListIterator")
defer span.End()
if req.ResourceVersion != 0 {
return 0, apierrors.NewBadRequest("List with explicit resourceVersion is not supported with this storage backend")
}
@@ -348,10 +369,16 @@ func (a *dashboardSqlAccess) WatchWriteEvents(ctx context.Context) (<-chan *reso
// Simple wrapper for index implementation
func (a *dashboardSqlAccess) Read(ctx context.Context, req *resourcepb.ReadRequest) (*resource.BackendReadResponse, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.Read")
defer span.End()
return a.ReadResource(ctx, req), nil
}
func (a *dashboardSqlAccess) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest) (*resourcepb.ResourceSearchResponse, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.Search")
defer span.End()
return a.dashboardSearchClient.Search(ctx, req)
}
@@ -365,5 +392,8 @@ func (a *dashboardSqlAccess) CountManagedObjects(context.Context, *resourcepb.Co
// GetStats implements ResourceServer.
func (a *dashboardSqlAccess) GetStats(ctx context.Context, req *resourcepb.ResourceStatsRequest) (*resourcepb.ResourceStatsResponse, error) {
ctx, span := tracer.Start(ctx, "legacy.dashboardSqlAccess.GetStats")
defer span.End()
return a.dashboardSearchClient.GetStats(ctx, req)
}
@@ -8,6 +8,7 @@ import (
"strings"
"google.golang.org/grpc"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/selection"
claims "github.com/grafana/authlib/types"
@@ -62,7 +63,7 @@ func ParseSortName(sortName string) (string, bool, error) {
}
}
return "", false, fmt.Errorf("no matching sort field found for: %s", sortName)
return "", false, apierrors.NewBadRequest(fmt.Sprintf("no matching sort field found for: %s", sortName))
}
// nolint:gocyclo
@@ -97,11 +98,11 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso
case folders.RESOURCE:
queryType = searchstore.TypeFolder
default:
return nil, fmt.Errorf("bad type request")
return nil, apierrors.NewBadRequest("bad type request")
}
if len(req.Federated) > 1 {
return nil, fmt.Errorf("bad type request")
return nil, apierrors.NewBadRequest("bad type request")
}
if len(req.Federated) == 1 &&
@@ -117,7 +118,7 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso
sortByField := ""
if len(req.SortBy) != 0 {
if len(req.SortBy) > 1 {
return nil, fmt.Errorf("only one sort field is supported")
return nil, apierrors.NewBadRequest("only one sort field is supported")
}
sort := req.SortBy[0]
sortByField = strings.TrimPrefix(sort.Field, resource.SEARCH_FIELD_PREFIX)
@@ -208,13 +209,13 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso
case resource.SEARCH_FIELD_SOURCE_PATH:
// only one value is supported in legacy search
if len(vals) != 1 {
return nil, fmt.Errorf("only one repo path query is supported")
return nil, apierrors.NewBadRequest("only one repo path query is supported")
}
query.SourcePath = vals[0]
case resource.SEARCH_FIELD_MANAGER_KIND:
if len(vals) != 1 {
return nil, fmt.Errorf("only one manager kind supported")
return nil, apierrors.NewBadRequest("only one manager kind supported")
}
query.ManagedBy = utils.ManagerKind(vals[0])
@@ -226,18 +227,38 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso
// only one value is supported in legacy search
if len(vals) != 1 {
return nil, fmt.Errorf("only one repo name is supported")
return nil, apierrors.NewBadRequest("only one repo name is supported")
}
query.ManagerIdentity = vals[0]
case unisearch.DASHBOARD_LIBRARY_PANEL_REFERENCE:
if len(vals) != 1 {
return nil, fmt.Errorf("only one library panel uid is supported")
return nil, apierrors.NewBadRequest("only one library panel uid is supported")
}
// Make sure the query does not include incompatible combinations
for _, f := range req.Options.Fields {
switch f.Key {
case resource.SEARCH_FIELD_NAME:
return nil, apierrors.NewBadRequest("libraryPanel query must not include explicit names")
}
}
query.DashboardUIDs, err = c.getLibraryPanelConnections(ctx, user, vals[0])
if err != nil {
return nil, err
}
if len(query.DashboardUIDs) == 0 {
// Empty results
return &resourcepb.ResourceSearchResponse{
TotalHits: 0,
Results: &resourcepb.ResourceTable{},
}, nil
}
return c.getLibraryPanelConnections(ctx, user, vals[0], req.Options.Key.Namespace)
case resource.SEARCH_FIELD_TITLE_PHRASE:
if len(vals) != 1 {
return nil, fmt.Errorf("only one title supported")
return nil, apierrors.NewBadRequest("only one title supported")
}
query.Title = vals[0]
@@ -256,7 +277,7 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso
// legacy sql query, since legacy search does not support this
if query.ManagerIdentity != "" || len(query.ManagerIdentityNotIn) > 0 || query.ManagedBy != "" {
if query.ManagedBy == utils.ManagerKindUnknown {
return nil, fmt.Errorf("query by manager identity also requires manager.kind parameter")
return nil, apierrors.NewBadRequest("query by manager identity also requires manager.kind parameter")
}
// for plugin and orphaned dashboards, we will only return the manager kind alongside the regular search response
@@ -312,7 +333,7 @@ func (c *DashboardSearchClient) Search(ctx context.Context, req *resourcepb.Reso
for _, dashboard := range provisioningData {
list.Results.Rows = append(list.Results.Rows, &resourcepb.ResourceTableRow{
Key: getResourceKey(&dashboards.DashboardSearchProjection{
UID: dashboard.Dashboard.UID,
UID: dashboard.UID,
}, req.Options.Key.Namespace),
Cells: c.createDetailedProvisioningCells(dashboard, query),
})
@@ -362,50 +383,36 @@ func getResourceKey(item *dashboards.DashboardSearchProjection, namespace string
}
}
// retrieves all the dashboards that are connected to the given library panel
func (c *DashboardSearchClient) getLibraryPanelConnections(ctx context.Context, user identity.Requester, libraryElementUID, namespace string) (*resourcepb.ResourceSearchResponse, error) {
// retrieves all dashboard UIDs connected to a given library panel
func (c *DashboardSearchClient) getLibraryPanelConnections(ctx context.Context, user identity.Requester, libraryElementUID string) ([]string, error) {
connections, err := c.dashboardStore.GetDashboardsByLibraryPanelUID(ctx, libraryElementUID, user.GetOrgID())
if err != nil {
return nil, err
}
columns := c.getColumns("", &dashboards.FindPersistedDashboardsQuery{})
list := &resourcepb.ResourceSearchResponse{
Results: &resourcepb.ResourceTable{
Columns: columns,
},
uids := make([]string, len(connections))
for i, dashboard := range connections {
uids[i] = dashboard.UID
}
for _, dashboard := range connections {
cells := c.createCommonCells("", dashboard.FolderUID, dashboard.ID, nil) // nolint:staticcheck
list.Results.Rows = append(list.Results.Rows, &resourcepb.ResourceTableRow{
Key: getResourceKey(&dashboards.DashboardSearchProjection{
UID: dashboard.UID,
}, namespace),
Cells: cells,
})
}
list.TotalHits = int64(len(list.Results.Rows))
return list, nil
return uids, nil
}
func (c *DashboardSearchClient) GetStats(ctx context.Context, req *resourcepb.ResourceStatsRequest, _ ...grpc.CallOption) (*resourcepb.ResourceStatsResponse, error) {
info, err := claims.ParseNamespace(req.Namespace)
if err != nil {
return nil, fmt.Errorf("unable to read namespace")
return nil, apierrors.NewInternalError(fmt.Errorf("unable to read namespace: %w", err))
}
if info.OrgID == 0 {
return nil, fmt.Errorf("invalid OrgID found in namespace")
return nil, apierrors.NewInternalError(fmt.Errorf("invalid OrgID found in namespace"))
}
if len(req.Kinds) != 1 {
return nil, fmt.Errorf("only can query for dashboard kind in legacy fallback")
return nil, apierrors.NewBadRequest("only can query for dashboard kind in legacy fallback")
}
parts := strings.SplitN(req.Kinds[0], "/", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid kind")
return nil, apierrors.NewBadRequest("invalid kind")
}
var count int64
@@ -415,7 +422,7 @@ func (c *DashboardSearchClient) GetStats(ctx context.Context, req *resourcepb.Re
case folders.GROUP:
count, err = c.dashboardStore.CountInOrg(ctx, info.OrgID, true)
default:
return nil, fmt.Errorf("invalid group")
return nil, apierrors.NewBadRequest("invalid group")
}
if err != nil {
return nil, err
@@ -512,7 +519,7 @@ func (c *DashboardSearchClient) createProvisioningCells(dashboard *dashboards.Da
}
func (c *DashboardSearchClient) createDetailedProvisioningCells(dashboard *dashboards.DashboardProvisioningSearchResults, query *dashboards.FindPersistedDashboardsQuery) [][]byte {
cells := c.createCommonCells(dashboard.Dashboard.Title, dashboard.Dashboard.FolderUID, dashboard.Dashboard.ID, []byte("[]"))
cells := c.createCommonCells(dashboard.Title, dashboard.FolderUID, dashboard.ID, []byte("[]"))
return append(cells,
[]byte(query.ManagedBy),
[]byte(dashboard.Provisioner),
@@ -454,7 +454,9 @@ func TestDashboardSearchClient_Search(t *testing.T) {
t.Run("Should retrieve dashboards by provisioner name through a different function", func(t *testing.T) {
mockStore.On("GetProvisionedDashboardsByName", mock.Anything, "test", mock.Anything).Return([]*dashboards.DashboardProvisioningSearchResults{
{
Dashboard: dashboards.Dashboard{UID: "uid", Title: "Test Dashboard", FolderUID: "folder1"},
UID: "uid",
Title: "Test Dashboard",
FolderUID: "folder1",
ExternalID: "test",
Provisioner: string(utils.ManagerKindClassicFP), // nolint:staticcheck
},
@@ -579,6 +581,11 @@ func TestDashboardSearchClient_Search(t *testing.T) {
{UID: "dashboard2", FolderUID: "folder2", ID: 2},
}, nil).Once()
mockStore.On("FindDashboards", mock.Anything, mock.Anything).Return([]dashboards.DashboardSearchProjection{
{UID: "dashboard1", FolderUID: "folder1", ID: 1},
{UID: "dashboard2", FolderUID: "folder2", ID: 2},
}, nil).Once()
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
@@ -638,6 +645,54 @@ func TestDashboardSearchClient_Search(t *testing.T) {
require.Contains(t, err.Error(), "only one library panel uid is supported")
require.Nil(t, resp)
})
t.Run("Should reject library panel query when combined with explicit dashboard names", func(t *testing.T) {
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
Fields: []*resourcepb.Requirement{
{
Key: unisearch.DASHBOARD_LIBRARY_PANEL_REFERENCE,
Operator: "=",
Values: []string{"test-library-panel"},
},
{
Key: resource.SEARCH_FIELD_NAME,
Operator: "=",
Values: []string{"dashboard-uid"},
},
},
},
}
resp, err := client.Search(ctx, req)
require.Error(t, err)
require.Contains(t, err.Error(), "libraryPanel query must not include explicit names")
require.Nil(t, resp)
// Note: mockStore should NOT be called since validation happens before any store calls
})
t.Run("Should return empty results when library panel has no connected dashboards", func(t *testing.T) {
mockStore.On("GetDashboardsByLibraryPanelUID", mock.Anything, "unused-library-panel", int64(2)).Return([]*dashboards.DashboardRef{}, nil).Once()
req := &resourcepb.ResourceSearchRequest{
Options: &resourcepb.ListOptions{
Key: dashboardKey,
Fields: []*resourcepb.Requirement{
{
Key: unisearch.DASHBOARD_LIBRARY_PANEL_REFERENCE,
Operator: "=",
Values: []string{"unused-library-panel"},
},
},
},
}
resp, err := client.Search(ctx, req)
require.NoError(t, err)
require.NotNil(t, resp)
require.Equal(t, int64(0), resp.TotalHits)
require.Empty(t, resp.Results.Rows)
mockStore.AssertExpectations(t)
})
}
func TestParseSortName(t *testing.T) {
+64 -8
View File
@@ -5,16 +5,17 @@ import (
"fmt"
"strconv"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
"github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"github.com/grafana/grafana/pkg/services/libraryelements"
)
var (
@@ -29,9 +30,9 @@ var (
)
type LibraryPanelStore struct {
Access legacy.DashboardAccess
ResourceInfo utils.ResourceInfo
AccessControl accesscontrol.AccessControl
Access legacy.DashboardAccess
ResourceInfo utils.ResourceInfo
service libraryelements.Service
}
func (s *LibraryPanelStore) New() runtime.Object {
@@ -57,15 +58,70 @@ func (s *LibraryPanelStore) ConvertToTable(ctx context.Context, object runtime.O
}
func (s *LibraryPanelStore) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
return nil, fmt.Errorf("method not yet implemented")
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
cmd, err := libraryelements.ToCreateLibraryElementCommand(obj)
if err != nil {
return nil, err
}
// NOTE: this includes all access control checks
out, err := s.service.CreateElement(ctx, user, *cmd)
if err != nil {
return nil, err
}
if out.UID == "" {
return nil, fmt.Errorf("created library panel has empty UID")
}
return s.Get(ctx, out.UID, &metav1.GetOptions{})
}
func (s *LibraryPanelStore) Update(ctx context.Context, name string, objInfo rest.UpdatedObjectInfo, createValidation rest.ValidateObjectFunc, updateValidation rest.ValidateObjectUpdateFunc, forceAllowCreate bool, options *metav1.UpdateOptions) (runtime.Object, bool, error) {
return nil, false, fmt.Errorf("method not yet implemented")
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, false, err
}
old, err := s.Get(ctx, name, &metav1.GetOptions{})
if err != nil {
return nil, false, err
}
// NOTE: this includes all access control checks
obj, err := objInfo.UpdatedObject(ctx, old)
if err != nil {
return nil, false, err
}
cmd, err := libraryelements.ToPatchLibraryElementCommand(obj)
if err != nil {
return nil, false, err
}
out, err := s.service.PatchLibraryElement(ctx, user, *cmd, name)
if err != nil {
return nil, false, err
}
if out.UID == "" {
return nil, false, fmt.Errorf("created library panel has empty UID")
}
obj, err = s.Get(ctx, out.UID, &metav1.GetOptions{})
return obj, false, err
}
func (s *LibraryPanelStore) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) {
return nil, false, fmt.Errorf("method not yet implemented")
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, false, err
}
// NOTE: this includes all access control checks
_, err = s.service.DeleteLibraryElement(ctx, user, name)
if err != nil {
return nil, false, err
}
return nil, true, nil
}
func (s *LibraryPanelStore) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
+13
View File
@@ -26,6 +26,19 @@ func (b *DashboardsAPIBuilder) Mutate(ctx context.Context, a admission.Attribute
if op != admission.Create && op != admission.Update {
return nil
}
switch a.GetResource().Resource {
case dashboardV0.DASHBOARD_RESOURCE:
return b.mutateDashboard(ctx, a)
case dashboardV0.LIBRARY_PANEL_RESOURCE:
return nil // nothing needed
}
return fmt.Errorf("unexpected resource: %+v", a.GetResource())
}
func (b *DashboardsAPIBuilder) mutateDashboard(ctx context.Context, a admission.Attributes) (err error) {
var internalID int64
obj := a.GetObject()
meta, err := utils.MetaAccessor(obj)
+2 -3
View File
@@ -7,7 +7,6 @@ import (
"github.com/stretchr/testify/require"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
dashv0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1"
@@ -180,10 +179,10 @@ func TestDashboardAPIBuilder_Mutate(t *testing.T) {
err := b.Mutate(context.Background(), admission.NewAttributesRecord(
tt.inputObj,
nil,
schema.GroupVersionKind{},
dashv1.DashboardResourceInfo.GroupVersionKind(),
"",
"test",
schema.GroupVersionResource{},
dashv1.DashboardResourceInfo.GroupVersionResource(),
"",
tt.operation,
operationOptions,
+39 -18
View File
@@ -50,8 +50,10 @@ import (
dashsvc "github.com/grafana/grafana/pkg/services/dashboards/service"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/libraryelements"
"github.com/grafana/grafana/pkg/services/librarypanels"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/publicdashboards"
"github.com/grafana/grafana/pkg/services/quota"
"github.com/grafana/grafana/pkg/services/search/sort"
"github.com/grafana/grafana/pkg/services/user"
@@ -68,6 +70,8 @@ var (
_ builder.APIGroupVersionsProvider = (*DashboardsAPIBuilder)(nil)
_ builder.OpenAPIPostProcessor = (*DashboardsAPIBuilder)(nil)
_ builder.APIGroupRouteProvider = (*DashboardsAPIBuilder)(nil)
_ builder.APIGroupMutation = (*DashboardsAPIBuilder)(nil)
_ builder.APIGroupValidation = (*DashboardsAPIBuilder)(nil)
)
const (
@@ -108,6 +112,8 @@ type DashboardsAPIBuilder struct {
minRefreshInterval string
dualWriter dualwrite.Service
folderClientProvider client.K8sHandlerProvider
libraryPanels libraryelements.Service // for legacy library panels
publicDashboardService publicdashboards.Service
isStandalone bool // skips any handling including anything to do with legacy storage
}
@@ -135,6 +141,8 @@ func RegisterAPIService(
libraryPanelSvc librarypanels.Service,
restConfigProvider apiserver.RestConfigProvider,
userService user.Service,
libraryPanels libraryelements.Service,
publicDashboardService publicdashboards.Service,
) *DashboardsAPIBuilder {
dbp := legacysql.NewDatabaseProvider(sql)
namespacer := request.GetNamespaceMapper(cfg)
@@ -157,6 +165,8 @@ func RegisterAPIService(
minRefreshInterval: cfg.MinRefreshInterval,
dualWriter: dual,
folderClientProvider: newSimpleFolderClientProvider(folderClient),
libraryPanels: libraryPanels,
publicDashboardService: publicDashboardService,
legacy: &DashboardStorage{
Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, dashboardPermissionsSvc, accessControl, features),
@@ -221,7 +231,7 @@ func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
}
// Register the explicit conversions
if err := conversion.RegisterConversions(scheme); err != nil {
if err := conversion.RegisterConversions(scheme, migration.GetDataSourceInfoProvider()); err != nil {
return err
}
@@ -229,26 +239,35 @@ func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
}
func (b *DashboardsAPIBuilder) AllowedV0Alpha1Resources() []string {
return []string{dashv0.DashboardKind().Plural()}
return []string{
dashv0.DashboardKind().Plural(),
dashv0.LIBRARY_PANEL_RESOURCE,
}
}
// Validate validates dashboard operations for the apiserver
func (b *DashboardsAPIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) {
op := a.GetOperation()
// Handle different operations
switch op {
case admission.Delete:
return b.validateDelete(ctx, a)
case admission.Create:
return b.validateCreate(ctx, a, o)
case admission.Update:
return b.validateUpdate(ctx, a, o)
case admission.Connect:
return nil
switch a.GetResource().Resource {
case dashv0.DASHBOARD_RESOURCE:
// Handle different operations
switch op {
case admission.Delete:
return b.validateDelete(ctx, a)
case admission.Create:
return b.validateCreate(ctx, a, o)
case admission.Update:
return b.validateUpdate(ctx, a, o)
case admission.Connect:
return nil
}
case dashv0.LIBRARY_PANEL_RESOURCE:
return nil // OK for now
}
return nil
return fmt.Errorf("unsupported validation: %+v", a.GetResource())
}
// validateDelete checks if a dashboard can be deleted
@@ -637,17 +656,19 @@ func (b *DashboardsAPIBuilder) storageForVersion(
b.accessControl,
opts.Scheme,
newDTOFunc,
b.publicDashboardService,
)
if err != nil {
return err
}
// Expose read only library panels
if libraryPanels != nil {
// Expose read library panels
//nolint:staticcheck // not yet migrated to OpenFeature
if libraryPanels != nil && b.features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
legacyLibraryStore := &LibraryPanelStore{
Access: b.legacy.Access,
ResourceInfo: *libraryPanels,
AccessControl: b.accessControl,
Access: b.legacy.Access,
ResourceInfo: *libraryPanels,
service: b.libraryPanels,
}
unifiedLibraryStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, *libraryPanels, opts.OptsGetter)
+18
View File
@@ -121,6 +121,15 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *
Schema: spec.ArrayProperty(spec.StringProperty()),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "libraryPanel",
In: "query",
Description: "find dashboards that reference a given libraryPanel",
Required: false,
Schema: spec.StringProperty(),
},
},
{
ParameterProps: spec3.ParameterProps{
Name: "sort",
@@ -363,6 +372,15 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) {
}}
}
// The libraryPanel filter
if libraryPanel, ok := queryParams["libraryPanel"]; ok {
searchRequest.Options.Fields = []*resourcepb.Requirement{{
Key: search.DASHBOARD_LIBRARY_PANEL_REFERENCE,
Operator: "=",
Values: libraryPanel,
}}
}
// The names filter
names := queryParams["name"]
+23 -14
View File
@@ -19,6 +19,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/publicdashboards"
"github.com/grafana/grafana/pkg/storage/unified/apistore"
"github.com/grafana/grafana/pkg/storage/unified/resource"
"github.com/grafana/grafana/pkg/storage/unified/resourcepb"
@@ -28,13 +29,14 @@ type dtoBuilder = func(dashboard runtime.Object, access *dashboard.DashboardAcce
// The DTO returns everything the UI needs in a single request
type DTOConnector struct {
getter rest.Getter
legacy legacy.DashboardAccess
unified resource.ResourceClient
largeObjects apistore.LargeObjectSupport
accessControl accesscontrol.AccessControl
scheme *runtime.Scheme
builder dtoBuilder
getter rest.Getter
legacy legacy.DashboardAccess
unified resource.ResourceClient
largeObjects apistore.LargeObjectSupport
accessControl accesscontrol.AccessControl
scheme *runtime.Scheme
builder dtoBuilder
publicDashboardService publicdashboards.Service
}
func NewDTOConnector(
@@ -45,15 +47,17 @@ func NewDTOConnector(
accessControl accesscontrol.AccessControl,
scheme *runtime.Scheme,
builder dtoBuilder,
publicDashboardService publicdashboards.Service,
) (rest.Storage, error) {
return &DTOConnector{
getter: getter,
legacy: legacyAccess,
accessControl: accessControl,
unified: resourceClient,
largeObjects: largeObjects,
builder: builder,
scheme: scheme,
getter: getter,
legacy: legacyAccess,
accessControl: accessControl,
unified: resourceClient,
largeObjects: largeObjects,
builder: builder,
scheme: scheme,
publicDashboardService: publicDashboardService,
}, nil
}
@@ -154,6 +158,11 @@ func (r *DTOConnector) Connect(ctx context.Context, name string, opts runtime.Ob
access.Slug = slugify.Slugify(title)
access.Url = dashboards.GetDashboardFolderURL(false, name, access.Slug)
pubDash, err := r.publicDashboardService.FindByDashboardUid(ctx, user.GetOrgID(), name)
if err == nil && pubDash != nil {
access.IsPublic = true
}
dash, err := r.builder(rawobj, access)
if err != nil {
responder.Error(err)
+31 -51
View File
@@ -3,10 +3,8 @@ package datasource
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"path/filepath"
"github.com/prometheus/client_golang/prometheus"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
@@ -16,14 +14,12 @@ import (
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
openapi "k8s.io/kube-openapi/pkg/common"
"k8s.io/utils/strings/slices"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/apimachinery/utils"
datasourceV0 "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic"
"github.com/grafana/grafana/pkg/configprovider"
"github.com/grafana/grafana/pkg/plugins"
"github.com/grafana/grafana/pkg/plugins/manager/sources"
"github.com/grafana/grafana/pkg/promlib/models"
@@ -31,7 +27,6 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/builder"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tsdb/grafana-testdata-datasource/kinds"
)
@@ -53,7 +48,6 @@ type DataSourceAPIBuilder struct {
}
func RegisterAPIService(
cfgProvider configprovider.ConfigProvider,
features featuremgmt.FeatureToggles,
apiRegistrar builder.APIRegistrar,
pluginClient plugins.Client, // access to everything
@@ -61,54 +55,29 @@ func RegisterAPIService(
contextProvider PluginContextWrapper,
accessControl accesscontrol.AccessControl,
reg prometheus.Registerer,
pluginSources sources.Registry,
) (*DataSourceAPIBuilder, error) {
// We want to expose just a limited set of plugins
//nolint:staticcheck // not yet migrated to OpenFeature
explicitPluginList := features.IsEnabledGlobally(featuremgmt.FlagDatasourceAPIServers)
// This requires devmode!
//nolint:staticcheck // not yet migrated to OpenFeature
if !explicitPluginList && !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
return nil, nil // skip registration unless opting into experimental apis
if !features.IsEnabledGlobally(featuremgmt.FlagQueryServiceWithConnections) && !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) {
return nil, nil
}
var err error
var builder *DataSourceAPIBuilder
cfg, err := cfgProvider.Get(context.Background())
pluginJSONs, err := getDatasourcePlugins(pluginSources)
if err != nil {
return nil, err
}
pluginJSONs, err := getCorePlugins(cfg)
if err != nil {
return nil, err
}
ids := []string{
"grafana-testdata-datasource",
"prometheus",
"graphite",
return nil, fmt.Errorf("error getting list of datasource plugins: %s", err)
}
for _, pluginJSON := range pluginJSONs {
if explicitPluginList && !slices.Contains(ids, pluginJSON.ID) {
continue // skip this one
}
if !pluginJSON.Backend {
continue // skip frontend only plugins
}
if pluginJSON.Type != plugins.TypeDataSource {
continue // skip non-datasource plugins
}
client, ok := pluginClient.(PluginClient)
if !ok {
return nil, fmt.Errorf("plugin client is not a PluginClient: %T", pluginClient)
}
builder, err = NewDataSourceAPIBuilder(pluginJSON,
builder, err = NewDataSourceAPIBuilder(
pluginJSON,
client,
datasources.GetDatasourceProvider(pluginJSON),
contextProvider,
@@ -299,21 +268,32 @@ func (b *DataSourceAPIBuilder) GetOpenAPIDefinitions() openapi.GetOpenAPIDefinit
}
}
func getCorePlugins(cfg *setting.Cfg) ([]plugins.JSONData, error) {
coreDataSourcesPath := filepath.Join(cfg.StaticRootPath, "app", "plugins", "datasource")
coreDataSourcesSrc := sources.NewLocalSource(
plugins.ClassCore,
[]string{coreDataSourcesPath},
)
func getDatasourcePlugins(pluginSources sources.Registry) ([]plugins.JSONData, error) {
var pluginJSONs []plugins.JSONData
res, err := coreDataSourcesSrc.Discover(context.Background())
if err != nil {
return nil, errors.New("failed to load core data source plugins")
}
// It's possible that the same plugin will be found in different sources.
// Registering the same plugin twice in the API is Probably A Bad Thing,
// so this map keeps track of uniques, so we can skip duplicates.
var uniquePlugins = map[string]bool{}
pluginJSONs := make([]plugins.JSONData, 0, len(res))
for _, p := range res {
pluginJSONs = append(pluginJSONs, p.Primary.JSONData)
for _, pluginSource := range pluginSources.List(context.Background()) {
res, err := pluginSource.Discover(context.Background())
if err != nil {
return nil, err
}
for _, p := range res {
if !p.Primary.JSONData.Backend || p.Primary.JSONData.Type != plugins.TypeDataSource {
continue
}
if _, found := uniquePlugins[p.Primary.JSONData.ID]; found {
backend.Logger.Info("Found duplicate plugin %s when registering API groups.", p.Primary.JSONData.ID)
continue
}
uniquePlugins[p.Primary.JSONData.ID] = true
pluginJSONs = append(pluginJSONs, p.Primary.JSONData)
}
}
return pluginJSONs, nil
}
@@ -10,6 +10,8 @@ import (
"k8s.io/apiserver/pkg/registry/rest"
datasource "github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)
type subHealthREST struct {
@@ -44,7 +46,19 @@ func (r *subHealthREST) NewConnectOptions() (runtime.Object, bool, string) {
return nil, false, ""
}
// FIXME: this endpoint has not been tested yet, so it is not enabled by default.
var healthEnabled = false
func (r *subHealthREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
if !healthEnabled {
return nil, &apierrors.StatusError{
ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusNotImplemented,
},
}
}
pluginCtx, err := r.builder.getPluginContext(ctx, name)
if err != nil {
return nil, err
+1 -2
View File
@@ -13,7 +13,6 @@ import (
data "github.com/grafana/grafana-plugin-sdk-go/experimental/apis/data/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/errutil"
query "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
query_headers "github.com/grafana/grafana/pkg/registry/apis/query"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/web"
@@ -91,7 +90,7 @@ func (r *subQueryREST) Connect(ctx context.Context, name string, opts runtime.Ob
rsp, err := r.builder.client.QueryData(ctx, &backend.QueryDataRequest{
Queries: queries,
PluginContext: pluginCtx,
Headers: query_headers.ExtractKnownHeaders(req.Header),
Headers: map[string]string{},
})
// all errors get converted into k8 errors when sent in responder.Error and lose important context like downstream info
@@ -4,8 +4,6 @@ import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
@@ -16,53 +14,8 @@ import (
"github.com/grafana/grafana/pkg/apis/datasource/v0alpha1"
queryV0 "github.com/grafana/grafana/pkg/apis/query/v0alpha1"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
func TestSubQueryConnect(t *testing.T) {
sqr := subQueryREST{
builder: &DataSourceAPIBuilder{
client: mockClient{
lastCalledWithHeaders: &map[string]string{},
},
datasources: mockDatasources{},
contextProvider: mockContextProvider{},
},
}
mr := mockResponder{}
handler, err := sqr.Connect(context.Background(), "dsname", nil, mr)
require.NoError(t, err)
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/some-path", nil)
req.Header.Set(models.FromAlertHeaderName, "true")
req.Header.Set(models.CacheSkipHeaderName, "true")
req.Header.Set("X-Rule-Name", "name-1")
req.Header.Set("X-Rule-Uid", "abc")
req.Header.Set("X-Rule-Folder", "folder-1")
req.Header.Set("X-Rule-Source", "grafana-ruler")
req.Header.Set("X-Rule-Type", "type-1")
req.Header.Set("X-Rule-Version", "version-1")
req.Header.Set("X-Grafana-Org-Id", "1")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("some-unexpected-header", "some-value")
handler.ServeHTTP(rr, req)
// test that headers are forwarded and cased appropriately
require.Equal(t, map[string]string{
models.FromAlertHeaderName: "true",
models.CacheSkipHeaderName: "true",
"X-Rule-Name": "name-1",
"X-Rule-Uid": "abc",
"X-Rule-Folder": "folder-1",
"X-Rule-Source": "grafana-ruler",
"X-Rule-Type": "type-1",
"X-Rule-Version": "version-1",
"X-Grafana-Org-Id": "1",
}, *sqr.builder.client.(mockClient).lastCalledWithHeaders)
}
func TestSubQueryConnectWhenDatasourceNotFound(t *testing.T) {
sqr := subQueryREST{
builder: &DataSourceAPIBuilder{
@@ -8,6 +8,7 @@ import (
"net/url"
"strings"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/rest"
@@ -46,7 +47,21 @@ func (r *subResourceREST) NewConnectOptions() (runtime.Object, bool, string) {
return nil, true, ""
}
// FIXME: this endpoint has not been tested yet, so it is not enabled by default.
// It is especially important to make sure the `ClearAuthHeadersMiddleware` is active,
// when using this endpoint.
var resourceEnabled = false
func (r *subResourceREST) Connect(ctx context.Context, name string, opts runtime.Object, responder rest.Responder) (http.Handler, error) {
if !resourceEnabled {
return nil, &apierrors.StatusError{
ErrStatus: metav1.Status{
Status: metav1.StatusFailure,
Code: http.StatusNotImplemented,
},
}
}
pluginCtx, err := r.builder.getPluginContext(ctx, name)
if err != nil {
return nil, err
+75 -7
View File
@@ -183,17 +183,85 @@ func TestFolderAPIBuilder_Validate_Create(t *testing.T) {
func TestFolderAPIBuilder_Validate_Delete(t *testing.T) {
tests := []struct {
name string
statsResponse *resourcepb.ResourceStatsResponse_Stats
statsResponse []*resourcepb.ResourceStatsResponse_Stats
wantErr bool
}{
{
name: "should allow deletion when folder is empty",
statsResponse: &resourcepb.ResourceStatsResponse_Stats{Count: 0},
name: "should allow deletion when folder is empty",
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
{Count: 0, Resource: "dashboards"},
},
},
{
name: "should return folder not empty when the folder is not empty",
statsResponse: &resourcepb.ResourceStatsResponse_Stats{Count: 2},
wantErr: true,
name: "should return folder not empty when folder contains dashboards",
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
{Count: 2, Resource: "dashboards", Group: "dashboard.grafana.app"},
},
wantErr: true,
},
{
name: "should return folder not empty when folder contains alertrules",
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
{Count: 3, Resource: "alertrules", Group: "alerting.grafana.app"},
},
wantErr: true,
},
{
name: "should return folder not empty when folder contains library_elements",
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
{Count: 1, Resource: "library_elements", Group: "library.grafana.app"},
},
wantErr: true,
},
{
name: "should return folder not empty when folder contains folders",
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
{Count: 2, Resource: "folders", Group: "folders.grafana.app"},
},
wantErr: true,
},
{
name: "should return folder not empty when folder has mixed resources with validated types",
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
{Count: 10, Resource: "folders", Group: "folders.grafana.app"},
{Count: 2, Resource: "dashboards", Group: "dashboard.grafana.app"},
{Count: 5, Resource: "playlists", Group: "playlist.grafana.app"},
},
wantErr: true,
},
{
name: "should return folder not empty when folder has multiple validated resource types",
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
{Count: 1, Resource: "dashboards", Group: "dashboard.grafana.app"},
{Count: 2, Resource: "alertrules", Group: "alerting.grafana.app"},
{Count: 1, Resource: "library_elements", Group: "library.grafana.app"},
{Count: 1, Resource: "folders", Group: "folders.grafana.app"},
},
wantErr: true,
},
{
name: "should allow deletion when all validated resource types are empty",
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
{Count: 0, Resource: "dashboards", Group: "dashboard.grafana.app"},
{Count: 0, Resource: "alertrules", Group: "alerting.grafana.app"},
{Count: 0, Resource: "library_elements", Group: "library.grafana.app"},
{Count: 0, Resource: "folders", Group: "folders.grafana.app"},
{Count: 10, Resource: "playlists", Group: "playlist.grafana.app"},
},
wantErr: false,
},
{
name: "should allow deletion when folder only contains non-validated resource types",
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{
{Count: 5, Resource: "playlists", Group: "playlist.grafana.app"},
{Count: 3, Resource: "other", Group: "other.grafana.app"},
},
wantErr: false,
},
{
name: "should allow deletion when stats array is empty",
statsResponse: []*resourcepb.ResourceStatsResponse_Stats{},
wantErr: false,
},
}
@@ -212,7 +280,7 @@ func TestFolderAPIBuilder_Validate_Delete(t *testing.T) {
us := grafanarest.NewMockStorage(t)
sm := resource.NewMockResourceClient(t)
sm.On("GetStats", mock.Anything, &resourcepb.ResourceStatsRequest{Namespace: obj.Namespace, Folder: obj.Name}).Return(
&resourcepb.ResourceStatsResponse{Stats: []*resourcepb.ResourceStatsResponse_Stats{tt.statsResponse}},
&resourcepb.ResourceStatsResponse{Stats: tt.statsResponse},
nil,
).Once()
+12 -2
View File
@@ -120,6 +120,14 @@ func validateOnUpdate(ctx context.Context,
return err
}
// Check that the folder being moved is not an ancestor of the target parent.
// This prevents circular references (e.g., moving A under B when B is already under A).
for _, ancestor := range info.Items {
if ancestor.Name == obj.Name {
return fmt.Errorf("cannot move folder under its own descendant, this would create a circular reference")
}
}
// if by moving a folder we exceed the max depth, return an error
if len(info.Items) > maxDepth+1 {
return folder.ErrMaximumDepthReached.Errorf("maximum folder depth reached")
@@ -144,9 +152,11 @@ func validateOnDelete(ctx context.Context,
return fmt.Errorf("could not verify if folder is empty: %v", resp.Error)
}
allowedResourceTypes := []string{"alertrules", "dashboards", "library_elements", "folders"}
for _, v := range resp.Stats {
if v.Count > 0 {
return folder.ErrFolderNotEmpty.Errorf("folder is not empty, contains %d resources", v.Count)
if slices.Contains(allowedResourceTypes, v.Resource) && v.Count > 0 {
return folder.ErrFolderNotEmpty.Errorf("folder is not empty, contains %d %s", v.Count, v.Resource)
}
}
return nil
+174 -5
View File
@@ -264,6 +264,71 @@ func TestValidateUpdate(t *testing.T) {
maxDepth: folder.MaxNestedFolderDepth,
expectedErr: "[folder.maximum-depth-reached]",
},
{
name: "error when moving folder under its own descendant (direct child)",
folder: &folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "parent",
Annotations: map[string]string{
utils.AnnoKeyFolder: "child",
},
},
Spec: folders.FolderSpec{
Title: "parent folder",
},
},
old: &folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "parent",
},
Spec: folders.FolderSpec{
Title: "parent folder",
},
},
// When querying parents of "child", we get the chain: child -> parent -> root
// This means "parent" is an ancestor of "child", so we can't move "parent" under "child"
parents: &folders.FolderInfoList{
Items: []folders.FolderInfo{
{Name: "child", Parent: "parent"},
{Name: "parent", Parent: folder.GeneralFolderUID},
{Name: folder.GeneralFolderUID},
},
},
expectedErr: "cannot move folder under its own descendant",
},
{
name: "error when moving folder under its grandchild",
folder: &folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "grandparent",
Annotations: map[string]string{
utils.AnnoKeyFolder: "grandchild",
},
},
Spec: folders.FolderSpec{
Title: "grandparent folder",
},
},
old: &folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "grandparent",
},
Spec: folders.FolderSpec{
Title: "grandparent folder",
},
},
// When querying parents of "grandchild", we get: grandchild -> child -> grandparent -> root
// This means "grandparent" is in the ancestry, so we can't move it under "grandchild"
parents: &folders.FolderInfoList{
Items: []folders.FolderInfo{
{Name: "grandchild", Parent: "child"},
{Name: "child", Parent: "grandparent"},
{Name: "grandparent", Parent: folder.GeneralFolderUID},
{Name: folder.GeneralFolderUID},
},
},
expectedErr: "cannot move folder under its own descendant",
},
}
for _, tt := range tests {
@@ -320,7 +385,7 @@ func TestValidateDelete(t *testing.T) {
},
},
}, {
name: "stats error",
name: "stats error - nil stats",
folder: &folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "nnn",
@@ -331,7 +396,7 @@ func TestValidateDelete(t *testing.T) {
},
expectedErr: "could not verify if folder is empty",
}, {
name: "stats error",
name: "stats error - search error",
folder: &folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "nnn",
@@ -342,7 +407,7 @@ func TestValidateDelete(t *testing.T) {
},
expectedErr: "error running stats",
}, {
name: "stats error",
name: "stats error - error result",
folder: &folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "nnn",
@@ -357,7 +422,64 @@ func TestValidateDelete(t *testing.T) {
},
expectedErr: "could not verify if folder is empty",
}, {
name: "folder not empty",
name: "folder not empty - contains dashboards",
folder: &folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "nnn",
},
},
searcher: &mockSearchClient{
stats: &resourcepb.ResourceStatsResponse{
Stats: []*resourcepb.ResourceStatsResponse_Stats{
{
Group: "dashboard.grafana.app",
Resource: "dashboards",
Count: 10, // not empty
},
},
},
},
expectedErr: "[folder.not-empty]",
}, {
name: "folder not empty - contains alertrules",
folder: &folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "nnn",
},
},
searcher: &mockSearchClient{
stats: &resourcepb.ResourceStatsResponse{
Stats: []*resourcepb.ResourceStatsResponse_Stats{
{
Group: "alerting.grafana.app",
Resource: "alertrules",
Count: 5, // not empty
},
},
},
},
expectedErr: "[folder.not-empty]",
}, {
name: "folder not empty - contains library_elements",
folder: &folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "nnn",
},
},
searcher: &mockSearchClient{
stats: &resourcepb.ResourceStatsResponse{
Stats: []*resourcepb.ResourceStatsResponse_Stats{
{
Group: "library.grafana.app",
Resource: "library_elements",
Count: 3, // not empty
},
},
},
},
expectedErr: "[folder.not-empty]",
}, {
name: "folder not empty - contains folders",
folder: &folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "nnn",
@@ -369,7 +491,54 @@ func TestValidateDelete(t *testing.T) {
{
Group: "folders.grafana.app",
Resource: "folders",
Count: 10, // not empty
Count: 2, // not empty
},
},
},
},
expectedErr: "[folder.not-empty]",
}, {
name: "folder can be deleted when it only contains non-validated resource types",
folder: &folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "nnn",
},
},
searcher: &mockSearchClient{
stats: &resourcepb.ResourceStatsResponse{
Stats: []*resourcepb.ResourceStatsResponse_Stats{
{
Group: "playlist.grafana.app",
Resource: "playlists",
Count: 10, // has content but not a validated resource type
},
{
Group: "other.grafana.app",
Resource: "other",
Count: 5, // has content but not a validated resource type
},
},
},
},
}, {
name: "folder not empty - mixed resources with validated types",
folder: &folders.Folder{
ObjectMeta: metav1.ObjectMeta{
Name: "nnn",
},
},
searcher: &mockSearchClient{
stats: &resourcepb.ResourceStatsResponse{
Stats: []*resourcepb.ResourceStatsResponse_Stats{
{
Group: "folders.grafana.app",
Resource: "folders",
Count: 10, // now validated
},
{
Group: "dashboard.grafana.app",
Resource: "dashboards",
Count: 2, // validated and has content
},
},
},
+2 -1
View File
@@ -24,7 +24,6 @@ func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient auth
// Identity specific resources
legacyAuthorizer := gfauthorizer.NewResourceAuthorizer(legacyAccessClient)
resourceAuthorizer[iamv0.TeamResourceInfo.GetName()] = legacyAuthorizer
resourceAuthorizer[iamv0.TeamBindingResourceInfo.GetName()] = legacyAuthorizer
resourceAuthorizer["display"] = legacyAuthorizer
@@ -36,6 +35,8 @@ func newIAMAuthorizer(accessClient authlib.AccessClient, legacyAccessClient auth
resourceAuthorizer[iamv0.RoleBindingInfo.GetName()] = authorizer
resourceAuthorizer[iamv0.ServiceAccountResourceInfo.GetName()] = authorizer
resourceAuthorizer[iamv0.UserResourceInfo.GetName()] = authorizer
resourceAuthorizer[iamv0.ExternalGroupMappingResourceInfo.GetName()] = authorizer
resourceAuthorizer[iamv0.TeamResourceInfo.GetName()] = authorizer
return &iamAuthorizer{resourceAuthorizer: resourceAuthorizer}
}
-296
View File
@@ -1,296 +0,0 @@
package iam
import (
"context"
"errors"
"fmt"
"strings"
"time"
"google.golang.org/protobuf/types/known/structpb"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/services/accesscontrol"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
var (
errEmptyName = errors.New("name cannot be empty")
errInvalidBasicRole = errors.New("invalid basic role")
errUnknownKind = errors.New("unknown permission kind")
defaultWriteTimeout = 15 * time.Second
)
func toZanzanaSubject(kind iamv0.ResourcePermissionSpecPermissionKind, name string) (string, error) {
if name == "" {
return "", errEmptyName
}
switch kind {
case iamv0.ResourcePermissionSpecPermissionKindUser:
return zanzana.NewTupleEntry(zanzana.TypeUser, name, ""), nil
case iamv0.ResourcePermissionSpecPermissionKindServiceAccount:
return zanzana.NewTupleEntry(zanzana.TypeServiceAccount, name, ""), nil
case iamv0.ResourcePermissionSpecPermissionKindTeam:
return zanzana.NewTupleEntry(zanzana.TypeTeam, name, ""), nil
case iamv0.ResourcePermissionSpecPermissionKindBasicRole:
basicRole := zanzana.TranslateBasicRole(name)
if basicRole == "" {
return "", fmt.Errorf("%w: %s", errInvalidBasicRole, name)
}
// e.g role:basic_viewer#assignee
return zanzana.NewTupleEntry(zanzana.TypeRole, basicRole, zanzana.RelationAssignee), nil
}
// should not happen since we are after create
// validation webhook should have caught invalid kinds
return "", errUnknownKind
}
func toZanzanaType(apiGroup string) string {
if apiGroup == "folder.grafana.app" {
return zanzana.TypeFolder
}
return zanzana.TypeResource
}
func NewResourceTuple(object string, resource iamv0.ResourcePermissionspecResource, perm iamv0.ResourcePermissionspecPermission) (*v1.TupleKey, error) {
// Typ is "folder" or "resource"
typ := toZanzanaType(resource.ApiGroup)
// subject
subject, err := toZanzanaSubject(perm.Kind, perm.Name)
if err != nil {
return nil, err
}
key := &v1.TupleKey{
// e.g. "user:{uid}", "serviceaccount:{uid}", "team:{uid}", "basicrole:{viewer|editor|admin}"
User: subject,
// "view", "edit", "admin"
Relation: strings.ToLower(perm.Verb),
// e.g. "folder:{name}" or "resource:{apiGroup}/{resource}/{name}"
Object: object,
}
// For resources we add a condition to filter by apiGroup/resource
// e.g "group_filter": {"group_resource": "dashboards.grafana.app/dashboards"}
if typ == zanzana.TypeResource {
key.Condition = &v1.RelationshipCondition{
Name: "group_filter",
Context: &structpb.Struct{
Fields: map[string]*structpb.Value{
"group_resource": structpb.NewStringValue(
resource.ApiGroup + "/" + resource.Resource,
),
},
},
}
}
return key, nil
}
// AfterResourcePermissionCreate is a post-create hook that writes the resource permission to Zanzana (openFGA)
func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj runtime.Object, _ *metav1.CreateOptions) {
if b.zClient == nil {
return
}
rp, ok := obj.(*iamv0.ResourcePermission)
if !ok {
return
}
// Grab a ticket to write to Zanzana
// This limits the amount of concurrent writes to Zanzana
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.Observe(time.Since(wait).Seconds()) // Record wait time
go func(rp *iamv0.ResourcePermission) {
defer func() {
// Release the ticket after write is done
<-b.zTickets
}()
resource := rp.Spec.Resource
permissions := rp.Spec.Permissions
object := zanzana.NewObjectEntry(toZanzanaType(resource.ApiGroup), resource.ApiGroup, resource.Resource, "", resource.Name)
tuples := make([]*v1.TupleKey, 0, len(permissions))
for _, p := range permissions {
tuple, err := NewResourceTuple(object, resource, p)
if err != nil {
b.logger.Error("failed to create resource permission tuple",
"namespace", rp.Namespace,
"object", object,
"err", err,
)
continue
}
tuples = append(tuples, tuple)
}
// Avoid writing if there are no valid tuples
if len(tuples) == 0 {
b.logger.Warn("no valid tuples to write", "namespace", rp.Namespace, "resource", object)
return
}
b.logger.Debug("writing resource permission to zanzana",
"namespace", rp.Namespace,
"object", object,
"tuplesCnt", len(tuples),
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
err := b.zClient.Write(ctx, &v1.WriteRequest{
Namespace: rp.Namespace,
Writes: &v1.WriteRequestWrites{
TupleKeys: tuples,
},
})
if err != nil {
b.logger.Error("failed to write resource permission to zanzana",
"err", err,
"namespace", rp.Namespace,
"object", object,
"tuplesCnt", len(tuples),
)
}
}(rp.DeepCopy()) // Pass a copy of the object
}
// convertRolePermissionsToTuples converts role permissions (action/scope) to v1 TupleKey format
// using the shared zanzana.ConvertRolePermissionsToTuples utility and common.ToAuthzExtTupleKeys
func convertRolePermissionsToTuples(roleUID string, permissions []iamv0.CoreRolespecPermission) ([]*v1.TupleKey, error) {
// Convert IAM permissions to zanzana.RolePermission format
rolePerms := make([]zanzana.RolePermission, 0, len(permissions))
for _, perm := range permissions {
// Split the scope to get kind, attribute, identifier
kind, _, identifier := accesscontrol.SplitScope(perm.Scope)
rolePerms = append(rolePerms, zanzana.RolePermission{
Action: perm.Action,
Kind: kind,
Identifier: identifier,
})
}
// Translate to Zanzana tuples
openfgaTuples, err := zanzana.ConvertRolePermissionsToTuples(roleUID, rolePerms)
if err != nil {
return nil, err
}
// Convert directly to v1 tuples using common utility
v1Tuples := common.ToAuthzExtTupleKeys(openfgaTuples)
return v1Tuples, nil
}
// AfterRoleCreate is a post-create hook that writes the role permissions to Zanzana (openFGA)
// It handles both Role and CoreRole types
func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object, _ *metav1.CreateOptions) {
if b.zClient == nil {
return
}
// Extract permissions based on the object type
var roleUID, namespace string
var permissions []iamv0.CoreRolespecPermission
var roleType string
// Try CoreRole first
if coreRole, ok := obj.(*iamv0.CoreRole); ok {
roleUID = coreRole.Name
namespace = coreRole.Namespace
// Deep copy permissions to avoid race conditions
permissions = make([]iamv0.CoreRolespecPermission, len(coreRole.Spec.Permissions))
copy(permissions, coreRole.Spec.Permissions)
roleType = "core role"
} else if role, ok := obj.(*iamv0.Role); ok {
// Try Role
roleUID = role.Name
namespace = role.Namespace
// Convert and copy permissions to avoid race conditions
permissions = make([]iamv0.CoreRolespecPermission, len(role.Spec.Permissions))
for i, p := range role.Spec.Permissions {
permissions[i] = iamv0.CoreRolespecPermission(p)
}
roleType = "role"
} else {
// Not a supported role type
return
}
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.Observe(time.Since(wait).Seconds())
go func() {
defer func() {
<-b.zTickets
}()
tuples, err := convertRolePermissionsToTuples(roleUID, permissions)
if err != nil {
b.logger.Error("failed to convert role permissions to tuples",
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"err", err,
"permissionsCnt", len(permissions),
)
return
}
// Avoid writing if there are no valid tuples
if len(tuples) == 0 {
b.logger.Debug("no valid tuples to write for role",
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"permissionsCnt", len(permissions),
)
return
}
b.logger.Debug("writing role permissions to zanzana",
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"tuplesCnt", len(tuples),
"permissionsCnt", len(permissions),
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
err = b.zClient.Write(ctx, &v1.WriteRequest{
Namespace: namespace,
Writes: &v1.WriteRequestWrites{
TupleKeys: tuples,
},
})
if err != nil {
b.logger.Error("failed to write role permissions to zanzana",
"err", err,
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"tuplesCnt", len(tuples),
)
}
}()
}
-435
View File
@@ -1,435 +0,0 @@
package iam
import (
"context"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/infra/log"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/stretchr/testify/require"
)
type FakeZanzanaClient struct {
zanzana.Client
writeCallback func(context.Context, *v1.WriteRequest) error
}
// Write implements zanzana.Client.
func (f *FakeZanzanaClient) Write(ctx context.Context, req *v1.WriteRequest) error {
return f.writeCallback(ctx, req)
}
func requireTuplesMatch(t *testing.T, actual []*v1.TupleKey, expected []*v1.TupleKey, msgAndArgs ...interface{}) {
t.Helper()
for _, exp := range expected {
found := false
for _, act := range actual {
if act.User == exp.User &&
act.Relation == exp.Relation &&
act.Object == exp.Object {
found = true
break
}
}
if !found {
require.Fail(t, "Expected tuple not found", "Tuple: %+v\n%v", exp, msgAndArgs)
}
}
}
func TestAfterResourcePermissionCreate(t *testing.T) {
t.Run("should create zanzana entries for folder resource permissions", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
folderPerm := iamv0.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Namespace: "org-2",
},
Spec: iamv0.ResourcePermissionSpec{
Resource: iamv0.ResourcePermissionspecResource{
ApiGroup: "folder.grafana.app", Resource: "folders", Name: "fold1",
},
Permissions: []iamv0.ResourcePermissionspecPermission{
{Kind: iamv0.ResourcePermissionSpecPermissionKindUser, Name: "u1", Verb: "View"},
{Kind: iamv0.ResourcePermissionSpecPermissionKindBasicRole, Name: "Editor", Verb: "Edit"},
},
},
}
testFolderEntries := func(ctx context.Context, req *v1.WriteRequest) error {
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
require.Equal(t, "org-2", req.Namespace)
expectedTuples := []*v1.TupleKey{
{User: "user:u1", Relation: "view", Object: "folder:fold1"},
{User: "role:basic_editor#assignee", Relation: "edit", Object: "folder:fold1"},
}
requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testFolderEntries}
b.AfterResourcePermissionCreate(&folderPerm, nil)
})
t.Run("should create zanzana entries for dashboard resource permissions", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
dashPerm := iamv0.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
},
Spec: iamv0.ResourcePermissionSpec{
Resource: iamv0.ResourcePermissionspecResource{
ApiGroup: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
},
Permissions: []iamv0.ResourcePermissionspecPermission{
{Kind: iamv0.ResourcePermissionSpecPermissionKindServiceAccount, Name: "sa1", Verb: "View"},
{Kind: iamv0.ResourcePermissionSpecPermissionKindTeam, Name: "team1", Verb: "Edit"},
},
},
}
testDashEntries := func(ctx context.Context, req *v1.WriteRequest) error {
object := "resource:dashboard.grafana.app/dashboards/dash1"
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
require.Equal(t, "default", req.Namespace)
// Verify all tuples have the group_filter condition
for _, tuple := range req.Writes.TupleKeys {
require.NotNil(t, tuple.Condition, "Condition should not be nil for tuple %+v", tuple)
require.Equal(t, "group_filter", tuple.Condition.Name)
}
expectedTuples := []*v1.TupleKey{
{User: "service-account:sa1", Relation: "view", Object: object},
{User: "team:team1", Relation: "edit", Object: object},
}
requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testDashEntries}
b.AfterResourcePermissionCreate(&dashPerm, nil)
})
}
func TestAfterCoreRoleCreate(t *testing.T) {
t.Run("should create zanzana entries for core role with folder permissions", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
coreRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "test-role-uid",
Namespace: "org-1",
},
Spec: iamv0.CoreRoleSpec{
Title: "Test Role",
Description: "Test role for folders",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder1"},
{Action: "folders:write", Scope: "folders:uid:folder1"},
},
},
}
testCoreRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
require.Equal(t, "org-1", req.Namespace)
expectedTuples := []*v1.TupleKey{
{User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder1"},
{User: "role:test-role-uid#assignee", Relation: "update", Object: "folder:folder1"},
}
requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testCoreRoleEntries}
b.AfterRoleCreate(&coreRole, nil)
})
t.Run("should create zanzana entries for core role with dashboard permissions", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
coreRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "dashboard-role-uid",
Namespace: "default",
},
Spec: iamv0.CoreRoleSpec{
Title: "Dashboard Role",
Description: "Test role for dashboards",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
{Action: "dashboards:write", Scope: "dashboards:uid:dash1"},
},
},
}
testDashboardRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
require.Equal(t, "default", req.Namespace)
// Check subject is role with assignee relation
for _, tuple := range req.Writes.TupleKeys {
require.Equal(t, "role:dashboard-role-uid#assignee", tuple.User)
require.Contains(t, tuple.Object, "resource:")
require.Contains(t, tuple.Object, "dashboard")
}
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testDashboardRoleEntries}
b.AfterRoleCreate(&coreRole, nil)
})
t.Run("should handle wildcard scopes", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
coreRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "wildcard-role-uid",
Namespace: "org-2",
},
Spec: iamv0.CoreRoleSpec{
Title: "Wildcard Role",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:*"},
},
},
}
testWildcardEntries := func(ctx context.Context, req *v1.WriteRequest) error {
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 1)
tuple := req.Writes.TupleKeys[0]
require.Equal(t, "role:wildcard-role-uid#assignee", tuple.User)
// Wildcard should create a group_resource tuple
require.Contains(t, tuple.Object, "group_resource:")
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testWildcardEntries}
b.AfterRoleCreate(&coreRole, nil)
})
t.Run("should skip untranslatable permissions", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
coreRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "mixed-role-uid",
Namespace: "org-1",
},
Spec: iamv0.CoreRoleSpec{
Title: "Mixed Role",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder1"},
{Action: "unknown:action", Scope: "unknown:scope"}, // This should be skipped
},
},
}
testMixedEntries := func(ctx context.Context, req *v1.WriteRequest) error {
require.NotNil(t, req)
require.NotNil(t, req.Writes)
// Should only have 1 tuple (the untranslatable one should be skipped)
require.Len(t, req.Writes.TupleKeys, 1)
tuple := req.Writes.TupleKeys[0]
require.Equal(t, "role:mixed-role-uid#assignee", tuple.User)
require.Equal(t, "folder:folder1", tuple.Object)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testMixedEntries}
b.AfterRoleCreate(&coreRole, nil)
})
}
func TestAfterRoleCreate(t *testing.T) {
t.Run("should create zanzana entries for role with folder permissions", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
role := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "custom-role-uid",
Namespace: "org-3",
},
Spec: iamv0.RoleSpec{
Title: "Custom Role",
Description: "Custom role for folders",
Permissions: []iamv0.RolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder2"},
{Action: "folders:delete", Scope: "folders:uid:folder2"},
},
},
}
testRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
require.Equal(t, "org-3", req.Namespace)
expectedTuples := []*v1.TupleKey{
{User: "role:custom-role-uid#assignee", Relation: "get", Object: "folder:folder2"},
{User: "role:custom-role-uid#assignee", Relation: "delete", Object: "folder:folder2"},
}
requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testRoleEntries}
b.AfterRoleCreate(&role, nil)
})
t.Run("should create zanzana entries for role with dashboard permissions", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
role := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "dash-role-uid",
Namespace: "default",
},
Spec: iamv0.RoleSpec{
Title: "Dashboard Custom Role",
Description: "Custom role for dashboards",
Permissions: []iamv0.RolespecPermission{
{Action: "dashboards:read", Scope: "dashboards:uid:mydash"},
{Action: "dashboards:delete", Scope: "dashboards:uid:mydash"},
},
},
}
testDashRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
require.Equal(t, "default", req.Namespace)
// Check subject is role with assignee relation
for _, tuple := range req.Writes.TupleKeys {
require.Equal(t, "role:dash-role-uid#assignee", tuple.User)
require.Contains(t, tuple.Object, "resource:")
}
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testDashRoleEntries}
b.AfterRoleCreate(&role, nil)
})
t.Run("should merge folder resource tuples with same object and user", func(t *testing.T) {
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Cleanup(func() {
<-b.zTickets
})
role := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "merge-role-uid",
Namespace: "org-1",
},
Spec: iamv0.RoleSpec{
Title: "Merge Test Role",
Permissions: []iamv0.RolespecPermission{
// These should create folder resource tuples that get merged
{Action: "dashboards:read", Scope: "folders:uid:parent-folder"},
{Action: "dashboards:write", Scope: "folders:uid:parent-folder"},
},
},
}
testMergedEntries := func(ctx context.Context, req *v1.WriteRequest) error {
require.NotNil(t, req)
require.NotNil(t, req.Writes)
// After merging, we should have tuples for the folder resource actions
require.Greater(t, len(req.Writes.TupleKeys), 0)
for _, tuple := range req.Writes.TupleKeys {
require.Equal(t, "role:merge-role-uid#assignee", tuple.User)
}
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testMergedEntries}
b.AfterRoleCreate(&role, nil)
})
}
+16 -5
View File
@@ -2,6 +2,7 @@ package legacy
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
@@ -129,18 +130,18 @@ func (s *legacySQLStore) ListTeams(ctx context.Context, ns claims.NamespaceInfo,
return nil, fmt.Errorf("expected non zero orgID")
}
sql, err := s.sql(ctx)
sqlConn, err := s.sql(ctx)
if err != nil {
return nil, err
}
req := newListTeams(sql, &query)
req := newListTeams(sqlConn, &query)
q, err := sqltemplate.Execute(sqlQueryTeamsTemplate, req)
if err != nil {
return nil, fmt.Errorf("execute template %q: %w", sqlQueryTeamsTemplate.Name(), err)
}
rows, err := sql.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
rows, err := sqlConn.DB.GetSqlxSession().Query(ctx, q, req.GetArgs()...)
defer func() {
if rows != nil {
_ = rows.Close()
@@ -155,11 +156,21 @@ func (s *legacySQLStore) ListTeams(ctx context.Context, ns claims.NamespaceInfo,
var lastID int64
for rows.Next() {
t := team.Team{}
err = rows.Scan(&t.ID, &t.UID, &t.Name, &t.Email, &t.ExternalUID, &t.IsProvisioned, &t.Created, &t.Updated)
var externalUID sql.NullString
var isProvisioned sql.NullBool
err = rows.Scan(&t.ID, &t.UID, &t.Name, &t.Email, &externalUID, &isProvisioned, &t.Created, &t.Updated)
if err != nil {
return res, err
}
if externalUID.Valid {
t.ExternalUID = externalUID.String
}
if isProvisioned.Valid {
t.IsProvisioned = isProvisioned.Bool
}
lastID = t.ID
res.Teams = append(res.Teams, t)
if len(res.Teams) > int(query.Pagination.Limit)-1 {
@@ -170,7 +181,7 @@ func (s *legacySQLStore) ListTeams(ctx context.Context, ns claims.NamespaceInfo,
}
if query.UID == "" {
res.RV, err = sql.GetResourceVersion(ctx, "team", "updated")
res.RV, err = sqlConn.GetResourceVersion(ctx, "team", "updated")
}
return res, err
+38 -4
View File
@@ -14,19 +14,53 @@ const (
var (
registerOnce sync.Once
hooksWaitHistogram = prometheus.NewHistogram(prometheus.HistogramOpts{
hooksWaitHistogram = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: metricsNamespace,
Subsystem: metricsSubSystem,
Name: "hooks_wait_duration_seconds",
Help: "Time spent in the hooks waiting for a ticket to start processing",
Buckets: prometheus.ExponentialBuckets(0.001, 2, 5), // 1ms to ~16s
})
}, []string{"resource_type", "operation"})
// hooksDurationHistogram tracks the total duration of hook operations
hooksDurationHistogram = prometheus.NewHistogramVec(prometheus.HistogramOpts{
Namespace: metricsNamespace,
Subsystem: metricsSubSystem,
Name: "hooks_operation_duration_seconds",
Help: "Time spent executing hook operations (create, update, delete)",
Buckets: prometheus.ExponentialBuckets(0.001, 2, 10), // 1ms to ~1s
}, []string{"resource_type", "operation", "status"})
// hooksOperationCounter tracks the number of hook operations
hooksOperationCounter = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: metricsNamespace,
Subsystem: metricsSubSystem,
Name: "hooks_operations_total",
Help: "Total number of hook operations by resource type, operation, and status",
}, []string{"resource_type", "operation", "status"})
// hooksTuplesCounter tracks the number of tuples written/deleted
hooksTuplesCounter = prometheus.NewCounterVec(prometheus.CounterOpts{
Namespace: metricsNamespace,
Subsystem: metricsSubSystem,
Name: "hooks_tuples_total",
Help: "Total number of tuples written or deleted by resource type and operation type",
}, []string{"resource_type", "operation", "action"})
)
func registerMetrics(reg prometheus.Registerer) {
registerOnce.Do(func() {
if err := reg.Register(hooksWaitHistogram); err != nil {
log.New("iam.apis").Warn("failed to register iam apiserver metrics", "error", err)
metrics := []prometheus.Collector{
hooksWaitHistogram,
hooksDurationHistogram,
hooksOperationCounter,
hooksTuplesCounter,
}
for _, metric := range metrics {
if err := reg.Register(metric); err != nil {
log.New("iam.apis").Warn("failed to register iam apiserver metrics", "error", err)
}
}
})
}
+10 -5
View File
@@ -35,14 +35,19 @@ type RoleStorageBackend interface{ resource.StorageBackend }
// Used by wire to identify the storage backend for role bindings.
type RoleBindingStorageBackend interface{ resource.StorageBackend }
// ExternalGroupMappingStorageBackend uses the resource.StorageBackend interface to provide storage for external group mappings.
// Used by wire to identify the storage backend for external group mappings.
type ExternalGroupMappingStorageBackend interface{ resource.StorageBackend }
// This is used just so wire has something unique to return
type IdentityAccessManagementAPIBuilder struct {
// Stores
store legacy.LegacyIdentityStore
coreRolesStorage CoreRoleStorageBackend
rolesStorage RoleStorageBackend
resourcePermissionsStorage resource.StorageBackend
roleBindingsStorage RoleBindingStorageBackend
store legacy.LegacyIdentityStore
coreRolesStorage CoreRoleStorageBackend
rolesStorage RoleStorageBackend
resourcePermissionsStorage resource.StorageBackend
roleBindingsStorage RoleBindingStorageBackend
externalGroupMappingStorage ExternalGroupMappingStorageBackend
// Access Control
authorizer authorizer.Authorizer
+122 -34
View File
@@ -30,6 +30,7 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/registry/apis/iam/legacy"
"github.com/grafana/grafana/pkg/registry/apis/iam/noopstorage"
"github.com/grafana/grafana/pkg/registry/apis/iam/resourcepermission"
"github.com/grafana/grafana/pkg/registry/apis/iam/serviceaccount"
"github.com/grafana/grafana/pkg/registry/apis/iam/sso"
@@ -63,6 +64,7 @@ func RegisterAPIService(
coreRolesStorage CoreRoleStorageBackend,
rolesStorage RoleStorageBackend,
roleBindingsStorage RoleBindingStorageBackend,
externalGroupMappingStorageBackend ExternalGroupMappingStorageBackend,
dual dualwrite.Service,
unified resource.ResourceClient,
userService legacyuser.Service,
@@ -74,46 +76,54 @@ func RegisterAPIService(
registerMetrics(reg)
builder := &IdentityAccessManagementAPIBuilder{
store: store,
coreRolesStorage: coreRolesStorage,
rolesStorage: rolesStorage,
resourcePermissionsStorage: resourcepermission.ProvideStorageBackend(dbProvider),
roleBindingsStorage: roleBindingsStorage,
sso: ssoService,
authorizer: authorizer,
legacyAccessClient: legacyAccessClient,
accessClient: accessClient,
zClient: zClient,
zTickets: make(chan bool, MaxConcurrentZanzanaWrites),
display: user.NewLegacyDisplayREST(store),
reg: reg,
logger: log.New("iam.apis"),
features: features,
enableDualWriter: true,
dual: dual,
unified: unified,
userSearchClient: resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(), unified, user.NewUserLegacySearchClient(userService), features),
store: store,
coreRolesStorage: coreRolesStorage,
rolesStorage: rolesStorage,
resourcePermissionsStorage: resourcepermission.ProvideStorageBackend(dbProvider),
roleBindingsStorage: roleBindingsStorage,
externalGroupMappingStorage: externalGroupMappingStorageBackend,
sso: ssoService,
authorizer: authorizer,
legacyAccessClient: legacyAccessClient,
accessClient: accessClient,
zClient: zClient,
zTickets: make(chan bool, MaxConcurrentZanzanaWrites),
display: user.NewLegacyDisplayREST(store),
reg: reg,
logger: log.New("iam.apis"),
features: features,
enableDualWriter: true,
dual: dual,
unified: unified,
userSearchClient: resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(), unified, user.NewUserLegacySearchClient(userService), features),
}
apiregistration.RegisterAPI(builder)
return builder, nil
}
// TODO zClient, zTickets, reg
func NewAPIService(
accessClient types.AccessClient,
dbProvider legacysql.LegacyDatabaseProvider,
features featuremgmt.FeatureToggles,
zClient zanzana.Client,
reg prometheus.Registerer,
) *IdentityAccessManagementAPIBuilder {
store := legacy.NewLegacySQLStores(dbProvider)
resourcePermissionsStorage := resourcepermission.ProvideStorageBackend(dbProvider)
resourceAuthorizer := gfauthorizer.NewResourceAuthorizer(accessClient)
noopStorage := noopstorage.ProvideStorageBackend()
registerMetrics(reg)
return &IdentityAccessManagementAPIBuilder{
store: store,
display: user.NewLegacyDisplayREST(store),
resourcePermissionsStorage: resourcePermissionsStorage,
logger: log.New("iam.apis"),
features: features,
store: store,
display: user.NewLegacyDisplayREST(store),
resourcePermissionsStorage: resourcePermissionsStorage,
externalGroupMappingStorage: noopStorage,
logger: log.New("iam.apis"),
features: features,
zClient: zClient,
zTickets: make(chan bool, MaxConcurrentZanzanaWrites),
reg: reg,
authorizer: authorizer.AuthorizerFunc(
func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) {
// For now only authorize resourcepermissions resource
@@ -219,6 +229,14 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
return err
}
// Only teamBindingStore exposes the AfterCreate, AfterDelete, and BeginUpdate hooks
if enableZanzanaSync {
b.logger.Info("Enabling hooks for TeamBinding to sync to Zanzana")
teamBindingStore.AfterCreate = b.AfterTeamBindingCreate
teamBindingStore.AfterDelete = b.AfterTeamBindingDelete
teamBindingStore.BeginUpdate = b.BeginTeamBindingUpdate
}
storage[teamBindingResource.StoragePath()] = teamBindingDW
}
@@ -233,6 +251,13 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
return err
}
if enableZanzanaSync {
b.logger.Info("Enabling hooks for User to sync basic role assignments to Zanzana")
store.AfterCreate = b.AfterUserCreate
store.BeginUpdate = b.BeginUserUpdate
store.AfterDelete = b.AfterUserDelete
}
dw, err := opts.DualWriteBuilder(userResource.GroupResource(), legacyStore, store)
if err != nil {
return err
@@ -269,6 +294,27 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
storage[ssoResource.StoragePath()] = sso.NewLegacyStore(b.sso)
}
externalGroupMappingResource := iamv0.ExternalGroupMappingResourceInfo
externalGroupMappingLegacyStore, err := NewLocalStore(externalGroupMappingResource, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.externalGroupMappingStorage)
if err != nil {
return err
}
storage[externalGroupMappingResource.StoragePath()] = externalGroupMappingLegacyStore
if b.enableDualWriter {
externalGroupMappingStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, externalGroupMappingResource, opts.OptsGetter)
if err != nil {
return err
}
externalGroupMappingDW, err := opts.DualWriteBuilder(externalGroupMappingResource.GroupResource(), externalGroupMappingLegacyStore, externalGroupMappingStore)
if err != nil {
return err
}
storage[externalGroupMappingResource.StoragePath()] = externalGroupMappingDW
}
//nolint:staticcheck // not yet migrated to OpenFeature
if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzApis) {
// v0alpha1
@@ -277,8 +323,10 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
return err
}
if enableZanzanaSync {
b.logger.Info("Enabling AfterCreate hook for CoreRole to sync to Zanzana")
b.logger.Info("Enabling hooks for CoreRole to sync to Zanzana")
coreRoleStore.AfterCreate = b.AfterRoleCreate
coreRoleStore.AfterDelete = b.AfterRoleDelete
coreRoleStore.BeginUpdate = b.BeginRoleUpdate
}
storage[iamv0.CoreRoleInfo.StoragePath()] = coreRoleStore
@@ -287,8 +335,10 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
return err
}
if enableZanzanaSync {
b.logger.Info("Enabling AfterCreate hook for Role to sync to Zanzana")
b.logger.Info("Enabling hooks for Role to sync to Zanzana")
roleStore.AfterCreate = b.AfterRoleCreate
roleStore.AfterDelete = b.AfterRoleDelete
roleStore.BeginUpdate = b.BeginRoleUpdate
}
storage[iamv0.RoleInfo.StoragePath()] = roleStore
@@ -300,21 +350,59 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
}
//nolint:staticcheck // not yet migrated to OpenFeature
if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzResourcePermissionApis) {
resourcePermissionStore, err := NewLocalStore(iamv0.ResourcePermissionInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.resourcePermissionsStorage)
if err != nil {
if err := b.UpdateResourcePermissionsAPIGroup(apiGroupInfo, opts, storage, b.enableDualWriter, enableZanzanaSync); err != nil {
return err
}
if enableZanzanaSync {
b.logger.Info("Enabling AfterCreate hook for ResourcePermission to sync to Zanzana")
resourcePermissionStore.AfterCreate = b.AfterResourcePermissionCreate
}
storage[iamv0.ResourcePermissionInfo.StoragePath()] = resourcePermissionStore
}
apiGroupInfo.VersionedResourcesStorageMap[legacyiamv0.VERSION] = storage
return nil
}
func (b *IdentityAccessManagementAPIBuilder) UpdateResourcePermissionsAPIGroup(
apiGroupInfo *genericapiserver.APIGroupInfo,
opts builder.APIGroupOptions,
storage map[string]rest.Storage,
enableDualWriter bool,
enableZanzanaSync bool,
) error {
var store rest.Storage
// Create the legacy store first
legacyStore, err := NewLocalStore(iamv0.ResourcePermissionInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.resourcePermissionsStorage)
if err != nil {
return err
}
// Register the hooks for Zanzana sync
// FIXME: The hooks are registered on the legacy store
// Once we fully migrate to unified storage, we can move these hooks to the unified store
if enableZanzanaSync {
b.logger.Info("Enabling AfterCreate, BeginUpdate, and AfterDelete hooks for ResourcePermission to sync to Zanzana")
legacyStore.AfterCreate = b.AfterResourcePermissionCreate
legacyStore.BeginUpdate = b.BeginResourcePermissionUpdate
legacyStore.AfterDelete = b.AfterResourcePermissionDelete
}
// Set the default store to the legacy store
store = legacyStore
if enableDualWriter {
// Create the dual write store (UniStore + LegacyStore)
uniStore, err := grafanaregistry.NewRegistryStore(apiGroupInfo.Scheme, iamv0.ResourcePermissionInfo, opts.OptsGetter)
if err != nil {
return err
}
store, err = opts.DualWriteBuilder(iamv0.ResourcePermissionInfo.GroupResource(), legacyStore, uniStore)
if err != nil {
return err
}
}
storage[iamv0.ResourcePermissionInfo.StoragePath()] = store
return nil
}
func (b *IdentityAccessManagementAPIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions {
return func(rc common.ReferenceCallback) map[string]common.OpenAPIDefinition {
dst := legacyiamv0.GetOpenAPIDefinitions(rc)
@@ -0,0 +1,346 @@
package iam
import (
"context"
"errors"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/generic/registry"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
)
var (
errEmptyName = errors.New("name cannot be empty")
defaultWriteTimeout = 15 * time.Second
)
// AfterResourcePermissionCreate is a post-create hook that writes the resource permission to Zanzana (openFGA)
func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionCreate(obj runtime.Object, _ *metav1.CreateOptions) {
if b.zClient == nil {
return
}
rp, ok := obj.(*iamv0.ResourcePermission)
if !ok {
b.logger.Error("failed to convert object to resourcePermission type", "object", obj)
return
}
resourceType := "resourcepermission"
operation := "create"
// Grab a ticket to write to Zanzana
// This limits the amount of concurrent connections to Zanzana
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) // Record wait time
go func(rp *iamv0.ResourcePermission) {
start := time.Now()
status := "success"
defer func() {
// Release the ticket after write is done
<-b.zTickets
// Record operation duration and count
hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds())
hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc()
}()
resource := rp.Spec.Resource
permissions := rp.Spec.Permissions
operations := make([]*v1.MutateOperation, 0, len(permissions))
for _, p := range permissions {
operations = append(operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_CreatePermission{
CreatePermission: &v1.CreatePermissionOperation{
Resource: &v1.Resource{
Group: resource.ApiGroup,
Resource: resource.Resource,
Name: resource.Name,
},
Permission: &v1.Permission{
Kind: string(p.Kind),
Name: p.Name,
Verb: p.Verb,
},
},
},
})
}
if len(operations) == 0 {
return
}
b.logger.Debug("writing resource permission to zanzana",
"namespace", rp.Namespace,
"resource", resource,
"operationsCount", len(operations),
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
Namespace: rp.Namespace,
Operations: operations,
})
if err != nil {
status = "failure"
b.logger.Error("failed to write resource permission to zanzana",
"err", err,
"namespace", rp.Namespace,
"resource", resource,
"operationsCount", len(operations),
)
} else {
// Record successful tuple writes
hooksTuplesCounter.WithLabelValues(resourceType, operation, "write").Add(float64(len(operations)))
}
}(rp.DeepCopy()) // Pass a copy of the object
}
// BeginResourcePermissionUpdate is a pre-update hook that prepares zanzana updates
// It converts old and new permissions to tuples and performs the zanzana write after K8s update succeeds
func (b *IdentityAccessManagementAPIBuilder) BeginResourcePermissionUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) {
if b.zClient == nil {
return nil, nil
}
// Extract permissions from both old and new objects
oldRP, ok := oldObj.(*iamv0.ResourcePermission)
if !ok {
return nil, nil
}
newRP, ok := obj.(*iamv0.ResourcePermission)
if !ok {
return nil, nil
}
// Convert old permissions to delete operations
deleteOperations := make([]*v1.MutateOperation, 0, len(oldRP.Spec.Permissions))
if len(oldRP.Spec.Permissions) > 0 {
oldResource := oldRP.Spec.Resource
for _, p := range oldRP.Spec.Permissions {
deleteOperations = append(deleteOperations, &v1.MutateOperation{
Operation: &v1.MutateOperation_DeletePermission{
DeletePermission: &v1.DeletePermissionOperation{
Resource: &v1.Resource{
Group: oldResource.ApiGroup,
Resource: oldResource.Resource,
Name: oldResource.Name,
},
Permission: &v1.Permission{
Kind: string(p.Kind),
Name: p.Name,
Verb: p.Verb,
},
},
},
})
}
}
// Convert new permissions to create operations
createOperations := make([]*v1.MutateOperation, 0, len(newRP.Spec.Permissions))
if len(newRP.Spec.Permissions) > 0 {
newResource := newRP.Spec.Resource
for _, p := range newRP.Spec.Permissions {
createOperations = append(createOperations, &v1.MutateOperation{
Operation: &v1.MutateOperation_CreatePermission{
CreatePermission: &v1.CreatePermissionOperation{
Resource: &v1.Resource{
Group: newResource.ApiGroup,
Resource: newResource.Resource,
Name: newResource.Name,
},
Permission: &v1.Permission{
Kind: string(p.Kind),
Name: p.Name,
Verb: p.Verb,
},
},
},
})
}
}
// Return a finish function that performs the zanzana write only on success
return func(ctx context.Context, success bool) {
if !success {
// Update failed, don't write to zanzana
return
}
// Grab a ticket to write to Zanzana
// This limits the amount of concurrent connections to Zanzana
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues("resourcepermission", "update").Observe(time.Since(wait).Seconds())
go func() {
start := time.Now()
status := "success"
defer func() {
<-b.zTickets
// Record operation duration and count
hooksDurationHistogram.WithLabelValues("resourcepermission", "update", status).Observe(time.Since(start).Seconds())
hooksOperationCounter.WithLabelValues("resourcepermission", "update", status).Inc()
}()
b.logger.Debug("updating resource permission in zanzana",
"namespace", newRP.Namespace,
"oldPermissionsCnt", len(oldRP.Spec.Permissions),
"newPermissionsCnt", len(newRP.Spec.Permissions),
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
// Prepare write request
req := &v1.MutateRequest{
Namespace: newRP.Namespace,
Operations: append(deleteOperations, createOperations...),
}
if len(req.Operations) == 0 {
return
}
if len(deleteOperations) > 0 {
b.logger.Debug("deleting existing resource permissions from zanzana",
"namespace", newRP.Namespace,
"operationsCount", len(deleteOperations),
)
}
if len(createOperations) > 0 {
b.logger.Debug("writing new resource permissions to zanzana",
"namespace", newRP.Namespace,
"operationsCount", len(createOperations),
)
}
// Only make the request if there are deletes or writes
if len(req.Operations) > 0 {
err := b.zClient.Mutate(ctx, req)
if err != nil {
status = "failure"
b.logger.Error("failed to update resource permission in zanzana",
"err", err,
"namespace", newRP.Namespace,
)
} else {
// Record successful tuple operations
if len(deleteOperations) > 0 {
hooksTuplesCounter.WithLabelValues("resourcepermission", "update", "delete").Add(float64(len(deleteOperations)))
}
if len(createOperations) > 0 {
hooksTuplesCounter.WithLabelValues("resourcepermission", "update", "write").Add(float64(len(createOperations)))
}
}
} else {
b.logger.Debug("no tuples to update in zanzana", "namespace", newRP.Namespace)
}
}()
}, nil
}
// AfterResourcePermissionDelete is a post-delete hook that removes the resource permission from Zanzana (openFGA)
func (b *IdentityAccessManagementAPIBuilder) AfterResourcePermissionDelete(obj runtime.Object, _ *metav1.DeleteOptions) {
if b.zClient == nil {
return
}
rp, ok := obj.(*iamv0.ResourcePermission)
if !ok {
b.logger.Error("failed to convert object to resourcePermission type", "object", obj)
return
}
resourceType := "resourcepermission"
operation := "delete"
// Grab a ticket to write to Zanzana
// This limits the amount of concurrent connections to Zanzana
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds()) // Record wait time
go func(rp *iamv0.ResourcePermission) {
start := time.Now()
status := "success"
defer func() {
// Release the ticket after write is done
<-b.zTickets
// Record operation duration and count
hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds())
hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc()
}()
resource := rp.Spec.Resource
permissions := rp.Spec.Permissions
// Generate delete tuples from the permissions
deleteOperations := make([]*v1.MutateOperation, 0, len(permissions))
for _, p := range permissions {
deleteOperations = append(deleteOperations, &v1.MutateOperation{
Operation: &v1.MutateOperation_DeletePermission{
DeletePermission: &v1.DeletePermissionOperation{
Resource: &v1.Resource{
Group: resource.ApiGroup,
Resource: resource.Resource,
Name: resource.Name,
},
Permission: &v1.Permission{
Kind: string(p.Kind),
Name: p.Name,
Verb: p.Verb,
},
},
},
})
}
// Avoid writing if there are no valid tuples
if len(deleteOperations) == 0 {
return
}
b.logger.Debug("deleting resource permission from zanzana",
"namespace", rp.Namespace,
"resource", resource,
"operationsCount", len(deleteOperations),
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
Namespace: rp.Namespace,
Operations: deleteOperations,
})
if err != nil {
status = "failure"
b.logger.Error("failed to delete resource permission from zanzana",
"err", err,
"namespace", rp.Namespace,
"resource", resource,
"operationsCount", len(deleteOperations),
)
} else {
// Record successful tuple deletions
hooksTuplesCounter.WithLabelValues(resourceType, operation, "delete").Add(float64(len(deleteOperations)))
}
}(rp.DeepCopy()) // Pass a copy of the object
}
@@ -0,0 +1,514 @@
package iam
import (
"context"
"sync"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/stretchr/testify/require"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/infra/log"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
)
type FakeZanzanaClient struct {
zanzana.Client
writeCallback func(context.Context, *v1.WriteRequest) error
readCallback func(context.Context, *v1.ReadRequest) (*v1.ReadResponse, error)
mutateCallback func(context.Context, *v1.MutateRequest) error
}
// Read implements zanzana.Client.
func (f *FakeZanzanaClient) Read(ctx context.Context, req *v1.ReadRequest) (*v1.ReadResponse, error) {
if f.readCallback != nil {
return f.readCallback(ctx, req)
}
return &v1.ReadResponse{}, nil
}
// Write implements zanzana.Client.
func (f *FakeZanzanaClient) Write(ctx context.Context, req *v1.WriteRequest) error {
return f.writeCallback(ctx, req)
}
// Mutate implements zanzana.Client.
func (f *FakeZanzanaClient) Mutate(ctx context.Context, req *v1.MutateRequest) error {
if f.mutateCallback != nil {
return f.mutateCallback(ctx, req)
}
return nil
}
func TestAfterResourcePermissionCreate(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should create zanzana entries for folder resource permissions", func(t *testing.T) {
wg.Add(1)
folderPerm := iamv0.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Namespace: "org-2",
},
Spec: iamv0.ResourcePermissionSpec{
Resource: iamv0.ResourcePermissionspecResource{
ApiGroup: "folder.grafana.app", Resource: "folders", Name: "fold1",
},
Permissions: []iamv0.ResourcePermissionspecPermission{
{Kind: iamv0.ResourcePermissionSpecPermissionKindUser, Name: "u1", Verb: "View"},
{Kind: iamv0.ResourcePermissionSpecPermissionKindBasicRole, Name: "Editor", Verb: "Edit"},
},
},
}
testFolderEntries := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Operations)
require.Len(t, req.Operations, 2)
require.Equal(t, "org-2", req.Namespace)
expectedOperations := []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_CreatePermission{
CreatePermission: &v1.CreatePermissionOperation{
Resource: &v1.Resource{
Group: "folder.grafana.app", Resource: "folders", Name: "fold1",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
Name: "u1",
Verb: "View",
},
},
},
},
{
Operation: &v1.MutateOperation_CreatePermission{
CreatePermission: &v1.CreatePermissionOperation{
Resource: &v1.Resource{
Group: "folder.grafana.app", Resource: "folders", Name: "fold1",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindBasicRole),
Name: "Editor",
Verb: "Edit",
},
},
},
},
}
require.ElementsMatch(t, expectedOperations, req.Operations)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testFolderEntries}
b.AfterResourcePermissionCreate(&folderPerm, nil)
wg.Wait()
})
t.Run("should create zanzana entries for dashboard resource permissions", func(t *testing.T) {
wg.Add(1)
dashPerm := iamv0.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
},
Spec: iamv0.ResourcePermissionSpec{
Resource: iamv0.ResourcePermissionspecResource{
ApiGroup: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
},
Permissions: []iamv0.ResourcePermissionspecPermission{
{Kind: iamv0.ResourcePermissionSpecPermissionKindServiceAccount, Name: "sa1", Verb: "View"},
{Kind: iamv0.ResourcePermissionSpecPermissionKindTeam, Name: "team1", Verb: "Edit"},
},
},
}
testDashEntries := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
// object := "resource:dashboard.grafana.app/dashboards/dash1"
require.NotNil(t, req)
require.NotNil(t, req.Operations)
require.Len(t, req.Operations, 2)
require.Equal(t, "default", req.Namespace)
expectedOperations := []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_CreatePermission{
CreatePermission: &v1.CreatePermissionOperation{
Resource: &v1.Resource{
Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindServiceAccount),
Name: "sa1",
Verb: "View",
},
},
},
},
{
Operation: &v1.MutateOperation_CreatePermission{
CreatePermission: &v1.CreatePermissionOperation{
Resource: &v1.Resource{
Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindTeam),
Name: "team1",
Verb: "Edit",
},
},
},
},
}
require.ElementsMatch(t, expectedOperations, req.Operations)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testDashEntries}
b.AfterResourcePermissionCreate(&dashPerm, nil)
wg.Wait()
})
}
func TestBeginResourcePermissionUpdate(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should update zanzana entries for folder resource permissions", func(t *testing.T) {
wg.Add(1)
oldFolderPerm := iamv0.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Namespace: "org-2",
},
Spec: iamv0.ResourcePermissionSpec{
Resource: iamv0.ResourcePermissionspecResource{
ApiGroup: "folder.grafana.app", Resource: "folders", Name: "fold1",
},
Permissions: []iamv0.ResourcePermissionspecPermission{
{Kind: iamv0.ResourcePermissionSpecPermissionKindUser, Name: "u1", Verb: "View"},
},
},
}
newFolderPerm := iamv0.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Namespace: "org-2",
},
Spec: iamv0.ResourcePermissionSpec{
Resource: iamv0.ResourcePermissionspecResource{
ApiGroup: "folder.grafana.app", Resource: "folders", Name: "fold1",
},
Permissions: []iamv0.ResourcePermissionspecPermission{
{Kind: iamv0.ResourcePermissionSpecPermissionKindUser, Name: "u2", Verb: "Edit"},
{Kind: iamv0.ResourcePermissionSpecPermissionKindTeam, Name: "team1", Verb: "View"},
},
},
}
testFolderWrite := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-2", req.Namespace)
// Should delete old permission
require.NotNil(t, req.Operations)
require.Len(t, req.Operations, 3)
require.Equal(t, "org-2", req.Namespace)
expectedOperations := []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_DeletePermission{
DeletePermission: &v1.DeletePermissionOperation{
Resource: &v1.Resource{
Group: "folder.grafana.app", Resource: "folders", Name: "fold1",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
Name: "u1",
Verb: "View",
},
},
},
},
{
Operation: &v1.MutateOperation_CreatePermission{
CreatePermission: &v1.CreatePermissionOperation{
Resource: &v1.Resource{
Group: "folder.grafana.app", Resource: "folders", Name: "fold1",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindTeam),
Name: "team1",
Verb: "View",
},
},
},
},
{
Operation: &v1.MutateOperation_CreatePermission{
CreatePermission: &v1.CreatePermissionOperation{
Resource: &v1.Resource{
Group: "folder.grafana.app", Resource: "folders", Name: "fold1",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
Name: "u2",
Verb: "Edit",
},
},
},
},
}
require.ElementsMatch(t, expectedOperations, req.Operations)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testFolderWrite}
// Call BeginUpdate which does all the work
finishFunc, err := b.BeginResourcePermissionUpdate(context.Background(), &newFolderPerm, &oldFolderPerm, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
// Call the finish function with success=true to trigger the zanzana write
finishFunc(context.Background(), true)
})
wg.Wait()
t.Run("should update zanzana entries for dashboard resource permissions", func(t *testing.T) {
wg.Add(1)
oldDashPerm := iamv0.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
},
Spec: iamv0.ResourcePermissionSpec{
Resource: iamv0.ResourcePermissionspecResource{
ApiGroup: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
},
Permissions: []iamv0.ResourcePermissionspecPermission{
{Kind: iamv0.ResourcePermissionSpecPermissionKindUser, Name: "u1", Verb: "View"},
},
},
}
newDashPerm := iamv0.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
},
Spec: iamv0.ResourcePermissionSpec{
Resource: iamv0.ResourcePermissionspecResource{
ApiGroup: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
},
Permissions: []iamv0.ResourcePermissionspecPermission{
{Kind: iamv0.ResourcePermissionSpecPermissionKindServiceAccount, Name: "sa1", Verb: "Edit"},
},
},
}
testDashWrite := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "default", req.Namespace)
// Should delete old permission
require.NotNil(t, req.Operations)
require.Len(t, req.Operations, 2)
expectedOperations := []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_DeletePermission{
DeletePermission: &v1.DeletePermissionOperation{
Resource: &v1.Resource{
Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
Name: "u1",
Verb: "View",
},
},
},
},
{
Operation: &v1.MutateOperation_CreatePermission{
CreatePermission: &v1.CreatePermissionOperation{
Resource: &v1.Resource{
Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindServiceAccount),
Name: "sa1",
Verb: "Edit",
},
},
},
},
}
require.ElementsMatch(t, expectedOperations, req.Operations)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testDashWrite}
// Call BeginUpdate which does all the work
finishFunc, err := b.BeginResourcePermissionUpdate(context.Background(), &newDashPerm, &oldDashPerm, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
// Call the finish function with success=true to trigger the zanzana write
finishFunc(context.Background(), true)
wg.Wait()
})
}
func TestAfterResourcePermissionDelete(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should delete zanzana entries for folder resource permissions", func(t *testing.T) {
wg.Add(1)
folderPerm := iamv0.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Namespace: "org-2",
},
Spec: iamv0.ResourcePermissionSpec{
Resource: iamv0.ResourcePermissionspecResource{
ApiGroup: "folder.grafana.app", Resource: "folders", Name: "fold1",
},
Permissions: []iamv0.ResourcePermissionspecPermission{
{Kind: iamv0.ResourcePermissionSpecPermissionKindUser, Name: "u1", Verb: "View"},
{Kind: iamv0.ResourcePermissionSpecPermissionKindBasicRole, Name: "Editor", Verb: "Edit"},
},
},
}
testFolderDelete := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-2", req.Namespace)
// Should have deletes but no writes
require.NotNil(t, req.Operations)
require.Len(t, req.Operations, 2)
expectedOperations := []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_DeletePermission{
DeletePermission: &v1.DeletePermissionOperation{
Resource: &v1.Resource{
Group: "folder.grafana.app", Resource: "folders", Name: "fold1",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindUser),
Name: "u1",
Verb: "View",
},
},
},
},
{
Operation: &v1.MutateOperation_DeletePermission{
DeletePermission: &v1.DeletePermissionOperation{
Resource: &v1.Resource{
Group: "folder.grafana.app", Resource: "folders", Name: "fold1",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindBasicRole),
Name: "Editor",
Verb: "Edit",
},
},
},
},
}
require.ElementsMatch(t, expectedOperations, req.Operations)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testFolderDelete}
b.AfterResourcePermissionDelete(&folderPerm, nil)
wg.Wait()
})
t.Run("should delete zanzana entries for dashboard resource permissions", func(t *testing.T) {
wg.Add(1)
dashPerm := iamv0.ResourcePermission{
ObjectMeta: metav1.ObjectMeta{
Namespace: "default",
},
Spec: iamv0.ResourcePermissionSpec{
Resource: iamv0.ResourcePermissionspecResource{
ApiGroup: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
},
Permissions: []iamv0.ResourcePermissionspecPermission{
{Kind: iamv0.ResourcePermissionSpecPermissionKindServiceAccount, Name: "sa1", Verb: "View"},
{Kind: iamv0.ResourcePermissionSpecPermissionKindTeam, Name: "team1", Verb: "Edit"},
},
},
}
testDashDelete := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "default", req.Namespace)
// Should have deletes but no writes
require.NotNil(t, req.Operations)
require.Len(t, req.Operations, 2)
expectedOperations := []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_DeletePermission{
DeletePermission: &v1.DeletePermissionOperation{
Resource: &v1.Resource{
Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindServiceAccount),
Name: "sa1",
Verb: "View",
},
},
},
},
{
Operation: &v1.MutateOperation_DeletePermission{
DeletePermission: &v1.DeletePermissionOperation{
Resource: &v1.Resource{
Group: "dashboard.grafana.app", Resource: "dashboards", Name: "dash1",
},
Permission: &v1.Permission{
Kind: string(iamv0.ResourcePermissionSpecPermissionKindTeam),
Name: "team1",
Verb: "Edit",
},
},
},
},
}
require.ElementsMatch(t, expectedOperations, req.Operations)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testDashDelete}
b.AfterResourcePermissionDelete(&dashPerm, nil)
wg.Wait()
})
}
+433
View File
@@ -0,0 +1,433 @@
package iam
import (
"context"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/generic/registry"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/services/accesscontrol"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/authz/zanzana/common"
)
// convertRolePermissionsToTuples converts role permissions (action/scope) to v1 TupleKey format
// using the shared zanzana.ConvertRolePermissionsToTuples utility and common.ToAuthzExtTupleKeys
func convertRolePermissionsToTuples(roleUID string, permissions []iamv0.CoreRolespecPermission) ([]*v1.TupleKey, error) {
// Convert IAM permissions to zanzana.RolePermission format
rolePerms := make([]zanzana.RolePermission, 0, len(permissions))
for _, perm := range permissions {
// Split the scope to get kind, attribute, identifier
kind, _, identifier := accesscontrol.SplitScope(perm.Scope)
rolePerms = append(rolePerms, zanzana.RolePermission{
Action: perm.Action,
Kind: kind,
Identifier: identifier,
})
}
// Translate to Zanzana tuples
openfgaTuples, err := zanzana.ConvertRolePermissionsToTuples(roleUID, rolePerms)
if err != nil {
return nil, err
}
// Convert directly to v1 tuples using common utility
v1Tuples := common.ToAuthzExtTupleKeys(openfgaTuples)
return v1Tuples, nil
}
// AfterRoleCreate is a post-create hook that writes the role permissions to Zanzana (openFGA)
// It handles both Role and CoreRole types
func (b *IdentityAccessManagementAPIBuilder) AfterRoleCreate(obj runtime.Object, _ *metav1.CreateOptions) {
if b.zClient == nil {
return
}
var rType string
var rt *iamv0.CoreRole
if coreRole, ok := obj.(*iamv0.CoreRole); ok {
rt = coreRole.DeepCopy()
rType = "coreRole"
} else if regRole, ok := obj.(*iamv0.Role); ok {
regRolePermissions := make([]iamv0.CoreRolespecPermission, len(regRole.Spec.Permissions))
for i, p := range regRole.Spec.Permissions {
regRolePermissions[i] = iamv0.CoreRolespecPermission(p)
}
rt = &iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: regRole.Name,
Namespace: regRole.Namespace,
},
Spec: iamv0.CoreRoleSpec{
Permissions: regRolePermissions,
},
}
rType = "role"
} else {
// Not a supported role type
return
}
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues(rType, "create").Observe(time.Since(wait).Seconds())
go func(role *iamv0.CoreRole, roleType string) {
start := time.Now()
status := "success"
defer func() {
<-b.zTickets
hooksDurationHistogram.WithLabelValues(rType, "create", status).Observe(time.Since(start).Seconds())
hooksOperationCounter.WithLabelValues(rType, "create", status).Inc()
}()
tuples, err := convertRolePermissionsToTuples(role.Name, role.Spec.Permissions)
if err != nil {
b.logger.Error("failed to convert role permissions to tuples",
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"err", err,
"permissionsCnt", len(role.Spec.Permissions),
)
status = "failure"
return
}
// Avoid writing if there are no valid tuples
if len(tuples) == 0 {
b.logger.Debug("no valid tuples to write for role",
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"permissionsCnt", len(role.Spec.Permissions),
)
status = "failure"
return
}
b.logger.Debug("writing role permissions to zanzana",
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"tuplesCnt", len(tuples),
"permissionsCnt", len(role.Spec.Permissions),
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
err = b.zClient.Write(ctx, &v1.WriteRequest{
Namespace: role.Namespace,
Writes: &v1.WriteRequestWrites{
TupleKeys: tuples,
},
})
if err != nil {
b.logger.Error("failed to write role permissions to zanzana",
"err", err,
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"tuplesCnt", len(tuples),
)
status = "failure"
return
}
// Record successful tuple writes
hooksTuplesCounter.WithLabelValues(rType, "create", "write").Add(float64(len(tuples)))
}(rt.DeepCopy(), rType)
}
// AfterRoleDelete is a post-delete hook that removes the role permissions from Zanzana (openFGA)
// It handles both Role and CoreRole types
func (b *IdentityAccessManagementAPIBuilder) AfterRoleDelete(obj runtime.Object, _ *metav1.DeleteOptions) {
if b.zClient == nil {
return
}
var rType string
var rt *iamv0.CoreRole
// Try CoreRole first
if coreRole, ok := obj.(*iamv0.CoreRole); ok {
rt = coreRole.DeepCopy()
rType = "coreRole"
} else if regRole, ok := obj.(*iamv0.Role); ok {
regRolePermissions := make([]iamv0.CoreRolespecPermission, len(regRole.Spec.Permissions))
for i, p := range regRole.Spec.Permissions {
regRolePermissions[i] = iamv0.CoreRolespecPermission(p)
}
rt = &iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: regRole.Name,
Namespace: regRole.Namespace,
},
Spec: iamv0.CoreRoleSpec{
Permissions: regRolePermissions,
},
}
rType = "role"
} else {
// Not a supported role type
return
}
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues("role", "delete").Observe(time.Since(wait).Seconds()) // Record wait time
go func(role *iamv0.CoreRole, roleType string) {
defer func() {
<-b.zTickets
}()
b.logger.Debug("deleting role permissions from zanzana",
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"permissionsCnt", len(role.Spec.Permissions),
)
tuples, err := convertRolePermissionsToTuples(role.Name, role.Spec.Permissions)
if err != nil {
b.logger.Error("failed to convert role permissions to tuples for deletion",
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"err", err,
"permissionsCnt", len(role.Spec.Permissions),
)
return
}
// Avoid deleting if there are no valid tuples
if len(tuples) == 0 {
b.logger.Debug("no valid tuples to delete for role",
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"permissionsCnt", len(role.Spec.Permissions),
)
return
}
// Convert tuples to TupleKeyWithoutCondition for deletion
deleteTuples := toTupleKeysWithoutCondition(tuples)
b.logger.Debug("deleting role permissions from zanzana",
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"tuplesCnt", len(deleteTuples),
"permissionsCnt", len(role.Spec.Permissions),
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
err = b.zClient.Write(ctx, &v1.WriteRequest{
Namespace: role.Namespace,
Deletes: &v1.WriteRequestDeletes{
TupleKeys: deleteTuples,
},
})
if err != nil {
b.logger.Error("failed to delete role permissions from zanzana",
"err", err,
"namespace", role.Namespace,
"roleUID", role.Name,
"roleType", roleType,
"tuplesCnt", len(deleteTuples),
)
}
}(rt.DeepCopy(), rType)
}
// beginRoleUpdate is a pre-update hook that prepares zanzana updates
// It converts old and new permissions to tuples and performs the zanzana write after K8s update succeeds
// It handles both Role and CoreRole types
func (b *IdentityAccessManagementAPIBuilder) BeginRoleUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) {
if b.zClient == nil {
return nil, nil
}
var oldRole, newRole *iamv0.CoreRole
var roleType string
if oldCoreRole, ok := oldObj.(*iamv0.CoreRole); ok { // Try CoreRole first
oldRole = oldCoreRole.DeepCopy()
newCoreRole, ok := obj.(*iamv0.CoreRole)
if !ok {
return nil, nil
}
newRole = newCoreRole.DeepCopy()
roleType = "coreRole"
} else if oldRegRole, ok := oldObj.(*iamv0.Role); ok { // Try Role
oldRegRolePermissions := make([]iamv0.CoreRolespecPermission, len(oldRegRole.Spec.Permissions))
for i, p := range oldRegRole.Spec.Permissions {
oldRegRolePermissions[i] = iamv0.CoreRolespecPermission(p)
}
oldRole = &iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: oldRegRole.Name,
Namespace: oldRegRole.Namespace,
},
Spec: iamv0.CoreRoleSpec{
Permissions: oldRegRolePermissions,
},
}
newRegRole, ok := obj.(*iamv0.Role)
if !ok {
return nil, nil
}
newRegRolePermissions := make([]iamv0.CoreRolespecPermission, len(newRegRole.Spec.Permissions))
for i, p := range newRegRole.Spec.Permissions {
newRegRolePermissions[i] = iamv0.CoreRolespecPermission(p)
}
newRole = &iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: newRegRole.Name,
Namespace: newRegRole.Namespace,
},
Spec: iamv0.CoreRoleSpec{
Permissions: newRegRolePermissions,
},
}
roleType = "role"
} else {
// Not a supported role type
return nil, nil
}
// Return a finish function that performs the zanzana write only on success
return func(ctx context.Context, success bool) {
if !success {
// Update failed, don't write to zanzana
return
}
// Grab a ticket to write to Zanzana
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues(roleType, "update").Observe(time.Since(wait).Seconds()) // Record wait time
go func(old *iamv0.CoreRole, new *iamv0.CoreRole) {
defer func() {
<-b.zTickets
}()
roleUID, namespace := old.Name, old.Namespace
oldPermissions, newPermissions := old.Spec.Permissions, new.Spec.Permissions
// Convert old permissions to tuples for deletion
var oldTuples []*v1.TupleKey
if len(oldPermissions) > 0 {
var err error
oldTuples, err = convertRolePermissionsToTuples(roleUID, oldPermissions)
if err != nil {
b.logger.Error("failed to convert old role permissions to tuples",
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"err", err,
)
}
}
// Convert new permissions to tuples for writing
newTuples, err := convertRolePermissionsToTuples(roleUID, newPermissions)
if err != nil {
b.logger.Error("failed to convert new role permissions to tuples",
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"err", err,
)
return
}
b.logger.Debug("updating role permissions in zanzana",
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"oldPermissionsCnt", len(oldPermissions),
"newPermissionsCnt", len(newPermissions),
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
// Prepare write request
req := &v1.WriteRequest{
Namespace: namespace,
}
// Add deletes for old tuples
if len(oldTuples) > 0 {
deleteTuples := toTupleKeysWithoutCondition(oldTuples)
req.Deletes = &v1.WriteRequestDeletes{
TupleKeys: deleteTuples,
}
b.logger.Debug("deleting existing role permissions from zanzana",
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"tuplesCnt", len(deleteTuples),
)
}
// Add writes for new tuples
if len(newTuples) > 0 {
req.Writes = &v1.WriteRequestWrites{
TupleKeys: newTuples,
}
b.logger.Debug("writing new role permissions to zanzana",
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
"tuplesCnt", len(newTuples),
)
}
// Only make the request if there are deletes or writes
if req.Deletes != nil || req.Writes != nil {
err = b.zClient.Write(ctx, req)
if err != nil {
b.logger.Error("failed to update role permissions in zanzana",
"err", err,
"namespace", namespace,
"roleUID", roleUID,
"roleType", roleType,
)
}
}
}(oldRole.DeepCopy(), newRole.DeepCopy())
}, nil
}
// tupleToTupleKeyWithoutCondition converts a TupleKey to TupleKeyWithoutCondition
// This is needed for delete operations which don't support conditions
func tupleToTupleKeyWithoutCondition(tuple *v1.TupleKey) *v1.TupleKeyWithoutCondition {
return &v1.TupleKeyWithoutCondition{
User: tuple.User,
Relation: tuple.Relation,
Object: tuple.Object,
}
}
// toTupleKeysWithoutCondition converts v1.TupleKey to v1.TupleKeyWithoutCondition
// by stripping the condition field, which is required for delete operations
func toTupleKeysWithoutCondition(tuples []*v1.TupleKey) []*v1.TupleKeyWithoutCondition {
result := make([]*v1.TupleKeyWithoutCondition, len(tuples))
for i, t := range tuples {
result[i] = tupleToTupleKeyWithoutCondition(t)
}
return result
}
+989
View File
@@ -0,0 +1,989 @@
package iam
import (
"context"
"sync"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/stretchr/testify/require"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/infra/log"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
)
func requireTuplesMatch(t *testing.T, actual []*v1.TupleKey, expected []*v1.TupleKey, msgAndArgs ...interface{}) {
t.Helper()
for _, exp := range expected {
found := false
for _, act := range actual {
if act.User == exp.User &&
act.Relation == exp.Relation &&
act.Object == exp.Object {
found = true
break
}
}
if !found {
require.Fail(t, "Expected tuple not found", "Tuple: %+v\n%v", exp, msgAndArgs)
}
}
}
func requireDeleteTuplesMatch(t *testing.T, actual []*v1.TupleKeyWithoutCondition, expected []*v1.TupleKeyWithoutCondition, msgAndArgs ...interface{}) {
t.Helper()
for _, exp := range expected {
found := false
for _, act := range actual {
if act.User == exp.User &&
act.Relation == exp.Relation &&
act.Object == exp.Object {
found = true
break
}
}
if !found {
require.Fail(t, "Expected delete tuple not found", "Tuple: %+v\n%v", exp, msgAndArgs)
}
}
}
func TestAfterCoreRoleCreate(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should create zanzana entries for core role with folder permissions", func(t *testing.T) {
wg.Add(1)
coreRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "test-role-uid",
Namespace: "org-1",
},
Spec: iamv0.CoreRoleSpec{
Title: "Test Role",
Description: "Test role for folders",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder1"},
{Action: "folders:write", Scope: "folders:uid:folder1"},
},
},
}
testCoreRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
require.Equal(t, "org-1", req.Namespace)
expectedTuples := []*v1.TupleKey{
{User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder1"},
{User: "role:test-role-uid#assignee", Relation: "update", Object: "folder:folder1"},
}
requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testCoreRoleEntries}
b.AfterRoleCreate(&coreRole, nil)
wg.Wait()
})
t.Run("should create zanzana entries for core role with dashboard permissions", func(t *testing.T) {
wg.Add(1)
coreRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "dashboard-role-uid",
Namespace: "default",
},
Spec: iamv0.CoreRoleSpec{
Title: "Dashboard Role",
Description: "Test role for dashboards",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
{Action: "dashboards:write", Scope: "dashboards:uid:dash1"},
},
},
}
testDashboardRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
require.Equal(t, "default", req.Namespace)
// Check subject is role with assignee relation
for _, tuple := range req.Writes.TupleKeys {
require.Equal(t, "role:dashboard-role-uid#assignee", tuple.User)
require.Contains(t, tuple.Object, "resource:")
require.Contains(t, tuple.Object, "dashboard")
}
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testDashboardRoleEntries}
b.AfterRoleCreate(&coreRole, nil)
wg.Wait()
})
t.Run("should handle wildcard scopes", func(t *testing.T) {
wg.Add(1)
coreRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "wildcard-role-uid",
Namespace: "org-2",
},
Spec: iamv0.CoreRoleSpec{
Title: "Wildcard Role",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:*"},
},
},
}
testWildcardEntries := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 1)
tuple := req.Writes.TupleKeys[0]
require.Equal(t, "role:wildcard-role-uid#assignee", tuple.User)
// Wildcard should create a group_resource tuple
require.Contains(t, tuple.Object, "group_resource:")
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testWildcardEntries}
b.AfterRoleCreate(&coreRole, nil)
wg.Wait()
})
t.Run("should skip untranslatable permissions", func(t *testing.T) {
wg.Add(1)
coreRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "mixed-role-uid",
Namespace: "org-1",
},
Spec: iamv0.CoreRoleSpec{
Title: "Mixed Role",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder1"},
{Action: "unknown:action", Scope: "unknown:scope"}, // This should be skipped
},
},
}
testMixedEntries := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Writes)
// Should only have 1 tuple (the untranslatable one should be skipped)
require.Len(t, req.Writes.TupleKeys, 1)
tuple := req.Writes.TupleKeys[0]
require.Equal(t, "role:mixed-role-uid#assignee", tuple.User)
require.Equal(t, "folder:folder1", tuple.Object)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testMixedEntries}
b.AfterRoleCreate(&coreRole, nil)
wg.Wait()
})
}
func TestAfterRoleCreate(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should create zanzana entries for role with folder permissions", func(t *testing.T) {
wg.Add(1)
role := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "custom-role-uid",
Namespace: "org-3",
},
Spec: iamv0.RoleSpec{
Title: "Custom Role",
Description: "Custom role for folders",
Permissions: []iamv0.RolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder2"},
{Action: "folders:delete", Scope: "folders:uid:folder2"},
},
},
}
testRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
require.Equal(t, "org-3", req.Namespace)
expectedTuples := []*v1.TupleKey{
{User: "role:custom-role-uid#assignee", Relation: "get", Object: "folder:folder2"},
{User: "role:custom-role-uid#assignee", Relation: "delete", Object: "folder:folder2"},
}
requireTuplesMatch(t, req.Writes.TupleKeys, expectedTuples)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testRoleEntries}
b.AfterRoleCreate(&role, nil)
wg.Wait()
})
t.Run("should create zanzana entries for role with dashboard permissions", func(t *testing.T) {
wg.Add(1)
role := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "dash-role-uid",
Namespace: "default",
},
Spec: iamv0.RoleSpec{
Title: "Dashboard Custom Role",
Description: "Custom role for dashboards",
Permissions: []iamv0.RolespecPermission{
{Action: "dashboards:read", Scope: "dashboards:uid:mydash"},
{Action: "dashboards:delete", Scope: "dashboards:uid:mydash"},
},
},
}
testDashRoleEntries := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
require.Equal(t, "default", req.Namespace)
// Check subject is role with assignee relation
for _, tuple := range req.Writes.TupleKeys {
require.Equal(t, "role:dash-role-uid#assignee", tuple.User)
require.Contains(t, tuple.Object, "resource:")
}
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testDashRoleEntries}
b.AfterRoleCreate(&role, nil)
wg.Wait()
})
t.Run("should merge folder resource tuples with same object and user", func(t *testing.T) {
wg.Add(1)
role := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "merge-role-uid",
Namespace: "org-1",
},
Spec: iamv0.RoleSpec{
Title: "Merge Test Role",
Permissions: []iamv0.RolespecPermission{
// These should create folder resource tuples that get merged
{Action: "dashboards:read", Scope: "folders:uid:parent-folder"},
{Action: "dashboards:write", Scope: "folders:uid:parent-folder"},
},
},
}
testMergedEntries := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Writes)
// After merging, we should have tuples for the folder resource actions
require.Greater(t, len(req.Writes.TupleKeys), 0)
for _, tuple := range req.Writes.TupleKeys {
require.Equal(t, "role:merge-role-uid#assignee", tuple.User)
}
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testMergedEntries}
b.AfterRoleCreate(&role, nil)
wg.Wait()
})
}
func TestBeginCoreRoleUpdate(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should update zanzana entries when permissions change", func(t *testing.T) {
wg.Add(1)
oldRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "test-role-uid",
Namespace: "org-1",
},
Spec: iamv0.CoreRoleSpec{
Title: "Test Role",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder1"},
{Action: "folders:write", Scope: "folders:uid:folder1"},
},
},
}
newRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "test-role-uid",
Namespace: "org-1",
},
Spec: iamv0.CoreRoleSpec{
Title: "Test Role Updated",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder2"},
{Action: "folders:delete", Scope: "folders:uid:folder2"},
},
},
}
testUpdate := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-1", req.Namespace)
// Verify deletes (old permissions)
require.NotNil(t, req.Deletes)
require.Len(t, req.Deletes.TupleKeys, 2)
expectedDeletes := []*v1.TupleKeyWithoutCondition{
{User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder1"},
{User: "role:test-role-uid#assignee", Relation: "update", Object: "folder:folder1"},
}
requireDeleteTuplesMatch(t, req.Deletes.TupleKeys, expectedDeletes)
// Verify writes (new permissions)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
expectedWrites := []*v1.TupleKey{
{User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder2"},
{User: "role:test-role-uid#assignee", Relation: "delete", Object: "folder:folder2"},
}
requireTuplesMatch(t, req.Writes.TupleKeys, expectedWrites)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testUpdate}
// Call BeginUpdate which does all the work
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
// Call the finish function with success=true to trigger the zanzana write
finishFunc(context.Background(), true)
wg.Wait()
})
t.Run("should handle adding new permissions", func(t *testing.T) {
wg.Add(1)
oldRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "expand-role-uid",
Namespace: "org-2",
},
Spec: iamv0.CoreRoleSpec{
Title: "Expand Role",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder1"},
},
},
}
newRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "expand-role-uid",
Namespace: "org-2",
},
Spec: iamv0.CoreRoleSpec{
Title: "Expand Role",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder1"},
{Action: "folders:write", Scope: "folders:uid:folder1"},
{Action: "folders:delete", Scope: "folders:uid:folder1"},
},
},
}
testExpand := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-2", req.Namespace)
// Should delete old permission
require.NotNil(t, req.Deletes)
require.Len(t, req.Deletes.TupleKeys, 1)
// Should write all new permissions
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 3)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testExpand}
// Call BeginUpdate which does all the work
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
// Call the finish function with success=true to trigger the zanzana write
finishFunc(context.Background(), true)
wg.Wait()
})
t.Run("should handle removing all permissions", func(t *testing.T) {
wg.Add(1)
oldRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "clear-role-uid",
Namespace: "org-3",
},
Spec: iamv0.CoreRoleSpec{
Title: "Clear Role",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder1"},
{Action: "folders:write", Scope: "folders:uid:folder1"},
},
},
}
newRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "clear-role-uid",
Namespace: "org-3",
},
Spec: iamv0.CoreRoleSpec{
Title: "Clear Role",
Permissions: []iamv0.CoreRolespecPermission{},
},
}
testClear := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-3", req.Namespace)
// Should delete old permissions
require.NotNil(t, req.Deletes)
require.Len(t, req.Deletes.TupleKeys, 2)
// Should have no writes
require.Nil(t, req.Writes)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testClear}
// Call BeginUpdate which does all the work
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
// Call the finish function with success=true to trigger the zanzana write
finishFunc(context.Background(), true)
wg.Wait()
})
}
func TestBeginRoleUpdate(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should update zanzana entries when permissions change", func(t *testing.T) {
wg.Add(1)
oldRole := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "custom-role-uid",
Namespace: "org-1",
},
Spec: iamv0.RoleSpec{
Title: "Custom Role",
Permissions: []iamv0.RolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder1"},
{Action: "folders:write", Scope: "folders:uid:folder1"},
},
},
}
newRole := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "custom-role-uid",
Namespace: "org-1",
},
Spec: iamv0.RoleSpec{
Title: "Custom Role Updated",
Permissions: []iamv0.RolespecPermission{
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
{Action: "dashboards:write", Scope: "dashboards:uid:dash1"},
},
},
}
testUpdate := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-1", req.Namespace)
// Verify deletes (old permissions)
require.NotNil(t, req.Deletes)
require.Len(t, req.Deletes.TupleKeys, 2)
expectedDeletes := []*v1.TupleKeyWithoutCondition{
{User: "role:custom-role-uid#assignee", Relation: "get", Object: "folder:folder1"},
{User: "role:custom-role-uid#assignee", Relation: "update", Object: "folder:folder1"},
}
requireDeleteTuplesMatch(t, req.Deletes.TupleKeys, expectedDeletes)
// Verify writes (new permissions) - dashboards use resource type
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
// All writes should be for dashboards
for _, tuple := range req.Writes.TupleKeys {
require.Equal(t, "role:custom-role-uid#assignee", tuple.User)
require.Contains(t, tuple.Object, "resource:")
require.Contains(t, tuple.Object, "dashboard")
}
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testUpdate}
// Call BeginUpdate which does all the work
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
// Call the finish function with success=true to trigger the zanzana write
finishFunc(context.Background(), true)
wg.Wait()
})
t.Run("should handle completely new permission set", func(t *testing.T) {
wg.Add(1)
oldRole := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "swap-role-uid",
Namespace: "default",
},
Spec: iamv0.RoleSpec{
Title: "Swap Role",
Permissions: []iamv0.RolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder1"},
},
},
}
newRole := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "swap-role-uid",
Namespace: "default",
},
Spec: iamv0.RoleSpec{
Title: "Swap Role",
Permissions: []iamv0.RolespecPermission{
{Action: "folders:write", Scope: "folders:uid:folder2"},
{Action: "folders:delete", Scope: "folders:uid:folder2"},
},
},
}
testSwap := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "default", req.Namespace)
// Should delete old permission
require.NotNil(t, req.Deletes)
require.Len(t, req.Deletes.TupleKeys, 1)
require.Equal(t, "role:swap-role-uid#assignee", req.Deletes.TupleKeys[0].User)
require.Equal(t, "folder:folder1", req.Deletes.TupleKeys[0].Object)
// Should write new permissions
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 2)
for _, tuple := range req.Writes.TupleKeys {
require.Equal(t, "role:swap-role-uid#assignee", tuple.User)
require.Equal(t, "folder:folder2", tuple.Object)
}
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testSwap}
// Call BeginUpdate which does all the work
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
// Call the finish function with success=true to trigger the zanzana write
finishFunc(context.Background(), true)
wg.Wait()
})
t.Run("should handle adding permissions to empty role", func(t *testing.T) {
wg.Add(1)
oldRole := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "empty-role-uid",
Namespace: "org-2",
},
Spec: iamv0.RoleSpec{
Title: "Empty Role",
Permissions: []iamv0.RolespecPermission{},
},
}
newRole := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "empty-role-uid",
Namespace: "org-2",
},
Spec: iamv0.RoleSpec{
Title: "Empty Role",
Permissions: []iamv0.RolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder1"},
},
},
}
testAddToEmpty := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-2", req.Namespace)
// Should have no deletes
require.Nil(t, req.Deletes)
// Should write new permission
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 1)
require.Equal(t, "role:empty-role-uid#assignee", req.Writes.TupleKeys[0].User)
require.Equal(t, "folder:folder1", req.Writes.TupleKeys[0].Object)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testAddToEmpty}
// Call BeginUpdate which does all the work
finishFunc, err := b.BeginRoleUpdate(context.Background(), &newRole, &oldRole, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
// Call the finish function with success=true to trigger the zanzana write
finishFunc(context.Background(), true)
wg.Wait()
})
}
func TestAfterCoreRoleDelete(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should delete zanzana entries for core role with folder permissions", func(t *testing.T) {
wg.Add(1)
coreRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "test-role-uid",
Namespace: "org-1",
},
Spec: iamv0.CoreRoleSpec{
Title: "Test Role",
Description: "Test role for folders",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder1"},
{Action: "folders:write", Scope: "folders:uid:folder1"},
},
},
}
testCoreRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Deletes)
require.Len(t, req.Deletes.TupleKeys, 2)
require.Equal(t, "org-1", req.Namespace)
expectedDeletes := []*v1.TupleKeyWithoutCondition{
{User: "role:test-role-uid#assignee", Relation: "get", Object: "folder:folder1"},
{User: "role:test-role-uid#assignee", Relation: "update", Object: "folder:folder1"},
}
requireDeleteTuplesMatch(t, req.Deletes.TupleKeys, expectedDeletes)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testCoreRoleDeletes}
b.AfterRoleDelete(&coreRole, nil)
wg.Wait()
})
t.Run("should delete zanzana entries for core role with dashboard permissions", func(t *testing.T) {
wg.Add(1)
coreRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "dashboard-role-uid",
Namespace: "default",
},
Spec: iamv0.CoreRoleSpec{
Title: "Dashboard Role",
Description: "Test role for dashboards",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "dashboards:read", Scope: "dashboards:uid:dash1"},
{Action: "dashboards:write", Scope: "dashboards:uid:dash1"},
},
},
}
testDashboardRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Deletes)
require.Len(t, req.Deletes.TupleKeys, 2)
require.Equal(t, "default", req.Namespace)
// Check all deletes have the correct subject
for _, tuple := range req.Deletes.TupleKeys {
require.Equal(t, "role:dashboard-role-uid#assignee", tuple.User)
require.Contains(t, tuple.Object, "resource:")
}
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testDashboardRoleDeletes}
b.AfterRoleDelete(&coreRole, nil)
wg.Wait()
})
t.Run("should handle wildcard scopes on delete", func(t *testing.T) {
wg.Add(1)
coreRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "wildcard-role-uid",
Namespace: "org-2",
},
Spec: iamv0.CoreRoleSpec{
Title: "Wildcard Role",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:*"},
},
},
}
testWildcardDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Deletes)
require.Len(t, req.Deletes.TupleKeys, 1)
tuple := req.Deletes.TupleKeys[0]
require.Equal(t, "role:wildcard-role-uid#assignee", tuple.User)
require.Contains(t, tuple.Object, "group_resource:")
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testWildcardDeletes}
b.AfterRoleDelete(&coreRole, nil)
wg.Wait()
})
t.Run("should skip untranslatable permissions on delete", func(t *testing.T) {
wg.Add(1)
coreRole := iamv0.CoreRole{
ObjectMeta: metav1.ObjectMeta{
Name: "mixed-role-uid",
Namespace: "org-1",
},
Spec: iamv0.CoreRoleSpec{
Title: "Mixed Role",
Permissions: []iamv0.CoreRolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder1"},
{Action: "unknown:action", Scope: "unknown:scope"}, // This should be skipped
},
},
}
testMixedDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Deletes)
// Should only delete 1 tuple (the untranslatable one should be skipped)
require.Len(t, req.Deletes.TupleKeys, 1)
tuple := req.Deletes.TupleKeys[0]
require.Equal(t, "role:mixed-role-uid#assignee", tuple.User)
require.Equal(t, "folder:folder1", tuple.Object)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testMixedDeletes}
b.AfterRoleDelete(&coreRole, nil)
wg.Wait()
})
}
func TestAfterRoleDelete(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should delete zanzana entries for role with folder permissions", func(t *testing.T) {
wg.Add(1)
role := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "custom-role-uid",
Namespace: "org-3",
},
Spec: iamv0.RoleSpec{
Title: "Custom Role",
Description: "Custom role for folders",
Permissions: []iamv0.RolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder2"},
{Action: "folders:delete", Scope: "folders:uid:folder2"},
},
},
}
testRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Deletes)
require.Len(t, req.Deletes.TupleKeys, 2)
require.Equal(t, "org-3", req.Namespace)
expectedDeletes := []*v1.TupleKeyWithoutCondition{
{User: "role:custom-role-uid#assignee", Relation: "get", Object: "folder:folder2"},
{User: "role:custom-role-uid#assignee", Relation: "delete", Object: "folder:folder2"},
}
requireDeleteTuplesMatch(t, req.Deletes.TupleKeys, expectedDeletes)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testRoleDeletes}
b.AfterRoleDelete(&role, nil)
wg.Wait()
})
t.Run("should delete zanzana entries for role with dashboard permissions", func(t *testing.T) {
wg.Add(1)
role := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "dash-role-uid",
Namespace: "default",
},
Spec: iamv0.RoleSpec{
Title: "Dashboard Custom Role",
Description: "Custom role for dashboards",
Permissions: []iamv0.RolespecPermission{
{Action: "dashboards:read", Scope: "dashboards:uid:mydash"},
{Action: "dashboards:delete", Scope: "dashboards:uid:mydash"},
},
},
}
testDashRoleDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Deletes)
require.Len(t, req.Deletes.TupleKeys, 2)
require.Equal(t, "default", req.Namespace)
// Check all deletes have the correct subject
for _, tuple := range req.Deletes.TupleKeys {
require.Equal(t, "role:dash-role-uid#assignee", tuple.User)
require.Contains(t, tuple.Object, "resource:")
}
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testDashRoleDeletes}
b.AfterRoleDelete(&role, nil)
wg.Wait()
})
t.Run("should handle multiple permissions on delete", func(t *testing.T) {
wg.Add(1)
role := iamv0.Role{
ObjectMeta: metav1.ObjectMeta{
Name: "multi-role-uid",
Namespace: "org-1",
},
Spec: iamv0.RoleSpec{
Title: "Multi Permission Role",
Permissions: []iamv0.RolespecPermission{
{Action: "folders:read", Scope: "folders:uid:folder1"},
{Action: "folders:write", Scope: "folders:uid:folder1"},
{Action: "folders:delete", Scope: "folders:uid:folder1"},
},
},
}
testMultiDeletes := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Deletes)
require.Len(t, req.Deletes.TupleKeys, 3)
// All should be for the same role and folder
for _, tuple := range req.Deletes.TupleKeys {
require.Equal(t, "role:multi-role-uid#assignee", tuple.User)
require.Equal(t, "folder:folder1", tuple.Object)
}
// Check all expected relations are present
relations := make(map[string]bool)
for _, tuple := range req.Deletes.TupleKeys {
relations[tuple.Relation] = true
}
require.True(t, relations["get"], "Expected 'get' relation")
require.True(t, relations["update"], "Expected 'update' relation")
require.True(t, relations["delete"], "Expected 'delete' relation")
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testMultiDeletes}
b.AfterRoleDelete(&role, nil)
wg.Wait()
})
}
+363
View File
@@ -0,0 +1,363 @@
package iam
import (
"context"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/generic/registry"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
)
// convertTeamBindingToTuple converts a TeamBinding to a v1 TupleKey format
// TeamBinding represents a user's membership in a team with a specific permission level
func convertTeamBindingToTuple(tb *iamv0.TeamBinding) (*v1.TupleKey, error) {
if tb.Spec.Subject.Name == "" {
return nil, errEmptyName
}
if tb.Spec.TeamRef.Name == "" {
return nil, errEmptyName
}
// Map permission to relation
var relation string
switch tb.Spec.Permission {
case iamv0.TeamBindingTeamPermissionAdmin:
relation = zanzana.RelationTeamAdmin
case iamv0.TeamBindingTeamPermissionMember:
relation = zanzana.RelationTeamMember
default:
// Default to member if unknown permission
relation = zanzana.RelationTeamMember
}
// Create tuple: user:{subjectUID} has {relation} relation to team:{teamUID}
tuple := &v1.TupleKey{
User: zanzana.NewTupleEntry(zanzana.TypeUser, tb.Spec.Subject.Name, ""),
Relation: relation,
Object: zanzana.NewTupleEntry(zanzana.TypeTeam, tb.Spec.TeamRef.Name, ""),
}
return tuple, nil
}
// AfterTeamBindingCreate is a post-create hook that writes the team binding to Zanzana (openFGA)
func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingCreate(obj runtime.Object, _ *metav1.CreateOptions) {
if b.zClient == nil {
return
}
tb, ok := obj.(*iamv0.TeamBinding)
if !ok {
b.logger.Error("failed to convert object to TeamBinding type", "object", obj)
return
}
resourceType := "teambinding"
operation := "create"
// Grab a ticket to write to Zanzana
// This limits the amount of concurrent connections to Zanzana
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds())
go func(tb *iamv0.TeamBinding) {
start := time.Now()
status := "success"
defer func() {
// Release the ticket after write is done
<-b.zTickets
// Record operation duration and count
hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds())
hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc()
}()
tuple, err := convertTeamBindingToTuple(tb)
if err != nil {
b.logger.Error("failed to convert team binding to tuple",
"namespace", tb.Namespace,
"name", tb.Name,
"subject", tb.Spec.Subject.Name,
"teamRef", tb.Spec.TeamRef.Name,
"permission", tb.Spec.Permission,
"err", err,
)
status = "failure"
return
}
b.logger.Debug("writing team binding to zanzana",
"namespace", tb.Namespace,
"name", tb.Name,
"subject", tb.Spec.Subject.Name,
"teamRef", tb.Spec.TeamRef.Name,
"permission", tb.Spec.Permission,
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
err = b.zClient.Write(ctx, &v1.WriteRequest{
Namespace: tb.Namespace,
Writes: &v1.WriteRequestWrites{
TupleKeys: []*v1.TupleKey{tuple},
},
})
if err != nil {
status = "failure"
b.logger.Error("failed to write team binding to zanzana",
"err", err,
"namespace", tb.Namespace,
"name", tb.Name,
"subject", tb.Spec.Subject.Name,
"teamRef", tb.Spec.TeamRef.Name,
"permission", tb.Spec.Permission,
)
} else {
// Record successful tuple write
hooksTuplesCounter.WithLabelValues(resourceType, operation, "write").Inc()
}
}(tb.DeepCopy()) // Pass a copy of the object
}
// BeginTeamBindingUpdate is a pre-update hook that prepares zanzana updates
// It converts old and new team bindings to tuples and performs the zanzana write after K8s update succeeds
func (b *IdentityAccessManagementAPIBuilder) BeginTeamBindingUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) {
if b.zClient == nil {
return nil, nil
}
// Extract team bindings from both old and new objects
oldTB, ok := oldObj.(*iamv0.TeamBinding)
if !ok {
return nil, nil
}
newTB, ok := obj.(*iamv0.TeamBinding)
if !ok {
return nil, nil
}
if oldTB.Spec.Subject.Name == newTB.Spec.Subject.Name && oldTB.Spec.TeamRef.Name == newTB.Spec.TeamRef.Name && oldTB.Spec.Permission == newTB.Spec.Permission {
return nil, nil // No changes to the team binding
}
if newTB.Spec.Subject.Name == "" || newTB.Spec.TeamRef.Name == "" {
b.logger.Error("invalid team binding",
"namespace", newTB.Namespace,
"name", newTB.Name,
"subject", newTB.Spec.Subject.Name,
"teamRef", newTB.Spec.TeamRef.Name,
)
return nil, nil
}
// Convert old team binding to tuple for deletion
var oldTuple *v1.TupleKey
var oldErr error
if oldTB.Spec.Subject.Name != "" && oldTB.Spec.TeamRef.Name != "" {
oldTuple, oldErr = convertTeamBindingToTuple(oldTB)
if oldErr != nil {
b.logger.Error("failed to convert old team binding to tuple",
"namespace", oldTB.Namespace,
"name", oldTB.Name,
"err", oldErr,
)
return nil, nil
}
}
// Convert new team binding to tuple for writing
var newTuple *v1.TupleKey
var newErr error
if newTB.Spec.Subject.Name != "" && newTB.Spec.TeamRef.Name != "" {
newTuple, newErr = convertTeamBindingToTuple(newTB)
if newErr != nil {
b.logger.Error("failed to convert new team binding to tuple",
"namespace", newTB.Namespace,
"name", newTB.Name,
"err", newErr,
)
return nil, nil
}
}
// Return a finish function that performs the zanzana write only on success
return func(ctx context.Context, success bool) {
if !success {
return
}
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues("teambinding", "update").Observe(time.Since(wait).Seconds())
go func() {
start := time.Now()
status := "success"
defer func() {
<-b.zTickets
// Record operation duration and count
hooksDurationHistogram.WithLabelValues("teambinding", "update", status).Observe(time.Since(start).Seconds())
hooksOperationCounter.WithLabelValues("teambinding", "update", status).Inc()
}()
b.logger.Debug("updating team binding in zanzana",
"namespace", newTB.Namespace,
"name", newTB.Name,
"oldSubject", oldTB.Spec.Subject.Name,
"newSubject", newTB.Spec.Subject.Name,
"oldTeamRef", oldTB.Spec.TeamRef.Name,
"newTeamRef", newTB.Spec.TeamRef.Name,
"oldPermission", oldTB.Spec.Permission,
"newPermission", newTB.Spec.Permission,
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
// Prepare write request
req := &v1.WriteRequest{
Namespace: newTB.Namespace,
}
// Add delete for old tuple
if oldTuple != nil && oldErr == nil {
deleteTuple := toTupleKeysWithoutCondition([]*v1.TupleKey{oldTuple})
req.Deletes = &v1.WriteRequestDeletes{
TupleKeys: deleteTuple,
}
b.logger.Debug("deleting existing team binding from zanzana",
"namespace", newTB.Namespace,
"subject", oldTB.Spec.Subject.Name,
"teamRef", oldTB.Spec.TeamRef.Name,
)
}
// Add write for new tuple
if newTuple != nil && newErr == nil {
req.Writes = &v1.WriteRequestWrites{
TupleKeys: []*v1.TupleKey{newTuple},
}
b.logger.Debug("writing new team binding to zanzana",
"namespace", newTB.Namespace,
"subject", newTB.Spec.Subject.Name,
"teamRef", newTB.Spec.TeamRef.Name,
)
}
// Only make the request if there are deletes or writes
if (req.Deletes != nil && len(req.Deletes.TupleKeys) > 0) || (req.Writes != nil && len(req.Writes.TupleKeys) > 0) {
err := b.zClient.Write(ctx, req)
if err != nil {
status = "failure"
b.logger.Error("failed to update team binding in zanzana",
"err", err,
"namespace", newTB.Namespace,
"name", newTB.Name,
)
} else {
// Record successful tuple operations
if oldTuple != nil && oldErr == nil {
hooksTuplesCounter.WithLabelValues("teambinding", "update", "delete").Inc()
}
if newTuple != nil && newErr == nil {
hooksTuplesCounter.WithLabelValues("teambinding", "update", "write").Inc()
}
}
} else {
b.logger.Debug("no tuples to update in zanzana", "namespace", newTB.Namespace, "name", newTB.Name)
}
}()
}, nil
}
// AfterTeamBindingDelete is a post-delete hook that removes the team binding from Zanzana (openFGA)
func (b *IdentityAccessManagementAPIBuilder) AfterTeamBindingDelete(obj runtime.Object, _ *metav1.DeleteOptions) {
if b.zClient == nil {
return
}
tb, ok := obj.(*iamv0.TeamBinding)
if !ok {
b.logger.Error("failed to convert object to TeamBinding type", "object", obj)
return
}
resourceType := "teambinding"
operation := "delete"
// Grab a ticket to write to Zanzana
// This limits the amount of concurrent connections to Zanzana
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds())
go func(tb *iamv0.TeamBinding) {
start := time.Now()
status := "success"
defer func() {
// Release the ticket after write is done
<-b.zTickets
// Record operation duration and count
hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds())
hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc()
}()
tuple, err := convertTeamBindingToTuple(tb)
if err != nil {
b.logger.Error("failed to convert team binding to tuple for deletion",
"namespace", tb.Namespace,
"name", tb.Name,
"subject", tb.Spec.Subject.Name,
"teamRef", tb.Spec.TeamRef.Name,
"err", err,
)
status = "failure"
return
}
// Convert tuple to TupleKeyWithoutCondition for deletion
deleteTuple := toTupleKeysWithoutCondition([]*v1.TupleKey{tuple})
b.logger.Debug("deleting team binding from zanzana",
"namespace", tb.Namespace,
"name", tb.Name,
"subject", tb.Spec.Subject.Name,
"teamRef", tb.Spec.TeamRef.Name,
"permission", tb.Spec.Permission,
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
err = b.zClient.Write(ctx, &v1.WriteRequest{
Namespace: tb.Namespace,
Deletes: &v1.WriteRequestDeletes{
TupleKeys: deleteTuple,
},
})
if err != nil {
status = "failure"
b.logger.Error("failed to delete team binding from zanzana",
"err", err,
"namespace", tb.Namespace,
"name", tb.Name,
"subject", tb.Spec.Subject.Name,
"teamRef", tb.Spec.TeamRef.Name,
)
} else {
// Record successful tuple deletion
hooksTuplesCounter.WithLabelValues(resourceType, operation, "delete").Inc()
}
}(tb.DeepCopy()) // Pass a copy of the object
}
@@ -0,0 +1,966 @@
package iam
import (
"context"
"sync"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/infra/log"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/stretchr/testify/require"
)
func TestAfterTeamBindingCreate(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should create zanzana entry for team binding with member permission", func(t *testing.T) {
wg.Add(1)
teamBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-1",
Namespace: "org-1",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
External: false,
},
}
testMemberBinding := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 1)
require.Equal(t, "org-1", req.Namespace)
require.Nil(t, req.Deletes)
expectedTuple := &v1.TupleKey{
User: "user:user-1",
Relation: "member",
Object: "team:team-1",
}
actualTuple := req.Writes.TupleKeys[0]
require.Equal(t, expectedTuple.User, actualTuple.User)
require.Equal(t, expectedTuple.Relation, actualTuple.Relation)
require.Equal(t, expectedTuple.Object, actualTuple.Object)
require.Nil(t, actualTuple.Condition)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testMemberBinding}
b.AfterTeamBindingCreate(&teamBinding, nil)
wg.Wait()
})
t.Run("should create zanzana entry for team binding with admin permission", func(t *testing.T) {
wg.Add(1)
teamBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-2",
Namespace: "org-2",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-2",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-2",
},
Permission: iamv0.TeamBindingTeamPermissionAdmin,
External: true,
},
}
testAdminBinding := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 1)
require.Equal(t, "org-2", req.Namespace)
require.Nil(t, req.Deletes)
expectedTuple := &v1.TupleKey{
User: "user:user-2",
Relation: "admin",
Object: "team:team-2",
}
actualTuple := req.Writes.TupleKeys[0]
require.Equal(t, expectedTuple.User, actualTuple.User)
require.Equal(t, expectedTuple.Relation, actualTuple.Relation)
require.Equal(t, expectedTuple.Object, actualTuple.Object)
require.Nil(t, actualTuple.Condition)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testAdminBinding}
b.AfterTeamBindingCreate(&teamBinding, nil)
wg.Wait()
})
t.Run("should not write to zanzana when zClient is nil", func(t *testing.T) {
builder := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
zClient: nil,
}
teamBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-3",
Namespace: "org-3",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-3",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-3",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
// Should not panic or error when zClient is nil
builder.AfterTeamBindingCreate(&teamBinding, nil)
})
t.Run("should handle conversion error gracefully", func(t *testing.T) {
// TeamBinding with empty subject name should fail conversion
teamBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-4",
Namespace: "org-4",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "", // Empty name should cause error
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-4",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
writeCalled := false
testErrorHandling := func(ctx context.Context, req *v1.WriteRequest) error {
writeCalled = true
// Should not be called due to conversion error
require.Fail(t, "Write should not be called when conversion fails")
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testErrorHandling}
b.AfterTeamBindingCreate(&teamBinding, nil)
// Wait a bit to ensure the goroutine has time to process
// The goroutine will complete but won't call the write callback
time.Sleep(100 * time.Millisecond)
require.False(t, writeCalled, "Write callback should not be called when conversion fails")
})
}
func TestBeginTeamBindingUpdate(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should update zanzana entry when permission changes from member to admin", func(t *testing.T) {
wg.Add(1)
oldBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-1",
Namespace: "org-1",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
newBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-1",
Namespace: "org-1",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionAdmin,
},
}
testPermissionUpdate := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-1", req.Namespace)
// Should delete old member permission
require.NotNil(t, req.Deletes)
require.Len(t, req.Deletes.TupleKeys, 1)
require.Equal(
t,
req.Deletes.TupleKeys[0],
&v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"},
)
// Should write new admin permission
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 1)
require.Equal(
t,
req.Writes.TupleKeys[0],
&v1.TupleKey{User: "user:user-1", Relation: "admin", Object: "team:team-1"},
)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testPermissionUpdate}
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
finishFunc(context.Background(), true)
wg.Wait()
})
t.Run("should update zanzana entry when user changes", func(t *testing.T) {
wg.Add(1)
oldBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-2",
Namespace: "org-2",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
newBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-2",
Namespace: "org-2",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-2",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
testUserUpdate := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-2", req.Namespace)
// Should delete old user binding
require.NotNil(t, req.Deletes)
require.Len(t, req.Deletes.TupleKeys, 1)
require.Equal(
t,
req.Deletes.TupleKeys[0],
&v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"},
)
// Should write new user binding
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 1)
require.Equal(
t,
req.Writes.TupleKeys[0],
&v1.TupleKey{User: "user:user-2", Relation: "member", Object: "team:team-1"},
)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testUserUpdate}
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
finishFunc(context.Background(), true)
wg.Wait()
})
t.Run("should update zanzana entry when team changes", func(t *testing.T) {
wg.Add(1)
oldBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-3",
Namespace: "org-3",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionAdmin,
},
}
newBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-3",
Namespace: "org-3",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-2",
},
Permission: iamv0.TeamBindingTeamPermissionAdmin,
},
}
testTeamUpdate := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-3", req.Namespace)
// Should delete old team binding
require.NotNil(t, req.Deletes)
require.Len(t, req.Deletes.TupleKeys, 1)
require.Equal(
t,
req.Deletes.TupleKeys[0],
&v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "admin", Object: "team:team-1"},
)
// Should write new team binding
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 1)
require.Equal(
t,
req.Writes.TupleKeys[0],
&v1.TupleKey{User: "user:user-1", Relation: "admin", Object: "team:team-2"},
)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testTeamUpdate}
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
finishFunc(context.Background(), true)
wg.Wait()
})
t.Run("should not write to zanzana when update fails", func(t *testing.T) {
oldBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-4",
Namespace: "org-4",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
newBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-4",
Namespace: "org-4",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-2",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
testNoWriteOnFailure := func(ctx context.Context, req *v1.WriteRequest) error {
// Should not be called when success=false
require.Fail(t, "Write should not be called when update fails")
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnFailure}
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
// Call finish function with success=false
finishFunc(context.Background(), false)
// No wait needed since write should not be called
})
t.Run("should not write to zanzana when zClient is nil", func(t *testing.T) {
builder := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
zClient: nil,
}
oldBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-5",
Namespace: "org-5",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
newBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-5",
Namespace: "org-5",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-2",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
finishFunc, err := builder.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.Nil(t, finishFunc) // Should return nil when zClient is nil
})
t.Run("should handle empty old binding subject name gracefully", func(t *testing.T) {
wg.Add(1)
oldBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-6",
Namespace: "org-6",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "", // Empty name - conversion will be skipped
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
newBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-6",
Namespace: "org-6",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-2",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
testEmptyOldBinding := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-6", req.Namespace)
// Should not delete old binding (it was skipped due to empty name)
require.Nil(t, req.Deletes)
// Should write new binding
require.NotNil(t, req.Writes)
require.Len(t, req.Writes.TupleKeys, 1)
require.Equal(
t,
req.Writes.TupleKeys[0],
&v1.TupleKey{User: "user:user-2", Relation: "member", Object: "team:team-1"},
)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testEmptyOldBinding}
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc) // Should still return finish function
finishFunc(context.Background(), true)
wg.Wait()
})
t.Run("should return nil finish func when bindings are identical", func(t *testing.T) {
oldBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-7",
Namespace: "org-7",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
newBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-7",
Namespace: "org-7",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
writeCalled := false
testNoWriteOnNoChange := func(ctx context.Context, req *v1.WriteRequest) error {
writeCalled = true
require.Fail(t, "Write should not be called when bindings are identical")
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnNoChange}
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.Nil(t, finishFunc) // Should return nil when bindings are identical
// Verify write was never called
time.Sleep(100 * time.Millisecond)
require.False(t, writeCalled, "Write callback should not be called when bindings are identical")
})
t.Run("should return nil finish func when new binding has empty subject name", func(t *testing.T) {
oldBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-8",
Namespace: "org-8",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
newBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-8",
Namespace: "org-8",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "", // Empty name - should cause early return
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
writeCalled := false
testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.WriteRequest) error {
writeCalled = true
require.Fail(t, "Write should not be called when new binding has empty subject name")
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnInvalidBinding}
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.Nil(t, finishFunc) // Should return nil when new binding has empty subject name
// Verify write was never called
time.Sleep(100 * time.Millisecond)
require.False(t, writeCalled, "Write callback should not be called when new binding has empty subject name")
})
t.Run("should return nil finish func when new binding has empty team ref name", func(t *testing.T) {
oldBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-9",
Namespace: "org-9",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
newBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-9",
Namespace: "org-9",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-2",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "", // Empty name - should cause early return
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
writeCalled := false
testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.WriteRequest) error {
writeCalled = true
require.Fail(t, "Write should not be called when new binding has empty team ref name")
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testNoWriteOnInvalidBinding}
finishFunc, err := b.BeginTeamBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.Nil(t, finishFunc) // Should return nil when new binding has empty team ref name
// Verify write was never called
time.Sleep(100 * time.Millisecond)
require.False(t, writeCalled, "Write callback should not be called when new binding has empty team ref name")
})
}
func TestAfterTeamBindingDelete(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should delete zanzana entry for team binding with member permission", func(t *testing.T) {
wg.Add(1)
teamBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-1",
Namespace: "org-1",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
External: false,
},
}
testMemberDelete := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-1", req.Namespace)
// Should have deletes but no writes
require.NotNil(t, req.Deletes)
require.Len(t, req.Deletes.TupleKeys, 1)
require.Nil(t, req.Writes)
require.Equal(
t,
req.Deletes.TupleKeys[0],
&v1.TupleKeyWithoutCondition{User: "user:user-1", Relation: "member", Object: "team:team-1"},
)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testMemberDelete}
b.AfterTeamBindingDelete(&teamBinding, nil)
wg.Wait()
})
t.Run("should delete zanzana entry for team binding with admin permission", func(t *testing.T) {
wg.Add(1)
teamBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-2",
Namespace: "org-2",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-2",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-2",
},
Permission: iamv0.TeamBindingTeamPermissionAdmin,
External: true,
},
}
testAdminDelete := func(ctx context.Context, req *v1.WriteRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-2", req.Namespace)
// Should have deletes but no writes
require.NotNil(t, req.Deletes)
require.Len(t, req.Deletes.TupleKeys, 1)
require.Nil(t, req.Writes)
require.Equal(
t,
req.Deletes.TupleKeys[0],
&v1.TupleKeyWithoutCondition{User: "user:user-2", Relation: "admin", Object: "team:team-2"},
)
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testAdminDelete}
b.AfterTeamBindingDelete(&teamBinding, nil)
wg.Wait()
})
t.Run("should not delete from zanzana when zClient is nil", func(t *testing.T) {
builder := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
zClient: nil,
}
teamBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-3",
Namespace: "org-3",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-3",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-3",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
// Should not panic or error when zClient is nil
builder.AfterTeamBindingDelete(&teamBinding, nil)
})
t.Run("should handle conversion error gracefully", func(t *testing.T) {
// TeamBinding with empty team ref name should fail conversion
teamBinding := iamv0.TeamBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-4",
Namespace: "org-4",
},
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-4",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "", // Empty name should cause error
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
writeCalled := false
testErrorHandling := func(ctx context.Context, req *v1.WriteRequest) error {
writeCalled = true
// Should not be called due to conversion error
require.Fail(t, "Write should not be called when conversion fails")
return nil
}
b.zClient = &FakeZanzanaClient{writeCallback: testErrorHandling}
b.AfterTeamBindingDelete(&teamBinding, nil)
// Wait a bit to ensure the goroutine has time to process
// The goroutine will complete but won't call the write callback
time.Sleep(100 * time.Millisecond)
require.False(t, writeCalled, "Write callback should not be called when conversion fails")
})
}
func TestConvertTeamBindingToTuple(t *testing.T) {
t.Run("should convert member permission correctly", func(t *testing.T) {
tb := &iamv0.TeamBinding{
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
tuple, err := convertTeamBindingToTuple(tb)
require.NoError(t, err)
require.NotNil(t, tuple)
require.Equal(t, "user:user-1", tuple.User)
require.Equal(t, "member", tuple.Relation)
require.Equal(t, "team:team-1", tuple.Object)
require.Nil(t, tuple.Condition)
})
t.Run("should convert admin permission correctly", func(t *testing.T) {
tb := &iamv0.TeamBinding{
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-2",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-2",
},
Permission: iamv0.TeamBindingTeamPermissionAdmin,
},
}
tuple, err := convertTeamBindingToTuple(tb)
require.NoError(t, err)
require.NotNil(t, tuple)
require.Equal(t, "user:user-2", tuple.User)
require.Equal(t, "admin", tuple.Relation)
require.Equal(t, "team:team-2", tuple.Object)
require.Nil(t, tuple.Condition)
})
t.Run("should return error for empty subject name", func(t *testing.T) {
tb := &iamv0.TeamBinding{
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
tuple, err := convertTeamBindingToTuple(tb)
require.Error(t, err)
require.Nil(t, tuple)
require.Equal(t, errEmptyName, err)
})
t.Run("should return error for empty team ref name", func(t *testing.T) {
tb := &iamv0.TeamBinding{
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "",
},
Permission: iamv0.TeamBindingTeamPermissionMember,
},
}
tuple, err := convertTeamBindingToTuple(tb)
require.Error(t, err)
require.Nil(t, tuple)
require.Equal(t, errEmptyName, err)
})
t.Run("should default to member for unknown permission", func(t *testing.T) {
tb := &iamv0.TeamBinding{
Spec: iamv0.TeamBindingSpec{
Subject: iamv0.TeamBindingspecSubject{
Name: "user-1",
},
TeamRef: iamv0.TeamBindingTeamRef{
Name: "team-1",
},
Permission: "unknown", // Invalid permission
},
}
tuple, err := convertTeamBindingToTuple(tb)
require.NoError(t, err)
require.NotNil(t, tuple)
// Should default to member relation
require.Equal(t, "member", tuple.Relation)
})
}
+238
View File
@@ -0,0 +1,238 @@
package iam
import (
"context"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/generic/registry"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
)
// AfterUserCreate is a post-create hook that writes the user's basic role assignment to Zanzana (openFGA)
func (b *IdentityAccessManagementAPIBuilder) AfterUserCreate(obj runtime.Object, _ *metav1.CreateOptions) {
if b.zClient == nil {
return
}
user, ok := obj.(*iamv0.User)
if !ok {
b.logger.Error("failed to convert object to User type", "object", obj)
return
}
// Skip if user has no role assigned
if user.Spec.Role == "" {
b.logger.Debug("user has no role assigned, skipping basic role sync",
"namespace", user.Namespace,
"name", user.Name,
)
return
}
resourceType := "user"
operation := "create"
// Grab a ticket to write to Zanzana
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds())
go func(namespace, subjectName, role, resourceType, operation string) {
start := time.Now()
status := "success"
defer func() {
<-b.zTickets
hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds())
hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc()
}()
b.logger.Debug("writing user basic role to zanzana",
"namespace", namespace,
"name", subjectName,
"role", role,
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
Namespace: namespace,
Operations: []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_UpdateUserOrgRole{
UpdateUserOrgRole: &v1.UpdateUserOrgRoleOperation{User: subjectName, Role: role},
},
},
},
})
if err != nil {
status = "failure"
b.logger.Error("failed to write user basic role to zanzana",
"err", err,
"namespace", namespace,
"name", subjectName,
"role", role,
)
} else {
hooksTuplesCounter.WithLabelValues(resourceType, operation, "write").Inc()
}
}(user.Namespace, user.Name, user.Spec.Role, resourceType, operation)
}
// BeginUserUpdate is a pre-update hook that gets called on user updates
// It compares old and new roles and performs the zanzana write after K8s update succeeds
func (b *IdentityAccessManagementAPIBuilder) BeginUserUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) {
if b.zClient == nil {
return nil, nil
}
oldUser, ok := oldObj.(*iamv0.User)
if !ok {
return nil, nil
}
newUser, ok := obj.(*iamv0.User)
if !ok {
return nil, nil
}
// If role hasn't changed, no need to update
if oldUser.Spec.Role == newUser.Spec.Role {
return nil, nil
}
// Return a finish function that performs the zanzana write only on success
return func(ctx context.Context, success bool) {
if !success {
return
}
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues("user", "update").Observe(time.Since(wait).Seconds())
go func(namespace, subjectName, oldRole, newRole string) {
start := time.Now()
status := "success"
defer func() {
<-b.zTickets
hooksDurationHistogram.WithLabelValues("user", "update", status).Observe(time.Since(start).Seconds())
hooksOperationCounter.WithLabelValues("user", "update", status).Inc()
}()
b.logger.Debug("updating user basic role in zanzana",
"namespace", namespace,
"name", subjectName,
"oldRole", oldRole,
"newRole", newRole,
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
Namespace: namespace,
Operations: []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_UpdateUserOrgRole{
UpdateUserOrgRole: &v1.UpdateUserOrgRoleOperation{User: subjectName, Role: newRole},
},
}, {
Operation: &v1.MutateOperation_DeleteUserOrgRole{
DeleteUserOrgRole: &v1.DeleteUserOrgRoleOperation{User: subjectName, Role: oldRole},
},
},
},
})
if err != nil {
status = "failure"
b.logger.Error("failed to update user basic role in zanzana",
"err", err,
"namespace", namespace,
"name", subjectName,
"role", newRole,
"oldRole", oldRole,
)
}
}(oldUser.Namespace, oldUser.Name, oldUser.Spec.Role, newUser.Spec.Role)
}, nil
}
// AfterUserDelete is a post-delete hook that removes the user's basic role assignment from Zanzana (openFGA)
func (b *IdentityAccessManagementAPIBuilder) AfterUserDelete(obj runtime.Object, _ *metav1.DeleteOptions) {
if b.zClient == nil {
return
}
user, ok := obj.(*iamv0.User)
if !ok {
b.logger.Error("failed to convert object to User type", "object", obj)
return
}
resourceType := "user"
operation := "delete"
// Skip if user had no role assigned
if user.Spec.Role == "" {
b.logger.Debug("user had no role assigned, skipping basic role sync",
"namespace", user.Namespace,
"name", user.Name,
)
return
}
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds())
go func(namespace, subjectName, role string) {
start := time.Now()
status := "success"
defer func() {
<-b.zTickets
hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds())
hooksOperationCounter.WithLabelValues(resourceType, operation, status).Inc()
}()
b.logger.Debug("deleting user basic role from zanzana",
"namespace", namespace,
"name", subjectName,
"role", role,
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
Namespace: namespace,
Operations: []*v1.MutateOperation{
{
Operation: &v1.MutateOperation_DeleteUserOrgRole{
DeleteUserOrgRole: &v1.DeleteUserOrgRoleOperation{User: subjectName, Role: role},
},
},
},
})
if err != nil {
status = "failure"
b.logger.Error("failed to delete user basic role from zanzana",
"err", err,
"namespace", namespace,
"name", subjectName,
"role", role,
)
} else {
hooksTuplesCounter.WithLabelValues(resourceType, operation, "delete").Inc()
}
}(user.Namespace, user.Name, user.Spec.Role)
}
@@ -0,0 +1,569 @@
package iam
import (
"context"
"sync"
"testing"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/infra/log"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
"github.com/stretchr/testify/require"
)
func TestAfterUserCreate(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should create zanzana entry for user with Admin role", func(t *testing.T) {
wg.Add(1)
user := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "df2p421det1q8c",
Namespace: "org-1",
},
Spec: iamv0.UserSpec{
Role: "Admin",
},
}
testAdminRole := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-1", req.Namespace)
require.Len(t, req.Operations, 1)
op := req.Operations[0]
require.NotNil(t, op)
updateOp := op.GetUpdateUserOrgRole()
require.NotNil(t, updateOp)
require.Equal(t, "df2p421det1q8c", updateOp.User)
require.Equal(t, "Admin", updateOp.Role)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testAdminRole}
b.AfterUserCreate(&user, nil)
wg.Wait()
})
t.Run("should create zanzana entry for user with Editor role", func(t *testing.T) {
wg.Add(1)
user := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "user123",
Namespace: "org-2",
},
Spec: iamv0.UserSpec{
Role: "Editor",
},
}
testEditorRole := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-2", req.Namespace)
require.Len(t, req.Operations, 1)
op := req.Operations[0]
require.NotNil(t, op)
updateOp := op.GetUpdateUserOrgRole()
require.NotNil(t, updateOp)
require.Equal(t, "user123", updateOp.User)
require.Equal(t, "Editor", updateOp.Role)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testEditorRole}
b.AfterUserCreate(&user, nil)
wg.Wait()
})
t.Run("should create zanzana entry for user with Viewer role", func(t *testing.T) {
wg.Add(1)
user := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "viewer456",
Namespace: "org-3",
},
Spec: iamv0.UserSpec{
Role: "Viewer",
},
}
testViewerRole := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-3", req.Namespace)
require.Len(t, req.Operations, 1)
op := req.Operations[0]
require.NotNil(t, op)
updateOp := op.GetUpdateUserOrgRole()
require.NotNil(t, updateOp)
require.Equal(t, "viewer456", updateOp.User)
require.Equal(t, "Viewer", updateOp.Role)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testViewerRole}
b.AfterUserCreate(&user, nil)
wg.Wait()
})
t.Run("should skip when user has no role", func(t *testing.T) {
user := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "norole789",
Namespace: "org-4",
},
Spec: iamv0.UserSpec{
Role: "",
},
}
// Should not call zanzana client
b.zClient = nil
b.AfterUserCreate(&user, nil)
// If we get here without panic, the test passes
})
t.Run("should skip when zClient is nil", func(t *testing.T) {
builder := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
zClient: nil,
}
user := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "testuser",
Namespace: "org-1",
},
Spec: iamv0.UserSpec{
Role: "Admin",
},
}
// Should return early without calling zanzana
builder.AfterUserCreate(&user, nil)
// If we get here without panic, the test passes
})
}
func TestBeginUserUpdate(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should update zanzana entry when role changes from Viewer to Admin", func(t *testing.T) {
wg.Add(1)
oldUser := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "testuser",
Namespace: "org-1",
},
Spec: iamv0.UserSpec{
Role: "Viewer",
},
}
newUser := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "testuser",
Namespace: "org-1",
},
Spec: iamv0.UserSpec{
Role: "Admin",
},
}
testRoleChange := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-1", req.Namespace)
require.Len(t, req.Operations, 2)
// First operation should be UpdateUserOrgRole with new role
updateOp := req.Operations[0].GetUpdateUserOrgRole()
require.NotNil(t, updateOp)
require.Equal(t, "testuser", updateOp.User)
require.Equal(t, "Admin", updateOp.Role)
// Second operation should be DeleteUserOrgRole with old role
deleteOp := req.Operations[1].GetDeleteUserOrgRole()
require.NotNil(t, deleteOp)
require.Equal(t, "testuser", deleteOp.User)
require.Equal(t, "Viewer", deleteOp.Role)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testRoleChange}
finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
finishFunc(context.Background(), true)
wg.Wait()
})
t.Run("should update role when new role is empty", func(t *testing.T) {
wg.Add(1)
oldUser := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "testuser2",
Namespace: "org-2",
},
Spec: iamv0.UserSpec{
Role: "Editor",
},
}
newUser := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "testuser2",
Namespace: "org-2",
},
Spec: iamv0.UserSpec{
Role: "",
},
}
testRemoveRole := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-2", req.Namespace)
require.Len(t, req.Operations, 2)
// First operation should be UpdateUserOrgRole with empty role
updateOp := req.Operations[0].GetUpdateUserOrgRole()
require.NotNil(t, updateOp)
require.Equal(t, "testuser2", updateOp.User)
require.Equal(t, "", updateOp.Role)
// Second operation should be DeleteUserOrgRole with old role
deleteOp := req.Operations[1].GetDeleteUserOrgRole()
require.NotNil(t, deleteOp)
require.Equal(t, "testuser2", deleteOp.User)
require.Equal(t, "Editor", deleteOp.Role)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testRemoveRole}
finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
finishFunc(context.Background(), true)
wg.Wait()
})
t.Run("should be able to add a new role when old role was empty", func(t *testing.T) {
wg.Add(1)
oldUser := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "testuser3",
Namespace: "org-3",
},
Spec: iamv0.UserSpec{
Role: "",
},
}
newUser := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "testuser3",
Namespace: "org-3",
},
Spec: iamv0.UserSpec{
Role: "Admin",
},
}
testAddRole := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-3", req.Namespace)
require.Len(t, req.Operations, 2)
// First operation should be UpdateUserOrgRole with new role
updateOp := req.Operations[0].GetUpdateUserOrgRole()
require.NotNil(t, updateOp)
require.Equal(t, "testuser3", updateOp.User)
require.Equal(t, "Admin", updateOp.Role)
// Second operation should be DeleteUserOrgRole with empty old role
deleteOp := req.Operations[1].GetDeleteUserOrgRole()
require.NotNil(t, deleteOp)
require.Equal(t, "testuser3", deleteOp.User)
require.Equal(t, "", deleteOp.Role)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testAddRole}
finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
finishFunc(context.Background(), true)
wg.Wait()
})
t.Run("should skip update when role hasn't changed", func(t *testing.T) {
oldUser := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "testuser4",
Namespace: "org-4",
},
Spec: iamv0.UserSpec{
Role: "Editor",
},
}
newUser := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "testuser4",
Namespace: "org-4",
},
Spec: iamv0.UserSpec{
Role: "Editor",
},
}
finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil)
require.NoError(t, err)
require.Nil(t, finishFunc) // Should return nil when no update needed
})
t.Run("should not call zanzana when update fails", func(t *testing.T) {
oldUser := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "testuser5",
Namespace: "org-5",
},
Spec: iamv0.UserSpec{
Role: "Viewer",
},
}
newUser := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "testuser5",
Namespace: "org-5",
},
Spec: iamv0.UserSpec{
Role: "Admin",
},
}
callCount := 0
testNoCall := func(ctx context.Context, req *v1.MutateRequest) error {
callCount++
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testNoCall}
finishFunc, err := b.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
// Call with success=false - should not trigger zanzana write
finishFunc(context.Background(), false)
require.Equal(t, 0, callCount, "zanzana should not be called when update fails")
})
t.Run("should skip when zClient is nil", func(t *testing.T) {
builder := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
zClient: nil,
}
oldUser := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "testuser",
Namespace: "org-1",
},
Spec: iamv0.UserSpec{
Role: "Viewer",
},
}
newUser := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "testuser",
Namespace: "org-1",
},
Spec: iamv0.UserSpec{
Role: "Admin",
},
}
finishFunc, err := builder.BeginUserUpdate(context.Background(), &newUser, &oldUser, nil)
require.NoError(t, err)
require.Nil(t, finishFunc) // Should return nil when zClient is nil
})
}
func TestAfterUserDelete(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should delete zanzana entry for user with Admin role", func(t *testing.T) {
wg.Add(1)
user := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "df2p421det1q8c",
Namespace: "org-1",
},
Spec: iamv0.UserSpec{
Role: "Admin",
},
}
testDeleteAdmin := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-1", req.Namespace)
require.Len(t, req.Operations, 1)
op := req.Operations[0]
require.NotNil(t, op)
deleteOp := op.GetDeleteUserOrgRole()
require.NotNil(t, deleteOp)
require.Equal(t, "df2p421det1q8c", deleteOp.User)
require.Equal(t, "Admin", deleteOp.Role)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testDeleteAdmin}
b.AfterUserDelete(&user, nil)
wg.Wait()
})
t.Run("should delete zanzana entry for user with Editor role", func(t *testing.T) {
wg.Add(1)
user := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "editor123",
Namespace: "org-2",
},
Spec: iamv0.UserSpec{
Role: "Editor",
},
}
testDeleteEditor := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-2", req.Namespace)
require.Len(t, req.Operations, 1)
op := req.Operations[0]
require.NotNil(t, op)
deleteOp := op.GetDeleteUserOrgRole()
require.NotNil(t, deleteOp)
require.Equal(t, "editor123", deleteOp.User)
require.Equal(t, "Editor", deleteOp.Role)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testDeleteEditor}
b.AfterUserDelete(&user, nil)
wg.Wait()
})
t.Run("should delete zanzana entry for user with Viewer role", func(t *testing.T) {
wg.Add(1)
user := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "viewer456",
Namespace: "org-3",
},
Spec: iamv0.UserSpec{
Role: "Viewer",
},
}
testDeleteViewer := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-3", req.Namespace)
require.Len(t, req.Operations, 1)
op := req.Operations[0]
require.NotNil(t, op)
deleteOp := op.GetDeleteUserOrgRole()
require.NotNil(t, deleteOp)
require.Equal(t, "viewer456", deleteOp.User)
require.Equal(t, "Viewer", deleteOp.Role)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testDeleteViewer}
b.AfterUserDelete(&user, nil)
wg.Wait()
})
t.Run("should skip when user has no role", func(t *testing.T) {
user := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "norole789",
Namespace: "org-4",
},
Spec: iamv0.UserSpec{
Role: "",
},
}
// Should not call zanzana client
b.zClient = nil
b.AfterUserDelete(&user, nil)
// If we get here without panic, the test passes
})
t.Run("should skip when zClient is nil", func(t *testing.T) {
builder := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
zClient: nil,
}
user := iamv0.User{
ObjectMeta: metav1.ObjectMeta{
Name: "testuser",
Namespace: "org-1",
},
Spec: iamv0.UserSpec{
Role: "Admin",
},
}
// Should return early without calling zanzana
builder.AfterUserDelete(&user, nil)
// If we get here without panic, the test passes
})
}
+17 -17
View File
@@ -248,16 +248,6 @@ func (b *APIBuilder) oneFlagHandler(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(ctx)
valid := b.validateNamespace(r)
b.logger.Debug("validating namespace in oneFlagHandler handler", "valid", valid)
if !valid {
_ = tracing.Errorf(span, namespaceMismatchMsg)
span.SetAttributes(semconv.HTTPStatusCode(http.StatusUnauthorized))
b.logger.Error(namespaceMismatchMsg)
http.Error(w, namespaceMismatchMsg, http.StatusUnauthorized)
return
}
flagKey := mux.Vars(r)["flagKey"]
if flagKey == "" {
_ = tracing.Errorf(span, "flagKey parameter is required")
@@ -266,6 +256,16 @@ func (b *APIBuilder) oneFlagHandler(w http.ResponseWriter, r *http.Request) {
return
}
valid, ns := b.validateNamespace(r)
b.logger.Debug("validating namespace in oneFlagHandler handler", "namespace", ns, "valid", valid, "flag", flagKey)
if !valid {
_ = tracing.Errorf(span, namespaceMismatchMsg)
span.SetAttributes(semconv.HTTPStatusCode(http.StatusUnauthorized))
b.logger.Error(namespaceMismatchMsg)
http.Error(w, namespaceMismatchMsg, http.StatusUnauthorized)
return
}
span.SetAttributes(attribute.String("flag_key", flagKey))
isAuthedReq := b.isAuthenticatedRequest(r)
@@ -294,8 +294,8 @@ func (b *APIBuilder) allFlagsHandler(w http.ResponseWriter, r *http.Request) {
r = r.WithContext(ctx)
valid := b.validateNamespace(r)
b.logger.Debug("validating namespace in allFlagsHandler handler", "valid", valid)
valid, ns := b.validateNamespace(r)
b.logger.Debug("validating namespace in allFlagsHandler handler", "namespace", ns, "valid", valid)
if !valid {
_ = tracing.Errorf(span, namespaceMismatchMsg)
@@ -359,14 +359,14 @@ func (b *APIBuilder) isAuthenticatedRequest(r *http.Request) bool {
}
// validateNamespace checks if the namespace in the evaluation context matches the namespace in the request
func (b *APIBuilder) validateNamespace(r *http.Request) bool {
func (b *APIBuilder) validateNamespace(r *http.Request) (bool, string) {
_, span := tracing.Start(r.Context(), "ofrep.validateNamespace")
defer span.End()
var namespace string
user, ok := types.AuthInfoFrom(r.Context())
if !ok {
return false
return false, ""
}
if user.GetNamespace() != "" {
@@ -381,7 +381,7 @@ func (b *APIBuilder) validateNamespace(r *http.Request) bool {
_ = tracing.Errorf(span, "failed to read request body: %w", err)
b.logger.Error("Error reading evaluation request body", "error", err)
span.SetAttributes(attribute.Bool("validation.success", false))
return false
return false, ""
}
r.Body = io.NopCloser(bytes.NewBuffer(body))
@@ -391,9 +391,9 @@ func (b *APIBuilder) validateNamespace(r *http.Request) bool {
// "default" namespace case can only occur in on-prem grafana
if (namespace == "default" && evalCtxNamespace == "") || (evalCtxNamespace == namespace) {
span.SetAttributes(attribute.Bool("validation.success", true))
return true
return true, evalCtxNamespace
}
span.SetAttributes(attribute.Bool("validation.success", false))
return false
return false, evalCtxNamespace
}
@@ -0,0 +1,129 @@
package provisioning
import (
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/org"
)
const (
// Repositories
ActionProvisioningRepositoriesCreate = "provisioning.repositories:create" // CREATE.
ActionProvisioningRepositoriesWrite = "provisioning.repositories:write" // UPDATE.
ActionProvisioningRepositoriesRead = "provisioning.repositories:read" // GET + LIST.
ActionProvisioningRepositoriesDelete = "provisioning.repositories:delete" // DELETE.
// Jobs
ActionProvisioningJobsCreate = "provisioning.jobs:create" // CREATE.
ActionProvisioningJobsWrite = "provisioning.jobs:write" // UPDATE.
ActionProvisioningJobsRead = "provisioning.jobs:read" // GET + LIST.
ActionProvisioningJobsDelete = "provisioning.jobs:delete" // DELETE.
// Historic Jobs
ActionProvisioningHistoricJobsRead = "provisioning.historicjobs:read" // GET + LIST.
)
func registerAccessControlRoles(service accesscontrol.Service) error {
// Repositories
repositoriesReader := accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Name: "fixed:provisioning.repositories:reader",
DisplayName: "Repositories Reader",
Description: "Read and list provisioning repositories.",
Group: "Provisioning",
Permissions: []accesscontrol.Permission{
{
Action: ActionProvisioningRepositoriesRead,
},
},
},
Grants: []string{string(org.RoleAdmin)},
}
repositoriesWriter := accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Name: "fixed:provisioning.repositories:writer",
DisplayName: "Repositories Writer",
Description: "Create, update and delete provisioning repositories.",
Group: "Provisioning",
Permissions: []accesscontrol.Permission{
{
Action: ActionProvisioningRepositoriesCreate,
},
{
Action: ActionProvisioningRepositoriesRead,
},
{
Action: ActionProvisioningRepositoriesWrite,
},
{
Action: ActionProvisioningRepositoriesDelete,
},
},
},
Grants: []string{string(org.RoleAdmin)},
}
// Jobs
jobsReader := accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Name: "fixed:provisioning.jobs:reader",
DisplayName: "Jobs Reader",
Description: "Read and list provisioning jobs.",
Group: "Provisioning",
Permissions: []accesscontrol.Permission{
{
Action: ActionProvisioningJobsRead,
},
},
},
Grants: []string{string(org.RoleAdmin)},
}
jobsWriter := accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Name: "fixed:provisioning.jobs:writer",
DisplayName: "Jobs Writer",
Description: "Create, update and delete provisioning jobs.",
Group: "Provisioning",
Permissions: []accesscontrol.Permission{
{
Action: ActionProvisioningJobsCreate,
},
{
Action: ActionProvisioningJobsRead,
},
{
Action: ActionProvisioningJobsWrite,
},
{
Action: ActionProvisioningJobsDelete,
},
},
},
Grants: []string{string(org.RoleAdmin)},
}
// Historic Jobs
historicJobsReader := accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Name: "fixed:provisioning.historicjobs:reader",
DisplayName: "Historic Jobs Reader",
Description: "Read and list provisioning historic jobs.",
Group: "Provisioning",
Permissions: []accesscontrol.Permission{
{
Action: ActionProvisioningHistoricJobsRead,
},
},
},
Grants: []string{string(org.RoleAdmin)},
}
return service.DeclareFixedRoles(
repositoriesReader,
repositoriesWriter,
jobsReader,
jobsWriter,
historicJobsReader,
)
}
@@ -7,8 +7,11 @@ import (
"slices"
"sort"
"strings"
"sync/atomic"
"time"
"github.com/grafana/dskit/concurrency"
"k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/types"
@@ -27,6 +30,7 @@ type finalizer struct {
lister resources.ResourceLister
clientFactory resources.ClientFactory
metrics *finalizerMetrics
maxWorkers int
}
func (f *finalizer) process(ctx context.Context,
@@ -113,27 +117,73 @@ func (f *finalizer) processExistingItems(
// Safe deletion order
sortResourceListForDeletion(items)
count := 0
var dashboards, folderItems []*provisioning.ResourceListItem
for _, item := range items.Items {
res, _, err := clients.ForResource(ctx, schema.GroupVersionResource{
if item.Group == folders.GroupVersion.Group {
folderItems = append(folderItems, &item)
} else {
dashboards = append(dashboards, &item)
}
}
processItem := func(jobCtx context.Context, item *provisioning.ResourceListItem) error {
res, _, err := clients.ForResource(jobCtx, schema.GroupVersionResource{
Group: item.Group,
Resource: item.Resource,
})
if err != nil {
logger.Error("error getting client for resource", "resource", item.Resource, "error", err)
return count, err
return err
}
err = cb(res, &item)
err = cb(res, item)
if err != nil {
if errors.IsNotFound(err) {
logger.Info("resource not found, skipping", "name", item.Name, "group", item.Group, "resource", item.Resource)
return nil
}
logger.Error("error processing item", "name", item.Name, "error", err)
return count, fmt.Errorf("processing item: %w", err)
} else {
return fmt.Errorf("processing item: %w", err)
}
return nil
}
processGroup := func(group []*provisioning.ResourceListItem) (int, error) {
var processed int64
err := concurrency.ForEachJob(ctx, len(group), f.maxWorkers, func(ctx context.Context, idx int) error {
jobCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
item := group[idx]
if err := processItem(jobCtx, item); err != nil {
return err
}
atomic.AddInt64(&processed, 1)
return nil
})
return int(processed), err
}
count := 0
if len(dashboards) > 0 {
processed, err := processGroup(dashboards)
if err != nil {
return processed, err
}
count += processed
}
if len(folderItems) > 0 {
for _, item := range folderItems {
if err := processItem(ctx, item); err != nil {
return count, err
}
count++
}
}
logger.Info("processed orphan items", "items", count)
logger.Info("processed items", "items", count)
return count, nil
}
@@ -2,10 +2,15 @@ package controller
import (
"context"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
mock "github.com/stretchr/testify/mock"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
"k8s.io/apimachinery/pkg/runtime/schema"
@@ -14,6 +19,8 @@ import (
"k8s.io/apimachinery/pkg/watch"
"k8s.io/client-go/dynamic"
"github.com/grafana/grafana-app-sdk/logging"
folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/resources"
@@ -140,7 +147,7 @@ func TestFinalizer_process(t *testing.T) {
resourceLister := resources.NewMockResourceLister(t)
resourceLister.
On("List", context.Background(), "default", "my-repo").
On("List", mock.Anything, "default", "my-repo").
Once().
Return(&provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
@@ -164,12 +171,12 @@ func TestFinalizer_process(t *testing.T) {
}
clientFactory.
On("Clients", context.Background(), "default").
On("Clients", mock.Anything, "default").
Once().
Return(clients, nil)
clients.
On("ForResource", context.Background(), schema.GroupVersionResource{
On("ForResource", mock.Anything, schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Resource: "dashboards",
}).
@@ -196,7 +203,7 @@ func TestFinalizer_process(t *testing.T) {
resourceLister := resources.NewMockResourceLister(t)
resourceLister.
On("List", context.Background(), "default", "my-repo").
On("List", mock.Anything, "default", "my-repo").
Once().
Return(&provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
@@ -220,12 +227,12 @@ func TestFinalizer_process(t *testing.T) {
}
clientFactory.
On("Clients", context.Background(), "default").
On("Clients", mock.Anything, "default").
Once().
Return(clients, nil)
clients.
On("ForResource", context.Background(), schema.GroupVersionResource{
On("ForResource", mock.Anything, schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Resource: "dashboards",
}).
@@ -253,7 +260,7 @@ func TestFinalizer_process(t *testing.T) {
clientFactory := resources.NewMockClientFactory(t)
clientFactory.
On("Clients", context.Background(), "default").
On("Clients", mock.Anything, "default").
Once().
Return(nil, assert.AnError)
@@ -275,7 +282,7 @@ func TestFinalizer_process(t *testing.T) {
resourceLister := resources.NewMockResourceLister(t)
resourceLister.
On("List", context.Background(), "default", "my-repo").
On("List", mock.Anything, "default", "my-repo").
Once().
Return(nil, assert.AnError)
@@ -286,7 +293,7 @@ func TestFinalizer_process(t *testing.T) {
clients := resources.NewMockResourceClients(t)
clientFactory.
On("Clients", context.Background(), "default").
On("Clients", mock.Anything, "default").
Once().
Return(clients, nil)
@@ -308,7 +315,7 @@ func TestFinalizer_process(t *testing.T) {
resourceLister := resources.NewMockResourceLister(t)
resourceLister.
On("List", context.Background(), "default", "my-repo").
On("List", mock.Anything, "default", "my-repo").
Once().
Return(&provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
@@ -327,12 +334,12 @@ func TestFinalizer_process(t *testing.T) {
clients := resources.NewMockResourceClients(t)
clientFactory.
On("Clients", context.Background(), "default").
On("Clients", mock.Anything, "default").
Once().
Return(clients, nil)
clients.
On("ForResource", context.Background(), schema.GroupVersionResource{
On("ForResource", mock.Anything, schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Resource: "dashboards",
}).
@@ -357,7 +364,7 @@ func TestFinalizer_process(t *testing.T) {
resourceLister := resources.NewMockResourceLister(t)
resourceLister.
On("List", context.Background(), "default", "my-repo").
On("List", mock.Anything, "default", "my-repo").
Once().
Return(&provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
@@ -381,12 +388,12 @@ func TestFinalizer_process(t *testing.T) {
}
clientFactory.
On("Clients", context.Background(), "default").
On("Clients", mock.Anything, "default").
Once().
Return(clients, nil)
clients.
On("ForResource", context.Background(), schema.GroupVersionResource{
On("ForResource", mock.Anything, schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Resource: "dashboards",
}).
@@ -414,7 +421,7 @@ func TestFinalizer_process(t *testing.T) {
resourceLister := resources.NewMockResourceLister(t)
resourceLister.
On("List", context.Background(), "default", "my-repo").
On("List", mock.Anything, "default", "my-repo").
Once().
Return(&provisioning.ResourceList{
Items: []provisioning.ResourceListItem{
@@ -438,12 +445,12 @@ func TestFinalizer_process(t *testing.T) {
}
clientFactory.
On("Clients", context.Background(), "default").
On("Clients", mock.Anything, "default").
Once().
Return(clients, nil)
clients.
On("ForResource", context.Background(), schema.GroupVersionResource{
On("ForResource", mock.Anything, schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Resource: "dashboards",
}).
@@ -560,3 +567,142 @@ func TestSortResourceListForDeletion(t *testing.T) {
})
}
}
func TestFinalizer_processExistingItems_Concurrency(t *testing.T) {
testCases := []struct {
name string
dashboardCount int
folderCount int
maxWorkers int
expectedConcurrency bool
}{
{
name: "Multiple dashboards processed concurrently",
dashboardCount: 10,
folderCount: 0,
maxWorkers: 5,
expectedConcurrency: true,
},
{
name: "Single worker processes dashboards sequentially",
dashboardCount: 5,
folderCount: 0,
maxWorkers: 1,
expectedConcurrency: false,
},
{
name: "Folders processed sequentially regardless of maxWorkers",
dashboardCount: 0,
folderCount: 5,
maxWorkers: 10,
expectedConcurrency: false,
},
{
name: "Mixed dashboards and folders - dashboards concurrent, folders sequential",
dashboardCount: 10,
folderCount: 3,
maxWorkers: 5,
expectedConcurrency: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
// Will be used to track concurrent executions
var (
concurrentCount int64
maxConcurrent int64
mu sync.Mutex
)
items := provisioning.ResourceList{Items: []provisioning.ResourceListItem{}}
for i := 0; i < tc.dashboardCount; i++ {
items.Items = append(items.Items, provisioning.ResourceListItem{
Group: "dashboard.grafana.app",
Resource: "dashboards",
Name: fmt.Sprintf("dashboard-%d", i),
})
}
for i := 0; i < tc.folderCount; i++ {
items.Items = append(items.Items, provisioning.ResourceListItem{
Group: folders.GroupVersion.Group,
Resource: "folders",
Name: fmt.Sprintf("folder-%d", i),
})
}
resourceLister := resources.NewMockResourceLister(t)
resourceLister.
On("List", mock.Anything, "default", "my-repo").
Return(&items, nil)
clientFactory := resources.NewMockClientFactory(t)
clients := resources.NewMockResourceClients(t)
client := &mockDynamicClient{
deleteFunc: func(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error {
// Track concurrent executions
current := atomic.AddInt64(&concurrentCount, 1)
defer atomic.AddInt64(&concurrentCount, -1)
mu.Lock()
if current > maxConcurrent {
maxConcurrent = current
}
mu.Unlock()
// Simulate slow client to allow concurrency to build up
time.Sleep(1 * time.Second)
return nil
},
}
clientFactory.
On("Clients", mock.Anything, "default").
Return(clients, nil)
clients.
On("ForResource", mock.Anything, mock.Anything).
Return(client, schema.GroupVersionKind{}, nil)
metrics := registerFinalizerMetrics(prometheus.NewRegistry())
f := &finalizer{
lister: resourceLister,
clientFactory: clientFactory,
metrics: &metrics,
maxWorkers: tc.maxWorkers,
}
repo := &provisioning.Repository{
ObjectMeta: metav1.ObjectMeta{
Name: "my-repo",
Namespace: "default",
},
}
count, err := f.processExistingItems(
context.Background(),
repo,
f.removeResources(context.Background(), logging.DefaultLogger),
)
assert.NoError(t, err)
assert.Equal(t, tc.dashboardCount+tc.folderCount, count)
if tc.expectedConcurrency {
// When concurrent, max concurrent should be > 1
assert.Greater(t, maxConcurrent, int64(1),
"Expected concurrent execution but maxConcurrent was %d", maxConcurrent)
// Should not exceed maxWorkers
assert.LessOrEqual(t, maxConcurrent, int64(tc.maxWorkers))
} else {
// When sequential, max concurrent should be 1
assert.Equal(t, int64(1), maxConcurrent,
"Expected sequential execution but maxConcurrent was %d", maxConcurrent)
}
})
}
}
@@ -6,6 +6,7 @@ import (
"fmt"
"time"
"go.opentelemetry.io/otel/attribute"
apierrors "k8s.io/apimachinery/pkg/api/errors"
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
@@ -84,6 +85,7 @@ func NewRepositoryController(
statusPatcher StatusPatcher,
registry prometheus.Registerer,
tracer tracing.Tracer,
parallelOperations int,
) (*RepositoryController, error) {
finalizerMetrics := registerFinalizerMetrics(registry)
@@ -104,6 +106,7 @@ func NewRepositoryController(
lister: resourceLister,
clientFactory: clients,
metrics: &finalizerMetrics,
maxWorkers: parallelOperations,
},
jobs: jobs,
logger: logging.DefaultLogger.With("logger", loggerName),
@@ -138,6 +141,9 @@ func repoKeyFunc(obj any) (string, error) {
}
// Run starts the RepositoryController.
//
// Note: This function intentionally does NOT create a tracing span because it runs indefinitely
// until shutdown. Individual processing operations already have their own spans.
func (rc *RepositoryController) Run(ctx context.Context, workerCount int) {
defer utilruntime.HandleCrash()
defer rc.queue.ShutDown()
@@ -384,50 +390,75 @@ func shouldUseIncrementalSync(ctx context.Context, versioned repository.Versione
}
func (rc *RepositoryController) addSyncJob(ctx context.Context, obj *provisioning.Repository, syncOptions *provisioning.SyncJobOptions) error {
ctx, span := rc.tracer.Start(ctx, "provisioning.controller.add_sync_job")
defer span.End()
span.SetAttributes(
attribute.String("repository", obj.GetName()),
attribute.String("namespace", obj.Namespace),
attribute.Bool("incremental", syncOptions != nil && syncOptions.Incremental),
)
job, err := rc.jobs.Insert(ctx, obj.Namespace, provisioning.JobSpec{
Repository: obj.GetName(),
Action: provisioning.JobActionPull,
Pull: syncOptions,
})
if apierrors.IsAlreadyExists(err) {
logging.FromContext(ctx).Info("sync job already exists, nothing triggered")
logging.FromContext(ctx).Info("sync job already exists")
return nil
}
if err != nil {
span.RecordError(err)
// FIXME: should we update the status of the repository if we fail to add the job?
return fmt.Errorf("error adding sync job: %w", err)
}
logging.FromContext(ctx).Info("sync job triggered", "job", job.Name)
span.SetAttributes(attribute.String("job.name", job.Name))
return nil
}
func (rc *RepositoryController) determineSyncStatus(obj *provisioning.Repository, syncOptions *provisioning.SyncJobOptions, healthStatus provisioning.HealthStatus) *provisioning.SyncStatus {
func (rc *RepositoryController) determineSyncStatusOps(obj *provisioning.Repository, syncOptions *provisioning.SyncJobOptions, healthStatus provisioning.HealthStatus) []map[string]interface{} {
const unhealthyMessage = "Repository is unhealthy"
hasUnhealthyMessage := len(obj.Status.Sync.Message) > 0 && obj.Status.Sync.Message[0] == unhealthyMessage
var patchOperations []map[string]interface{}
switch {
case syncOptions != nil:
return &provisioning.SyncStatus{
State: provisioning.JobStatePending,
LastRef: obj.Status.Sync.LastRef,
Started: time.Now().UnixMilli(),
}
// We will try to trigger a new sync job if we have sync options
patchOperations = append(patchOperations, map[string]interface{}{
"op": "replace",
"path": "/status/sync/state",
"value": provisioning.JobStatePending,
})
patchOperations = append(patchOperations, map[string]interface{}{
"op": "replace",
"path": "/status/sync/started",
"value": int64(0),
})
case healthStatus.Healthy && hasUnhealthyMessage: // if the repository is healthy and the message is set, clear it
// FIXME: is this the clearest way to do this? Should we introduce another status or way of way of handling more
// specific errors?
return &provisioning.SyncStatus{
LastRef: obj.Status.Sync.LastRef,
}
patchOperations = append(patchOperations, map[string]interface{}{
"op": "replace",
"path": "/status/sync/message",
"value": []string{},
})
case !healthStatus.Healthy && !hasUnhealthyMessage: // if the repository is unhealthy and the message is not already set, set it
return &provisioning.SyncStatus{
State: provisioning.JobStateError,
Message: []string{unhealthyMessage},
LastRef: obj.Status.Sync.LastRef,
}
default:
return nil
patchOperations = append(patchOperations, map[string]interface{}{
"op": "replace",
"path": "/status/sync/state",
"value": provisioning.JobStateError,
})
patchOperations = append(patchOperations, map[string]interface{}{
"op": "replace",
"path": "/status/sync/message",
"value": []string{unhealthyMessage},
})
}
return patchOperations
}
//nolint:gocyclo
@@ -507,13 +538,7 @@ func (rc *RepositoryController) process(item *queueItem) error {
// determine the sync strategy and sync status to apply
syncOptions := rc.determineSyncStrategy(ctx, obj, repo, shouldResync, healthStatus)
if syncStatus := rc.determineSyncStatus(obj, syncOptions, healthStatus); syncStatus != nil {
patchOperations = append(patchOperations, map[string]interface{}{
"op": "replace",
"path": "/status/sync",
"value": syncStatus,
})
}
patchOperations = append(patchOperations, rc.determineSyncStatusOps(obj, syncOptions, healthStatus)...)
// Apply all patch operations
if len(patchOperations) > 0 {
@@ -523,6 +548,8 @@ func (rc *RepositoryController) process(item *queueItem) error {
}
}
// QUESTION: should we trigger the sync job after we have applied all patch operations or before?
// Is there are risk of race condition here?
// Trigger sync job after we have applied all patch operations
if syncOptions != nil {
if err := rc.addSyncJob(ctx, obj, syncOptions); err != nil {
@@ -0,0 +1,24 @@
package provisioning
import (
"fmt"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/setting"
)
// DependencyRegisterer is set to satisfy wire gen and make sure the `RegisterDependencies` is called.
type DependencyRegisterer struct{}
func RegisterDependencies(
cfg *setting.Cfg,
accessControlService accesscontrol.Service,
features featuremgmt.FeatureToggles,
) (*DependencyRegisterer, error) {
if err := registerAccessControlRoles(accessControlService); err != nil {
return nil, fmt.Errorf("registering access control roles: %w", err)
}
return &DependencyRegisterer{}, nil
}
+22 -14
View File
@@ -132,28 +132,36 @@ func (c *jobsConnector) Connect(
}
spec.Repository = name
// If a sync job is being created, we should update its status to pending.
job, err := c.jobs.GetJobQueue().Insert(ctx, cfg.Namespace, spec)
if err != nil {
responder.Error(err)
return
}
// For pull jobs update the sync status
// patch the sync status 'state' to 'pending', and reset the 'started' field, leaving other fields unchanged.
// Intentionally maintain the previous job name until the jobs is picked up.
if spec.Pull != nil {
err = c.statusPatcherProvider.GetStatusPatcher().Patch(ctx, cfg, map[string]interface{}{
"op": "replace",
"path": "/status/sync",
"value": &provisioning.SyncStatus{
State: provisioning.JobStatePending,
LastRef: cfg.Status.Sync.LastRef,
Started: time.Now().UnixMilli(),
err = c.statusPatcherProvider.GetStatusPatcher().Patch(ctx, cfg,
map[string]interface{}{
"op": "replace",
"path": "/status/sync/state",
"value": provisioning.JobStatePending,
},
})
map[string]interface{}{
// Use "replace" instead of "remove" since "remove" fails if the path does not exist (RFC 6902).
// "started" field uses "omitempty", so it may be missing in the JSON.
"op": "replace",
"path": "/status/sync/started",
"value": int64(0),
},
)
if err != nil {
responder.Error(err)
return
}
}
job, err := c.jobs.GetJobQueue().Insert(ctx, cfg.Namespace, spec)
if err != nil {
responder.Error(err)
return
}
responder.Object(http.StatusAccepted, job)
}), 30*time.Second), nil
}
@@ -75,9 +75,12 @@ func NewConcurrentJobDriver(
// Run starts multiple job drivers concurrently and handles cleanup coordination.
// This is a blocking function that will run until the context is canceled or an error occurs.
//
// Note: This function intentionally does NOT create a tracing span because it runs indefinitely
// until shutdown. Individual job processing and cleanup operations already have their own spans.
func (c *ConcurrentJobDriver) Run(ctx context.Context) error {
logger := logging.FromContext(ctx).With("logger", "concurrent-job-driver", "num_drivers", c.numDrivers)
logger.Info("starting concurrent job driver with lease-based cleanup", "cleanup_interval", c.cleanupInterval)
logger.Info("start concurrent job driver", "num_drivers", c.numDrivers, "cleanup_interval", c.cleanupInterval)
// Set up cleanup ticker - runs more frequently with lease-based approach
cleanupTicker := time.NewTicker(c.cleanupInterval)
@@ -85,7 +88,7 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error {
// Initial cleanup
if err := c.store.Cleanup(ctx); err != nil {
logger.Error("failed to clean up old jobs at start", "error", err)
logger.Error("failed initial cleanup", "error", err)
}
var wg sync.WaitGroup
@@ -99,10 +102,10 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error {
select {
case <-cleanupTicker.C:
if err := c.store.Cleanup(ctx); err != nil {
logger.Error("failed to cleanup jobs", "error", err)
logger.Error("failed cleanup", "error", err)
}
case <-ctx.Done():
logger.Debug("cleanup goroutine stopping")
logger.Debug("cleanup routine stopped")
return
}
}
@@ -133,13 +136,13 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error {
return
}
driverLogger.Debug("starting job driver")
driverLogger.Info("start job driver")
if err := driver.Run(driverCtx); err != nil {
driverLogger.Error("job driver failed", "error", err)
errChan <- err
return
}
driverLogger.Debug("job driver stopped")
driverLogger.Info("job driver stopped")
}(i)
}
@@ -157,6 +160,10 @@ func (c *ConcurrentJobDriver) Run(ctx context.Context) error {
}
}
logger.Info("all job driver workers stopped")
return ctx.Err()
if ctx.Err() != nil {
logger.Info("all job drivers gracefully stopped")
return nil
}
return fmt.Errorf("concurrent job driver stopped unexpectedly")
}
+154 -46
View File
@@ -4,8 +4,10 @@ import (
"context"
"errors"
"strings"
"sync"
"time"
"go.opentelemetry.io/otel/attribute"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apiserver/pkg/endpoints/request"
@@ -13,6 +15,7 @@ import (
"github.com/grafana/grafana/apps/provisioning/pkg/apifmt"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/tracing"
)
// Store is an abstraction for the storage API.
@@ -74,6 +77,11 @@ type jobDriver struct {
// notifications channel for job create events
notifications chan struct{}
// Mutex to protect concurrent access to job processing
mu sync.Mutex
// currentJob is the job currently being processed
currentJob *provisioning.Job
}
func NewJobDriver(
@@ -99,6 +107,9 @@ func NewJobDriver(
// Run drives jobs to completion. This is a blocking function.
// It will run until the context is canceled or an error occurs.
// This is a thread-safe function; it may be called from multiple goroutines.
//
// Note: This function intentionally does NOT create a tracing span because it runs indefinitely
// until shutdown. Individual job processing operations already have their own spans.
func (d *jobDriver) Run(ctx context.Context) error {
jobTicker := time.NewTicker(d.jobInterval)
defer jobTicker.Stop()
@@ -116,7 +127,8 @@ func (d *jobDriver) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
logger.Info("job driver stopped")
return nil // Context cancellation is expected during shutdown
case <-jobTicker.C:
d.processJobsUntilDoneOrError(ctx)
case <-d.notifications:
@@ -128,6 +140,11 @@ func (d *jobDriver) Run(ctx context.Context) error {
// This will keep processing jobs until there are none left (or we hit an error)
func (d *jobDriver) processJobsUntilDoneOrError(ctx context.Context) {
for {
// Check if context is cancelled before attempting to claim jobs
if ctx.Err() != nil {
return
}
err := d.claimAndProcessOneJob(ctx)
if err != nil {
if !errors.Is(err, ErrNoJobs) {
@@ -139,25 +156,39 @@ func (d *jobDriver) processJobsUntilDoneOrError(ctx context.Context) {
}
func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error {
ctx, span := tracing.Start(ctx, "provisioning.jobs.claim_and_process_one_job")
defer span.End()
logger := logging.FromContext(ctx)
// Claim a job to work on.
job, rollback, err := d.store.Claim(ctx)
claimedJob, rollback, err := d.store.Claim(ctx)
if err != nil {
if !errors.Is(err, ErrNoJobs) {
span.RecordError(err)
}
return apifmt.Errorf("failed to claim job: %w", err)
}
// Ensure that the job is cleaned up if we fail to complete it.
// The rollback function does not care about cancellations.
defer rollback()
logger = logger.With("job", job.GetName(), "namespace", job.GetNamespace())
namespace := claimedJob.GetNamespace()
logger = logger.With("job", claimedJob.GetName(), "namespace", namespace)
ctx = logging.Context(ctx, logger)
logger.Debug("claimed a job")
d.currentJob = claimedJob
span.SetAttributes(
attribute.String("job.name", claimedJob.GetName()),
attribute.String("job.namespace", namespace),
attribute.String("job.repository", claimedJob.Spec.Repository),
attribute.String("job.action", string(claimedJob.Spec.Action)),
)
// Now that we have a job, we need to augment our namespace to grant ourselves permission to work on it.
// Incidentally, this also limits our permissions to only the namespace of the job.
ctx = request.WithNamespace(ctx, job.GetNamespace())
ctx, _, err = identity.WithProvisioningIdentity(ctx, job.GetNamespace())
ctx = request.WithNamespace(ctx, namespace)
ctx, _, err = identity.WithProvisioningIdentity(ctx, namespace)
if err != nil {
return apifmt.Errorf("failed to grant provisioning identity: %w", err)
}
@@ -169,50 +200,72 @@ func (d *jobDriver) claimAndProcessOneJob(ctx context.Context) error {
leaseRenewalCtx, cancelLeaseRenewal := context.WithCancel(jobctx)
leaseExpired := make(chan struct{})
go d.leaseRenewalLoop(leaseRenewalCtx, job, logger, leaseExpired)
go d.leaseRenewalLoop(leaseRenewalCtx, logger, leaseExpired)
defer cancelLeaseRenewal()
recorder := newJobProgressRecorder(d.onProgress(job))
recorder := newJobProgressRecorder(d.onProgress())
recorder.SetMessage(ctx, "start job")
// Process the job with lease loss detection
start := time.Now()
job.Status.Started = start.UnixMilli()
err = d.processJobWithLeaseCheck(jobctx, job, recorder, leaseExpired)
err = d.processJobWithLeaseCheck(jobctx, recorder, leaseExpired)
end := time.Now()
logger.Debug("job processed", "duration", end.Sub(start), "error", err)
logger.Debug("job processed", "duration", end.Sub(recorder.Started()), "error", err)
// Capture job timeout
if jobctx.Err() != nil && err == nil {
// Check if parent context was cancelled (graceful shutdown)
if ctx.Err() != nil {
logger.Debug("context cancel - job will retry")
// Don't complete the job - let it be retried by another worker
d.mu.Lock()
d.currentJob = nil
d.mu.Unlock()
return nil
}
// Capture job timeout (but not parent context cancellation)
if jobctx.Err() != nil && err == nil && ctx.Err() == nil {
err = jobctx.Err()
}
job.Status = recorder.Complete(ctx, err)
// Record job processing error on span
if err != nil {
span.RecordError(err)
}
// Complete the job
d.mu.Lock()
d.currentJob.Status = recorder.Complete(ctx, err)
defer func() {
d.currentJob = nil
d.mu.Unlock()
}()
// Save the finished job
err = d.historicJobs.WriteJob(ctx, job.DeepCopy())
err = d.historicJobs.WriteJob(ctx, d.currentJob.DeepCopy())
if err != nil {
// We're not going to return this as it is not critical. Not ideal, but not critical.
logger.Warn("failed to create historic job", "historic_job", *job, "error", err)
} else {
logger.Debug("created historic job", "historic_job", *job)
logger.Warn("failed to write historic job", "error", err)
}
// Mark the job as completed.
if err := d.store.Complete(ctx, job); err != nil {
return apifmt.Errorf("failed to complete job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
if err := d.store.Complete(ctx, d.currentJob); err != nil {
span.RecordError(err)
return apifmt.Errorf("failed to complete job '%s' in '%s': %w", d.currentJob.GetName(), d.currentJob.GetNamespace(), err)
}
logger.Debug("job completed")
logger.Info("job complete")
return nil
}
// leaseRenewalLoop continuously renews the lease for a job until the context is cancelled.
// If lease renewal fails persistently, it signals via the leaseExpired channel.
func (d *jobDriver) leaseRenewalLoop(ctx context.Context, job *provisioning.Job, logger logging.Logger, leaseExpired chan struct{}) {
//
// Note: This function intentionally does NOT create a tracing span because it runs indefinitely
// for the lifetime of a job. Individual RenewLease calls already have their own spans.
func (d *jobDriver) leaseRenewalLoop(ctx context.Context, logger logging.Logger, leaseExpired chan struct{}) {
ticker := time.NewTicker(d.leaseRenewalInterval)
defer ticker.Stop()
logger.Debug("starting lease renewal loop", "renewal_interval", d.leaseRenewalInterval)
logger.Debug("start lease renewal loop", "renewal_interval", d.leaseRenewalInterval)
consecutiveFailures := 0
maxFailures := 3 // Allow a few failures before giving up
@@ -220,10 +273,18 @@ func (d *jobDriver) leaseRenewalLoop(ctx context.Context, job *provisioning.Job,
for {
select {
case <-ctx.Done():
logger.Debug("lease renewal loop stopping")
logger.Debug("lease renewal loop stopped")
return
case <-ticker.C:
err := d.store.RenewLease(ctx, job)
d.mu.Lock()
if d.currentJob == nil {
d.mu.Unlock()
return
}
err := d.store.RenewLease(ctx, d.currentJob)
d.mu.Unlock()
if err != nil {
consecutiveFailures++
if apierrors.IsNotFound(err) ||
@@ -246,18 +307,17 @@ func (d *jobDriver) leaseRenewalLoop(ctx context.Context, job *provisioning.Job,
logger.Debug("lease renewal recovered", "previous_failures", consecutiveFailures)
}
consecutiveFailures = 0
logger.Debug("lease renewed successfully")
}
}
}
}
// processJobWithLeaseCheck processes a job but aborts if the lease expires.
func (d *jobDriver) processJobWithLeaseCheck(ctx context.Context, job *provisioning.Job, recorder JobProgressRecorder, leaseExpired <-chan struct{}) error {
// processJobWithLeaseCheck processes a job but aborts if the lease expires or context is cancelled.
func (d *jobDriver) processJobWithLeaseCheck(ctx context.Context, recorder JobProgressRecorder, leaseExpired <-chan struct{}) error {
// Run the job processing in a goroutine so we can monitor lease expiry
resultChan := make(chan error, 1)
go func() {
resultChan <- d.processJob(ctx, job, recorder)
resultChan <- d.processJob(ctx, recorder)
}()
select {
@@ -266,25 +326,48 @@ func (d *jobDriver) processJobWithLeaseCheck(ctx context.Context, job *provision
case <-leaseExpired:
return apifmt.Errorf("job aborted due to lease expiry")
case <-ctx.Done():
// Return context error directly - caller will determine if this is due to graceful shutdown
// or job timeout based on which context was cancelled
return ctx.Err()
}
}
func (d *jobDriver) processJob(ctx context.Context, job *provisioning.Job, recorder JobProgressRecorder) error {
func (d *jobDriver) processJob(ctx context.Context, recorder JobProgressRecorder) error {
ctx, span := tracing.Start(ctx, "provisioning.jobs.process_job")
defer span.End()
logger := logging.FromContext(ctx)
d.mu.Lock()
if d.currentJob == nil {
d.mu.Unlock()
return nil
}
// Here it's safe to copy as only job spec is used for processing
job := d.currentJob.DeepCopy()
repoName := d.currentJob.Spec.Repository
namespace := d.currentJob.Namespace
d.mu.Unlock()
span.SetAttributes(
attribute.String("job.repository", repoName),
attribute.String("job.action", string(job.Spec.Action)),
)
for _, worker := range d.workers {
if !worker.IsSupported(ctx, *job) {
continue
}
repo, err := d.repoGetter.GetRepository(ctx, job.Namespace, job.Spec.Repository)
repo, err := d.repoGetter.GetRepository(ctx, namespace, repoName)
if err != nil {
return apifmt.Errorf("failed to get repository '%s': %w", job.Spec.Repository, err)
span.RecordError(err)
return apifmt.Errorf("failed to get repository '%s': %w", repoName, err)
}
r := repo.Config()
if r.DeletionTimestamp != nil && !r.DeletionTimestamp.IsZero() {
logger.Info("repository is marked for deletion, skipping processing job",
logger.Info("repository marked for deletion - skip job",
"name", r.Name,
"namespace", r.Namespace,
"deletionTimestamp", r.DeletionTimestamp,
@@ -292,51 +375,76 @@ func (d *jobDriver) processJob(ctx context.Context, job *provisioning.Job, recor
return nil
}
return worker.Process(ctx, repo, *job, recorder)
err = worker.Process(ctx, repo, *job, recorder)
if err != nil {
span.RecordError(err)
}
return err
}
return apifmt.Errorf("no workers were registered to handle the job")
err := apifmt.Errorf("no workers were registered to handle the job")
span.RecordError(err)
return err
}
func (d *jobDriver) onProgress(job *provisioning.Job) ProgressFn {
func (d *jobDriver) onProgress() ProgressFn {
return func(ctx context.Context, status provisioning.JobStatus) error {
ctx, span := tracing.Start(ctx, "provisioning.jobs.update_progress")
defer span.End()
logging.FromContext(ctx).Debug("job progress", "status", status)
const maxRetries = 3
for attempt := 0; attempt < maxRetries; attempt++ {
// Use the current job for the first attempt, fetch fresh for retries
currentJob := job
d.mu.Lock()
if d.currentJob == nil {
d.mu.Unlock()
return nil
}
// Use the current job for the first attempt; on retry attempts, fetch fresh data from the store to resolve conflicts
if attempt > 0 {
// Fetch the latest version to resolve conflicts
latest, err := d.store.Get(ctx, job.GetNamespace(), job.GetName())
latest, err := d.store.Get(ctx, d.currentJob.GetNamespace(), d.currentJob.GetName())
if err != nil {
d.mu.Unlock()
if apierrors.IsNotFound(err) {
// Job was completed/deleted, nothing to update
return nil
}
return apifmt.Errorf("failed to fetch job for progress update: %w", err)
}
currentJob = latest
*d.currentJob = *latest
}
job := d.currentJob
// Update status on the current job
currentJob.Status = status
updated, err := d.store.Update(ctx, currentJob)
job.Status = status
updated, err := d.store.Update(ctx, job)
if err != nil {
if apierrors.IsConflict(err) && attempt < maxRetries-1 {
// Conflict detected, retry with fresh data
logging.FromContext(ctx).Debug("progress update conflict, retrying", "attempt", attempt+1)
continue
}
d.mu.Unlock()
return apifmt.Errorf("failed to update job progress: %w", err)
}
// Update succeeded, update our local copy
*job = *updated
*d.currentJob = *updated
d.mu.Unlock()
span.SetAttributes(
attribute.String("job.state", string(status.State)),
attribute.Int("attempt", attempt+1),
)
return nil
}
return apifmt.Errorf("failed to update job progress after %d attempts", maxRetries)
err := apifmt.Errorf("failed to update job progress after %d attempts", maxRetries)
span.RecordError(err)
return err
}
}
@@ -1,12 +1,14 @@
// Code generated by mockery v2.52.4. DO NOT EDIT.
// Code generated by mockery v2.53.4. DO NOT EDIT.
package jobs
import (
context "context"
time "time"
mock "github.com/stretchr/testify/mock"
v0alpha1 "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
mock "github.com/stretchr/testify/mock"
)
// MockJobProgressRecorder is an autogenerated mock type for the JobProgressRecorder type
@@ -271,6 +273,51 @@ func (_c *MockJobProgressRecorder_SetTotal_Call) RunAndReturn(run func(context.C
return _c
}
// Started provides a mock function with no fields
func (_m *MockJobProgressRecorder) Started() time.Time {
ret := _m.Called()
if len(ret) == 0 {
panic("no return value specified for Started")
}
var r0 time.Time
if rf, ok := ret.Get(0).(func() time.Time); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(time.Time)
}
return r0
}
// MockJobProgressRecorder_Started_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Started'
type MockJobProgressRecorder_Started_Call struct {
*mock.Call
}
// Started is a helper method to define mock.On call
func (_e *MockJobProgressRecorder_Expecter) Started() *MockJobProgressRecorder_Started_Call {
return &MockJobProgressRecorder_Started_Call{Call: _e.mock.On("Started")}
}
func (_c *MockJobProgressRecorder_Started_Call) Run(run func()) *MockJobProgressRecorder_Started_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockJobProgressRecorder_Started_Call) Return(_a0 time.Time) *MockJobProgressRecorder_Started_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockJobProgressRecorder_Started_Call) RunAndReturn(run func() time.Time) *MockJobProgressRecorder_Started_Call {
_c.Call.Return(run)
return _c
}
// StrictMaxErrors provides a mock function with given fields: maxErrors
func (_m *MockJobProgressRecorder) StrictMaxErrors(maxErrors int) {
_m.Called(maxErrors)
+123 -4
View File
@@ -1,6 +1,8 @@
package jobs
import (
"time"
"github.com/grafana/grafana/pkg/registry/apis/provisioning/utils"
"github.com/prometheus/client_golang/prometheus"
)
@@ -9,6 +11,10 @@ type JobMetrics struct {
registry prometheus.Registerer
processedTotal *prometheus.CounterVec
durationHist *prometheus.HistogramVec
incrementalSyncPhaseDurationHist *prometheus.HistogramVec // phases of incremental sync
fullSyncPhaseDurationHist *prometheus.HistogramVec // phases of full sync
syncDurationHist *prometheus.HistogramVec // total sync durations
}
type QueueMetrics struct {
@@ -72,12 +78,44 @@ func RegisterJobMetrics(registry prometheus.Registerer) JobMetrics {
},
[]string{"action", "resources_changed_bucket"},
)
registry.MustRegister(durationHist)
incrementalSyncPhaseDurationHist := prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "grafana_provisioning_jobs_incremental_sync_phase_duration_seconds",
Help: "Duration of job phases for incremental sync",
Buckets: prometheus.ExponentialBucketsRange(0.01, 10*60, 8), // 1ms -> 10m
},
[]string{"phase"},
)
registry.MustRegister(incrementalSyncPhaseDurationHist)
fullSyncPhaseDurationHist := prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "grafana_provisioning_jobs_full_sync_phase_duration_seconds",
Help: "Duration of job phases for full sync",
Buckets: prometheus.ExponentialBucketsRange(0.01, 10*60, 8), // 1ms -> 10m
},
[]string{"phase"},
)
registry.MustRegister(fullSyncPhaseDurationHist)
syncDurationHist := prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "grafana_provisioning_jobs_sync_duration_seconds",
Help: "Duration of sync (full or incremental)",
Buckets: prometheus.ExponentialBucketsRange(0.01, 10*60, 8), // 1ms -> 10m
},
[]string{"type"},
)
registry.MustRegister(syncDurationHist)
return JobMetrics{
registry: registry,
processedTotal: processedTotal,
durationHist: durationHist,
registry: registry,
processedTotal: processedTotal,
durationHist: durationHist,
incrementalSyncPhaseDurationHist: incrementalSyncPhaseDurationHist,
fullSyncPhaseDurationHist: fullSyncPhaseDurationHist,
syncDurationHist: syncDurationHist,
}
}
@@ -90,6 +128,18 @@ func (m *JobMetrics) RecordJob(jobAction string, outcome string, resourceCountCh
}
}
func (m *JobMetrics) RecordIncrementalSyncPhase(phase IncrementalSyncPhase, duration time.Duration) {
m.incrementalSyncPhaseDurationHist.WithLabelValues(phase.String()).Observe(duration.Seconds())
}
func (m *JobMetrics) RecordFullSyncPhase(phase FullSyncPhase, duration time.Duration) {
m.fullSyncPhaseDurationHist.WithLabelValues(phase.String()).Observe(duration.Seconds())
}
func (m *JobMetrics) RecordSyncDuration(syncType SyncType, duration time.Duration) {
m.syncDurationHist.WithLabelValues(syncType.String()).Observe(duration.Seconds())
}
func recordConcurrentDriverMetric(registry prometheus.Registerer, numDrivers int) {
concurrentDriver := prometheus.NewGaugeVec(
prometheus.GaugeOpts{
@@ -101,3 +151,72 @@ func recordConcurrentDriverMetric(registry prometheus.Registerer, numDrivers int
registry.MustRegister(concurrentDriver)
concurrentDriver.WithLabelValues().Set(float64(numDrivers))
}
type SyncType int
const (
SyncTypeUnknown SyncType = iota // to prevent zero value being valid
SyncTypeFull
SyncTypeIncremental
)
func (t SyncType) String() string {
switch t {
case SyncTypeFull:
return "full"
case SyncTypeIncremental:
return "incremental"
default:
return "unknown"
}
}
type FullSyncPhase int
const (
FullSyncPhaseUnknown FullSyncPhase = iota // to prevent zero value being valid
FullSyncPhaseCompare
FullSyncPhaseFileDeletions
FullSyncPhaseFolderDeletions
FullSyncPhaseFolderCreations
FullSyncPhaseFileCreations
)
func (p FullSyncPhase) String() string {
switch p {
case FullSyncPhaseCompare:
return "compare"
case FullSyncPhaseFileDeletions:
return "file_deletions"
case FullSyncPhaseFolderDeletions:
return "folder_deletions"
case FullSyncPhaseFolderCreations:
return "folder_creations"
case FullSyncPhaseFileCreations:
return "file_creations"
default:
return "unknown"
}
}
type IncrementalSyncPhase int
const (
IncrementalSyncPhaseUnknown IncrementalSyncPhase = iota // to prevent zero value being valid
IncrementalSyncPhaseCompare
IncrementalSyncPhaseApply
IncrementalSyncPhaseCleanup
)
func (p IncrementalSyncPhase) String() string {
switch p {
case IncrementalSyncPhaseCompare:
return "compare"
case IncrementalSyncPhaseApply:
return "apply"
case IncrementalSyncPhaseCleanup:
return "cleanup"
default:
return "unknown"
}
}
@@ -8,6 +8,7 @@ import (
"strconv"
"time"
"go.opentelemetry.io/otel/attribute"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/labels"
@@ -18,6 +19,7 @@ import (
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
client "github.com/grafana/grafana/apps/provisioning/pkg/generated/clientset/versioned/typed/provisioning/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/prometheus/client_golang/prometheus"
)
@@ -100,6 +102,16 @@ func NewJobStore(provisioningClient client.ProvisioningV0alpha1Interface, expiry
// If err is not nil, the job and rollback values are always nil.
// The err may be ErrNoJobs if there are no jobs to claim.
func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rollback func(), err error) {
ctx, span := tracing.Start(ctx, "provisioning.jobs.claim")
defer func() {
if err != nil && !errors.Is(err, ErrNoJobs) {
span.RecordError(err)
}
span.End()
}()
logger := logging.FromContext(ctx).With("operation", "claim")
requirement, err := labels.NewRequirement(LabelJobClaim, selection.DoesNotExist, nil)
if err != nil {
return nil, nil, apifmt.Errorf("could not create requirement: %w", err)
@@ -114,9 +126,12 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol
}
if len(jobs.Items) == 0 {
logger.Debug("no jobs available to claim")
return nil, nil, ErrNoJobs
}
logger.Debug("found jobs available", "count", len(jobs.Items))
for _, job := range jobs.Items {
if job.Labels == nil {
job.Labels = make(map[string]string)
@@ -145,6 +160,20 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol
return nil, nil, apifmt.Errorf("failed to claim job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
}
logger.Info("job claim complete",
"job", updatedJob.GetName(),
"namespace", updatedJob.GetNamespace(),
"repository", updatedJob.Spec.Repository,
"action", updatedJob.Spec.Action,
)
span.SetAttributes(
attribute.String("job.name", updatedJob.GetName()),
attribute.String("job.namespace", updatedJob.GetNamespace()),
attribute.String("job.repository", updatedJob.Spec.Repository),
attribute.String("job.action", string(updatedJob.Spec.Action)),
)
return updatedJob.DeepCopy(), func() {
// Rolling back does not need to care about the parent's cancellation state.
// This will also use the parent context (i.e. from the for loop!), ensuring we have permissions to do this.
@@ -181,50 +210,99 @@ func (s *persistentStore) Claim(ctx context.Context) (job *provisioning.Job, rol
}
// We failed to claim any jobs.
logger.Debug("no jobs claimed - all already claimed by others")
return nil, nil, ErrNoJobs
}
// Update saves the job back to the store.
func (s *persistentStore) Update(ctx context.Context, job *provisioning.Job) (*provisioning.Job, error) {
ctx, span := tracing.Start(ctx, "provisioning.jobs.update")
defer span.End()
logger := logging.FromContext(ctx).With(
"operation", "update",
"job", job.GetName(),
"namespace", job.GetNamespace(),
)
span.SetAttributes(
attribute.String("job.name", job.GetName()),
attribute.String("job.namespace", job.GetNamespace()),
)
// Set up the provisioning identity for this namespace
ctx, _, err := identity.WithProvisioningIdentity(ctx, job.GetNamespace())
if err != nil {
span.RecordError(err)
return nil, apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
}
updatedJob, err := s.client.Jobs(job.GetNamespace()).Update(ctx, job, metav1.UpdateOptions{})
if err != nil {
span.RecordError(err)
return nil, apifmt.Errorf("failed to update job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
}
logger.Debug("update job complete")
return updatedJob, nil
}
// Get retrieves a job by name for conflict resolution.
func (s *persistentStore) Get(ctx context.Context, namespace, name string) (*provisioning.Job, error) {
ctx, span := tracing.Start(ctx, "provisioning.jobs.get")
defer span.End()
logger := logging.FromContext(ctx).With(
"operation", "get",
"job", name,
"namespace", namespace,
)
span.SetAttributes(
attribute.String("job.name", name),
attribute.String("job.namespace", namespace),
)
// Set up provisioning identity to access jobs across all namespaces
ctx, _, err := identity.WithProvisioningIdentity(ctx, namespace)
if err != nil {
span.RecordError(err)
return nil, apifmt.Errorf("failed to grant provisioning identity for job lookup: %w", err)
}
// Use Get to directly fetch the job by name
job, err := s.client.Jobs(namespace).Get(ctx, name, metav1.GetOptions{})
if err != nil {
span.RecordError(err)
return nil, apifmt.Errorf("failed to get job by name '%s': %w", name, err)
}
logger.Debug("get job complete")
return job, nil
}
// Complete marks a job as completed and moves it to the historic job store.
// When in the historic store, there is no more claim on the job.
func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) error {
logger := logging.FromContext(ctx).With("namespace", job.GetNamespace(), "job", job.GetName())
ctx, span := tracing.Start(ctx, "provisioning.jobs.complete")
defer span.End()
logger := logging.FromContext(ctx).With(
"operation", "complete",
"namespace", job.GetNamespace(),
"job", job.GetName(),
)
span.SetAttributes(
attribute.String("job.name", job.GetName()),
attribute.String("job.namespace", job.GetNamespace()),
attribute.String("job.action", string(job.Spec.Action)),
)
// Set up the provisioning identity for this namespace
ctx, _, err := identity.WithProvisioningIdentity(ctx, job.GetNamespace())
if err != nil {
span.RecordError(err)
return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
}
@@ -235,6 +313,7 @@ func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) e
// This is a best-effort operation; if the job is not in the claimed state, we will still attempt to move it to the historic job store.
err = s.client.Jobs(job.GetNamespace()).Delete(ctx, job.GetName(), metav1.DeleteOptions{})
if err != nil {
span.RecordError(err)
return apifmt.Errorf("failed to delete job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
}
logger.Debug("deleted job from job store")
@@ -246,26 +325,44 @@ func (s *persistentStore) Complete(ctx context.Context, job *provisioning.Job) e
delete(job.Labels, LabelJobClaim)
s.queueMetrics.DecreaseQueueSize(string(job.Spec.Action))
logger.Debug("job completion done")
logger.Debug("complete job complete")
return nil
}
// RenewLease renews the lease for a claimed job, extending its expiry time.
// Returns an error if the lease cannot be renewed (e.g., job was completed or lease expired).
func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job) error {
ctx, span := tracing.Start(ctx, "provisioning.jobs.renew_lease")
defer span.End()
logger := logging.FromContext(ctx).With(
"operation", "renew_lease",
"job", job.GetName(),
"namespace", job.GetNamespace(),
)
span.SetAttributes(
attribute.String("job.name", job.GetName()),
attribute.String("job.namespace", job.GetNamespace()),
)
if job.Labels == nil || job.Labels[LabelJobClaim] == "" {
return apifmt.Errorf("job '%s' in '%s' is not claimed", job.GetName(), job.GetNamespace())
err := apifmt.Errorf("job '%s' in '%s' is not claimed", job.GetName(), job.GetNamespace())
span.RecordError(err)
return err
}
// Set up the provisioning identity for this namespace
ctx, _, err := identity.WithProvisioningIdentity(ctx, job.GetNamespace())
if err != nil {
span.RecordError(err)
return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
}
// Fetch the latest version to avoid conflicts
latestJob, err := s.client.Jobs(job.GetNamespace()).Get(ctx, job.GetName(), metav1.GetOptions{})
if err != nil {
span.RecordError(err)
if apierrors.IsNotFound(err) {
return apifmt.Errorf("failed to renew lease for job '%s' in '%s': job no longer exists", job.GetName(), job.GetNamespace())
}
@@ -274,7 +371,9 @@ func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job)
// Verify we still own the lease
if latestJob.Labels == nil || latestJob.Labels[LabelJobClaim] == "" {
return apifmt.Errorf("lease lost for job '%s' in '%s': no longer claimed", job.GetName(), job.GetNamespace())
err := apifmt.Errorf("lease lost for job '%s' in '%s': no longer claimed", job.GetName(), job.GetNamespace())
span.RecordError(err)
return err
}
// Update the claim timestamp to current time
@@ -284,85 +383,213 @@ func (s *persistentStore) RenewLease(ctx context.Context, job *provisioning.Job)
// Update the job in storage with the latest resource version
_, err = s.client.Jobs(job.GetNamespace()).Update(ctx, updatedJob, metav1.UpdateOptions{})
if apierrors.IsConflict(err) {
return apifmt.Errorf("failed to renew lease for job '%s' in '%s': lease conflict", job.GetName(), job.GetNamespace())
err := apifmt.Errorf("failed to renew lease for job '%s' in '%s': lease conflict", job.GetName(), job.GetNamespace())
span.RecordError(err)
return err
}
if apierrors.IsNotFound(err) {
return apifmt.Errorf("failed to renew lease for job '%s' in '%s': job no longer exists", job.GetName(), job.GetNamespace())
err := apifmt.Errorf("failed to renew lease for job '%s' in '%s': job no longer exists", job.GetName(), job.GetNamespace())
span.RecordError(err)
return err
}
if err != nil {
span.RecordError(err)
return apifmt.Errorf("failed to renew lease for job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
}
// Update the job's claim timestamp and resource version in memory
job.Labels[LabelJobClaim] = updatedJob.Labels[LabelJobClaim]
job.ResourceVersion = updatedJob.ResourceVersion
logger.Debug("renew lease complete")
return nil
}
// Cleanup finds jobs with expired leases and marks them as failed.
// This replaces the old cleanup mechanism and should be called more frequently.
func (s *persistentStore) Cleanup(ctx context.Context) error {
// Set up provisioning identity to access jobs across all namespaces
ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") // "*" grants access to all namespaces
if err != nil {
return apifmt.Errorf("failed to grant provisioning identity for cleanup: %w", err)
}
ctx, span := tracing.Start(ctx, "provisioning.jobs.cleanup")
defer span.End()
// Find jobs with expired leases (older than expiry time)
expiry := s.clock().Add(-s.expiry).UnixMilli()
requirement, err := labels.NewRequirement(LabelJobClaim, selection.LessThan, []string{strconv.FormatInt(expiry, 10)})
if err != nil {
return apifmt.Errorf("could not create requirement: %w", err)
}
startTime := s.clock()
logger := logging.FromContext(ctx).With("operation", "cleanup")
timeoutCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
jobs, err := s.client.Jobs("").List(timeoutCtx, metav1.ListOptions{
LabelSelector: labels.NewSelector().Add(*requirement).String(),
Limit: 100, // Process in batches
})
cancel()
// List expired jobs
jobs, err := s.listExpiredJobs(ctx)
if err != nil {
return apifmt.Errorf("failed to list jobs with expired leases: %w", err)
span.RecordError(err)
return err
}
// If no jobs found, cleanup is complete
if len(jobs.Items) == 0 {
if len(jobs) == 0 {
duration := s.clock().Sub(startTime)
logger.Info("cleanup complete - no expired jobs found", "duration", duration)
span.SetAttributes(
attribute.Int("count", 0),
attribute.Int64("duration_ms", duration.Milliseconds()),
)
return nil
}
for _, job := range jobs.Items {
// Mark job as failed due to lease expiry and archive it
job := job.DeepCopy()
job.Status.State = provisioning.JobStateError
job.Status.Message = "Job failed due to lease expiry - worker may have crashed or lost connection"
logger.Info("found expired jobs", "count", len(jobs))
// Set namespace context for the completion
ctx, _, err = identity.WithProvisioningIdentity(ctx, job.GetNamespace())
if err != nil {
return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
}
// Use Complete to properly archive the failed job
if err := s.Complete(ctx, job); err != nil {
if apierrors.IsNotFound(err) {
// Job was already completed/deleted by another process
continue
}
return apifmt.Errorf("failed to complete expired job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
// Clean up each expired job
for _, job := range jobs {
if err := s.cleanUpExpiredJob(ctx, job); err != nil {
span.RecordError(err)
return err
}
}
duration := s.clock().Sub(startTime)
logger.Info("cleanup complete",
"duration", duration,
"count", len(jobs),
)
span.SetAttributes(
attribute.Int("count", len(jobs)),
attribute.Int64("duration_ms", duration.Milliseconds()),
)
return nil
}
// listExpiredJobs returns jobs with expired leases.
func (s *persistentStore) listExpiredJobs(ctx context.Context) ([]provisioning.Job, error) {
logger := logging.FromContext(ctx)
// Set up provisioning identity to access jobs across all namespaces
ctx, _, err := identity.WithProvisioningIdentity(ctx, "*") // "*" grants access to all namespaces
if err != nil {
return nil, apifmt.Errorf("failed to grant provisioning identity for cleanup: %w", err)
}
// Find jobs with expired leases (older than expiry time)
expiry := s.clock().Add(-s.expiry).UnixMilli()
expiryTime := time.UnixMilli(expiry)
logger.Debug("search for expired jobs", "expiry_threshold", expiryTime.Format(time.RFC3339))
requirement, err := labels.NewRequirement(LabelJobClaim, selection.LessThan, []string{strconv.FormatInt(expiry, 10)})
if err != nil {
return nil, apifmt.Errorf("could not create requirement: %w", err)
}
listCtx, listSpan := tracing.Start(ctx, "provisioning.jobs.cleanup.list_expired_jobs")
defer listSpan.End()
listSpan.SetAttributes(
attribute.String("expiry_threshold", expiryTime.Format(time.RFC3339)),
attribute.Int64("expiry_duration_seconds", int64(s.expiry.Seconds())),
)
timeoutCtx, cancel := context.WithTimeout(listCtx, 5*time.Second)
defer cancel()
jobList, err := s.client.Jobs("").List(timeoutCtx, metav1.ListOptions{
LabelSelector: labels.NewSelector().Add(*requirement).String(),
Limit: 100, // Process in batches
})
if err != nil {
listSpan.RecordError(err)
return nil, apifmt.Errorf("failed to list jobs with expired leases: %w", err)
}
listSpan.SetAttributes(attribute.Int("jobs_found", len(jobList.Items)))
return jobList.Items, nil
}
// cleanUpExpiredJob marks a single expired job as failed and archives it.
func (s *persistentStore) cleanUpExpiredJob(ctx context.Context, job provisioning.Job) error {
// Calculate how long the job has been expired
var expiredFor time.Duration
var claimTimestamp time.Time
if claimTime, exists := job.Labels[LabelJobClaim]; exists {
claimMillis, parseErr := strconv.ParseInt(claimTime, 10, 64)
if parseErr == nil {
claimTimestamp = time.UnixMilli(claimMillis)
expiredFor = s.clock().Sub(claimTimestamp)
}
}
logger := logging.FromContext(ctx).With(
"job", job.GetName(),
"namespace", job.GetNamespace(),
"repository", job.Spec.Repository,
"action", job.Spec.Action,
"expired_for", expiredFor,
)
if !claimTimestamp.IsZero() {
logger = logger.With("claim_time", claimTimestamp.Format(time.RFC3339))
}
jobCtx, jobSpan := tracing.Start(ctx, "provisioning.jobs.cleanup.complete_expired_job")
defer jobSpan.End()
jobSpan.SetAttributes(
attribute.String("job.name", job.GetName()),
attribute.String("job.namespace", job.GetNamespace()),
attribute.String("job.repository", job.Spec.Repository),
attribute.String("job.action", string(job.Spec.Action)),
attribute.String("job.expired_for", expiredFor.String()),
)
// Mark job as failed due to lease expiry and archive it
jobCopy := job.DeepCopy()
jobCopy.Status.State = provisioning.JobStateError
jobCopy.Status.Message = "Job failed due to lease expiry - worker may have crashed or lost connection"
// Set namespace context for the completion
jobCtx, _, err := identity.WithProvisioningIdentity(jobCtx, job.GetNamespace())
if err != nil {
jobSpan.RecordError(err)
return apifmt.Errorf("failed to get provisioning identity for '%s': %w", job.GetNamespace(), err)
}
// Use Complete to properly archive the failed job
if err := s.Complete(jobCtx, jobCopy); err != nil {
if apierrors.IsNotFound(err) {
// Job was already completed/deleted by another process - this is expected
logger.Warn("job already completed or deleted by another process")
return nil
}
jobSpan.RecordError(err)
return apifmt.Errorf("failed to complete expired job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
}
logger.Info("clean up expired job complete")
return nil
}
func (s *persistentStore) Insert(ctx context.Context, namespace string, spec provisioning.JobSpec) (*provisioning.Job, error) {
ctx, span := tracing.Start(ctx, "provisioning.jobs.insert")
defer span.End()
logger := logging.FromContext(ctx).With(
"operation", "insert",
"namespace", namespace,
"repository", spec.Repository,
"action", spec.Action,
)
span.SetAttributes(
attribute.String("job.namespace", namespace),
attribute.String("job.repository", spec.Repository),
attribute.String("job.action", string(spec.Action)),
)
if spec.Repository == "" {
return nil, errors.New("missing repository in job")
err := errors.New("missing repository in job")
span.RecordError(err)
return nil, err
}
// Set up the provisioning identity for this namespace
ctx, _, err := identity.WithProvisioningIdentity(ctx, namespace)
if err != nil {
span.RecordError(err)
return nil, apifmt.Errorf("failed to get provisioning identity for '%s': %w", namespace, err)
}
@@ -376,19 +603,27 @@ func (s *persistentStore) Insert(ctx context.Context, namespace string, spec pro
Spec: spec,
}
if err := mutateJobAction(job); err != nil {
span.RecordError(err)
return nil, err
}
generateJobName(job) // Side-effect: updates the job's name.
logger = logger.With("job", job.GetName())
span.SetAttributes(attribute.String("job.name", job.GetName()))
created, err := s.client.Jobs(namespace).Create(ctx, job, metav1.CreateOptions{})
if apierrors.IsAlreadyExists(err) {
span.RecordError(err)
return nil, apifmt.Errorf("job '%s' in '%s' already exists: %w", job.GetName(), job.GetNamespace(), err)
}
if err != nil {
span.RecordError(err)
return nil, apifmt.Errorf("failed to create job '%s' in '%s': %w", job.GetName(), job.GetNamespace(), err)
}
s.queueMetrics.IncreaseQueueSize(string(job.Spec.Action))
logger.Info("insert job complete")
return created, nil
}
+25 -19
View File
@@ -69,24 +69,36 @@ func newJobProgressRecorder(ProgressFn ProgressFn) JobProgressRecorder {
}
}
func (r *jobProgressRecorder) Started() time.Time {
return r.started
}
func (r *jobProgressRecorder) Record(ctx context.Context, result JobResourceResult) {
var shouldLogError bool
var logErr error
r.mu.Lock()
r.resultCount++
logger := logging.FromContext(ctx).With("path", result.Path, "group", result.Group, "kind", result.Kind, "action", result.Action, "name", result.Name)
if result.Error != nil {
logger.Error("job resource operation failed", "err", result.Error)
shouldLogError = true
logErr = result.Error
if len(r.errors) < 20 {
r.errors = append(r.errors, result.Error.Error())
}
r.errorCount++
} else {
logger.Info("job resource operation succeeded")
}
r.updateSummary(result)
r.mu.Unlock()
logger := logging.FromContext(ctx).With("path", result.Path, "group", result.Group, "kind", result.Kind, "action", result.Action, "name", result.Name)
if shouldLogError {
logger.Error("job resource operation failed", "err", logErr)
} else {
logger.Info("job resource operation succeeded")
}
r.maybeNotify(ctx)
}
@@ -145,9 +157,6 @@ func (r *jobProgressRecorder) StrictMaxErrors(maxErrors int) {
}
func (r *jobProgressRecorder) TooManyErrors() error {
r.mu.RLock()
defer r.mu.RUnlock()
if r.maxErrors > 0 && r.errorCount >= r.maxErrors {
return fmt.Errorf("too many errors: %d", r.errorCount)
}
@@ -156,9 +165,6 @@ func (r *jobProgressRecorder) TooManyErrors() error {
}
func (r *jobProgressRecorder) summary() []*provisioning.JobResourceSummary {
r.mu.RLock()
defer r.mu.RUnlock()
if len(r.summaries) == 0 {
return nil
}
@@ -247,13 +253,9 @@ func (r *jobProgressRecorder) maybeNotify(ctx context.Context) {
func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provisioning.JobStatus {
r.mu.RLock()
defer r.mu.RUnlock()
// Initialize base job status
jobStatus := provisioning.JobStatus{
Started: r.started.UnixMilli(),
// FIXME: if we call this method twice, the state will be different
// This results in sync status to be different from job status
Started: r.started.UnixMilli(),
Finished: time.Now().UnixMilli(),
State: provisioning.JobStateSuccess,
Message: "completed successfully",
@@ -268,9 +270,13 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision
jobStatus.Errors = r.errors
jobStatus.URLs = r.refURLs
// Check for errors during execution
tooManyErrors := r.maxErrors > 0 && r.errorCount >= r.maxErrors
finalMessage := r.finalMessage
r.mu.RUnlock()
if len(jobStatus.Errors) > 0 && jobStatus.State != provisioning.JobStateError {
if r.TooManyErrors() != nil {
if tooManyErrors {
jobStatus.Message = "completed with too many errors"
jobStatus.State = provisioning.JobStateError
} else {
@@ -280,8 +286,8 @@ func (r *jobProgressRecorder) Complete(ctx context.Context, err error) provision
}
// Override message if progress have a more explicit message
if r.finalMessage != "" && jobStatus.State != provisioning.JobStateError {
jobStatus.Message = r.finalMessage
if finalMessage != "" && jobStatus.State != provisioning.JobStateError {
jobStatus.Message = finalMessage
}
return jobStatus
@@ -2,6 +2,7 @@ package jobs
import (
"context"
"time"
provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1"
"github.com/grafana/grafana/apps/provisioning/pkg/repository"
@@ -18,6 +19,7 @@ type RepoGetter interface {
//
//go:generate mockery --name JobProgressRecorder --structname MockJobProgressRecorder --inpackage --filename job_progress_recorder_mock.go --with-expecter
type JobProgressRecorder interface {
Started() time.Time
Record(ctx context.Context, result JobResourceResult)
ResetResults()
SetFinalMessage(ctx context.Context, msg string)

Some files were not shown because too many files have changed in this diff Show More