mirror of
https://github.com/gravitational/teleport.git
synced 2026-10-30 18:29:21 +08:00
Support arbitrary role resource version in the editor (#51160)
* Support arbitrary role resource version in the editor * Review, bugfix * Switch RoleVersion to an enum
This commit is contained in:
@@ -83,6 +83,7 @@
|
||||
"d3-time-format": "^4.1.0",
|
||||
"date-fns": "^2.28.0",
|
||||
"history": "^4.9.0",
|
||||
"immer": "^10.1.1",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^18.3.1",
|
||||
"react-day-picker": "^8.10.1",
|
||||
@@ -95,6 +96,7 @@
|
||||
"react-transition-group": "^4.4.5",
|
||||
"styled-components": "^6.1.13",
|
||||
"tslib": "^2.8.1",
|
||||
"use-immer": "^0.11.0",
|
||||
"whatwg-fetch": "^3.6.20"
|
||||
},
|
||||
"msw": {
|
||||
|
||||
Generated
+21
-7
@@ -55,6 +55,9 @@ importers:
|
||||
history:
|
||||
specifier: ^4.9.0
|
||||
version: 4.10.1
|
||||
immer:
|
||||
specifier: ^10.1.1
|
||||
version: 10.1.1
|
||||
prop-types:
|
||||
specifier: ^15.8.1
|
||||
version: 15.8.1
|
||||
@@ -91,6 +94,9 @@ importers:
|
||||
tslib:
|
||||
specifier: ^2.8.1
|
||||
version: 2.8.1
|
||||
use-immer:
|
||||
specifier: ^0.11.0
|
||||
version: 0.11.0(immer@10.1.1)(react@18.3.1)
|
||||
whatwg-fetch:
|
||||
specifier: ^3.6.20
|
||||
version: 3.6.20
|
||||
@@ -484,9 +490,6 @@ importers:
|
||||
events:
|
||||
specifier: 3.3.0
|
||||
version: 3.3.0
|
||||
immer:
|
||||
specifier: ^10.1.1
|
||||
version: 10.1.1
|
||||
jest-canvas-mock:
|
||||
specifier: ^2.5.2
|
||||
version: 2.5.2
|
||||
@@ -3579,8 +3582,8 @@ packages:
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
cookie@0.7.2:
|
||||
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
|
||||
cookie@0.7.1:
|
||||
resolution: {integrity: sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
core-js-compat@3.38.1:
|
||||
@@ -6645,6 +6648,12 @@ packages:
|
||||
url-parse@1.5.10:
|
||||
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
|
||||
|
||||
use-immer@0.11.0:
|
||||
resolution: {integrity: sha512-RNAqi3GqsWJ4bcCd4LMBgdzvPmTABam24DUaFiKfX9s3MSorNRz9RDZYJkllJoMHUxVLMDetwAuCDeyWNrp1yA==}
|
||||
peerDependencies:
|
||||
immer: '>=8.0.0'
|
||||
react: ^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0
|
||||
|
||||
use-isomorphic-layout-effect@1.2.0:
|
||||
resolution: {integrity: sha512-q6ayo8DWoPZT0VdG4u3D3uxcgONP3Mevx2i2b0434cwWBoL+aelL1DzkXI6w3PhTZzUeR2kaVlZn70iCiseP6w==}
|
||||
peerDependencies:
|
||||
@@ -7791,7 +7800,7 @@ snapshots:
|
||||
|
||||
'@bundled-es-modules/cookie@2.0.1':
|
||||
dependencies:
|
||||
cookie: 0.7.2
|
||||
cookie: 0.7.1
|
||||
|
||||
'@bundled-es-modules/statuses@1.0.1':
|
||||
dependencies:
|
||||
@@ -10601,7 +10610,7 @@ snapshots:
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cookie@0.7.2: {}
|
||||
cookie@0.7.1: {}
|
||||
|
||||
core-js-compat@3.38.1:
|
||||
dependencies:
|
||||
@@ -14372,6 +14381,11 @@ snapshots:
|
||||
querystringify: 2.2.0
|
||||
requires-port: 1.0.0
|
||||
|
||||
use-immer@0.11.0(immer@10.1.1)(react@18.3.1):
|
||||
dependencies:
|
||||
immer: 10.1.1
|
||||
react: 18.3.1
|
||||
|
||||
use-isomorphic-layout-effect@1.2.0(@types/react@18.3.12)(react@18.3.1):
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
@@ -27,6 +27,7 @@ import Flex from 'design/Flex';
|
||||
import useResources from 'teleport/components/useResources';
|
||||
import cfg from 'teleport/config';
|
||||
import { createTeleportContext } from 'teleport/mocks/contexts';
|
||||
import { RoleVersion } from 'teleport/services/resources';
|
||||
import { Access } from 'teleport/services/user';
|
||||
import { YamlSupportedResourceKind } from 'teleport/services/yaml/types';
|
||||
import TeleportContextProvider from 'teleport/TeleportContextProvider';
|
||||
@@ -73,7 +74,7 @@ const parseHandler = http.post(
|
||||
HttpResponse.json({
|
||||
resource: withDefaults({
|
||||
metadata: { name: 'dummy-role' },
|
||||
version: 'v7',
|
||||
version: RoleVersion.V7,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
import TeleportContextProvider from 'teleport/TeleportContextProvider';
|
||||
|
||||
import { RoleEditor, RoleEditorProps } from './RoleEditor';
|
||||
import { defaultRoleVersion } from './StandardEditor/standardmodel';
|
||||
import { defaultOptions, withDefaults } from './StandardEditor/withDefaults';
|
||||
|
||||
// The Ace editor is very difficult to deal with in tests, especially that for
|
||||
@@ -95,7 +96,7 @@ test('rendering and switching tabs for new role', async () => {
|
||||
deny: {},
|
||||
options: {},
|
||||
},
|
||||
version: 'v7',
|
||||
version: defaultRoleVersion,
|
||||
})
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Create Role' })).toBeEnabled();
|
||||
|
||||
@@ -16,14 +16,16 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import Box from 'design/Box';
|
||||
import Text from 'design/Text';
|
||||
import FieldInput from 'shared/components/FieldInput';
|
||||
import { FieldSelect } from 'shared/components/FieldSelect';
|
||||
import { precomputed } from 'shared/components/Validation/rules';
|
||||
|
||||
import { LabelsInput } from 'teleport/components/LabelsInput';
|
||||
|
||||
import { SectionBox, SectionProps } from './sections';
|
||||
import { MetadataModel } from './standardmodel';
|
||||
import { MetadataModel, roleVersionOptions } from './standardmodel';
|
||||
import { MetadataValidationResult } from './validation';
|
||||
|
||||
export const MetadataSection = ({
|
||||
@@ -55,14 +57,24 @@ export const MetadataSection = ({
|
||||
onChange({ ...value, description: e.target.value })
|
||||
}
|
||||
/>
|
||||
<Text typography="body3" mb={1}>
|
||||
Labels
|
||||
</Text>
|
||||
<LabelsInput
|
||||
disableBtns={isProcessing}
|
||||
labels={value.labels}
|
||||
setLabels={labels => onChange?.({ ...value, labels })}
|
||||
rule={precomputed(validation.fields.labels)}
|
||||
<Box mb={3}>
|
||||
<Text typography="body3" mb={1}>
|
||||
Labels
|
||||
</Text>
|
||||
<LabelsInput
|
||||
disableBtns={isProcessing}
|
||||
labels={value.labels}
|
||||
setLabels={labels => onChange({ ...value, labels })}
|
||||
rule={precomputed(validation.fields.labels)}
|
||||
/>
|
||||
</Box>
|
||||
<FieldSelect
|
||||
label="Version"
|
||||
isDisabled={isProcessing}
|
||||
options={roleVersionOptions}
|
||||
value={value.version}
|
||||
onChange={version => onChange({ ...value, version })}
|
||||
mb={0}
|
||||
/>
|
||||
</SectionBox>
|
||||
);
|
||||
|
||||
@@ -22,6 +22,8 @@ import selectEvent from 'react-select-event';
|
||||
import { render, screen, userEvent } from 'design/utils/testing';
|
||||
import { Validator } from 'shared/components/Validation';
|
||||
|
||||
import { RoleVersion } from 'teleport/services/resources';
|
||||
|
||||
import {
|
||||
AppAccessSection,
|
||||
DatabaseAccessSection,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
import {
|
||||
AppAccess,
|
||||
DatabaseAccess,
|
||||
defaultRoleVersion,
|
||||
KubernetesAccess,
|
||||
newResourceAccess,
|
||||
ServerAccess,
|
||||
@@ -50,7 +53,7 @@ describe('ServerAccessSection', () => {
|
||||
render(
|
||||
<StatefulSection<ServerAccess, ResourceAccessValidationResult>
|
||||
component={ServerAccessSection}
|
||||
defaultValue={newResourceAccess('node')}
|
||||
defaultValue={newResourceAccess('node', defaultRoleVersion)}
|
||||
onChange={onChange}
|
||||
validatorRef={v => {
|
||||
validator = v;
|
||||
@@ -104,13 +107,16 @@ describe('ServerAccessSection', () => {
|
||||
});
|
||||
|
||||
describe('KubernetesAccessSection', () => {
|
||||
const setup = () => {
|
||||
const setup = (roleVersion: RoleVersion = defaultRoleVersion) => {
|
||||
const onChange = jest.fn();
|
||||
let validator: Validator;
|
||||
render(
|
||||
<StatefulSection<KubernetesAccess, ResourceAccessValidationResult>
|
||||
component={KubernetesAccessSection}
|
||||
defaultValue={newResourceAccess('kube_cluster')}
|
||||
defaultValue={{
|
||||
...newResourceAccess('kube_cluster', defaultRoleVersion),
|
||||
roleVersion,
|
||||
}}
|
||||
onChange={onChange}
|
||||
validatorRef={v => {
|
||||
validator = v;
|
||||
@@ -169,8 +175,10 @@ describe('KubernetesAccessSection', () => {
|
||||
expect.objectContaining({ value: 'create' }),
|
||||
expect.objectContaining({ value: 'delete' }),
|
||||
],
|
||||
roleVersion: 'v7',
|
||||
},
|
||||
],
|
||||
roleVersion: 'v7',
|
||||
} as KubernetesAccess);
|
||||
});
|
||||
|
||||
@@ -228,12 +236,20 @@ describe('KubernetesAccessSection', () => {
|
||||
});
|
||||
|
||||
test('validation', async () => {
|
||||
const { user, validator } = setup();
|
||||
const { user, validator } = setup(RoleVersion.V6);
|
||||
await user.click(screen.getByRole('button', { name: 'Add a Label' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Add a Resource' }));
|
||||
await selectEvent.select(screen.getByLabelText('Kind'), 'Service');
|
||||
await user.clear(screen.getByLabelText('Name'));
|
||||
await user.clear(screen.getByLabelText('Namespace'));
|
||||
await selectEvent.select(screen.getByLabelText('Verbs'), [
|
||||
'All verbs',
|
||||
'create',
|
||||
]);
|
||||
act(() => validator.validate());
|
||||
expect(
|
||||
screen.getByText('Only pods are allowed for role version v6')
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByPlaceholderText('label key')
|
||||
).toHaveAccessibleDescription('required');
|
||||
@@ -243,6 +259,9 @@ describe('KubernetesAccessSection', () => {
|
||||
expect(screen.getByLabelText('Namespace')).toHaveAccessibleDescription(
|
||||
'Namespace is required for resources of this kind'
|
||||
);
|
||||
expect(
|
||||
screen.getByText('Mixing "All verbs" with other options is not allowed')
|
||||
).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,7 +272,7 @@ describe('AppAccessSection', () => {
|
||||
render(
|
||||
<StatefulSection<AppAccess, ResourceAccessValidationResult>
|
||||
component={AppAccessSection}
|
||||
defaultValue={newResourceAccess('app')}
|
||||
defaultValue={newResourceAccess('app', defaultRoleVersion)}
|
||||
onChange={onChange}
|
||||
validatorRef={v => {
|
||||
validator = v;
|
||||
@@ -359,7 +378,7 @@ describe('DatabaseAccessSection', () => {
|
||||
render(
|
||||
<StatefulSection<DatabaseAccess, ResourceAccessValidationResult>
|
||||
component={DatabaseAccessSection}
|
||||
defaultValue={newResourceAccess('db')}
|
||||
defaultValue={newResourceAccess('db', defaultRoleVersion)}
|
||||
onChange={onChange}
|
||||
validatorRef={v => {
|
||||
validator = v;
|
||||
@@ -425,7 +444,7 @@ describe('WindowsDesktopAccessSection', () => {
|
||||
render(
|
||||
<StatefulSection<WindowsDesktopAccess, ResourceAccessValidationResult>
|
||||
component={WindowsDesktopAccessSection}
|
||||
defaultValue={newResourceAccess('windows_desktop')}
|
||||
defaultValue={newResourceAccess('windows_desktop', defaultRoleVersion)}
|
||||
onChange={onChange}
|
||||
validatorRef={v => {
|
||||
validator = v;
|
||||
|
||||
@@ -28,7 +28,8 @@ import { Mark } from 'design/Mark';
|
||||
import Text, { H4 } from 'design/Text';
|
||||
import FieldInput from 'shared/components/FieldInput';
|
||||
import { FieldMultiInput } from 'shared/components/FieldMultiInput/FieldMultiInput';
|
||||
import FieldSelect, {
|
||||
import {
|
||||
FieldSelect,
|
||||
FieldSelectCreatable,
|
||||
} from 'shared/components/FieldSelect';
|
||||
import { MenuButton, MenuItem } from 'shared/components/MenuAction';
|
||||
@@ -321,7 +322,10 @@ export function KubernetesAccessSection({
|
||||
onClick={() =>
|
||||
onChange?.({
|
||||
...value,
|
||||
resources: [...value.resources, newKubernetesResourceModel()],
|
||||
resources: [
|
||||
...value.resources,
|
||||
newKubernetesResourceModel(value.roleVersion),
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -378,6 +382,7 @@ function KubernetesResourceView({
|
||||
isDisabled={isProcessing}
|
||||
options={kubernetesResourceKindOptions}
|
||||
value={kind}
|
||||
rule={precomputed(validation.kind)}
|
||||
onChange={k => onChange?.({ ...value, kind: k })}
|
||||
/>
|
||||
<FieldInput
|
||||
@@ -412,6 +417,7 @@ function KubernetesResourceView({
|
||||
isDisabled={isProcessing}
|
||||
options={kubernetesVerbOptions}
|
||||
value={verbs}
|
||||
rule={precomputed(validation.verbs)}
|
||||
onChange={v => onChange?.({ ...value, verbs: v })}
|
||||
mb={0}
|
||||
/>
|
||||
|
||||
@@ -138,8 +138,10 @@ test('edits metadata', async () => {
|
||||
const onSave = (r: Role) => (role = r);
|
||||
render(<TestStandardEditor onSave={onSave} />);
|
||||
await user.type(screen.getByLabelText('Description'), 'foo');
|
||||
await selectEvent.select(screen.getByLabelText('Version'), 'v6');
|
||||
await user.click(screen.getByRole('button', { name: 'Create Role' }));
|
||||
expect(role.metadata.description).toBe('foo');
|
||||
expect(role.version).toBe('v6');
|
||||
});
|
||||
|
||||
test('edits resource access', async () => {
|
||||
@@ -159,6 +161,41 @@ test('edits resource access', async () => {
|
||||
expect(role.spec.allow.logins).toEqual(['{{internal.logins}}', 'ec2-user']);
|
||||
});
|
||||
|
||||
test('triggers v6 validation for Kubernetes resources', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSave = jest.fn();
|
||||
render(<TestStandardEditor onSave={onSave} />);
|
||||
await selectEvent.select(screen.getByLabelText('Version'), 'v6');
|
||||
await user.click(screen.getByRole('tab', { name: 'Resources' }));
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Add New Resource Access' })
|
||||
);
|
||||
await user.click(screen.getByRole('menuitem', { name: 'Kubernetes' }));
|
||||
await user.click(screen.getByRole('button', { name: 'Add a Resource' }));
|
||||
await selectEvent.select(screen.getByLabelText('Kind'), 'Job');
|
||||
|
||||
// Adding a second resource to make sure that we don't run into attempting to
|
||||
// modify an immer-frozen object. This might happen if the reducer tried to
|
||||
// modify resources that were already there.
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Add Another Resource' })
|
||||
);
|
||||
await selectEvent.select(screen.getAllByLabelText('Kind')[1], 'Pod');
|
||||
await user.click(screen.getByRole('button', { name: 'Create Role' }));
|
||||
|
||||
// Validation should have failed on a Job resource and role v6.
|
||||
expect(
|
||||
screen.getByText('Only pods are allowed for role version v6')
|
||||
).toBeVisible();
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
|
||||
// Back to v7, try again
|
||||
await user.click(screen.getByRole('tab', { name: 'Overview' }));
|
||||
await selectEvent.select(screen.getByLabelText('Version'), 'v7');
|
||||
await user.click(screen.getByRole('button', { name: 'Create Role' }));
|
||||
expect(onSave).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const getAllMenuItemNames = () =>
|
||||
screen.queryAllByRole('menuitem').map(m => m.textContent);
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import React, { useReducer } from 'react';
|
||||
import Validation, { Validator } from 'shared/components/Validation';
|
||||
|
||||
import { SectionProps, SectionPropsWithDispatch } from './sections';
|
||||
import { StandardEditorModel } from './standardmodel';
|
||||
import { defaultRoleVersion, StandardEditorModel } from './standardmodel';
|
||||
import { useStandardModel } from './useStandardModel';
|
||||
import { withDefaults } from './withDefaults';
|
||||
|
||||
@@ -78,7 +78,7 @@ export function StatefulSection<Model, ValidationResult>({
|
||||
|
||||
const minimalRole = withDefaults({
|
||||
metadata: { name: 'foobar' },
|
||||
version: 'v7',
|
||||
version: defaultRoleVersion,
|
||||
});
|
||||
|
||||
/** A helper for testing editor section components. */
|
||||
|
||||
@@ -35,7 +35,7 @@ export type SectionProps<Model, ValidationResult> = {
|
||||
value: Model;
|
||||
isProcessing: boolean;
|
||||
validation?: ValidationResult;
|
||||
onChange?(value: Model): void;
|
||||
onChange(value: Model): void;
|
||||
};
|
||||
|
||||
/** Properties of a section that uses a dispatcher to change the model. */
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
RequireMFAType,
|
||||
ResourceKind,
|
||||
Role,
|
||||
RoleVersion,
|
||||
Rule,
|
||||
SessionRecordingMode,
|
||||
SSHPortForwarding,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
import {
|
||||
createDBUserModeOptionsMap,
|
||||
createHostUserModeOptionsMap,
|
||||
defaultRoleVersion,
|
||||
KubernetesAccess,
|
||||
kubernetesResourceKindOptionsMap,
|
||||
kubernetesVerbOptionsMap,
|
||||
@@ -44,6 +46,7 @@ import {
|
||||
RoleEditorModel,
|
||||
roleEditorModelToRole,
|
||||
roleToRoleEditorModel,
|
||||
roleVersionOptionsMap,
|
||||
sessionRecordingModeOptionsMap,
|
||||
sshPortForwardingModeOptionsMap,
|
||||
verbOptionsMap,
|
||||
@@ -51,10 +54,14 @@ import {
|
||||
import { optionsWithDefaults, withDefaults } from './withDefaults';
|
||||
|
||||
const minimalRole = () =>
|
||||
withDefaults({ metadata: { name: 'foobar' }, version: 'v7' });
|
||||
withDefaults({ metadata: { name: 'foobar' }, version: defaultRoleVersion });
|
||||
|
||||
const minimalRoleModel = (): RoleEditorModel => ({
|
||||
metadata: { name: 'foobar', labels: [] },
|
||||
metadata: {
|
||||
name: 'foobar',
|
||||
labels: [],
|
||||
version: roleVersionOptionsMap.get(defaultRoleVersion),
|
||||
},
|
||||
resources: [],
|
||||
rules: [],
|
||||
requiresReset: false,
|
||||
@@ -92,6 +99,7 @@ describe.each<{ name: string; role: Role; model: RoleEditorModel }>([
|
||||
description: 'role-description',
|
||||
labels: { foo: 'bar' },
|
||||
},
|
||||
version: RoleVersion.V6,
|
||||
},
|
||||
model: {
|
||||
...minimalRoleModel(),
|
||||
@@ -99,6 +107,7 @@ describe.each<{ name: string; role: Role; model: RoleEditorModel }>([
|
||||
name: 'role-name',
|
||||
description: 'role-description',
|
||||
labels: [{ name: 'foo', value: 'bar' }],
|
||||
version: roleVersionOptionsMap.get(RoleVersion.V6),
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -431,6 +440,7 @@ describe('roleToRoleEditorModel', () => {
|
||||
groups: [],
|
||||
labels: [],
|
||||
resources: [],
|
||||
roleVersion: defaultRoleVersion,
|
||||
});
|
||||
|
||||
test.each<{ name: string; role: Role; model?: RoleEditorModel }>([
|
||||
@@ -767,8 +777,10 @@ describe('roleToRoleEditorModel', () => {
|
||||
}
|
||||
);
|
||||
|
||||
test('version change requires reset', () => {
|
||||
expect(roleToRoleEditorModel({ ...minimalRole(), version: 'v1' })).toEqual({
|
||||
test('unsupported version requires reset', () => {
|
||||
expect(
|
||||
roleToRoleEditorModel({ ...minimalRole(), version: 'v1' as RoleVersion })
|
||||
).toEqual({
|
||||
...minimalRoleModel(),
|
||||
requiresReset: true,
|
||||
} as RoleEditorModel);
|
||||
@@ -795,6 +807,7 @@ describe('roleToRoleEditorModel', () => {
|
||||
name: 'role-name',
|
||||
revision: originalRev,
|
||||
labels: [],
|
||||
version: roleVersionOptionsMap.get(defaultRoleVersion),
|
||||
},
|
||||
requiresReset: true,
|
||||
} as RoleEditorModel);
|
||||
@@ -824,6 +837,7 @@ describe('roleToRoleEditorModel', () => {
|
||||
name: 'role-name',
|
||||
revision: 'e39ea9f1-79b7-4d28-8f0c-af6848f9e655',
|
||||
labels: [],
|
||||
version: roleVersionOptionsMap.get(defaultRoleVersion),
|
||||
},
|
||||
requiresReset: true,
|
||||
} as RoleEditorModel);
|
||||
@@ -877,6 +891,7 @@ describe('roleToRoleEditorModel', () => {
|
||||
kubernetesVerbOptionsMap.get('get'),
|
||||
kubernetesVerbOptionsMap.get('update'),
|
||||
],
|
||||
roleVersion: defaultRoleVersion,
|
||||
},
|
||||
{
|
||||
id: expect.any(String),
|
||||
@@ -884,8 +899,10 @@ describe('roleToRoleEditorModel', () => {
|
||||
name: 'some-node',
|
||||
namespace: '',
|
||||
verbs: [],
|
||||
roleVersion: defaultRoleVersion,
|
||||
},
|
||||
],
|
||||
roleVersion: defaultRoleVersion,
|
||||
},
|
||||
],
|
||||
} as RoleEditorModel);
|
||||
@@ -974,6 +991,7 @@ describe('roleEditorModelToRole', () => {
|
||||
description: 'walks dogs',
|
||||
revision: 'e2a3ccf8-09b9-4d97-8e47-6dbe3d53c0e5',
|
||||
labels: [{ name: 'kind', value: 'occupation' }],
|
||||
version: roleVersionOptionsMap.get(RoleVersion.V5),
|
||||
},
|
||||
})
|
||||
).toEqual({
|
||||
@@ -984,6 +1002,7 @@ describe('roleEditorModelToRole', () => {
|
||||
revision: 'e2a3ccf8-09b9-4d97-8e47-6dbe3d53c0e5',
|
||||
labels: { kind: 'occupation' },
|
||||
},
|
||||
version: 'v5',
|
||||
} as Role);
|
||||
});
|
||||
|
||||
@@ -1012,6 +1031,7 @@ describe('roleEditorModelToRole', () => {
|
||||
kubernetesVerbOptionsMap.get('get'),
|
||||
kubernetesVerbOptionsMap.get('update'),
|
||||
],
|
||||
roleVersion: defaultRoleVersion,
|
||||
},
|
||||
{
|
||||
id: 'dummy-id-2',
|
||||
@@ -1019,8 +1039,10 @@ describe('roleEditorModelToRole', () => {
|
||||
name: 'some-node',
|
||||
namespace: '',
|
||||
verbs: [],
|
||||
roleVersion: defaultRoleVersion,
|
||||
},
|
||||
],
|
||||
roleVersion: defaultRoleVersion,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
RequireMFAType,
|
||||
ResourceKind,
|
||||
RoleOptions,
|
||||
RoleVersion,
|
||||
Rule,
|
||||
SessionRecordingMode,
|
||||
SSHPortForwarding,
|
||||
@@ -77,6 +78,7 @@ export type MetadataModel = {
|
||||
description?: string;
|
||||
revision?: string;
|
||||
labels: UILabel[];
|
||||
version: RoleVersionOption;
|
||||
};
|
||||
|
||||
/** A model for resource section. */
|
||||
@@ -112,6 +114,14 @@ export type KubernetesAccess = ResourceAccessBase<'kube_cluster'> & {
|
||||
groups: readonly Option[];
|
||||
labels: UILabel[];
|
||||
resources: KubernetesResourceModel[];
|
||||
|
||||
/**
|
||||
* Version of the role that owns this section. Required to propagate it to
|
||||
* {@link KubernetesResourceModel}. It's the responsibility of
|
||||
* `useStandardModel` reducer to keep this value consistent with the actual
|
||||
* role version.
|
||||
*/
|
||||
roleVersion: RoleVersion;
|
||||
};
|
||||
|
||||
export type KubernetesResourceModel = {
|
||||
@@ -121,6 +131,14 @@ export type KubernetesResourceModel = {
|
||||
name: string;
|
||||
namespace: string;
|
||||
verbs: readonly KubernetesVerbOption[];
|
||||
|
||||
/**
|
||||
* Version of the role that owns this section. Required in order to support
|
||||
* version-specific validation rules. It's the responsibility of
|
||||
* `useStandardModel` reducer to keep this value consistent with the actual
|
||||
* role version.
|
||||
*/
|
||||
roleVersion: RoleVersion;
|
||||
};
|
||||
|
||||
type KubernetesResourceKindOption = Option<KubernetesResourceKind, string>;
|
||||
@@ -171,7 +189,7 @@ export const kubernetesResourceKindOptionsMap = optionsToMap(
|
||||
kubernetesResourceKindOptions
|
||||
);
|
||||
|
||||
type KubernetesVerbOption = Option<KubernetesVerb, string>;
|
||||
export type KubernetesVerbOption = Option<KubernetesVerb, string>;
|
||||
/**
|
||||
* All possible Kubernetes verb drop-down options. This array needs to be kept
|
||||
* in sync with `KubernetesVerbs` in `api/types/constants.go.
|
||||
@@ -361,7 +379,14 @@ export const sshPortForwardingModeOptionsMap = optionsToMap(
|
||||
sshPortForwardingModeOptions
|
||||
);
|
||||
|
||||
const roleVersion = 'v7';
|
||||
export type RoleVersionOption = Option<RoleVersion>;
|
||||
export const roleVersionOptions = Object.values(RoleVersion)
|
||||
.toSorted()
|
||||
.toReversed()
|
||||
.map(o => ({ value: o, label: o }));
|
||||
export const roleVersionOptionsMap = optionsToMap(roleVersionOptions);
|
||||
|
||||
export const defaultRoleVersion = RoleVersion.V7;
|
||||
|
||||
/**
|
||||
* Returns the role object with required fields defined with empty values.
|
||||
@@ -377,19 +402,44 @@ export function newRole(): Role {
|
||||
deny: {},
|
||||
options: defaultOptions(),
|
||||
},
|
||||
version: roleVersion,
|
||||
version: defaultRoleVersion,
|
||||
};
|
||||
}
|
||||
|
||||
export function newResourceAccess(kind: 'node'): ServerAccess;
|
||||
export function newResourceAccess(kind: 'kube_cluster'): KubernetesAccess;
|
||||
export function newResourceAccess(kind: 'app'): AppAccess;
|
||||
export function newResourceAccess(kind: 'db'): DatabaseAccess;
|
||||
export function newResourceAccess(
|
||||
kind: 'windows_desktop'
|
||||
kind: 'node',
|
||||
roleVersion: RoleVersion
|
||||
): ServerAccess;
|
||||
|
||||
export function newResourceAccess(
|
||||
kind: 'kube_cluster',
|
||||
roleVersion: RoleVersion
|
||||
): KubernetesAccess;
|
||||
|
||||
export function newResourceAccess(
|
||||
kind: 'app',
|
||||
roleVersion: RoleVersion
|
||||
): AppAccess;
|
||||
|
||||
export function newResourceAccess(
|
||||
kind: 'db',
|
||||
roleVersion: RoleVersion
|
||||
): DatabaseAccess;
|
||||
|
||||
export function newResourceAccess(
|
||||
kind: 'windows_desktop',
|
||||
roleVersion: RoleVersion
|
||||
): WindowsDesktopAccess;
|
||||
export function newResourceAccess(kind: ResourceAccessKind): AppAccess;
|
||||
export function newResourceAccess(kind: ResourceAccessKind): ResourceAccess {
|
||||
|
||||
export function newResourceAccess(
|
||||
kind: ResourceAccessKind,
|
||||
roleVersion: RoleVersion
|
||||
): AppAccess;
|
||||
|
||||
export function newResourceAccess(
|
||||
kind: ResourceAccessKind,
|
||||
roleVersion: RoleVersion
|
||||
): ResourceAccess {
|
||||
switch (kind) {
|
||||
case 'node':
|
||||
return {
|
||||
@@ -403,6 +453,7 @@ export function newResourceAccess(kind: ResourceAccessKind): ResourceAccess {
|
||||
groups: [stringToOption('{{internal.kubernetes_groups}}')],
|
||||
labels: [],
|
||||
resources: [],
|
||||
roleVersion,
|
||||
};
|
||||
case 'app':
|
||||
return {
|
||||
@@ -431,13 +482,16 @@ export function newResourceAccess(kind: ResourceAccessKind): ResourceAccess {
|
||||
}
|
||||
}
|
||||
|
||||
export function newKubernetesResourceModel(): KubernetesResourceModel {
|
||||
export function newKubernetesResourceModel(
|
||||
roleVersion: RoleVersion
|
||||
): KubernetesResourceModel {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
kind: kubernetesResourceKindOptions.find(k => k.value === '*'),
|
||||
name: '*',
|
||||
namespace: '*',
|
||||
verbs: [],
|
||||
roleVersion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -470,9 +524,10 @@ export function roleToRoleEditorModel(
|
||||
resources,
|
||||
rules,
|
||||
requiresReset: allowRequiresReset,
|
||||
} = roleConditionsToModel(allow);
|
||||
} = roleConditionsToModel(allow, version);
|
||||
const { model: optionsModel, requiresReset: optionsRequireReset } =
|
||||
optionsToModel(options);
|
||||
const versionOption = roleVersionOptionsMap.get(version);
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
@@ -480,13 +535,14 @@ export function roleToRoleEditorModel(
|
||||
description,
|
||||
revision: originalRole?.metadata?.revision,
|
||||
labels: labelsToModel(labels),
|
||||
version: versionOption ?? roleVersionOptionsMap.get(RoleVersion.V7),
|
||||
},
|
||||
resources,
|
||||
rules,
|
||||
options: optionsModel,
|
||||
requiresReset:
|
||||
revision !== originalRole?.metadata?.revision ||
|
||||
version !== roleVersion ||
|
||||
versionOption === undefined ||
|
||||
!(
|
||||
isEmpty(unsupported) &&
|
||||
isEmpty(unsupportedMetadata) &&
|
||||
@@ -503,7 +559,8 @@ export function roleToRoleEditorModel(
|
||||
* specific) to a part of the role editor model.
|
||||
*/
|
||||
function roleConditionsToModel(
|
||||
conditions: RoleConditions
|
||||
conditions: RoleConditions,
|
||||
roleVersion: RoleVersion
|
||||
): Pick<RoleEditorModel, 'resources' | 'rules' | 'requiresReset'> {
|
||||
const {
|
||||
node_labels,
|
||||
@@ -548,13 +605,14 @@ function roleConditionsToModel(
|
||||
const {
|
||||
model: kubeResourcesModel,
|
||||
requiresReset: kubernetesResourcesRequireReset,
|
||||
} = kubernetesResourcesToModel(kubernetes_resources);
|
||||
} = kubernetesResourcesToModel(kubernetes_resources, roleVersion);
|
||||
if (someNonEmpty(kubeGroupsModel, kubeLabelsModel, kubeResourcesModel)) {
|
||||
resources.push({
|
||||
kind: 'kube_cluster',
|
||||
groups: kubeGroupsModel,
|
||||
labels: kubeLabelsModel,
|
||||
resources: kubeResourcesModel,
|
||||
roleVersion,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -649,16 +707,22 @@ function stringsToOptions<T extends string>(arr: T[]): Option<T>[] {
|
||||
}
|
||||
|
||||
function kubernetesResourcesToModel(
|
||||
resources: KubernetesResource[] | undefined
|
||||
resources: KubernetesResource[] | undefined,
|
||||
roleVersion: RoleVersion
|
||||
): { model: KubernetesResourceModel[]; requiresReset: boolean } {
|
||||
const result = (resources ?? []).map(kubernetesResourceToModel);
|
||||
const result = (resources ?? []).map(res =>
|
||||
kubernetesResourceToModel(res, roleVersion)
|
||||
);
|
||||
return {
|
||||
model: result.map(r => r.model).filter(m => m !== undefined),
|
||||
requiresReset: result.some(r => r.requiresReset),
|
||||
};
|
||||
}
|
||||
|
||||
function kubernetesResourceToModel(res: KubernetesResource): {
|
||||
function kubernetesResourceToModel(
|
||||
res: KubernetesResource,
|
||||
roleVersion: RoleVersion
|
||||
): {
|
||||
model?: KubernetesResourceModel;
|
||||
requiresReset: boolean;
|
||||
} {
|
||||
@@ -675,6 +739,7 @@ function kubernetesResourceToModel(res: KubernetesResource): {
|
||||
name,
|
||||
namespace,
|
||||
verbs: knownVerbOptions,
|
||||
roleVersion,
|
||||
}
|
||||
: undefined,
|
||||
requiresReset:
|
||||
@@ -862,7 +927,8 @@ function isEmpty(obj: object) {
|
||||
* Converts a role editor model to a role. This operation is lossless.
|
||||
*/
|
||||
export function roleEditorModelToRole(roleModel: RoleEditorModel): Role {
|
||||
const { name, description, revision, labels, ...mRest } = roleModel.metadata;
|
||||
const { name, description, revision, labels, version, ...mRest } =
|
||||
roleModel.metadata;
|
||||
// Compile-time assert that protects us from silently losing fields.
|
||||
mRest satisfies Record<any, never>;
|
||||
|
||||
@@ -882,7 +948,7 @@ export function roleEditorModelToRole(roleModel: RoleEditorModel): Role {
|
||||
deny: {},
|
||||
options: optionsModelToRoleOptions(roleModel.options),
|
||||
},
|
||||
version: roleVersion,
|
||||
version: version.value,
|
||||
};
|
||||
|
||||
for (const res of roleModel.resources) {
|
||||
|
||||
@@ -16,9 +16,11 @@
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { Dispatch, useReducer } from 'react';
|
||||
import { current, original } from 'immer';
|
||||
import { Dispatch } from 'react';
|
||||
import { useImmerReducer } from 'use-immer';
|
||||
|
||||
import { Role } from 'teleport/services/resources';
|
||||
import { Role, RoleVersion } from 'teleport/services/resources';
|
||||
|
||||
import {
|
||||
hasModifiedFields,
|
||||
@@ -43,7 +45,7 @@ import { validateRoleEditorModel } from './validation';
|
||||
export const useStandardModel = (
|
||||
originalRole?: Role
|
||||
): [StandardEditorModel, StandardModelDispatcher] =>
|
||||
useReducer(reduce, originalRole, initializeState);
|
||||
useImmerReducer(reduce, originalRole, initializeState);
|
||||
|
||||
const initializeState = (originalRole?: Role): StandardEditorModel => {
|
||||
const role = originalRole ?? newRole();
|
||||
@@ -102,79 +104,93 @@ const reduce = (
|
||||
// block will cause a syntax error.
|
||||
const { type, payload } = action;
|
||||
|
||||
// This reduce uses Immer, so we modify the model draft directly.
|
||||
// TODO(bl-nero): add immutability to the model data types.
|
||||
switch (type) {
|
||||
case 'set-role-model':
|
||||
return updateRoleModel(state, payload);
|
||||
state.roleModel = payload;
|
||||
break;
|
||||
|
||||
case 'set-metadata':
|
||||
return updateRoleModel(state, { metadata: payload });
|
||||
state.roleModel.metadata = payload;
|
||||
updateRoleVersionInResources(
|
||||
state.roleModel.resources,
|
||||
payload.version.value
|
||||
);
|
||||
break;
|
||||
|
||||
case 'set-options':
|
||||
return updateRoleModel(state, { options: payload });
|
||||
state.roleModel.options = payload;
|
||||
break;
|
||||
|
||||
case 'add-resource-access':
|
||||
return updateRoleModel(state, {
|
||||
resources: [
|
||||
...state.roleModel.resources,
|
||||
newResourceAccess(payload.kind),
|
||||
],
|
||||
});
|
||||
state.roleModel.resources.push(
|
||||
newResourceAccess(payload.kind, state.roleModel.metadata.version.value)
|
||||
);
|
||||
break;
|
||||
|
||||
case 'set-resource-access':
|
||||
return updateRoleModel(state, {
|
||||
resources: state.roleModel.resources.map(r =>
|
||||
r.kind === payload.kind ? payload : r
|
||||
),
|
||||
});
|
||||
state.roleModel.resources = state.roleModel.resources.map(r =>
|
||||
r.kind === payload.kind ? payload : r
|
||||
);
|
||||
break;
|
||||
|
||||
case 'remove-resource-access':
|
||||
return updateRoleModel(state, {
|
||||
resources: state.roleModel.resources.filter(
|
||||
r => r.kind !== payload.kind
|
||||
),
|
||||
});
|
||||
state.roleModel.resources = state.roleModel.resources.filter(
|
||||
r => r.kind !== payload.kind
|
||||
);
|
||||
break;
|
||||
|
||||
case 'add-access-rule':
|
||||
return updateRoleModel(state, {
|
||||
rules: [...state.roleModel.rules, newRuleModel()],
|
||||
});
|
||||
state.roleModel.rules.push(newRuleModel());
|
||||
break;
|
||||
|
||||
case 'set-access-rule':
|
||||
return updateRoleModel(state, {
|
||||
rules: state.roleModel.rules.map(r =>
|
||||
r.id === payload.id ? payload : r
|
||||
),
|
||||
});
|
||||
state.roleModel.rules = state.roleModel.rules.map(r =>
|
||||
r.id === payload.id ? payload : r
|
||||
);
|
||||
break;
|
||||
|
||||
case 'remove-access-rule':
|
||||
return updateRoleModel(state, {
|
||||
rules: state.roleModel.rules.filter(r => r.id !== payload.id),
|
||||
});
|
||||
state.roleModel.rules = state.roleModel.rules.filter(
|
||||
r => r.id !== payload.id
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
(type) satisfies never;
|
||||
return state;
|
||||
}
|
||||
|
||||
processEditorModel(state);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a new model state given existing state and a patch to
|
||||
* RoleEditorModel. Validates the state and recognizes whether it's dirty (i.e.
|
||||
* changed from the original).
|
||||
* Recomputes dependent fields of a state draft. Validates the state and
|
||||
* recognizes whether it's dirty (i.e. changed from the original).
|
||||
*/
|
||||
const updateRoleModel = (
|
||||
{ roleModel, originalRole, validationResult }: StandardEditorModel,
|
||||
roleModelPatch: Partial<RoleEditorModel>
|
||||
): StandardEditorModel => {
|
||||
const newRoleModel = { ...roleModel, ...roleModelPatch };
|
||||
return {
|
||||
roleModel: newRoleModel,
|
||||
originalRole,
|
||||
isDirty: hasModifiedFields(newRoleModel, originalRole),
|
||||
validationResult: validateRoleEditorModel(
|
||||
newRoleModel,
|
||||
roleModel,
|
||||
validationResult
|
||||
),
|
||||
};
|
||||
const processEditorModel = (state: StandardEditorModel) => {
|
||||
const { roleModel, originalRole, validationResult } = state;
|
||||
state.isDirty = hasModifiedFields(roleModel, originalRole);
|
||||
state.validationResult = validateRoleEditorModel(
|
||||
// It's crucial to use `current` and `original` here from the performance
|
||||
// standpoint, since `validateEditorModel` recognizes unchanged state by
|
||||
// reference. We want to make sure that the objects passed to it have
|
||||
// stable identities.
|
||||
current(state).roleModel,
|
||||
original(state).roleModel,
|
||||
validationResult
|
||||
);
|
||||
};
|
||||
|
||||
const updateRoleVersionInResources = (
|
||||
resources: ResourceAccess[],
|
||||
version: RoleVersion
|
||||
) => {
|
||||
for (const res of resources.filter(res => res.kind === 'kube_cluster')) {
|
||||
res.roleVersion = version;
|
||||
for (const kubeRes of res.resources) {
|
||||
kubeRes.roleVersion = version;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -19,11 +19,16 @@
|
||||
import { ResourceKind } from 'teleport/services/resources';
|
||||
|
||||
import {
|
||||
defaultRoleVersion,
|
||||
kubernetesResourceKindOptionsMap,
|
||||
kubernetesVerbOptionsMap,
|
||||
newKubernetesResourceModel,
|
||||
ResourceAccess,
|
||||
roleToRoleEditorModel,
|
||||
RuleModel,
|
||||
} from './standardmodel';
|
||||
import {
|
||||
KubernetesAccessValidationResult,
|
||||
validateAccessRule,
|
||||
validateResourceAccess,
|
||||
validateRoleEditorModel,
|
||||
@@ -32,7 +37,10 @@ import { withDefaults } from './withDefaults';
|
||||
|
||||
const minimalRoleModel = () =>
|
||||
roleToRoleEditorModel(
|
||||
withDefaults({ metadata: { name: 'role-name' }, version: 'v7' })
|
||||
withDefaults({
|
||||
metadata: { name: 'role-name' },
|
||||
version: defaultRoleVersion,
|
||||
})
|
||||
);
|
||||
|
||||
const validity = (arr: { valid: boolean }[]) => arr.map(it => it.valid);
|
||||
@@ -65,8 +73,10 @@ describe('validateRoleEditorModel', () => {
|
||||
name: 'res-name',
|
||||
namespace: 'dummy-namespace',
|
||||
verbs: [],
|
||||
roleVersion: defaultRoleVersion,
|
||||
},
|
||||
],
|
||||
roleVersion: defaultRoleVersion,
|
||||
},
|
||||
{
|
||||
kind: 'node',
|
||||
@@ -129,6 +139,74 @@ describe('validateRoleEditorModel', () => {
|
||||
expect(result.isValid).toBe(false);
|
||||
});
|
||||
|
||||
test('forbids mixing "*" and other Kubernetes verbs', () => {
|
||||
const model = minimalRoleModel();
|
||||
model.resources = [
|
||||
{
|
||||
kind: 'kube_cluster',
|
||||
groups: [],
|
||||
labels: [],
|
||||
resources: [
|
||||
{
|
||||
...newKubernetesResourceModel(defaultRoleVersion),
|
||||
verbs: [
|
||||
kubernetesVerbOptionsMap.get('*'),
|
||||
kubernetesVerbOptionsMap.get('get'),
|
||||
],
|
||||
},
|
||||
],
|
||||
roleVersion: defaultRoleVersion,
|
||||
},
|
||||
];
|
||||
const result = validateRoleEditorModel(model, undefined, undefined);
|
||||
expect(validity(result.resources)).toEqual([false]);
|
||||
});
|
||||
|
||||
test.each`
|
||||
roleVersion | results
|
||||
${'v3'} | ${[false, true, false]}
|
||||
${'v4'} | ${[false, true, false]}
|
||||
${'v5'} | ${[false, true, false]}
|
||||
${'v6'} | ${[false, true, false]}
|
||||
${'v7'} | ${[true, true, true]}
|
||||
`(
|
||||
'correct types of resources allowed for $roleVersion',
|
||||
({ roleVersion, results }) => {
|
||||
const model = minimalRoleModel();
|
||||
model.resources = [
|
||||
{
|
||||
kind: 'kube_cluster',
|
||||
groups: [],
|
||||
labels: [],
|
||||
roleVersion,
|
||||
resources: [
|
||||
{
|
||||
...newKubernetesResourceModel(defaultRoleVersion),
|
||||
kind: kubernetesResourceKindOptionsMap.get('job'),
|
||||
roleVersion,
|
||||
},
|
||||
{
|
||||
...newKubernetesResourceModel(defaultRoleVersion),
|
||||
kind: kubernetesResourceKindOptionsMap.get('pod'),
|
||||
roleVersion,
|
||||
},
|
||||
{
|
||||
...newKubernetesResourceModel(defaultRoleVersion),
|
||||
kind: kubernetesResourceKindOptionsMap.get('service'),
|
||||
roleVersion,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const result = validateRoleEditorModel(model, undefined, undefined);
|
||||
const resourceResult = result
|
||||
.resources[0] as KubernetesAccessValidationResult;
|
||||
expect(validity(resourceResult.fields.resources.results)).toEqual(
|
||||
results
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
test('invalid access rule', () => {
|
||||
const model = minimalRoleModel();
|
||||
model.rules = [
|
||||
|
||||
@@ -26,10 +26,14 @@ import {
|
||||
} from 'shared/components/Validation/rules';
|
||||
|
||||
import { nonEmptyLabels } from 'teleport/components/LabelsInput/LabelsInput';
|
||||
import { KubernetesResourceKind } from 'teleport/services/resources';
|
||||
import {
|
||||
KubernetesResourceKind,
|
||||
RoleVersion,
|
||||
} from 'teleport/services/resources';
|
||||
|
||||
import {
|
||||
KubernetesResourceModel,
|
||||
KubernetesVerbOption,
|
||||
MetadataModel,
|
||||
ResourceAccess,
|
||||
RoleEditorModel,
|
||||
@@ -171,6 +175,7 @@ export type ResourceAccessValidationResult =
|
||||
| WindowsDesktopAccessValidationResult;
|
||||
|
||||
const validKubernetesResource = (res: KubernetesResourceModel) => () => {
|
||||
const kind = validKubernetesKind(res.kind.value, res.roleVersion);
|
||||
const name = requiredField(
|
||||
'Resource name is required, use "*" for any resource'
|
||||
)(res.name)();
|
||||
@@ -179,15 +184,64 @@ const validKubernetesResource = (res: KubernetesResourceModel) => () => {
|
||||
: requiredField('Namespace is required for resources of this kind')(
|
||||
res.namespace
|
||||
)();
|
||||
const verbs = validKubernetesVerbs(res.verbs);
|
||||
return {
|
||||
valid: name.valid && namespace.valid,
|
||||
valid: kind.valid && name.valid && namespace.valid && verbs.valid,
|
||||
kind,
|
||||
name,
|
||||
namespace,
|
||||
verbs,
|
||||
};
|
||||
};
|
||||
export type KubernetesResourceValidationResult = {
|
||||
kind: ValidationResult;
|
||||
name: ValidationResult;
|
||||
namespace: ValidationResult;
|
||||
verbs: ValidationResult;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a `kind` field of a `KubernetesResourceModel`. In roles with
|
||||
* version v6, the auth server allows only specifying access for `pod`
|
||||
* Kubernetes resources.
|
||||
*/
|
||||
const validKubernetesKind = (
|
||||
kind: KubernetesResourceKind,
|
||||
ver: RoleVersion
|
||||
): ValidationResult => {
|
||||
switch (ver) {
|
||||
case RoleVersion.V3:
|
||||
case RoleVersion.V4:
|
||||
case RoleVersion.V5:
|
||||
case RoleVersion.V6:
|
||||
const valid = kind === 'pod';
|
||||
return {
|
||||
valid,
|
||||
message: valid
|
||||
? undefined
|
||||
: `Only pods are allowed for role version ${ver}`,
|
||||
};
|
||||
|
||||
case RoleVersion.V7:
|
||||
return { valid: true };
|
||||
|
||||
default:
|
||||
ver satisfies never;
|
||||
return { valid: true };
|
||||
}
|
||||
};
|
||||
|
||||
const validKubernetesVerbs = (
|
||||
verbs: readonly KubernetesVerbOption[]
|
||||
): ValidationResult => {
|
||||
// Don't allow mixing '*' and other resource types.
|
||||
const valid = verbs.length < 2 || verbs.every(v => v.value !== '*');
|
||||
return {
|
||||
valid,
|
||||
message: valid
|
||||
? undefined
|
||||
: 'Mixing "All verbs" with other options is not allowed',
|
||||
};
|
||||
};
|
||||
|
||||
const kubernetesAccessValidationRules = {
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
|
||||
import { Role, RoleOptions } from 'teleport/services/resources';
|
||||
|
||||
import { defaultRoleVersion } from './standardmodel';
|
||||
|
||||
export type DeepPartial<T> = {
|
||||
[k in keyof T]?: T[k] extends object ? DeepPartial<T[k]> : T[k];
|
||||
};
|
||||
@@ -31,7 +33,7 @@ export type DeepPartial<T> = {
|
||||
*/
|
||||
export const withDefaults = (role: DeepPartial<Role>): Role => ({
|
||||
kind: 'role',
|
||||
version: '',
|
||||
version: defaultRoleVersion,
|
||||
|
||||
...role,
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export type RoleResource = Resource<KindRole>;
|
||||
*/
|
||||
export type Role = {
|
||||
kind: KindRole;
|
||||
version: RoleVersion;
|
||||
metadata: {
|
||||
name: string;
|
||||
description?: string;
|
||||
@@ -58,9 +59,16 @@ export type Role = {
|
||||
deny: RoleConditions;
|
||||
options: RoleOptions;
|
||||
};
|
||||
version: string;
|
||||
};
|
||||
|
||||
export enum RoleVersion {
|
||||
V3 = 'v3',
|
||||
V4 = 'v4',
|
||||
V5 = 'v5',
|
||||
V6 = 'v6',
|
||||
V7 = 'v7',
|
||||
}
|
||||
|
||||
/**
|
||||
* A set of conditions that must be matched to allow or deny access. Fields
|
||||
* follow the snake case convention to match the wire format.
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
"electron-builder": "^25.1.8",
|
||||
"electron-vite": "^2.3.0",
|
||||
"events": "3.3.0",
|
||||
"immer": "^10.1.1",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"react-dnd": "^14.0.4",
|
||||
"react-dnd-html5-backend": "^14.0.2",
|
||||
|
||||
Reference in New Issue
Block a user