diff --git a/pkg/api/api.go b/pkg/api/api.go index d08edc1e550..705cdda17c5 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -52,8 +52,8 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/profile/password", reqSignedInNoAnonymous, hs.Index) r.Get("/.well-known/change-password", redirectToChangePassword) r.Get("/profile/switch-org/:id", reqSignedInNoAnonymous, hs.ChangeActiveOrgAndRedirectToHome) - r.Get("/org/", reqOrgAdmin, hs.Index) - r.Get("/org/new", reqGrafanaAdmin, hs.Index) + r.Get("/org/", authorize(reqOrgAdmin, orgPreferencesAccessEvaluator), hs.Index) + r.Get("/org/new", authorizeInOrg(reqGrafanaAdmin, acmiddleware.UseGlobalOrg, orgsCreateAccessEvaluator), hs.Index) r.Get("/datasources/", authorize(reqOrgAdmin, dataSourcesConfigurationAccessEvaluator), hs.Index) r.Get("/datasources/new", authorize(reqOrgAdmin, dataSourcesNewAccessEvaluator), hs.Index) r.Get("/datasources/edit/*", authorize(reqOrgAdmin, dataSourcesEditAccessEvaluator), hs.Index) @@ -70,8 +70,8 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/admin/users", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)), hs.Index) r.Get("/admin/users/create", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersCreate)), hs.Index) r.Get("/admin/users/edit/:id", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead)), hs.Index) - r.Get("/admin/orgs", reqGrafanaAdmin, hs.Index) - r.Get("/admin/orgs/edit/:id", reqGrafanaAdmin, hs.Index) + r.Get("/admin/orgs", authorizeInOrg(reqGrafanaAdmin, acmiddleware.UseGlobalOrg, orgsAccessEvaluator), hs.Index) + r.Get("/admin/orgs/edit/:id", authorizeInOrg(reqGrafanaAdmin, acmiddleware.UseGlobalOrg, orgsAccessEvaluator), hs.Index) r.Get("/admin/stats", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionServerStatsRead)), hs.Index) r.Get("/admin/ldap", authorize(reqGrafanaAdmin, ac.EvalPermission(ac.ActionLDAPStatusRead)), hs.Index) r.Get("/styleguide", reqSignedIn, hs.Index) diff --git a/pkg/api/index.go b/pkg/api/index.go index 6af89d1eabf..e1e3e2c38d7 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -267,7 +267,9 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto Icon: "plug", Url: hs.Cfg.AppSubURL + "/plugins", }) + } + if hasAccess(ac.ReqOrgAdmin, orgPreferencesAccessEvaluator) { configNodes = append(configNodes, &dtos.NavLink{ Text: "Preferences", Id: "org-settings", @@ -275,6 +277,9 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto Icon: "sliders-v-alt", Url: hs.Cfg.AppSubURL + "/org", }) + } + + if c.OrgRole == models.ROLE_ADMIN { configNodes = append(configNodes, &dtos.NavLink{ Text: "API keys", Id: "apikeys", @@ -472,6 +477,7 @@ func (hs *HTTPServer) buildCreateNavLinks(c *models.ReqContext) []*dtos.NavLink func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink { hasAccess := ac.HasAccess(hs.AccessControl, c) + hasGlobalAccess := ac.HasGlobalAccess(hs.AccessControl, c) adminNavLinks := []*dtos.NavLink{} if hasAccess(ac.ReqGrafanaAdmin, ac.EvalPermission(ac.ActionUsersRead, ac.ScopeGlobalUsersAll)) { @@ -480,7 +486,7 @@ func (hs *HTTPServer) buildAdminNavLinks(c *models.ReqContext) []*dtos.NavLink { }) } - if c.IsGrafanaAdmin { + if hasGlobalAccess(ac.ReqGrafanaAdmin, orgsAccessEvaluator) { adminNavLinks = append(adminNavLinks, &dtos.NavLink{ Text: "Orgs", Id: "global-orgs", Url: hs.Cfg.AppSubURL + "/admin/orgs", Icon: "building", }) diff --git a/pkg/api/roles.go b/pkg/api/roles.go index f73b13bfb0b..603926c58c6 100644 --- a/pkg/api/roles.go +++ b/pkg/api/roles.go @@ -231,3 +231,25 @@ var dataSourcesEditAccessEvaluator = accesscontrol.EvalAll( accesscontrol.EvalPermission(ActionDatasourcesRead, ScopeDatasourcesAll), accesscontrol.EvalPermission(ActionDatasourcesWrite), ) + +// orgPreferencesAccessEvaluator is used to protect the "Configure > Preferences" page access +var orgPreferencesAccessEvaluator = accesscontrol.EvalAny( + accesscontrol.EvalAll( + accesscontrol.EvalPermission(ActionOrgsRead), + accesscontrol.EvalPermission(ActionOrgsWrite), + ), + accesscontrol.EvalAll( + accesscontrol.EvalPermission(ActionOrgsPreferencesRead), + accesscontrol.EvalPermission(ActionOrgsPreferencesWrite), + ), +) + +// orgsAccessEvaluator is used to protect the "Server Admin > Orgs" page access +// (you need to have read access to update or delete orgs; read is the minimum) +var orgsAccessEvaluator = accesscontrol.EvalPermission(ActionOrgsRead) + +// orgsCreateAccessEvaluator is used to protect the "Server Admin > Orgs > New Org" page access +var orgsCreateAccessEvaluator = accesscontrol.EvalAll( + accesscontrol.EvalPermission(ActionOrgsRead), + accesscontrol.EvalPermission(ActionOrgsCreate), +) diff --git a/pkg/services/accesscontrol/accesscontrol.go b/pkg/services/accesscontrol/accesscontrol.go index c0c0575891c..ded3de73003 100644 --- a/pkg/services/accesscontrol/accesscontrol.go +++ b/pkg/services/accesscontrol/accesscontrol.go @@ -42,6 +42,27 @@ type ResourceStore interface { GetResourcesPermissions(ctx context.Context, orgID int64, query GetResourcesPermissionsQuery) ([]ResourcePermission, error) } +// HasGlobalAccess checks user access with globally assigned permissions only +func HasGlobalAccess(ac AccessControl, c *models.ReqContext) func(fallback func(*models.ReqContext) bool, evaluator Evaluator) bool { + return func(fallback func(*models.ReqContext) bool, evaluator Evaluator) bool { + if ac.IsDisabled() { + return fallback(c) + } + + userCopy := *c.SignedInUser + userCopy.OrgId = GlobalOrgID + userCopy.OrgRole = "" + userCopy.OrgName = "" + hasAccess, err := ac.Evaluate(c.Req.Context(), &userCopy, evaluator) + if err != nil { + c.Logger.Error("Error from access control system", "error", err) + return false + } + + return hasAccess + } +} + func HasAccess(ac AccessControl, c *models.ReqContext) func(fallback func(*models.ReqContext) bool, evaluator Evaluator) bool { return func(fallback func(*models.ReqContext) bool, evaluator Evaluator) bool { if ac.IsDisabled() { diff --git a/public/app/core/components/SharedPreferences/SharedPreferences.tsx b/public/app/core/components/SharedPreferences/SharedPreferences.tsx index 528e6cbc24d..ecedadb50ca 100644 --- a/public/app/core/components/SharedPreferences/SharedPreferences.tsx +++ b/public/app/core/components/SharedPreferences/SharedPreferences.tsx @@ -24,6 +24,7 @@ import { PreferencesService } from 'app/core/services/PreferencesService'; export interface Props { resourceUri: string; + disabled?: boolean; } export interface State { @@ -126,13 +127,14 @@ export class SharedPreferences extends PureComponent { render() { const { theme, timezone, weekStart, homeDashboardId, dashboards } = this.state; + const { disabled } = this.props; const styles = getStyles(); return (
{() => { return ( -
+
{ }; const getOrgUsers = async (orgId: UrlQueryValue) => { - return await getBackendSrv().get('/api/orgs/' + orgId + '/users'); + if (contextSrv.hasPermission(AccessControlAction.OrgUsersRead)) { + return await getBackendSrv().get(`/api/orgs/${orgId}/users`); + } + return []; }; const updateOrgUserRole = async (orgUser: OrgUser, orgId: UrlQueryValue) => { @@ -37,6 +41,8 @@ export const AdminEditOrgPage: FC = ({ match }) => { const navIndex = useSelector((state: StoreState) => state.navIndex); const navModel = getNavModel(navIndex, 'global-orgs'); const orgId = parseInt(match.params.id, 10); + const canWriteOrg = contextSrv.hasPermission(AccessControlAction.OrgsWrite); + const canReadUsers = contextSrv.hasPermission(AccessControlAction.OrgUsersRead); const [users, setUsers] = useState([]); @@ -52,12 +58,20 @@ export const AdminEditOrgPage: FC = ({ match }) => { return await getBackendSrv().put('/api/orgs/' + orgId, { ...orgState.value, name }); }; + const renderMissingUserListRightsMessage = () => { + return ( + + You do not have permission to see users in this organization. To update this organization, contact your server + administrator. + + ); + }; + return ( <> Edit organization - {orgState.value && ( = ({ match }) => { > {({ register, errors }) => ( <> - + - + )} @@ -80,7 +94,8 @@ export const AdminEditOrgPage: FC = ({ match }) => { `} > Organization users - {!!users.length && ( + {!canReadUsers && renderMissingUserListRightsMessage()} + {canReadUsers && !!users.length && ( { diff --git a/public/app/features/admin/AdminListOrgsPage.tsx b/public/app/features/admin/AdminListOrgsPage.tsx index b46de682e71..925b62b5fd7 100644 --- a/public/app/features/admin/AdminListOrgsPage.tsx +++ b/public/app/features/admin/AdminListOrgsPage.tsx @@ -7,6 +7,8 @@ import { LinkButton } from '@grafana/ui'; import { getBackendSrv } from '@grafana/runtime'; import { AdminOrgsTable } from './AdminOrgsTable'; import useAsyncFn from 'react-use/lib/useAsyncFn'; +import { contextSrv } from 'app/core/services/context_srv'; +import { AccessControlAction } from 'app/types'; const deleteOrg = async (orgId: number) => { return await getBackendSrv().delete('/api/orgs/' + orgId); @@ -16,10 +18,15 @@ const getOrgs = async () => { return await getBackendSrv().get('/api/orgs'); }; +const getErrorMessage = (error: any) => { + return error?.data?.message || 'An unexpected error happened.'; +}; + export const AdminListOrgsPages: FC = () => { const navIndex = useSelector((state: StoreState) => state.navIndex); const navModel = getNavModel(navIndex, 'global-orgs'); const [state, fetchOrgs] = useAsyncFn(async () => await getOrgs(), []); + const canCreateOrg = contextSrv.hasPermission(AccessControlAction.OrgsCreate); useEffect(() => { fetchOrgs(); @@ -31,12 +38,12 @@ export const AdminListOrgsPages: FC = () => { <>
- + New org
+ {state.error && getErrorMessage(state.error)} {state.loading && 'Fetching organizations'} - {state.error} {state.value && ( = ({ orgs, onDelete }) => { + const canDeleteOrgs = contextSrv.hasPermission(AccessControlAction.OrgsDelete); + const [deleteOrg, setDeleteOrg] = useState(); return ( @@ -34,6 +37,7 @@ export const AdminOrgsTable: FC = ({ orgs, onDelete }) => { icon="times" onClick={() => setDeleteOrg(org)} aria-label="Delete org" + disabled={!canDeleteOrgs} /> diff --git a/public/app/features/org/OrgDetailsPage.test.tsx b/public/app/features/org/OrgDetailsPage.test.tsx index 7b62b1f83a6..93cca7cc902 100644 --- a/public/app/features/org/OrgDetailsPage.test.tsx +++ b/public/app/features/org/OrgDetailsPage.test.tsx @@ -7,6 +7,14 @@ import { Organization } from '../../types'; import { mockToolkitActionCreator } from 'test/core/redux/mocks'; import { setOrganizationName } from './state/reducers'; +jest.mock('app/core/core', () => { + return { + contextSrv: { + hasPermission: () => true, + }, + }; +}); + const setup = (propOverrides?: object) => { const props: Props = { organization: {} as Organization, diff --git a/public/app/features/org/OrgDetailsPage.tsx b/public/app/features/org/OrgDetailsPage.tsx index 152969c0be8..75776303601 100644 --- a/public/app/features/org/OrgDetailsPage.tsx +++ b/public/app/features/org/OrgDetailsPage.tsx @@ -6,10 +6,11 @@ import Page from 'app/core/components/Page/Page'; import OrgProfile from './OrgProfile'; import SharedPreferences from 'app/core/components/SharedPreferences/SharedPreferences'; import { loadOrganization, updateOrganization } from './state/actions'; -import { Organization, StoreState } from 'app/types'; +import { AccessControlAction, Organization, StoreState } from 'app/types'; import { getNavModel } from 'app/core/selectors/navModel'; import { setOrganizationName } from './state/reducers'; import { VerticalGroup } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; export interface Props { navModel: NavModel; @@ -32,14 +33,17 @@ export class OrgDetailsPage extends PureComponent { render() { const { navModel, organization } = this.props; const isLoading = Object.keys(organization).length === 0; + const canReadOrg = contextSrv.hasPermission(AccessControlAction.OrgsRead); + const canReadPreferences = contextSrv.hasPermission(AccessControlAction.OrgsPreferencesRead); + const canWritePreferences = contextSrv.hasPermission(AccessControlAction.OrgsPreferencesWrite); return ( {!isLoading && ( - - + {canReadOrg && } + {canReadPreferences && } )} diff --git a/public/app/features/org/OrgProfile.test.tsx b/public/app/features/org/OrgProfile.test.tsx index 216beb001e4..9eceebcee95 100644 --- a/public/app/features/org/OrgProfile.test.tsx +++ b/public/app/features/org/OrgProfile.test.tsx @@ -2,6 +2,14 @@ import React from 'react'; import { shallow } from 'enzyme'; import OrgProfile, { Props } from './OrgProfile'; +jest.mock('app/core/core', () => { + return { + contextSrv: { + hasPermission: () => true, + }, + }; +}); + const setup = () => { const props: Props = { orgName: 'Main org', diff --git a/public/app/features/org/OrgProfile.tsx b/public/app/features/org/OrgProfile.tsx index 7554a3e5465..f4b9a0bd293 100644 --- a/public/app/features/org/OrgProfile.tsx +++ b/public/app/features/org/OrgProfile.tsx @@ -1,5 +1,7 @@ import React, { FC } from 'react'; import { Input, Field, FieldSet, Button, Form } from '@grafana/ui'; +import { contextSrv } from 'app/core/core'; +import { AccessControlAction } from 'app/types'; export interface Props { orgName: string; @@ -11,10 +13,12 @@ interface FormDTO { } const OrgProfile: FC = ({ onSubmit, orgName }) => { + const canWriteOrg = contextSrv.hasPermission(AccessControlAction.OrgsWrite); + return (
onSubmit(orgName)}> {({ register }) => ( -
+
diff --git a/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap b/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap index 69c6a3db757..448a4d789ab 100644 --- a/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap +++ b/public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap @@ -43,6 +43,7 @@ exports[`Render should render organization and preferences 1`] = ` orgName="Cool org" /> diff --git a/public/app/types/accessControl.ts b/public/app/types/accessControl.ts index c285b8a7a9e..07e987f7329 100644 --- a/public/app/types/accessControl.ts +++ b/public/app/types/accessControl.ts @@ -25,6 +25,12 @@ export enum AccessControlAction { UsersQuotasList = 'users.quotas:list', UsersQuotasUpdate = 'users.quotas:update', + OrgsRead = 'orgs:read', + OrgsPreferencesRead = 'orgs.preferences:read', + OrgsWrite = 'orgs:write', + OrgsPreferencesWrite = 'orgs.preferences:write', + OrgsCreate = 'orgs:create', + OrgsDelete = 'orgs:delete', OrgUsersRead = 'org.users:read', OrgUsersAdd = 'org.users:add', OrgUsersRemove = 'org.users:remove',