Serviceaccounts: feat - tabview for serviceaccounts (#43573)

This commit is contained in:
Eric Leijonmarck 2022-01-05 15:32:38 +01:00 committed by GitHub
parent 5c88acd5aa
commit 0aa905bb1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 435 additions and 5 deletions

View File

@ -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")

View File

@ -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{}

View File

@ -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
}

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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,

View File

@ -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<typeof connector>;
export interface State {}
const ITEMS_PER_PAGE = 30;
export class ServiceAccountsListPage extends PureComponent<Props, State> {
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 (
<VerticalGroup spacing="md">
<ServiceAccountsTable
serviceAccounts={paginatedServiceAccounts}
onRoleChange={(role, serviceAccount) => this.onRoleChange(role, serviceAccount)}
onRemoveServiceaccount={(serviceAccount) => this.props.removeServiceAccount(serviceAccount.serviceAccountId)}
/>
<HorizontalGroup justify="flex-end">
<Pagination
onNavigate={setServiceAccountsSearchPage}
currentPage={this.props.searchPage}
numberOfPages={totalPages}
hideWhenSinglePage={true}
/>
</HorizontalGroup>
</VerticalGroup>
);
}
render() {
const { navModel, hasFetched } = this.props;
return (
<Page navModel={navModel}>
<Page.Contents isLoading={!hasFetched}>
<>{hasFetched && this.renderTable()}</>
</Page.Contents>
</Page>
);
}
}
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);

View File

@ -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> = (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<Role[]>([]);
const [builtinRoles, setBuiltinRoles] = useState<Record<string, Role[]>>({});
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 (
<table className="filter-table form-inline">
<thead>
<tr>
<th />
<th>Login</th>
<th>Email</th>
<th>Name</th>
<th>Seen</th>
<th>Role</th>
<th style={{ width: '34px' }} />
</tr>
</thead>
<tbody>
{serviceAccounts.map((serviceAccount, index) => {
return (
<tr key={`${serviceAccount.serviceAccountId}-${index}`}>
<td className="width-2 text-center">
<img className="filter-table__avatar" src={serviceAccount.avatarUrl} alt="serviceaccount avatar" />
</td>
<td className="max-width-6">
<span className="ellipsis" title={serviceAccount.login}>
{serviceAccount.login}
</span>
</td>
<td className="max-width-5">
<span className="ellipsis" title={serviceAccount.email}>
{serviceAccount.email}
</span>
</td>
<td className="max-width-5">
<span className="ellipsis" title={serviceAccount.name}>
{serviceAccount.name}
</span>
</td>
<td className="width-1">{serviceAccount.lastSeenAtAge}</td>
<td className="width-8">
{contextSrv.accessControlEnabled() ? (
<UserRolePicker
userId={serviceAccount.serviceAccountId}
orgId={orgId}
builtInRole={serviceAccount.role}
onBuiltinRoleChange={(newRole) => onRoleChange(newRole, serviceAccount)}
getRoleOptions={getRoleOptions}
getBuiltinRoles={getBuiltinRoles}
disabled={rolePickerDisabled}
/>
) : (
<OrgRolePicker
aria-label="Role"
value={serviceAccount.role}
disabled={!canUpdateRole}
onChange={(newRole) => onRoleChange(newRole, serviceAccount)}
/>
)}
</td>
{canRemoveFromOrg && (
<td>
<Button
size="sm"
variant="destructive"
onClick={() => setShowRemoveModal(Boolean(serviceAccount.login))}
icon="times"
aria-label="Delete serviceaccount"
/>
<ConfirmModal
body={`Are you sure you want to delete serviceaccount ${serviceAccount.login}?`}
confirmText="Delete"
title="Delete"
onDismiss={() => setShowRemoveModal(false)}
isOpen={Boolean(serviceAccount.login) === showRemoveModal}
onConfirm={() => {
onRemoveserviceaccount(serviceAccount);
}}
/>
</td>
)}
</tr>
);
})}
</tbody>
</table>
);
};
export default ServiceaccountsTable;

View File

@ -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<void> {
return async (dispatch) => {
const serviceAccounts = await getBackendSrv().get('/api/serviceaccounts');
dispatch(serviceAccountsLoaded(serviceAccounts));
};
}
export function updateServiceAccount(serviceAccount: OrgServiceAccount): ThunkResult<void> {
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<void> {
return async (dispatch) => {
await getBackendSrv().delete(`/api/serviceaccounts/${serviceAccountId}`);
dispatch(loadServiceAccounts());
};
}

View File

@ -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<OrgServiceAccount[]>): ServiceAccountsState => {
return { ...state, isLoading: true, serviceAccounts: action.payload };
},
setServiceAccountsSearchQuery: (state, action: PayloadAction<string>): ServiceAccountsState => {
// reset searchPage otherwise search results won't appear
return { ...state, searchQuery: action.payload, searchPage: initialState.searchPage };
},
setServiceAccountsSearchPage: (state, action: PayloadAction<number>): ServiceAccountsState => {
return { ...state, searchPage: action.payload };
},
},
});
export const {
setServiceAccountsSearchQuery,
setServiceAccountsSearchPage,
serviceAccountsLoaded,
} = serviceAccountsSlice.actions;
export const serviceAccountsReducer = serviceAccountsSlice.reducer;
export default {
serviceAccounts: serviceAccountsReducer,
};

View File

@ -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;

View File

@ -16,7 +16,7 @@ export interface Props {
const UsersTable: FC<Props> = (props) => {
const { users, orgId, onRoleChange, onRemoveUser } = props;
const [showRemoveModal, setShowRemoveModal] = useState<string | boolean>(false);
const [showRemoveModal, setShowRemoveModal] = useState(false);
const [roleOptions, setRoleOptions] = useState<Role[]>([]);
const [builtinRoles, setBuiltinRoles] = useState<{ [key: string]: Role[] }>({});
@ -103,7 +103,7 @@ const UsersTable: FC<Props> = (props) => {
<Button
size="sm"
variant="destructive"
onClick={() => setShowRemoveModal(user.login)}
onClick={() => setShowRemoveModal(Boolean(user.login))}
icon="times"
aria-label="Delete user"
/>
@ -112,7 +112,7 @@ const UsersTable: FC<Props> = (props) => {
confirmText="Delete"
title="Delete"
onDismiss={() => setShowRemoveModal(false)}
isOpen={user.login === showRemoveModal}
isOpen={Boolean(user.login) === showRemoveModal}
onConfirm={() => {
onRemoveUser(user);
}}

View File

@ -182,6 +182,14 @@ export function getAppRoutes(): RouteDescriptor[] {
() => import(/* webpackChunkName: "ApiKeysPage" */ 'app/features/api-keys/ApiKeysPage')
),
},
{
path: '/org/serviceaccounts',
roles: () => ['Editor', 'Admin'],
component: SafeDynamicImport(
() =>
import(/* webpackChunkName: "ServiceAccountsPage" */ 'app/features/serviceaccounts/ServiceAccountsListPage')
),
},
{
path: '/org/teams',
roles: () => (config.editorsCanAdmin ? [] : ['Editor', 'Admin']),

View File

@ -6,6 +6,7 @@ export * from './dashboard';
export * from './acl';
export * from './apiKeys';
export * from './user';
export * from './serviceaccount';
export * from './datasources';
export * from './plugins';
export * from './organization';

View File

@ -0,0 +1,76 @@
import { OrgRole, Unit } from '.';
import { SelectableValue } from '@grafana/data';
export interface OrgServiceAccount {
avatarUrl: string;
email: string;
lastSeenAt: string;
lastSeenAtAge: string;
login: string;
name: string;
orgId: number;
role: OrgRole;
serviceAccountId: number;
}
export interface ServiceAccount {
id: number;
label: string;
avatarUrl: string;
login: string;
email: string;
name: string;
orgId?: number;
}
export interface ServiceAccountDTO {
id: number;
login: string;
email: string;
name: string;
isGrafanaAdmin: boolean;
isDisabled: boolean;
isAdmin?: boolean;
updatedAt?: string;
authLabels?: string[];
avatarUrl?: string;
orgId?: number;
lastSeenAtAge?: string;
licensedRole?: string;
permissions?: string[];
teams?: Unit[];
orgs?: Unit[];
}
export interface ServiceAccountsState {
serviceAccounts: OrgServiceAccount[];
searchQuery: string;
searchPage: number;
isLoading: boolean;
}
export interface ServiceAccountSession {
id: number;
createdAt: string;
clientIp: string;
isActive: boolean;
seenAt: string;
}
export interface ServiceAccountOrg {
name: string;
orgId: number;
role: OrgRole;
}
export type ServiceAccountFilter = Record<string, string | boolean | SelectableValue[]>;
export interface ServiceaccountListAdminState {
serviceaccounts: ServiceAccountDTO[];
query: string;
perPage: number;
page: number;
totalPages: number;
showPaging: boolean;
filters: ServiceAccountFilter[];
isLoading: boolean;
}