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. )} -
    -

    - {!isEditable ? 'Contact point' : initialValues ? 'Update contact point' : 'Create contact point'} -

    + + + +

    + {!isEditable ? 'Contact point' : initialValues ? 'Update contact point' : 'Create contact point'} +

    + {canManagePermissions && contactPointId && ( + + )} +
    ({ const getStyles = (theme: GrafanaTheme2) => ({ heading: css({ - margin: theme.spacing(4, 0), + margin: theme.spacing(2, 0, 3, 0), }), buttons: css({ marginTop: theme.spacing(4), @@ -228,6 +244,9 @@ const getStyles = (theme: GrafanaTheme2) => ({ marginLeft: theme.spacing(1), }, }), + wrapper: css({ + maxWidth: `${theme.breakpoints.values.xl}px`, + }), }); function getErrorMessage(error: unknown) { diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx index 87acb2ba535..abe0aa0f1a2 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/SimplifiedRuleEditor.test.tsx @@ -14,6 +14,7 @@ import { setAlertmanagerChoices } from 'app/features/alerting/unified/mocks/serv import { captureRequests, serializeRequests } from 'app/features/alerting/unified/mocks/server/events'; import { FOLDER_TITLE_HAPPY_PATH } from 'app/features/alerting/unified/mocks/server/handlers/search'; import { AlertmanagerProvider } from 'app/features/alerting/unified/state/AlertmanagerContext'; +import { testWithFeatureToggles } from 'app/features/alerting/unified/test/test-utils'; import { DataSourceType, GRAFANA_DATASOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types'; import { AccessControlAction } from 'app/types'; @@ -135,9 +136,7 @@ describe('Can create a new grafana managed alert using simplified routing', () = }); describe('alertingApiServer enabled', () => { - beforeEach(() => { - config.featureToggles.alertingApiServer = true; - }); + testWithFeatureToggles(['alertingApiServer']); it('allows selecting a contact point when using alerting API server', async () => { const user = userEvent.setup(); diff --git a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx index cc5284231dc..452ec78496e 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx +++ b/public/app/features/alerting/unified/components/rules/Filter/RulesFilter.v1.tsx @@ -6,8 +6,10 @@ import { DataSourceInstanceSettings, GrafanaTheme2, SelectableValue } from '@gra import { config } from '@grafana/runtime'; import { Button, Field, Icon, Input, Label, RadioButtonGroup, Stack, Tooltip, useStyles2 } from '@grafana/ui'; import { DashboardPicker } from 'app/core/components/Select/DashboardPicker'; +import { contextSrv } from 'app/core/core'; import { Trans } from 'app/core/internationalization'; import { ContactPointSelector } from 'app/features/alerting/unified/components/notification-policies/ContactPointSelector'; +import { AccessControlAction } from 'app/types'; import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; import { @@ -150,7 +152,10 @@ const RulesFilter = ({ onClear = () => undefined }: RulesFilerProps) => { trackRulesSearchComponentInteraction('contactPoint'); }; - const canRenderContactPointSelector = config.featureToggles.alertingSimplifiedRouting ?? false; + const canRenderContactPointSelector = + (contextSrv.hasPermission(AccessControlAction.AlertingReceiversRead) && + config.featureToggles.alertingSimplifiedRouting) ?? + false; const searchIcon = ; return ( diff --git a/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap b/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap index c67bb2b2285..10575c675a5 100644 --- a/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap +++ b/public/app/features/alerting/unified/hooks/__snapshots__/useAbilities.test.tsx.snap @@ -239,7 +239,7 @@ exports[`alertmanager abilities should report everything except exporting for Mi ], "export-contact-point": [ false, - true, + false, ], "export-mute-timings": [ false, diff --git a/public/app/features/alerting/unified/hooks/useAbilities.ts b/public/app/features/alerting/unified/hooks/useAbilities.ts index f449bada4e4..dac04eb248a 100644 --- a/public/app/features/alerting/unified/hooks/useAbilities.ts +++ b/public/app/features/alerting/unified/hooks/useAbilities.ts @@ -225,12 +225,19 @@ export function useAllAlertmanagerAbilities(): Abilities { AccessControlAction.AlertingNotificationsExternalWrite ), // -- contact points -- - [AlertmanagerAction.CreateContactPoint]: toAbility(hasConfigurationAPI, notificationsPermissions.create), + [AlertmanagerAction.CreateContactPoint]: toAbility( + hasConfigurationAPI, + notificationsPermissions.create, + // TODO: Move this into the permissions config and generalise that code to allow for an array of permissions + isGrafanaFlavoredAlertmanager ? AccessControlAction.AlertingReceiversCreate : null + ), [AlertmanagerAction.ViewContactPoint]: toAbility(AlwaysSupported, notificationsPermissions.read), [AlertmanagerAction.UpdateContactPoint]: toAbility(hasConfigurationAPI, notificationsPermissions.update), [AlertmanagerAction.DeleteContactPoint]: toAbility(hasConfigurationAPI, notificationsPermissions.delete), - // only Grafana flavored alertmanager supports exporting - [AlertmanagerAction.ExportContactPoint]: toAbility(isGrafanaFlavoredAlertmanager, notificationsPermissions.read), + // At the time of writing, only Grafana flavored alertmanager supports exporting, + // and if a user can view the contact point, then they can also export it + // So the only check we make is if the alertmanager is Grafana flavored + [AlertmanagerAction.ExportContactPoint]: [isGrafanaFlavoredAlertmanager, isGrafanaFlavoredAlertmanager], // -- notification templates -- [AlertmanagerAction.CreateNotificationTemplate]: toAbility(hasConfigurationAPI, notificationsPermissions.create), [AlertmanagerAction.ViewNotificationTemplate]: toAbility(AlwaysSupported, notificationsPermissions.read), @@ -324,4 +331,7 @@ function useCanSilence(rule: CombinedRule): [boolean, boolean] { } // just a convenient function -const toAbility = (supported: boolean, action: AccessControlAction): Ability => [supported, ctx.hasPermission(action)]; +const toAbility = (supported: boolean, ...actions: Array): Ability => [ + supported, + actions.some((action) => action && ctx.hasPermission(action)), +]; diff --git a/public/app/features/alerting/unified/mocks/server/all-handlers.ts b/public/app/features/alerting/unified/mocks/server/all-handlers.ts index 0885f7021bb..049e7c8d03e 100644 --- a/public/app/features/alerting/unified/mocks/server/all-handlers.ts +++ b/public/app/features/alerting/unified/mocks/server/all-handlers.ts @@ -2,6 +2,7 @@ * Contains all handlers that are required for test rendering of components within Alerting */ +import accessControlHandlers from 'app/features/alerting/unified/mocks/server/handlers/accessControl'; import alertNotifierHandlers from 'app/features/alerting/unified/mocks/server/handlers/alertNotifiers'; import alertmanagerHandlers from 'app/features/alerting/unified/mocks/server/handlers/alertmanagers'; import datasourcesHandlers from 'app/features/alerting/unified/mocks/server/handlers/datasources'; @@ -22,6 +23,7 @@ import silenceHandlers from 'app/features/alerting/unified/mocks/server/handlers * Array of all mock handlers that are required across Alerting tests */ const allHandlers = [ + ...accessControlHandlers, ...alertNotifierHandlers, ...grafanaRulerHandlers, ...mimirRulerHandlers, diff --git a/public/app/features/alerting/unified/mocks/server/handlers/accessControl.ts b/public/app/features/alerting/unified/mocks/server/handlers/accessControl.ts new file mode 100644 index 00000000000..84dd2bf1a8c --- /dev/null +++ b/public/app/features/alerting/unified/mocks/server/handlers/accessControl.ts @@ -0,0 +1,67 @@ +import { HttpResponse, http } from 'msw'; + +import { Description, ResourcePermission } from 'app/core/components/AccessControl/types'; +import { AccessControlAction } from 'app/types'; + +// TODO: Expand this out to more realistic use cases as we work on RBAC for contact points +const resourceDescriptionsMap: Record = { + receivers: { + assignments: { + users: true, + serviceAccounts: true, + teams: true, + builtInRoles: true, + }, + permissions: ['View', 'Edit', 'Admin'], + }, +}; + +/** + * Map of pre-determined resources and corresponding IDs for those resources, + * to permissions for those resources + * */ +const resourceDetailsMap: Record> = { + receivers: { + lotsaEmails: [ + { + id: 123, + roleName: 'somerole:name', + isManaged: true, + isInherited: false, + isServiceAccount: false, + builtInRole: 'Viewer', + actions: [AccessControlAction.FoldersRead, AccessControlAction.AlertingRuleRead], + permission: 'View', + }, + ], + }, +}; + +const getAccessControlResourceDescriptionHandler = () => + http.get<{ resourceType: string }>(`/api/access-control/:resourceType/description`, ({ params }) => { + const matchedResourceDescription = resourceDescriptionsMap[params.resourceType]; + return matchedResourceDescription + ? HttpResponse.json(matchedResourceDescription) + : HttpResponse.json({ message: 'Not found' }, { status: 404 }); + }); + +const getAccessControlResourceDetailsHandler = () => + http.get<{ resourceType: string; resourceId: string }>( + `/api/access-control/:resourceType/:resourceId`, + ({ params }) => { + const matchedResourceDetails = resourceDetailsMap[params.resourceType][params.resourceId]; + return matchedResourceDetails + ? HttpResponse.json(matchedResourceDetails) + : HttpResponse.json( + { + message: 'Failed to get permissions', + traceID: '', + }, + { status: 404 } + ); + } + ); + +const handlers = [getAccessControlResourceDescriptionHandler(), getAccessControlResourceDetailsHandler()]; + +export default handlers; diff --git a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts index 9963678011a..e4718b82498 100644 --- a/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts +++ b/public/app/features/alerting/unified/mocks/server/handlers/k8s/receivers.k8s.ts @@ -4,7 +4,7 @@ import { HttpResponse, http } from 'msw'; import alertmanagerConfig from 'app/features/alerting/unified/components/contact-points/__mocks__/alertmanager.config.mock.json'; import { ALERTING_API_SERVER_BASE_URL, getK8sResponse } from 'app/features/alerting/unified/mocks/server/utils'; import { ComGithubGrafanaGrafanaPkgApisAlertingNotificationsV0Alpha1Receiver } from 'app/features/alerting/unified/openapi/receiversApi.gen'; -import { PROVENANCE_ANNOTATION, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { PROVENANCE_NONE, K8sAnnotations } from 'app/features/alerting/unified/utils/k8s/constants'; import { AlertManagerCortexConfig } from 'app/plugins/datasource/alertmanager/types'; const config: AlertManagerCortexConfig = alertmanagerConfig; @@ -20,7 +20,12 @@ const mappedReceivers = metadata: { // This isn't exactly accurate, but its the cleanest way to use the same data for AM config and K8S responses uid: camelCase(contactPoint.name), - annotations: { [PROVENANCE_ANNOTATION]: provenance }, + annotations: { + [K8sAnnotations.Provenance]: provenance, + [K8sAnnotations.AccessAdmin]: 'true', + [K8sAnnotations.AccessDelete]: 'true', + [K8sAnnotations.AccessWrite]: 'true', + }, }, spec: { title: contactPoint.name, diff --git a/public/app/features/alerting/unified/test/test-utils.ts b/public/app/features/alerting/unified/test/test-utils.ts new file mode 100644 index 00000000000..88de7e27984 --- /dev/null +++ b/public/app/features/alerting/unified/test/test-utils.ts @@ -0,0 +1,49 @@ +import { act } from '@testing-library/react'; + +import { FeatureToggles } from '@grafana/data'; +import { config } from '@grafana/runtime'; + +/** + * Flushes out microtasks so we don't get warnings from `@floating-ui/react` + * as per https://floating-ui.com/docs/react#testing + */ +export const flushMicrotasks = async () => { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +}; + +/** + * Enables feature toggles `beforeEach` test, and sets back to original settings `afterEach` test + */ +export const testWithFeatureToggles = (featureToggles: Array) => { + const originalToggles = { ...config.featureToggles }; + + beforeEach(() => { + featureToggles.forEach((featureToggle) => { + config.featureToggles[featureToggle] = true; + }); + }); + + afterEach(() => { + config.featureToggles = originalToggles; + }); +}; + +/** + * Enables license features `beforeEach` test, and sets back to original settings `afterEach` test + */ +export const testWithLicenseFeatures = (features: string[]) => { + const originalFeatures = { ...config.licenseInfo.enabledFeatures }; + beforeEach(() => { + config.licenseInfo.enabledFeatures = config.licenseInfo.enabledFeatures || {}; + + features.forEach((feature) => { + config.licenseInfo.enabledFeatures[feature] = true; + }); + }); + + afterEach(() => { + config.licenseInfo.enabledFeatures = originalFeatures; + }); +}; diff --git a/public/app/features/alerting/unified/utils/datasource.ts b/public/app/features/alerting/unified/utils/datasource.ts index 7b922f78966..764de417bfb 100644 --- a/public/app/features/alerting/unified/utils/datasource.ts +++ b/public/app/features/alerting/unified/utils/datasource.ts @@ -11,6 +11,7 @@ import { RulesSource } from 'app/types/unified-alerting'; import { PromApplication, RulesSourceApplication } from 'app/types/unified-alerting-dto'; import { alertmanagerApi } from '../api/alertmanagerApi'; +import { PERMISSIONS_CONTACT_POINTS } from '../components/contact-points/permissions'; import { useAlertManagersByPermission } from '../hooks/useAlertManagerSources'; import { isAlertManagerWithConfigAPI } from '../state/AlertmanagerContext'; @@ -147,7 +148,11 @@ export function getAlertManagerDataSourcesByPermission(permission: 'instance' | silence: silencesPermissions.read, }; - const builtinAlertmanagerPermissions = Object.values(permissions).flatMap((permissions) => permissions.grafana); + const builtinAlertmanagerPermissions = [ + ...Object.values(permissions).flatMap((permissions) => permissions.grafana), + ...PERMISSIONS_CONTACT_POINTS, + ]; + const hasPermissionsForInternalAlertmanager = builtinAlertmanagerPermissions.some((permission) => contextSrv.hasPermission(permission) ); diff --git a/public/app/features/alerting/unified/utils/k8s/constants.ts b/public/app/features/alerting/unified/utils/k8s/constants.ts index 940c6539826..af93278ae99 100644 --- a/public/app/features/alerting/unified/utils/k8s/constants.ts +++ b/public/app/features/alerting/unified/utils/k8s/constants.ts @@ -1,5 +1,24 @@ -/** Name of the custom annotation label used in k8s APIs for us to discern if a given entity was provisioned */ +/** + * Name of the custom annotation label used in k8s APIs for us to discern if a given entity was provisioned + * @deprecated Use {@link K8sAnnotations.Provenance} instead + * */ export const PROVENANCE_ANNOTATION = 'grafana.com/provenance'; /** Value of {@link PROVENANCE_ANNOTATION} given for entities that were not provisioned */ export const PROVENANCE_NONE = 'none'; + +export enum K8sAnnotations { + Provenance = 'grafana.com/provenance', + + /** Annotation key that indicates how many notification policy routes are using this entity */ + InUseRoutes = 'grafana.com/inUse/routes', + /** Annotation key that indicates how many alert rules are using this entity */ + InUseRules = 'grafana.com/inUse/rules', + + /** Annotation key that indicates that the calling user is able to write (edit) this entity */ + AccessWrite = 'grafana.com/access/canWrite', + /** Annotation key that indicates that the calling user is able to admin the permissions of this entity */ + AccessAdmin = 'grafana.com/access/canAdmin', + /** Annotation key that indicates that the calling user is able to delete this entity */ + AccessDelete = 'grafana.com/access/canDelete', +} diff --git a/public/app/features/alerting/unified/utils/k8s/utils.ts b/public/app/features/alerting/unified/utils/k8s/utils.ts index f985d430c43..58072dc6462 100644 --- a/public/app/features/alerting/unified/utils/k8s/utils.ts +++ b/public/app/features/alerting/unified/utils/k8s/utils.ts @@ -1,6 +1,7 @@ import { config } from '@grafana/runtime'; +import { IoK8SApimachineryPkgApisMetaV1ObjectMeta } from 'app/features/alerting/unified/openapi/receiversApi.gen'; import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource'; -import { PROVENANCE_ANNOTATION, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; +import { K8sAnnotations, PROVENANCE_NONE } from 'app/features/alerting/unified/utils/k8s/constants'; /** * Get the correct namespace to use when using the K8S API. @@ -18,15 +19,30 @@ export const shouldUseK8sApi = (alertmanager?: string) => { return featureToggleEnabled && alertmanager === GRAFANA_RULES_SOURCE_NAME; }; -type Entity = { - metadata: { - annotations?: Record; - }; +type EntityToCheck = { + metadata?: IoK8SApimachineryPkgApisMetaV1ObjectMeta; }; /** * Check the metadata of a kubernetes entity and check if has the necessary annotations * that denote it as provisioned */ -export const isK8sEntityProvisioned = (item: Entity) => - item.metadata.annotations?.[PROVENANCE_ANNOTATION] !== PROVENANCE_NONE; +export const isK8sEntityProvisioned = (k8sEntity: EntityToCheck) => + getAnnotation(k8sEntity, K8sAnnotations.Provenance) !== PROVENANCE_NONE; + +export const ANNOTATION_PREFIX_ACCESS = 'grafana.com/access/'; + +/** + * Checks annotations on a k8s entity to see if the requesting user has the required permission + */ +export const getAnnotation = (k8sEntity: EntityToCheck, annotation: K8sAnnotations) => + k8sEntity.metadata?.annotations?.[annotation]; + +export const canEditEntity = (k8sEntity: EntityToCheck) => + getAnnotation(k8sEntity, K8sAnnotations.AccessWrite) === 'true'; + +export const canAdminEntity = (k8sEntity: EntityToCheck) => + getAnnotation(k8sEntity, K8sAnnotations.AccessAdmin) === 'true'; + +export const canDeleteEntity = (k8sEntity: EntityToCheck) => + getAnnotation(k8sEntity, K8sAnnotations.AccessDelete) === 'true'; diff --git a/public/app/types/accessControl.ts b/public/app/types/accessControl.ts index 7ea9ffa4a7b..de2c79b0362 100644 --- a/public/app/types/accessControl.ts +++ b/public/app/types/accessControl.ts @@ -127,6 +127,13 @@ export enum AccessControlAction { AlertingProvisioningRead = 'alert.provisioning:read', AlertingProvisioningWrite = 'alert.provisioning:write', + // Alerting receivers actions + AlertingReceiversPermissionsRead = 'receivers.permissions:read', + AlertingReceiversPermissionsWrite = 'receivers.permissions:write', + AlertingReceiversCreate = 'alert.notifications.receivers:create', + AlertingReceiversWrite = 'alert.notifications.receivers:write', + AlertingReceiversRead = 'alert.notifications.receivers:read', + ActionAPIKeysRead = 'apikeys:read', ActionAPIKeysCreate = 'apikeys:create', ActionAPIKeysDelete = 'apikeys:delete', diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 4adc53c31a8..6db3c417c68 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -108,7 +108,18 @@ }, "contact-point": "Contact Point", "contact-points": { + "create": "Create contact point", + "delete-reasons": { + "heading": "Contact point cannot be deleted for the following reasons:", + "no-permissions": "You do not have the required permission to delete this contact point", + "policies": "Contact point is referenced by one or more notification policies", + "provisioned": "Contact point is provisioned and cannot be deleted via the UI", + "rules": "Contact point is referenced by one or more alert rules" + }, "delivery-duration": "Last delivery took <1>", + "empty-state": { + "title": "You don't have any contact points yet" + }, "last-delivery-attempt": "Last delivery attempt", "last-delivery-failed": "Last delivery attempt failed", "no-delivery-attempts": "No delivery attempts", @@ -119,7 +130,9 @@ "parse-mode-warning-title": "Telegram messages are limited to 4096 UTF-8 characters." }, "used-by_one": "Used by {{ count }} notification policy", - "used-by_other": "Used by {{ count }} notification policy" + "used-by_other": "Used by {{ count }} notification policy", + "used-by-rules_one": "Used by {{ count }} alert rule", + "used-by-rules_other": "Used by {{ count }} alert rules" }, "contactPointFilter": { "label": "Contact point" @@ -142,6 +155,10 @@ } } }, + "manage-permissions": { + "button": "Manage permissions", + "title": "Manage permissions" + }, "mute_timings": { "error-loading": { "description": "Could not load mute timings. Please try again later.", diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index 359abaed9f0..42240ab6b0f 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -108,7 +108,18 @@ }, "contact-point": "Cőʼnŧäčŧ Pőįʼnŧ", "contact-points": { + "create": "Cřęäŧę čőʼnŧäčŧ pőįʼnŧ", + "delete-reasons": { + "heading": "Cőʼnŧäčŧ pőįʼnŧ čäʼnʼnőŧ þę đęľęŧęđ ƒőř ŧĥę ƒőľľőŵįʼnģ řęäşőʼnş:", + "no-permissions": "Ÿőū đő ʼnőŧ ĥävę ŧĥę řęqūįřęđ pęřmįşşįőʼn ŧő đęľęŧę ŧĥįş čőʼnŧäčŧ pőįʼnŧ", + "policies": "Cőʼnŧäčŧ pőįʼnŧ įş řęƒęřęʼnčęđ þy őʼnę őř mőřę ʼnőŧįƒįčäŧįőʼn pőľįčįęş", + "provisioned": "Cőʼnŧäčŧ pőįʼnŧ įş přővįşįőʼnęđ äʼnđ čäʼnʼnőŧ þę đęľęŧęđ vįä ŧĥę ŮĨ", + "rules": "Cőʼnŧäčŧ pőįʼnŧ įş řęƒęřęʼnčęđ þy őʼnę őř mőřę äľęřŧ řūľęş" + }, "delivery-duration": "Ŀäşŧ đęľįvęřy ŧőőĸ <1>", + "empty-state": { + "title": "Ÿőū đőʼn'ŧ ĥävę äʼny čőʼnŧäčŧ pőįʼnŧş yęŧ" + }, "last-delivery-attempt": "Ŀäşŧ đęľįvęřy äŧŧęmpŧ", "last-delivery-failed": "Ŀäşŧ đęľįvęřy äŧŧęmpŧ ƒäįľęđ", "no-delivery-attempts": "Ńő đęľįvęřy äŧŧęmpŧş", @@ -119,7 +130,9 @@ "parse-mode-warning-title": "Ŧęľęģřäm męşşäģęş äřę ľįmįŧęđ ŧő 4096 ŮŦF-8 čĥäřäčŧęřş." }, "used-by_one": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčy", - "used-by_other": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčy" + "used-by_other": "Ůşęđ þy {{ count }} ʼnőŧįƒįčäŧįőʼn pőľįčy", + "used-by-rules_one": "Ůşęđ þy {{ count }} äľęřŧ řūľę", + "used-by-rules_other": "Ůşęđ þy {{ count }} äľęřŧ řūľęş" }, "contactPointFilter": { "label": "Cőʼnŧäčŧ pőįʼnŧ" @@ -142,6 +155,10 @@ } } }, + "manage-permissions": { + "button": "Mäʼnäģę pęřmįşşįőʼnş", + "title": "Mäʼnäģę pęřmįşşįőʼnş" + }, "mute_timings": { "error-loading": { "description": "Cőūľđ ʼnőŧ ľőäđ mūŧę ŧįmįʼnģş. Pľęäşę ŧřy äģäįʼn ľäŧęř.",