Access Control: Add fine-grained access control to explore (#35883)

* add fixed role for datasource read operations

* Add action for datasource explore

* add authorize middleware to explore index route

* add fgac support for explore navlink

* update hasAccessToExplore to check if accesscontrol is enable and evalute action if it is

* add getExploreRoles to evalute roles based onaccesscontrol, viewersCanEdit and default

* create function to evaluate permissions or using fallback if accesscontrol is disabled

* change hasAccess to prop and derive the value in mapStateToProps

* add test case to ensure buttons is not rendered when user does not have access

* Only hide return with changes button

* remove internal links if user does not have access to explorer

Co-authored-by: Ivana Huckova <30407135+ivanahuckova@users.noreply.github.com>
This commit is contained in:
Karl Persson 2021-07-02 14:43:12 +02:00 committed by GitHub
parent ef05596e07
commit 2fd7031102
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 150 additions and 34 deletions

View File

@ -25,6 +25,7 @@ Fixed roles | Permissions | Descriptions
`fixed:server:admin:read` | `server.stats:read` | Read server stats
`fixed:settings:admin:read` | `settings:read` | Read settings
`fixed:settings:admin:edit` | All permissions from `fixed:settings:admin:read` and<br>`settings:write` | Update settings
`fixed:datasource:editor:read` | `datasources:explore` | Explore datasources
## Default built-in role assignments
@ -32,3 +33,4 @@ Built-in roles | Associated roles | Descriptions
--- | --- | ---
Grafana Admin | `fixed:permissions:admin:edit`<br>`fixed:permissions:admin:read`<br>`fixed:reporting:admin:edit`<br>`fixed:reporting:admin:read`<br>`fixed:users:admin:edit`<br>`fixed:users:admin:read`<br>`fixed:users:org:edit`<br>`fixed:users:org:read`<br>`fixed:ldap:admin:edit`<br>`fixed:ldap:admin:read`<br>`fixed:server:admin:read`<br>`fixed:settings:admin:read`<br>`fixed:settings:admin:edit` | Allows access to resources which [Grafana Server Admin]({{< relref "../../permissions/_index.md#grafana-server-admin-role" >}}) has permissions by default.
Admin | `fixed:users:org:edit`<br>`fixed:users:org:read`<br>`fixed:reporting:admin:edit`<br>`fixed:reporting:admin:read` | Allows access to resource which [Admin]({{< relref "../../permissions/organization_roles.md" >}}) has permissions by default.
Editor | `fixed:datasource:editor:read`

View File

@ -66,6 +66,7 @@ Actions | Applicable scopes | Descriptions
`settings:read` | `settings:**`<br>`settings:auth.saml:*`<br>`settings:auth.saml:enabled` (property level) | Read settings
`settings:write` | `settings:**`<br>`settings:auth.saml:*`<br>`settings:auth.saml:enabled` (property level) | Update settings
`server.stats:read` | n/a | Read server stats
`datasources:explore` | n/a | Enable explore
## Scope definitions

View File

@ -94,7 +94,12 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/dashboards/*", reqSignedIn, hs.Index)
r.Get("/goto/:uid", reqSignedIn, hs.redirectFromShortURL, hs.Index)
r.Get("/explore", reqSignedIn, middleware.EnsureEditorOrViewerCanEdit, hs.Index)
r.Get("/explore", authorize(func(c *models.ReqContext) {
if f, ok := reqSignedIn.(func(c *models.ReqContext)); ok {
f(c)
}
middleware.EnsureEditorOrViewerCanEdit(c)
}, accesscontrol.ActionDatasourcesExplore), hs.Index)
r.Get("/playlists/", reqSignedIn, hs.Index)
r.Get("/playlists/*", reqSignedIn, hs.Index)

View File

@ -186,7 +186,11 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto
Children: dashboardChildNavs,
})
if setting.ExploreEnabled && (c.OrgRole == models.ROLE_ADMIN || c.OrgRole == models.ROLE_EDITOR || setting.ViewersCanEdit) {
canExplore := func(context *models.ReqContext) bool {
return c.OrgRole == models.ROLE_ADMIN || c.OrgRole == models.ROLE_EDITOR || setting.ViewersCanEdit
}
if setting.ExploreEnabled && hasAccess(canExplore, ac.ActionDatasourcesExplore) {
navTree = append(navTree, &dtos.NavLink{
Text: "Explore",
Id: "explore",

View File

@ -42,7 +42,6 @@ func (p RoleDTO) Role() Role {
const (
// Permission actions
// Actions
// Provisioning actions
ActionProvisioningReload = "provisioning:reload"
@ -86,6 +85,9 @@ const (
// Settings actions
ActionSettingsRead = "settings:read"
// Datasources actions
ActionDatasourcesExplore = "datasources:explore"
// Global Scopes
ScopeGlobalUsersAll = "global:users:*"

View File

@ -2,6 +2,16 @@ package accesscontrol
import "github.com/grafana/grafana/pkg/models"
var datasourcesEditorReadRole = RoleDTO{
Version: 1,
Name: datasourcesEditorRead,
Permissions: []Permission{
{
Action: ActionDatasourcesExplore,
},
},
}
var ldapAdminReadRole = RoleDTO{
Name: ldapAdminRead,
Version: 1,
@ -166,7 +176,8 @@ var provisioningAdminRole = RoleDTO{
// resource. FixedRoleGrants lists which built-in roles are
// assigned which fixed roles in this list.
var FixedRoles = map[string]RoleDTO{
serverAdminRead: serverAdminReadRole,
datasourcesEditorRead: datasourcesEditorReadRole,
serverAdminRead: serverAdminReadRole,
settingsAdminRead: settingsAdminReadRole,
@ -183,6 +194,8 @@ var FixedRoles = map[string]RoleDTO{
}
const (
datasourcesEditorRead = "fixed:datasources:editor:read"
serverAdminRead = "fixed:server:admin:read"
settingsAdminRead = "fixed:settings:admin:read"
@ -217,6 +230,9 @@ var FixedRoleGrants = map[string][]string{
usersOrgEdit,
usersOrgRead,
},
string(models.ROLE_EDITOR): {
datasourcesEditorRead,
},
}
func ConcatPermissions(permissions ...[]Permission) []Permission {

View File

@ -106,6 +106,9 @@ export class ContextSrv {
}
hasAccessToExplore() {
if (config.featureToggles['accesscontrol']) {
return this.hasPermission(AccessControlAction.DataSourcesExplore);
}
return (this.isEditor || config.viewersCanEdit) && config.exploreEnabled;
}
}

View File

@ -7,6 +7,7 @@ const createProps = (propsOverride?: Partial<ComponentProps<typeof ReturnToDashb
const defaultProps = {
originPanelId: 1,
splitted: false,
canEdit: true,
exploreId: ExploreId.left,
queries: [],
setDashboardQueriesToUpdateOnLoad: jest.fn(),
@ -31,6 +32,11 @@ describe('ReturnToDashboardButton', () => {
expect(screen.queryByTestId(/returnButton/i)).toBeNull();
});
it('should not render return to panel with changes button if user cannot edit panel', () => {
render(<ReturnToDashboardButton {...createProps({ canEdit: false })} />);
expect(screen.getAllByTestId(/returnButton/i)).toHaveLength(1);
});
it('should show option to return to dashboard with changes', () => {
render(<ReturnToDashboardButton {...createProps()} />);
const returnWithChangesButton = screen.getByTestId('returnButtonWithChanges');

View File

@ -4,24 +4,32 @@ import { ButtonGroup, ButtonSelect, Icon, ToolbarButton, Tooltip } from '@grafan
import { DataQuery, urlUtil } from '@grafana/data';
import kbn from '../../core/utils/kbn';
import config from 'app/core/config';
import { getDashboardSrv } from '../dashboard/services/DashboardSrv';
import { StoreState } from 'app/types';
import { ExploreId } from 'app/types/explore';
import { setDashboardQueriesToUpdateOnLoad } from '../dashboard/state/reducers';
import { isSplit } from './state/selectors';
import { locationService } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) {
const explore = state.explore;
const splitted = isSplit(state);
const { datasourceInstance, queries, originPanelId } = explore[exploreId]!;
const roles = ['Editor', 'Admin'];
if (config.viewersCanEdit) {
roles.push('Viewer');
}
return {
exploreId,
datasourceInstance,
queries,
originPanelId,
splitted,
canEdit: roles.some((r) => contextSrv.hasRole(r)),
};
}
@ -37,6 +45,7 @@ export const UnconnectedReturnToDashboardButton: FC<Props> = ({
setDashboardQueriesToUpdateOnLoad,
queries,
splitted,
canEdit,
}) => {
const withOriginId = originPanelId && Number.isInteger(originPanelId);
@ -87,11 +96,13 @@ export const UnconnectedReturnToDashboardButton: FC<Props> = ({
<Icon name="arrow-left" />
</ToolbarButton>
</Tooltip>
<ButtonSelect
data-testid="returnButtonWithChanges"
options={[{ label: 'Return to panel with changes', value: '' }]}
onChange={() => returnToPanel({ withChanges: true })}
/>
{canEdit && (
<ButtonSelect
data-testid="returnButtonWithChanges"
options={[{ label: 'Return to panel with changes', value: '' }]}
onChange={() => returnToPanel({ withChanges: true })}
/>
)}
</ButtonGroup>
);
};

View File

@ -10,6 +10,7 @@ import {
TimeRange,
} from '@grafana/data';
import { setLinkSrv } from '../../panel/panellinks/link_srv';
import { setContextSrv } from '../../../core/services/context_srv';
describe('getFieldLinksForExplore', () => {
it('returns correct link model for external link', () => {
@ -62,9 +63,40 @@ describe('getFieldLinksForExplore', () => {
range,
});
});
it('returns correct link model for external link when user does not have access to explore', () => {
const { field, range } = setup(
{
title: 'external',
url: 'http://regionalhost',
},
false
);
const links = getFieldLinksForExplore({ field, rowIndex: 0, range });
expect(links[0].href).toBe('http://regionalhost');
expect(links[0].title).toBe('external');
});
it('returns no internal links if when user does not have access to explore', () => {
const { field, range } = setup(
{
title: '',
url: '',
internal: {
query: { query: 'query_1' },
datasourceUid: 'uid_1',
datasourceName: 'test_ds',
},
},
false
);
const links = getFieldLinksForExplore({ field, rowIndex: 0, range });
expect(links).toHaveLength(0);
});
});
function setup(link: DataLink) {
function setup(link: DataLink, hasAccess = true) {
setLinkSrv({
getDataLinkUIModel(link: DataLink, replaceVariables: InterpolateFunction | undefined, origin: any): LinkModel<any> {
return {
@ -82,6 +114,10 @@ function setup(link: DataLink) {
},
});
setContextSrv({
hasAccessToExplore: () => hasAccess,
} as any);
const field: Field<string> = {
name: 'flux-dimensions',
type: FieldType.string,

View File

@ -12,6 +12,7 @@ import {
import { getTemplateSrv } from '@grafana/runtime';
import { SplitOpen } from 'app/types/explore';
import { getLinkSrv } from '../../panel/panellinks/link_srv';
import { contextSrv } from 'app/core/services/context_srv';
/**
* Get links from the field of a dataframe and in addition check if there is associated
@ -52,30 +53,40 @@ export const getFieldLinksForExplore = (options: {
};
}
return field.config.links
? field.config.links.map((link) => {
if (!link.internal) {
const replace: InterpolateFunction = (value, vars) =>
getTemplateSrv().replace(value, { ...vars, ...scopedVars });
if (field.config.links) {
const links = [];
const linkModel = getLinkSrv().getDataLinkUIModel(link, replace, field);
if (!linkModel.title) {
linkModel.title = getTitleFromHref(linkModel.href);
}
return linkModel;
} else {
return mapInternalLinkToExplore({
link,
internalLink: link.internal,
scopedVars: scopedVars,
range,
field,
onClickFn: splitOpenFn,
replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()),
});
if (!contextSrv.hasAccessToExplore()) {
links.push(...field.config.links.filter((l) => !l.internal));
} else {
links.push(...field.config.links);
}
return links.map((link) => {
if (!link.internal) {
const replace: InterpolateFunction = (value, vars) =>
getTemplateSrv().replace(value, { ...vars, ...scopedVars });
const linkModel = getLinkSrv().getDataLinkUIModel(link, replace, field);
if (!linkModel.title) {
linkModel.title = getTitleFromHref(linkModel.href);
}
})
: [];
return linkModel;
} else {
return mapInternalLinkToExplore({
link,
internalLink: link.internal,
scopedVars: scopedVars,
range,
field,
onClickFn: splitOpenFn,
replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()),
});
}
});
}
return [];
};
function getTitleFromHref(href: string): string {

View File

@ -3,11 +3,12 @@ import LdapPage from 'app/features/admin/ldap/LdapPage';
import UserAdminPage from 'app/features/admin/UserAdminPage';
import { LoginPage } from 'app/core/components/Login/LoginPage';
import config from 'app/core/config';
import { DashboardRoutes } from 'app/types';
import { AccessControlAction, DashboardRoutes } from 'app/types';
import { SafeDynamicImport } from '../core/components/DynamicImports/SafeDynamicImport';
import { RouteDescriptor } from '../core/navigation/types';
import { Redirect } from 'react-router-dom';
import ErrorPage from 'app/core/components/ErrorPage/ErrorPage';
import { contextSrv } from 'app/core/services/context_srv';
export const extraRoutes: RouteDescriptor[] = [];
@ -135,7 +136,11 @@ export function getAppRoutes(): RouteDescriptor[] {
{
path: '/explore',
pageClass: 'page-explore',
roles: () => (config.viewersCanEdit ? [] : ['Editor', 'Admin']),
roles: () =>
evaluatePermission(
() => (config.viewersCanEdit ? [] : ['Editor', 'Admin']),
AccessControlAction.DataSourcesExplore
),
component: SafeDynamicImport(() => import(/* webpackChunkName: "explore" */ 'app/features/explore/Wrapper')),
},
{
@ -515,3 +520,16 @@ export function getAppRoutes(): RouteDescriptor[] {
// ...playlistRoutes,
];
}
// evaluates access control permission, using fallback if access control is disabled
const evaluatePermission = (fallback: () => string[], action: AccessControlAction): string[] => {
if (!config.featureToggles['accesscontrol']) {
return fallback();
}
if (contextSrv.hasPermission(action)) {
return [];
} else {
// Hack to reject when user does not have permission
return ['Reject'];
}
};

View File

@ -33,4 +33,5 @@ export enum AccessControlAction {
LDAPUsersRead = 'ldap.user:read',
LDAPUsersSync = 'ldap.user:sync',
LDAPStatusRead = 'ldap.status:read',
DataSourcesExplore = 'datasources:explore',
}