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:
Bartosz Leper
2025-01-23 11:20:35 +00:00
committed by GitHub
parent aebfaf7090
commit d47273ca37
18 changed files with 448 additions and 111 deletions
+2
View File
@@ -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": {
+21 -7
View File
@@ -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.
-1
View File
@@ -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",