From 14ec0cbd3b33759346c9dac0bbd7b7ed87bb3efb Mon Sep 17 00:00:00 2001 From: J Guerreiro Date: Fri, 25 Feb 2022 10:33:34 +0000 Subject: [PATCH] Service Accounts: Polish service account detail page (#45846) * ServiceAccounts: add teams to service account DTO * ServiceAccounts: Add team display to service accounts * ServiceAccounts: add AC metadata to detail route * ServiceAccounts: add role picker to detail page * ServiceAccounts: Add role to profile DTO * ServiceAccounts: remove wip mention of created by --- pkg/services/serviceaccounts/api/api.go | 4 +- .../serviceaccounts/database/database.go | 14 +++++ .../serviceaccounts/database/database_test.go | 19 ++++++ pkg/services/serviceaccounts/models.go | 2 + .../serviceaccounts/ServiceAccountPage.tsx | 27 ++++++++- .../serviceaccounts/ServiceAccountProfile.tsx | 23 ++++++-- .../serviceaccounts/ServiceAccountRoleRow.tsx | 58 +++++++++++++++++++ .../features/serviceaccounts/state/actions.ts | 2 +- public/app/types/serviceaccount.ts | 1 + 9 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 public/app/features/serviceaccounts/ServiceAccountRoleRow.tsx diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index 7fba7661d61..c6742b5a945 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -197,8 +197,10 @@ func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) re } } + saIDString := strconv.FormatInt(serviceAccount.Id, 10) + metadata := api.getAccessControlMetadata(ctx, map[string]bool{saIDString: true}) serviceAccount.AvatarUrl = dtos.GetGravatarUrlWithDefault("", serviceAccount.Name) - + serviceAccount.AccessControl = metadata[saIDString] return response.JSON(http.StatusOK, serviceAccount) } diff --git a/pkg/services/serviceaccounts/database/database.go b/pkg/services/serviceaccounts/database/database.go index 0b0da8e5d0b..d9ec41dd7e1 100644 --- a/pkg/services/serviceaccounts/database/database.go +++ b/pkg/services/serviceaccounts/database/database.go @@ -178,6 +178,18 @@ func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, o return nil, serviceaccounts.ErrServiceAccountNotFound } + // Get Teams of service account. Can be optimized by combining with the query above + // in refactor + getTeamQuery := models.GetTeamsByUserQuery{UserId: serviceAccountID, OrgId: orgID} + if err := s.sqlStore.GetTeamsByUser(ctx, &getTeamQuery); err != nil { + return nil, err + } + teams := make([]string, len(getTeamQuery.Result)) + + for i := range getTeamQuery.Result { + teams[i] = getTeamQuery.Result[i].Name + } + saProfile := &serviceaccounts.ServiceAccountProfileDTO{ Id: query.Result[0].UserId, Name: query.Result[0].Name, @@ -185,6 +197,8 @@ func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, o OrgId: query.Result[0].OrgId, UpdatedAt: query.Result[0].Updated, CreatedAt: query.Result[0].Created, + Role: query.Result[0].Role, + Teams: teams, } return saProfile, nil } diff --git a/pkg/services/serviceaccounts/database/database_test.go b/pkg/services/serviceaccounts/database/database_test.go index 799cc1ac8ce..dcf74047c7d 100644 --- a/pkg/services/serviceaccounts/database/database_test.go +++ b/pkg/services/serviceaccounts/database/database_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/services/serviceaccounts" "github.com/grafana/grafana/pkg/services/serviceaccounts/tests" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -76,7 +77,25 @@ func TestStore_RetrieveServiceAccount(t *testing.T) { } else { require.NoError(t, err) require.Equal(t, c.user.Login, dto.Login) + require.Len(t, dto.Teams, 0) } }) } } +func TestStore_RetrieveServiceAccountWithTeams(t *testing.T) { + userToCreate := tests.TestUser{Login: "servicetestwithTeam@admin", IsServiceAccount: true} + db, store := setupTestDatabase(t) + user := tests.SetupUserServiceAccount(t, db, userToCreate) + + team, err := store.sqlStore.CreateTeam("serviceTeam", "serviceTeam", user.OrgId) + require.NoError(t, err) + + err = store.sqlStore.AddTeamMember(user.Id, user.OrgId, team.Id, false, models.PERMISSION_VIEW) + require.NoError(t, err) + + dto, err := store.RetrieveServiceAccount(context.Background(), user.OrgId, user.Id) + require.NoError(t, err) + require.Equal(t, userToCreate.Login, dto.Login) + require.Len(t, dto.Teams, 1) + require.Equal(t, "serviceTeam", dto.Teams[0]) +} diff --git a/pkg/services/serviceaccounts/models.go b/pkg/services/serviceaccounts/models.go index 81ad0337b4d..bd0d9e54108 100644 --- a/pkg/services/serviceaccounts/models.go +++ b/pkg/services/serviceaccounts/models.go @@ -53,5 +53,7 @@ type ServiceAccountProfileDTO struct { UpdatedAt time.Time `json:"updatedAt"` CreatedAt time.Time `json:"createdAt"` AvatarUrl string `json:"avatarUrl"` + Role string `json:"role"` + Teams []string `json:"teams"` AccessControl map[string]bool `json:"accessControl,omitempty"` } diff --git a/public/app/features/serviceaccounts/ServiceAccountPage.tsx b/public/app/features/serviceaccounts/ServiceAccountPage.tsx index 4d685ef0050..06df51c5cb2 100644 --- a/public/app/features/serviceaccounts/ServiceAccountPage.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountPage.tsx @@ -3,24 +3,29 @@ import { connect, ConnectedProps } from 'react-redux'; import { getNavModel } from 'app/core/selectors/navModel'; import Page from 'app/core/components/Page/Page'; import { ServiceAccountProfile } from './ServiceAccountProfile'; -import { StoreState, ServiceAccountDTO, ApiKey } from 'app/types'; +import { StoreState, ServiceAccountDTO, ApiKey, Role } from 'app/types'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { deleteServiceAccountToken, loadServiceAccount, loadServiceAccountTokens, createServiceAccountToken, + fetchACOptions, + updateServiceAccount, } from './state/actions'; import { ServiceAccountTokensTable } from './ServiceAccountTokensTable'; import { getTimeZone, NavModel } from '@grafana/data'; import { Button, VerticalGroup } from '@grafana/ui'; import { CreateTokenModal } from './CreateTokenModal'; +import { contextSrv } from 'app/core/core'; interface OwnProps extends GrafanaRouteComponentProps<{ id: string }> { navModel: NavModel; serviceAccount?: ServiceAccountDTO; tokens: ApiKey[]; isLoading: boolean; + roleOptions: Role[]; + builtInRoles: Record; } function mapStateToProps(state: StoreState) { @@ -29,6 +34,8 @@ function mapStateToProps(state: StoreState) { serviceAccount: state.serviceAccountProfile.serviceAccount, tokens: state.serviceAccountProfile.tokens, isLoading: state.serviceAccountProfile.isLoading, + roleOptions: state.serviceAccounts.roleOptions, + builtInRoles: state.serviceAccounts.builtInRoles, timezone: getTimeZone(state.user), }; } @@ -37,6 +44,7 @@ const mapDispatchToProps = { loadServiceAccountTokens, createServiceAccountToken, deleteServiceAccountToken, + fetchACOptions, }; const connector = connect(mapStateToProps, mapDispatchToProps); @@ -49,10 +57,13 @@ const ServiceAccountPageUnconnected = ({ tokens, timezone, isLoading, + roleOptions, + builtInRoles, loadServiceAccount, loadServiceAccountTokens, createServiceAccountToken, deleteServiceAccountToken, + fetchACOptions, }: Props) => { const [isModalOpen, setIsModalOpen] = useState(false); const [newToken, setNewToken] = useState(''); @@ -61,7 +72,10 @@ const ServiceAccountPageUnconnected = ({ const serviceAccountId = parseInt(match.params.id, 10); loadServiceAccount(serviceAccountId); loadServiceAccountTokens(serviceAccountId); - }, [match, loadServiceAccount, loadServiceAccountTokens]); + if (contextSrv.accessControlEnabled()) { + fetchACOptions(); + } + }, [match, loadServiceAccount, loadServiceAccountTokens, fetchACOptions]); const onDeleteServiceAccountToken = (key: ApiKey) => { deleteServiceAccountToken(parseInt(match.params.id, 10), key.id!); @@ -76,6 +90,12 @@ const ServiceAccountPageUnconnected = ({ setNewToken(''); }; + const onRoleChange = (role: OrgRole, serviceAccount: ServiceAccountDTO) => { + const updatedServiceAccount = { ...serviceAccount, role: role }; + + updateServiceAccount(updatedServiceAccount); + }; + return ( @@ -96,6 +116,9 @@ const ServiceAccountPageUnconnected = ({ onServiceAccountEnable={() => { console.log(`not implemented`); }} + onRoleChange={onRoleChange} + roleOptions={roleOptions} + builtInRoles={builtInRoles} /> )} diff --git a/public/app/features/serviceaccounts/ServiceAccountProfile.tsx b/public/app/features/serviceaccounts/ServiceAccountProfile.tsx index 1059c302e33..86c7b8c552a 100644 --- a/public/app/features/serviceaccounts/ServiceAccountProfile.tsx +++ b/public/app/features/serviceaccounts/ServiceAccountProfile.tsx @@ -1,9 +1,10 @@ import React, { PureComponent, useRef, useState } from 'react'; -import { ServiceAccountDTO } from 'app/types'; +import { Role, ServiceAccountDTO } from 'app/types'; import { css, cx } from '@emotion/css'; import { config } from 'app/core/config'; -import { dateTimeFormat, GrafanaTheme, TimeZone } from '@grafana/data'; +import { dateTimeFormat, GrafanaTheme, OrgRole, TimeZone } from '@grafana/data'; import { Button, ConfirmButton, ConfirmModal, Input, LegacyInputStatus, stylesFactory } from '@grafana/ui'; +import { ServiceAccountRoleRow } from './ServiceAccountRoleRow'; interface Props { serviceAccount: ServiceAccountDTO; @@ -13,6 +14,10 @@ interface Props { onServiceAccountDelete: (serviceAccountId: number) => void; onServiceAccountDisable: (serviceAccountId: number) => void; onServiceAccountEnable: (serviceAccountId: number) => void; + + onRoleChange: (role: OrgRole, serviceAccount: ServiceAccountDTO) => void; + roleOptions: Role[]; + builtInRoles: Record; } export function ServiceAccountProfile({ @@ -22,6 +27,9 @@ export function ServiceAccountProfile({ onServiceAccountDelete, onServiceAccountDisable, onServiceAccountEnable, + onRoleChange, + roleOptions, + builtInRoles, }: Props) { const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDisableModal, setShowDisableModal] = useState(false); @@ -73,9 +81,14 @@ export function ServiceAccountProfile({ onChange={onServiceAccountNameChange} /> - - - + + void; + roleOptions: Role[]; + builtInRoles: Record; +} + +export class ServiceAccountRoleRow extends PureComponent { + render() { + const { label, serviceAccount, roleOptions, builtInRoles, onRoleChange } = this.props; + const canUpdateRole = contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount); + const rolePickerDisabled = !canUpdateRole; + const labelClass = cx( + 'width-16', + css` + font-weight: 500; + ` + ); + + const inputId = `${label}-input`; + return ( + + + + + + {contextSrv.licensedAccessControlEnabled() ? ( + onRoleChange(newRole, serviceAccount)} + roleOptions={roleOptions} + builtInRoles={builtInRoles} + disabled={rolePickerDisabled} + /> + ) : ( + onRoleChange(newRole, serviceAccount)} + /> + )} + + + + ); + } +} diff --git a/public/app/features/serviceaccounts/state/actions.ts b/public/app/features/serviceaccounts/state/actions.ts index a908c19aad8..e372e9c1272 100644 --- a/public/app/features/serviceaccounts/state/actions.ts +++ b/public/app/features/serviceaccounts/state/actions.ts @@ -39,7 +39,7 @@ export function setServiceAccountToRemove(serviceAccount: ServiceAccountDTO | nu export function loadServiceAccount(saID: number): ThunkResult { return async (dispatch) => { try { - const response = await getBackendSrv().get(`${BASE_URL}/${saID}`); + const response = await getBackendSrv().get(`${BASE_URL}/${saID}`, accessControlQueryParam()); dispatch(serviceAccountLoaded(response)); } catch (error) { console.error(error); diff --git a/public/app/types/serviceaccount.ts b/public/app/types/serviceaccount.ts index 0ba970d9074..1a5c728b0dc 100644 --- a/public/app/types/serviceaccount.ts +++ b/public/app/types/serviceaccount.ts @@ -33,6 +33,7 @@ export interface ServiceAccountDTO extends WithAccessControlMetadata { avatarUrl?: string; createdAt: string; isDisabled: boolean; + teams: string[]; role: OrgRole; }