Compare commits

..

7 Commits

Author SHA1 Message Date
Yuri Tseretyan 4236845ea8 register endpoint 2026-01-05 12:47:21 -05:00
Yuri Tseretyan d4e94cef50 define a custom route for receiver testing 2026-01-05 12:47:21 -05:00
Yuri Tseretyan c723526f4e integration testing svc 2026-01-05 12:47:21 -05:00
Yuri Tseretyan e56fc80d93 create testintegration method 2026-01-05 12:47:20 -05:00
Yuri Tseretyan dfaa5ec1d4 refactor: extract conversion to integration to ConvertReceiverIntegrationToIntegration 2026-01-05 12:46:26 -05:00
Yuri Tseretyan 4abd88ec95 refactor: consolidate all encrypt\decrypt functions 2026-01-05 12:46:26 -05:00
Yuri Tseretyan 405871d41d refactor: change GetReceiver to get by UID
to avoid conversions of name to uid back an forth
2026-01-05 12:46:25 -05:00
43 changed files with 2445 additions and 239 deletions
@@ -430,4 +430,100 @@ spec:
type: object
scope: Namespaced
name: v0alpha1
routes:
namespaced:
/testing/integration:
get:
operationId: getIntegrationTest
requestBody:
content:
application/json:
schema:
additionalProperties: false
properties:
alert:
$ref: '#/components/schemas/getIntegrationTestAlert'
integration:
$ref: '#/components/schemas/getIntegrationTestIntegration'
receiver_ref:
type: string
required:
- alert
- integration
type: object
required: true
responses:
default:
content:
application/json:
schema:
additionalProperties: false
properties:
apiVersion:
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
duration:
type: string
error:
type: string
kind:
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
timestamp:
format: date-time
type: string
required:
- timestamp
- duration
- apiVersion
- kind
type: object
description: Default OK response
schemas:
getIntegrationTestAlert:
additionalProperties: false
properties:
annotations:
additionalProperties:
type: string
type: object
labels:
additionalProperties:
type: string
type: object
required:
- labels
- annotations
type: object
getIntegrationTestIntegration:
additionalProperties: false
properties:
disableResolveMessage:
type: boolean
secureFields:
additionalProperties:
type: boolean
type: object
settings:
additionalProperties:
additionalProperties: {}
type: object
type: object
type:
type: string
uid:
type: string
version:
type: string
required:
- type
- version
- settings
type: object
served: true
+36 -1
View File
@@ -1,5 +1,19 @@
package kinds
import (
"time",
"github.com/grafana/grafana/apps/alerting/notifications/kinds/v0alpha1"
)
#Alert: {
labels: {
[string]: string
}
annotations: {
[string]: string
}
}
manifest: {
appName: "alerting-notifications"
groupOverride: "notifications.alerting.grafana.app"
@@ -14,7 +28,28 @@ manifest: {
routeTreev0alpha1,
templatev0alpha1,
timeIntervalv0alpha1,
]
],
routes: {
namespaced: {
"/testing/integration" : {
"GET": {
name: "getIntegrationTest"
request: {
body: {
alert: #Alert
receiver_ref?: string
integration: v0alpha1.#Integration
}
}
response: {
timestamp: time.Time
duration: string
error?: string
}
}
}
}
},
}
}
}
@@ -0,0 +1,46 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
type GetIntegrationTestRequestAlert struct {
Labels map[string]string `json:"labels"`
Annotations map[string]string `json:"annotations"`
}
// NewGetIntegrationTestRequestAlert creates a new GetIntegrationTestRequestAlert object.
func NewGetIntegrationTestRequestAlert() *GetIntegrationTestRequestAlert {
return &GetIntegrationTestRequestAlert{
Labels: map[string]string{},
Annotations: map[string]string{},
}
}
type GetIntegrationTestRequestIntegration struct {
Uid *string `json:"uid,omitempty"`
Type string `json:"type"`
Version string `json:"version"`
DisableResolveMessage *bool `json:"disableResolveMessage,omitempty"`
Settings map[string]any `json:"settings"`
SecureFields map[string]bool `json:"secureFields,omitempty"`
}
// NewGetIntegrationTestRequestIntegration creates a new GetIntegrationTestRequestIntegration object.
func NewGetIntegrationTestRequestIntegration() *GetIntegrationTestRequestIntegration {
return &GetIntegrationTestRequestIntegration{
Settings: map[string]any{},
}
}
type GetIntegrationTestRequestBody struct {
Alert GetIntegrationTestRequestAlert `json:"alert"`
ReceiverRef *string `json:"receiver_ref,omitempty"`
Integration GetIntegrationTestRequestIntegration `json:"integration"`
}
// NewGetIntegrationTestRequestBody creates a new GetIntegrationTestRequestBody object.
func NewGetIntegrationTestRequestBody() *GetIntegrationTestRequestBody {
return &GetIntegrationTestRequestBody{
Alert: *NewGetIntegrationTestRequestAlert(),
Integration: *NewGetIntegrationTestRequestIntegration(),
}
}
@@ -0,0 +1,19 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
import (
time "time"
)
// +k8s:openapi-gen=true
type GetIntegrationTestBody struct {
Timestamp time.Time `json:"timestamp"`
Duration string `json:"duration"`
Error *string `json:"error,omitempty"`
}
// NewGetIntegrationTestBody creates a new GetIntegrationTestBody object.
func NewGetIntegrationTestBody() *GetIntegrationTestBody {
return &GetIntegrationTestBody{}
}
@@ -0,0 +1,37 @@
// Code generated - EDITING IS FUTILE. DO NOT EDIT.
package v0alpha1
import (
"github.com/grafana/grafana-app-sdk/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
)
// +k8s:openapi-gen=true
type GetIntegrationTest struct {
metav1.TypeMeta `json:",inline"`
GetIntegrationTestBody `json:",inline"`
}
func NewGetIntegrationTest() *GetIntegrationTest {
return &GetIntegrationTest{}
}
func (t *GetIntegrationTestBody) DeepCopyInto(dst *GetIntegrationTestBody) {
_ = resource.CopyObjectInto(dst, t)
}
func (o *GetIntegrationTest) DeepCopyObject() runtime.Object {
dst := NewGetIntegrationTest()
o.DeepCopyInto(dst)
return dst
}
func (o *GetIntegrationTest) DeepCopyInto(dst *GetIntegrationTest) {
dst.TypeMeta.APIVersion = o.TypeMeta.APIVersion
dst.TypeMeta.Kind = o.TypeMeta.Kind
o.GetIntegrationTestBody.DeepCopyInto(&dst.GetIntegrationTestBody)
}
var _ runtime.Object = NewGetIntegrationTest()
@@ -79,9 +79,206 @@ var appManifestData = app.ManifestData{
},
},
Routes: app.ManifestVersionRoutes{
Namespaced: map[string]spec3.PathProps{},
Cluster: map[string]spec3.PathProps{},
Schemas: map[string]spec.Schema{},
Namespaced: map[string]spec3.PathProps{
"/testing/integration": {
Get: &spec3.Operation{
OperationProps: spec3.OperationProps{
OperationId: "getIntegrationTest",
RequestBody: &spec3.RequestBody{
RequestBodyProps: spec3.RequestBodyProps{
Required: true,
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"alert": {
SchemaProps: spec.SchemaProps{
Ref: spec.MustCreateRef("#/components/schemas/getIntegrationTestAlert"),
},
},
"integration": {
SchemaProps: spec.SchemaProps{
Ref: spec.MustCreateRef("#/components/schemas/getIntegrationTestIntegration"),
},
},
"receiver_ref": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
Required: []string{
"alert",
"integration",
},
}},
}},
},
}},
Responses: &spec3.Responses{
ResponsesProps: spec3.ResponsesProps{
Default: &spec3.Response{
ResponseProps: spec3.ResponseProps{
Description: "Default OK response",
Content: map[string]*spec3.MediaType{
"application/json": {
MediaTypeProps: spec3.MediaTypeProps{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"apiVersion": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
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",
},
},
"duration": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
"error": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
"kind": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
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",
},
},
"timestamp": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
Format: "date-time",
},
},
},
Required: []string{
"timestamp",
"duration",
"apiVersion",
"kind",
},
}},
}},
},
},
},
}},
},
},
},
},
Cluster: map[string]spec3.PathProps{},
Schemas: map[string]spec.Schema{
"getIntegrationTestAlert": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"annotations": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
},
},
"labels": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
},
},
},
Required: []string{
"labels",
"annotations",
},
},
},
"getIntegrationTestIntegration": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
Properties: map[string]spec.Schema{
"disableResolveMessage": {
SchemaProps: spec.SchemaProps{
Type: []string{"boolean"},
},
},
"secureFields": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"boolean"},
},
},
},
},
},
"settings": {
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{},
},
},
},
},
},
},
},
"type": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
"uid": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
"version": {
SchemaProps: spec.SchemaProps{
Type: []string{"string"},
},
},
},
Required: []string{
"type",
"version",
"settings",
},
},
},
},
},
},
},
@@ -109,7 +306,9 @@ func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exist
return goType, exists
}
var customRouteToGoResponseType = map[string]any{}
var customRouteToGoResponseType = map[string]any{
"v0alpha1||<namespace>/testing/integration|GET": v0alpha1.GetIntegrationTest{},
}
// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists.
// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths.
@@ -133,7 +332,9 @@ func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goTyp
return goType, exists
}
var customRouteToGoRequestBodyType = map[string]any{}
var customRouteToGoRequestBodyType = map[string]any{
"v0alpha1||<namespace>/testing/integration|GET": v0alpha1.GetIntegrationTestRequestBody{},
}
func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) {
if len(path) > 0 && path[0] == '/' {
@@ -2,6 +2,8 @@ package app
import (
"context"
"errors"
"fmt"
"github.com/grafana/grafana-app-sdk/app"
"github.com/grafana/grafana-app-sdk/logging"
@@ -9,6 +11,7 @@ import (
"github.com/grafana/grafana-app-sdk/simple"
"github.com/grafana/grafana/apps/alerting/notifications/pkg/apis"
"github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/alertingnotifications/v0alpha1"
)
func New(cfg app.Config) (app.App, error) {
@@ -19,6 +22,14 @@ func New(cfg app.Config) (app.App, error) {
}
}
customCfg, ok := cfg.SpecificConfig.(*Config)
if !ok {
return nil, errors.New("no configuration")
}
if err := customCfg.Validate(); err != nil {
return nil, fmt.Errorf("invalid configuration: %w", err)
}
c := simple.AppConfig{
Name: "alerting.notification",
KubeConfig: cfg.KubeConfig,
@@ -30,6 +41,15 @@ func New(cfg app.Config) (app.App, error) {
},
},
ManagedKinds: managedKinds,
VersionedCustomRoutes: map[string]simple.AppVersionRouteHandlers{
v0alpha1.APIVersion: {
simple.AppVersionRoute{
Namespaced: true,
Path: "testing/integration",
Method: "GET",
}: customCfg.ReceiverTestingHandler.HandleReceiverTestingRequest,
},
},
}
a, err := simple.NewApp(c)
@@ -0,0 +1,23 @@
package app
import (
"context"
"errors"
"github.com/grafana/grafana-app-sdk/app"
)
type Config struct {
ReceiverTestingHandler ReceiverTestingHandler
}
func (c *Config) Validate() error {
if c.ReceiverTestingHandler == nil {
return errors.New("receiver testing handler is required")
}
return nil
}
type ReceiverTestingHandler interface {
HandleReceiverTestingRequest(context.Context, app.CustomRouteResponseWriter, *app.CustomRouteRequest) error
}
@@ -1,12 +1,11 @@
---
aliases:
- ../../data-sources/aws-cloudwatch/configure/
- ../../data-sources/aws-cloudwatch/
- ../../data-sources/aws-cloudwatch/preconfig-cloudwatch-dashboards/
- ../../data-sources/aws-cloudwatch/provision-cloudwatch/
- ../cloudwatch/
- ../preconfig-cloudwatch-dashboards/
- ../provision-cloudwatch/
- ../data-sources/aws-CloudWatch/
- ../data-sources/aws-CloudWatch/preconfig-CloudWatch-dashboards/
- ../data-sources/aws-CloudWatch/provision-CloudWatch/
- CloudWatch/
- preconfig-CloudWatch-dashboards/
- provision-CloudWatch/
description: This document provides configuration instructions for the CloudWatch data source.
keywords:
- grafana
@@ -26,6 +25,11 @@ refs:
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/logs/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/visualizations/logs/
explore:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
provisioning-data-sources:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/provisioning/#data-sources
@@ -36,6 +40,16 @@ refs:
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#aws
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/setup-grafana/configure-grafana/#aws
alerting:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana-cloud/alerting-and-irm/alerting/
build-dashboards:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/dashboards/build-dashboards/
data-source-management:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/administration/data-source-management/
@@ -139,7 +153,7 @@ You must use both an access key ID and a secret access key to authenticate.
Grafana automatically creates a link to a trace in X-Ray data source if logs contain the `@xrayTraceId` field. To use this feature, you must already have an X-Ray data source configured. For details, see the [X-Ray data source docs](/grafana/plugins/grafana-X-Ray-datasource/). To view the X-Ray link, select the log row in either the Explore view or dashboard [Logs panel](ref:logs) to view the log details section.
To log the `@xrayTraceId`, refer to the [AWS X-Ray documentation](https://docs.aws.amazon.com/xray/latest/devguide/xray-services.html). To provide the field to Grafana, your log queries must also contain the `@xrayTraceId` field, for example by using the query `fields @message, @xrayTraceId`.
To log the `@xrayTraceId`, refer to the [AWS X-Ray documentation](https://docs.amazonaws.cn/en_us/xray/latest/devguide/xray-services.html). To provide the field to Grafana, your log queries must also contain the `@xrayTraceId` field, for example by using the query `fields @message, @xrayTraceId`.
**Private data source connect** - _Only for Grafana Cloud users._
@@ -34,6 +34,11 @@ refs:
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/#navigate-the-query-tab
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/panels-visualizations/query-transform-data/#navigate-the-query-tab
explore:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
- pattern: /docs/grafana-cloud/
destination: /docs/grafana/<GRAFANA_VERSION>/explore/
alerting:
- pattern: /docs/grafana/
destination: /docs/grafana/<GRAFANA_VERSION>/alerting/
@@ -178,7 +183,7 @@ If you use the expression field to reference another query, such as `queryA * 2`
When you select `Builder` mode within the Metric search editor, a new Account field is displayed. Use the `Account` field to specify which of the linked monitoring accounts to target for the given query. By default, the `All` option is specified, which will target all linked accounts.
While in `Code` mode, you can specify any math expression. If the Monitoring account badge displays in the query editor header, all `SEARCH` expressions entered in this field will be cross-account by default and can query metrics from linked accounts. Note that while queries run cross-account, the autocomplete feature currently doesn't fetch cross-account resources, so you'll need to manually specify resource names when writing cross-account queries.
You can limit the search to one or a set of accounts, as documented in the [AWS documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html).
You can limit the search to one or a set of accounts, as documented in the [AWS documentation](http://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html).
### Period macro
@@ -193,7 +198,7 @@ The link provided is valid for any account but displays the expected metrics onl
{{< figure src="/media/docs/cloudwatch/cloudwatch-deep-link-v12.1.png" caption="CloudWatch deep linking" >}}
This feature is not available for metrics based on [metric math expressions](#use-metric-math-expressions).
This feature is not available for metrics based on [metric math expressions](#metric-math-expressions).
### Use Metric Insights syntax
@@ -314,9 +319,9 @@ The CloudWatch plugin monitors and troubleshoots applications that span multiple
To enable cross-account observability, complete the following steps:
1. Go to the [Amazon CloudWatch documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html) and follow the instructions for enabling cross-account observability.
1. Go to the [Amazon CloudWatch documentation](http://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-Unified-Cross-Account.html) and follow the instructions for enabling cross-account observability.
1. Add [two API actions](https://grafana.com/docs/grafana/latest/datasources/aws-cloudwatch/configure/#cross-account-observability-permissions) to the IAM policy attached to the role/user running the plugin.
1. Add [two API actions](https://grafana.com//docs/grafana/latest/datasources/aws-cloudwatch/configure/#cross-account-observability-permissions) to the IAM policy attached to the role/user running the plugin.
Cross-account querying is available in the plugin through the **Logs**, **Metric search**, and **Metric Insights** modes.
After you have configured it, you'll see a **Monitoring account** badge in the query editor header.
+1 -1
View File
@@ -26,7 +26,7 @@ require (
github.com/Masterminds/semver/v3 v3.4.0 // @grafana/grafana-developer-enablement-squad
github.com/Masterminds/sprig/v3 v3.3.0 // @grafana/grafana-backend-group
github.com/ProtonMail/go-crypto v1.1.6 // @grafana/plugins-platform-backend
github.com/VividCortex/mysqlerr v1.0.0 // @grafana/grafana-backend-group
github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f // @grafana/grafana-backend-group
github.com/alicebob/miniredis/v2 v2.34.0 // @grafana/alerting-backend
github.com/andybalholm/brotli v1.2.0 // @grafana/partner-datasources
github.com/apache/arrow-go/v18 v18.4.1 // @grafana/plugins-platform-backend
+2 -2
View File
@@ -779,8 +779,8 @@ github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo=
github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI=
github.com/VividCortex/mysqlerr v1.0.0 h1:5pZ2TZA+YnzPgzBfiUWGqWmKDVNBdrkf9g+DNe1Tiq8=
github.com/VividCortex/mysqlerr v1.0.0/go.mod h1:xERx8E4tBhLvpjzdUyQiSfUxeMcATEQrflDAfXsqcAE=
github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f h1:HR5nRmUQgXrwqZOwZ2DAc/aCi3Bu3xENpspW935vxu0=
github.com/VividCortex/mysqlerr v0.0.0-20170204212430-6c6b55f8796f/go.mod h1:f3HiCrHjHBdcm6E83vGaXh1KomZMA2P6aeo3hKx/wg0=
github.com/Workiva/go-datastructures v1.1.5 h1:5YfhQ4ry7bZc2Mc7R0YZyYwpf5c6t1cEFvdAhd6Mkf4=
github.com/Workiva/go-datastructures v1.1.5/go.mod h1:1yZL+zfsztete+ePzZz/Zb1/t5BnDuE2Ya2MMGhzP6A=
github.com/Yiling-J/theine-go v0.6.2 h1:1GeoXeQ0O0AUkiwj2S9Jc0Mzx+hpqzmqsJ4kIC4M9AY=
@@ -128,47 +128,55 @@ func convertToDomainModel(receiver *model.Receiver) (*ngmodels.Receiver, map[str
}
storedSecureFields := make(map[string][]string, len(receiver.Spec.Integrations))
for _, integration := range receiver.Spec.Integrations {
t, err := alertingNotify.IntegrationTypeFromString(integration.Type)
grafanaIntegration, secureFields, err := ConvertReceiverIntegrationToIntegration(receiver.Spec.Title, integration)
if err != nil {
return nil, nil, err
}
var config schema.IntegrationSchemaVersion
typeSchema, _ := alertingNotify.GetSchemaForIntegration(t)
if integration.Version != "" {
var ok bool
config, ok = typeSchema.GetVersion(schema.Version(integration.Version))
if !ok {
return nil, nil, fmt.Errorf("invalid version %s for integration type %s", integration.Version, integration.Type)
}
} else {
config = typeSchema.GetCurrentVersion()
}
grafanaIntegration := ngmodels.Integration{
Name: receiver.Spec.Title,
Config: config,
Settings: maps.Clone(integration.Settings),
SecureSettings: make(map[string]string),
}
if integration.Uid != nil {
grafanaIntegration.UID = *integration.Uid
}
if integration.DisableResolveMessage != nil {
grafanaIntegration.DisableResolveMessage = *integration.DisableResolveMessage
}
domain.Integrations = append(domain.Integrations, &grafanaIntegration)
if grafanaIntegration.UID != "" {
// This is an existing integration, so we track the secure fields being requested to copy over from existing values.
secureFields := make([]string, 0, len(integration.SecureFields))
for k, isSecure := range integration.SecureFields {
if isSecure {
secureFields = append(secureFields, k)
}
}
storedSecureFields[grafanaIntegration.UID] = secureFields
}
storedSecureFields[grafanaIntegration.UID] = secureFields
}
return domain, storedSecureFields, nil
}
func ConvertReceiverIntegrationToIntegration(receiverTitle string, integration model.ReceiverIntegration) (ngmodels.Integration, []string, error) {
t, err := alertingNotify.IntegrationTypeFromString(integration.Type)
if err != nil {
return ngmodels.Integration{}, nil, err
}
var config schema.IntegrationSchemaVersion
typeSchema, _ := alertingNotify.GetSchemaForIntegration(t)
if integration.Version != "" {
var ok bool
config, ok = typeSchema.GetVersion(schema.Version(integration.Version))
if !ok {
return ngmodels.Integration{}, nil, fmt.Errorf("invalid version %s for integration type %s", integration.Version, integration.Type)
}
} else {
config = typeSchema.GetCurrentVersion()
}
grafanaIntegration := ngmodels.Integration{
Name: receiverTitle,
Config: config,
Settings: maps.Clone(integration.Settings),
SecureSettings: make(map[string]string),
}
if integration.Uid != nil {
grafanaIntegration.UID = *integration.Uid
}
if integration.DisableResolveMessage != nil {
grafanaIntegration.DisableResolveMessage = *integration.DisableResolveMessage
}
var secureFields []string
if grafanaIntegration.UID != "" {
// This is an existing integration, so we track the secure fields being requested to copy over from existing values.
secureFields = make([]string, 0, len(integration.SecureFields))
for k, isSecure := range integration.SecureFields {
if isSecure {
secureFields = append(secureFields, k)
}
}
}
return grafanaIntegration, secureFields, nil
}
@@ -25,7 +25,7 @@ var (
)
type ReceiverService interface {
GetReceiver(ctx context.Context, q ngmodels.GetReceiverQuery, user identity.Requester) (*ngmodels.Receiver, error)
GetReceiver(ctx context.Context, uid string, decrypt bool, user identity.Requester) (*ngmodels.Receiver, error)
GetReceivers(ctx context.Context, q ngmodels.GetReceiversQuery, user identity.Requester) ([]*ngmodels.Receiver, error)
CreateReceiver(ctx context.Context, r *ngmodels.Receiver, orgID int64, user identity.Requester) (*ngmodels.Receiver, error)
UpdateReceiver(ctx context.Context, r *ngmodels.Receiver, storedSecureFields map[string][]string, orgID int64, user identity.Requester) (*ngmodels.Receiver, error)
@@ -120,18 +120,13 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption
if err != nil {
return nil, apierrors.NewNotFound(ResourceInfo.GroupResource(), uid)
}
q := ngmodels.GetReceiverQuery{
OrgID: info.OrgID,
Name: name,
Decrypt: false,
}
user, err := identity.GetRequester(ctx)
if err != nil {
return nil, err
}
r, err := s.service.GetReceiver(ctx, q, user)
r, err := s.service.GetReceiver(ctx, name, false, user)
if err != nil {
return nil, err
}
@@ -0,0 +1,26 @@
package receivertesting
import (
"context"
"k8s.io/apiserver/pkg/authorization/authorizer"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/accesscontrol"
)
func Authorize(ctx context.Context, ac accesscontrol.AccessControl, attr authorizer.Attributes) (authorized authorizer.Decision, reason string, err error) {
user, err := identity.GetRequester(ctx)
if err != nil {
return authorizer.DecisionDeny, "valid user is required", err
}
eval := accesscontrol.EvalAny(
accesscontrol.EvalPermission(accesscontrol.ActionAlertingNotificationsWrite),
accesscontrol.EvalPermission(accesscontrol.ActionAlertingReceiversTest),
)
ok, err := ac.Evaluate(ctx, user, eval)
if ok {
return authorizer.DecisionAllow, "", nil
}
return authorizer.DecisionDeny, "", err
}
@@ -0,0 +1,89 @@
package receivertesting
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/grafana/grafana-app-sdk/app"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/grafana/grafana/apps/alerting/notifications/pkg/apis/alertingnotifications/v0alpha1"
_ "github.com/grafana/grafana/pkg/apimachinery/errutil"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications/receiver"
"github.com/grafana/grafana/pkg/services/ngalert"
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
)
type ReceiverTestingHandler struct {
testingSvc *notifier.ReceiverTestingSvc
}
func New(ng *ngalert.AlertNG) *ReceiverTestingHandler {
testingSvc := notifier.NewReceiverTestingSvc(ng.Api.ReceiverService, ng.MultiOrgAlertmanager, ng.SecretsService)
return &ReceiverTestingHandler{
testingSvc: testingSvc,
}
}
func (p *ReceiverTestingHandler) HandleReceiverTestingRequest(ctx context.Context, w app.CustomRouteResponseWriter, r *app.CustomRouteRequest) error {
user, err := identity.GetRequester(ctx)
if err != nil {
return err
}
var req v0alpha1.GetIntegrationTestRequestBody
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
writeBadRequest(w, err)
}
alert := notifier.Alert{
Labels: req.Alert.Labels,
Annotations: req.Alert.Annotations,
}
integration, secure, err := receiver.ConvertReceiverIntegrationToIntegration("test-receiver", v0alpha1.ReceiverIntegration(req.Integration))
if err != nil {
writeBadRequest(w, err)
}
receiverUID := ""
if req.ReceiverRef != nil {
receiverUID = *req.ReceiverRef
}
result, err := p.testingSvc.Test(ctx, user, alert, receiverUID, integration, secure)
if err != nil {
// TODO better error handling
writeBadRequest(w, err)
}
response := v0alpha1.GetIntegrationTest{
TypeMeta: metav1.TypeMeta{},
GetIntegrationTestBody: v0alpha1.GetIntegrationTestBody{
Timestamp: time.Time(result.LastNotifyAttempt),
Duration: result.LastNotifyAttemptDuration,
},
}
if result.LastNotifyAttemptError != "" {
response.GetIntegrationTestBody.Error = &result.LastNotifyAttemptError
}
json, err := json.Marshal(response)
if err != nil {
return fmt.Errorf("failed to marshal response: %w", err)
}
w.WriteHeader(200)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(json)
return nil
}
func writeBadRequest(w app.CustomRouteResponseWriter, err error) {
w.WriteHeader(400)
_, _ = w.Write([]byte(err.Error()))
}
@@ -15,6 +15,7 @@ import (
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications/receiver"
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications/receiver/receivertesting"
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications/routingtree"
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications/templategroup"
"github.com/grafana/grafana/pkg/registry/apps/alerting/notifications/timeinterval"
@@ -53,6 +54,10 @@ func RegisterAppInstaller(
ng: ng,
}
customCfg := notificationsApp.Config{
ReceiverTestingHandler: receivertesting.New(ng),
}
localManifest := apis.LocalManifest()
provider := simple.NewAppProvider(localManifest, nil, notificationsApp.New)
@@ -60,7 +65,7 @@ func RegisterAppInstaller(
appConfig := app.Config{
KubeConfig: restclient.Config{}, // this will be overridden by the installer's InitializeApp method
ManifestData: *localManifest.ManifestData,
SpecificConfig: nil,
SpecificConfig: &customCfg,
}
i, err := appsdkapiserver.NewDefaultAppInstaller(provider, appConfig, &apis.GoTypeAssociator{})
@@ -85,6 +90,8 @@ func (a AlertingNotificationsAppInstaller) GetAuthorizer() authorizer.Authorizer
return receiver.Authorize(ctx, ac.NewReceiverAccess[*ngmodels.Receiver](authz, false), a)
case routingtree.ResourceInfo.GroupResource().Resource:
return routingtree.Authorize(ctx, authz, a)
case "testing":
return receivertesting.Authorize(ctx, authz, a)
}
return authorizer.DecisionNoOpinion, "", nil
})
@@ -0,0 +1,44 @@
package acimpl
import (
"context"
"time"
"github.com/grafana/grafana/pkg/services/accesscontrol"
)
const (
ossBasicRoleSeedLockName = "oss-ac-basic-role-seeder"
ossBasicRoleSeedTimeout = 2 * time.Minute
)
// refreshBasicRolePermissionsInDB ensures basic role permissions are fully derived from in-memory registrations
func (s *Service) refreshBasicRolePermissionsInDB(ctx context.Context, rolesSnapshot map[string][]accesscontrol.Permission) error {
if s.sql == nil || s.seeder == nil {
return nil
}
run := func(ctx context.Context) error {
desired := map[accesscontrol.SeedPermission]struct{}{}
for role, permissions := range rolesSnapshot {
for _, permission := range permissions {
desired[accesscontrol.SeedPermission{BuiltInRole: role, Action: permission.Action, Scope: permission.Scope}] = struct{}{}
}
}
s.seeder.SetDesiredPermissions(desired)
return s.seeder.Seed(ctx)
}
if s.serverLock == nil {
return run(ctx)
}
var err error
errLock := s.serverLock.LockExecuteAndRelease(ctx, ossBasicRoleSeedLockName, ossBasicRoleSeedTimeout, func(ctx context.Context) {
err = run(ctx)
})
if errLock != nil {
return errLock
}
return err
}
@@ -0,0 +1,128 @@
package acimpl
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/localcache"
"github.com/grafana/grafana/pkg/infra/tracing"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/database"
"github.com/grafana/grafana/pkg/services/accesscontrol/permreg"
"github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/org"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util/testutil"
)
func TestIntegration_OSSBasicRolePermissions_PersistAndRefreshOnRegisterFixedRoles(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
ctx := context.Background()
sql := db.InitTestDB(t)
store := database.ProvideService(sql)
svc := ProvideOSSService(
setting.NewCfg(),
store,
&resourcepermissions.FakeActionSetSvc{},
localcache.ProvideService(),
featuremgmt.WithFeatures(),
tracing.InitializeTracerForTest(),
sql,
permreg.ProvidePermissionRegistry(),
nil,
)
require.NoError(t, svc.DeclareFixedRoles(accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Name: "fixed:test:role",
Permissions: []accesscontrol.Permission{
{Action: "test:read", Scope: ""},
},
},
Grants: []string{string(org.RoleViewer)},
}))
require.NoError(t, svc.RegisterFixedRoles(ctx))
// verify permission is persisted to DB for basic:viewer
require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error {
var role accesscontrol.Role
ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role)
require.NoError(t, err)
require.True(t, ok)
var count int64
count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count()
require.NoError(t, err)
require.Equal(t, int64(1), count)
return nil
}))
// ensure RegisterFixedRoles refreshes it back to defaults
require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error {
ts := time.Now()
var role accesscontrol.Role
ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role)
require.NoError(t, err)
require.True(t, ok)
_, err = sess.Exec("DELETE FROM permission WHERE role_id = ?", role.ID)
require.NoError(t, err)
p := accesscontrol.Permission{
RoleID: role.ID,
Action: "custom:keep",
Scope: "",
Created: ts,
Updated: ts,
}
p.Kind, p.Attribute, p.Identifier = accesscontrol.SplitScope(p.Scope)
_, err = sess.Table("permission").Insert(&p)
return err
}))
svc2 := ProvideOSSService(
setting.NewCfg(),
store,
&resourcepermissions.FakeActionSetSvc{},
localcache.ProvideService(),
featuremgmt.WithFeatures(),
tracing.InitializeTracerForTest(),
sql,
permreg.ProvidePermissionRegistry(),
nil,
)
require.NoError(t, svc2.DeclareFixedRoles(accesscontrol.RoleRegistration{
Role: accesscontrol.RoleDTO{
Name: "fixed:test:role",
Permissions: []accesscontrol.Permission{
{Action: "test:read", Scope: ""},
},
},
Grants: []string{string(org.RoleViewer)},
}))
require.NoError(t, svc2.RegisterFixedRoles(ctx))
require.NoError(t, sql.WithDbSession(ctx, func(sess *db.Session) error {
var role accesscontrol.Role
ok, err := sess.Table("role").Where("uid = ?", accesscontrol.BasicRoleUIDPrefix+"viewer").Get(&role)
require.NoError(t, err)
require.True(t, ok)
var count int64
count, err = sess.Table("permission").Where("role_id = ? AND action = ? AND scope = ?", role.ID, "test:read", "").Count()
require.NoError(t, err)
require.Equal(t, int64(1), count)
count, err = sess.Table("permission").Where("role_id = ? AND action = ?", role.ID, "custom:keep").Count()
require.NoError(t, err)
require.Equal(t, int64(0), count)
return nil
}))
}
+62 -2
View File
@@ -30,6 +30,7 @@ import (
"github.com/grafana/grafana/pkg/services/accesscontrol/migrator"
"github.com/grafana/grafana/pkg/services/accesscontrol/permreg"
"github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils"
"github.com/grafana/grafana/pkg/services/accesscontrol/seeding"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
@@ -96,6 +97,12 @@ func ProvideOSSService(
roles: accesscontrol.BuildBasicRoleDefinitions(),
store: store,
permRegistry: permRegistry,
sql: db,
serverLock: lock,
}
if backend, ok := store.(*database.AccessControlStore); ok {
s.seeder = seeding.New(log.New("accesscontrol.seeder"), backend, backend)
}
return s
@@ -112,8 +119,11 @@ type Service struct {
rolesMu sync.RWMutex
roles map[string]*accesscontrol.RoleDTO
store accesscontrol.Store
seeder *seeding.Seeder
permRegistry permreg.PermissionRegistry
isInitialized bool
sql db.DB
serverLock *serverlock.ServerLockService
}
func (s *Service) GetUsageStats(_ context.Context) map[string]any {
@@ -431,17 +441,54 @@ func (s *Service) RegisterFixedRoles(ctx context.Context) error {
defer span.End()
s.rolesMu.Lock()
defer s.rolesMu.Unlock()
registrations := s.registrations.Slice()
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
s.registerRolesLocked(registration)
return true
})
s.isInitialized = true
rolesSnapshot := s.getBasicRolePermissionsLocked()
s.rolesMu.Unlock()
if s.seeder != nil {
if err := s.seeder.SeedRoles(ctx, registrations); err != nil {
return err
}
if err := s.seeder.RemoveAbsentRoles(ctx); err != nil {
return err
}
}
if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil {
return err
}
return nil
}
// getBasicRolePermissionsSnapshotFromRegistrationsLocked computes the desired basic role permissions from the
// current registration list, using the shared seeding registration logic.
//
// it has to be called while holding the roles lock
func (s *Service) getBasicRolePermissionsLocked() map[string][]accesscontrol.Permission {
desired := map[accesscontrol.SeedPermission]struct{}{}
s.registrations.Range(func(registration accesscontrol.RoleRegistration) bool {
seeding.AppendDesiredPermissions(desired, s.log, &registration.Role, registration.Grants, registration.Exclude, true)
return true
})
out := make(map[string][]accesscontrol.Permission)
for sp := range desired {
out[sp.BuiltInRole] = append(out[sp.BuiltInRole], accesscontrol.Permission{
Action: sp.Action,
Scope: sp.Scope,
})
}
return out
}
// registerRolesLocked processes a single role registration and adds permissions to basic roles.
// Must be called with s.rolesMu locked.
func (s *Service) registerRolesLocked(registration accesscontrol.RoleRegistration) {
@@ -474,6 +521,7 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs
defer span.End()
acRegs := pluginutils.ToRegistrations(ID, name, regs)
updatedBasicRoles := false
for _, r := range acRegs {
if err := pluginutils.ValidatePluginRole(ID, r.Role); err != nil {
return err
@@ -500,11 +548,23 @@ func (s *Service) DeclarePluginRoles(ctx context.Context, ID, name string, regs
if initialized {
s.rolesMu.Lock()
s.registerRolesLocked(r)
updatedBasicRoles = true
s.rolesMu.Unlock()
s.cache.Flush()
}
}
if updatedBasicRoles {
s.rolesMu.RLock()
rolesSnapshot := s.getBasicRolePermissionsLocked()
s.rolesMu.RUnlock()
// plugin roles can be declared after startup - keep DB in sync
if err := s.refreshBasicRolePermissionsInDB(ctx, rolesSnapshot); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,623 @@
package database
import (
"context"
"strings"
"time"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/seeding"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/util/xorm/core"
)
const basicRolePermBatchSize = 500
// LoadRoles returns all fixed and plugin roles (global org) with permissions, indexed by role name.
func (s *AccessControlStore) LoadRoles(ctx context.Context) (map[string]*accesscontrol.RoleDTO, error) {
out := map[string]*accesscontrol.RoleDTO{}
err := s.sql.WithDbSession(ctx, func(sess *db.Session) error {
type roleRow struct {
ID int64 `xorm:"id"`
OrgID int64 `xorm:"org_id"`
Version int64 `xorm:"version"`
UID string `xorm:"uid"`
Name string `xorm:"name"`
DisplayName string `xorm:"display_name"`
Description string `xorm:"description"`
Group string `xorm:"group_name"`
Hidden bool `xorm:"hidden"`
Updated time.Time `xorm:"updated"`
Created time.Time `xorm:"created"`
}
roles := []roleRow{}
if err := sess.Table("role").
Where("org_id = ?", accesscontrol.GlobalOrgID).
Where("(name LIKE ? OR name LIKE ?)", accesscontrol.FixedRolePrefix+"%", accesscontrol.PluginRolePrefix+"%").
Find(&roles); err != nil {
return err
}
if len(roles) == 0 {
return nil
}
roleIDs := make([]any, 0, len(roles))
roleByID := make(map[int64]*accesscontrol.RoleDTO, len(roles))
for _, r := range roles {
dto := &accesscontrol.RoleDTO{
ID: r.ID,
OrgID: r.OrgID,
Version: r.Version,
UID: r.UID,
Name: r.Name,
DisplayName: r.DisplayName,
Description: r.Description,
Group: r.Group,
Hidden: r.Hidden,
Updated: r.Updated,
Created: r.Created,
}
out[dto.Name] = dto
roleByID[dto.ID] = dto
roleIDs = append(roleIDs, dto.ID)
}
type permRow struct {
RoleID int64 `xorm:"role_id"`
Action string `xorm:"action"`
Scope string `xorm:"scope"`
}
perms := []permRow{}
if err := sess.Table("permission").In("role_id", roleIDs...).Find(&perms); err != nil {
return err
}
for _, p := range perms {
dto := roleByID[p.RoleID]
if dto == nil {
continue
}
dto.Permissions = append(dto.Permissions, accesscontrol.Permission{
RoleID: p.RoleID,
Action: p.Action,
Scope: p.Scope,
})
}
return nil
})
return out, err
}
func (s *AccessControlStore) SetRole(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error {
if existingRole == nil {
return nil
}
return s.sql.WithDbSession(ctx, func(sess *db.Session) error {
_, err := sess.Table("role").
Where("id = ? AND org_id = ?", existingRole.ID, accesscontrol.GlobalOrgID).
Update(map[string]any{
"display_name": wantedRole.DisplayName,
"description": wantedRole.Description,
"group_name": wantedRole.Group,
"hidden": wantedRole.Hidden,
"updated": time.Now(),
})
return err
})
}
func (s *AccessControlStore) SetPermissions(ctx context.Context, existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) error {
if existingRole == nil {
return nil
}
type key struct{ Action, Scope string }
existing := map[key]struct{}{}
for _, p := range existingRole.Permissions {
existing[key{p.Action, p.Scope}] = struct{}{}
}
desired := map[key]struct{}{}
for _, p := range wantedRole.Permissions {
desired[key{p.Action, p.Scope}] = struct{}{}
}
toAdd := make([]accesscontrol.Permission, 0)
toRemove := make([]accesscontrol.SeedPermission, 0)
now := time.Now()
for k := range desired {
if _, ok := existing[k]; ok {
continue
}
perm := accesscontrol.Permission{
RoleID: existingRole.ID,
Action: k.Action,
Scope: k.Scope,
Created: now,
Updated: now,
}
perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope)
toAdd = append(toAdd, perm)
}
for k := range existing {
if _, ok := desired[k]; ok {
continue
}
toRemove = append(toRemove, accesscontrol.SeedPermission{Action: k.Action, Scope: k.Scope})
}
if len(toAdd) == 0 && len(toRemove) == 0 {
return nil
}
return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
if len(toRemove) > 0 {
if err := DeleteRolePermissionTuples(sess, s.sql.GetDBType(), existingRole.ID, toRemove); err != nil {
return err
}
}
if len(toAdd) > 0 {
_, err := sess.InsertMulti(toAdd)
return err
}
return nil
})
}
func (s *AccessControlStore) CreateRole(ctx context.Context, role accesscontrol.RoleDTO) error {
now := time.Now()
uid := role.UID
if uid == "" && (strings.HasPrefix(role.Name, accesscontrol.FixedRolePrefix) || strings.HasPrefix(role.Name, accesscontrol.PluginRolePrefix)) {
uid = accesscontrol.PrefixedRoleUID(role.Name)
}
r := accesscontrol.Role{
OrgID: accesscontrol.GlobalOrgID,
Version: role.Version,
UID: uid,
Name: role.Name,
DisplayName: role.DisplayName,
Description: role.Description,
Group: role.Group,
Hidden: role.Hidden,
Created: now,
Updated: now,
}
if r.Version == 0 {
r.Version = 1
}
return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
if _, err := sess.Insert(&r); err != nil {
return err
}
if len(role.Permissions) == 0 {
return nil
}
// De-duplicate permissions on (action, scope) to avoid unique constraint violations.
// Some role definitions may accidentally include duplicates.
type permKey struct{ Action, Scope string }
seen := make(map[permKey]struct{}, len(role.Permissions))
perms := make([]accesscontrol.Permission, 0, len(role.Permissions))
for _, p := range role.Permissions {
k := permKey{Action: p.Action, Scope: p.Scope}
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
perm := accesscontrol.Permission{
RoleID: r.ID,
Action: p.Action,
Scope: p.Scope,
Created: now,
Updated: now,
}
perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope)
perms = append(perms, perm)
}
_, err := sess.InsertMulti(perms)
return err
})
}
func (s *AccessControlStore) DeleteRoles(ctx context.Context, roleUIDs []string) error {
if len(roleUIDs) == 0 {
return nil
}
uids := make([]any, 0, len(roleUIDs))
for _, uid := range roleUIDs {
uids = append(uids, uid)
}
return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
type row struct {
ID int64 `xorm:"id"`
UID string `xorm:"uid"`
}
rows := []row{}
if err := sess.Table("role").
Where("org_id = ?", accesscontrol.GlobalOrgID).
In("uid", uids...).
Find(&rows); err != nil {
return err
}
if len(rows) == 0 {
return nil
}
roleIDs := make([]any, 0, len(rows))
for _, r := range rows {
roleIDs = append(roleIDs, r.ID)
}
// Remove permissions and assignments first to avoid FK issues (if enabled).
{
args := append([]any{"DELETE FROM permission WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...)
if _, err := sess.Exec(args...); err != nil {
return err
}
}
{
args := append([]any{"DELETE FROM user_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...)
if _, err := sess.Exec(args...); err != nil {
return err
}
}
{
args := append([]any{"DELETE FROM team_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...)
if _, err := sess.Exec(args...); err != nil {
return err
}
}
{
args := append([]any{"DELETE FROM builtin_role WHERE role_id IN (?" + strings.Repeat(",?", len(roleIDs)-1) + ")"}, roleIDs...)
if _, err := sess.Exec(args...); err != nil {
return err
}
}
args := append([]any{"DELETE FROM role WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")", accesscontrol.GlobalOrgID}, uids...)
_, err := sess.Exec(args...)
return err
})
}
// OSS basic-role permission refresh uses seeding.Seeder.Seed() with a desired set computed in memory.
// These methods implement the permission seeding part of seeding.SeedingBackend against the current permission table.
func (s *AccessControlStore) LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error) {
var out map[accesscontrol.SeedPermission]struct{}
err := s.sql.WithDbSession(ctx, func(sess *db.Session) error {
rows, err := LoadBasicRoleSeedPermissions(sess)
if err != nil {
return err
}
out = make(map[accesscontrol.SeedPermission]struct{}, len(rows))
for _, r := range rows {
r.Origin = ""
out[r] = struct{}{}
}
return nil
})
return out, err
}
func (s *AccessControlStore) Apply(ctx context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error {
rolesToUpgrade := seeding.RolesToUpgrade(added, removed)
// Run the same OSS apply logic as ossBasicRoleSeedBackend.Apply inside a single transaction.
return s.sql.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
defs := accesscontrol.BuildBasicRoleDefinitions()
builtinToRoleID, err := EnsureBasicRolesExist(sess, defs)
if err != nil {
return err
}
backend := &ossBasicRoleSeedBackend{
sess: sess,
now: time.Now(),
builtinToRoleID: builtinToRoleID,
desired: nil,
dbType: s.sql.GetDBType(),
}
if err := backend.Apply(ctx, added, removed, updated); err != nil {
return err
}
return BumpBasicRoleVersions(sess, rolesToUpgrade)
})
}
// EnsureBasicRolesExist ensures the built-in basic roles exist in the role table and are bound in builtin_role.
// It returns a mapping from builtin role name (for example "Admin") to role ID.
func EnsureBasicRolesExist(sess *db.Session, defs map[string]*accesscontrol.RoleDTO) (map[string]int64, error) {
uidToBuiltin := make(map[string]string, len(defs))
uids := make([]any, 0, len(defs))
for builtin, def := range defs {
uidToBuiltin[def.UID] = builtin
uids = append(uids, def.UID)
}
type roleRow struct {
ID int64 `xorm:"id"`
UID string `xorm:"uid"`
}
rows := []roleRow{}
if err := sess.Table("role").
Where("org_id = ?", accesscontrol.GlobalOrgID).
In("uid", uids...).
Find(&rows); err != nil {
return nil, err
}
ts := time.Now()
builtinToRoleID := make(map[string]int64, len(defs))
for _, r := range rows {
br, ok := uidToBuiltin[r.UID]
if !ok {
continue
}
builtinToRoleID[br] = r.ID
}
for builtin, def := range defs {
roleID, ok := builtinToRoleID[builtin]
if !ok {
role := accesscontrol.Role{
OrgID: def.OrgID,
Version: def.Version,
UID: def.UID,
Name: def.Name,
DisplayName: def.DisplayName,
Description: def.Description,
Group: def.Group,
Hidden: def.Hidden,
Created: ts,
Updated: ts,
}
if _, err := sess.Insert(&role); err != nil {
return nil, err
}
roleID = role.ID
builtinToRoleID[builtin] = roleID
}
has, err := sess.Table("builtin_role").
Where("role_id = ? AND role = ? AND org_id = ?", roleID, builtin, accesscontrol.GlobalOrgID).
Exist()
if err != nil {
return nil, err
}
if !has {
br := accesscontrol.BuiltinRole{
RoleID: roleID,
OrgID: accesscontrol.GlobalOrgID,
Role: builtin,
Created: ts,
Updated: ts,
}
if _, err := sess.Table("builtin_role").Insert(&br); err != nil {
return nil, err
}
}
}
return builtinToRoleID, nil
}
// DeleteRolePermissionTuples deletes permissions for a single role by (action, scope) pairs.
//
// It uses a row-constructor IN clause where supported (MySQL, Postgres, SQLite) and falls back
// to a WHERE ... OR ... form for MSSQL.
func DeleteRolePermissionTuples(sess *db.Session, dbType core.DbType, roleID int64, perms []accesscontrol.SeedPermission) error {
if len(perms) == 0 {
return nil
}
if dbType == migrator.MSSQL {
// MSSQL doesn't support (action, scope) IN ((?,?),(?,?)) row constructors.
where := make([]string, 0, len(perms))
args := make([]any, 0, 1+len(perms)*2)
args = append(args, roleID)
for _, p := range perms {
where = append(where, "(action = ? AND scope = ?)")
args = append(args, p.Action, p.Scope)
}
_, err := sess.Exec(
append([]any{
"DELETE FROM permission WHERE role_id = ? AND (" + strings.Join(where, " OR ") + ")",
}, args...)...,
)
return err
}
args := make([]any, 0, 1+len(perms)*2)
args = append(args, roleID)
for _, p := range perms {
args = append(args, p.Action, p.Scope)
}
sql := "DELETE FROM permission WHERE role_id = ? AND (action, scope) IN (" +
strings.Repeat("(?, ?),", len(perms)-1) + "(?, ?))"
_, err := sess.Exec(append([]any{sql}, args...)...)
return err
}
type ossBasicRoleSeedBackend struct {
sess *db.Session
now time.Time
builtinToRoleID map[string]int64
desired map[accesscontrol.SeedPermission]struct{}
dbType core.DbType
}
func (b *ossBasicRoleSeedBackend) LoadPrevious(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) {
rows, err := LoadBasicRoleSeedPermissions(b.sess)
if err != nil {
return nil, err
}
out := make(map[accesscontrol.SeedPermission]struct{}, len(rows))
for _, r := range rows {
// Ensure the key matches what OSS seeding uses (Origin is always empty for basic role refresh).
r.Origin = ""
out[r] = struct{}{}
}
return out, nil
}
func (b *ossBasicRoleSeedBackend) LoadDesired(_ context.Context) (map[accesscontrol.SeedPermission]struct{}, error) {
return b.desired, nil
}
func (b *ossBasicRoleSeedBackend) Apply(_ context.Context, added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) error {
// Delete removed permissions (this includes user-defined permissions that aren't in desired).
if len(removed) > 0 {
permsByRoleID := map[int64][]accesscontrol.SeedPermission{}
for _, p := range removed {
roleID, ok := b.builtinToRoleID[p.BuiltInRole]
if !ok {
continue
}
permsByRoleID[roleID] = append(permsByRoleID[roleID], p)
}
for roleID, perms := range permsByRoleID {
// Chunk to keep statement sizes and parameter counts bounded.
if err := batch(len(perms), basicRolePermBatchSize, func(start, end int) error {
return DeleteRolePermissionTuples(b.sess, b.dbType, roleID, perms[start:end])
}); err != nil {
return err
}
}
}
// Insert added permissions and updated-target permissions.
toInsertSeed := make([]accesscontrol.SeedPermission, 0, len(added)+len(updated))
toInsertSeed = append(toInsertSeed, added...)
for _, v := range updated {
toInsertSeed = append(toInsertSeed, v)
}
if len(toInsertSeed) == 0 {
return nil
}
// De-duplicate on (role_id, action, scope). This avoids unique constraint violations when:
// - the same permission appears in both added and updated
// - multiple plugin origins grant the same permission (Origin is not persisted in permission table)
type permKey struct {
RoleID int64
Action string
Scope string
}
seen := make(map[permKey]struct{}, len(toInsertSeed))
toInsert := make([]accesscontrol.Permission, 0, len(toInsertSeed))
for _, p := range toInsertSeed {
roleID, ok := b.builtinToRoleID[p.BuiltInRole]
if !ok {
continue
}
k := permKey{RoleID: roleID, Action: p.Action, Scope: p.Scope}
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
perm := accesscontrol.Permission{
RoleID: roleID,
Action: p.Action,
Scope: p.Scope,
Created: b.now,
Updated: b.now,
}
perm.Kind, perm.Attribute, perm.Identifier = accesscontrol.SplitScope(perm.Scope)
toInsert = append(toInsert, perm)
}
return batch(len(toInsert), basicRolePermBatchSize, func(start, end int) error {
// MySQL: ignore conflicts to make seeding idempotent under retries/concurrency.
// Conflicts can happen if the same permission already exists (unique on role_id, action, scope).
if b.dbType == migrator.MySQL {
args := make([]any, 0, (end-start)*8)
for i := start; i < end; i++ {
p := toInsert[i]
args = append(args, p.RoleID, p.Action, p.Scope, p.Kind, p.Attribute, p.Identifier, p.Updated, p.Created)
}
sql := append([]any{`INSERT IGNORE INTO permission (role_id, action, scope, kind, attribute, identifier, updated, created) VALUES ` +
strings.Repeat("(?, ?, ?, ?, ?, ?, ?, ?),", end-start-1) + "(?, ?, ?, ?, ?, ?, ?, ?)"}, args...)
_, err := b.sess.Exec(sql...)
return err
}
_, err := b.sess.InsertMulti(toInsert[start:end])
return err
})
}
func batch(count, size int, eachFn func(start, end int) error) error {
for i := 0; i < count; {
end := i + size
if end > count {
end = count
}
if err := eachFn(i, end); err != nil {
return err
}
i = end
}
return nil
}
// BumpBasicRoleVersions increments the role version for the given builtin basic roles (Viewer/Editor/Admin/Grafana Admin).
// Unknown role names are ignored.
func BumpBasicRoleVersions(sess *db.Session, basicRoles []string) error {
if len(basicRoles) == 0 {
return nil
}
defs := accesscontrol.BuildBasicRoleDefinitions()
uids := make([]any, 0, len(basicRoles))
for _, br := range basicRoles {
def, ok := defs[br]
if !ok {
continue
}
uids = append(uids, def.UID)
}
if len(uids) == 0 {
return nil
}
sql := "UPDATE role SET version = version + 1 WHERE org_id = ? AND uid IN (?" + strings.Repeat(",?", len(uids)-1) + ")"
_, err := sess.Exec(append([]any{sql, accesscontrol.GlobalOrgID}, uids...)...)
return err
}
// LoadBasicRoleSeedPermissions returns the current (builtin_role, action, scope) permissions granted to basic roles.
// It sets Origin to empty.
func LoadBasicRoleSeedPermissions(sess *db.Session) ([]accesscontrol.SeedPermission, error) {
rows := []accesscontrol.SeedPermission{}
err := sess.SQL(
`SELECT role.display_name AS builtin_role, p.action, p.scope, '' AS origin
FROM role INNER JOIN permission AS p ON p.role_id = role.id
WHERE role.org_id = ? AND role.name LIKE 'basic:%'`,
accesscontrol.GlobalOrgID,
).Find(&rows)
return rows, err
}
@@ -15,6 +15,7 @@ import (
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/serverlock"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/authz/zanzana"
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/folder"
@@ -130,6 +131,9 @@ func (r *ZanzanaReconciler) Run(ctx context.Context) error {
// Reconcile schedules as job that will run and reconcile resources between
// legacy access control and zanzana.
func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error {
// Ensure we don't reconcile an empty/partial RBAC state before OSS has seeded basic role permissions.
// This matters most during startup where fixed-role loading + basic-role permission refresh runs as another background service.
r.waitForBasicRolesSeeded(ctx)
r.reconcile(ctx)
// FIXME:
@@ -145,6 +149,57 @@ func (r *ZanzanaReconciler) Reconcile(ctx context.Context) error {
}
}
func (r *ZanzanaReconciler) hasBasicRolePermissions(ctx context.Context) bool {
var count int64
// Basic role permissions are stored on "basic:%" roles in the global org (0).
// In a fresh DB, this will be empty until fixed roles are registered and the basic role permission refresh runs.
type row struct {
Count int64 `xorm:"count"`
}
_ = r.store.WithDbSession(ctx, func(sess *db.Session) error {
var rr row
_, err := sess.SQL(
`SELECT COUNT(*) AS count
FROM role INNER JOIN permission AS p ON p.role_id = role.id
WHERE role.org_id = ? AND role.name LIKE ?`,
accesscontrol.GlobalOrgID,
accesscontrol.BasicRolePrefix+"%",
).Get(&rr)
if err != nil {
return err
}
count = rr.Count
return nil
})
return count > 0
}
func (r *ZanzanaReconciler) waitForBasicRolesSeeded(ctx context.Context) {
// Best-effort: don't block forever. If we can't observe basic roles, proceed anyway.
const (
maxWait = 15 * time.Second
interval = 1 * time.Second
)
deadline := time.NewTimer(maxWait)
defer deadline.Stop()
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
if r.hasBasicRolePermissions(ctx) {
return
}
select {
case <-ctx.Done():
return
case <-deadline.C:
return
case <-ticker.C:
}
}
}
func (r *ZanzanaReconciler) reconcile(ctx context.Context) {
run := func(ctx context.Context, namespace string) (ok bool) {
now := time.Now()
@@ -0,0 +1,67 @@
package dualwrite
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/services/accesscontrol"
)
func TestZanzanaReconciler_hasBasicRolePermissions(t *testing.T) {
env := setupTestEnv(t)
r := &ZanzanaReconciler{
store: env.db,
}
ctx := context.Background()
require.False(t, r.hasBasicRolePermissions(ctx))
err := env.db.WithDbSession(ctx, func(sess *db.Session) error {
now := time.Now()
_, err := sess.Exec(
`INSERT INTO role (org_id, uid, name, display_name, group_name, description, hidden, version, created, updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
accesscontrol.GlobalOrgID,
"basic_viewer_uid_test",
accesscontrol.BasicRolePrefix+"viewer",
"Viewer",
"Basic",
"Viewer role",
false,
1,
now,
now,
)
if err != nil {
return err
}
var roleID int64
if _, err := sess.SQL(`SELECT id FROM role WHERE org_id = ? AND uid = ?`, accesscontrol.GlobalOrgID, "basic_viewer_uid_test").Get(&roleID); err != nil {
return err
}
_, err = sess.Exec(
`INSERT INTO permission (role_id, action, scope, kind, attribute, identifier, created, updated)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
roleID,
"dashboards:read",
"dashboards:*",
"",
"",
"",
now,
now,
)
return err
})
require.NoError(t, err)
require.True(t, r.hasBasicRolePermissions(ctx))
}
+16
View File
@@ -1,6 +1,7 @@
package accesscontrol
import (
"context"
"encoding/json"
"errors"
"fmt"
@@ -594,3 +595,18 @@ type QueryWithOrg struct {
OrgId *int64 `json:"orgId"`
Global bool `json:"global"`
}
type SeedPermission struct {
BuiltInRole string `xorm:"builtin_role"`
Action string `xorm:"action"`
Scope string `xorm:"scope"`
Origin string `xorm:"origin"`
}
type RoleStore interface {
LoadRoles(ctx context.Context) (map[string]*RoleDTO, error)
SetRole(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error
SetPermissions(ctx context.Context, existingRole *RoleDTO, wantedRole RoleDTO) error
CreateRole(ctx context.Context, role RoleDTO) error
DeleteRoles(ctx context.Context, roleUIDs []string) error
}
@@ -0,0 +1,451 @@
package seeding
import (
"context"
"fmt"
"regexp"
"slices"
"strings"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/accesscontrol/pluginutils"
"github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol"
)
type Seeder struct {
log log.Logger
roleStore accesscontrol.RoleStore
backend SeedingBackend
builtinsPermissions map[accesscontrol.SeedPermission]struct{}
seededFixedRoles map[string]bool
seededPluginRoles map[string]bool
seededPlugins map[string]bool
hasSeededAlready bool
}
// SeedingBackend provides the seed-set specific operations needed to seed.
type SeedingBackend interface {
// LoadPrevious returns the currently stored permissions for previously seeded roles.
LoadPrevious(ctx context.Context) (map[accesscontrol.SeedPermission]struct{}, error)
// Apply updates the database to match the desired permissions.
Apply(ctx context.Context,
added, removed []accesscontrol.SeedPermission,
updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission,
) error
}
func New(log log.Logger, roleStore accesscontrol.RoleStore, backend SeedingBackend) *Seeder {
return &Seeder{
log: log,
roleStore: roleStore,
backend: backend,
builtinsPermissions: map[accesscontrol.SeedPermission]struct{}{},
seededFixedRoles: map[string]bool{},
seededPluginRoles: map[string]bool{},
seededPlugins: map[string]bool{},
hasSeededAlready: false,
}
}
// SetDesiredPermissions replaces the in-memory desired permission set used by Seed().
func (s *Seeder) SetDesiredPermissions(desired map[accesscontrol.SeedPermission]struct{}) {
if desired == nil {
s.builtinsPermissions = map[accesscontrol.SeedPermission]struct{}{}
return
}
s.builtinsPermissions = desired
}
// Seed loads current and desired permissions, diffs them (including scope updates), applies changes, and bumps versions.
func (s *Seeder) Seed(ctx context.Context) error {
previous, err := s.backend.LoadPrevious(ctx)
if err != nil {
return err
}
// - Do not remove plugin permissions when the plugin didn't register this run (Origin set but not in seededPlugins).
// - Preserve legacy plugin app access permissions in the persisted seed set (these are granted by default).
if len(previous) > 0 {
filtered := make(map[accesscontrol.SeedPermission]struct{}, len(previous))
for p := range previous {
if p.Action == pluginaccesscontrol.ActionAppAccess {
continue
}
if p.Origin != "" && !s.seededPlugins[p.Origin] {
continue
}
filtered[p] = struct{}{}
}
previous = filtered
}
added, removed, updated := s.permissionDiff(previous, s.builtinsPermissions)
if err := s.backend.Apply(ctx, added, removed, updated); err != nil {
return err
}
return nil
}
// SeedRoles populates the database with the roles and their assignments
// It will create roles that do not exist and update roles that have changed
// Do not use for provisioning. Validation is not enforced.
func (s *Seeder) SeedRoles(ctx context.Context, registrationList []accesscontrol.RoleRegistration) error {
roleMap, err := s.roleStore.LoadRoles(ctx)
if err != nil {
return err
}
missingRoles := make([]accesscontrol.RoleRegistration, 0, len(registrationList))
// Diff existing roles with the ones we want to seed.
// If a role is missing, we add it to the missingRoles list
for _, registration := range registrationList {
registration := registration
role, ok := roleMap[registration.Role.Name]
switch {
case registration.Role.IsFixed():
s.seededFixedRoles[registration.Role.Name] = true
case registration.Role.IsPlugin():
s.seededPluginRoles[registration.Role.Name] = true
// To be resilient to failed plugin loadings, we remember the plugins that have registered,
// later we'll ignore permissions and roles of other plugins
s.seededPlugins[pluginutils.PluginIDFromName(registration.Role.Name)] = true
}
s.rememberPermissionAssignments(&registration.Role, registration.Grants, registration.Exclude)
if !ok {
missingRoles = append(missingRoles, registration)
continue
}
if needsRoleUpdate(role, registration.Role) {
if err := s.roleStore.SetRole(ctx, role, registration.Role); err != nil {
return err
}
}
if needsPermissionsUpdate(role, registration.Role) {
if err := s.roleStore.SetPermissions(ctx, role, registration.Role); err != nil {
return err
}
}
}
for _, registration := range missingRoles {
if err := s.roleStore.CreateRole(ctx, registration.Role); err != nil {
return err
}
}
return nil
}
func needsPermissionsUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool {
if existingRole == nil {
return true
}
if len(existingRole.Permissions) != len(wantedRole.Permissions) {
return true
}
for _, p := range wantedRole.Permissions {
found := false
for _, ep := range existingRole.Permissions {
if ep.Action == p.Action && ep.Scope == p.Scope {
found = true
break
}
}
if !found {
return true
}
}
return false
}
func needsRoleUpdate(existingRole *accesscontrol.RoleDTO, wantedRole accesscontrol.RoleDTO) bool {
if existingRole == nil {
return true
}
if existingRole.Name != wantedRole.Name {
return false
}
if existingRole.DisplayName != wantedRole.DisplayName {
return true
}
if existingRole.Description != wantedRole.Description {
return true
}
if existingRole.Group != wantedRole.Group {
return true
}
if existingRole.Hidden != wantedRole.Hidden {
return true
}
return false
}
// Deprecated: SeedRole is deprecated and should not be used.
// SeedRoles only does boot up seeding and should not be used for runtime seeding.
func (s *Seeder) SeedRole(ctx context.Context, role accesscontrol.RoleDTO, builtInRoles []string) error {
addedPermissions := make(map[string]struct{}, len(role.Permissions))
permissions := make([]accesscontrol.Permission, 0, len(role.Permissions))
for _, p := range role.Permissions {
key := fmt.Sprintf("%s:%s", p.Action, p.Scope)
if _, ok := addedPermissions[key]; !ok {
addedPermissions[key] = struct{}{}
permissions = append(permissions, accesscontrol.Permission{Action: p.Action, Scope: p.Scope})
}
}
wantedRole := accesscontrol.RoleDTO{
OrgID: accesscontrol.GlobalOrgID,
Version: role.Version,
UID: role.UID,
Name: role.Name,
DisplayName: role.DisplayName,
Description: role.Description,
Group: role.Group,
Permissions: permissions,
Hidden: role.Hidden,
}
roleMap, err := s.roleStore.LoadRoles(ctx)
if err != nil {
return err
}
existingRole := roleMap[wantedRole.Name]
if existingRole == nil {
if err := s.roleStore.CreateRole(ctx, wantedRole); err != nil {
return err
}
} else {
if needsRoleUpdate(existingRole, wantedRole) {
if err := s.roleStore.SetRole(ctx, existingRole, wantedRole); err != nil {
return err
}
}
if needsPermissionsUpdate(existingRole, wantedRole) {
if err := s.roleStore.SetPermissions(ctx, existingRole, wantedRole); err != nil {
return err
}
}
}
// Remember seeded roles
if wantedRole.IsFixed() {
s.seededFixedRoles[wantedRole.Name] = true
}
isPluginRole := wantedRole.IsPlugin()
if isPluginRole {
s.seededPluginRoles[wantedRole.Name] = true
// To be resilient to failed plugin loadings, we remember the plugins that have registered,
// later we'll ignore permissions and roles of other plugins
s.seededPlugins[pluginutils.PluginIDFromName(role.Name)] = true
}
s.rememberPermissionAssignments(&wantedRole, builtInRoles, []string{})
return nil
}
func (s *Seeder) rememberPermissionAssignments(role *accesscontrol.RoleDTO, builtInRoles []string, excludedRoles []string) {
AppendDesiredPermissions(s.builtinsPermissions, s.log, role, builtInRoles, excludedRoles, true)
}
// AppendDesiredPermissions accumulates permissions from a role registration onto basic roles (Viewer/Editor/Admin/Grafana Admin).
// - It expands parents via accesscontrol.BuiltInRolesWithParents.
// - It can optionally ignore plugin app access permissions (which are granted by default).
func AppendDesiredPermissions(
out map[accesscontrol.SeedPermission]struct{},
logger log.Logger,
role *accesscontrol.RoleDTO,
builtInRoles []string,
excludedRoles []string,
ignorePluginAppAccess bool,
) {
if out == nil || role == nil {
return
}
for builtInRole := range accesscontrol.BuiltInRolesWithParents(builtInRoles) {
// Skip excluded grants
if slices.Contains(excludedRoles, builtInRole) {
continue
}
for _, perm := range role.Permissions {
if ignorePluginAppAccess && perm.Action == pluginaccesscontrol.ActionAppAccess {
logger.Debug("Role is attempting to grant access permission, but this permission is already granted by default and will be ignored",
"role", role.Name, "permission", perm.Action, "scope", perm.Scope)
continue
}
sp := accesscontrol.SeedPermission{
BuiltInRole: builtInRole,
Action: perm.Action,
Scope: perm.Scope,
}
if role.IsPlugin() {
sp.Origin = pluginutils.PluginIDFromName(role.Name)
}
out[sp] = struct{}{}
}
}
}
// permissionDiff returns:
// - added: present in desired permissions, not in previous permissions
// - removed: present in previous permissions, not in desired permissions
// - updated: same role + action, but scope changed
func (s *Seeder) permissionDiff(previous, desired map[accesscontrol.SeedPermission]struct{}) (added, removed []accesscontrol.SeedPermission, updated map[accesscontrol.SeedPermission]accesscontrol.SeedPermission) {
addedSet := make(map[accesscontrol.SeedPermission]struct{}, 0)
for n := range desired {
if _, already := previous[n]; !already {
addedSet[n] = struct{}{}
} else {
delete(previous, n)
}
}
// Check if any of the new permissions is actually an old permission with an updated scope
updated = make(map[accesscontrol.SeedPermission]accesscontrol.SeedPermission, 0)
for n := range addedSet {
for p := range previous {
if n.BuiltInRole == p.BuiltInRole && n.Action == p.Action {
updated[p] = n
delete(addedSet, n)
}
}
}
for p := range addedSet {
added = append(added, p)
}
for p := range previous {
if p.Action == pluginaccesscontrol.ActionAppAccess &&
p.Scope != pluginaccesscontrol.ScopeProvider.GetResourceAllScope() {
// Allows backward compatibility with plugins that have been seeded before the grant ignore rule was added
s.log.Info("This permission already existed so it will not be removed",
"role", p.BuiltInRole, "permission", p.Action, "scope", p.Scope)
continue
}
removed = append(removed, p)
}
return added, removed, updated
}
func (s *Seeder) ClearBasicRolesPluginPermissions(ID string) {
removable := []accesscontrol.SeedPermission{}
for key := range s.builtinsPermissions {
if matchPermissionByPluginID(key, ID) {
removable = append(removable, key)
}
}
for _, perm := range removable {
delete(s.builtinsPermissions, perm)
}
}
func matchPermissionByPluginID(perm accesscontrol.SeedPermission, pluginID string) bool {
if perm.Origin != pluginID {
return false
}
actionTemplate := regexp.MustCompile(fmt.Sprintf("%s[.:]", pluginID))
scopeTemplate := fmt.Sprintf(":%s", pluginID)
return actionTemplate.MatchString(perm.Action) || strings.HasSuffix(perm.Scope, scopeTemplate)
}
// RolesToUpgrade returns the unique basic roles that should have their version incremented.
func RolesToUpgrade(added, removed []accesscontrol.SeedPermission) []string {
set := map[string]struct{}{}
for _, p := range added {
set[p.BuiltInRole] = struct{}{}
}
for _, p := range removed {
set[p.BuiltInRole] = struct{}{}
}
out := make([]string, 0, len(set))
for r := range set {
out = append(out, r)
}
return out
}
func (s *Seeder) ClearPluginRoles(ID string) {
expectedPrefix := fmt.Sprintf("%s%s:", accesscontrol.PluginRolePrefix, ID)
for roleName := range s.seededPluginRoles {
if strings.HasPrefix(roleName, expectedPrefix) {
delete(s.seededPluginRoles, roleName)
}
}
}
func (s *Seeder) MarkSeededAlready() {
s.hasSeededAlready = true
}
func (s *Seeder) HasSeededAlready() bool {
return s.hasSeededAlready
}
func (s *Seeder) RemoveAbsentRoles(ctx context.Context) error {
roleMap, errGet := s.roleStore.LoadRoles(ctx)
if errGet != nil {
s.log.Error("failed to get fixed roles from store", "err", errGet)
return errGet
}
toRemove := []string{}
for _, r := range roleMap {
if r == nil {
continue
}
if r.IsFixed() {
if !s.seededFixedRoles[r.Name] {
s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name)
toRemove = append(toRemove, r.UID)
}
continue
}
if r.IsPlugin() {
if !s.seededPlugins[pluginutils.PluginIDFromName(r.Name)] {
// To be resilient to failed plugin loadings
// ignore stored roles related to plugins that have not registered this time
s.log.Debug("plugin role has not been registered on this run skipping its removal", "role", r.Name)
continue
}
if !s.seededPluginRoles[r.Name] {
s.log.Info("role is not seeded anymore, mark it for deletion", "role", r.Name)
toRemove = append(toRemove, r.UID)
}
}
}
if errDelete := s.roleStore.DeleteRoles(ctx, toRemove); errDelete != nil {
s.log.Error("failed to delete absent fixed and plugin roles", "err", errDelete)
return errDelete
}
return nil
}
-3
View File
@@ -294,9 +294,6 @@ type DashboardProvisioning struct {
ExternalID string `xorm:"external_id"`
CheckSum string
Updated int64
// note: only used when writing metadata to unified storage resources - not saved in legacy table.
AllowUIUpdates bool `xorm:"-"`
}
type DeleteDashboardCommand struct {
@@ -1942,7 +1942,6 @@ func (dr *DashboardServiceImpl) saveProvisionedDashboardThroughK8s(ctx context.C
// HOWEVER, maybe OK to leave this for now and "fix" it by using file provisioning for mode 4
m.Kind = utils.ManagerKindClassicFP // nolint:staticcheck
m.Identity = provisioning.Name
m.AllowsEdits = provisioning.AllowUIUpdates
s.Path = provisioning.ExternalID
s.Checksum = provisioning.CheckSum
s.TimestampMillis = time.Unix(provisioning.Updated, 0).UnixMilli()
@@ -813,6 +813,65 @@ func (_c *AlertmanagerMock_StopAndWait_Call) RunAndReturn(run func()) *Alertmana
return _c
}
// TestIntegration provides a mock function with given fields: ctx, receiverName, integrationConfig, alert
func (_m *AlertmanagerMock) TestIntegration(ctx context.Context, receiverName string, integrationConfig models.Integration, alert alertingmodels.TestReceiversConfigAlertParams) (alertingmodels.IntegrationStatus, error) {
ret := _m.Called(ctx, receiverName, integrationConfig, alert)
if len(ret) == 0 {
panic("no return value specified for TestIntegration")
}
var r0 alertingmodels.IntegrationStatus
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string, models.Integration, alertingmodels.TestReceiversConfigAlertParams) (alertingmodels.IntegrationStatus, error)); ok {
return rf(ctx, receiverName, integrationConfig, alert)
}
if rf, ok := ret.Get(0).(func(context.Context, string, models.Integration, alertingmodels.TestReceiversConfigAlertParams) alertingmodels.IntegrationStatus); ok {
r0 = rf(ctx, receiverName, integrationConfig, alert)
} else {
r0 = ret.Get(0).(alertingmodels.IntegrationStatus)
}
if rf, ok := ret.Get(1).(func(context.Context, string, models.Integration, alertingmodels.TestReceiversConfigAlertParams) error); ok {
r1 = rf(ctx, receiverName, integrationConfig, alert)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// AlertmanagerMock_TestIntegration_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TestIntegration'
type AlertmanagerMock_TestIntegration_Call struct {
*mock.Call
}
// TestIntegration is a helper method to define mock.On call
// - ctx context.Context
// - receiverName string
// - integrationConfig models.Integration
// - alert alertingmodels.TestReceiversConfigAlertParams
func (_e *AlertmanagerMock_Expecter) TestIntegration(ctx interface{}, receiverName interface{}, integrationConfig interface{}, alert interface{}) *AlertmanagerMock_TestIntegration_Call {
return &AlertmanagerMock_TestIntegration_Call{Call: _e.mock.On("TestIntegration", ctx, receiverName, integrationConfig, alert)}
}
func (_c *AlertmanagerMock_TestIntegration_Call) Run(run func(ctx context.Context, receiverName string, integrationConfig models.Integration, alert alertingmodels.TestReceiversConfigAlertParams)) *AlertmanagerMock_TestIntegration_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string), args[2].(models.Integration), args[3].(alertingmodels.TestReceiversConfigAlertParams))
})
return _c
}
func (_c *AlertmanagerMock_TestIntegration_Call) Return(_a0 alertingmodels.IntegrationStatus, _a1 error) *AlertmanagerMock_TestIntegration_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *AlertmanagerMock_TestIntegration_Call) RunAndReturn(run func(context.Context, string, models.Integration, alertingmodels.TestReceiversConfigAlertParams) (alertingmodels.IntegrationStatus, error)) *AlertmanagerMock_TestIntegration_Call {
_c.Call.Return(run)
return _c
}
// TestReceivers provides a mock function with given fields: ctx, c
func (_m *AlertmanagerMock) TestReceivers(ctx context.Context, c definitions.TestReceiversConfigBodyParams) (*notify.TestReceiversResult, int, error) {
ret := _m.Called(ctx, c)
+18
View File
@@ -1,6 +1,9 @@
package notifier
import (
"encoding/json"
alertingModels "github.com/grafana/alerting/models"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/services/ngalert/models"
@@ -31,3 +34,18 @@ func SilenceToPostableSilence(s models.Silence) *alertingNotify.PostableSilence
Silence: s.Silence,
}
}
func IntegrationToIntegrationConfig(i models.Integration) (alertingModels.IntegrationConfig, error) {
raw, err := json.Marshal(i.Settings)
if err != nil {
return alertingModels.IntegrationConfig{}, err
}
return alertingModels.IntegrationConfig{
UID: i.UID,
Name: i.Name,
Type: string(i.Config.Type()),
DisableResolveMessage: i.DisableResolveMessage,
Settings: raw,
SecureSettings: i.SecureSettings,
}, nil
}
+26
View File
@@ -378,3 +378,29 @@ func EncryptedReceivers(receivers []*definitions.PostableApiReceiver, encryptFn
}
return encrypted, nil
}
// DecryptIntegrationSettings returns a function to decrypt integration settings.
func DecryptIntegrationSettings(ctx context.Context, ss secretService) models.DecryptFn {
return func(value string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(value)
if err != nil {
return "", err
}
decrypted, err := ss.Decrypt(ctx, decoded)
if err != nil {
return "", err
}
return string(decrypted), nil
}
}
// EncryptIntegrationSettings returns a function to encrypt integration settings.
func EncryptIntegrationSettings(ctx context.Context, ss secretService) models.EncryptFn {
return func(payload string) (string, error) {
encrypted, err := ss.Encrypt(ctx, []byte(payload), secrets.WithoutScope())
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(encrypted), nil
}
}
@@ -7,6 +7,7 @@ import (
"sync"
"time"
alertingModels "github.com/grafana/alerting/models"
"github.com/grafana/alerting/notify/nfstatus"
"github.com/prometheus/client_golang/prometheus"
@@ -71,6 +72,7 @@ type Alertmanager interface {
// Receivers
GetReceivers(ctx context.Context) ([]apimodels.Receiver, error)
TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigBodyParams) (*alertingNotify.TestReceiversResult, int, error)
TestIntegration(ctx context.Context, receiverName string, integrationConfig models.Integration, alert alertingModels.TestReceiversConfigAlertParams) (alertingModels.IntegrationStatus, error)
TestTemplate(ctx context.Context, c apimodels.TestTemplatesConfigBodyParams) (*TestTemplatesResults, error)
// Lifecycle
+12 -29
View File
@@ -2,7 +2,6 @@ package notifier
import (
"context"
"encoding/base64"
"errors"
"fmt"
"strings"
@@ -133,27 +132,27 @@ func (rs *ReceiverService) loadProvenances(ctx context.Context, orgID int64) (ma
return rs.provisioningStore.GetProvenances(ctx, orgID, (&models.Integration{}).ResourceType())
}
// GetReceiver returns a receiver by name.
// GetReceiver returns a receiver by its UID.
// The receiver's secure settings are decrypted if requested and the user has access to do so.
func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiverQuery, user identity.Requester) (*models.Receiver, error) {
func (rs *ReceiverService) GetReceiver(ctx context.Context, uid string, decrypt bool, user identity.Requester) (*models.Receiver, error) {
ctx, span := rs.tracer.Start(ctx, "alerting.receivers.get", trace.WithAttributes(
attribute.Int64("query_org_id", q.OrgID),
attribute.String("query_name", q.Name),
attribute.Bool("query_decrypt", q.Decrypt),
attribute.Int64("query_org_id", user.GetOrgID()),
attribute.String("query_uid", uid),
attribute.Bool("query_decrypt", decrypt),
))
defer span.End()
revision, err := rs.cfgStore.Get(ctx, q.OrgID)
revision, err := rs.cfgStore.Get(ctx, user.GetOrgID())
if err != nil {
return nil, err
}
prov, err := rs.loadProvenances(ctx, q.OrgID)
prov, err := rs.loadProvenances(ctx, user.GetOrgID())
if err != nil {
return nil, err
}
rcv, err := revision.GetReceiver(legacy_storage.NameToUid(q.Name), prov)
rcv, err := revision.GetReceiver(uid, prov)
if err != nil {
if errors.Is(err, legacy_storage.ErrReceiverNotFound) && rs.includeImported {
imported := rs.getImportedReceivers(ctx, span, []string{legacy_storage.NameToUid(q.Name)}, revision)
@@ -171,14 +170,14 @@ func (rs *ReceiverService) GetReceiver(ctx context.Context, q models.GetReceiver
))
auth := rs.authz.AuthorizeReadDecrypted
if !q.Decrypt {
if !decrypt {
auth = rs.authz.AuthorizeRead
}
if err := auth(ctx, user, rcv); err != nil {
return nil, err
}
if q.Decrypt {
if decrypt {
err := rcv.Decrypt(rs.decryptor(ctx))
if err != nil {
rs.log.FromContext(ctx).Warn("Failed to decrypt secure settings", "name", rcv.Name, "error", err)
@@ -684,28 +683,12 @@ func (rs *ReceiverService) deleteProvenances(ctx context.Context, orgID int64, i
// decryptor returns a models.DecryptFn that decrypts a secure setting. If decryption fails, the fallback value is used.
func (rs *ReceiverService) decryptor(ctx context.Context) models.DecryptFn {
return func(value string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(value)
if err != nil {
return "", err
}
decrypted, err := rs.encryptionService.Decrypt(ctx, decoded)
if err != nil {
return "", err
}
return string(decrypted), nil
}
return DecryptIntegrationSettings(ctx, rs.encryptionService)
}
// encryptor creates an encrypt function that delegates to secrets.Service and returns the base64 encoded result.
func (rs *ReceiverService) encryptor(ctx context.Context) models.EncryptFn {
return func(payload string) (string, error) {
s, err := rs.encryptionService.Encrypt(ctx, []byte(payload), secrets.WithoutScope())
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(s), nil
}
return EncryptIntegrationSettings(ctx, rs.encryptionService)
}
// checkOptimisticConcurrency checks if the existing receiver's version matches the desired version.
@@ -50,7 +50,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) {
t.Run("service gets receiver from AM config", func(t *testing.T) {
sut := createReceiverServiceSut(t, secretsService)
recv, err := sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser)
recv, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("slack receiver"), false, redactedUser)
require.NoError(t, err)
require.Equal(t, "slack receiver", recv.Name)
require.Len(t, recv.Integrations, 1)
@@ -60,7 +60,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) {
t.Run("service returns error when receiver does not exist", func(t *testing.T) {
sut := createReceiverServiceSut(t, secretsService)
_, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser)
_, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid("receiver1"), redactedUser)
require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound)
})
@@ -81,7 +81,7 @@ func TestIntegrationReceiverService_GetReceiver(t *testing.T) {
t.Run("falls to only Grafana if cannot read imported receivers", func(t *testing.T) {
sut := createReceiverServiceSut(t, secretsService, withImportedIncluded, withInvalidExtraConfig)
_, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), redactedUser)
_, err := sut.GetReceiver(context.Background(), singleQ(1, "receiver1"), false, redactedUser)
require.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound)
_, err = sut.GetReceiver(context.Background(), singleQ(1, "slack receiver"), redactedUser)
require.NoError(t, err)
@@ -412,8 +412,7 @@ func TestReceiverService_Delete(t *testing.T) {
// Ensure receiver saved to store is correct.
name, err := legacy_storage.UidToName(tc.deleteUID)
require.NoError(t, err)
q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: name}
_, err = sut.GetReceiver(context.Background(), q, writer)
_, err = sut.GetReceiver(context.Background(), legacy_storage.NameToUid(name), false, writer)
assert.ErrorIs(t, err, legacy_storage.ErrReceiverNotFound)
provenances, err := sut.provisioningStore.GetProvenances(context.Background(), tc.user.GetOrgID(), (&definitions.EmbeddedContactPoint{}).ResourceType())
@@ -626,8 +625,7 @@ func TestReceiverService_Create(t *testing.T) {
assert.Equal(t, tc.expectedCreate, *created)
// Ensure receiver saved to store is correct.
q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: tc.receiver.Name, Decrypt: true}
stored, err := sut.GetReceiver(context.Background(), q, decryptUser)
stored, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(tc.receiver.Name), true, decryptUser)
require.NoError(t, err)
decrypted := models.CopyReceiverWith(tc.expectedCreate, models.ReceiverMuts.Decrypted(models.Base64Decrypt))
decrypted.Version = tc.expectedCreate.Version // Version is calculated before decryption.
@@ -931,8 +929,7 @@ func TestReceiverService_Update(t *testing.T) {
assert.Equal(t, tc.expectedUpdate, *updated)
// Ensure receiver saved to store is correct.
q := models.GetReceiverQuery{OrgID: tc.user.GetOrgID(), Name: tc.receiver.Name, Decrypt: true}
stored, err := sut.GetReceiver(context.Background(), q, decryptUser)
stored, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(tc.receiver.Name), true, decryptUser)
require.NoError(t, err)
decrypted := models.CopyReceiverWith(tc.expectedUpdate, models.ReceiverMuts.Decrypted(models.Base64Decrypt))
decrypted.Version = tc.expectedUpdate.Version // Version is calculated before decryption.
@@ -1185,7 +1182,7 @@ func TestReceiverServiceAC_Read(t *testing.T) {
return false
}
for _, recv := range allReceivers() {
response, err := sut.GetReceiver(context.Background(), singleQ(orgId, recv.Name), usr)
response, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(recv.Name), false, usr)
if isVisible(recv.UID) {
require.NoErrorf(t, err, "receiver '%s' should be visible, but isn't", recv.Name)
assert.NotNil(t, response)
@@ -1207,7 +1204,7 @@ func TestReceiverServiceAC_Read(t *testing.T) {
}
sut.authz = ac.NewReceiverAccess[*models.Receiver](acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), true)
for _, recv := range allReceivers() {
response, err := sut.GetReceiver(context.Background(), singleQ(orgId, recv.Name), usr)
response, err := sut.GetReceiver(context.Background(), legacy_storage.NameToUid(recv.Name), false, usr)
if isVisibleInProvisioning(recv.UID) {
require.NoErrorf(t, err, "receiver '%s' should be visible, but isn't", recv.Name)
assert.NotNil(t, response)
@@ -1842,13 +1839,6 @@ func createEncryptedConfig(t *testing.T, secretService secretService, extraConfi
return string(bytes)
}
func singleQ(orgID int64, name string) models.GetReceiverQuery {
return models.GetReceiverQuery{
OrgID: orgID,
Name: name,
}
}
func multiQ(orgID int64, names ...string) models.GetReceiversQuery {
return models.GetReceiversQuery{
OrgID: orgID,
@@ -0,0 +1,112 @@
package notifier
import (
"context"
"fmt"
"slices"
alertingModels "github.com/grafana/alerting/models"
"github.com/prometheus/common/model"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
type AlertmanagerProvider interface {
AlertmanagerFor(orgID int64) (Alertmanager, error)
}
type ReceiverGetter interface {
GetReceiver(ctx context.Context, uid string, decrypt bool, user identity.Requester) (*models.Receiver, error)
}
func NewReceiverTestingSvc(receiverSvc *ReceiverService, amProvider AlertmanagerProvider, encryptionService secretService) *ReceiverTestingSvc {
return &ReceiverTestingSvc{
receiverSvc: receiverSvc,
amProvider: amProvider,
encryptionService: encryptionService,
}
}
type ReceiverTestingSvc struct {
receiverSvc ReceiverGetter
amProvider AlertmanagerProvider
encryptionService secretService
}
type Alert struct {
Labels map[string]string
Annotations map[string]string
}
type IntegrationTestResult alertingModels.IntegrationStatus
func (t *ReceiverTestingSvc) Test(ctx context.Context, user identity.Requester, alert Alert, receiverUID string, integration models.Integration, requiredSecrets []string) (IntegrationTestResult, error) {
alertParam, err := convertToAlertParam(alert)
if err != nil {
return IntegrationTestResult{}, err
}
decryptedPatchedIntegration, err := t.patchSecrets(ctx, user, receiverUID, integration, requiredSecrets)
if err != nil {
return IntegrationTestResult{}, err
}
err = decryptedPatchedIntegration.Validate(DecryptIntegrationSettings(ctx, t.encryptionService))
if err != nil {
return IntegrationTestResult{}, err
}
am, err := t.amProvider.AlertmanagerFor(user.GetOrgID())
if err != nil {
return IntegrationTestResult{}, err
}
result, err := am.TestIntegration(ctx, "test-receiver", decryptedPatchedIntegration, alertParam)
return IntegrationTestResult(result), err
}
func (t *ReceiverTestingSvc) patchSecrets(ctx context.Context, user identity.Requester, receiverUID string, integration models.Integration, secrets []string) (models.Integration, error) {
if len(secrets) == 0 {
return integration, nil
}
if integration.UID == "" || receiverUID == "" {
return integration, fmt.Errorf("cannot patch secrets for integration without receiver or integration UID")
}
rcv, err := t.receiverSvc.GetReceiver(ctx, receiverUID, false, user)
if err != nil {
return integration, err
}
if rcv == nil {
return integration, fmt.Errorf("cannot patch secrets for receiver that does not exist")
}
idx := slices.IndexFunc(rcv.Integrations, func(i *models.Integration) bool {
return i.UID == integration.UID
})
if idx < 0 {
return integration, fmt.Errorf("cannot patch secrets for integration that does not exist")
}
integration.WithExistingSecureFields(rcv.Integrations[idx], secrets)
err = integration.Decrypt(DecryptIntegrationSettings(ctx, t.encryptionService))
if err != nil {
return integration, err
}
return integration, nil
}
func convertToAlertParam(alert Alert) (alertingModels.TestReceiversConfigAlertParams, error) {
alertParam := alertingModels.TestReceiversConfigAlertParams{
Annotations: make(model.LabelSet, len(alert.Annotations)),
Labels: make(model.LabelSet, len(alert.Labels)),
}
for k, v := range alert.Annotations {
alertParam.Annotations[model.LabelName(k)] = model.LabelValue(v)
}
for k, v := range alert.Labels {
alertParam.Labels[model.LabelName(k)] = model.LabelValue(v)
}
if err := alertParam.Annotations.Validate(); err != nil {
return alertingModels.TestReceiversConfigAlertParams{}, fmt.Errorf("invalid annotations: %w", err)
}
if err := alertParam.Labels.Validate(); err != nil {
return alertingModels.TestReceiversConfigAlertParams{}, fmt.Errorf("invalid labels: %w", err)
}
return alertParam, nil
}
@@ -9,6 +9,7 @@ import (
v2 "github.com/prometheus/alertmanager/api/v2"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
)
func (am *alertmanager) TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigBodyParams) (*alertingNotify.TestReceiversResult, int, error) {
@@ -52,6 +53,14 @@ func (am *alertmanager) TestReceivers(ctx context.Context, c apimodels.TestRecei
})
}
func (am *alertmanager) TestIntegration(ctx context.Context, receiverName string, integrationConfig ngmodels.Integration, alert models.TestReceiversConfigAlertParams) (models.IntegrationStatus, error) {
cfg, err := IntegrationToIntegrationConfig(integrationConfig)
if err != nil {
return models.IntegrationStatus{}, err
}
return am.Base.TestIntegration(ctx, receiverName, cfg, alert)
}
func (am *alertmanager) GetReceivers(_ context.Context) ([]apimodels.Receiver, error) {
return am.Base.GetReceiversStatus(), nil
}
+51 -17
View File
@@ -30,7 +30,8 @@ import (
"github.com/prometheus/alertmanager/pkg/labels"
"github.com/prometheus/client_golang/prometheus"
common_config "github.com/prometheus/common/config"
"go.yaml.in/yaml/v3"
"github.com/prometheus/common/model"
"gopkg.in/yaml.v3"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/infra/tracing"
@@ -40,6 +41,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
remoteClient "github.com/grafana/grafana/pkg/services/ngalert/remote/client"
"github.com/grafana/grafana/pkg/services/ngalert/sender"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/util/cmputil"
)
@@ -57,6 +59,7 @@ func NoopAutogenFn(_ context.Context, _ log.Logger, _ int64, _ *apimodels.Postab
}
type Crypto interface {
Encrypt(ctx context.Context, payload []byte, opt secrets.EncryptionOptions) ([]byte, error)
Decrypt(ctx context.Context, payload []byte) ([]byte, error)
DecryptExtraConfigs(ctx context.Context, config *apimodels.PostableUserConfig) error
}
@@ -289,20 +292,6 @@ func (am *Alertmanager) isDefaultConfiguration(configHash string) bool {
return configHash == am.defaultConfigHash
}
func decrypter(ctx context.Context, crypto Crypto) models.DecryptFn {
return func(value string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(value)
if err != nil {
return "", err
}
decrypted, err := crypto.Decrypt(ctx, decoded)
if err != nil {
return "", err
}
return string(decrypted), nil
}
}
// buildConfiguration takes a raw Alertmanager configuration and returns a config that the remote Alertmanager can use.
// It parses the initial configuration, adds auto-generated routes, decrypts receivers, and merges the extra configs.
func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, createdAtEpoch int64, autogenInvalidReceiverAction notifier.InvalidReceiversAction) (remoteClient.UserGrafanaConfig, error) {
@@ -317,7 +306,7 @@ func (am *Alertmanager) buildConfiguration(ctx context.Context, raw []byte, crea
}
// Decrypt the receivers in the configuration.
decryptedReceivers, err := notifier.DecryptedReceivers(c.AlertmanagerConfig.Receivers, decrypter(ctx, am.crypto))
decryptedReceivers, err := notifier.DecryptedReceivers(c.AlertmanagerConfig.Receivers, notifier.DecryptIntegrationSettings(ctx, am.crypto))
if err != nil {
return remoteClient.UserGrafanaConfig{}, fmt.Errorf("unable to decrypt receivers: %w", err)
}
@@ -619,7 +608,7 @@ func (am *Alertmanager) GetReceivers(ctx context.Context) ([]apimodels.Receiver,
}
func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigBodyParams) (*alertingNotify.TestReceiversResult, int, error) {
decryptedReceivers, err := notifier.DecryptedReceivers(c.Receivers, decrypter(ctx, am.crypto))
decryptedReceivers, err := notifier.DecryptedReceivers(c.Receivers, notifier.DecryptIntegrationSettings(ctx, am.crypto))
if err != nil {
return nil, 0, fmt.Errorf("failed to decrypt receivers: %w", err)
}
@@ -636,6 +625,51 @@ func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestRecei
})
}
func (am *Alertmanager) TestIntegration(ctx context.Context, receiverName string, integrationConfig models.Integration, alert alertingModels.TestReceiversConfigAlertParams) (alertingModels.IntegrationStatus, error) {
decrypted := integrationConfig.Clone()
err := decrypted.Decrypt(notifier.DecryptIntegrationSettings(ctx, am.crypto))
if err != nil {
return alertingModels.IntegrationStatus{}, fmt.Errorf("failed to decrypt receivers: %w", err)
}
cfg, err := notifier.IntegrationToIntegrationConfig(decrypted)
if err != nil {
return alertingModels.IntegrationStatus{}, fmt.Errorf("failed to convert integration to integration config: %w", err)
}
apiReceivers := []*alertingNotify.APIReceiver{
{
ConfigReceiver: alertingNotify.ConfigReceiver{
Name: receiverName,
},
ReceiverConfig: alertingModels.ReceiverConfig{
Integrations: []*alertingModels.IntegrationConfig{
&cfg,
},
},
},
}
t := time.Now()
result, _, err := am.mimirClient.TestReceivers(ctx, alertingNotify.TestReceiversConfigBodyParams{
Alert: &alert,
Receivers: apiReceivers,
})
duration := time.Since(t)
if err != nil {
return alertingModels.IntegrationStatus{}, fmt.Errorf("failed to test integration: %w", err)
}
status := alertingModels.IntegrationStatus{
LastNotifyAttempt: strfmt.DateTime(result.NotifedAt),
LastNotifyAttemptDuration: model.Duration(duration).String(),
Name: cfg.Type,
SendResolved: false,
}
if len(result.Receivers) > 0 && len(result.Receivers[0].Configs) > 0 {
status.LastNotifyAttemptError = result.Receivers[0].Configs[0].Error
}
return status, nil
}
func (am *Alertmanager) TestTemplate(ctx context.Context, c apimodels.TestTemplatesConfigBodyParams) (*notifier.TestTemplatesResults, error) {
for _, alert := range c.Alerts {
notifier.AddDefaultLabelsAndAnnotations(alert)
@@ -43,7 +43,6 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/notifier"
"github.com/grafana/grafana/pkg/services/ngalert/remote/client"
ngfakes "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
"github.com/grafana/grafana/pkg/services/secrets"
"github.com/grafana/grafana/pkg/services/secrets/database"
"github.com/grafana/grafana/pkg/services/secrets/fakes"
secretsManager "github.com/grafana/grafana/pkg/services/secrets/manager"
@@ -298,13 +297,7 @@ func TestIntegrationApplyConfig(t *testing.T) {
var c apimodels.PostableUserConfig
require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &c))
secretsService := secretsManager.SetupTestService(t, database.ProvideSecretsStore(db.InitTestDB(t)))
encryptedReceivers, err := notifier.EncryptedReceivers(c.AlertmanagerConfig.Receivers, func(payload string) (string, error) {
encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope())
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(encrypted), nil
})
encryptedReceivers, err := notifier.EncryptedReceivers(c.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService))
c.AlertmanagerConfig.Receivers = encryptedReceivers
require.NoError(t, err)
@@ -462,13 +455,7 @@ func TestCompareAndSendConfiguration(t *testing.T) {
// Create a config with correctly encrypted and encoded secrets.
var inputCfg apimodels.PostableUserConfig
require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &inputCfg))
encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) {
encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope())
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(encrypted), nil
})
encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService))
inputCfg.AlertmanagerConfig.Receivers = encryptedReceivers
require.NoError(t, err)
testGrafanaConfigWithEncryptedSecret, err := json.Marshal(inputCfg)
@@ -663,13 +650,7 @@ func Test_TestReceiversDecryptsSecureSettings(t *testing.T) {
var inputCfg apimodels.PostableUserConfig
require.NoError(t, json.Unmarshal([]byte(testGrafanaConfigWithSecret), &inputCfg))
encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) {
encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope())
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(encrypted), nil
})
encryptedReceivers, err := notifier.EncryptedReceivers(inputCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService))
inputCfg.AlertmanagerConfig.Receivers = encryptedReceivers
require.NoError(t, err)
@@ -1037,13 +1018,7 @@ func TestIntegrationRemoteAlertmanagerConfiguration(t *testing.T) {
{
postableCfg, err := notifier.Load([]byte(testGrafanaConfigWithSecret))
require.NoError(t, err)
encryptedReceivers, err := notifier.EncryptedReceivers(postableCfg.AlertmanagerConfig.Receivers, func(payload string) (string, error) {
encrypted, err := secretsService.Encrypt(context.Background(), []byte(payload), secrets.WithoutScope())
if err != nil {
return "", err
}
return base64.StdEncoding.EncodeToString(encrypted), nil
})
encryptedReceivers, err := notifier.EncryptedReceivers(postableCfg.AlertmanagerConfig.Receivers, notifier.EncryptIntegrationSettings(context.Background(), secretsService))
postableCfg.AlertmanagerConfig.Receivers = encryptedReceivers
require.NoError(t, err)
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
alertingModels "github.com/grafana/alerting/models"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/infra/kvstore"
@@ -173,6 +174,10 @@ func (fam *RemotePrimaryForkedAlertmanager) TestReceivers(ctx context.Context, c
return fam.remote.TestReceivers(ctx, c)
}
func (fam *RemotePrimaryForkedAlertmanager) TestIntegration(ctx context.Context, receiverName string, integrationConfig models.Integration, alert alertingModels.TestReceiversConfigAlertParams) (alertingModels.IntegrationStatus, error) {
return fam.remote.TestIntegration(ctx, receiverName, integrationConfig, alert)
}
func (fam *RemotePrimaryForkedAlertmanager) TestTemplate(ctx context.Context, c apimodels.TestTemplatesConfigBodyParams) (*notifier.TestTemplatesResults, error) {
return fam.remote.TestTemplate(ctx, c)
}
@@ -6,6 +6,7 @@ import (
"sync"
"time"
alertingModels "github.com/grafana/alerting/models"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/infra/kvstore"
@@ -234,6 +235,10 @@ func (fam *RemoteSecondaryForkedAlertmanager) TestReceivers(ctx context.Context,
return fam.internal.TestReceivers(ctx, c)
}
func (fam *RemoteSecondaryForkedAlertmanager) TestIntegration(ctx context.Context, receiverName string, integrationConfig models.Integration, alert alertingModels.TestReceiversConfigAlertParams) (alertingModels.IntegrationStatus, error) {
return fam.internal.TestIntegration(ctx, receiverName, integrationConfig, alert)
}
func (fam *RemoteSecondaryForkedAlertmanager) TestTemplate(ctx context.Context, c apimodels.TestTemplatesConfigBodyParams) (*notifier.TestTemplatesResults, error) {
return fam.internal.TestTemplate(ctx, c)
}
@@ -358,8 +358,6 @@ func (fr *FileReader) saveDashboard(ctx context.Context, path string, folderID i
Name: fr.Cfg.Name,
Updated: resolvedFileInfo.ModTime().Unix(),
CheckSum: jsonFile.checkSum,
// adds `grafana.app/managerAllowsEdits` to the provisioned dashboards in unified storage. not used if in legacy.
AllowUIUpdates: fr.Cfg.AllowUIUpdates,
}
_, err := fr.dashboardProvisioningService.SaveProvisionedDashboard(ctx, dash, dp)
if err != nil {
@@ -33,8 +33,6 @@ import (
)
func TestIntegrationFolderTreeZanzana(t *testing.T) {
// TODO: Add back OSS seeding and enable this test
t.Skip("Skipping folder tree test with Zanzana")
testutil.SkipIntegrationTestInShortMode(t)
runIntegrationFolderTree(t, testinfra.GrafanaOpts{
+1 -66
View File
@@ -1,15 +1,7 @@
import { GrafanaConfig, locationUtil } from '@grafana/data';
import * as folderHooks from 'app/api/clients/folder/v1beta1/hooks';
import { backendSrv } from 'app/core/services/backend_srv';
import {
AnnoKeyFolder,
AnnoKeyManagerAllowsEdits,
AnnoKeyManagerKind,
AnnoKeyMessage,
AnnoKeySourcePath,
AnnoReloadOnParamsChange,
ManagerKind,
} from 'app/features/apiserver/types';
import { AnnoKeyFolder, AnnoKeyMessage, AnnoReloadOnParamsChange } from 'app/features/apiserver/types';
import { DashboardDataDTO } from 'app/types/dashboard';
import { DashboardWithAccessInfo } from './types';
@@ -223,63 +215,6 @@ describe('v1 dashboard API', () => {
expect(result.meta.reloadOnParamsChange).toBe(true);
});
describe('managed/provisioned dashboards', () => {
it('should not mark dashboard as provisioned when manager allows UI edits', async () => {
mockGet.mockResolvedValueOnce({
...mockDashboardDto,
metadata: {
...mockDashboardDto.metadata,
annotations: {
[AnnoKeyManagerKind]: ManagerKind.Terraform,
[AnnoKeyManagerAllowsEdits]: 'true',
[AnnoKeySourcePath]: 'dashboards/test.json',
},
},
});
const api = new K8sDashboardAPI();
const result = await api.getDashboardDTO('test');
expect(result.meta.provisioned).toBe(false);
expect(result.meta.provisionedExternalId).toBe('dashboards/test.json');
});
it('should mark dashboard as provisioned when manager does not allow UI edits', async () => {
mockGet.mockResolvedValueOnce({
...mockDashboardDto,
metadata: {
...mockDashboardDto.metadata,
annotations: {
[AnnoKeyManagerKind]: ManagerKind.Terraform,
[AnnoKeySourcePath]: 'dashboards/test.json',
},
},
});
const api = new K8sDashboardAPI();
const result = await api.getDashboardDTO('test');
expect(result.meta.provisioned).toBe(true);
expect(result.meta.provisionedExternalId).toBe('dashboards/test.json');
});
it('should not mark repository-managed dashboard as provisioned (locked)', async () => {
mockGet.mockResolvedValueOnce({
...mockDashboardDto,
metadata: {
...mockDashboardDto.metadata,
annotations: {
[AnnoKeyManagerKind]: ManagerKind.Repo,
[AnnoKeySourcePath]: 'dashboards/test.json',
},
},
});
const api = new K8sDashboardAPI();
const result = await api.getDashboardDTO('test');
expect(result.meta.provisioned).toBe(false);
expect(result.meta.provisionedExternalId).toBe('dashboards/test.json');
});
});
describe('saveDashboard', () => {
beforeEach(() => {
locationUtil.initialize({
+1 -5
View File
@@ -164,11 +164,7 @@ export class K8sDashboardAPI implements DashboardAPI<DashboardDTO, Dashboard> {
const managerKind = annotations[AnnoKeyManagerKind];
if (managerKind) {
// `meta.provisioned` is used by the save/delete UI to decide if a dashboard is locked
// (i.e. it can't be saved from the UI). This should match the legacy behavior where
// `allowUiUpdates: true` keeps the dashboard editable/savable.
const allowsEdits = annotations[AnnoKeyManagerAllowsEdits] === 'true';
result.meta.provisioned = !allowsEdits && managerKind !== ManagerKind.Repo;
result.meta.provisioned = annotations[AnnoKeyManagerAllowsEdits] === 'true' || managerKind === ManagerKind.Repo;
result.meta.provisionedExternalId = annotations[AnnoKeySourcePath];
}