Access Control: hiding annotation edition and deletion without permissions (#46904)

* Access Control: disabling annotation edition without FGAC permissions
This commit is contained in:
Ezequiel Victorero 2022-04-04 11:57:43 -03:00 committed by GitHub
parent f8d11fbef9
commit 76b221e9d5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 168 additions and 57 deletions

View File

@ -30,6 +30,8 @@ export interface PanelContext {
onToggleSeriesVisibility?: (label: string, mode: SeriesVisibilityChangeMode) => void;
canAddAnnotations?: () => boolean;
canEditAnnotations?: (dashboardId: number) => boolean;
canDeleteAnnotations?: (dashboardId: number) => boolean;
onAnnotationCreate?: (annotation: AnnotationEventUIModel) => void;
onAnnotationUpdate?: (annotation: AnnotationEventUIModel) => void;
onAnnotationDelete?: (id: string) => void;

View File

@ -17,6 +17,7 @@ import (
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/alerting"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/guardian"
@ -115,25 +116,33 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response {
creator = hs.getUserLogin(c.Req.Context(), dash.CreatedBy)
}
annotationPermissions := &dtos.AnnotationPermission{}
if !hs.AccessControl.IsDisabled() {
hs.getAnnotationPermissionsByScope(c, &annotationPermissions.Dashboard, accesscontrol.ScopeAnnotationsTypeDashboard)
hs.getAnnotationPermissionsByScope(c, &annotationPermissions.Organization, accesscontrol.ScopeAnnotationsTypeOrganization)
}
meta := dtos.DashboardMeta{
IsStarred: isStarred,
Slug: dash.Slug,
Type: models.DashTypeDB,
CanStar: c.IsSignedIn,
CanSave: canSave,
CanEdit: canEdit,
CanAdmin: canAdmin,
CanDelete: canDelete,
Created: dash.Created,
Updated: dash.Updated,
UpdatedBy: updater,
CreatedBy: creator,
Version: dash.Version,
HasAcl: dash.HasAcl,
IsFolder: dash.IsFolder,
FolderId: dash.FolderId,
Url: dash.GetUrl(),
FolderTitle: "General",
IsStarred: isStarred,
Slug: dash.Slug,
Type: models.DashTypeDB,
CanStar: c.IsSignedIn,
CanSave: canSave,
CanEdit: canEdit,
CanAdmin: canAdmin,
CanDelete: canDelete,
Created: dash.Created,
Updated: dash.Updated,
UpdatedBy: updater,
CreatedBy: creator,
Version: dash.Version,
HasAcl: dash.HasAcl,
IsFolder: dash.IsFolder,
FolderId: dash.FolderId,
Url: dash.GetUrl(),
FolderTitle: "General",
AnnotationsPermissions: annotationPermissions,
}
// lookup folder title
@ -190,6 +199,22 @@ func (hs *HTTPServer) GetDashboard(c *models.ReqContext) response.Response {
return response.JSON(200, dto)
}
func (hs *HTTPServer) getAnnotationPermissionsByScope(c *models.ReqContext, actions *dtos.AnnotationActions, scope string) {
var err error
evaluate := accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsDelete, scope)
actions.CanDelete, err = hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluate)
if err != nil {
hs.log.Warn("Failed to evaluate permission", "err", err, "action", accesscontrol.ActionAnnotationsDelete, "scope", scope)
}
evaluate = accesscontrol.EvalPermission(accesscontrol.ActionAnnotationsWrite, scope)
actions.CanEdit, err = hs.AccessControl.Evaluate(c.Req.Context(), c.SignedInUser, evaluate)
if err != nil {
hs.log.Warn("Failed to evaluate permission", "err", err, "action", accesscontrol.ActionAnnotationsWrite, "scope", scope)
}
}
func (hs *HTTPServer) getUserLogin(ctx context.Context, userID int64) string {
query := models.GetUserByIdQuery{Id: userID}
err := hs.SQLStore.GetUserById(ctx, &query)

View File

@ -118,10 +118,11 @@ func TestDashboardAPIEndpoint(t *testing.T) {
mockSQLStore.ExpectedDashboard = fakeDash
hs := &HTTPServer{
Cfg: setting.NewCfg(),
pluginStore: &fakePluginStore{},
SQLStore: mockSQLStore,
Features: featuremgmt.WithFeatures(),
Cfg: setting.NewCfg(),
pluginStore: &fakePluginStore{},
SQLStore: mockSQLStore,
AccessControl: accesscontrolmock.New(),
Features: featuremgmt.WithFeatures(),
}
hs.SQLStore = mockSQLStore
@ -224,6 +225,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
LibraryPanelService: &mockLibraryPanelService{},
LibraryElementService: &mockLibraryElementService{},
SQLStore: mockSQLStore,
AccessControl: accesscontrolmock.New(),
dashboardService: service.ProvideDashboardService(
cfg, dashboardStore, nil, features, accesscontrolmock.NewPermissionsServicesMock(),
),
@ -890,6 +892,7 @@ func TestDashboardAPIEndpoint(t *testing.T) {
LibraryElementService: &mockLibraryElementService{},
dashboardProvisioningService: mockDashboardProvisioningService{},
SQLStore: mockSQLStore,
AccessControl: accesscontrolmock.New(),
}
hs.callGetDashboard(sc)
@ -927,6 +930,7 @@ func getDashboardShouldReturn200WithConfig(t *testing.T, sc *scenarioContext, pr
LibraryElementService: &libraryElementsService,
SQLStore: sc.sqlStore,
ProvisioningService: provisioningService,
AccessControl: accesscontrolmock.New(),
dashboardProvisioningService: service.ProvideDashboardService(
cfg, dashboardStore, nil, features, accesscontrolmock.NewPermissionsServicesMock(),
),

View File

@ -7,31 +7,41 @@ import (
)
type DashboardMeta struct {
IsStarred bool `json:"isStarred,omitempty"`
IsHome bool `json:"isHome,omitempty"`
IsSnapshot bool `json:"isSnapshot,omitempty"`
Type string `json:"type,omitempty"`
CanSave bool `json:"canSave"`
CanEdit bool `json:"canEdit"`
CanAdmin bool `json:"canAdmin"`
CanStar bool `json:"canStar"`
CanDelete bool `json:"canDelete"`
Slug string `json:"slug"`
Url string `json:"url"`
Expires time.Time `json:"expires"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
UpdatedBy string `json:"updatedBy"`
CreatedBy string `json:"createdBy"`
Version int `json:"version"`
HasAcl bool `json:"hasAcl"`
IsFolder bool `json:"isFolder"`
FolderId int64 `json:"folderId"`
FolderUid string `json:"folderUid"`
FolderTitle string `json:"folderTitle"`
FolderUrl string `json:"folderUrl"`
Provisioned bool `json:"provisioned"`
ProvisionedExternalId string `json:"provisionedExternalId"`
IsStarred bool `json:"isStarred,omitempty"`
IsHome bool `json:"isHome,omitempty"`
IsSnapshot bool `json:"isSnapshot,omitempty"`
Type string `json:"type,omitempty"`
CanSave bool `json:"canSave"`
CanEdit bool `json:"canEdit"`
CanAdmin bool `json:"canAdmin"`
CanStar bool `json:"canStar"`
CanDelete bool `json:"canDelete"`
Slug string `json:"slug"`
Url string `json:"url"`
Expires time.Time `json:"expires"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
UpdatedBy string `json:"updatedBy"`
CreatedBy string `json:"createdBy"`
Version int `json:"version"`
HasAcl bool `json:"hasAcl"`
IsFolder bool `json:"isFolder"`
FolderId int64 `json:"folderId"`
FolderUid string `json:"folderUid"`
FolderTitle string `json:"folderTitle"`
FolderUrl string `json:"folderUrl"`
Provisioned bool `json:"provisioned"`
ProvisionedExternalId string `json:"provisionedExternalId"`
AnnotationsPermissions *AnnotationPermission `json:"annotationsPermissions"`
}
type AnnotationPermission struct {
Dashboard AnnotationActions `json:"dashboard"`
Organization AnnotationActions `json:"organization"`
}
type AnnotationActions struct {
CanEdit bool `json:"canEdit"`
CanDelete bool `json:"canDelete"`
}
type DashboardFullWithMeta struct {

View File

@ -27,7 +27,7 @@
<div class="gf-form-button-row">
<button type="submit" class="btn btn-primary" ng-click="ctrl.save()">Save</button>
<button ng-if="ctrl.event.id" type="submit" class="btn btn-danger" ng-click="ctrl.delete()">Delete</button>
<button ng-if="ctrl.event.id && ctrl.canDelete()" type="submit" class="btn btn-danger" ng-click="ctrl.delete()">Delete</button>
<a class="btn-text" ng-click="ctrl.close();">Cancel</a>
</div>
</div>

View File

@ -124,6 +124,7 @@ const alertEventAndAnnotationFields: AnnotationFieldInfo[] = [
{ key: 'data' as any },
{ key: 'panelId' },
{ key: 'alertId' },
{ key: 'dashboardId' },
];
export function getAnnotationsFromData(

View File

@ -38,6 +38,7 @@ import { deleteAnnotation, saveAnnotation, updateAnnotation } from '../../annota
import { getDashboardQueryRunner } from '../../query/state/DashboardQueryRunner/DashboardQueryRunner';
import { liveTimer } from './liveTimer';
import { isSoloRoute } from '../../../routes/utils';
import { contextSrv } from '../../../core/services/context_srv';
const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
@ -90,11 +91,39 @@ export class PanelChrome extends PureComponent<Props, State> {
canAddAnnotations: () => Boolean(props.dashboard.meta.canEdit || props.dashboard.meta.canMakeEditable),
onInstanceStateChange: this.onInstanceStateChange,
onToggleLegendSort: this.onToggleLegendSort,
canEditAnnotations: this.canEditAnnotation,
canDeleteAnnotations: this.canDeleteAnnotation,
},
data: this.getInitialPanelDataState(),
};
}
canEditAnnotation = (dashboardId: number) => {
let canEdit = true;
if (contextSrv.accessControlEnabled()) {
if (dashboardId !== 0) {
canEdit = !!this.props.dashboard.meta.annotationsPermissions?.dashboard.canEdit;
} else {
canEdit = !!this.props.dashboard.meta.annotationsPermissions?.organization.canEdit;
}
}
return canEdit && Boolean(this.props.dashboard.meta.canEdit || this.props.dashboard.meta.canMakeEditable);
};
canDeleteAnnotation = (dashboardId: number) => {
let canDelete = true;
if (contextSrv.accessControlEnabled()) {
if (dashboardId !== 0) {
canDelete = !!this.props.dashboard.meta.annotationsPermissions?.dashboard.canDelete;
} else {
canDelete = !!this.props.dashboard.meta.annotationsPermissions?.organization.canDelete;
}
}
return canDelete && Boolean(this.props.dashboard.meta.canEdit || this.props.dashboard.meta.canMakeEditable);
};
// Due to a mutable panel model we get the sync settings via function that proactively reads from the model
getSync = () => (this.props.isEditing ? DashboardCursorSync.Off : this.props.dashboard.graphTooltip);

View File

@ -1178,6 +1178,20 @@ export class DashboardModel implements TimeModel {
return this.getVariablesFromState(this.uid);
};
canEditAnnotations(dashboardId: number) {
let canEdit = true;
// if FGAC is enabled there are additional conditions to check
if (contextSrv.accessControlEnabled()) {
if (dashboardId === 0) {
canEdit = !!this.meta.annotationsPermissions?.organization.canEdit;
} else {
canEdit = !!this.meta.annotationsPermissions?.dashboard.canEdit;
}
}
return this.canAddAnnotations() && canEdit;
}
canAddAnnotations() {
return this.meta.canEdit || this.meta.canMakeEditable;
}

View File

@ -59,7 +59,7 @@ export function annotationTooltipDirective(
`;
// Show edit icon only for users with at least Editor role
if (event.id && dashboard?.canAddAnnotations()) {
if (event.id && dashboard?.canEditAnnotations(event.dashboardId)) {
header += `
<span class="pointer graph-annotation__edit-icon" ng-click="onEdit()">
<i class="fa fa-pencil-square"></i>

View File

@ -4,6 +4,7 @@ import { AnnotationEvent, dateTime } from '@grafana/data';
import { MetricsPanelCtrl } from 'app/angular/panel/metrics_panel_ctrl';
import { deleteAnnotation, saveAnnotation, updateAnnotation } from '../../../features/annotations/api';
import { getDashboardQueryRunner } from '../../../features/query/state/DashboardQueryRunner/DashboardQueryRunner';
import { contextSrv } from '../../../core/services/context_srv';
export class EventEditorCtrl {
// @ts-ignore initialized through Angular not constructor
@ -31,6 +32,16 @@ export class EventEditorCtrl {
this.timeFormated = this.panelCtrl.dashboard.formatDate(this.event.time!);
}
canDelete(): boolean {
if (contextSrv.accessControlEnabled()) {
if (this.event.source.type === 'dashboard') {
return !!this.panelCtrl.dashboard.meta.annotationsPermissions?.dashboard.canDelete;
}
return !!this.panelCtrl.dashboard.meta.annotationsPermissions?.organization.canDelete;
}
return true;
}
async save(): Promise<void> {
if (!this.form.$valid) {
return;

View File

@ -27,7 +27,7 @@ const POPPER_CONFIG = {
};
export function AnnotationMarker({ annotation, timeZone, style }: Props) {
const { canAddAnnotations, ...panelCtx } = usePanelContext();
const { canAddAnnotations, canEditAnnotations, canDeleteAnnotations, ...panelCtx } = usePanelContext();
const commonStyles = useStyles2(getCommonAnnotationStyles);
const styles = useStyles2(getStyles);
@ -89,10 +89,11 @@ export function AnnotationMarker({ annotation, timeZone, style }: Props) {
timeFormatter={timeFormatter}
onEdit={onAnnotationEdit}
onDelete={onAnnotationDelete}
editable={Boolean(canAddAnnotations && canAddAnnotations())}
canEdit={canEditAnnotations!(annotation.dashboardId)}
canDelete={canDeleteAnnotations!(annotation.dashboardId)}
/>
);
}, [canAddAnnotations, onAnnotationDelete, onAnnotationEdit, timeFormatter, annotation]);
}, [canEditAnnotations, canDeleteAnnotations, onAnnotationDelete, onAnnotationEdit, timeFormatter, annotation]);
const isRegionAnnotation = Boolean(annotation.isRegion);

View File

@ -10,7 +10,8 @@ import config from 'app/core/config';
interface AnnotationTooltipProps {
annotation: AnnotationsDataFrameViewDTO;
timeFormatter: (v: number) => string;
editable: boolean;
canEdit: boolean;
canDelete: boolean;
onEdit: () => void;
onDelete: () => void;
}
@ -18,7 +19,8 @@ interface AnnotationTooltipProps {
export const AnnotationTooltip = ({
annotation,
timeFormatter,
editable,
canEdit,
canDelete,
onEdit,
onDelete,
}: AnnotationTooltipProps) => {
@ -51,11 +53,11 @@ export const AnnotationTooltip = ({
text = annotation.title + '<br />' + (typeof text === 'string' ? text : '');
}
if (editable) {
if (canEdit || canDelete) {
editControls = (
<div className={styles.editControls}>
<IconButton name={'pen'} size={'sm'} onClick={onEdit} />
<IconButton name={'trash-alt'} size={'sm'} onClick={onDelete} />
{canEdit && <IconButton name={'pen'} size={'sm'} onClick={onEdit} />}
{canDelete && <IconButton name={'trash-alt'} size={'sm'} onClick={onDelete} />}
</div>
);
}

View File

@ -1,5 +1,6 @@
interface AnnotationsDataFrameViewDTO {
id: string;
dashboardId: number;
time: number;
timeEnd: number;
text: string;

View File

@ -35,6 +35,17 @@ export interface DashboardMeta {
fromScript?: boolean;
fromFile?: boolean;
hasUnsavedFolderChange?: boolean;
annotationsPermissions?: AnnotationsPermissions;
}
export interface AnnotationActions {
canEdit: boolean;
canDelete: boolean;
}
export interface AnnotationsPermissions {
dashboard: AnnotationActions;
organization: AnnotationActions;
}
export interface DashboardDataDTO {