Переглянути джерело

Merge pull request #13702 from grafana/data-source-instance-to-react

Support Data source permissions
Marcus Efraimsson 7 роки тому
батько
коміт
da89c27caf

+ 3 - 3
pkg/api/api.go

@@ -234,13 +234,13 @@ func (hs *HTTPServer) registerRoutes() {
 			datasourceRoute.Get("/", Wrap(GetDataSources))
 			datasourceRoute.Post("/", quota("data_source"), bind(m.AddDataSourceCommand{}), Wrap(AddDataSource))
 			datasourceRoute.Put("/:id", bind(m.UpdateDataSourceCommand{}), Wrap(UpdateDataSource))
-			datasourceRoute.Delete("/:id", Wrap(DeleteDataSourceByID))
+			datasourceRoute.Delete("/:id", Wrap(DeleteDataSourceById))
 			datasourceRoute.Delete("/name/:name", Wrap(DeleteDataSourceByName))
-			datasourceRoute.Get("/:id", Wrap(GetDataSourceByID))
+			datasourceRoute.Get("/:id", Wrap(GetDataSourceById))
 			datasourceRoute.Get("/name/:name", Wrap(GetDataSourceByName))
 		}, reqOrgAdmin)
 
-		apiRoute.Get("/datasources/id/:name", Wrap(GetDataSourceIDByName), reqSignedIn)
+		apiRoute.Get("/datasources/id/:name", Wrap(GetDataSourceIdByName), reqSignedIn)
 
 		apiRoute.Get("/plugins", Wrap(GetPluginList))
 		apiRoute.Get("/plugins/:pluginId/settings", Wrap(GetPluginSettingByID))

+ 18 - 1
pkg/api/dataproxy.go

