diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 7a86d7e6c7f..2c7291f7c06 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -14,6 +14,7 @@ import ( "sync" "github.com/grafana/grafana/pkg/services/query" + "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/thumbs" "github.com/grafana/grafana/pkg/api/routing" @@ -113,6 +114,7 @@ type HTTPServer struct { updateChecker *updatechecker.Service searchUsersService searchusers.Service queryDataService *query.Service + serviceAccountsService serviceaccounts.Service } type ServerOptions struct { @@ -137,7 +139,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi quotaService *quota.QuotaService, socialService social.Service, tracingService tracing.Tracer, encryptionService encryption.Internal, updateChecker *updatechecker.Service, searchUsersService searchusers.Service, dataSourcesService *datasources.Service, secretsService secrets.Service, - queryDataService *query.Service) (*HTTPServer, error) { + queryDataService *query.Service, serviceaccountsService serviceaccounts.Service) (*HTTPServer, error) { web.Env = cfg.Env m := web.New() @@ -189,6 +191,7 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi DataSourcesService: dataSourcesService, searchUsersService: searchUsersService, queryDataService: queryDataService, + serviceAccountsService: serviceaccountsService, } if hs.Listener != nil { hs.log.Debug("Using provided listener") diff --git a/pkg/api/index.go b/pkg/api/index.go index 0163474db3b..85e3ef9c007 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -142,6 +142,14 @@ func (hs *HTTPServer) getAppLinks(c *models.ReqContext) ([]*dtos.NavLink, error) return appLinks, nil } +func enableServiceAccount(hs *HTTPServer, c *models.ReqContext) bool { + return c.OrgRole == models.ROLE_ADMIN && hs.Cfg.IsServiceAccountEnabled() && hs.serviceAccountsService.Migrated(c.Req.Context(), c.OrgId) +} + +func enableTeams(hs *HTTPServer, c *models.ReqContext) bool { + return c.OrgRole == models.ROLE_ADMIN || (hs.Cfg.EditorsCanAdmin && c.OrgRole == models.ROLE_EDITOR) +} + func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dtos.NavLink, error) { hasAccess := ac.HasAccess(hs.AccessControl, c) navTree := []*dtos.NavLink{} @@ -253,7 +261,7 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto }) } - if c.OrgRole == models.ROLE_ADMIN || (hs.Cfg.EditorsCanAdmin && c.OrgRole == models.ROLE_EDITOR) { + if enableTeams(hs, c) { configNodes = append(configNodes, &dtos.NavLink{ Text: "Teams", Id: "teams", @@ -292,6 +300,17 @@ func (hs *HTTPServer) getNavTree(c *models.ReqContext, hasEditPerm bool) ([]*dto Url: hs.Cfg.AppSubURL + "/org/apikeys", }) } + // needs both feature flag and migration to be able to show service accounts + if enableServiceAccount(hs, c) { + configNodes = append(configNodes, &dtos.NavLink{ + Text: "Service accounts", + Id: "serviceaccounts", + Description: "Manage service accounts", + // TODO: change icon to "key-skeleton-alt" when it's available + Icon: "key-skeleton-alt", + Url: hs.Cfg.AppSubURL + "/org/serviceaccounts", + }) + } if hs.Cfg.FeatureToggles["live-pipeline"] { liveNavLinks := []*dtos.NavLink{} diff --git a/pkg/services/serviceaccounts/manager/service.go b/pkg/services/serviceaccounts/manager/service.go index 010a92da099..8f7a440d012 100644 --- a/pkg/services/serviceaccounts/manager/service.go +++ b/pkg/services/serviceaccounts/manager/service.go @@ -61,3 +61,9 @@ func (sa *ServiceAccountsService) DeleteServiceAccount(ctx context.Context, orgI } return sa.store.DeleteServiceAccount(ctx, orgID, serviceAccountID) } + +func (sa *ServiceAccountsService) Migrated(ctx context.Context, orgID int64) bool { + // TODO: implement migration logic + // change this to return true for development of service accounts page + return false +} diff --git a/pkg/services/serviceaccounts/serviceaccounts.go b/pkg/services/serviceaccounts/serviceaccounts.go index 53465b7af20..7f5132b7e52 100644 --- a/pkg/services/serviceaccounts/serviceaccounts.go +++ b/pkg/services/serviceaccounts/serviceaccounts.go @@ -9,7 +9,9 @@ import ( type Service interface { CreateServiceAccount(ctx context.Context, saForm *CreateServiceaccountForm) (*models.User, error) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error + Migrated(ctx context.Context, orgID int64) bool } + type Store interface { CreateServiceAccount(ctx context.Context, saForm *CreateServiceaccountForm) (*models.User, error) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index a52ca12c2a8..f2339b3d041 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -37,6 +37,10 @@ func (s *ServiceAccountMock) DeleteServiceAccount(ctx context.Context, orgID, se return nil } +func (s *ServiceAccountMock) Migrated(ctx context.Context, orgID int64) bool { + return false +} + func SetupMockAccesscontrol(t *testing.T, userpermissionsfunc func(c context.Context, siu *models.SignedInUser) ([]*accesscontrol.Permission, error), disableAccessControl bool) *accesscontrolmock.Mock { t.Helper() acmock := accesscontrolmock.New() diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index e698e70a294..1b5db0e641a 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -459,6 +459,10 @@ func (cfg Cfg) IsNewNavigationEnabled() bool { return cfg.FeatureToggles["newNavigation"] } +func (cfg Cfg) IsServiceAccountEnabled() bool { + return cfg.FeatureToggles["service-accounts"] +} + type CommandLineArgs struct { Config string HomePath string diff --git a/public/app/core/reducers/root.ts b/public/app/core/reducers/root.ts index c33e2819273..88ff7d0fdfb 100644 --- a/public/app/core/reducers/root.ts +++ b/public/app/core/reducers/root.ts @@ -17,6 +17,7 @@ import templatingReducers from 'app/features/variables/state/reducers'; import importDashboardReducers from 'app/features/manage-dashboards/state/reducers'; import panelEditorReducers from 'app/features/dashboard/components/PanelEditor/state/reducers'; import panelsReducers from 'app/features/panel/state/reducers'; +import serviceAccountsReducer from 'app/features/serviceaccounts/state/reducers'; const rootReducers = { ...sharedReducers, @@ -28,6 +29,7 @@ const rootReducers = { ...exploreReducers, ...dataSourcesReducers, ...usersReducers, + ...serviceAccountsReducer, ...userReducers, ...organizationReducers, ...ldapReducers, diff --git a/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx new file mode 100644 index 00000000000..c689d3e9bd0 --- /dev/null +++ b/public/app/features/serviceaccounts/ServiceAccountsListPage.tsx @@ -0,0 +1,93 @@ +import React, { PureComponent } from 'react'; +import { connect, ConnectedProps } from 'react-redux'; +import { HorizontalGroup, Pagination, VerticalGroup } from '@grafana/ui'; + +import Page from 'app/core/components/Page/Page'; +import ServiceAccountsTable from './ServiceAccountsTable'; +import { OrgServiceAccount, OrgRole, StoreState } from 'app/types'; +import { loadServiceAccounts, removeServiceAccount, updateServiceAccount } from './state/actions'; +import { getNavModel } from 'app/core/selectors/navModel'; +import { getServiceAccounts, getServiceAccountsSearchPage, getServiceAccountsSearchQuery } from './state/selectors'; +import { setServiceAccountsSearchPage } from './state/reducers'; +export type Props = ConnectedProps; + +export interface State {} + +const ITEMS_PER_PAGE = 30; + +export class ServiceAccountsListPage extends PureComponent { + componentDidMount() { + this.fetchServiceAccounts(); + } + + async fetchServiceAccounts() { + return this.props.loadServiceAccounts(); + } + + onRoleChange = (role: OrgRole, serviceAccount: OrgServiceAccount) => { + const updatedServiceAccount = { ...serviceAccount, role: role }; + + this.props.updateServiceAccount(updatedServiceAccount); + }; + + getPaginatedServiceAccounts = (serviceAccounts: OrgServiceAccount[]) => { + const offset = (this.props.searchPage - 1) * ITEMS_PER_PAGE; + return serviceAccounts.slice(offset, offset + ITEMS_PER_PAGE); + }; + + renderTable() { + const { serviceAccounts } = this.props; + const paginatedServiceAccounts = this.getPaginatedServiceAccounts(serviceAccounts); + const totalPages = Math.ceil(serviceAccounts.length / ITEMS_PER_PAGE); + + return ( + + this.onRoleChange(role, serviceAccount)} + onRemoveServiceaccount={(serviceAccount) => this.props.removeServiceAccount(serviceAccount.serviceAccountId)} + /> + + + + + ); + } + + render() { + const { navModel, hasFetched } = this.props; + + return ( + + + <>{hasFetched && this.renderTable()} + + + ); + } +} + +function mapStateToProps(state: StoreState) { + return { + navModel: getNavModel(state.navIndex, 'serviceaccounts'), + serviceAccounts: getServiceAccounts(state.serviceAccounts), + searchQuery: getServiceAccountsSearchQuery(state.serviceAccounts), + searchPage: getServiceAccountsSearchPage(state.serviceAccounts), + hasFetched: state.serviceAccounts.isLoading, + }; +} + +const mapDispatchToProps = { + loadServiceAccounts, + updateServiceAccount, + removeServiceAccount, +}; + +const connector = connect(mapStateToProps, mapDispatchToProps); + +export default connector(ServiceAccountsListPage); diff --git a/public/app/features/serviceaccounts/ServiceAccountsTable.tsx b/public/app/features/serviceaccounts/ServiceAccountsTable.tsx new file mode 100644 index 00000000000..26259e49d5b --- /dev/null +++ b/public/app/features/serviceaccounts/ServiceAccountsTable.tsx @@ -0,0 +1,133 @@ +import React, { FC, useEffect, useState } from 'react'; +import { AccessControlAction, Role, OrgServiceAccount } from 'app/types'; +import { OrgRolePicker } from '../admin/OrgRolePicker'; +import { Button, ConfirmModal } from '@grafana/ui'; +import { OrgRole } from '@grafana/data'; +import { contextSrv } from 'app/core/core'; +import { fetchBuiltinRoles, fetchRoleOptions, UserRolePicker } from 'app/core/components/RolePicker/UserRolePicker'; + +export interface Props { + serviceAccounts: OrgServiceAccount[]; + orgId?: number; + onRoleChange: (role: OrgRole, serviceaccount: OrgServiceAccount) => void; + onRemoveServiceaccount: (serviceaccount: OrgServiceAccount) => void; +} + +const ServiceaccountsTable: FC = (props) => { + const { serviceAccounts, orgId, onRoleChange, onRemoveServiceaccount: onRemoveserviceaccount } = props; + const canUpdateRole = contextSrv.hasPermission(AccessControlAction.OrgUsersRoleUpdate); + const canRemoveFromOrg = contextSrv.hasPermission(AccessControlAction.OrgUsersRemove); + const rolePickerDisabled = !canUpdateRole; + + const [showRemoveModal, setShowRemoveModal] = useState(false); + const [roleOptions, setRoleOptions] = useState([]); + const [builtinRoles, setBuiltinRoles] = useState>({}); + + useEffect(() => { + async function fetchOptions() { + try { + let options = await fetchRoleOptions(orgId); + setRoleOptions(options); + const builtInRoles = await fetchBuiltinRoles(orgId); + setBuiltinRoles(builtInRoles); + } catch (e) { + console.error('Error loading options'); + } + } + if (contextSrv.accessControlEnabled()) { + fetchOptions(); + } + }, [orgId]); + + const getRoleOptions = async () => roleOptions; + const getBuiltinRoles = async () => builtinRoles; + + return ( + + + + + + + + + + + + {serviceAccounts.map((serviceAccount, index) => { + return ( + + + + + + + + + + + {canRemoveFromOrg && ( + + )} + + ); + })} + +
+ LoginEmailNameSeenRole +
+ serviceaccount avatar + + + {serviceAccount.login} + + + + {serviceAccount.email} + + + + {serviceAccount.name} + + {serviceAccount.lastSeenAtAge} + {contextSrv.accessControlEnabled() ? ( + onRoleChange(newRole, serviceAccount)} + getRoleOptions={getRoleOptions} + getBuiltinRoles={getBuiltinRoles} + disabled={rolePickerDisabled} + /> + ) : ( + onRoleChange(newRole, serviceAccount)} + /> + )} + +
+ ); +}; + +export default ServiceaccountsTable; diff --git a/public/app/features/serviceaccounts/state/actions.ts b/public/app/features/serviceaccounts/state/actions.ts new file mode 100644 index 00000000000..3066bb3af6e --- /dev/null +++ b/public/app/features/serviceaccounts/state/actions.ts @@ -0,0 +1,28 @@ +import { ThunkResult } from '../../../types'; +import { getBackendSrv } from '@grafana/runtime'; +import { OrgServiceAccount as OrgServiceAccount } from 'app/types'; +import { serviceAccountsLoaded } from './reducers'; + +export function loadServiceAccounts(): ThunkResult { + return async (dispatch) => { + const serviceAccounts = await getBackendSrv().get('/api/serviceaccounts'); + dispatch(serviceAccountsLoaded(serviceAccounts)); + }; +} + +export function updateServiceAccount(serviceAccount: OrgServiceAccount): ThunkResult { + return async (dispatch) => { + // TODO: implement on backend + await getBackendSrv().patch(`/api/serviceaccounts/${serviceAccount.serviceAccountId}`, { + role: serviceAccount.role, + }); + dispatch(loadServiceAccounts()); + }; +} + +export function removeServiceAccount(serviceAccountId: number): ThunkResult { + return async (dispatch) => { + await getBackendSrv().delete(`/api/serviceaccounts/${serviceAccountId}`); + dispatch(loadServiceAccounts()); + }; +} diff --git a/public/app/features/serviceaccounts/state/reducers.ts b/public/app/features/serviceaccounts/state/reducers.ts new file mode 100644 index 00000000000..9243e07f88b --- /dev/null +++ b/public/app/features/serviceaccounts/state/reducers.ts @@ -0,0 +1,39 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; + +import { OrgServiceAccount, ServiceAccountsState } from 'app/types'; + +export const initialState: ServiceAccountsState = { + serviceAccounts: [] as OrgServiceAccount[], + searchQuery: '', + searchPage: 1, + isLoading: true, +}; + +const serviceAccountsSlice = createSlice({ + name: 'serviceaccounts', + initialState, + reducers: { + serviceAccountsLoaded: (state, action: PayloadAction): ServiceAccountsState => { + return { ...state, isLoading: true, serviceAccounts: action.payload }; + }, + setServiceAccountsSearchQuery: (state, action: PayloadAction): ServiceAccountsState => { + // reset searchPage otherwise search results won't appear + return { ...state, searchQuery: action.payload, searchPage: initialState.searchPage }; + }, + setServiceAccountsSearchPage: (state, action: PayloadAction): ServiceAccountsState => { + return { ...state, searchPage: action.payload }; + }, + }, +}); + +export const { + setServiceAccountsSearchQuery, + setServiceAccountsSearchPage, + serviceAccountsLoaded, +} = serviceAccountsSlice.actions; + +export const serviceAccountsReducer = serviceAccountsSlice.reducer; + +export default { + serviceAccounts: serviceAccountsReducer, +}; diff --git a/public/app/features/serviceaccounts/state/selectors.ts b/public/app/features/serviceaccounts/state/selectors.ts new file mode 100644 index 00000000000..2efdbc5bad8 --- /dev/null +++ b/public/app/features/serviceaccounts/state/selectors.ts @@ -0,0 +1,12 @@ +import { ServiceAccountsState } from 'app/types'; + +export const getServiceAccounts = (state: ServiceAccountsState) => { + const regex = new RegExp(state.searchQuery, 'i'); + + return state.serviceAccounts.filter((serviceaccount) => { + return regex.test(serviceaccount.login) || regex.test(serviceaccount.email) || regex.test(serviceaccount.name); + }); +}; + +export const getServiceAccountsSearchQuery = (state: ServiceAccountsState) => state.searchQuery; +export const getServiceAccountsSearchPage = (state: ServiceAccountsState) => state.searchPage; diff --git a/public/app/features/users/UsersTable.tsx b/public/app/features/users/UsersTable.tsx index 7429a6320e0..24ddbb82d8c 100644 --- a/public/app/features/users/UsersTable.tsx +++ b/public/app/features/users/UsersTable.tsx @@ -16,7 +16,7 @@ export interface Props { const UsersTable: FC = (props) => { const { users, orgId, onRoleChange, onRemoveUser } = props; - const [showRemoveModal, setShowRemoveModal] = useState(false); + const [showRemoveModal, setShowRemoveModal] = useState(false); const [roleOptions, setRoleOptions] = useState([]); const [builtinRoles, setBuiltinRoles] = useState<{ [key: string]: Role[] }>({}); @@ -103,7 +103,7 @@ const UsersTable: FC = (props) => {