Skip to content

Commit

Permalink
Add admin rule tab to the role editor
Browse files Browse the repository at this point in the history
Also tightens some constraints about standard role editor conformance.
  • Loading branch information
bl-nero committed Dec 4, 2024
1 parent 17823aa commit 5e10c55
Show file tree
Hide file tree
Showing 8 changed files with 805 additions and 84 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
94 changes: 83 additions & 11 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 {
AdminRules,
AppAccessSpecSection,
DatabaseAccessSpecSection,
KubernetesAccessSpecSection,
Expand All @@ -48,7 +51,12 @@ import {
StandardEditorProps,
WindowsDesktopAccessSpecSection,
} from './StandardEditor';
import { validateAccessSpec } from './validation';
import {
AccessSpecValidationResult,
AdminRuleValidationResult,
validateAccessSpec,
validateAdminRule,
} from './validation';

const TestStandardEditor = (props: Partial<StandardEditorProps>) => {
const ctx = createTeleportContext();
Expand Down Expand Up @@ -165,19 +173,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<S, V>({
defaultValue,
component: Component,
onChange,
validatorRef,
validate,
}: {
defaultValue: S;
component: React.ComponentType<SectionProps<S, any>>;
onChange(spec: S): void;
validatorRef?(v: Validator): void;
}) => {
validate(arg: S): V;
}) {
const [model, setModel] = useState<S>(defaultValue);
const validation = validateAccessSpec(model);
const validation = validate(model);
return (
<Validation>
{({ validator }) => {
Expand All @@ -196,20 +206,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 +269,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 +411,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 +489,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 +546,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 +584,63 @@ describe('WindowsDesktopAccessSpecSection', () => {
});
});

describe('AdminRules', () => {
const setup = () => {
const onChange = jest.fn();
let validator: Validator;
render(
<StatefulSection<RuleModel[], AdminRuleValidationResult[]>
component={AdminRules}
defaultValue={[]}
onChange={onChange}
validatorRef={v => {
validator = v;
}}
validate={rules => rules.map(validateAdminRule)}
/>
);
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');
100 changes: 100 additions & 0 deletions web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ import {
AppAccessSpec,
DatabaseAccessSpec,
WindowsDesktopAccessSpec,
RuleModel,
resourceKindOptions,
verbOptions,
newRuleModel,
} from './standardmodel';
import {
validateRoleEditorModel,
Expand All @@ -80,6 +84,7 @@ import {
AppSpecValidationResult,
DatabaseSpecValidationResult,
WindowsDesktopSpecValidationResult,
AdminRuleValidationResult,
} from './validation';
import { EditorSaveCancelButton } from './Shared';
import { RequiresResetToStandard } from './RequiresResetToStandard';
Expand Down Expand Up @@ -188,6 +193,13 @@ export const StandardEditor = ({
});
}

function setRules(rules: RuleModel[]) {
handleChange({
...standardEditorModel.roleModel,
rules,
});
}

return (
<Validation>
{({ validator }) => (
Expand Down Expand Up @@ -227,6 +239,11 @@ export const StandardEditor = ({
key: StandardEditorTab.AdminRules,
title: 'Admin Rules',
controls: adminRulesTabId,
status:
validator.state.validating &&
validation.rules.some(s => !s.valid)
? validationErrorTabStatus
: undefined,
},
{
key: StandardEditorTab.Options,
Expand Down Expand Up @@ -306,6 +323,20 @@ export const StandardEditor = ({
</Box>
</Flex>
</div>
<div
id={adminRulesTabId}
style={{
display:
currentTab === StandardEditorTab.AdminRules ? '' : 'none',
}}
>
<AdminRules
isProcessing={isProcessing}
value={roleModel.rules}
onChange={setRules}
validation={validation.rules}
/>
</div>
</EditorWrapper>
<EditorSaveCancelButton
onSave={() => handleSave(validator)}
Expand Down Expand Up @@ -896,6 +927,75 @@ export function WindowsDesktopAccessSpecSection({
);
}

export function AdminRules({
value,
isProcessing,
validation,
onChange,
}: SectionProps<RuleModel[], AdminRuleValidationResult[]>) {
function addRule() {
onChange?.([...value, newRuleModel()]);
}
function setRule(rule: RuleModel) {
onChange?.(value.map(r => (r.id === rule.id ? rule : r)));
}
return (
<Flex flexDirection="column" gap={3}>
{value.map((rule, i) => (
<AdminRule
key={rule.id}
isProcessing={isProcessing}
value={rule}
onChange={setRule}
validation={validation[i]}
/>
))}
<ButtonSecondary alignSelf="start" onClick={addRule}>
<Icon.Plus size="small" mr={2} />
Add New
</ButtonSecondary>
</Flex>
);
}

function AdminRule({
value,
isProcessing,
validation,
onChange,
}: SectionProps<RuleModel, AdminRuleValidationResult>) {
const { resources, verbs } = value;
const theme = useTheme();
return (
<Box
border={1}
borderColor={theme.colors.interactive.tonal.neutral[0]}
borderRadius={3}
p={3}
>
<FieldSelect
isMulti
label="Resources"
isDisabled={isProcessing}
options={resourceKindOptions}
value={resources}
onChange={r => onChange?.({ ...value, resources: r })}
rule={precomputed(validation.fields.resources)}
/>
<FieldSelect
isMulti
label="Permissions"
isDisabled={isProcessing}
options={verbOptions}
value={verbs}
onChange={v => onChange?.({ ...value, verbs: v })}
rule={precomputed(validation.fields.verbs)}
mb={0}
/>
</Box>
);
}

export const EditorWrapper = styled(Box)<{ mute?: boolean }>`
opacity: ${p => (p.mute ? 0.4 : 1)};
pointer-events: ${p => (p.mute ? 'none' : '')};
Expand Down
Loading

0 comments on commit 5e10c55

Please sign in to comment.