Skip to content

Commit

Permalink
Add admin rule tab to the role editor (#49764)
Browse files Browse the repository at this point in the history
* Add admin rule tab to the role editor

Also tightens some constraints about standard role editor conformance.

* Add a way to delete an admin rule

* Review
  • Loading branch information
bl-nero authored Dec 9, 2024
1 parent 3208c3d commit 02141ca
Show file tree
Hide file tree
Showing 9 changed files with 1,026 additions and 290 deletions.
5 changes: 5 additions & 0 deletions api/types/constants.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import (
)

const (
// The `Kind*` constants in this const block identify resource kinds used for
// storage an/or and access control. Please keep these in sync with the
// `ResourceKind` enum in
// `web/packages/teleport/src/services/resources/types.ts`.

// DefaultAPIGroup is a default group of permissions API,
// lets us to add different permission types
DefaultAPIGroup = "gravitational.io/teleport"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export default {
}
return (
<TeleportContextProvider ctx={ctx}>
<Flex flexDirection="column" width="500px" height="800px">
<Flex flexDirection="column" width="700px" height="800px">
<Story />
</Flex>
</TeleportContextProvider>
Expand Down
107 changes: 60 additions & 47 deletions web/packages/teleport/src/Roles/RoleEditor/RoleEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import { Alert, Flex } from 'design';
import React, { useId, useState } from 'react';
import { useAsync } from 'shared/hooks/useAsync';

import Validation, { Validator } from 'shared/components/Validation';

import { Role, RoleWithYaml } from 'teleport/services/resources';
import { yamlService } from 'teleport/services/yaml';
import { YamlSupportedResourceKind } from 'teleport/services/yaml/types';
Expand Down Expand Up @@ -119,11 +121,18 @@ export const RoleEditor = ({
yamlifyAttempt.status === 'processing' ||
saveAttempt.status === 'processing';

async function onTabChange(activeIndex: EditorTab) {
async function onTabChange(activeIndex: EditorTab, validator: Validator) {
// The code below is not idempotent, so we need to protect ourselves from
// an accidental model replacement.
if (activeIndex === selectedEditorTab) return;

// Validate the model on tab switch, because the server-side yamlification
// requires model to be valid. However, if it's OK, we reset the validator.
// We don't want it to be validating at this point, since the user didn't
// attempt to submit the form.
if (!validator.validate()) return;
validator.reset();

switch (activeIndex) {
case EditorTab.Standard: {
if (!yamlModel.content) {
Expand Down Expand Up @@ -169,55 +178,59 @@ export const RoleEditor = ({
}

return (
<Flex flexDirection="column" flex="1">
<EditorHeader
role={originalRole?.object}
onDelete={onDelete}
selectedEditorTab={selectedEditorTab}
onEditorTabChange={onTabChange}
isProcessing={isProcessing}
standardEditorId={standardEditorId}
yamlEditorId={yamlEditorId}
/>
{saveAttempt.status === 'error' && (
<Alert mt={3} dismissible>
{saveAttempt.statusText}
</Alert>
)}
{parseAttempt.status === 'error' && (
<Alert mt={3} dismissible>
{parseAttempt.statusText}
</Alert>
)}
{yamlifyAttempt.status === 'error' && (
<Alert mt={3} dismissible>
{yamlifyAttempt.statusText}
</Alert>
)}
{selectedEditorTab === EditorTab.Standard && (
<div id={standardEditorId}>
<StandardEditor
originalRole={originalRole}
onSave={object => handleSave({ object })}
onCancel={handleCancel}
standardEditorModel={standardModel}
isProcessing={isProcessing}
onChange={setStandardModel}
/>
</div>
)}
{selectedEditorTab === EditorTab.Yaml && (
<Flex flexDirection="column" flex="1" id={yamlEditorId}>
<YamlEditor
yamlEditorModel={yamlModel}
onChange={setYamlModel}
onSave={async yaml => void (await handleSave({ yaml }))}
<Validation>
{({ validator }) => (
<Flex flexDirection="column" flex="1">
<EditorHeader
role={originalRole?.object}
onDelete={onDelete}
selectedEditorTab={selectedEditorTab}
onEditorTabChange={index => onTabChange(index, validator)}
isProcessing={isProcessing}
onCancel={handleCancel}
originalRole={originalRole}
standardEditorId={standardEditorId}
yamlEditorId={yamlEditorId}
/>
{saveAttempt.status === 'error' && (
<Alert mt={3} dismissible>
{saveAttempt.statusText}
</Alert>
)}
{parseAttempt.status === 'error' && (
<Alert mt={3} dismissible>
{parseAttempt.statusText}
</Alert>
)}
{yamlifyAttempt.status === 'error' && (
<Alert mt={3} dismissible>
{yamlifyAttempt.statusText}
</Alert>
)}
{selectedEditorTab === EditorTab.Standard && (
<div id={standardEditorId}>
<StandardEditor
originalRole={originalRole}
onSave={object => handleSave({ object })}
onCancel={handleCancel}
standardEditorModel={standardModel}
isProcessing={isProcessing}
onChange={setStandardModel}
/>
</div>
)}
{selectedEditorTab === EditorTab.Yaml && (
<Flex flexDirection="column" flex="1" id={yamlEditorId}>
<YamlEditor
yamlEditorModel={yamlModel}
onChange={setYamlModel}
onSave={async yaml => void (await handleSave({ yaml }))}
isProcessing={isProcessing}
onCancel={handleCancel}
originalRole={originalRole}
/>
</Flex>
)}
</Flex>
)}
</Flex>
</Validation>
);
};
118 changes: 96 additions & 22 deletions web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,22 @@ import selectEvent from 'react-select-event';
import TeleportContextProvider from 'teleport/TeleportContextProvider';
import { createTeleportContext } from 'teleport/mocks/contexts';

import { ResourceKind } from 'teleport/services/resources';

import {
AccessSpec,
AppAccessSpec,
DatabaseAccessSpec,
KubernetesAccessSpec,
newAccessSpec,
newRole,
roleToRoleEditorModel,
RuleModel,
ServerAccessSpec,
StandardEditorModel,
WindowsDesktopAccessSpec,
} from './standardmodel';
import {
AccessRules,
AppAccessSpecSection,
DatabaseAccessSpecSection,
KubernetesAccessSpecSection,
Expand All @@ -48,7 +51,12 @@ import {
StandardEditorProps,
WindowsDesktopAccessSpecSection,
} from './StandardEditor';
import { validateAccessSpec } from './validation';
import {
AccessSpecValidationResult,
AccessRuleValidationResult,
validateAccessSpec,
validateAccessRule,
} from './validation';

const TestStandardEditor = (props: Partial<StandardEditorProps>) => {
const ctx = createTeleportContext();
Expand All @@ -58,13 +66,15 @@ const TestStandardEditor = (props: Partial<StandardEditorProps>) => {
});
return (
<TeleportContextProvider ctx={ctx}>
<StandardEditor
originalRole={null}
standardEditorModel={model}
isProcessing={false}
onChange={setModel}
{...props}
/>
<Validation>
<StandardEditor
originalRole={null}
standardEditorModel={model}
isProcessing={false}
onChange={setModel}
{...props}
/>
</Validation>
</TeleportContextProvider>
);
};
Expand Down Expand Up @@ -165,19 +175,21 @@ const getSectionByName = (name: string) =>
// eslint-disable-next-line testing-library/no-node-access
screen.getByRole('heading', { level: 3, name }).closest('details');

const StatefulSection = <S extends AccessSpec>({
function StatefulSection<Spec, ValidationResult>({
defaultValue,
component: Component,
onChange,
validatorRef,
validate,
}: {
defaultValue: S;
component: React.ComponentType<SectionProps<S, any>>;
onChange(spec: S): void;
defaultValue: Spec;
component: React.ComponentType<SectionProps<Spec, any>>;
onChange(spec: Spec): void;
validatorRef?(v: Validator): void;
}) => {
const [model, setModel] = useState<S>(defaultValue);
const validation = validateAccessSpec(model);
validate(arg: Spec): ValidationResult;
}) {
const [model, setModel] = useState<Spec>(defaultValue);
const validation = validate(model);
return (
<Validation>
{({ validator }) => {
Expand All @@ -196,20 +208,21 @@ const StatefulSection = <S extends AccessSpec>({
}}
</Validation>
);
};
}

describe('ServerAccessSpecSection', () => {
const setup = () => {
const onChange = jest.fn();
let validator: Validator;
render(
<StatefulSection<ServerAccessSpec>
<StatefulSection<ServerAccessSpec, AccessSpecValidationResult>
component={ServerAccessSpecSection}
defaultValue={newAccessSpec('node')}
onChange={onChange}
validatorRef={v => {
validator = v;
}}
validate={validateAccessSpec}
/>
);
return { user: userEvent.setup(), onChange, validator };
Expand Down Expand Up @@ -258,13 +271,14 @@ describe('KubernetesAccessSpecSection', () => {
const onChange = jest.fn();
let validator: Validator;
render(
<StatefulSection<KubernetesAccessSpec>
<StatefulSection<KubernetesAccessSpec, AccessSpecValidationResult>
component={KubernetesAccessSpecSection}
defaultValue={newAccessSpec('kube_cluster')}
onChange={onChange}
validatorRef={v => {
validator = v;
}}
validate={validateAccessSpec}
/>
);
return { user: userEvent.setup(), onChange, validator };
Expand Down Expand Up @@ -399,13 +413,14 @@ describe('AppAccessSpecSection', () => {
const onChange = jest.fn();
let validator: Validator;
render(
<StatefulSection<AppAccessSpec>
<StatefulSection<AppAccessSpec, AccessSpecValidationResult>
component={AppAccessSpecSection}
defaultValue={newAccessSpec('app')}
onChange={onChange}
validatorRef={v => {
validator = v;
}}
validate={validateAccessSpec}
/>
);
return { user: userEvent.setup(), onChange, validator };
Expand Down Expand Up @@ -476,13 +491,14 @@ describe('DatabaseAccessSpecSection', () => {
const onChange = jest.fn();
let validator: Validator;
render(
<StatefulSection<DatabaseAccessSpec>
<StatefulSection<DatabaseAccessSpec, AccessSpecValidationResult>
component={DatabaseAccessSpecSection}
defaultValue={newAccessSpec('db')}
onChange={onChange}
validatorRef={v => {
validator = v;
}}
validate={validateAccessSpec}
/>
);
return { user: userEvent.setup(), onChange, validator };
Expand Down Expand Up @@ -532,13 +548,14 @@ describe('WindowsDesktopAccessSpecSection', () => {
const onChange = jest.fn();
let validator: Validator;
render(
<StatefulSection<WindowsDesktopAccessSpec>
<StatefulSection<WindowsDesktopAccessSpec, AccessSpecValidationResult>
component={WindowsDesktopAccessSpecSection}
defaultValue={newAccessSpec('windows_desktop')}
onChange={onChange}
validatorRef={v => {
validator = v;
}}
validate={validateAccessSpec}
/>
);
return { user: userEvent.setup(), onChange, validator };
Expand Down Expand Up @@ -569,6 +586,63 @@ describe('WindowsDesktopAccessSpecSection', () => {
});
});

describe('AccessRules', () => {
const setup = () => {
const onChange = jest.fn();
let validator: Validator;
render(
<StatefulSection<RuleModel[], AccessRuleValidationResult[]>
component={AccessRules}
defaultValue={[]}
onChange={onChange}
validatorRef={v => {
validator = v;
}}
validate={rules => rules.map(validateAccessRule)}
/>
);
return { user: userEvent.setup(), onChange, validator };
};

test('editing', async () => {
const { user, onChange } = setup();
await user.click(screen.getByRole('button', { name: 'Add New' }));
await selectEvent.select(screen.getByLabelText('Resources'), [
'db',
'node',
]);
await selectEvent.select(screen.getByLabelText('Permissions'), [
'list',
'read',
]);
expect(onChange).toHaveBeenLastCalledWith([
{
id: expect.any(String),
resources: [
{ label: ResourceKind.Database, value: 'db' },
{ label: ResourceKind.Node, value: 'node' },
],
verbs: [
{ label: 'list', value: 'list' },
{ label: 'read', value: 'read' },
],
},
] as RuleModel[]);
});

test('validation', async () => {
const { user, validator } = setup();
await user.click(screen.getByRole('button', { name: 'Add New' }));
act(() => validator.validate());
expect(
screen.getByText('At least one resource kind is required')
).toBeInTheDocument();
expect(
screen.getByText('At least one permission is required')
).toBeInTheDocument();
});
});

const reactSelectValueContainer = (input: HTMLInputElement) =>
// eslint-disable-next-line testing-library/no-node-access
input.closest('.react-select__value-container');
Loading

0 comments on commit 02141ca

Please sign in to comment.