mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Service accounts: RBAC the service account UI (#47788)
* WIP * fix: bug for saving name did not remove edit * refactor: better error msg * Display the column Roles even when user can't see the role picker * Remove spaces when building the search query request * Disable Edit button and fix token addition and deletion * Fix the error message text Co-authored-by: Vardan Torosyan <vardants@gmail.com>
This commit is contained in:
parent
2a178bd73c
commit
b43e9b50b4
@ -93,7 +93,7 @@ func (api *ServiceAccountsAPI) CreateServiceAccount(c *models.ReqContext) respon
|
|||||||
serviceAccount, err := api.store.CreateServiceAccount(c.Req.Context(), c.OrgId, cmd.Name)
|
serviceAccount, err := api.store.CreateServiceAccount(c.Req.Context(), c.OrgId, cmd.Name)
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, &database.ErrSAInvalidName{}):
|
case errors.Is(err, &database.ErrSAInvalidName{}):
|
||||||
return response.Error(http.StatusBadRequest, "Invalid service account name", err)
|
return response.Error(http.StatusBadRequest, "Failed due to %s", err)
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return response.Error(http.StatusInternalServerError, "Failed to create service account", err)
|
return response.Error(http.StatusInternalServerError, "Failed to create service account", err)
|
||||||
}
|
}
|
||||||
|
@ -17,14 +17,14 @@ func (e *ErrSAInvalidName) Unwrap() error {
|
|||||||
return models.ErrUserAlreadyExists
|
return models.ErrUserAlreadyExists
|
||||||
}
|
}
|
||||||
|
|
||||||
type ErrMisingSAToken struct {
|
type ErrMissingSAToken struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ErrMisingSAToken) Error() string {
|
func (e *ErrMissingSAToken) Error() string {
|
||||||
return "service account token not found"
|
return "service account token not found"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ErrMisingSAToken) Unwrap() error {
|
func (e *ErrMissingSAToken) Unwrap() error {
|
||||||
return models.ErrApiKeyNotFound
|
return models.ErrApiKeyNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,7 +44,7 @@ type ErrDuplicateSAToken struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *ErrDuplicateSAToken) Error() string {
|
func (e *ErrDuplicateSAToken) Error() string {
|
||||||
return fmt.Sprintf("service account token %s already exists", e.name)
|
return fmt.Sprintf("service account token %s already exists in the organization", e.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *ErrDuplicateSAToken) Unwrap() error {
|
func (e *ErrDuplicateSAToken) Unwrap() error {
|
||||||
|
@ -57,7 +57,7 @@ func (s *ServiceAccountsStoreImpl) DeleteServiceAccountToken(ctx context.Context
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
} else if n == 0 {
|
} else if n == 0 {
|
||||||
return &ErrMisingSAToken{}
|
return &ErrMissingSAToken{}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
@ -3,7 +3,7 @@ import { connect, ConnectedProps } from 'react-redux';
|
|||||||
import { getNavModel } from 'app/core/selectors/navModel';
|
import { getNavModel } from 'app/core/selectors/navModel';
|
||||||
import Page from 'app/core/components/Page/Page';
|
import Page from 'app/core/components/Page/Page';
|
||||||
import { ServiceAccountProfile } from './ServiceAccountProfile';
|
import { ServiceAccountProfile } from './ServiceAccountProfile';
|
||||||
import { StoreState, ServiceAccountDTO, ApiKey, Role } from 'app/types';
|
import { StoreState, ServiceAccountDTO, ApiKey, Role, AccessControlAction } from 'app/types';
|
||||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
|
||||||
import {
|
import {
|
||||||
deleteServiceAccountToken,
|
deleteServiceAccountToken,
|
||||||
@ -114,12 +114,24 @@ const ServiceAccountPageUnconnected = ({
|
|||||||
<h3 className="page-heading" style={{ marginBottom: '0px' }}>
|
<h3 className="page-heading" style={{ marginBottom: '0px' }}>
|
||||||
Tokens
|
Tokens
|
||||||
</h3>
|
</h3>
|
||||||
<Button onClick={() => setIsModalOpen(true)}>Add token</Button>
|
<Button
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
disabled={!contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite)}
|
||||||
|
>
|
||||||
|
Add token
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{tokens && (
|
{tokens && (
|
||||||
<ServiceAccountTokensTable tokens={tokens} timeZone={timezone} onDelete={onDeleteServiceAccountToken} />
|
<ServiceAccountTokensTable tokens={tokens} timeZone={timezone} onDelete={onDeleteServiceAccountToken} />
|
||||||
)}
|
)}
|
||||||
<CreateTokenModal isOpen={isModalOpen} token={newToken} onCreateToken={onCreateToken} onClose={onModalClose} />
|
{contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite) && (
|
||||||
|
<CreateTokenModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
token={newToken}
|
||||||
|
onCreateToken={onCreateToken}
|
||||||
|
onClose={onModalClose}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Page.Contents>
|
</Page.Contents>
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
|
@ -1,9 +1,10 @@
|
|||||||
import React, { PureComponent, useRef, useState } from 'react';
|
import React, { PureComponent, useRef, useState } from 'react';
|
||||||
import { Role, ServiceAccountDTO } from 'app/types';
|
import { Role, ServiceAccountDTO, AccessControlAction } from 'app/types';
|
||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { dateTimeFormat, GrafanaTheme2, OrgRole, TimeZone } from '@grafana/data';
|
import { dateTimeFormat, GrafanaTheme2, OrgRole, TimeZone } from '@grafana/data';
|
||||||
import { Button, ConfirmButton, ConfirmModal, Input, LegacyInputStatus, useStyles2 } from '@grafana/ui';
|
import { Button, ConfirmButton, ConfirmModal, Input, LegacyInputStatus, useStyles2 } from '@grafana/ui';
|
||||||
import { ServiceAccountRoleRow } from './ServiceAccountRoleRow';
|
import { ServiceAccountRoleRow } from './ServiceAccountRoleRow';
|
||||||
|
import { contextSrv } from 'app/core/core';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
serviceAccount: ServiceAccountDTO;
|
serviceAccount: ServiceAccountDTO;
|
||||||
@ -26,6 +27,8 @@ export function ServiceAccountProfile({
|
|||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||||
const [showDisableModal, setShowDisableModal] = useState(false);
|
const [showDisableModal, setShowDisableModal] = useState(false);
|
||||||
|
|
||||||
|
const ableToWrite = contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite);
|
||||||
|
|
||||||
const deleteServiceAccountRef = useRef<HTMLButtonElement | null>(null);
|
const deleteServiceAccountRef = useRef<HTMLButtonElement | null>(null);
|
||||||
const showDeleteServiceAccountModal = (show: boolean) => () => {
|
const showDeleteServiceAccountModal = (show: boolean) => () => {
|
||||||
setShowDeleteModal(show);
|
setShowDeleteModal(show);
|
||||||
@ -83,9 +86,10 @@ export function ServiceAccountProfile({
|
|||||||
<table className="filter-table form-inline">
|
<table className="filter-table form-inline">
|
||||||
<tbody>
|
<tbody>
|
||||||
<ServiceAccountProfileRow
|
<ServiceAccountProfileRow
|
||||||
label="Display Name"
|
label="Name"
|
||||||
value={serviceAccount.name}
|
value={serviceAccount.name}
|
||||||
onChange={onServiceAccountNameChange}
|
onChange={onServiceAccountNameChange}
|
||||||
|
disabled={!ableToWrite}
|
||||||
/>
|
/>
|
||||||
<ServiceAccountProfileRow label="ID" value={serviceAccount.login} />
|
<ServiceAccountProfileRow label="ID" value={serviceAccount.login} />
|
||||||
<ServiceAccountRoleRow
|
<ServiceAccountRoleRow
|
||||||
@ -110,6 +114,7 @@ export function ServiceAccountProfile({
|
|||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={showDeleteServiceAccountModal(true)}
|
onClick={showDeleteServiceAccountModal(true)}
|
||||||
ref={deleteServiceAccountRef}
|
ref={deleteServiceAccountRef}
|
||||||
|
disabled={!contextSrv.hasPermission(AccessControlAction.ServiceAccountsDelete)}
|
||||||
>
|
>
|
||||||
Delete service account
|
Delete service account
|
||||||
</Button>
|
</Button>
|
||||||
@ -123,7 +128,7 @@ export function ServiceAccountProfile({
|
|||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
{serviceAccount.isDisabled ? (
|
{serviceAccount.isDisabled ? (
|
||||||
<Button type={'button'} variant="secondary" onClick={handleServiceAccountEnable}>
|
<Button type={'button'} variant="secondary" onClick={handleServiceAccountEnable} disabled={!ableToWrite}>
|
||||||
Enable service account
|
Enable service account
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
@ -133,6 +138,7 @@ export function ServiceAccountProfile({
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={showDisableServiceAccountModal(true)}
|
onClick={showDisableServiceAccountModal(true)}
|
||||||
ref={disableServiceAccountRef}
|
ref={disableServiceAccountRef}
|
||||||
|
disabled={!ableToWrite}
|
||||||
>
|
>
|
||||||
Disable service account
|
Disable service account
|
||||||
</Button>
|
</Button>
|
||||||
@ -168,6 +174,7 @@ interface ServiceAccountProfileRowProps {
|
|||||||
value?: string;
|
value?: string;
|
||||||
inputType?: string;
|
inputType?: string;
|
||||||
onChange?: (value: string) => void;
|
onChange?: (value: string) => void;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ServiceAccountProfileRowState {
|
interface ServiceAccountProfileRowState {
|
||||||
@ -226,6 +233,7 @@ export class ServiceAccountProfileRow extends PureComponent<
|
|||||||
};
|
};
|
||||||
|
|
||||||
onSave = () => {
|
onSave = () => {
|
||||||
|
this.setState({ editing: false });
|
||||||
if (this.props.onChange) {
|
if (this.props.onChange) {
|
||||||
this.props.onChange(this.state.value);
|
this.props.onChange(this.state.value);
|
||||||
}
|
}
|
||||||
@ -248,7 +256,7 @@ export class ServiceAccountProfileRow extends PureComponent<
|
|||||||
<label htmlFor={inputId}>{label}</label>
|
<label htmlFor={inputId}>{label}</label>
|
||||||
</td>
|
</td>
|
||||||
<td className="width-25" colSpan={2}>
|
<td className="width-25" colSpan={2}>
|
||||||
{this.state.editing ? (
|
{!this.props.disabled && this.state.editing ? (
|
||||||
<Input
|
<Input
|
||||||
id={inputId}
|
id={inputId}
|
||||||
type={inputType}
|
type={inputType}
|
||||||
@ -265,10 +273,12 @@ export class ServiceAccountProfileRow extends PureComponent<
|
|||||||
<td>
|
<td>
|
||||||
{this.props.onChange && (
|
{this.props.onChange && (
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
|
closeOnConfirm
|
||||||
confirmText="Save"
|
confirmText="Save"
|
||||||
onClick={this.onEditClick}
|
|
||||||
onConfirm={this.onSave}
|
onConfirm={this.onSave}
|
||||||
|
onClick={this.onEditClick}
|
||||||
onCancel={this.onCancelClick}
|
onCancel={this.onCancelClick}
|
||||||
|
disabled={this.props.disabled}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</ConfirmButton>
|
</ConfirmButton>
|
||||||
|
@ -2,8 +2,10 @@ import React, { FC } from 'react';
|
|||||||
import { DeleteButton, Icon, Tooltip, useStyles2, useTheme2 } from '@grafana/ui';
|
import { DeleteButton, Icon, Tooltip, useStyles2, useTheme2 } from '@grafana/ui';
|
||||||
import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data';
|
import { dateTimeFormat, GrafanaTheme2, TimeZone } from '@grafana/data';
|
||||||
|
|
||||||
|
import { AccessControlAction } from 'app/types';
|
||||||
import { ApiKey } from '../../types';
|
import { ApiKey } from '../../types';
|
||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
|
import { contextSrv } from 'app/core/core';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
tokens: ApiKey[];
|
tokens: ApiKey[];
|
||||||
@ -35,9 +37,11 @@ export const ServiceAccountTokensTable: FC<Props> = ({ tokens, timeZone, onDelet
|
|||||||
<TokenExpiration timeZone={timeZone} token={key} />
|
<TokenExpiration timeZone={timeZone} token={key} />
|
||||||
</td>
|
</td>
|
||||||
<td>{formatDate(timeZone, key.created)}</td>
|
<td>{formatDate(timeZone, key.created)}</td>
|
||||||
<td>
|
{contextSrv.hasPermission(AccessControlAction.ServiceAccountsDelete) && (
|
||||||
<DeleteButton aria-label="Delete API key" size="sm" onConfirm={() => onDelete(key)} />
|
<td>
|
||||||
</td>
|
<DeleteButton aria-label="Delete service account token" size="sm" onConfirm={() => onDelete(key)} />
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -82,7 +86,7 @@ const TokenExpiration = ({ timeZone, token }: TokenExpirationProps) => {
|
|||||||
<span className={styles.hasExpired}>
|
<span className={styles.hasExpired}>
|
||||||
Expired
|
Expired
|
||||||
<span className={styles.tooltipContainer}>
|
<span className={styles.tooltipContainer}>
|
||||||
<Tooltip content="This API key has expired.">
|
<Tooltip content="This token has expired">
|
||||||
<Icon name="exclamation-triangle" className={styles.toolTipIcon} />
|
<Icon name="exclamation-triangle" className={styles.toolTipIcon} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</span>
|
</span>
|
||||||
|
@ -65,8 +65,8 @@ const ServiceAccountListItem = memo(
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
{contextSrv.licensedAccessControlEnabled() ? (
|
{contextSrv.licensedAccessControlEnabled() ? (
|
||||||
displayRolePicker && (
|
<td className={cx('link-td', styles.iconRow)}>
|
||||||
<td className={cx('link-td', styles.iconRow)}>
|
{displayRolePicker && (
|
||||||
<UserRolePicker
|
<UserRolePicker
|
||||||
userId={serviceAccount.id}
|
userId={serviceAccount.id}
|
||||||
orgId={serviceAccount.orgId}
|
orgId={serviceAccount.orgId}
|
||||||
@ -76,8 +76,8 @@ const ServiceAccountListItem = memo(
|
|||||||
builtInRoles={builtInRoles}
|
builtInRoles={builtInRoles}
|
||||||
disabled={!enableRolePicker}
|
disabled={!enableRolePicker}
|
||||||
/>
|
/>
|
||||||
</td>
|
)}
|
||||||
)
|
</td>
|
||||||
) : (
|
) : (
|
||||||
<td className={cx('link-td', styles.iconRow)}>
|
<td className={cx('link-td', styles.iconRow)}>
|
||||||
<OrgRolePicker
|
<OrgRolePicker
|
||||||
|
Loading…
Reference in New Issue
Block a user