Merge pull request #2174 from influxdata/multitenancy_ui_role_authorization

Implement Role-based authorization UI for Viewer and Editor roles
pull/10616/head
Alex Paxton 2017-10-31 20:03:01 -07:00 committed by GitHub
commit 17561bd1d6
17 changed files with 409 additions and 164 deletions

89
ui/src/auth/Authorized.js Normal file
View File

@ -0,0 +1,89 @@
import React, {PropTypes} from 'react'
import {connect} from 'react-redux'
import _ from 'lodash'
export const VIEWER_ROLE = 'viewer'
export const EDITOR_ROLE = 'editor'
export const ADMIN_ROLE = 'admin'
export const SUPERADMIN_ROLE = 'superadmin'
export const isUserAuthorized = (meRole, requiredRole) => {
switch (requiredRole) {
case VIEWER_ROLE:
return (
meRole === VIEWER_ROLE ||
meRole === EDITOR_ROLE ||
meRole === ADMIN_ROLE ||
meRole === SUPERADMIN_ROLE
)
case EDITOR_ROLE:
return (
meRole === EDITOR_ROLE ||
meRole === ADMIN_ROLE ||
meRole === SUPERADMIN_ROLE
)
case ADMIN_ROLE:
return meRole === ADMIN_ROLE || meRole === SUPERADMIN_ROLE
case SUPERADMIN_ROLE:
return meRole === SUPERADMIN_ROLE
default:
return false
}
}
export const getMeRole = me => {
return _.get(_.first(_.get(me, 'roles', [])), 'name', 'none') // TODO: TBD if 'none' should be returned if none
}
const Authorized = ({
children,
me,
isUsingAuth,
requiredRole,
replaceWith,
propsOverride,
}) => {
// if me response has not been received yet, render nothing
if (typeof isUsingAuth !== 'boolean') {
return null
}
// React.isValidElement guards against multiple children wrapped by Authorized
const firstChild = React.isValidElement(children) ? children : children[0]
const meRole = getMeRole(me)
if (!isUsingAuth || isUserAuthorized(meRole, requiredRole)) {
return firstChild
}
if (propsOverride) {
return React.cloneElement(firstChild, {...propsOverride})
}
return replaceWith || null
}
const {arrayOf, bool, node, shape, string} = PropTypes
Authorized.propTypes = {
isUsingAuth: bool,
replaceWith: node,
children: node.isRequired,
me: shape({
roles: arrayOf(
shape({
name: string.isRequired,
})
),
}),
requiredRole: string.isRequired,
propsOverride: shape(),
}
const mapStateToProps = ({auth: {me, isUsingAuth}}) => ({
me,
isUsingAuth,
})
export default connect(mapStateToProps)(Authorized)

View File

