From fc51ec70ba99cd047ef33c59472c7a8a598a2ff8 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Fri, 27 Sep 2024 19:56:32 +0100 Subject: [PATCH] Alerting: Add manage permissions UI logic for Contact Points (#92885) * Add showPolicies prop * Add manage permissions component for easier reuse within alerting * Add method for checking whether to show access control within alerting * Remove accidental console.log from main * Tweak styling for contact point width and add manage permissions drawer * Improve typing for access control type response * Add basic test for manage permissions on contact points list * Only show manage permissions if grafana AM and alertingApiServer is enabled * Update i18n * Add test utils for turning features on and back off * Add access control handlers * Update tests with new util * Pass AM in and add tests * Receiver OSS resource permissions There is a complication that is not fully addressed: Viewer defaults to read:* and Editor defaults to read+write+delete:* This is different to other resource permissions where non-admin are not granted any global permissions and instead access is handled solely by resource-specific permissions that are populated on create and removed on delete. This allows them to easily remove permission to view or edit a single resource from basic roles. The reason this is tricky here is that we have multiple APIs that can create/delete receivers: config api, provisioning api, and k8s receivers api. Config api in particular is not well-equipped to determine when creates/deletes are happening and thus ensuring that the proper resource-specific permissions are created/deleted is finicky. We would also have to create a migration to populate resource-specific permissions for all current receivers. This migration would need to be reset so it can run again if the flag is disabled. * Add access control permissions * Pass in contact point ID to receivers form * Temporarily remove access control check for contact points * Include access control metadata in k8s receiver List & Get GET: Always included. LIST: Included by adding a label selector with value `grafana.com/accessControl` * Include new permissions for contact points navbar * Fix receiver creator fixed role to not give global read * Include in-use metadata in k8s receiver List & Get GET: Always included. LIST: Included by adding a label selector with value `grafana.com/inUse` * Add receiver creator permission to receiver writer * Add receiver creator permission to navbar * Always allow listing receivers, don't return 403 * Remove receiver read precondition from receiver create Otherwise, Creator role will not be able to create their first receiver * Update routes permissions * Add further support for RBAC in contact points * Update routes permissions * Update contact points header logic * Back out test feature toggle refactor Not working atm, not sure why * Tidy up imports * Update mock permissions * Revert more test changes * Update i18n * Sync inuse metadata pr * Add back canAdmin permissions after main merge * Split out check for policies navtree item * Tidy up utils and imports and fix rules in use * Fix contact point tests and act warnings * Add missing ReceiverPermissionAdmin after merge conflict * Move contact points permissions * Only show contact points filter when permissions are correct * Move to constants * Fallback to empty array and remove labelSelectors (not needed) * Allow `toAbility` to take multiple actions * Show builtin alertmanager if contact points permission * Add empty state and hide templates if missing permissions * Translations * Tidy up mock data * Fix tests and templates permission * Update message for unused contact points * Don't return 403 when user lists receivers and has access to none * Fix receiver create not adding empty uid permissions * Move SetDefaultPermissions to ReceiverPermissionService * Have SetDefaultPermissions use uid from string Fixes circular dependency * Add FakeReceiverPermissionsService and fix test wiring * Implement resource permission handling in provisioning API and renames Create: Sets to default permissions Delete: Removes permissions Update: If receiver name is modified and the new name doesn't exist, it copies the permissions from the old receiver to the newly created one. If old receiver is now empty, it removes the old permissions as well. * Split contact point permissions checks for read/modify * Generalise getting annotation values from k8s entities * Proxy RouteDeleteAlertingConfig through MultiOrgAlertmanager * Cleanup permissions on config api reset and restore * Cleanup permissions on config api POST note this is still not available with feature flag enabled * Gate the permission manager behind FF until initial migration is added * Sync changes from config api PR * Switch to named export * Revert unnecessary changes * Revert Filter auth change and implement in k8s api only * Don't allow new scoped permissions to give access without FF Prevents complications around mixed support for the scoped permissions causing oddities in the UI. * Fix integration tests to account for list permission change * Move to `permissions` file * Add additional tests for contact points * Fix redirect for viewer on edit page * Combine alerting test utils and move to new file location * Allow new permissions to access provisioning export paths with FF * Always allow exporting if its grafana flavoured * Fix logic for showing auto generated policies * Fix delete logic for contact point only referenced by a rule * Suppress warning message when renaming a contact point * Clear team and role perm cache on receiver rename Prevents temporarily broken UI permissions after rename when a user's source of elevated permissions comes from a cached team or basic role permission. * Debug log failed cache clear on CopyPermissions --------- Co-authored-by: Matt Jacobson --- .betterer.results | 3 +- .../notifications/receiver/authorize.go | 4 +- .../notifications/receiver/legacy_storage.go | 19 ++- .../ossaccesscontrol/receivers.go | 9 +- pkg/services/navtree/navtreeimpl/navtree.go | 23 ++- pkg/services/ngalert/api/authorization.go | 20 ++- .../ngalert/api/authorization_test.go | 3 +- .../notifications/receivers/receiver_test.go | 14 +- .../core/components/AccessControl/types.ts | 7 +- public/app/features/alerting/routes.tsx | 10 ++ .../alerting/unified/api/alertingApi.ts | 14 +- .../alerting/unified/api/receiversK8sApi.ts | 5 + .../contact-points/ContactPointHeader.tsx | 142 ++++++++++++++-- .../contact-points/ContactPoints.test.tsx | 156 +++++++++++++++++- .../contact-points/ContactPoints.tsx | 61 +++++-- .../contact-points/components/UnusedBadge.tsx | 3 +- .../components/contact-points/permissions.ts | 25 +++ .../contact-points/useContactPoints.tsx | 9 +- .../components/contact-points/utils.ts | 5 + .../permissions/ManagePermissions.tsx | 71 ++++++++ .../components/receivers/EditReceiverView.tsx | 1 - .../receivers/NewReceiverView.test.tsx | 9 +- .../receivers/form/ChannelSubForm.tsx | 1 - .../receivers/form/GrafanaReceiverForm.tsx | 10 +- .../receivers/form/ReceiverForm.tsx | 31 +++- .../SimplifiedRuleEditor.test.tsx | 5 +- .../rules/Filter/RulesFilter.v1.tsx | 7 +- .../__snapshots__/useAbilities.test.tsx.snap | 2 +- .../alerting/unified/hooks/useAbilities.ts | 18 +- .../unified/mocks/server/all-handlers.ts | 2 + .../mocks/server/handlers/accessControl.ts | 67 ++++++++ .../server/handlers/k8s/receivers.k8s.ts | 9 +- .../alerting/unified/test/test-utils.ts | 49 ++++++ .../alerting/unified/utils/datasource.ts | 7 +- .../alerting/unified/utils/k8s/constants.ts | 21 ++- .../alerting/unified/utils/k8s/utils.ts | 30 +++- public/app/types/accessControl.ts | 7 + public/locales/en-US/grafana.json | 19 ++- public/locales/pseudo-LOCALE/grafana.json | 19 ++- 39 files changed, 809 insertions(+), 108 deletions(-) create mode 100644 public/app/features/alerting/unified/components/contact-points/permissions.ts create mode 100644 public/app/features/alerting/unified/components/permissions/ManagePermissions.tsx create mode 100644 public/app/features/alerting/unified/mocks/server/handlers/accessControl.ts create mode 100644 public/app/features/alerting/unified/test/test-utils.ts 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 canEdit = editSupported && editAllowed && !provisioned; - const canDelete = deleteSupported && deleteAllowed && !provisioned && !isReferencedByRegularPolicies; + 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; + + /** 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 && ( )} - {provisioned && ( - + {numberOfRules > 0 && ( + + {referencedByRulesText} + )} - {!isReferencedByAnyPolicy && } + {provisioned && ( + + )} + {!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 ľäŧęř.",