diff --git a/.betterer.results b/.betterer.results
index a6c0daa79b8..b58d8b5712b 100644
--- a/.betterer.results
+++ b/.betterer.results
@@ -1655,8 +1655,7 @@ exports[`better eslint`] = {
"public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with ", "0"],
[0, 0, 0, "No untranslated strings. Wrap text with ", "1"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "2"],
- [0, 0, 0, "No untranslated strings. Wrap text with ", "3"]
+ [0, 0, 0, "No untranslated strings. Wrap text with ", "2"]
],
"public/app/features/alerting/unified/components/contact-points/components/ContactPointsFilter.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with ", "0"]
diff --git a/pkg/registry/apis/alerting/notifications/receiver/authorize.go b/pkg/registry/apis/alerting/notifications/receiver/authorize.go
index baf4bffd371..ce357351364 100644
--- a/pkg/registry/apis/alerting/notifications/receiver/authorize.go
+++ b/pkg/registry/apis/alerting/notifications/receiver/authorize.go
@@ -53,9 +53,7 @@ func Authorize(ctx context.Context, ac AccessControlService, attr authorizer.Att
return deny(err)
}
case "list":
- if err := ac.AuthorizeReadSome(ctx, user); err != nil { // Preconditions, further checks are done downstream.
- return deny(err)
- }
+ return authorizer.DecisionAllow, "", nil // Always allow listing, receivers are filtered downstream.
case "create":
if err := ac.AuthorizeCreate(ctx, user); err != nil {
return deny(err)
diff --git a/pkg/registry/apis/alerting/notifications/receiver/legacy_storage.go b/pkg/registry/apis/alerting/notifications/receiver/legacy_storage.go
index 6f05581820c..79a97b640c9 100644
--- a/pkg/registry/apis/alerting/notifications/receiver/legacy_storage.go
+++ b/pkg/registry/apis/alerting/notifications/receiver/legacy_storage.go
@@ -2,9 +2,10 @@ package receiver
import (
"context"
+ "errors"
"fmt"
- "k8s.io/apimachinery/pkg/api/errors"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@@ -14,6 +15,7 @@ import (
notifications "github.com/grafana/grafana/pkg/apis/alerting_notifications/v0alpha1"
grafanaRest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
+ alertingac "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/notifier/legacy_storage"
@@ -88,7 +90,14 @@ func (s *legacyStorage) List(ctx context.Context, opts *internalversion.ListOpti
res, err := s.service.GetReceivers(ctx, q, user)
if err != nil {
- return nil, err
+ // This API should not be returning a forbidden error when the user does not have access to any resources.
+ // This can be true for a contact point creator role, for example.
+ // This should eventually be changed downstream in the auth logic but provisioning API currently relies on this
+ // behaviour to return useful forbidden errors when exporting decrypted receivers.
+ if !errors.Is(err, alertingac.ErrAuthorizationBase) {
+ return nil, err
+ }
+ res = nil
}
accesses, err := s.metadata.AccessControlMetadata(ctx, user, res...)
@@ -112,7 +121,7 @@ func (s *legacyStorage) Get(ctx context.Context, uid string, _ *metav1.GetOption
name, err := legacy_storage.UidToName(uid)
if err != nil {
- return nil, errors.NewNotFound(resourceInfo.GroupResource(), uid)
+ return nil, apierrors.NewNotFound(resourceInfo.GroupResource(), uid)
}
q := ngmodels.GetReceiverQuery{
OrgID: info.OrgID,
@@ -172,7 +181,7 @@ func (s *legacyStorage) Create(ctx context.Context,
return nil, fmt.Errorf("expected receiver but got %s", obj.GetObjectKind().GroupVersionKind())
}
if p.ObjectMeta.Name != "" { // TODO remove when metadata.name can be defined by user
- return nil, errors.NewBadRequest("object's metadata.name should be empty")
+ return nil, apierrors.NewBadRequest("object's metadata.name should be empty")
}
model, _, err := convertToDomainModel(p)
if err != nil {
@@ -271,5 +280,5 @@ func (s *legacyStorage) Delete(ctx context.Context, uid string, deleteValidation
}
func (s *legacyStorage) DeleteCollection(ctx context.Context, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions, listOptions *internalversion.ListOptions) (runtime.Object, error) {
- return nil, errors.NewMethodNotSupported(resourceInfo.GroupResource(), "deleteCollection")
+ return nil, apierrors.NewMethodNotSupported(resourceInfo.GroupResource(), "deleteCollection")
}
diff --git a/pkg/services/accesscontrol/ossaccesscontrol/receivers.go b/pkg/services/accesscontrol/ossaccesscontrol/receivers.go
index ac5f5f6d33c..a818d8b3e84 100644
--- a/pkg/services/accesscontrol/ossaccesscontrol/receivers.go
+++ b/pkg/services/accesscontrol/ossaccesscontrol/receivers.go
@@ -133,7 +133,14 @@ func (r ReceiverPermissionsService) CopyPermissions(ctx context.Context, orgID i
// Clear permission cache for the user who updated the receiver, so that new permissions are fetched for their next call
// Required for cases when caller wants to immediately interact with the newly updated object
if user != nil && user.IsIdentityType(claims.TypeUser) {
- r.ac.ClearUserPermissionCache(user)
+ // A more comprehensive means of clearing the user's permissions cache than ClearUserPermissionCache.
+ // It also clears the cache for basic roles and teams, which is required for the user to not have temporarily
+ // broken UI permissions when their source of elevated permissions comes from a cached team or basic role
+ // permission.
+ _, err = r.ac.GetUserPermissions(ctx, user, accesscontrol.Options{ReloadCache: true})
+ if err != nil {
+ r.log.Debug("Failed to clear user permissions cache", "error", err)
+ }
}
return countCustomPermissions(setPermissionCommands), nil
diff --git a/pkg/services/navtree/navtreeimpl/navtree.go b/pkg/services/navtree/navtreeimpl/navtree.go
index d33e93f763b..0fe4e27e31c 100644
--- a/pkg/services/navtree/navtreeimpl/navtree.go
+++ b/pkg/services/navtree/navtreeimpl/navtree.go
@@ -417,11 +417,32 @@ func (s *ServiceImpl) buildAlertNavLinks(c *contextmodel.ReqContext) *navtree.Na
})
}
- if hasAccess(ac.EvalAny(ac.EvalPermission(ac.ActionAlertingNotificationsRead), ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead))) {
+ contactPointsPerms := []ac.Evaluator{
+ ac.EvalPermission(ac.ActionAlertingNotificationsRead),
+ ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead),
+ }
+
+ // With the new alerting API, we have other permissions to consider. We don't want to consider these with the old
+ // alerting API to maintain backwards compatibility.
+ if s.features.IsEnabled(c.Req.Context(), featuremgmt.FlagAlertingApiServer) {
+ contactPointsPerms = append(contactPointsPerms,
+ ac.EvalPermission(ac.ActionAlertingReceiversRead),
+ ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets),
+ ac.EvalPermission(ac.ActionAlertingReceiversCreate),
+ )
+ }
+
+ if hasAccess(ac.EvalAny(contactPointsPerms...)) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{
Text: "Contact points", SubTitle: "Choose how to notify your contact points when an alert instance fires", Id: "receivers", Url: s.cfg.AppSubURL + "/alerting/notifications",
Icon: "comment-alt-share",
})
+ }
+
+ if hasAccess(ac.EvalAny(
+ ac.EvalPermission(ac.ActionAlertingNotificationsRead),
+ ac.EvalPermission(ac.ActionAlertingNotificationsExternalRead),
+ )) {
alertChildNavs = append(alertChildNavs, &navtree.NavLink{Text: "Notification policies", SubTitle: "Determine how alerts are routed to contact points", Id: "am-routes", Url: s.cfg.AppSubURL + "/alerting/routes", Icon: "sitemap"})
}
diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go
index 08b48544c65..2fad5000f69 100644
--- a/pkg/services/ngalert/api/authorization.go
+++ b/pkg/services/ngalert/api/authorization.go
@@ -8,6 +8,7 @@ import (
ac "github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/datasources"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/web"
)
@@ -242,9 +243,8 @@ func (api *API) authorize(method, path string) web.Handler {
http.MethodGet + "/api/v1/ngalert/alertmanagers":
return middleware.ReqOrgAdmin
- // Grafana-only Provisioning Read Paths
+ // Grafana-only Provisioning Export Paths for everything except contact points.
case http.MethodGet + "/api/v1/provisioning/policies/export",
- http.MethodGet + "/api/v1/provisioning/contact-points/export",
http.MethodGet + "/api/v1/provisioning/mute-timings/export",
http.MethodGet + "/api/v1/provisioning/mute-timings/{name}/export":
eval = ac.EvalAny(
@@ -254,6 +254,22 @@ func (api *API) authorize(method, path string) web.Handler {
ac.EvalPermission(ac.ActionAlertingProvisioningReadSecrets), // organization scope
)
+ // Grafana-only Provisioning Export Paths for contact points.
+ case http.MethodGet + "/api/v1/provisioning/contact-points/export":
+ perms := []ac.Evaluator{
+ ac.EvalPermission(ac.ActionAlertingNotificationsRead), // organization scope
+ ac.EvalPermission(ac.ActionAlertingProvisioningRead), // organization scope
+ ac.EvalPermission(ac.ActionAlertingNotificationsProvisioningRead), // organization scope
+ ac.EvalPermission(ac.ActionAlertingProvisioningReadSecrets), // organization scope
+ }
+ if api.FeatureManager.IsEnabledGlobally(featuremgmt.FlagAlertingApiServer) {
+ perms = append(perms,
+ ac.EvalPermission(ac.ActionAlertingReceiversRead),
+ ac.EvalPermission(ac.ActionAlertingReceiversReadSecrets),
+ )
+ }
+ eval = ac.EvalAny(perms...)
+
case http.MethodGet + "/api/v1/provisioning/alert-rules",
http.MethodGet + "/api/v1/provisioning/alert-rules/export":
eval = ac.EvalAny(
diff --git a/pkg/services/ngalert/api/authorization_test.go b/pkg/services/ngalert/api/authorization_test.go
index b16305cfd78..76eaae47437 100644
--- a/pkg/services/ngalert/api/authorization_test.go
+++ b/pkg/services/ngalert/api/authorization_test.go
@@ -10,6 +10,7 @@ import (
"github.com/stretchr/testify/require"
acmock "github.com/grafana/grafana/pkg/services/accesscontrol/mock"
+ "github.com/grafana/grafana/pkg/services/featuremgmt"
)
func TestAuthorize(t *testing.T) {
@@ -43,7 +44,7 @@ func TestAuthorize(t *testing.T) {
require.Len(t, paths, 59)
ac := acmock.New()
- api := &API{AccessControl: ac}
+ api := &API{AccessControl: ac, FeatureManager: featuremgmt.WithFeatures()}
t.Run("should not panic on known routes", func(t *testing.T) {
for path, methods := range paths {
diff --git a/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go b/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go
index 3e65c662567..c90bb4d9382 100644
--- a/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go
+++ b/pkg/tests/apis/alerting/notifications/receivers/receiver_test.go
@@ -396,9 +396,10 @@ func TestIntegrationResourcePermissions(t *testing.T) {
assert.Equalf(t, expectedGetWithMetadata, got, "Expected %v but got %v", expectedGetWithMetadata, got)
})
} else {
- t.Run("should be forbidden to list receivers", func(t *testing.T) {
- _, err := client.List(ctx, v1.ListOptions{})
- require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err)
+ t.Run("list receivers should be empty", func(t *testing.T) {
+ list, err := client.List(ctx, v1.ListOptions{})
+ require.NoError(t, err)
+ require.Emptyf(t, list.Items, "Expected no receivers but got %v", list.Items)
})
t.Run("should be forbidden to read receiver by name", func(t *testing.T) {
@@ -640,9 +641,10 @@ func TestIntegrationAccessControl(t *testing.T) {
})
})
} else {
- t.Run("should be forbidden to list receivers", func(t *testing.T) {
- _, err := client.List(ctx, v1.ListOptions{})
- require.Truef(t, errors.IsForbidden(err), "should get Forbidden error but got %s", err)
+ t.Run("list receivers should be empty", func(t *testing.T) {
+ list, err := client.List(ctx, v1.ListOptions{})
+ require.NoError(t, err)
+ require.Emptyf(t, list.Items, "Expected no receivers but got %v", list.Items)
})
t.Run("should be forbidden to read receiver by name", func(t *testing.T) {
diff --git a/public/app/core/components/AccessControl/types.ts b/public/app/core/components/AccessControl/types.ts
index 80ceca019bc..f4e4363de5f 100644
--- a/public/app/core/components/AccessControl/types.ts
+++ b/public/app/core/components/AccessControl/types.ts
@@ -1,6 +1,8 @@
+import { AccessControlAction } from 'app/types';
+
export type ResourcePermission = {
id: number;
- resourceId: string;
+ resourceId?: string;
isManaged: boolean;
isInherited: boolean;
isServiceAccount: boolean;
@@ -11,8 +13,9 @@ export type ResourcePermission = {
teamId?: number;
teamAvatarUrl?: string;
builtInRole?: string;
- actions: string[];
+ actions: AccessControlAction[];
permission: string;
+ roleName?: string;
warning?: string;
};
diff --git a/public/app/features/alerting/routes.tsx b/public/app/features/alerting/routes.tsx
index 82a46aeacba..04c8fd63e44 100644
--- a/public/app/features/alerting/routes.tsx
+++ b/public/app/features/alerting/routes.tsx
@@ -3,6 +3,10 @@ import { config } from 'app/core/config';
import { GrafanaRouteComponent, RouteDescriptor } from 'app/core/navigation/types';
import { AccessControlAction } from 'app/types';
+import {
+ PERMISSIONS_CONTACT_POINTS,
+ PERMISSIONS_CONTACT_POINTS_MODIFY,
+} from './unified/components/contact-points/permissions';
import { evaluateAccess } from './unified/utils/access-control';
export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
@@ -89,6 +93,7 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
roles: evaluateAccess([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
+ ...PERMISSIONS_CONTACT_POINTS,
]),
component: importAlertingComponent(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
@@ -109,6 +114,7 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
roles: evaluateAccess([
AccessControlAction.AlertingNotificationsWrite,
AccessControlAction.AlertingNotificationsExternalWrite,
+ ...PERMISSIONS_CONTACT_POINTS_MODIFY,
]),
component: importAlertingComponent(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
@@ -121,6 +127,10 @@ export function getAlertingRoutes(cfg = config): RouteDescriptor[] {
AccessControlAction.AlertingNotificationsExternalWrite,
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsExternalRead,
+ // We check any contact point permission here because a user without edit permissions
+ // still has to be able to visit the "edit" page, because we don't have a separate view for edit vs view
+ // (we just disable the form instead)
+ ...PERMISSIONS_CONTACT_POINTS,
]),
component: importAlertingComponent(
() => import(/* webpackChunkName: "NotificationsListPage" */ 'app/features/alerting/unified/Receivers')
diff --git a/public/app/features/alerting/unified/api/alertingApi.ts b/public/app/features/alerting/unified/api/alertingApi.ts
index e80a87fa000..628aa0caf9d 100644
--- a/public/app/features/alerting/unified/api/alertingApi.ts
+++ b/public/app/features/alerting/unified/api/alertingApi.ts
@@ -45,17 +45,27 @@ export type BaseQueryFnArgs = WithNotificationOptions<
Omit
>;
+export type AlertingApiExtraOptions = {
+ /**
+ * Suppress the error message display on an endpoint entirely.
+ * Useful for autogenerated API endpoints where we want to easily suppress error messages
+ * without having to overwrite endpoint logic/definitions
+ */
+ hideErrorMessage?: boolean;
+};
+
export const backendSrvBaseQuery =
(): BaseQueryFn =>
- async ({ body, notificationOptions = {}, ...requestOptions }) => {
+ async ({ body, notificationOptions = {}, ...requestOptions }, api, extraOptions?: AlertingApiExtraOptions) => {
const { errorMessage, showErrorAlert, successMessage, showSuccessAlert } = notificationOptions;
+ const { hideErrorMessage } = extraOptions || {};
try {
const modifiedRequestOptions: BackendSrvRequest = {
...requestOptions,
...(body && { data: body }),
...(successMessage && { showSuccessAlert: false }),
- ...(errorMessage && { showErrorAlert: false }),
+ ...((errorMessage || hideErrorMessage) && { showErrorAlert: false }),
};
const requestStartTs = performance.now();
diff --git a/public/app/features/alerting/unified/api/receiversK8sApi.ts b/public/app/features/alerting/unified/api/receiversK8sApi.ts
index 568077eb09f..3a6d9f7d2d1 100644
--- a/public/app/features/alerting/unified/api/receiversK8sApi.ts
+++ b/public/app/features/alerting/unified/api/receiversK8sApi.ts
@@ -1,3 +1,4 @@
+import { AlertingApiExtraOptions } from 'app/features/alerting/unified/api/alertingApi';
import { generatedReceiversApi } from 'app/features/alerting/unified/openapi/receiversApi.gen';
export const receiversApi = generatedReceiversApi.enhanceEndpoints({
@@ -18,5 +19,9 @@ export const receiversApi = generatedReceiversApi.enhanceEndpoints({
return baseQuery;
};
},
+ readNamespacedReceiver: (endpoint) => {
+ const extraOptions: AlertingApiExtraOptions = { hideErrorMessage: true };
+ endpoint.extraOptions = extraOptions;
+ },
},
});
diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx
index 7bb4f9c0908..4aa61a60ba2 100644
--- a/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx
+++ b/public/app/features/alerting/unified/components/contact-points/ContactPointHeader.tsx
@@ -1,12 +1,20 @@
import { css } from '@emotion/css';
-import { Fragment } from 'react';
+import { Fragment, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Dropdown, LinkButton, Menu, Stack, Text, TextLink, Tooltip, useStyles2 } from '@grafana/ui';
-import { t } from 'app/core/internationalization';
+import { t, Trans } from 'app/core/internationalization';
import ConditionalWrap from 'app/features/alerting/unified/components/ConditionalWrap';
import { useExportContactPoint } from 'app/features/alerting/unified/components/contact-points/useExportContactPoint';
-import { PROVENANCE_ANNOTATION } from 'app/features/alerting/unified/utils/k8s/constants';
+import { ManagePermissionsDrawer } from 'app/features/alerting/unified/components/permissions/ManagePermissions';
+import { useAlertmanager } from 'app/features/alerting/unified/state/AlertmanagerContext';
+import { K8sAnnotations } from 'app/features/alerting/unified/utils/k8s/constants';
+import {
+ canDeleteEntity,
+ canEditEntity,
+ getAnnotation,
+ shouldUseK8sApi,
+} from 'app/features/alerting/unified/utils/k8s/utils';
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { createRelativeUrl } from '../../utils/url';
@@ -15,7 +23,7 @@ import { ProvisioningBadge } from '../Provisioning';
import { Spacer } from '../Spacer';
import { UnusedContactPointBadge } from './components/UnusedBadge';
-import { ContactPointWithMetadata } from './utils';
+import { ContactPointWithMetadata, showManageContactPointPermissions } from './utils';
interface ContactPointHeaderProps {
contactPoint: ContactPointWithMetadata;
@@ -26,21 +34,63 @@ interface ContactPointHeaderProps {
export const ContactPointHeader = ({ contactPoint, disabled = false, onDelete }: ContactPointHeaderProps) => {
const { name, id, provisioned, policies = [] } = contactPoint;
const styles = useStyles2(getStyles);
+ const [showPermissionsDrawer, setShowPermissionsDrawer] = useState(false);
+ const { selectedAlertmanager } = useAlertmanager();
+
+ const usingK8sApi = shouldUseK8sApi(selectedAlertmanager!);
const [exportSupported, exportAllowed] = useAlertmanagerAbility(AlertmanagerAction.ExportContactPoint);
const [editSupported, editAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateContactPoint);
const [deleteSupported, deleteAllowed] = useAlertmanagerAbility(AlertmanagerAction.UpdateContactPoint);
-
const [ExportDrawer, openExportDrawer] = useExportContactPoint();
- const numberOfPolicies = policies.length;
- const isReferencedByAnyPolicy = numberOfPolicies > 0;
- const isReferencedByRegularPolicies = policies.some((ref) => ref.route.type !== 'auto-generated');
+ const showManagePermissions = showManageContactPointPermissions(selectedAlertmanager!, contactPoint);
+
+ const regularPolicyReferences = policies.filter((ref) => ref.route.type !== 'auto-generated');
+
+ const k8sRoutesInUse = getAnnotation(contactPoint, K8sAnnotations.InUseRoutes);
+ /**
+ * Number of policies that reference this contact point
+ *
+ * When the k8s API is being used, this number will only be the regular policies
+ * (will not include the auto generated simplified routing policies in the count)
+ */
+ const numberOfPolicies = usingK8sApi ? Number(k8sRoutesInUse) : policies.length;
+
+ const numberOfPoliciesPreventingDeletion = usingK8sApi ? Number(k8sRoutesInUse) : regularPolicyReferences.length;
+
+ /** Number of rules that use this contact point for simplified routing */
+ const numberOfRules = Number(getAnnotation(contactPoint, K8sAnnotations.InUseRules)) || 0;
+
+ /**
+ * Is the contact point referenced by anything such as notification policies or as a simplified routing contact point?
+ *
+ * Used to determine whether to show the "Unused" badge
+ */
+ const isReferencedByAnything = usingK8sApi ? Boolean(numberOfPolicies || numberOfRules) : policies.length > 0;
+
+ /** Does the current user have permissions to edit the contact point? */
+ const hasAbilityToEdit = canEditEntity(contactPoint) || editAllowed;
+ /** Can the contact point actually be edited via the UI? */
+ const contactPointIsEditable = !provisioned;
+ /** Given the alertmanager, the user's permissions, and the state of the contact point - can it actually be edited? */
+ const canEdit = editSupported && hasAbilityToEdit && contactPointIsEditable;
- const canEdit = editSupported && editAllowed && !provisioned;
- const canDelete = deleteSupported && deleteAllowed && !provisioned && !isReferencedByRegularPolicies;
+ /** Does the current user have permissions to delete the contact point? */
+ const hasAbilityToDelete = canDeleteEntity(contactPoint) || deleteAllowed;
+ /** Can the contact point actually be deleted, regardless of permissions? i.e. ensuring it isn't provisioned and isn't referenced elsewhere */
+ const contactPointIsDeleteable = !provisioned && !numberOfPoliciesPreventingDeletion && !numberOfRules;
+ /** Given the alertmanager, the user's permissions, and the state of the contact point - can it actually be deleted? */
+ const canBeDeleted = deleteSupported && hasAbilityToDelete && contactPointIsDeleteable;
const menuActions: JSX.Element[] = [];
+ if (showManagePermissions) {
+ menuActions.push(
+
+ setShowPermissionsDrawer(true)} />
+
+ );
+ }
if (exportSupported) {
menuActions.push(
@@ -59,12 +109,48 @@ export const ContactPointHeader = ({ contactPoint, disabled = false, onDelete }:
}
if (deleteSupported) {
+ const cannotDeleteNoPermissions = t(
+ 'alerting.contact-points.delete-reasons.no-permissions',
+ 'You do not have the required permission to delete this contact point'
+ );
+ const cannotDeleteProvisioned = t(
+ 'alerting.contact-points.delete-reasons.provisioned',
+ 'Contact point is provisioned and cannot be deleted via the UI'
+ );
+ const cannotDeletePolicies = t(
+ 'alerting.contact-points.delete-reasons.policies',
+ 'Contact point is referenced by one or more notification policies'
+ );
+ const cannotDeleteRules = t(
+ 'alerting.contact-points.delete-reasons.rules',
+ 'Contact point is referenced by one or more alert rules'
+ );
+
+ const reasonsDeleteIsDisabled = [
+ !hasAbilityToDelete ? cannotDeleteNoPermissions : '',
+ provisioned ? cannotDeleteProvisioned : '',
+ numberOfPoliciesPreventingDeletion > 0 ? cannotDeletePolicies : '',
+ numberOfRules ? cannotDeleteRules : '',
+ ].filter(Boolean);
+
+ const deleteTooltipContent = (
+ <>
+
+ Contact point cannot be deleted for the following reasons:
+
+
+ {reasonsDeleteIsDisabled.map((reason) => (
+ {reason}
+ ))}
+ >
+ );
+
menuActions.push(
(
-
+
{children}
)}
@@ -74,7 +160,7 @@ export const ContactPointHeader = ({ contactPoint, disabled = false, onDelete }:
ariaLabel="delete"
icon="trash-alt"
destructive
- disabled={disabled || !canDelete}
+ disabled={disabled || !canBeDeleted}
onClick={() => onDelete(contactPoint)}
/>
@@ -85,6 +171,10 @@ export const ContactPointHeader = ({ contactPoint, disabled = false, onDelete }:
count: numberOfPolicies,
});
+ const referencedByRulesText = t('alerting.contact-points.used-by-rules', 'Used by {{ count }} alert rule', {
+ count: numberOfRules,
+ });
+
// TOOD: Tidy up/consolidate logic for working out id for contact point. This requires some unravelling of
// existing types so its clearer where the ID has come from
const urlId = id || name;
@@ -97,7 +187,7 @@ export const ContactPointHeader = ({ contactPoint, disabled = false, onDelete }:
{name}
- {isReferencedByAnyPolicy && (
+ {numberOfPolicies > 0 && (
)}
+ {numberOfRules > 0 && (
+
+ {referencedByRulesText}
+
+ )}
{provisioned && (
-
+
)}
- {!isReferencedByAnyPolicy && }
+ {!isReferencedByAnything && }
{ExportDrawer}
+ {showPermissionsDrawer && (
+ setShowPermissionsDrawer(false)}
+ />
+ )}
);
};
diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx
index e1599527caa..ddfb8a8cf18 100644
--- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx
+++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.test.tsx
@@ -1,9 +1,14 @@
import { MemoryHistoryBuildOptions } from 'history';
import { ComponentProps, ReactNode } from 'react';
-import { render, screen, userEvent, waitFor, waitForElementToBeRemoved } from 'test/test-utils';
+import { render, screen, userEvent, waitFor, waitForElementToBeRemoved, within } from 'test/test-utils';
import { selectors } from '@grafana/e2e-selectors';
-import { config } from '@grafana/runtime';
+import {
+ testWithFeatureToggles,
+ testWithLicenseFeatures,
+ flushMicrotasks,
+} from 'app/features/alerting/unified/test/test-utils';
+import { K8sAnnotations } from 'app/features/alerting/unified/utils/k8s/constants';
import { AlertManagerDataSourceJsonData, AlertManagerImplementation } from 'app/plugins/datasource/alertmanager/types';
import { AccessControlAction } from 'app/types';
@@ -58,10 +63,30 @@ const basicContactPoint: ContactPointWithMetadata = {
grafana_managed_receiver_configs: [],
};
-const attemptDeleteContactPoint = async (name: string) => {
+const contactPointWithEverything: ContactPointWithMetadata = {
+ ...basicContactPoint,
+ metadata: {
+ annotations: {
+ [K8sAnnotations.InUseRules]: '3',
+ [K8sAnnotations.InUseRoutes]: '1',
+ [K8sAnnotations.AccessAdmin]: 'true',
+ [K8sAnnotations.AccessDelete]: 'true',
+ [K8sAnnotations.AccessWrite]: 'true',
+ },
+ },
+};
+
+const clickMoreActionsButton = async (name: string) => {
const user = userEvent.setup();
const moreActions = await screen.findByRole('button', { name: `More actions for contact point "${name}"` });
await user.click(moreActions);
+ await flushMicrotasks();
+};
+
+const attemptDeleteContactPoint = async (name: string) => {
+ const user = userEvent.setup();
+
+ await clickMoreActionsButton(name);
const deleteButton = screen.getByRole('menuitem', { name: /delete/i });
await user.click(deleteButton);
@@ -83,7 +108,7 @@ describe('contact points', () => {
test('loads contact points tab', async () => {
renderWithProvider(, { initialEntries: ['/?tab=contact_points'] });
- expect(await screen.findByText(/add contact point/i)).toBeInTheDocument();
+ expect(await screen.findByText(/create contact point/i)).toBeInTheDocument();
});
test('loads templates tab', async () => {
@@ -95,13 +120,13 @@ describe('contact points', () => {
test('defaults to contact points tab with invalid query param', async () => {
renderWithProvider(, { initialEntries: ['/?tab=foo_bar'] });
- expect(await screen.findByText(/add contact point/i)).toBeInTheDocument();
+ expect(await screen.findByText(/create contact point/i)).toBeInTheDocument();
});
test('defaults to contact points tab with no query param', async () => {
renderWithProvider();
- expect(await screen.findByText(/add contact point/i)).toBeInTheDocument();
+ expect(await screen.findByText(/create contact point/i)).toBeInTheDocument();
});
});
@@ -394,8 +419,9 @@ describe('contact points', () => {
});
describe('alertingApiServer enabled', () => {
+ testWithFeatureToggles(['alertingApiServer']);
+
beforeEach(() => {
- config.featureToggles.alertingApiServer = true;
grantUserPermissions([
AccessControlAction.AlertingNotificationsRead,
AccessControlAction.AlertingNotificationsWrite,
@@ -428,5 +454,121 @@ describe('contact points', () => {
return expect(attemptDeleteContactPoint('provisioned-contact-point')).rejects.toBeTruthy();
});
+
+ it('renders number of alert rules and policies and does not permit deletion', async () => {
+ const { user } = renderWithProvider();
+
+ expect(screen.getByText(/used by 3 alert rule/i)).toBeInTheDocument();
+ expect(screen.getByText(/used by 1 notification policy/i)).toBeInTheDocument();
+
+ await clickMoreActionsButton(contactPointWithEverything.name);
+ const deleteButton = screen.getByRole('menuitem', { name: /delete/i });
+ expect(deleteButton).toBeDisabled();
+ await user.hover(deleteButton);
+
+ expect(await screen.findByText(/Contact point is referenced by one or more alert rules/i)).toBeInTheDocument();
+ expect(
+ await screen.findByText(/Contact point is referenced by one or more notification policies/i)
+ ).toBeInTheDocument();
+ });
+
+ it('does not permit deletion when contact point is only referenced by a rule', async () => {
+ const contactPointWithRule: ContactPointWithMetadata = {
+ ...basicContactPoint,
+ metadata: {
+ annotations: {
+ [K8sAnnotations.InUseRules]: '1',
+ },
+ },
+ };
+ const { user } = renderWithProvider();
+
+ expect(screen.getByText(/used by 1 alert rule/i)).toBeInTheDocument();
+
+ await clickMoreActionsButton(contactPointWithEverything.name);
+ const deleteButton = screen.getByRole('menuitem', { name: /delete/i });
+ expect(deleteButton).toBeDisabled();
+ await user.hover(deleteButton);
+
+ expect(await screen.findByText(/Contact point is referenced by one or more alert rules/i)).toBeInTheDocument();
+ });
+
+ it('does not permit deletion when lacking permissions to delete', async () => {
+ grantUserPermissions([AccessControlAction.AlertingNotificationsRead]);
+ const contactPointWithoutPermissions: ContactPointWithMetadata = {
+ ...contactPointWithEverything,
+ metadata: {
+ annotations: {
+ [K8sAnnotations.AccessDelete]: 'false',
+ },
+ },
+ };
+
+ const { user } = renderWithProvider();
+
+ await clickMoreActionsButton(contactPointWithEverything.name);
+
+ const deleteButton = screen.getByRole('menuitem', { name: /delete/i });
+ await waitFor(() => expect(deleteButton).toBeDisabled());
+
+ await user.hover(deleteButton);
+
+ expect(
+ await screen.findByText(/You do not have the required permission to delete this contact point/i)
+ ).toBeInTheDocument();
+ });
+
+ it('allows deletion when there are no rules or policies referenced, and user has permission', async () => {
+ grantUserPermissions([AccessControlAction.AlertingNotificationsRead]);
+ const contactPointWithoutPermissions: ContactPointWithMetadata = {
+ ...contactPointWithEverything,
+ metadata: {
+ annotations: {
+ [K8sAnnotations.AccessDelete]: 'false',
+ },
+ },
+ };
+
+ const { user } = renderWithProvider();
+
+ await clickMoreActionsButton(contactPointWithEverything.name);
+
+ const deleteButton = screen.getByRole('menuitem', { name: /delete/i });
+ await waitFor(() => expect(deleteButton).toBeDisabled());
+
+ await user.hover(deleteButton);
+
+ expect(
+ await screen.findByText(/You do not have the required permission to delete this contact point/i)
+ ).toBeInTheDocument();
+ });
+
+ it('does not show manage permissions', async () => {
+ renderGrafanaContactPoints();
+
+ await clickMoreActionsButton('lotsa-emails');
+
+ expect(screen.queryByRole('menuitem', { name: /manage permissions/i })).not.toBeInTheDocument();
+ });
+
+ describe('accesscontrol license feature enabled', () => {
+ testWithLicenseFeatures(['accesscontrol']);
+
+ it('shows manage permissions and allows closing', async () => {
+ const { user } = renderGrafanaContactPoints();
+
+ await clickMoreActionsButton('lotsa-emails');
+
+ await user.click(await screen.findByRole('menuitem', { name: /manage permissions/i }));
+
+ const permissionsDialog = await screen.findByRole('dialog', { name: /drawer title manage permissions/i });
+
+ expect(permissionsDialog).toBeInTheDocument();
+ expect(await screen.findByRole('table')).toBeInTheDocument();
+
+ await user.click(within(permissionsDialog).getAllByRole('button', { name: /close/i })[0]);
+ expect(permissionsDialog).not.toBeInTheDocument();
+ });
+ });
});
});
diff --git a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx
index 3ffd22dd237..2e8a57588aa 100644
--- a/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx
+++ b/public/app/features/alerting/unified/components/contact-points/ContactPoints.tsx
@@ -3,6 +3,7 @@ import { useMemo } from 'react';
import {
Alert,
Button,
+ EmptyState,
LinkButton,
LoadingPlaceholder,
Pagination,
@@ -12,7 +13,11 @@ import {
TabsBar,
Text,
} from '@grafana/ui';
-import { stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
+import { contextSrv } from 'app/core/core';
+import { t, Trans } from 'app/core/internationalization';
+import { shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
+import { makeAMLink, stringifyErrorLike } from 'app/features/alerting/unified/utils/misc';
+import { AccessControlAction } from 'app/types';
import { AlertmanagerAction, useAlertmanagerAbility } from '../../hooks/useAbilities';
import { usePagination } from '../../hooks/usePagination';
@@ -41,10 +46,16 @@ const ContactPointsTab = () => {
const { selectedAlertmanager } = useAlertmanager();
const [queryParams] = useURLSearchParams();
+ // If we're using the K8S API, then we don't need to fetch the policies info within the hook,
+ // as we get metadata about this from the API
+ const fetchPolicies = !shouldUseK8sApi(selectedAlertmanager!);
+ // User may have access to list contact points, but not permission to fetch the status endpoint
+ const fetchStatuses = contextSrv.hasPermission(AccessControlAction.AlertingNotificationsRead);
+
const { isLoading, error, contactPoints } = useContactPointsWithStatus({
alertmanager: selectedAlertmanager!,
- fetchPolicies: true,
- fetchStatuses: true,
+ fetchPolicies,
+ fetchStatuses,
});
const [addContactPointSupported, addContactPointAllowed] = useAlertmanagerAbility(
@@ -58,16 +69,32 @@ const ContactPointsTab = () => {
const search = queryParams.get('search');
- if (error) {
- // TODO fix this type casting, when error comes from "getContactPointsStatus" it probably won't be a SerializedError
- return {stringifyErrorLike(error)};
- }
-
if (isLoading) {
return ;
}
const isGrafanaManagedAlertmanager = selectedAlertmanager === GRAFANA_RULES_SOURCE_NAME;
+
+ if (contactPoints.length === 0) {
+ return (
+
+ Create contact point
+
+ )
+ }
+ message={t('alerting.contact-points.empty-state.title', "You don't have any contact points yet")}
+ />
+ );
+ }
+
return (
<>
{/* TODO we can add some additional info here with a ToggleTip */}
@@ -83,7 +110,7 @@ const ContactPointsTab = () => {
href="/alerting/notifications/receivers/new"
disabled={!addContactPointAllowed}
>
- Add contact point
+ Create contact point
)}
{exportContactPointsSupported && (
@@ -99,7 +126,8 @@ const ContactPointsTab = () => {
)}
-
+ {error && {stringifyErrorLike(error)}}
+ {!error && }
{/* Grafana manager Alertmanager does not support global config, Mimir and Cortex do */}
{!isGrafanaManagedAlertmanager && }
{ExportDrawer}
@@ -158,6 +186,7 @@ const ContactPointsPageContents = () => {
const { contactPoints } = useContactPointsWithStatus({
alertmanager: selectedAlertmanager!,
});
+ const [_, showTemplatesTab] = useAlertmanagerAbility(AlertmanagerAction.ViewNotificationTemplate);
const showingContactPoints = activeTab === ActiveTab.ContactPoints;
const showNotificationTemplates = activeTab === ActiveTab.NotificationTemplates;
@@ -173,11 +202,13 @@ const ContactPointsPageContents = () => {
counter={contactPoints.length}
onChangeTab={() => setActiveTab(ActiveTab.ContactPoints)}
/>
- setActiveTab(ActiveTab.NotificationTemplates)}
- />
+ {showTemplatesTab && (
+ setActiveTab(ActiveTab.NotificationTemplates)}
+ />
+ )}
diff --git a/public/app/features/alerting/unified/components/contact-points/components/UnusedBadge.tsx b/public/app/features/alerting/unified/components/contact-points/components/UnusedBadge.tsx
index 7406ed7d515..1165f4b6977 100644
--- a/public/app/features/alerting/unified/components/contact-points/components/UnusedBadge.tsx
+++ b/public/app/features/alerting/unified/components/contact-points/components/UnusedBadge.tsx
@@ -6,7 +6,6 @@ export const UnusedContactPointBadge = () => (
aria-label="unused"
color="orange"
icon="exclamation-triangle"
- // is not used in any policy, but it can receive notifications from an auto auto generated policy. Non admin users can't see auto generated policies.
- tooltip="This contact point is not used in any notification policy"
+ tooltip="This contact point is not used in any notification policy or alert rule"
/>
);
diff --git a/public/app/features/alerting/unified/components/contact-points/permissions.ts b/public/app/features/alerting/unified/components/contact-points/permissions.ts
new file mode 100644
index 00000000000..7cf13ff907c
--- /dev/null
+++ b/public/app/features/alerting/unified/components/contact-points/permissions.ts
@@ -0,0 +1,25 @@
+import { AccessControlAction } from 'app/types';
+
+/**
+ * List of granular permissions that allow viewing contact points
+ *
+ * Any permission in this list will be checked for client side access to view Contact Points functionality.
+ */
+const PERMISSIONS_CONTACT_POINTS_READ = [AccessControlAction.AlertingReceiversRead];
+
+/**
+ * List of granular permissions that allow modifying contact points
+ */
+export const PERMISSIONS_CONTACT_POINTS_MODIFY = [
+ AccessControlAction.AlertingReceiversCreate,
+ AccessControlAction.AlertingReceiversWrite,
+];
+
+/**
+ * List of all permissions that allow contact points read/write functionality
+ *
+ * Any permission in this list will also be checked for whether the built-in Grafana Alertmanager is shown
+ * (as the implication is that if they have one of these permissions, then they should be able to see Grafana AM in the AM selector)
+ */
+
+export const PERMISSIONS_CONTACT_POINTS = [...PERMISSIONS_CONTACT_POINTS_READ, ...PERMISSIONS_CONTACT_POINTS_MODIFY];
diff --git a/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx b/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx
index 10d56e2ac5f..82e68c3324d 100644
--- a/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx
+++ b/public/app/features/alerting/unified/components/contact-points/useContactPoints.tsx
@@ -110,7 +110,8 @@ const useK8sContactPoints = (...[hookParams, queryOptions]: Parameters {
...item,
provisioned: item.grafana_managed_receiver_configs?.some((item) => item.provenance),
}));
-
return {
...result,
data,
@@ -170,6 +170,7 @@ export const useGrafanaContactPoints = ({
const onCallResponse = useOnCallIntegrations(potentiallySkip);
const alertNotifiers = useGrafanaNotifiersQuery(undefined, potentiallySkip);
const contactPointsListResponse = useFetchGrafanaContactPoints(potentiallySkip);
+
const contactPointsStatusResponse = useGetContactPointsStatusQuery(undefined, {
...defaultOptions,
pollingInterval: RECEIVER_STATUS_POLLING_INTERVAL,
@@ -182,7 +183,7 @@ export const useGrafanaContactPoints = ({
return useMemo(() => {
const isLoading = onCallResponse.isLoading || alertNotifiers.isLoading || contactPointsListResponse.isLoading;
- if (isLoading || !contactPointsListResponse.data) {
+ if (isLoading) {
return {
...contactPointsListResponse,
// If we're inside this block, it means that at least one of the endpoints we care about is still loading,
@@ -198,7 +199,7 @@ export const useGrafanaContactPoints = ({
status: contactPointsStatusResponse.data,
notifiers: alertNotifiers.data,
onCallIntegrations: onCallResponse?.data,
- contactPoints: contactPointsListResponse.data,
+ contactPoints: contactPointsListResponse.data || [],
alertmanagerConfiguration: alertmanagerConfigResponse.data,
});
diff --git a/public/app/features/alerting/unified/components/contact-points/utils.ts b/public/app/features/alerting/unified/components/contact-points/utils.ts
index 7288d9785cd..0f81ff496a9 100644
--- a/public/app/features/alerting/unified/components/contact-points/utils.ts
+++ b/public/app/features/alerting/unified/components/contact-points/utils.ts
@@ -2,6 +2,8 @@ import { difference, groupBy, take, trim, upperFirst } from 'lodash';
import { ReactNode } from 'react';
import { config } from '@grafana/runtime';
+import { contextSrv } from 'app/core/core';
+import { canAdminEntity, shouldUseK8sApi } from 'app/features/alerting/unified/utils/k8s/utils';
import {
AlertManagerCortexConfig,
GrafanaManagedContactPoint,
@@ -206,3 +208,6 @@ function getNotifierMetadata(notifiers: NotifierDTO[], receiver: GrafanaManagedR
description: match?.description,
};
}
+
+export const showManageContactPointPermissions = (alertmanager: string, contactPoint: GrafanaManagedContactPoint) =>
+ shouldUseK8sApi(alertmanager) && contextSrv.licensedAccessControlEnabled() && canAdminEntity(contactPoint);
diff --git a/public/app/features/alerting/unified/components/permissions/ManagePermissions.tsx b/public/app/features/alerting/unified/components/permissions/ManagePermissions.tsx
new file mode 100644
index 00000000000..cd0c08b2e99
--- /dev/null
+++ b/public/app/features/alerting/unified/components/permissions/ManagePermissions.tsx
@@ -0,0 +1,71 @@
+import { useState, ComponentProps } from 'react';
+
+import { Button, Drawer } from '@grafana/ui';
+import { Permissions } from 'app/core/components/AccessControl';
+import { t, Trans } from 'app/core/internationalization';
+
+type ButtonProps = { onClick: () => void };
+
+type BaseProps = Pick, 'resource' | 'resourceId'> & {
+ resourceName?: string;
+ title?: string;
+};
+
+type Props = BaseProps & {
+ renderButton?: (props: ButtonProps) => JSX.Element;
+};
+
+/**
+ * Renders just the drawer containing permissions management for the resource.
+ *
+ * Useful for manually controlling the state/display of the drawer when you need to render the
+ * controlling button within a dropdown etc.
+ */
+export const ManagePermissionsDrawer = ({
+ resourceName,
+ title,
+ onClose,
+ ...permissionsProps
+}: BaseProps & Pick, 'onClose'>) => {
+ const defaultTitle = t('alerting.manage-permissions.title', 'Manage permissions');
+ return (
+
+
+
+ );
+};
+
+/** Default way to render the button for "manage permissions" */
+const DefaultButton = ({ onClick }: ButtonProps) => {
+ return (
+
+ );
+};
+
+/**
+ * Renders a button that opens a drawer with the permissions editor.
+ *
+ * Provides capability to render button as custom component, and manages open/close state internally
+ */
+export const ManagePermissions = ({ resource, resourceId, resourceName, title, renderButton }: Props) => {
+ const [showDrawer, setShowDrawer] = useState(false);
+ const closeDrawer = () => setShowDrawer(false);
+ const openDrawer = () => setShowDrawer(true);
+
+ return (
+ <>
+ {renderButton ? renderButton({ onClick: openDrawer }) : }
+ {showDrawer && (
+
+ )}
+ >
+ );
+};
diff --git a/public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx b/public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx
index 89bccca70b5..dae0843d303 100644
--- a/public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx
+++ b/public/app/features/alerting/unified/components/receivers/EditReceiverView.tsx
@@ -17,7 +17,6 @@ export const EditReceiverView = ({ contactPoint, alertmanagerName }: Props) => {
const readOnly = !editSupported || !editAllowed;
if (alertmanagerName === GRAFANA_RULES_SOURCE_NAME) {
- console.log(contactPoint);
return ;
} else {
return (
diff --git a/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx b/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx
index fa6c20423ba..a7d1df7ffb5 100644
--- a/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx
+++ b/public/app/features/alerting/unified/components/receivers/NewReceiverView.test.tsx
@@ -3,7 +3,6 @@ import { Route } from 'react-router';
import { render, screen } from 'test/test-utils';
import { byLabelText, byPlaceholderText, byRole, byTestId } from 'testing-library-selector';
-import { config } from '@grafana/runtime';
import { makeGrafanaAlertmanagerConfigUpdateFail } from 'app/features/alerting/unified/mocks/server/configure';
import { captureRequests } from 'app/features/alerting/unified/mocks/server/events';
import { AccessControlAction } from 'app/types';
@@ -11,6 +10,7 @@ import { AccessControlAction } from 'app/types';
import { setupMswServer } from '../../mockApi';
import { grantUserPermissions } from '../../mocks';
import { AlertmanagerProvider } from '../../state/AlertmanagerContext';
+import { testWithFeatureToggles } from '../../test/test-utils';
import NewReceiverView from './NewReceiverView';
@@ -36,9 +36,7 @@ beforeEach(() => {
});
describe('alerting API server enabled', () => {
- beforeEach(() => {
- config.featureToggles.alertingApiServer = true;
- });
+ testWithFeatureToggles(['alertingApiServer']);
it('can create a receiver', async () => {
const { user } = renderForm();
@@ -59,9 +57,6 @@ describe('alerting API server enabled', () => {
});
describe('alerting API server disabled', () => {
- beforeEach(() => {
- config.featureToggles.alertingApiServer = false;
- });
it('should be able to test and save a receiver', async () => {
const capture = captureRequests();
diff --git a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx
index a239b3400db..6f4952a41d2 100644
--- a/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx
+++ b/public/app/features/alerting/unified/components/receivers/form/ChannelSubForm.tsx
@@ -262,7 +262,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
padding: theme.spacing(1),
border: `solid 1px ${theme.colors.border.medium}`,
borderRadius: theme.shape.radius.default,
- maxWidth: `${theme.breakpoints.values.xl}${theme.breakpoints.unit}`,
}),
topRow: css({
display: 'flex',
diff --git a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx
index a7d26ed25cd..00ef22ca8fc 100644
--- a/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx
+++ b/public/app/features/alerting/unified/components/receivers/form/GrafanaReceiverForm.tsx
@@ -6,7 +6,9 @@ import {
useCreateContactPoint,
useUpdateContactPoint,
} from 'app/features/alerting/unified/components/contact-points/useContactPoints';
+import { showManageContactPointPermissions } from 'app/features/alerting/unified/components/contact-points/utils';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
+import { canEditEntity } from 'app/features/alerting/unified/utils/k8s/utils';
import {
GrafanaManagedContactPoint,
GrafanaManagedReceiverConfig,
@@ -121,7 +123,9 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
}
};
- const isEditable = !readOnly && !contactPoint?.provisioned;
+ const isEditable = Boolean(
+ (!readOnly || (contactPoint && canEditEntity(contactPoint))) && !contactPoint?.provisioned
+ );
const isTestable = !readOnly;
if (isLoadingNotifiers || isLoadingOnCallIntegration) {
@@ -150,6 +154,7 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
{contactPoint?.provisioned && }
+ contactPointId={contactPoint?.id}
isEditable={isEditable}
isTestable={isTestable}
onSubmit={onSubmit}
@@ -160,6 +165,9 @@ export const GrafanaReceiverForm = ({ contactPoint, readOnly = false, editMode }
defaultItem={{ ...defaultChannelValues }}
commonSettingsComponent={GrafanaCommonChannelSettings}
customValidators={{ [ReceiverTypes.OnCall]: onCallFormValidators }}
+ canManagePermissions={
+ editMode && contactPoint && showManageContactPointPermissions(GRAFANA_RULES_SOURCE_NAME, contactPoint)
+ }
/>
setTestChannelValues(undefined)}
diff --git a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx
index b1d5d9005a0..53ef77adcd6 100644
--- a/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx
+++ b/public/app/features/alerting/unified/components/receivers/form/ReceiverForm.tsx
@@ -4,10 +4,11 @@ import { FieldErrors, FormProvider, SubmitErrorHandler, useForm } from 'react-ho
import { GrafanaTheme2 } from '@grafana/data';
import { isFetchError } from '@grafana/runtime';
-import { Alert, Button, Field, Input, LinkButton, useStyles2 } from '@grafana/ui';
+import { Alert, Button, Field, Input, LinkButton, Stack, useStyles2 } from '@grafana/ui';
import { useAppNotification } from 'app/core/copy/appNotification';
import { useCleanup } from 'app/core/hooks/useCleanup';
import { useValidateContactPoint } from 'app/features/alerting/unified/components/contact-points/useContactPoints';
+import { ManagePermissions } from 'app/features/alerting/unified/components/permissions/ManagePermissions';
import { getMessageFromError } from '../../../../../../core/utils/errors';
import { logError } from '../../../Analytics';
@@ -38,6 +39,8 @@ interface Props {
* and that contact point being created will be set as the default?
*/
showDefaultRouteWarning?: boolean;
+ contactPointId?: string;
+ canManagePermissions?: boolean;
}
export function ReceiverForm({
@@ -52,6 +55,8 @@ export function ReceiverForm({
isTestable,
customValidators,
showDefaultRouteWarning,
+ contactPointId,
+ canManagePermissions,
}: Props) {
const notifyApp = useAppNotification();
const styles = useStyles2(getStyles);
@@ -126,10 +131,21 @@ export function ReceiverForm({
Because there is no default policy configured yet, this contact point will automatically be set as default.
)}
-