@@ -2,6 +2,7 @@ package api
 
 import (
 	"fmt"
+	"github.com/pkg/errors"
 	"time"
 
 	"github.com/grafana/grafana/pkg/api/pluginproxy"
@@ -14,6 +15,20 @@ import (
 const HeaderNameNoBackendCache = "X-Grafana-NoCache"
 
 func (hs *HTTPServer) getDatasourceFromCache(id int64, c *m.ReqContext) (*m.DataSource, error) {
+	userPermissionsQuery := m.GetDataSourcePermissionsForUserQuery{
+		User: c.SignedInUser,
+	}
+	if err := bus.Dispatch(&userPermissionsQuery); err != nil {
+		if err != bus.ErrHandlerNotFound {
+			return nil, err
+		}
+	} else {
+		permissionType, exists := userPermissionsQuery.Result[id]
+		if exists && permissionType != m.DsPermissionQuery {
+			return nil, errors.New("User not allowed to access datasource")
+		}
+	}
+
 	nocache := c.Req.Header.Get(HeaderNameNoBackendCache) == "true"
 	cacheKey := fmt.Sprintf("ds-%d", id)
 
@@ -38,7 +53,9 @@ func (hs *HTTPServer) getDatasourceFromCache(id int64, c *m.ReqContext) (*m.Data
 func (hs *HTTPServer) ProxyDataSourceRequest(c *m.ReqContext) {
 	c.TimeRequest(metrics.M_DataSource_ProxyReq_Timer)
 
-	ds, err := hs.getDatasourceFromCache(c.ParamsInt64(":id"), c)
+	dsId := c.ParamsInt64(":id")
+	ds, err := hs.getDatasourceFromCache(dsId, c)
+
 	if err != nil {
 		c.JsonApiErr(500, "Unable to load datasource meta data", err)
 		return

+ 7 - 7
pkg/api/datasources.go

@@ -20,8 +20,8 @@ func GetDataSources(c *m.ReqContext) Response {
 	result := make(dtos.DataSourceList, 0)
 	for _, ds := range query.Result {
 		dsItem := dtos.DataSourceListItemDTO{
-			Id:        ds.Id,
 			OrgId:     ds.OrgId,
+			Id:        ds.Id,
 			Name:      ds.Name,
 			Url:       ds.Url,
 			Type:      ds.Type,
@@ -49,7 +49,7 @@ func GetDataSources(c *m.ReqContext) Response {
 	return JSON(200, &result)
 }
 
-func GetDataSourceByID(c *m.ReqContext) Response {
+func GetDataSourceById(c *m.ReqContext) Response {
 	query := m.GetDataSourceByIdQuery{
 		Id:    c.ParamsInt64(":id"),
 		OrgId: c.OrgId,
@@ -68,14 +68,14 @@ func GetDataSourceByID(c *m.ReqContext) Response {
 	return JSON(200, &dtos)
 }
 
-func DeleteDataSourceByID(c *m.ReqContext) Response {
+func DeleteDataSourceById(c *m.ReqContext) Response {
 	id := c.ParamsInt64(":id")
 
 	if id <= 0 {
 		return Error(400, "Missing valid datasource id", nil)
 	}
 
-	ds, err := getRawDataSourceByID(id, c.OrgId)
+	ds, err := getRawDataSourceById(id, c.OrgId)
 	if err != nil {
 		return Error(400, "Failed to delete datasource", nil)
 	}
@@ -186,7 +186,7 @@ func fillWithSecureJSONData(cmd *m.UpdateDataSourceCommand) error {
 		return nil
 	}
 
-	ds, err := getRawDataSourceByID(cmd.Id, cmd.OrgId)
+	ds, err := getRawDataSourceById(cmd.Id, cmd.OrgId)
 	if err != nil {
 		return err
 	}
@@ -206,7 +206,7 @@ func fillWithSecureJSONData(cmd *m.UpdateDataSourceCommand) error {
 	return nil
 }
 
-func getRawDataSourceByID(id int64, orgID int64) (*m.DataSource, error) {
+func getRawDataSourceById(id int64, orgID int64) (*m.DataSource, error) {
 	query := m.GetDataSourceByIdQuery{
 		Id:    id,
 		OrgId: orgID,
@@ -236,7 +236,7 @@ func GetDataSourceByName(c *m.ReqContext) Response {
 }
 
 // Get /api/datasources/id/:name
-func GetDataSourceIDByName(c *m.ReqContext) Response {
+func GetDataSourceIdByName(c *m.ReqContext) Response {
 	query := m.GetDataSourceByNameQuery{Name: c.Params(":name"), OrgId: c.OrgId}
 
 	if err := bus.Dispatch(&query); err != nil {

+ 14 - 1
pkg/api/frontendsettings.go

@@ -22,7 +22,20 @@ func getFrontendSettingsMap(c *m.ReqContext) (map[string]interface{}, error) {
 			return nil, err
 		}
 
-		orgDataSources = query.Result
+		dsFilterQuery := m.DatasourcesPermissionFilterQuery{
+			User:        c.SignedInUser,
+			Datasources: query.Result,
+		}
+
+		if err := bus.Dispatch(&dsFilterQuery); err != nil {
+			if err != bus.ErrHandlerNotFound {
+				return nil, err
+			}
+
+			orgDataSources = query.Result
+		} else {
+			orgDataSources = dsFilterQuery.Result
+		}
 	}
 
 	datasources := make(map[string]interface{})

+ 29 - 2
pkg/models/datasource.go

@@ -30,6 +30,7 @@ var (
 	ErrDataSourceNameExists         = errors.New("Data source with same name already exists")
 	ErrDataSourceUpdatingOldVersion = errors.New("Trying to update old version of datasource")
 	ErrDatasourceIsReadOnly         = errors.New("Data source is readonly. Can only be updated from configuration.")
+	ErrDataSourceAccessDenied       = errors.New("Data source access denied")
 )
 
 type DsAccess string
@@ -167,6 +168,7 @@ type DeleteDataSourceByNameCommand struct {
 
 type GetDataSourcesQuery struct {
 	OrgId  int64
+	User   *SignedInUser
 	Result []*DataSource
 }
 
@@ -187,6 +189,31 @@ type GetDataSourceByNameQuery struct {
 }
 
 // ---------------------
-// EVENTS
-type DataSourceCreatedEvent struct {
+//  Permissions
+// ---------------------
+
+type DsPermissionType int
+
+const (
+	DsPermissionNoAccess DsPermissionType = iota
+	DsPermissionQuery
+)
+
+func (p DsPermissionType) String() string {
+	names := map[int]string{
+		int(DsPermissionQuery):    "Query",
+		int(DsPermissionNoAccess): "No Access",
+	}
+	return names[int(p)]
+}
+
+type GetDataSourcePermissionsForUserQuery struct {
+	User   *SignedInUser
+	Result map[int64]DsPermissionType
+}
+
+type DatasourcesPermissionFilterQuery struct {
+	User        *SignedInUser
+	Datasources []*DataSource
+	Result      []*DataSource
 }

+ 1 - 0
pkg/services/sqlstore/datasource.go

@@ -27,6 +27,7 @@ func GetDataSourceById(query *m.GetDataSourceByIdQuery) error {
 
 	datasource := m.DataSource{OrgId: query.OrgId, Id: query.Id}
 	has, err := x.Get(&datasource)
+
 	if err != nil {
 		return err
 	}

+ 9 - 2
pkg/services/sqlstore/sqlstore.go

@@ -53,6 +53,7 @@ type SqlStore struct {
 	dbCfg           DatabaseConfig
 	engine          *xorm.Engine
 	log             log.Logger
+	Dialect         migrator.Dialect
 	skipEnsureAdmin bool
 }
 
@@ -125,10 +126,12 @@ func (ss *SqlStore) Init() error {
 	}
 
 	ss.engine = engine
+	ss.Dialect = migrator.NewDialect(ss.engine)
 
 	// temporarily still set global var
 	x = engine
-	dialect = migrator.NewDialect(x)
+	dialect = ss.Dialect
+
 	migrator := migrator.NewMigrator(x)
 	migrations.AddMigrations(migrator)
 
@@ -347,7 +350,11 @@ func InitTestDB(t *testing.T) *SqlStore {
 		t.Fatalf("Failed to init test database: %v", err)
 	}
 
-	dialect = migrator.NewDialect(engine)
+	sqlstore.Dialect = migrator.NewDialect(engine)
+
+	// temp global var until we get rid of global vars
+	dialect = sqlstore.Dialect
+
 	if err := dialect.CleanDB(); err != nil {
 		t.Fatalf("Failed to clean test db %v", err)
 	}

+ 4 - 0
public/app/core/components/PermissionList/AddPermission.tsx

@@ -18,6 +18,10 @@ export interface Props {
 }
 
 class AddPermissions extends Component<Props, NewDashboardAclItem> {
+  static defaultProps = {
+    showPermissionLevels: true,
+  };
+
   constructor(props) {
     super(props);
     this.state = this.getCleanState();

+ 0 - 4
public/app/core/components/Picker/DescriptionPicker.tsx

@@ -22,10 +22,6 @@ export interface Props {
 const getSelectedOption = (optionsWithDesc, value) => optionsWithDesc.find(option => option.value === value);
 
 class DescriptionPicker extends Component<Props, any> {
-  constructor(props) {
-    super(props);
-  }
-
   render() {
     const { optionsWithDesc, onSelected, disabled, className, value } = this.props;
     const selectedOption = getSelectedOption(optionsWithDesc, value);

+ 125 - 0
public/app/features/datasources/DataSourceSettings.tsx

@@ -0,0 +1,125 @@
+import React, { PureComponent } from 'react';
+import { connect } from 'react-redux';
+import { DataSource, Plugin } from 'app/types';
+
+export interface Props {
+  dataSource: DataSource;
+  dataSourceMeta: Plugin;
+}
+interface State {
+  name: string;
+}
+
+enum DataSourceStates {
+  Alpha = 'alpha',
+  Beta = 'beta',
+}
+
+export class DataSourceSettings extends PureComponent<Props, State> {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      name: props.dataSource.name,
+    };
+  }
+
+  onNameChange = event => {
+    this.setState({
+      name: event.target.value,
+    });
+  };
+
+  onSubmit = event => {
+    event.preventDefault();
+    console.log(event);
+  };
+
+  onDelete = event => {
+    console.log(event);
+  };
+
+  isReadyOnly() {
+    return this.props.dataSource.readOnly === true;
+  }
+
+  shouldRenderInfoBox() {
+    const { state } = this.props.dataSourceMeta;
+
+    return state === DataSourceStates.Alpha || state === DataSourceStates.Beta;
+  }
+
+  getInfoText() {
+    const { dataSourceMeta } = this.props;
+
+    switch (dataSourceMeta.state) {
+      case DataSourceStates.Alpha:
+        return (
+          'This plugin is marked as being in alpha state, which means it is in early development phase and updates' +
+          ' will include breaking changes.'
+        );
+
+      case DataSourceStates.Beta:
+        return (
+          'This plugin is marked as being in a beta development state. This means it is in currently in active' +
+          ' development and could be missing important features.'
+        );
+    }
+
+    return null;
+  }
+
+  render() {
+    const { name } = this.state;
+
+    return (
+      <div>
+        <h3 className="page-sub-heading">Settings</h3>
+        <form onSubmit={this.onSubmit}>
+          <div className="gf-form-group">
+            <div className="gf-form-inline">
+              <div className="gf-form max-width-30">
+                <span className="gf-form-label width-10">Name</span>
+                <input
+                  className="gf-form-input max-width-23"
+                  type="text"
+                  value={name}
+                  placeholder="name"
+                  onChange={this.onNameChange}
+                  required
+                />
+              </div>
+            </div>
+          </div>
+          {this.shouldRenderInfoBox() && <div className="grafana-info-box">{this.getInfoText()}</div>}
+          {this.isReadyOnly() && (
+            <div className="grafana-info-box span8">
+              This datasource was added by config and cannot be modified using the UI. Please contact your server admin
+              to update this datasource.
+            </div>
+          )}
+          <div className="gf-form-button-row">
+            <button type="submit" className="btn btn-success" disabled={this.isReadyOnly()} onClick={this.onSubmit}>
+              Save &amp; Test
+            </button>
+            <button type="submit" className="btn btn-danger" disabled={this.isReadyOnly()} onClick={this.onDelete}>
+              Delete
+            </button>
+            <a className="btn btn-inverse" href="datasources">
+              Back
+            </a>
+          </div>
+        </form>
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  return {
+    dataSource: state.dataSources.dataSource,
+    dataSourceMeta: state.dataSources.dataSourceMeta,
+  };
+}
+
+export default connect(mapStateToProps)(DataSourceSettings);

+ 0 - 3
public/app/features/datasources/NewDataSourcePage.tsx

@@ -4,7 +4,6 @@ import { hot } from 'react-hot-loader';
 import PageHeader from 'app/core/components/PageHeader/PageHeader';
 import { NavModel, Plugin } from 'app/types';
 import { addDataSource, loadDataSourceTypes, setDataSourceTypeSearchQuery } from './state/actions';
-import { updateLocation } from '../../core/actions';
 import { getNavModel } from 'app/core/selectors/navModel';
 import { getDataSourceTypes } from './state/selectors';
 
@@ -13,7 +12,6 @@ export interface Props {
   dataSourceTypes: Plugin[];
   addDataSource: typeof addDataSource;
   loadDataSourceTypes: typeof loadDataSourceTypes;
-  updateLocation: typeof updateLocation;
   dataSourceTypeSearchQuery: string;
   setDataSourceTypeSearchQuery: typeof setDataSourceTypeSearchQuery;
 }
@@ -81,7 +79,6 @@ function mapStateToProps(state) {
 const mapDispatchToProps = {
   addDataSource,
   loadDataSourceTypes,
-  updateLocation,
   setDataSourceTypeSearchQuery,
 };
 

+ 38 - 2
public/app/features/datasources/state/actions.ts

@@ -2,12 +2,15 @@ import { ThunkAction } from 'redux-thunk';
 import { DataSource, Plugin, StoreState } from 'app/types';
 import { getBackendSrv } from '../../../core/services/backend_srv';
 import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector';
-import { updateLocation } from '../../../core/actions';
+import { updateLocation, updateNavIndex, UpdateNavIndexAction } from '../../../core/actions';
 import { UpdateLocationAction } from '../../../core/actions/location';
+import { buildNavModel } from './navModel';
 
 export enum ActionTypes {
   LoadDataSources = 'LOAD_DATA_SOURCES',
   LoadDataSourceTypes = 'LOAD_DATA_SOURCE_TYPES',
+  LoadDataSource = 'LOAD_DATA_SOURCE',
+  LoadDataSourceMeta = 'LOAD_DATA_SOURCE_META',
   SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY',
   SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE',
   SetDataSourceTypeSearchQuery = 'SET_DATA_SOURCE_TYPE_SEARCH_QUERY',
@@ -38,11 +41,31 @@ export interface SetDataSourceTypeSearchQueryAction {
   payload: string;
 }
 
+export interface LoadDataSourceAction {
+  type: ActionTypes.LoadDataSource;
+  payload: DataSource;
+}
+
+export interface LoadDataSourceMetaAction {
+  type: ActionTypes.LoadDataSourceMeta;
+  payload: Plugin;
+}
+
 const dataSourcesLoaded = (dataSources: DataSource[]): LoadDataSourcesAction => ({
   type: ActionTypes.LoadDataSources,
   payload: dataSources,
 });
 
+const dataSourceLoaded = (dataSource: DataSource): LoadDataSourceAction => ({
+  type: ActionTypes.LoadDataSource,
+  payload: dataSource,
+});
+
+const dataSourceMetaLoaded = (dataSourceMeta: Plugin): LoadDataSourceMetaAction => ({
+  type: ActionTypes.LoadDataSourceMeta,
+  payload: dataSourceMeta,
+});
+
 const dataSourceTypesLoaded = (dataSourceTypes: Plugin[]): LoadDataSourceTypesAction => ({
   type: ActionTypes.LoadDataSourceTypes,
   payload: dataSourceTypes,
@@ -69,7 +92,10 @@ export type Action =
   | SetDataSourcesLayoutModeAction
   | UpdateLocationAction
   | LoadDataSourceTypesAction
-  | SetDataSourceTypeSearchQueryAction;
+  | SetDataSourceTypeSearchQueryAction
+  | LoadDataSourceAction
+  | UpdateNavIndexAction
+  | LoadDataSourceMetaAction;
 
 type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
 
@@ -80,6 +106,16 @@ export function loadDataSources(): ThunkResult<void> {
   };
 }
 
+export function loadDataSource(id: number): ThunkResult<void> {
+  return async dispatch => {
+    const dataSource = await getBackendSrv().get(`/api/datasources/${id}`);
+    const pluginInfo = await getBackendSrv().get(`/api/plugins/${dataSource.type}/settings`);
+    dispatch(dataSourceLoaded(dataSource));
+    dispatch(dataSourceMetaLoaded(pluginInfo));
+    dispatch(updateNavIndex(buildNavModel(dataSource, pluginInfo)));
+  };
+}
+
 export function addDataSource(plugin: Plugin): ThunkResult<void> {
   return async (dispatch, getStore) => {
     await dispatch(loadDataSources());

+ 109 - 0
public/app/features/datasources/state/navModel.ts

@@ -0,0 +1,109 @@
+import { DataSource, NavModel, NavModelItem, PluginMeta } from 'app/types';
+import config from 'app/core/config';
+
+export function buildNavModel(dataSource: DataSource, pluginMeta: PluginMeta): NavModelItem {
+  const navModel = {
+    img: pluginMeta.info.logos.large,
+    id: 'datasource-' + dataSource.id,
+    subTitle: `Type: ${pluginMeta.name}`,
+    url: '',
+    text: dataSource.name,
+    breadcrumbs: [{ title: 'Data Sources', url: 'datasources' }],
+    children: [
+      {
+        active: false,
+        icon: 'fa fa-fw fa-sliders',
+        id: `datasource-settings-${dataSource.id}`,
+        text: 'Settings',
+        url: `datasources/edit/${dataSource.id}`,
+      },
+    ],
+  };
+
+  if (pluginMeta.includes && hasDashboards(pluginMeta.includes)) {
+    navModel.children.push({
+      active: false,
+      icon: 'fa fa-fw fa-th-large',
+      id: `datasource-dashboards-${dataSource.id}`,
+      text: 'Dashboards',
+      url: `datasources/edit/${dataSource.id}/dashboards`,
+    });
+  }
+
+  if (config.buildInfo.isEnterprise) {
+    navModel.children.push({
+      active: false,
+      icon: 'fa fa-fw fa-lock',
+      id: `datasource-permissions-${dataSource.id}`,
+      text: 'Permissions',
+      url: `datasources/edit/${dataSource.id}/permissions`,
+    });
+  }
+
+  return navModel;
+}
+
+export function getDataSourceLoadingNav(pageName: string): NavModel {
+  const main = buildNavModel(
+    {
+      access: '',
+      basicAuth: false,
+      database: '',
+      id: 1,
+      isDefault: false,
+      jsonData: { authType: 'credentials', defaultRegion: 'eu-west-2' },
+      name: 'Loading',
+      orgId: 1,
+      password: '',
+      readOnly: false,
+      type: 'Loading',
+      typeLogoUrl: 'public/img/icn-datasource.svg',
+      url: '',
+      user: '',
+    },
+    {
+      id: '1',
+      name: '',
+      info: {
+        author: {
+          name: '',
+          url: '',
+        },
+        description: '',
+        links: [''],
+        logos: {
+          large: '',
+          small: '',
+        },
+        screenshots: '',
+        updated: '',
+        version: '',
+      },
+      includes: [{ type: '', name: '', path: '' }],
+    }
+  );
+
+  let node: NavModelItem;
+
+  // find active page
+  for (const child of main.children) {
+    if (child.id.indexOf(pageName) > 0) {
+      child.active = true;
+      node = child;
+      break;
+    }
+  }
+
+  return {
+    main: main,
+    node: node,
+  };
+}
+
+function hasDashboards(includes) {
+  return (
+    includes.filter(include => {
+      return include.type === 'dashboard';
+    }).length > 0
+  );
+}

+ 8 - 0
public/app/features/datasources/state/reducers.ts

@@ -4,11 +4,13 @@ import { LayoutModes } from '../../../core/components/LayoutSelector/LayoutSelec
 
 const initialState: DataSourcesState = {
   dataSources: [] as DataSource[],
+  dataSource: {} as DataSource,
   layoutMode: LayoutModes.Grid,
   searchQuery: '',
   dataSourcesCount: 0,
   dataSourceTypes: [] as Plugin[],
   dataSourceTypeSearchQuery: '',
+  dataSourceMeta: {} as Plugin,
   hasFetched: false,
 };
 
@@ -17,6 +19,9 @@ export const dataSourcesReducer = (state = initialState, action: Action): DataSo
     case ActionTypes.LoadDataSources:
       return { ...state, hasFetched: true, dataSources: action.payload, dataSourcesCount: action.payload.length };
 
+    case ActionTypes.LoadDataSource:
+      return { ...state, dataSource: action.payload };
+
     case ActionTypes.SetDataSourcesSearchQuery:
       return { ...state, searchQuery: action.payload };
 
@@ -28,6 +33,9 @@ export const dataSourcesReducer = (state = initialState, action: Action): DataSo
 
     case ActionTypes.SetDataSourceTypeSearchQuery:
       return { ...state, dataSourceTypeSearchQuery: action.payload };
+
+    case ActionTypes.LoadDataSourceMeta:
+      return { ...state, dataSourceMeta: action.payload };
   }
 
   return state;

+ 9 - 0
public/app/features/datasources/state/selectors.ts

@@ -1,3 +1,5 @@
+import { DataSource } from '../../../types';
+
 export const getDataSources = state => {
   const regex = new RegExp(state.searchQuery, 'i');
 
@@ -14,6 +16,13 @@ export const getDataSourceTypes = state => {
   });
 };
 
+export const getDataSource = (state, dataSourceId): DataSource | null => {
+  if (state.dataSource.id === parseInt(dataSourceId, 10)) {
+    return state.dataSource;
+  }
+  return null;
+};
+
 export const getDataSourcesSearchQuery = state => state.searchQuery;
 export const getDataSourcesLayoutMode = state => state.layoutMode;
 export const getDataSourcesCount = state => state.dataSourcesCount;

+ 4 - 4
public/app/features/explore/PromQueryField.tsx

@@ -571,10 +571,10 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
               <button className="btn navbar-button navbar-button--tight">Log labels</button>
             </Cascader>
           ) : (
-              <Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
-                <button className="btn navbar-button navbar-button--tight">Metrics</button>
-              </Cascader>
-            )}
+            <Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
+              <button className="btn navbar-button navbar-button--tight">Metrics</button>
+            </Cascader>
+          )}
         </div>
         <div className="prom-query-field-wrapper">
           <div className="slate-query-field-wrapper">

+ 11 - 0
public/app/features/plugins/state/navModel.ts

@@ -1,5 +1,6 @@
 import _ from 'lodash';
 import { DataSource, PluginMeta, NavModel } from 'app/types';
+import config from 'app/core/config';
 
 export function buildNavModel(ds: DataSource, plugin: PluginMeta, currentPage: string): NavModel {
   let title = 'New';
@@ -38,6 +39,16 @@ export function buildNavModel(ds: DataSource, plugin: PluginMeta, currentPage: s
     });
   }
 
+  if (config.buildInfo.isEnterprise) {
+    main.children.push({
+      active: currentPage === 'datasource-permissions',
+      icon: 'fa fa-fw fa-lock',
+      id: 'datasource-permissions',
+      text: 'Permissions',
+      url: `datasources/edit/${ds.id}/permissions`,
+    });
+  }
+
   return {
     main: main,
     node: _.find(main.children, { active: true }),

+ 8 - 2
public/app/store/configureStore.ts

@@ -11,7 +11,7 @@ import pluginReducers from 'app/features/plugins/state/reducers';
 import dataSourcesReducers from 'app/features/datasources/state/reducers';
 import usersReducers from 'app/features/users/state/reducers';
 
-const rootReducer = combineReducers({
+const rootReducers = {
   ...sharedReducers,
   ...alertingReducers,
   ...teamsReducers,
@@ -21,13 +21,19 @@ const rootReducer = combineReducers({
   ...pluginReducers,
   ...dataSourcesReducers,
   ...usersReducers,
-});
+};
 
 export let store;
 
+export function addRootReducer(reducers) {
+  Object.assign(rootReducers, ...reducers);
+}
+
 export function configureStore() {
   const composeEnhancers = (window as any).__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
 
+  const rootReducer = combineReducers(rootReducers);
+
   if (process.env.NODE_ENV !== 'production') {
     // DEV builds we had the logger middleware
     store = createStore(rootReducer, {}, composeEnhancers(applyMiddleware(thunk, createLogger())));

+ 9 - 0
public/app/types/acl.ts

@@ -61,6 +61,11 @@ export enum PermissionLevel {
   Admin = 4,
 }
 
+export enum DataSourcePermissionLevel {
+  Query = 1,
+  Admin = 2,
+}
+
 export enum AclTarget {
   Team = 'Team',
   User = 'User',
@@ -73,6 +78,10 @@ export interface AclTargetInfo {
   text: string;
 }
 
+export const dataSourceAclLevels = [
+  { value: DataSourcePermissionLevel.Query, label: 'Query', description: 'Can query data source.' },
+];
+
 export const dashboardAclTargets: AclTargetInfo[] = [
   { value: AclTarget.Team, text: 'Team' },
   { value: AclTarget.User, text: 'User' },

+ 5 - 3
public/app/types/datasources.ts

@@ -12,10 +12,10 @@ export interface DataSource {
   password: string;
   user: string;
   database: string;
-  basicAuth: false;
-  isDefault: false;
+  basicAuth: boolean;
+  isDefault: boolean;
   jsonData: { authType: string; defaultRegion: string };
-  readOnly: false;
+  readOnly: boolean;
 }
 
 export interface DataSourcesState {
@@ -25,5 +25,7 @@ export interface DataSourcesState {
   layoutMode: LayoutMode;
   dataSourcesCount: number;
   dataSourceTypes: Plugin[];
+  dataSource: DataSource;
+  dataSourceMeta: Plugin;
   hasFetched: boolean;
 }