mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Export contact points to check access control action instead legacy role (#71990)
* introduce a new action "alert.provisioning.secrets:read" and role "fixed:alerting.provisioning.secrets:reader" * update alerting API authorization layer to let the user read provisioning with the new action * let new action use decrypt flag * add action and role to docs
This commit is contained in:
@@ -455,8 +455,9 @@ const (
|
||||
ActionAlertingNotificationsExternalRead = "alert.notifications.external:read"
|
||||
|
||||
// Alerting provisioning actions
|
||||
ActionAlertingProvisioningRead = "alert.provisioning:read"
|
||||
ActionAlertingProvisioningWrite = "alert.provisioning:write"
|
||||
ActionAlertingProvisioningRead = "alert.provisioning:read"
|
||||
ActionAlertingProvisioningReadSecrets = "alert.provisioning.secrets:read"
|
||||
ActionAlertingProvisioningWrite = "alert.provisioning:write"
|
||||
|
||||
// Feature Management actions
|
||||
ActionFeatureManagementRead = "featuremgmt.read"
|
||||
|
||||
@@ -171,6 +171,24 @@ var (
|
||||
},
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
|
||||
alertingProvisioningReaderWithSecretsRole = accesscontrol.RoleRegistration{
|
||||
Role: accesscontrol.RoleDTO{
|
||||
Name: accesscontrol.FixedRolePrefix + "alerting.provisioning.secrets:reader",
|
||||
DisplayName: "Read via Provisioning API + Export Secrets",
|
||||
Description: "Read all alert rules, contact points, notification policies, silences, etc. in the organization via provisioning API and use export with decrypted secrets",
|
||||
Group: AlertRolesGroup,
|
||||
Permissions: []accesscontrol.Permission{
|
||||
{
|
||||
Action: accesscontrol.ActionAlertingProvisioningReadSecrets, // organization scope
|
||||
},
|
||||
{
|
||||
Action: accesscontrol.ActionAlertingProvisioningRead, // organization scope
|
||||
},
|
||||
},
|
||||
},
|
||||
Grants: []string{string(org.RoleAdmin)},
|
||||
}
|
||||
)
|
||||
|
||||
func DeclareFixedRoles(service accesscontrol.Service) error {
|
||||
@@ -178,6 +196,6 @@ func DeclareFixedRoles(service accesscontrol.Service) error {
|
||||
rulesReaderRole, rulesWriterRole,
|
||||
instancesReaderRole, instancesWriterRole,
|
||||
notificationsReaderRole, notificationsWriterRole,
|
||||
alertingReaderRole, alertingWriterRole, alertingProvisionerRole,
|
||||
alertingReaderRole, alertingWriterRole, alertingProvisionerRole, alertingProvisioningReaderWithSecretsRole,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ import (
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/infra/log/logtest"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
@@ -27,7 +29,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/secrets"
|
||||
secrets_fakes "github.com/grafana/grafana/pkg/services/secrets/fakes"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
@@ -1008,8 +1009,15 @@ func TestProvisioningApiContactPointExport(t *testing.T) {
|
||||
require.Equal(t, "", rc.Context.Resp.Header().Get("Content-Disposition"))
|
||||
})
|
||||
|
||||
t.Run("decrypt true without admin returns 403", func(t *testing.T) {
|
||||
sut := createProvisioningSrvSut(t)
|
||||
t.Run("decrypt true without alert.provisioning.secrets:read permissions returns 403", func(t *testing.T) {
|
||||
env := createTestEnv(t, testConfig)
|
||||
env.ac = &recordingAccessControlFake{
|
||||
Callback: func(user *user.SignedInUser, evaluator accesscontrol.Evaluator) (bool, error) {
|
||||
return false, nil
|
||||
},
|
||||
}
|
||||
|
||||
sut := createProvisioningSrvSutFromEnv(t, &env)
|
||||
rc := createTestRequestCtx()
|
||||
|
||||
rc.Context.Req.Form.Set("decrypt", "true")
|
||||
@@ -1017,19 +1025,30 @@ func TestProvisioningApiContactPointExport(t *testing.T) {
|
||||
response := sut.RouteGetContactPointsExport(&rc)
|
||||
|
||||
require.Equal(t, 403, response.Status())
|
||||
require.Len(t, env.ac.EvaluateRecordings, 1)
|
||||
require.Equal(t, accesscontrol.ActionAlertingProvisioningReadSecrets, env.ac.EvaluateRecordings[0].Evaluator.String())
|
||||
})
|
||||
|
||||
t.Run("decrypt true with admin returns 200", func(t *testing.T) {
|
||||
sut := createProvisioningSrvSut(t)
|
||||
env := createTestEnv(t, testConfig)
|
||||
env.ac = &recordingAccessControlFake{
|
||||
Callback: func(user *user.SignedInUser, evaluator accesscontrol.Evaluator) (bool, error) {
|
||||
require.Equal(t, accesscontrol.ActionAlertingProvisioningReadSecrets, evaluator.String())
|
||||
return true, nil
|
||||
},
|
||||
}
|
||||
|
||||
sut := createProvisioningSrvSutFromEnv(t, &env)
|
||||
rc := createTestRequestCtx()
|
||||
|
||||
rc.SignedInUser.OrgRole = org.RoleAdmin
|
||||
rc.Context.Req.Form.Set("decrypt", "true")
|
||||
|
||||
response := sut.RouteGetContactPointsExport(&rc)
|
||||
response.WriteTo(&rc)
|
||||
|
||||
require.Equal(t, 200, response.Status())
|
||||
require.Len(t, env.ac.EvaluateRecordings, 1)
|
||||
require.Equal(t, accesscontrol.ActionAlertingProvisioningReadSecrets, env.ac.EvaluateRecordings[0].Evaluator.String())
|
||||
})
|
||||
|
||||
t.Run("json body content is as expected", func(t *testing.T) {
|
||||
@@ -1061,10 +1080,12 @@ func TestProvisioningApiContactPointExport(t *testing.T) {
|
||||
})
|
||||
t.Run("decrypt true", func(t *testing.T) {
|
||||
env := createTestEnv(t, testContactPointConfig)
|
||||
env.ac.Callback = func(user *user.SignedInUser, evaluator accesscontrol.Evaluator) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
sut := createProvisioningSrvSutFromEnv(t, &env)
|
||||
rc := createTestRequestCtx()
|
||||
|
||||
rc.SignedInUser.OrgRole = org.RoleAdmin
|
||||
rc.Context.Req.Header.Add("Accept", "application/json")
|
||||
rc.Context.Req.Form.Set("decrypt", "true")
|
||||
|
||||
@@ -1119,10 +1140,12 @@ func TestProvisioningApiContactPointExport(t *testing.T) {
|
||||
})
|
||||
t.Run("decrypt true", func(t *testing.T) {
|
||||
env := createTestEnv(t, testContactPointConfig)
|
||||
env.ac.Callback = func(user *user.SignedInUser, evaluator accesscontrol.Evaluator) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
sut := createProvisioningSrvSutFromEnv(t, &env)
|
||||
rc := createTestRequestCtx()
|
||||
|
||||
rc.SignedInUser.OrgRole = org.RoleAdmin
|
||||
rc.Context.Req.Header.Add("Accept", "application/yaml")
|
||||
rc.Context.Req.Form.Set("decrypt", "true")
|
||||
|
||||
@@ -1160,6 +1183,7 @@ type testEnvironment struct {
|
||||
xact provisioning.TransactionManager
|
||||
quotas provisioning.QuotaChecker
|
||||
prov provisioning.ProvisioningStore
|
||||
ac *recordingAccessControlFake
|
||||
}
|
||||
|
||||
func createTestEnv(t *testing.T, testConfig string) testEnvironment {
|
||||
@@ -1212,6 +1236,8 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment {
|
||||
Title: "Folder Title2",
|
||||
}}, nil).Maybe()
|
||||
|
||||
ac := &recordingAccessControlFake{}
|
||||
|
||||
return testEnvironment{
|
||||
secrets: secretsService,
|
||||
log: log,
|
||||
@@ -1221,6 +1247,7 @@ func createTestEnv(t *testing.T, testConfig string) testEnvironment {
|
||||
xact: xact,
|
||||
prov: prov,
|
||||
quotas: quotas,
|
||||
ac: ac,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1237,7 +1264,7 @@ func createProvisioningSrvSutFromEnv(t *testing.T, env *testEnvironment) Provisi
|
||||
return ProvisioningSrv{
|
||||
log: env.log,
|
||||
policies: newFakeNotificationPolicyService(),
|
||||
contactPointService: provisioning.NewContactPointService(env.configs, env.secrets, env.prov, env.xact, env.log),
|
||||
contactPointService: provisioning.NewContactPointService(env.configs, env.secrets, env.prov, env.xact, env.log, env.ac),
|
||||
templates: provisioning.NewTemplateService(env.configs, env.prov, env.xact, env.log),
|
||||
muteTimings: provisioning.NewMuteTimingService(env.configs, env.prov, env.xact, env.log),
|
||||
alertRules: provisioning.NewAlertRuleService(env.store, env.prov, env.dashboardService, env.quotas, env.xact, 60, 10, env.log),
|
||||
@@ -1256,6 +1283,7 @@ func createTestRequestCtx() contextmodel.ReqContext {
|
||||
SignedInUser: &user.SignedInUser{
|
||||
OrgID: 1,
|
||||
},
|
||||
Logger: &logtest.Fake{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -198,7 +198,7 @@ func (api *API) authorize(method, path string) web.Handler {
|
||||
http.MethodGet + "/api/v1/provisioning/alert-rules/{UID}/export",
|
||||
http.MethodGet + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}",
|
||||
http.MethodGet + "/api/v1/provisioning/folder/{FolderUID}/rule-groups/{Group}/export":
|
||||
eval = ac.EvalPermission(ac.ActionAlertingProvisioningRead) // organization scope
|
||||
eval = ac.EvalAny(ac.EvalPermission(ac.ActionAlertingProvisioningRead), ac.EvalPermission(ac.ActionAlertingProvisioningReadSecrets)) // organization scope
|
||||
|
||||
case http.MethodPut + "/api/v1/provisioning/policies",
|
||||
http.MethodDelete + "/api/v1/provisioning/policies",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -8,8 +9,10 @@ import (
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/eval"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/state"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
)
|
||||
|
||||
type fakeAlertInstanceManager struct {
|
||||
@@ -103,3 +106,34 @@ func (f *fakeAlertInstanceManager) GenerateAlertInstances(orgID int64, alertRule
|
||||
f.states[orgID][alertRuleUID] = append(f.states[orgID][alertRuleUID], newState)
|
||||
}
|
||||
}
|
||||
|
||||
type recordingAccessControlFake struct {
|
||||
Disabled bool
|
||||
EvaluateRecordings []struct {
|
||||
User *user.SignedInUser
|
||||
Evaluator accesscontrol.Evaluator
|
||||
}
|
||||
Callback func(user *user.SignedInUser, evaluator accesscontrol.Evaluator) (bool, error)
|
||||
}
|
||||
|
||||
func (a *recordingAccessControlFake) Evaluate(ctx context.Context, u *user.SignedInUser, evaluator accesscontrol.Evaluator) (bool, error) {
|
||||
a.EvaluateRecordings = append(a.EvaluateRecordings, struct {
|
||||
User *user.SignedInUser
|
||||
Evaluator accesscontrol.Evaluator
|
||||
}{User: u, Evaluator: evaluator})
|
||||
if a.Callback == nil {
|
||||
return false, nil
|
||||
}
|
||||
return a.Callback(u, evaluator)
|
||||
}
|
||||
|
||||
func (a *recordingAccessControlFake) RegisterScopeAttributeResolver(prefix string, resolver accesscontrol.ScopeAttributeResolver) {
|
||||
// TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (a *recordingAccessControlFake) IsDisabled() bool {
|
||||
return a.Disabled
|
||||
}
|
||||
|
||||
var _ accesscontrol.AccessControl = &recordingAccessControlFake{}
|
||||
|
||||
@@ -234,7 +234,7 @@ func (ng *AlertNG) init() error {
|
||||
|
||||
// Provisioning
|
||||
policyService := provisioning.NewNotificationPolicyService(ng.store, ng.store, ng.store, ng.Cfg.UnifiedAlerting, ng.Log)
|
||||
contactPointService := provisioning.NewContactPointService(ng.store, ng.SecretsService, ng.store, ng.store, ng.Log)
|
||||
contactPointService := provisioning.NewContactPointService(ng.store, ng.SecretsService, ng.store, ng.store, ng.Log, ng.accesscontrol)
|
||||
templateService := provisioning.NewTemplateService(ng.store, ng.store, ng.store, ng.Log)
|
||||
muteTimingService := provisioning.NewMuteTimingService(ng.store, ng.store, ng.store, ng.Log)
|
||||
alertRuleService := provisioning.NewAlertRuleService(ng.store, ng.store, ng.dashboardService, ng.QuotaService, ng.store,
|
||||
|
||||
@@ -13,10 +13,10 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier/channels_config"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/secrets"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
@@ -28,16 +28,18 @@ type ContactPointService struct {
|
||||
provenanceStore ProvisioningStore
|
||||
xact TransactionManager
|
||||
log log.Logger
|
||||
ac accesscontrol.AccessControl
|
||||
}
|
||||
|
||||
func NewContactPointService(store AMConfigStore, encryptionService secrets.Service,
|
||||
provenanceStore ProvisioningStore, xact TransactionManager, log log.Logger) *ContactPointService {
|
||||
provenanceStore ProvisioningStore, xact TransactionManager, log log.Logger, ac accesscontrol.AccessControl) *ContactPointService {
|
||||
return &ContactPointService{
|
||||
amStore: store,
|
||||
encryptionService: encryptionService,
|
||||
provenanceStore: provenanceStore,
|
||||
xact: xact,
|
||||
log: log,
|
||||
ac: ac,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,10 +51,22 @@ type ContactPointQuery struct {
|
||||
Decrypt bool
|
||||
}
|
||||
|
||||
func (ecp *ContactPointService) canDecryptSecrets(ctx context.Context, u *user.SignedInUser) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
permitted, err := ecp.ac.Evaluate(ctx, u, accesscontrol.EvalPermission(accesscontrol.ActionAlertingProvisioningReadSecrets))
|
||||
if err != nil {
|
||||
ecp.log.Error("Failed to evaluate user permissions", "error", err)
|
||||
permitted = false
|
||||
}
|
||||
return permitted
|
||||
}
|
||||
|
||||
// GetContactPoints returns contact points. If q.Decrypt is true and the user is an OrgAdmin, decrypted secure settings are included instead of redacted ones.
|
||||
func (ecp *ContactPointService) GetContactPoints(ctx context.Context, q ContactPointQuery, u *user.SignedInUser) ([]apimodels.EmbeddedContactPoint, error) {
|
||||
if q.Decrypt && (u == nil || !u.HasRole(org.RoleAdmin)) {
|
||||
return nil, fmt.Errorf("%w: decrypting secure settings requires Org Admin", ErrPermissionDenied)
|
||||
if q.Decrypt && !ecp.canDecryptSecrets(ctx, u) {
|
||||
return nil, fmt.Errorf("%w: user requires Admin role or alert.provisioning.secrets:read permission to view decrypted secure settings", ErrPermissionDenied)
|
||||
}
|
||||
revision, err := getLastConfiguration(ctx, q.OrgID, ecp.amStore)
|
||||
if err != nil {
|
||||
|
||||
@@ -12,14 +12,17 @@ import (
|
||||
"github.com/grafana/grafana/pkg/components/simplejson"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/acimpl"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol/actest"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/secrets"
|
||||
"github.com/grafana/grafana/pkg/services/secrets/database"
|
||||
"github.com/grafana/grafana/pkg/services/secrets/manager"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
)
|
||||
|
||||
func TestContactPointService(t *testing.T) {
|
||||
@@ -243,6 +246,7 @@ func TestContactPointService(t *testing.T) {
|
||||
func TestContactPointServiceDecryptRedact(t *testing.T) {
|
||||
sqlStore := db.InitTestDB(t)
|
||||
secretsService := manager.SetupTestService(t, database.ProvideSecretsStore(sqlStore))
|
||||
ac := acimpl.ProvideAccessControl(setting.NewCfg())
|
||||
t.Run("GetContactPoints gets redacted contact points by default", func(t *testing.T) {
|
||||
sut := createContactPointServiceSut(t, secretsService)
|
||||
|
||||
@@ -253,20 +257,36 @@ func TestContactPointServiceDecryptRedact(t *testing.T) {
|
||||
require.Equal(t, "slack receiver", cps[0].Name)
|
||||
require.Equal(t, definitions.RedactedValue, cps[0].Settings.Get("url").MustString())
|
||||
})
|
||||
t.Run("GetContactPoints errors when Decrypt = true and user not Org Admin", func(t *testing.T) {
|
||||
t.Run("GetContactPoints errors when Decrypt = true and user does not have permissions", func(t *testing.T) {
|
||||
sut := createContactPointServiceSut(t, secretsService)
|
||||
sut.ac = ac
|
||||
|
||||
q := cpsQuery(1)
|
||||
q.Decrypt = true
|
||||
_, err := sut.GetContactPoints(context.Background(), q, &user.SignedInUser{})
|
||||
require.ErrorIs(t, err, ErrPermissionDenied)
|
||||
})
|
||||
t.Run("GetContactPoints errors when Decrypt = true and user is nil", func(t *testing.T) {
|
||||
sut := createContactPointServiceSut(t, secretsService)
|
||||
sut.ac = ac
|
||||
|
||||
q := cpsQuery(1)
|
||||
q.Decrypt = true
|
||||
_, err := sut.GetContactPoints(context.Background(), q, nil)
|
||||
require.ErrorIs(t, err, ErrPermissionDenied)
|
||||
})
|
||||
t.Run("GetContactPoints gets decrypted contact points when Decrypt = true and user is Org Admin", func(t *testing.T) {
|
||||
|
||||
t.Run("GetContactPoints gets decrypted contact points when Decrypt = true and user has permissions", func(t *testing.T) {
|
||||
sut := createContactPointServiceSut(t, secretsService)
|
||||
sut.ac = ac
|
||||
|
||||
q := cpsQuery(1)
|
||||
q.Decrypt = true
|
||||
cps, err := sut.GetContactPoints(context.Background(), q, &user.SignedInUser{OrgID: 1, OrgRole: org.RoleAdmin})
|
||||
cps, err := sut.GetContactPoints(context.Background(), q, &user.SignedInUser{OrgID: 1, Permissions: map[int64]map[string][]string{
|
||||
1: {
|
||||
accesscontrol.ActionAlertingProvisioningReadSecrets: nil,
|
||||
},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, cps, 1)
|
||||
@@ -325,6 +345,7 @@ func createContactPointServiceSut(t *testing.T, secretService secrets.Service) *
|
||||
xact: newNopTransactionManager(),
|
||||
encryptionService: secretService,
|
||||
log: log.NewNopLogger(),
|
||||
ac: actest.FakeAccessControl{},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -276,7 +276,7 @@ func (ps *ProvisioningServiceImpl) ProvisionAlerting(ctx context.Context) error
|
||||
int64(ps.Cfg.UnifiedAlerting.BaseInterval.Seconds()),
|
||||
ps.log)
|
||||
contactPointService := provisioning.NewContactPointService(&st, ps.secretService,
|
||||
st, ps.SQLStore, ps.log)
|
||||
st, ps.SQLStore, ps.log, ps.ac)
|
||||
notificationPolicyService := provisioning.NewNotificationPolicyService(&st,
|
||||
st, ps.SQLStore, ps.Cfg.UnifiedAlerting, ps.log)
|
||||
mutetimingsService := provisioning.NewMuteTimingService(&st, st, &st, ps.log)
|
||||
|
||||
Reference in New Issue
Block a user