@ -1,6 +1,8 @@
import React, {PropTypes} from 'react'
import classnames from 'classnames'
import Authorized, {EDITOR_ROLE} from 'src/auth/Authorized'
import AutoRefreshDropdown from 'shared/components/AutoRefreshDropdown'
import TimeRangeDropdown from 'shared/components/TimeRangeDropdown'
import SourceIndicator from 'shared/components/SourceIndicator'
@ -46,13 +48,22 @@ const DashboardHeader = ({
/>
: null}
{dashboard
? <DashboardHeaderEdit
onSave={onSave}
onCancel={onCancel}
activeDashboard={activeDashboard}
onEditDashboard={onEditDashboard}
isEditMode={isEditMode}
/>
? <Authorized
requiredRole={EDITOR_ROLE}
replaceWith={
<h1 className="page-header__title">
{activeDashboard}
</h1>
}
>
<DashboardHeaderEdit
onSave={onSave}
onCancel={onCancel}
activeDashboard={activeDashboard}
onEditDashboard={onEditDashboard}
isEditMode={isEditMode}
/>
</Authorized>
: <h1 className="page-header__title">
{activeDashboard}
</h1>}
@ -61,10 +72,15 @@ const DashboardHeader = ({
<GraphTips />
<SourceIndicator />
{dashboard
? <button className="btn btn-primary btn-sm" onClick={onAddCell}>
<span className="icon plus" />
Add Cell
</button>
? <Authorized requiredRole={EDITOR_ROLE}>
<button
className="btn btn-primary btn-sm"
onClick={onAddCell}
>
<span className="icon plus" />
Add Cell
</button>
</Authorized>
: null}
{dashboard
? <div

View File

@ -1,5 +1,7 @@
import React, {PropTypes} from 'react'
import Authorized, {EDITOR_ROLE} from 'src/auth/Authorized'
import DashboardsTable from 'src/dashboards/components/DashboardsTable'
import FancyScrollbar from 'shared/components/FancyScrollbar'
@ -28,12 +30,14 @@ const DashboardsPageContents = ({
<h2 className="panel-title">
{tableHeader}
</h2>
<button
className="btn btn-sm btn-primary"
onClick={onCreateDashboard}
>
<span className="icon plus" /> Create Dashboard
</button>
<Authorized requiredRole={EDITOR_ROLE}>
<button
className="btn btn-sm btn-primary"
onClick={onCreateDashboard}
>
<span className="icon plus" /> Create Dashboard
</button>
</Authorized>
</div>
<div className="panel-body">
<DashboardsTable

View File

@ -2,6 +2,8 @@ import React, {PropTypes} from 'react'
import {Link} from 'react-router'
import _ from 'lodash'
import Authorized, {EDITOR_ROLE} from 'src/auth/Authorized'
import DeleteConfirmTableCell from 'shared/components/DeleteConfirmTableCell'
const DashboardsTable = ({
@ -36,11 +38,13 @@ const DashboardsTable = ({
)
: <span className="empty-string">None</span>}
</td>
<DeleteConfirmTableCell
onDelete={onDeleteDashboard}
item={dashboard}
buttonSize="btn-xs"
/>
<Authorized requiredRole={EDITOR_ROLE} replaceWith={<td />}>
<DeleteConfirmTableCell
onDelete={onDeleteDashboard}
item={dashboard}
buttonSize="btn-xs"
/>
</Authorized>
</tr>
)}
</tbody>

View File

@ -2,6 +2,8 @@ import React, {PropTypes} from 'react'
import classnames from 'classnames'
import calculateSize from 'calculate-size'
import Authorized, {EDITOR_ROLE} from 'src/auth/Authorized'
import Dropdown from 'shared/components/Dropdown'
const minTempVarDropdownWidth = 146
@ -75,13 +77,15 @@ const TemplateControlBar = ({
This dashboard does not have any Template Variables
</div>}
</div>
<button
className="btn btn-primary btn-sm template-control--manage"
onClick={onOpenTemplateManager}
>
<span className="icon cog-thick" />
Manage
</button>
<Authorized requiredRole={EDITOR_ROLE}>
<button
className="btn btn-primary btn-sm template-control--manage"
onClick={onOpenTemplateManager}
>
<span className="icon cog-thick" />
Manage
</button>
</Authorized>
</div>
</div>

View File

@ -164,7 +164,7 @@ class Dropdown extends Component {
>
{item.text}
</a>
{actions.length > 0
{actions && actions.length
? <div className="dropdown-actions">
{actions.map(action => {
return (

View File

@ -1,6 +1,8 @@
import React, {Component, PropTypes} from 'react'
import _ from 'lodash'
import Authorized, {EDITOR_ROLE} from 'src/auth/Authorized'
import LayoutCellMenu from 'shared/components/LayoutCellMenu'
import LayoutCellHeader from 'shared/components/LayoutCellHeader'
import {errorThrown} from 'shared/actions/errors'
@ -52,17 +54,19 @@ class LayoutCell extends Component {
return (
<div className="dash-graph">
<LayoutCellMenu
cell={cell}
dataExists={!!celldata.length}
isDeleting={isDeleting}
isEditable={isEditable}
onDelete={this.handleDeleteCell}
onEdit={this.handleSummonOverlay}
handleClickOutside={this.closeMenu}
onDeleteClick={this.handleDeleteClick}
onCSVDownload={this.handleCSVDownload}
/>
<Authorized requiredRole={EDITOR_ROLE}>
<LayoutCellMenu
cell={cell}
dataExists={!!celldata.length}
isDeleting={isDeleting}
isEditable={isEditable}
onDelete={this.handleDeleteCell}
onEdit={this.handleSummonOverlay}
handleClickOutside={this.closeMenu}
onDeleteClick={this.handleDeleteClick}
onCSVDownload={this.handleCSVDownload}
/>
</Authorized>
<LayoutCellHeader
queries={queries}
cellName={cell.name}
@ -72,12 +76,14 @@ class LayoutCell extends Component {
{queries.length
? children
: <div className="graph-empty">
<button
className="no-query--button btn btn-md btn-primary"
onClick={this.handleSummonOverlay(cell)}
>
<span className="icon plus" /> Add Graph
</button>
<Authorized requiredRole={EDITOR_ROLE}>
<button
className="no-query--button btn btn-md btn-primary"
onClick={this.handleSummonOverlay(cell)}
>
<span className="icon plus" /> Add Graph
</button>
</Authorized>
</div>}
</div>
</div>

View File

@ -4,6 +4,8 @@ import Resizeable from 'react-component-resizable'
import _ from 'lodash'
import Authorized, {EDITOR_ROLE} from 'src/auth/Authorized'
import Layout from 'src/shared/components/Layout'
import {
@ -87,43 +89,59 @@ class LayoutRenderer extends Component {
return (
<Resizeable onResize={this.handleCellResize}>
<GridLayout
layout={cells}
cols={12}
rowHeight={rowHeight}
margin={[LAYOUT_MARGIN, LAYOUT_MARGIN]}
containerPadding={[0, 0]}
useCSSTransforms={false}
onResize={this.handleCellResize}
onLayoutChange={this.handleLayoutChange}
draggableHandle={'.dash-graph--name'}
isDraggable={isDashboard}
isResizable={isDashboard}
<Authorized
requiredRole={EDITOR_ROLE}
propsOverride={{
isDraggable: false,
isResizable: false,
draggableHandle: null,
}}
>
{cells.map(cell =>
<div key={cell.i}>
<Layout
key={cell.i}
cell={cell}
host={host}
source={source}
onZoom={onZoom}
sources={sources}
templates={templates}
timeRange={timeRange}
isEditable={isEditable}
onEditCell={onEditCell}
resizeCoords={resizeCoords}
autoRefresh={autoRefresh}
manualRefresh={manualRefresh}
onDeleteCell={onDeleteCell}
synchronizer={synchronizer}
onCancelEditCell={onCancelEditCell}
onSummonOverlayTechnologies={onSummonOverlayTechnologies}
/>
</div>
)}
</GridLayout>
<GridLayout
layout={cells}
cols={12}
rowHeight={rowHeight}
margin={[LAYOUT_MARGIN, LAYOUT_MARGIN]}
containerPadding={[0, 0]}
useCSSTransforms={false}
onResize={this.handleCellResize}
onLayoutChange={this.handleLayoutChange}
draggableHandle={'.dash-graph--name'}
isDraggable={isDashboard}
isResizable={isDashboard}
>
{cells.map(cell =>
<div key={cell.i}>
<Authorized
requiredRole={EDITOR_ROLE}
propsOverride={{
isEditable: false,
}}
>
<Layout
key={cell.i}
cell={cell}
host={host}
source={source}
onZoom={onZoom}
sources={sources}
templates={templates}
timeRange={timeRange}
isEditable={isEditable}
onEditCell={onEditCell}
resizeCoords={resizeCoords}
autoRefresh={autoRefresh}
manualRefresh={manualRefresh}
onDeleteCell={onDeleteCell}
synchronizer={synchronizer}
onCancelEditCell={onCancelEditCell}
onSummonOverlayTechnologies={onSummonOverlayTechnologies}
/>
</Authorized>
</div>
)}
</GridLayout>
</Authorized>
</Resizeable>
)
}

View File

@ -0,0 +1,55 @@
import React, {PropTypes} from 'react'
import uuid from 'node-uuid'
import {connect} from 'react-redux'
import ReactTooltip from 'react-tooltip'
import {getMeRole} from 'src/auth/Authorized'
const RoleIndicator = ({me, isUsingAuth}) => {
if (!isUsingAuth) {
return null
}
const roleName = getMeRole(me)
const RoleTooltip = `<h1>Role: <code>${roleName}</code></h1>`
const uuidTooltip = uuid.v4()
return (
<div
className="role-indicator"
data-for={uuidTooltip}
data-tip={RoleTooltip}
>
<span className="icon user" />
<ReactTooltip
id={uuidTooltip}
effect="solid"
html={true}
place="bottom"
class="influx-tooltip"
/>
</div>
)
}
const {arrayOf, bool, shape, string} = PropTypes
RoleIndicator.propTypes = {
isUsingAuth: bool.isRequired,
me: shape({
roles: arrayOf(
shape({
name: string.isRequired,
})
),
}),
}
const mapStateToProps = ({auth: {me, isUsingAuth}}) => ({
me,
isUsingAuth,
})
export default connect(mapStateToProps)(RoleIndicator)

View File

@ -30,7 +30,7 @@ const authReducer = (state = initialState, action) => {
}
case 'LOGOUT_LINK_RECEIVED': {
const {logoutLink} = action.payload
return {...state, logoutLink}
return {...state, logoutLink, isUsingAuth: !!logoutLink}
}
}

View File

@ -66,9 +66,8 @@ const NavBlock = React.createClass({
render() {
const {location, className} = this.props
const isActive = React.Children.toArray(this.props.children).find(child => {
return location.startsWith(child.props.link)
return location.startsWith(child.props.link) // if location is undefined, this will fail silently
})
const children = React.Children.map(this.props.children, child => {
@ -114,19 +113,11 @@ const NavBlock = React.createClass({
const NavBar = React.createClass({
propTypes: {
children: node,
location: string.isRequired,
},
render() {
const children = React.Children.map(this.props.children, child => {
if (child && child.type === NavBlock) {
return React.cloneElement(child, {
location: this.props.location,
})
}
const {children} = this.props
return child
})
return (
<nav className="sidebar">
{children}

View File

@ -2,6 +2,8 @@ import React, {PropTypes} from 'react'
import {withRouter, Link} from 'react-router'
import {connect} from 'react-redux'
import Authorized, {ADMIN_ROLE} from 'src/auth/Authorized'
import {
NavBar,
NavBlock,
@ -22,6 +24,7 @@ const SideNav = React.createClass({
pathname: string.isRequired,
}).isRequired,
isHidden: bool.isRequired,
isUsingAuth: bool,
logoutLink: string,
customLinks: arrayOf(
shape({
@ -61,13 +64,13 @@ const SideNav = React.createClass({
params: {sourceID},
location: {pathname: location},
isHidden,
isUsingAuth,
logoutLink,
customLinks,
} = this.props
const sourcePrefix = `/sources/${sourceID}`
const dataExplorerLink = `${sourcePrefix}/chronograf/data-explorer`
const isUsingAuth = !!logoutLink
const isDefaultPage = location.split('/').includes(DEFAULT_HOME_PAGE)
@ -84,13 +87,25 @@ const SideNav = React.createClass({
<span className="sidebar--icon icon cubo-uniform" />
</Link>
</div>
<NavBlock icon="cubo-node" link={`${sourcePrefix}/hosts`}>
<NavBlock
icon="cubo-node"
link={`${sourcePrefix}/hosts`}
location={location}
>
<NavHeader link={`${sourcePrefix}/hosts`} title="Host List" />
</NavBlock>
<NavBlock icon="graphline" link={dataExplorerLink}>
<NavBlock
icon="graphline"
link={dataExplorerLink}
location={location}
>
<NavHeader link={dataExplorerLink} title="Data Explorer" />
</NavBlock>
<NavBlock icon="dash-h" link={`${sourcePrefix}/dashboards`}>
<NavBlock
icon="dash-h"
link={`${sourcePrefix}/dashboards`}
location={location}
>
<NavHeader
link={`${sourcePrefix}/dashboards`}
title={'Dashboards'}
@ -100,6 +115,7 @@ const SideNav = React.createClass({
matcher="alerts"
icon="alert-triangle"
link={`${sourcePrefix}/alerts`}
location={location}
>
<NavHeader link={`${sourcePrefix}/alerts`} title="Alerting" />
<NavListItem link={`${sourcePrefix}/alerts`}>History</NavListItem>
@ -107,17 +123,27 @@ const SideNav = React.createClass({
Create
</NavListItem>
</NavBlock>
<NavBlock icon="crown2" link={`${sourcePrefix}/admin`}>
<NavHeader link={`${sourcePrefix}/admin`} title="Admin" />
</NavBlock>
<NavBlock icon="cog-thick" link={`${sourcePrefix}/manage-sources`}>
<Authorized requiredRole={ADMIN_ROLE}>
<NavBlock
icon="crown2"
link={`${sourcePrefix}/admin`}
location={location}
>
<NavHeader link={`${sourcePrefix}/admin`} title="Admin" />
</NavBlock>
</Authorized>
<NavBlock
icon="cog-thick"
link={`${sourcePrefix}/manage-sources`}
location={location}
>
<NavHeader
link={`${sourcePrefix}/manage-sources`}
title="Configuration"
/>
</NavBlock>
{isUsingAuth
? <NavBlock icon="user">
? <NavBlock icon="user" location={location}>
{customLinks
? this.renderUserMenuBlockWithCustomLinks(
customLinks,
@ -135,11 +161,12 @@ const SideNav = React.createClass({
})
const mapStateToProps = ({
auth: {logoutLink},
auth: {isUsingAuth, logoutLink},
app: {ephemeral: {inPresentationMode}},
links: {external: {custom: customLinks}},
}) => ({
isHidden: inPresentationMode,
isUsingAuth,
logoutLink,
customLinks,
})

View File

@ -1,6 +1,8 @@
import React, {PropTypes} from 'react'
import {Link, withRouter} from 'react-router'
import Authorized, {EDITOR_ROLE} from 'src/auth/Authorized'
import Dropdown from 'shared/components/Dropdown'
import QuestionMarkTooltip from 'shared/components/QuestionMarkTooltip'
@ -13,12 +15,14 @@ const kapacitorDropdown = (
) => {
if (!kapacitors || kapacitors.length === 0) {
return (
<Link
to={`/sources/${source.id}/kapacitors/new`}
className="btn btn-xs btn-default"
>
<span className="icon plus" /> Add Config
</Link>
<Authorized requiredRole={EDITOR_ROLE}>
<Link
to={`/sources/${source.id}/kapacitors/new`}
className="btn btn-xs btn-default"
>
<span className="icon plus" /> Add Config
</Link>
</Authorized>
)
}
const kapacitorItems = kapacitors.map(k => {
@ -39,35 +43,40 @@ const kapacitorDropdown = (
}
return (
<Dropdown
className="dropdown-260"
buttonColor="btn-primary"
buttonSize="btn-xs"
items={kapacitorItems}
onChoose={setActiveKapacitor}
addNew={{
url: `/sources/${source.id}/kapacitors/new`,
text: 'Add Kapacitor',
}}
actions={[
{
icon: 'pencil',
text: 'edit',
handler: item => {
router.push(`${item.resource}/edit`)
<Authorized
requiredRole={EDITOR_ROLE}
propsOverride={{addNew: null, actions: null}}
>
<Dropdown
className="dropdown-260"
buttonColor="btn-primary"
buttonSize="btn-xs"
items={kapacitorItems}
onChoose={setActiveKapacitor}
addNew={{
url: `/sources/${source.id}/kapacitors/new`,
text: 'Add Kapacitor',
}}
actions={[
{
icon: 'pencil',
text: 'edit',
handler: item => {
router.push(`${item.resource}/edit`)
},
},
},
{
icon: 'trash',
text: 'delete',
handler: item => {
handleDeleteKapacitor(item.kapacitor)
{
icon: 'trash',
text: 'delete',
handler: item => {
handleDeleteKapacitor(item.kapacitor)
},
confirmable: true,
},
confirmable: true,
},
]}
selected={selected}
/>
]}
selected={selected}
/>
</Authorized>
)
}
@ -85,12 +94,14 @@ const InfluxTable = ({
<div className="panel panel-minimal">
<div className="panel-heading u-flex u-ai-center u-jc-space-between">
<h2 className="panel-title">InfluxDB Sources</h2>
<Link
to={`/sources/${source.id}/manage-sources/new`}
className="btn btn-sm btn-primary"
>
<span className="icon plus" /> Add Source
</Link>
<Authorized requiredRole={EDITOR_ROLE}>
<Link
to={`/sources/${source.id}/manage-sources/new`}
className="btn btn-sm btn-primary"
>
<span className="icon plus" /> Add Source
</Link>
</Authorized>
</div>
<div className="panel-body">
<table className="table v-center margin-bottom-zero table-highlight">
@ -131,28 +142,41 @@ const InfluxTable = ({
</td>
<td>
<h5 className="margin-zero">
<Link
to={`${location.pathname}/${s.id}/edit`}
className={s.id === source.id ? 'link-success' : null}
<Authorized
requiredRole={EDITOR_ROLE}
replaceWith={
<strong>
{s.name}
</strong>
}
>
<strong>
{s.name}
</strong>
{s.default ? ' (Default)' : null}
</Link>
<Link
to={`${location.pathname}/${s.id}/edit`}
className={
s.id === source.id ? 'link-success' : null
}
>
<strong>
{s.name}
</strong>
{s.default ? ' (Default)' : null}
</Link>
</Authorized>
</h5>
<span>
{s.url}
</span>
</td>
<td className="text-right">
<a
className="btn btn-xs btn-danger table--show-on-row-hover"
href="#"
onClick={handleDeleteSource(s)}
>
Delete Source
</a>
<Authorized requiredRole={EDITOR_ROLE}>
<a
className="btn btn-xs btn-danger table--show-on-row-hover"
href="#"
onClick={handleDeleteSource(s)}
>
Delete Source
</a>
</Authorized>
</td>
<td className="source-table--kapacitor">
{kapacitorDropdown(

View File

@ -49,7 +49,7 @@
@import 'components/redacted-input';
@import 'components/resizer';
@import 'components/search-widget';
@import 'components/source-indicator';
@import 'components/info-indicators';
@import 'components/source-selector';
@import 'components/tables';

View File

@ -1,7 +1,8 @@
/*
Source Indicator component styles
----------------------------------------------------------------
Source & Role Indicator component styles
----------------------------------------------------------------------------
*/
.role-indicator,
.source-indicator {
@include no-user-select();
display: inline-block;

View File

@ -46,6 +46,10 @@ $tooltip-code-color: $c-potassium;
line-height: 1.125em;
letter-spacing: 0;
font-family: $default-font;
&:only-child {
margin: 0;
}
}
p {

View File

@ -163,8 +163,6 @@ $dash-graph-options-arrow: 8px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dash-graph--name {
position: relative;
height: $dash-graph-heading;
line-height: $dash-graph-heading;
@ -172,6 +170,10 @@ $dash-graph-options-arrow: 8px;
padding-left: 10px;
transition: color 0.25s ease, background-color 0.25s ease,
border-color 0.25s ease;
&:only-child {
width: 100%;
}
}
.dash-graph--name.dash-graph--name__default {
font-style: italic;