Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add admin rule tab to the role editor #49764

Merged
merged 3 commits into from
Dec 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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`.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like this comment block is almost guaranteed to be ignored, but I don't really have a better idea how to solve this. If I was ever adding a new kind, I'd do "Go to definition" on an existing, similar kind and never see this comment.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel the same, but that's the least I could do. The ultimatlely correct solution for this one would probably be consuming a protobuf enum, but that would be a much bigger undertaking.


// 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')}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit for the future, as it's not directly related to what's in this PR and changing this at this point could require a lot of not-strictly-necessary work: I noticed that those overloads on newAccessSpec don't seem to provide a lot of type safety. For example, this doesn't raise a type error about the implementation of newAccessSpec:

-export function newAccessSpec(kind: 'db'): DatabaseAccessSpec;
+export function newAccessSpec(kind: 'db'): AppAccessSpec;

https://www.typescriptlang.org/docs/handbook/2/functions.html#writing-good-overloads

Always prefer parameters with union types instead of overloads when possible.

There's a couple of other solutions we could consider. If it's important for us to be able to call a function and get a specific variant of AccessSpec back, we could have a separate function for each variant (e.g. newDatabaseAccessSpec) that then get called from newAccessSpec. If that's not important, then we could have just a single export function newAccessSpec(kind: AccessSpecKind): AccessSpec definition without the overloads.


As a side note on overloads, TypeScript has a ton of features for expressing things that are legal in JavaScript, as TypeScript has to account for the fact that JavaScript is a dynamic language and that there are libraries which weren't written with JS in mind. But when authoring code, I found that usually I'm better off pretending that TypeScript is a static language with a type system akin to that found in Rust, F#, Haskell, or Elm. That is, I let go of the constructs like overloads which are supposed to help with typing JS code and make use mostly of product and sum types.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I'll keep it in mind.

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());
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to trigger validation by interacting with the UI rather than depending on validator being available?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There would, of course, be a way if I wrapped the component with a button as a part of our test case. I just thought it's unnecessary, since the component under test in this case doesn't have that capability built-in, so it doesn't matter whether we add UI around it just for the testing purposes or not. Why do you think it would be beneficial?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We often depend on some internal deps within our tests which then leads to brittle or flaky tests, see #49870. But I suppose using validator here makes sense, as you're not testing a complete UI that's surfaced to the user, but rather a utility for building such UIs.

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
Loading