mirror of https://github.com/grafana/grafana.git
IAM: Protect external service accounts frontend list page (#77834)
* Add `isExternal` property to frontend model * Remove enabled and token buttons for external SA * Replace trash icon for lock icon for external SA * Block the role picker for external SA * Filter SA list using the external filter * Add only external filter at backend --------- Co-authored-by: Gabriel MABILLE <gamab@users.noreply.github.com>
This commit is contained in:
parent
d4322f6e5a
commit
5bc4f56c79
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||||
"github.com/grafana/grafana/pkg/services/auth/identity"
|
"github.com/grafana/grafana/pkg/services/auth/identity"
|
||||||
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model"
|
||||||
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||||
"github.com/grafana/grafana/pkg/services/org"
|
"github.com/grafana/grafana/pkg/services/org"
|
||||||
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
"github.com/grafana/grafana/pkg/services/serviceaccounts"
|
||||||
"github.com/grafana/grafana/pkg/setting"
|
"github.com/grafana/grafana/pkg/setting"
|
||||||
|
|
@ -27,6 +28,7 @@ type ServiceAccountsAPI struct {
|
||||||
RouterRegister routing.RouteRegister
|
RouterRegister routing.RouteRegister
|
||||||
log log.Logger
|
log log.Logger
|
||||||
permissionService accesscontrol.ServiceAccountPermissionsService
|
permissionService accesscontrol.ServiceAccountPermissionsService
|
||||||
|
isExternalSAEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServiceAccountsAPI(
|
func NewServiceAccountsAPI(
|
||||||
|
|
@ -36,6 +38,7 @@ func NewServiceAccountsAPI(
|
||||||
accesscontrolService accesscontrol.Service,
|
accesscontrolService accesscontrol.Service,
|
||||||
routerRegister routing.RouteRegister,
|
routerRegister routing.RouteRegister,
|
||||||
permissionService accesscontrol.ServiceAccountPermissionsService,
|
permissionService accesscontrol.ServiceAccountPermissionsService,
|
||||||
|
features *featuremgmt.FeatureManager,
|
||||||
) *ServiceAccountsAPI {
|
) *ServiceAccountsAPI {
|
||||||
return &ServiceAccountsAPI{
|
return &ServiceAccountsAPI{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
|
|
@ -45,6 +48,7 @@ func NewServiceAccountsAPI(
|
||||||
RouterRegister: routerRegister,
|
RouterRegister: routerRegister,
|
||||||
log: log.New("serviceaccounts.api"),
|
log: log.New("serviceaccounts.api"),
|
||||||
permissionService: permissionService,
|
permissionService: permissionService,
|
||||||
|
isExternalSAEnabled: features.IsEnabled(featuremgmt.FlagExternalServiceAccounts) || features.IsEnabled(featuremgmt.FlagExternalServiceAuth),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -265,10 +269,14 @@ func (api *ServiceAccountsAPI) SearchOrgServiceAccountsWithPaging(c *contextmode
|
||||||
// its okay that it fails, it is only filtering that might be weird, but to safe quard against any weird incoming query param
|
// its okay that it fails, it is only filtering that might be weird, but to safe quard against any weird incoming query param
|
||||||
onlyWithExpiredTokens := c.QueryBool("expiredTokens")
|
onlyWithExpiredTokens := c.QueryBool("expiredTokens")
|
||||||
onlyDisabled := c.QueryBool("disabled")
|
onlyDisabled := c.QueryBool("disabled")
|
||||||
|
onlyExternal := c.QueryBool("external")
|
||||||
filter := serviceaccounts.FilterIncludeAll
|
filter := serviceaccounts.FilterIncludeAll
|
||||||
if onlyWithExpiredTokens {
|
if onlyWithExpiredTokens {
|
||||||
filter = serviceaccounts.FilterOnlyExpiredTokens
|
filter = serviceaccounts.FilterOnlyExpiredTokens
|
||||||
}
|
}
|
||||||
|
if api.isExternalSAEnabled && onlyExternal {
|
||||||
|
filter = serviceaccounts.FilterOnlyExternal
|
||||||
|
}
|
||||||
if onlyDisabled {
|
if onlyDisabled {
|
||||||
filter = serviceaccounts.FilterOnlyDisabled
|
filter = serviceaccounts.FilterOnlyDisabled
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ type ServiceAccountDTO struct {
|
||||||
// example: false
|
// example: false
|
||||||
IsDisabled bool `json:"isDisabled" xorm:"is_disabled"`
|
IsDisabled bool `json:"isDisabled" xorm:"is_disabled"`
|
||||||
// example: false
|
// example: false
|
||||||
IsManaged bool `json:"isManaged,omitempty" xorm:"-"`
|
IsExternal bool `json:"isExternal,omitempty" xorm:"-"`
|
||||||
// example: Viewer
|
// example: Viewer
|
||||||
Role string `json:"role" xorm:"role"`
|
Role string `json:"role" xorm:"role"`
|
||||||
// example: 0
|
// example: 0
|
||||||
|
|
@ -157,7 +157,7 @@ type ServiceAccountProfileDTO struct {
|
||||||
// example: []
|
// example: []
|
||||||
Teams []string `json:"teams" xorm:"-"`
|
Teams []string `json:"teams" xorm:"-"`
|
||||||
// example: false
|
// example: false
|
||||||
IsManaged bool `json:"isManaged,omitempty" xorm:"-"`
|
IsExternal bool `json:"isExternal,omitempty" xorm:"-"`
|
||||||
|
|
||||||
Tokens int64 `json:"tokens,omitempty"`
|
Tokens int64 `json:"tokens,omitempty"`
|
||||||
AccessControl map[string]bool `json:"accessControl,omitempty" xorm:"-"`
|
AccessControl map[string]bool `json:"accessControl,omitempty" xorm:"-"`
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ func ProvideServiceAccountsProxy(
|
||||||
isProxyEnabled: features.IsEnabled(featuremgmt.FlagExternalServiceAccounts) || features.IsEnabled(featuremgmt.FlagExternalServiceAuth),
|
isProxyEnabled: features.IsEnabled(featuremgmt.FlagExternalServiceAccounts) || features.IsEnabled(featuremgmt.FlagExternalServiceAuth),
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceaccountsAPI := api.NewServiceAccountsAPI(cfg, s, ac, accesscontrolService, routeRegister, permissionService)
|
serviceaccountsAPI := api.NewServiceAccountsAPI(cfg, s, ac, accesscontrolService, routeRegister, permissionService, features)
|
||||||
serviceaccountsAPI.RegisterAPIEndpoints()
|
serviceaccountsAPI.RegisterAPIEndpoints()
|
||||||
|
|
||||||
return s, nil
|
return s, nil
|
||||||
|
|
@ -138,7 +138,7 @@ func (s *ServiceAccountsProxy) RetrieveServiceAccount(ctx context.Context, orgID
|
||||||
}
|
}
|
||||||
|
|
||||||
if s.isProxyEnabled {
|
if s.isProxyEnabled {
|
||||||
sa.IsManaged = isExternalServiceAccount(sa.Login)
|
sa.IsExternal = isExternalServiceAccount(sa.Login)
|
||||||
}
|
}
|
||||||
|
|
||||||
return sa, nil
|
return sa, nil
|
||||||
|
|
@ -175,7 +175,7 @@ func (s *ServiceAccountsProxy) SearchOrgServiceAccounts(ctx context.Context, que
|
||||||
|
|
||||||
if s.isProxyEnabled {
|
if s.isProxyEnabled {
|
||||||
for i := range sa.ServiceAccounts {
|
for i := range sa.ServiceAccounts {
|
||||||
sa.ServiceAccounts[i].IsManaged = isExternalServiceAccount(sa.ServiceAccounts[i].Login)
|
sa.ServiceAccounts[i].IsExternal = isExternalServiceAccount(sa.ServiceAccounts[i].Login)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sa, nil
|
return sa, nil
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ func TestProvideServiceAccount_crudServiceAccount(t *testing.T) {
|
||||||
serviceMock.ExpectedServiceAccountProfile = tc.expectedServiceAccount
|
serviceMock.ExpectedServiceAccountProfile = tc.expectedServiceAccount
|
||||||
sa, err := svc.RetrieveServiceAccount(context.Background(), testOrgId, testServiceAccountId)
|
sa, err := svc.RetrieveServiceAccount(context.Background(), testOrgId, testServiceAccountId)
|
||||||
assert.NoError(t, err, tc.description)
|
assert.NoError(t, err, tc.description)
|
||||||
assert.Equal(t, tc.expectedIsExternal, sa.IsManaged, tc.description)
|
assert.Equal(t, tc.expectedIsExternal, sa.IsExternal, tc.description)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -164,8 +164,8 @@ func TestProvideServiceAccount_crudServiceAccount(t *testing.T) {
|
||||||
res, err := svc.SearchOrgServiceAccounts(context.Background(), &serviceaccounts.SearchOrgServiceAccountsQuery{OrgID: 1})
|
res, err := svc.SearchOrgServiceAccounts(context.Background(), &serviceaccounts.SearchOrgServiceAccountsQuery{OrgID: 1})
|
||||||
require.Len(t, res.ServiceAccounts, 2)
|
require.Len(t, res.ServiceAccounts, 2)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.False(t, res.ServiceAccounts[0].IsManaged)
|
require.False(t, res.ServiceAccounts[0].IsExternal)
|
||||||
require.True(t, res.ServiceAccounts[1].IsManaged)
|
require.True(t, res.ServiceAccounts[1].IsExternal)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("should update service account", func(t *testing.T) {
|
t.Run("should update service account", func(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -6767,7 +6767,7 @@
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"example": false
|
"example": false
|
||||||
},
|
},
|
||||||
"isManaged": {
|
"isExternal": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"example": false
|
"example": false
|
||||||
},
|
},
|
||||||
|
|
@ -6822,7 +6822,7 @@
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"example": false
|
"example": false
|
||||||
},
|
},
|
||||||
"isManaged": {
|
"isExternal": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"example": false
|
"example": false
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -18652,7 +18652,7 @@
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"example": false
|
"example": false
|
||||||
},
|
},
|
||||||
"isManaged": {
|
"isExternal": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"example": false
|
"example": false
|
||||||
},
|
},
|
||||||
|
|
@ -18707,7 +18707,7 @@
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"example": false
|
"example": false
|
||||||
},
|
},
|
||||||
"isManaged": {
|
"isExternal": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"example": false
|
"example": false
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
|
||||||
import { Page } from 'app/core/components/Page/Page';
|
import { Page } from 'app/core/components/Page/Page';
|
||||||
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
import PageLoader from 'app/core/components/PageLoader/PageLoader';
|
||||||
|
import config from 'app/core/config';
|
||||||
import { contextSrv } from 'app/core/core';
|
import { contextSrv } from 'app/core/core';
|
||||||
import { StoreState, ServiceAccountDTO, AccessControlAction, ServiceAccountStateFilter } from 'app/types';
|
import { StoreState, ServiceAccountDTO, AccessControlAction, ServiceAccountStateFilter } from 'app/types';
|
||||||
|
|
||||||
|
|
@ -56,6 +57,16 @@ const mapDispatchToProps = {
|
||||||
|
|
||||||
const connector = connect(mapStateToProps, mapDispatchToProps);
|
const connector = connect(mapStateToProps, mapDispatchToProps);
|
||||||
|
|
||||||
|
const availableFilters = [
|
||||||
|
{ label: 'All', value: ServiceAccountStateFilter.All },
|
||||||
|
{ label: 'With expired tokens', value: ServiceAccountStateFilter.WithExpiredTokens },
|
||||||
|
{ label: 'Disabled', value: ServiceAccountStateFilter.Disabled },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (config.featureToggles.externalServiceAccounts || config.featureToggles.externalServiceAuth) {
|
||||||
|
availableFilters.push({ label: 'Managed', value: ServiceAccountStateFilter.External });
|
||||||
|
}
|
||||||
|
|
||||||
export const ServiceAccountsListPageUnconnected = ({
|
export const ServiceAccountsListPageUnconnected = ({
|
||||||
page,
|
page,
|
||||||
changePage,
|
changePage,
|
||||||
|
|
@ -191,11 +202,7 @@ export const ServiceAccountsListPageUnconnected = ({
|
||||||
/>
|
/>
|
||||||
</InlineField>
|
</InlineField>
|
||||||
<RadioButtonGroup
|
<RadioButtonGroup
|
||||||
options={[
|
options={availableFilters}
|
||||||
{ label: 'All', value: ServiceAccountStateFilter.All },
|
|
||||||
{ label: 'With expired tokens', value: ServiceAccountStateFilter.WithExpiredTokens },
|
|
||||||
{ label: 'Disabled', value: ServiceAccountStateFilter.Disabled },
|
|
||||||
]}
|
|
||||||
onChange={onStateFilterChange}
|
onChange={onStateFilterChange}
|
||||||
value={serviceAccountStateFilter}
|
value={serviceAccountStateFilter}
|
||||||
className={styles.filter}
|
className={styles.filter}
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,7 @@ const ServiceAccountListItem = memo(
|
||||||
<OrgRolePicker
|
<OrgRolePicker
|
||||||
aria-label="Role"
|
aria-label="Role"
|
||||||
value={serviceAccount.role}
|
value={serviceAccount.role}
|
||||||
disabled={!canUpdateRole || serviceAccount.isDisabled}
|
disabled={serviceAccount.isExternal || !canUpdateRole || serviceAccount.isDisabled}
|
||||||
onChange={(newRole) => onRoleChange(newRole, serviceAccount)}
|
onChange={(newRole) => onRoleChange(newRole, serviceAccount)}
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -112,32 +112,48 @@ const ServiceAccountListItem = memo(
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<HorizontalGroup justify="flex-end">
|
{!serviceAccount.isExternal && (
|
||||||
{contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite) && !serviceAccount.tokens && (
|
<HorizontalGroup justify="flex-end">
|
||||||
<Button onClick={() => onAddTokenClick(serviceAccount)} disabled={serviceAccount.isDisabled}>
|
{contextSrv.hasPermission(AccessControlAction.ServiceAccountsWrite) && !serviceAccount.tokens && (
|
||||||
Add token
|
<Button
|
||||||
</Button>
|
onClick={() => onAddTokenClick(serviceAccount)}
|
||||||
)}
|
disabled={serviceAccount.isDisabled}
|
||||||
{contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount) &&
|
className={styles.actionButton}
|
||||||
(serviceAccount.isDisabled ? (
|
>
|
||||||
<Button variant="primary" onClick={() => onEnable(serviceAccount)}>
|
Add token
|
||||||
Enable
|
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
)}
|
||||||
<Button variant="secondary" onClick={() => onDisable(serviceAccount)}>
|
{contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsWrite, serviceAccount) &&
|
||||||
Disable
|
(serviceAccount.isDisabled ? (
|
||||||
</Button>
|
<Button variant="primary" onClick={() => onEnable(serviceAccount)} className={styles.actionButton}>
|
||||||
))}
|
Enable
|
||||||
{contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsDelete, serviceAccount) && (
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="secondary" onClick={() => onDisable(serviceAccount)} className={styles.actionButton}>
|
||||||
|
Disable
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{contextSrv.hasPermissionInMetadata(AccessControlAction.ServiceAccountsDelete, serviceAccount) && (
|
||||||
|
<IconButton
|
||||||
|
className={styles.deleteButton}
|
||||||
|
name="trash-alt"
|
||||||
|
size="md"
|
||||||
|
onClick={() => onRemoveButtonClick(serviceAccount)}
|
||||||
|
tooltip={`Delete service account ${serviceAccount.name}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</HorizontalGroup>
|
||||||
|
)}
|
||||||
|
{serviceAccount.isExternal && (
|
||||||
|
<HorizontalGroup justify="flex-end">
|
||||||
<IconButton
|
<IconButton
|
||||||
className={styles.deleteButton}
|
disabled={true}
|
||||||
name="trash-alt"
|
name="lock"
|
||||||
size="md"
|
size="md"
|
||||||
onClick={() => onRemoveButtonClick(serviceAccount)}
|
tooltip={`This is a managed service account and cannot be modified.`}
|
||||||
tooltip={`Delete service account ${serviceAccount.name}`}
|
|
||||||
/>
|
/>
|
||||||
)}
|
</HorizontalGroup>
|
||||||
</HorizontalGroup>
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
|
|
@ -174,6 +190,9 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||||
color: ${theme.colors.text.secondary};
|
color: ${theme.colors.text.secondary};
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
|
actionButton: css({
|
||||||
|
minWidth: 85,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,6 +114,8 @@ const getStateFilter = (value: ServiceAccountStateFilter) => {
|
||||||
return '&expiredTokens=true';
|
return '&expiredTokens=true';
|
||||||
case ServiceAccountStateFilter.Disabled:
|
case ServiceAccountStateFilter.Disabled:
|
||||||
return '&disabled=true';
|
return '&disabled=true';
|
||||||
|
case ServiceAccountStateFilter.External:
|
||||||
|
return '&external=true';
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ export interface ServiceAccountDTO extends WithAccessControlMetadata {
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
isDisabled: boolean;
|
isDisabled: boolean;
|
||||||
|
isExternal?: boolean;
|
||||||
teams: string[];
|
teams: string[];
|
||||||
role: OrgRole;
|
role: OrgRole;
|
||||||
roles?: Role[];
|
roles?: Role[];
|
||||||
|
|
@ -60,6 +61,7 @@ export interface ServiceAccountProfileState {
|
||||||
export enum ServiceAccountStateFilter {
|
export enum ServiceAccountStateFilter {
|
||||||
All = 'All',
|
All = 'All',
|
||||||
WithExpiredTokens = 'WithExpiredTokens',
|
WithExpiredTokens = 'WithExpiredTokens',
|
||||||
|
External = 'External',
|
||||||
Disabled = 'Disabled',
|
Disabled = 'Disabled',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9554,7 +9554,7 @@
|
||||||
"example": false,
|
"example": false,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"isManaged": {
|
"isExternal": {
|
||||||
"example": false,
|
"example": false,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
|
@ -9609,7 +9609,7 @@
|
||||||
"example": false,
|
"example": false,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
"isManaged": {
|
"isExternal": {
|
||||||
"example": false,
|
"example": false,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue