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
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:server:admin:read` | `server.stats:read` | Read server stats
`fixed:settings:admin:read` | `settings:read` | Read settings `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: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 ## 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. 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. 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: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 `settings:write` | `settings:**`<br>`settings:auth.saml:*`<br>`settings:auth.saml:enabled` (property level) | Update settings
`server.stats:read` | n/a | Read server stats `server.stats:read` | n/a | Read server stats
`datasources:explore` | n/a | Enable explore
## Scope definitions ## Scope definitions

View File

@@ -94,7 +94,12 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/dashboards/*", reqSignedIn, hs.Index) r.Get("/dashboards/*", reqSignedIn, hs.Index)
r.Get("/goto/:uid", reqSignedIn, hs.redirectFromShortURL, 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)
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, 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{ navTree = append(navTree, &dtos.NavLink{
Text: "Explore", Text: "Explore",
Id: "explore", Id: "explore",

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ const createProps = (propsOverride?: Partial<ComponentProps<typeof ReturnToDashb
const defaultProps = { const defaultProps = {
originPanelId: 1, originPanelId: 1,
splitted: false, splitted: false,
canEdit: true,
exploreId: ExploreId.left, exploreId: ExploreId.left,
queries: [], queries: [],
setDashboardQueriesToUpdateOnLoad: jest.fn(), setDashboardQueriesToUpdateOnLoad: jest.fn(),
@@ -31,6 +32,11 @@ describe('ReturnToDashboardButton', () => {
expect(screen.queryByTestId(/returnButton/i)).toBeNull(); 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', () => { it('should show option to return to dashboard with changes', () => {
render(<ReturnToDashboardButton {...createProps()} />); render(<ReturnToDashboardButton {...createProps()} />);
const returnWithChangesButton = screen.getByTestId('returnButtonWithChanges'); 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 { DataQuery, urlUtil } from '@grafana/data';
import kbn from '../../core/utils/kbn'; import kbn from '../../core/utils/kbn';
import config from 'app/core/config';
import { getDashboardSrv } from '../dashboard/services/DashboardSrv'; import { getDashboardSrv } from '../dashboard/services/DashboardSrv';
import { StoreState } from 'app/types'; import { StoreState } from 'app/types';
import { ExploreId } from 'app/types/explore'; import { ExploreId } from 'app/types/explore';
import { setDashboardQueriesToUpdateOnLoad } from '../dashboard/state/reducers'; import { setDashboardQueriesToUpdateOnLoad } from '../dashboard/state/reducers';
import { isSplit } from './state/selectors'; import { isSplit } from './state/selectors';
import { locationService } from '@grafana/runtime'; import { locationService } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) { function mapStateToProps(state: StoreState, { exploreId }: { exploreId: ExploreId }) {
const explore = state.explore; const explore = state.explore;
const splitted = isSplit(state); const splitted = isSplit(state);
const { datasourceInstance, queries, originPanelId } = explore[exploreId]!; const { datasourceInstance, queries, originPanelId } = explore[exploreId]!;
const roles = ['Editor', 'Admin'];
if (config.viewersCanEdit) {
roles.push('Viewer');
}
return { return {
exploreId, exploreId,
datasourceInstance, datasourceInstance,
queries, queries,
originPanelId, originPanelId,
splitted, splitted,
canEdit: roles.some((r) => contextSrv.hasRole(r)),
}; };
} }
@@ -37,6 +45,7 @@ export const UnconnectedReturnToDashboardButton: FC<Props> = ({
setDashboardQueriesToUpdateOnLoad, setDashboardQueriesToUpdateOnLoad,
queries, queries,
splitted, splitted,
canEdit,
}) => { }) => {
const withOriginId = originPanelId && Number.isInteger(originPanelId); const withOriginId = originPanelId && Number.isInteger(originPanelId);
@@ -87,11 +96,13 @@ export const UnconnectedReturnToDashboardButton: FC<Props> = ({
<Icon name="arrow-left" /> <Icon name="arrow-left" />
</ToolbarButton> </ToolbarButton>
</Tooltip> </Tooltip>
{canEdit && (
<ButtonSelect <ButtonSelect
data-testid="returnButtonWithChanges" data-testid="returnButtonWithChanges"
options={[{ label: 'Return to panel with changes', value: '' }]} options={[{ label: 'Return to panel with changes', value: '' }]}
onChange={() => returnToPanel({ withChanges: true })} onChange={() => returnToPanel({ withChanges: true })}
/> />
)}
</ButtonGroup> </ButtonGroup>
); );
}; };

View File

@@ -10,6 +10,7 @@ import {
TimeRange, TimeRange,
} from '@grafana/data'; } from '@grafana/data';
import { setLinkSrv } from '../../panel/panellinks/link_srv'; import { setLinkSrv } from '../../panel/panellinks/link_srv';
import { setContextSrv } from '../../../core/services/context_srv';
describe('getFieldLinksForExplore', () => { describe('getFieldLinksForExplore', () => {
it('returns correct link model for external link', () => { it('returns correct link model for external link', () => {
@@ -62,9 +63,40 @@ describe('getFieldLinksForExplore', () => {
range, 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');
}); });
function setup(link: DataLink) { 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, hasAccess = true) {
setLinkSrv({ setLinkSrv({
getDataLinkUIModel(link: DataLink, replaceVariables: InterpolateFunction | undefined, origin: any): LinkModel<any> { getDataLinkUIModel(link: DataLink, replaceVariables: InterpolateFunction | undefined, origin: any): LinkModel<any> {
return { return {
@@ -82,6 +114,10 @@ function setup(link: DataLink) {
}, },
}); });
setContextSrv({
hasAccessToExplore: () => hasAccess,
} as any);
const field: Field<string> = { const field: Field<string> = {
name: 'flux-dimensions', name: 'flux-dimensions',
type: FieldType.string, type: FieldType.string,

View File

@@ -12,6 +12,7 @@ import {
import { getTemplateSrv } from '@grafana/runtime'; import { getTemplateSrv } from '@grafana/runtime';
import { SplitOpen } from 'app/types/explore'; import { SplitOpen } from 'app/types/explore';
import { getLinkSrv } from '../../panel/panellinks/link_srv'; 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 * Get links from the field of a dataframe and in addition check if there is associated
@@ -52,8 +53,16 @@ export const getFieldLinksForExplore = (options: {
}; };
} }
return field.config.links if (field.config.links) {
? field.config.links.map((link) => { const links = [];
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) { if (!link.internal) {
const replace: InterpolateFunction = (value, vars) => const replace: InterpolateFunction = (value, vars) =>
getTemplateSrv().replace(value, { ...vars, ...scopedVars }); getTemplateSrv().replace(value, { ...vars, ...scopedVars });
@@ -74,8 +83,10 @@ export const getFieldLinksForExplore = (options: {
replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()), replaceVariables: getTemplateSrv().replace.bind(getTemplateSrv()),
}); });
} }
}) });
: []; }
return [];
}; };
function getTitleFromHref(href: string): string { 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 UserAdminPage from 'app/features/admin/UserAdminPage';
import { LoginPage } from 'app/core/components/Login/LoginPage'; import { LoginPage } from 'app/core/components/Login/LoginPage';
import config from 'app/core/config'; 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 { SafeDynamicImport } from '../core/components/DynamicImports/SafeDynamicImport';
import { RouteDescriptor } from '../core/navigation/types'; import { RouteDescriptor } from '../core/navigation/types';
import { Redirect } from 'react-router-dom'; import { Redirect } from 'react-router-dom';
import ErrorPage from 'app/core/components/ErrorPage/ErrorPage'; import ErrorPage from 'app/core/components/ErrorPage/ErrorPage';
import { contextSrv } from 'app/core/services/context_srv';
export const extraRoutes: RouteDescriptor[] = []; export const extraRoutes: RouteDescriptor[] = [];
@@ -135,7 +136,11 @@ export function getAppRoutes(): RouteDescriptor[] {
{ {
path: '/explore', path: '/explore',
pageClass: 'page-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')), component: SafeDynamicImport(() => import(/* webpackChunkName: "explore" */ 'app/features/explore/Wrapper')),
}, },
{ {
@@ -515,3 +520,16 @@ export function getAppRoutes(): RouteDescriptor[] {
// ...playlistRoutes, // ...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', LDAPUsersRead = 'ldap.user:read',
LDAPUsersSync = 'ldap.user:sync', LDAPUsersSync = 'ldap.user:sync',
LDAPStatusRead = 'ldap.status:read', LDAPStatusRead = 'ldap.status:read',
DataSourcesExplore = 'datasources:explore',
} }