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:
Yuri Tseretyan
2023-08-08 12:29:34 -04:00
committed by GitHub
parent e1d239a86e
commit 6b4a9d73d7
17 changed files with 347 additions and 104 deletions

View File

@@ -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"

View File

@@ -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,
)
}

View File

@@ -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{},
}
}

View File

@@ -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",

View File

@@ -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{}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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{},
}
}

View File

@@ -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)