mirror of
https://github.com/grafana/grafana.git
synced 2025-01-08 23:23:45 -06:00
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:
parent
ef05596e07
commit
2fd7031102
@ -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`
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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",
|
||||
|
@ -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:*"
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -106,6 +106,9 @@ export class ContextSrv {
|
||||
}
|
||||
|
||||
hasAccessToExplore() {
|
||||
if (config.featureToggles['accesscontrol']) {
|
||||
return this.hasPermission(AccessControlAction.DataSourcesExplore);
|
||||
}
|
||||
return (this.isEditor || config.viewersCanEdit) && config.exploreEnabled;
|
||||
}
|
||||
}
|
||||
|
@ -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');
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
@ -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'];
|
||||
}
|
||||
};
|
||||
|
@ -33,4 +33,5 @@ export enum AccessControlAction {
|
||||
LDAPUsersRead = 'ldap.user:read',
|
||||
LDAPUsersSync = 'ldap.user:sync',
|
||||
LDAPStatusRead = 'ldap.status:read',
|
||||
DataSourcesExplore = 'datasources:explore',
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user