-
- {saveAttempt.status === 'error' && (
-
- {saveAttempt.statusText}
-
- )}
- {parseAttempt.status === 'error' && (
-
- {parseAttempt.statusText}
-
- )}
- {yamlifyAttempt.status === 'error' && (
-
- {yamlifyAttempt.statusText}
-
- )}
- {selectedEditorTab === EditorTab.Standard && (
-
- handleSave({ object })}
- onCancel={handleCancel}
- standardEditorModel={standardModel}
- isProcessing={isProcessing}
- onChange={setStandardModel}
- />
-
- )}
- {selectedEditorTab === EditorTab.Yaml && (
-
- void (await handleSave({ yaml }))}
+
+ {({ validator }) => (
+
+ onTabChange(index, validator)}
isProcessing={isProcessing}
- onCancel={handleCancel}
- originalRole={originalRole}
+ standardEditorId={standardEditorId}
+ yamlEditorId={yamlEditorId}
/>
+ {saveAttempt.status === 'error' && (
+
+ {saveAttempt.statusText}
+
+ )}
+ {parseAttempt.status === 'error' && (
+
+ {parseAttempt.statusText}
+
+ )}
+ {yamlifyAttempt.status === 'error' && (
+
+ {yamlifyAttempt.statusText}
+
+ )}
+ {selectedEditorTab === EditorTab.Standard && (
+
+ handleSave({ object })}
+ onCancel={handleCancel}
+ standardEditorModel={standardModel}
+ isProcessing={isProcessing}
+ onChange={setStandardModel}
+ />
+
+ )}
+ {selectedEditorTab === EditorTab.Yaml && (
+
+ void (await handleSave({ yaml }))}
+ isProcessing={isProcessing}
+ onCancel={handleCancel}
+ originalRole={originalRole}
+ />
+
+ )}
)}
-
+
);
};
diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx
index 59a7af9928081..fc8a1574637f0 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx
+++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx
@@ -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,
@@ -48,7 +51,12 @@ import {
StandardEditorProps,
WindowsDesktopAccessSpecSection,
} from './StandardEditor';
-import { validateAccessSpec } from './validation';
+import {
+ AccessSpecValidationResult,
+ AccessRuleValidationResult,
+ validateAccessSpec,
+ validateAccessRule,
+} from './validation';
const TestStandardEditor = (props: Partial) => {
const ctx = createTeleportContext();
@@ -58,13 +66,15 @@ const TestStandardEditor = (props: Partial) => {
});
return (
-
+
+
+
);
};
@@ -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 = ({
+function StatefulSection({
defaultValue,
component: Component,
onChange,
validatorRef,
+ validate,
}: {
- defaultValue: S;
- component: React.ComponentType>;
- onChange(spec: S): void;
+ defaultValue: Spec;
+ component: React.ComponentType>;
+ onChange(spec: Spec): void;
validatorRef?(v: Validator): void;
-}) => {
- const [model, setModel] = useState(defaultValue);
- const validation = validateAccessSpec(model);
+ validate(arg: Spec): ValidationResult;
+}) {
+ const [model, setModel] = useState(defaultValue);
+ const validation = validate(model);
return (
{({ validator }) => {
@@ -196,20 +208,21 @@ const StatefulSection = ({
}}
);
-};
+}
describe('ServerAccessSpecSection', () => {
const setup = () => {
const onChange = jest.fn();
let validator: Validator;
render(
-
+
component={ServerAccessSpecSection}
defaultValue={newAccessSpec('node')}
onChange={onChange}
validatorRef={v => {
validator = v;
}}
+ validate={validateAccessSpec}
/>
);
return { user: userEvent.setup(), onChange, validator };
@@ -258,13 +271,14 @@ describe('KubernetesAccessSpecSection', () => {
const onChange = jest.fn();
let validator: Validator;
render(
-
+
component={KubernetesAccessSpecSection}
defaultValue={newAccessSpec('kube_cluster')}
onChange={onChange}
validatorRef={v => {
validator = v;
}}
+ validate={validateAccessSpec}
/>
);
return { user: userEvent.setup(), onChange, validator };
@@ -399,13 +413,14 @@ describe('AppAccessSpecSection', () => {
const onChange = jest.fn();
let validator: Validator;
render(
-
+
component={AppAccessSpecSection}
defaultValue={newAccessSpec('app')}
onChange={onChange}
validatorRef={v => {
validator = v;
}}
+ validate={validateAccessSpec}
/>
);
return { user: userEvent.setup(), onChange, validator };
@@ -476,13 +491,14 @@ describe('DatabaseAccessSpecSection', () => {
const onChange = jest.fn();
let validator: Validator;
render(
-
+
component={DatabaseAccessSpecSection}
defaultValue={newAccessSpec('db')}
onChange={onChange}
validatorRef={v => {
validator = v;
}}
+ validate={validateAccessSpec}
/>
);
return { user: userEvent.setup(), onChange, validator };
@@ -532,13 +548,14 @@ describe('WindowsDesktopAccessSpecSection', () => {
const onChange = jest.fn();
let validator: Validator;
render(
-
+
component={WindowsDesktopAccessSpecSection}
defaultValue={newAccessSpec('windows_desktop')}
onChange={onChange}
validatorRef={v => {
validator = v;
}}
+ validate={validateAccessSpec}
/>
);
return { user: userEvent.setup(), onChange, validator };
@@ -569,6 +586,63 @@ describe('WindowsDesktopAccessSpecSection', () => {
});
});
+describe('AccessRules', () => {
+ const setup = () => {
+ const onChange = jest.fn();
+ let validator: Validator;
+ render(
+
+ 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');
diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx
index bf1567ee235cd..f17dce9ff179f 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx
+++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx
@@ -28,10 +28,7 @@ import {
Text,
} from 'design';
import FieldInput from 'shared/components/FieldInput';
-import Validation, {
- useValidation,
- Validator,
-} from 'shared/components/Validation';
+import { useValidation } from 'shared/components/Validation';
import {
precomputed,
ValidationResult,
@@ -69,6 +66,10 @@ import {
AppAccessSpec,
DatabaseAccessSpec,
WindowsDesktopAccessSpec,
+ RuleModel,
+ resourceKindOptions,
+ verbOptions,
+ newRuleModel,
} from './standardmodel';
import {
validateRoleEditorModel,
@@ -80,6 +81,7 @@ import {
AppSpecValidationResult,
DatabaseSpecValidationResult,
WindowsDesktopSpecValidationResult,
+ AccessRuleValidationResult,
} from './validation';
import { EditorSaveCancelButton } from './Shared';
import { RequiresResetToStandard } from './RequiresResetToStandard';
@@ -117,7 +119,7 @@ export const StandardEditor = ({
enum StandardEditorTab {
Overview,
Resources,
- AdminRules,
+ AccessRules,
Options,
}
@@ -125,10 +127,12 @@ export const StandardEditor = ({
const idPrefix = useId();
const overviewTabId = `${idPrefix}-overview`;
const resourcesTabId = `${idPrefix}-resources`;
- const adminRulesTabId = `${idPrefix}-admin-rules`;
+ const accessRulesTabId = `${idPrefix}-access-rules`;
const optionsTabId = `${idPrefix}-options`;
- function handleSave(validator: Validator) {
+ const validator = useValidation();
+
+ function handleSave() {
if (!validator.validate()) {
return;
}
@@ -188,146 +192,165 @@ export const StandardEditor = ({
});
}
+ function setRules(rules: RuleModel[]) {
+ handleChange({
+ ...standardEditorModel.roleModel,
+ rules,
+ });
+ }
+
return (
-
- {({ validator }) => (
- <>
- {roleModel.requiresReset && (
-
- )}
-
-
- !s.valid)
- ? validationErrorTabStatus
- : undefined,
- },
- {
- key: StandardEditorTab.AdminRules,
- title: 'Admin Rules',
- controls: adminRulesTabId,
+ <>
+ {roleModel.requiresReset && (
+
+ )}
+
+
+ !s.valid)
+ ? validationErrorTabStatus
+ : undefined,
+ },
+ {
+ key: StandardEditorTab.AccessRules,
+ title: 'Access Rules',
+ controls: accessRulesTabId,
+ status:
+ validator.state.validating &&
+ validation.rules.some(s => !s.valid)
+ ? validationErrorTabStatus
+ : undefined,
+ },
+ {
+ key: StandardEditorTab.Options,
+ title: 'Options',
+ controls: optionsTabId,
+ },
+ ]}
+ activeIndex={currentTab}
+ onChange={setCurrentTab}
+ />
+
+
+ handleChange({ ...roleModel, metadata })}
+ />
+
+
+
+ {roleModel.accessSpecs.map((spec, i) => {
+ const validationResult = validation.accessSpecs[i];
+ return (
+ setAccessSpec(value)}
+ onRemove={() => removeAccessSpec(spec.kind)}
+ />
+ );
+ })}
+
+
+ }}
+ buttonText={
+ <>
+
+ Add New Specifications
+ >
+ }
+ buttonProps={{
+ size: 'medium',
+ fill: 'filled',
+ disabled: isProcessing || allowedSpecKinds.length === 0,
+ }}
+ >
+ {allowedSpecKinds.map(kind => (
+
+ ))}
+
-
- handleChange({ ...roleModel, metadata })}
- />
-
-
-
- {roleModel.accessSpecs.map((spec, i) => {
- const validationResult = validation.accessSpecs[i];
- return (
- setAccessSpec(value)}
- onRemove={() => removeAccessSpec(spec.kind)}
- />
- );
- })}
-
-
-
- Add New Specifications
- >
- }
- buttonProps={{
- size: 'medium',
- fill: 'filled',
- disabled: isProcessing || allowedSpecKinds.length === 0,
- }}
- >
- {allowedSpecKinds.map(kind => (
-
- ))}
-
-
-
-
-
- handleSave(validator)}
- onCancel={onCancel}
- disabled={
- isProcessing ||
- standardEditorModel.roleModel.requiresReset ||
- !standardEditorModel.isDirty
- }
- isEditing={isEditing}
+
+
+
+
+ handleSave()}
+ onCancel={onCancel}
+ disabled={
+ isProcessing ||
+ standardEditorModel.roleModel.requiresReset ||
+ !standardEditorModel.isDirty
+ }
+ isEditing={isEditing}
+ />
+ >
);
};
-export type SectionProps = {
- value: T;
+export type SectionProps = {
+ value: Model;
isProcessing: boolean;
- validation?: V;
- onChange?(value: T): void;
+ validation?: ValidationResult;
+ onChange?(value: Model): void;
};
const validationErrorTabStatus = {
@@ -896,6 +919,83 @@ export function WindowsDesktopAccessSpecSection({
);
}
+export function AccessRules({
+ value,
+ isProcessing,
+ validation,
+ onChange,
+}: SectionProps) {
+ function addRule() {
+ onChange?.([...value, newRuleModel()]);
+ }
+ function setRule(rule: RuleModel) {
+ onChange?.(value.map(r => (r.id === rule.id ? rule : r)));
+ }
+ function removeRule(id: string) {
+ onChange?.(value.filter(r => r.id !== id));
+ }
+ return (
+
+ {value.map((rule, i) => (
+ removeRule(rule.id)}
+ />
+ ))}
+
+
+ Add New
+
+
+ );
+}
+
+function AccessRule({
+ value,
+ isProcessing,
+ validation,
+ onChange,
+ onRemove,
+}: SectionProps & {
+ onRemove?(): void;
+}) {
+ const { resources, verbs } = value;
+ return (
+
+ onChange?.({ ...value, resources: r })}
+ rule={precomputed(validation.fields.resources)}
+ />
+ onChange?.({ ...value, verbs: v })}
+ rule={precomputed(validation.fields.verbs)}
+ mb={0}
+ />
+
+ );
+}
+
export const EditorWrapper = styled(Box)<{ mute?: boolean }>`
opacity: ${p => (p.mute ? 0.4 : 1)};
pointer-events: ${p => (p.mute ? 'none' : '')};
diff --git a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.test.ts b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.test.ts
index 616e36673bbfe..157eee00c85e3 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.test.ts
+++ b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.test.ts
@@ -16,7 +16,12 @@
* along with this program. If not, see .
*/
-import { Role } from 'teleport/services/resources';
+import {
+ KubernetesResource,
+ ResourceKind,
+ Role,
+ Rule,
+} from 'teleport/services/resources';
import { Label as UILabel } from 'teleport/components/LabelsInput/LabelsInput';
@@ -25,6 +30,7 @@ import { Labels } from 'teleport/services/resources';
import {
labelsModelToLabels,
labelsToModel,
+ newAccessSpec,
RoleEditorModel,
roleEditorModelToRole,
roleToRoleEditorModel,
@@ -37,6 +43,7 @@ const minimalRole = () =>
const minimalRoleModel = (): RoleEditorModel => ({
metadata: { name: 'foobar' },
accessSpecs: [],
+ rules: [],
requiresReset: false,
});
@@ -211,88 +218,217 @@ describe.each<{ name: string; role: Role; model: RoleEditorModel }>([
});
describe('roleToRoleEditorModel', () => {
- it('detects unknown fields', () => {
- const minRole = minimalRole();
- const roleModelWithReset: RoleEditorModel = {
- ...minimalRoleModel(),
- requiresReset: true,
- };
-
- expect(roleToRoleEditorModel(minRole).requiresReset).toEqual(false);
-
- expect(
- roleToRoleEditorModel({ ...minRole, unknownField: 123 } as Role)
- ).toEqual(roleModelWithReset);
+ const minRole = minimalRole();
+ const roleModelWithReset: RoleEditorModel = {
+ ...minimalRoleModel(),
+ requiresReset: true,
+ };
+
+ test.each<{ name: string; role: Role; model?: RoleEditorModel }>([
+ {
+ name: 'unknown fields in Role',
+ role: { ...minRole, unknownField: 123 } as Role,
+ },
- expect(
- roleToRoleEditorModel({
+ {
+ name: 'unknown fields in metadata',
+ role: {
...minRole,
metadata: { name: 'foobar', unknownField: 123 },
- } as Role)
- ).toEqual(roleModelWithReset);
+ } as Role,
+ },
- expect(
- roleToRoleEditorModel({
+ {
+ name: 'unknown fields in spec',
+ role: {
...minRole,
spec: { ...minRole.spec, unknownField: 123 },
- } as Role)
- ).toEqual(roleModelWithReset);
+ } as Role,
+ },
- expect(
- roleToRoleEditorModel({
+ {
+ name: 'unknown fields in spec.allow',
+ role: {
...minRole,
spec: {
...minRole.spec,
allow: { ...minRole.spec.allow, unknownField: 123 },
},
- } as Role)
- ).toEqual(roleModelWithReset);
+ } as Role,
+ },
- expect(
- roleToRoleEditorModel({
+ {
+ name: 'unknown fields in KubernetesResource',
+ role: {
...minRole,
spec: {
...minRole.spec,
- deny: { ...minRole.spec.deny, unknownField: 123 },
+ allow: {
+ ...minRole.spec.allow,
+ kubernetes_resources: [
+ { kind: 'job', unknownField: 123 } as KubernetesResource,
+ ],
+ },
},
- } as Role)
- ).toEqual(roleModelWithReset);
+ } as Role,
+ model: {
+ ...roleModelWithReset,
+ accessSpecs: [
+ {
+ ...newAccessSpec('kube_cluster'),
+ resources: [expect.any(Object)],
+ },
+ ],
+ },
+ },
- expect(
- roleToRoleEditorModel({
+ {
+ name: 'unsupported resource kind in KubernetesResource',
+ role: {
...minRole,
spec: {
...minRole.spec,
- deny: { ...minRole.spec.deny, unknownField: 123 },
+ allow: {
+ ...minRole.spec.allow,
+ kubernetes_resources: [
+ { kind: 'illegal' } as unknown as KubernetesResource,
+ { kind: 'job' },
+ ],
+ },
},
- } as Role)
- ).toEqual(roleModelWithReset);
+ } as Role,
+ model: {
+ ...roleModelWithReset,
+ accessSpecs: [
+ {
+ ...newAccessSpec('kube_cluster'),
+ resources: [
+ expect.objectContaining({ kind: { value: 'job', label: 'Job' } }),
+ ],
+ },
+ ],
+ },
+ },
- expect(
- roleToRoleEditorModel({
+ {
+ name: 'unsupported verb in KubernetesResource',
+ role: {
...minRole,
spec: {
...minRole.spec,
- options: { ...minRole.spec.options, unknownField: 123 },
+ allow: {
+ ...minRole.spec.allow,
+ kubernetes_resources: [
+ {
+ kind: '*',
+ verbs: ['illegal', 'get'],
+ } as unknown as KubernetesResource,
+ ],
+ },
},
- } as Role)
- ).toEqual(roleModelWithReset);
+ } as Role,
+ model: {
+ ...roleModelWithReset,
+ accessSpecs: [
+ {
+ ...newAccessSpec('kube_cluster'),
+ resources: [
+ expect.objectContaining({
+ verbs: [{ value: 'get', label: 'get' }],
+ }),
+ ],
+ },
+ ],
+ },
+ },
- expect(
- roleToRoleEditorModel({
+ {
+ name: 'unknown fields in Rule',
+ role: {
...minRole,
spec: {
...minRole.spec,
- options: {
- ...minRole.spec.options,
- idp: { saml: { enabled: true }, unknownField: 123 },
+ allow: {
+ ...minRole.spec.allow,
+ rules: [{ unknownField: 123 } as Rule],
},
},
- } as Role)
- ).toEqual(roleModelWithReset);
+ } as Role,
+ model: {
+ ...roleModelWithReset,
+ rules: [expect.any(Object)],
+ },
+ },
- expect(
- roleToRoleEditorModel({
+ {
+ name: 'unsupported resource kind in Rule',
+ role: {
+ ...minRole,
+ spec: {
+ ...minRole.spec,
+ allow: {
+ ...minRole.spec.allow,
+ rules: [{ resources: ['illegal', 'node'] } as unknown as Rule],
+ },
+ },
+ } as Role,
+ model: {
+ ...roleModelWithReset,
+ rules: [
+ expect.objectContaining({
+ resources: [{ value: 'node', label: 'node' }],
+ }),
+ ],
+ },
+ },
+
+ {
+ name: 'unsupported verb in Rule',
+ role: {
+ ...minRole,
+ spec: {
+ ...minRole.spec,
+ allow: {
+ ...minRole.spec.allow,
+ rules: [{ verbs: ['illegal', 'create'] } as unknown as Rule],
+ },
+ },
+ } as Role,
+ model: {
+ ...roleModelWithReset,
+ rules: [
+ expect.objectContaining({
+ verbs: [{ value: 'create', label: 'create' }],
+ }),
+ ],
+ },
+ },
+
+ {
+ name: 'unknown fields in spec.deny',
+ role: {
+ ...minRole,
+ spec: {
+ ...minRole.spec,
+ deny: { ...minRole.spec.deny, unknownField: 123 },
+ },
+ } as Role,
+ },
+
+ {
+ name: 'unknown fields in spec.options',
+ role: {
+ ...minRole,
+ spec: {
+ ...minRole.spec,
+ options: { ...minRole.spec.options, unknownField: 123 },
+ },
+ } as Role,
+ },
+
+ {
+ name: 'unknown fields in spec.options.idp.saml',
+ role: {
...minRole,
spec: {
...minRole.spec,
@@ -301,11 +437,12 @@ describe('roleToRoleEditorModel', () => {
idp: { saml: { enabled: true, unknownField: 123 } },
},
},
- } as Role)
- ).toEqual(roleModelWithReset);
+ } as Role,
+ },
- expect(
- roleToRoleEditorModel({
+ {
+ name: 'unknown fields in spec.options.record_session',
+ role: {
...minRole,
spec: {
...minRole.spec,
@@ -317,9 +454,14 @@ describe('roleToRoleEditorModel', () => {
},
},
},
- } as Role)
- ).toEqual(roleModelWithReset);
- });
+ } as Role,
+ },
+ ])(
+ 'requires reset because of $name',
+ ({ role, model = roleModelWithReset }) => {
+ expect(roleToRoleEditorModel(role)).toEqual(model);
+ }
+ );
test('version change requires reset', () => {
expect(roleToRoleEditorModel({ ...minimalRole(), version: 'v1' })).toEqual({
@@ -471,6 +613,46 @@ describe('roleToRoleEditorModel', () => {
});
});
+it('creates a rule model', () => {
+ expect(
+ roleToRoleEditorModel({
+ ...minimalRole(),
+ spec: {
+ ...minimalRole().spec,
+ allow: {
+ rules: [
+ {
+ resources: [ResourceKind.User, ResourceKind.DatabaseService],
+ verbs: ['read', 'list'],
+ },
+ { resources: [ResourceKind.Lock], verbs: ['create'] },
+ ],
+ },
+ },
+ })
+ ).toEqual({
+ ...minimalRoleModel(),
+ rules: [
+ {
+ id: expect.any(String),
+ resources: [
+ { label: 'user', value: 'user' },
+ { label: 'db_service', value: 'db_service' },
+ ],
+ verbs: [
+ { label: 'read', value: 'read' },
+ { label: 'list', value: 'list' },
+ ],
+ },
+ {
+ id: expect.any(String),
+ resources: [{ label: 'lock', value: 'lock' }],
+ verbs: [{ label: 'create', value: 'create' }],
+ },
+ ],
+ } as RoleEditorModel);
+});
+
test('labelsToModel', () => {
expect(labelsToModel({ foo: 'bar', doubleFoo: ['bar1', 'bar2'] })).toEqual([
{ name: 'foo', value: 'bar' },
@@ -562,6 +744,43 @@ describe('roleEditorModelToRole', () => {
},
} as Role);
});
+
+ it('converts a rule model', () => {
+ expect(
+ roleEditorModelToRole({
+ ...minimalRoleModel(),
+ rules: [
+ {
+ id: 'dummy-id-1',
+ resources: [
+ { label: 'user', value: ResourceKind.User },
+ { label: 'db_service', value: ResourceKind.DatabaseService },
+ ],
+ verbs: [
+ { label: 'read', value: 'read' },
+ { label: 'list', value: 'list' },
+ ],
+ },
+ {
+ id: 'dummy-id-2',
+ resources: [{ label: 'lock', value: ResourceKind.Lock }],
+ verbs: [{ label: 'create', value: 'create' }],
+ },
+ ],
+ })
+ ).toEqual({
+ ...minimalRole(),
+ spec: {
+ ...minimalRole().spec,
+ allow: {
+ rules: [
+ { resources: ['user', 'db_service'], verbs: ['read', 'list'] },
+ { resources: ['lock'], verbs: ['create'] },
+ ],
+ },
+ },
+ } as Role);
+ });
});
test('labelsModelToLabels', () => {
diff --git a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts
index f50158add2ba6..6826957c127c2 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts
+++ b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts
@@ -29,6 +29,9 @@ import { Label as UILabel } from 'teleport/components/LabelsInput/LabelsInput';
import {
KubernetesResourceKind,
KubernetesVerb,
+ ResourceKind,
+ Rule,
+ Verb,
} from 'teleport/services/resources/types';
import { defaultOptions } from './withDefaults';
@@ -49,6 +52,7 @@ export type StandardEditorModel = {
export type RoleEditorModel = {
metadata: MetadataModel;
accessSpecs: AccessSpec[];
+ rules: RuleModel[];
/**
* Indicates whether the current resource, as described by YAML, is
* accurately represented by this editor model. If it's not, the user needs
@@ -150,6 +154,13 @@ export const kubernetesResourceKindOptions: KubernetesResourceKindOption[] = [
).toSorted((a, b) => a.label.localeCompare(b.label)),
];
+const optionsToMap = (opts: Option[]) =>
+ new Map(opts.map(o => [o.value, o]));
+
+const kubernetesResourceKindOptionsMap = optionsToMap(
+ kubernetesResourceKindOptions
+);
+
type KubernetesVerbOption = Option;
/**
* All possible Kubernetes verb drop-down options. This array needs to be kept
@@ -180,6 +191,33 @@ export const kubernetesVerbOptions: KubernetesVerbOption[] = [
.toSorted((a, b) => a.localeCompare(b))
.map(stringToOption),
];
+const kubernetesVerbOptionsMap = optionsToMap(kubernetesVerbOptions);
+
+type ResourceKindOption = Option;
+export const resourceKindOptions: ResourceKindOption[] = Object.values(
+ ResourceKind
+)
+ .toSorted()
+ .map(stringToOption);
+const resourceKindOptionsMap = optionsToMap(resourceKindOptions);
+
+type VerbOption = Option;
+export const verbOptions: VerbOption[] = (
+ [
+ '*',
+ 'create',
+ 'create_enroll_token',
+ 'delete',
+ 'enroll',
+ 'list',
+ 'read',
+ 'readnosecrets',
+ 'rotate',
+ 'update',
+ 'use',
+ ] as const
+).map(stringToOption);
+const verbOptionsMap = optionsToMap(verbOptions);
/** Model for the server access specification section. */
export type ServerAccessSpec = AccessSpecBase<'node'> & {
@@ -206,6 +244,13 @@ export type WindowsDesktopAccessSpec = AccessSpecBase<'windows_desktop'> & {
logins: readonly Option[];
};
+export type RuleModel = {
+ /** Autogenerated ID to be used with the `key` property. */
+ id: string;
+ resources: readonly ResourceKindOption[];
+ verbs: readonly VerbOption[];
+};
+
const roleVersion = 'v7';
/**
@@ -267,6 +312,14 @@ export function newKubernetesResourceModel(): KubernetesResourceModel {
};
}
+export function newRuleModel(): RuleModel {
+ return {
+ id: crypto.randomUUID(),
+ resources: [],
+ verbs: [],
+ };
+}
+
/**
* Converts a role to its in-editor UI model representation. The resulting
* model may be marked as requiring reset if the role contains unsupported
@@ -280,11 +333,14 @@ export function roleToRoleEditorModel(
// has been left. Therefore, we don't want Lint to warn us that we didn't use
// some of the fields.
// eslint-disable-next-line @typescript-eslint/no-unused-vars
- const { kind, metadata, spec, version, ...rest } = role;
- const { name, description, revision, ...mRest } = metadata;
- const { allow, deny, options, ...sRest } = spec;
- const { accessSpecs, requiresReset: allowRequiresReset } =
- roleConditionsToAccessSpecs(allow);
+ const { kind, metadata, spec, version, ...unsupported } = role;
+ const { name, description, revision, ...unsupportedMetadata } = metadata;
+ const { allow, deny, options, ...unsupportedSpecs } = spec;
+ const {
+ accessSpecs,
+ rules,
+ requiresReset: allowRequiresReset,
+ } = roleConditionsToModel(allow);
return {
metadata: {
@@ -293,13 +349,14 @@ export function roleToRoleEditorModel(
revision: originalRole?.metadata?.revision,
},
accessSpecs,
+ rules,
requiresReset:
revision !== originalRole?.metadata?.revision ||
version !== roleVersion ||
!(
- isEmpty(rest) &&
- isEmpty(mRest) &&
- isEmpty(sRest) &&
+ isEmpty(unsupported) &&
+ isEmpty(unsupportedMetadata) &&
+ isEmpty(unsupportedSpecs) &&
isEmpty(deny) &&
equalsDeep(options, defaultOptions())
) ||
@@ -311,10 +368,9 @@ export function roleToRoleEditorModel(
* Converts a `RoleConditions` instance (an "allow" or "deny" section, to be
* specific) to a list of access specification models.
*/
-function roleConditionsToAccessSpecs(conditions: RoleConditions): {
- accessSpecs: AccessSpec[];
- requiresReset: boolean;
-} {
+function roleConditionsToModel(
+ conditions: RoleConditions
+): Pick {
const {
node_labels,
logins,
@@ -335,7 +391,10 @@ function roleConditionsToAccessSpecs(conditions: RoleConditions): {
windows_desktop_labels,
windows_desktop_logins,
- ...rest
+
+ rules,
+
+ ...unsupportedConditions
} = conditions;
const accessSpecs: AccessSpec[] = [];
@@ -352,7 +411,10 @@ function roleConditionsToAccessSpecs(conditions: RoleConditions): {
const kubeGroupsModel = stringsToOptions(kubernetes_groups ?? []);
const kubeLabelsModel = labelsToModel(kubernetes_labels);
- const kubeResourcesModel = kubernetesResourcesToModel(kubernetes_resources);
+ const {
+ model: kubeResourcesModel,
+ requiresReset: kubernetesResourcesRequireReset,
+ } = kubernetesResourcesToModel(kubernetes_resources);
if (someNonEmpty(kubeGroupsModel, kubeLabelsModel, kubeResourcesModel)) {
accessSpecs.push({
kind: 'kube_cluster',
@@ -409,9 +471,16 @@ function roleConditionsToAccessSpecs(conditions: RoleConditions): {
});
}
+ const { model: rulesModel, requiresReset: rulesRequireReset } =
+ rulesToModel(rules);
+
return {
accessSpecs,
- requiresReset: !isEmpty(rest),
+ rules: rulesModel,
+ requiresReset:
+ kubernetesResourcesRequireReset ||
+ rulesRequireReset ||
+ !isEmpty(unsupportedConditions),
};
}
@@ -447,18 +516,69 @@ function stringsToOptions(arr: T[]): Option[] {
function kubernetesResourcesToModel(
resources: KubernetesResource[] | undefined
-): KubernetesResourceModel[] {
- return (resources ?? []).map(
- ({ kind, name, namespace = '', verbs = [] }) => ({
+): { model: KubernetesResourceModel[]; requiresReset: boolean } {
+ const result = (resources ?? []).map(kubernetesResourceToModel);
+ return {
+ model: result.map(r => r.model).filter(m => m !== undefined),
+ requiresReset: result.some(r => r.requiresReset),
+ };
+}
+
+function kubernetesResourceToModel(res: KubernetesResource): {
+ model?: KubernetesResourceModel;
+ requiresReset: boolean;
+} {
+ const { kind, name, namespace = '', verbs = [], ...unsupported } = res;
+ const kindOption = kubernetesResourceKindOptionsMap.get(kind);
+ const verbOptions = verbs.map(verb => kubernetesVerbOptionsMap.get(verb));
+ const knownVerbOptions = verbOptions.filter(v => v !== undefined);
+ return {
+ model:
+ kindOption !== undefined
+ ? {
+ id: crypto.randomUUID(),
+ kind: kindOption,
+ name,
+ namespace,
+ verbs: knownVerbOptions,
+ }
+ : undefined,
+ requiresReset:
+ kindOption === undefined ||
+ verbOptions.length !== knownVerbOptions.length ||
+ !isEmpty(unsupported),
+ };
+}
+
+function rulesToModel(rules: Rule[]): {
+ model: RuleModel[];
+ requiresReset: boolean;
+} {
+ const result = (rules ?? []).map(ruleToModel);
+ return {
+ model: result.map(r => r.model),
+ requiresReset: result.some(r => r.requiresReset),
+ };
+}
+
+function ruleToModel(rule: Rule): { model: RuleModel; requiresReset: boolean } {
+ const { resources = [], verbs = [], ...unsupported } = rule;
+ const resourcesModel = resources.map(k => resourceKindOptionsMap.get(k));
+ const knownResourcesModel = resourcesModel.filter(m => m !== undefined);
+ const verbsModel = verbs.map(v => verbOptionsMap.get(v));
+ const knownVerbsModel = verbsModel.filter(m => m !== undefined);
+ const requiresReset =
+ !isEmpty(unsupported) ||
+ knownResourcesModel.length !== resourcesModel.length ||
+ knownVerbsModel.length !== verbs.length;
+ return {
+ model: {
id: crypto.randomUUID(),
- kind: kubernetesResourceKindOptions.find(o => o.value === kind),
- name,
- namespace,
- verbs: verbs.map(verb =>
- kubernetesVerbOptions.find(o => o.value === verb)
- ),
- })
- );
+ resources: knownResourcesModel,
+ verbs: knownVerbsModel,
+ },
+ requiresReset,
+ };
}
function isEmpty(obj: object) {
@@ -535,6 +655,13 @@ export function roleEditorModelToRole(roleModel: RoleEditorModel): Role {
}
}
+ if (roleModel.rules.length > 0) {
+ role.spec.allow.rules = roleModel.rules.map(role => ({
+ resources: role.resources.map(r => r.value),
+ verbs: role.verbs.map(v => v.value),
+ }));
+ }
+
return role;
}
diff --git a/web/packages/teleport/src/Roles/RoleEditor/validation.ts b/web/packages/teleport/src/Roles/RoleEditor/validation.ts
index 95cde89ed036c..d7ca5b05f7144 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/validation.ts
+++ b/web/packages/teleport/src/Roles/RoleEditor/validation.ts
@@ -35,6 +35,7 @@ import {
KubernetesResourceModel,
MetadataModel,
RoleEditorModel,
+ RuleModel,
} from './standardmodel';
const kubernetesClusterWideResourceKinds: KubernetesResourceKind[] = [
@@ -49,10 +50,12 @@ const kubernetesClusterWideResourceKinds: KubernetesResourceKind[] = [
export function validateRoleEditorModel({
metadata,
accessSpecs,
+ rules,
}: RoleEditorModel) {
return {
metadata: validateMetadata(metadata),
accessSpecs: accessSpecs.map(validateAccessSpec),
+ rules: rules.map(validateAccessRule),
};
}
@@ -166,3 +169,14 @@ const windowsDesktopSpecValidationRules = {
export type WindowsDesktopSpecValidationResult = RuleSetValidationResult<
typeof windowsDesktopSpecValidationRules
>;
+
+export const validateAccessRule = (accessRule: RuleModel) =>
+ runRules(accessRule, accessRuleValidationRules);
+
+const accessRuleValidationRules = {
+ resources: requiredField('At least one resource kind is required'),
+ verbs: requiredField('At least one permission is required'),
+};
+export type AccessRuleValidationResult = RuleSetValidationResult<
+ typeof accessRuleValidationRules
+>;
diff --git a/web/packages/teleport/src/services/resources/types.ts b/web/packages/teleport/src/services/resources/types.ts
index c5ab066e2c894..c5a17f313e872 100644
--- a/web/packages/teleport/src/services/resources/types.ts
+++ b/web/packages/teleport/src/services/resources/types.ts
@@ -83,6 +83,8 @@ export type RoleConditions = {
windows_desktop_labels?: Labels;
windows_desktop_logins?: string[];
+
+ rules?: Rule[];
};
export type Labels = Record;
@@ -143,6 +145,188 @@ export type KubernetesVerb =
| 'exec'
| 'portforward';
+export type Rule = {
+ resources?: ResourceKind[];
+ verbs?: Verb[];
+};
+
+export enum ResourceKind {
+ Wildcard = '*',
+
+ // This list was taken from all of the `Kind*` constants in
+ // `api/types/constants.go`. Please keep these in sync.
+
+ // Resources backed by objects in the backend database.
+ AccessGraphSecretAuthorizedKey = 'access_graph_authorized_key',
+ AccessGraphSecretPrivateKey = 'access_graph_private_key',
+ AccessGraphSettings = 'access_graph_settings',
+ AccessList = 'access_list',
+ AccessListMember = 'access_list_member',
+ AccessListReview = 'access_list_review',
+ AccessMonitoringRule = 'access_monitoring_rule',
+ AccessRequest = 'access_request',
+ App = 'app',
+ AppOrSAMLIdPServiceProvider = 'app_server_or_saml_idp_sp',
+ AppServer = 'app_server',
+ AuditQuery = 'audit_query',
+ AuthServer = 'auth_server',
+ AutoUpdateAgentRollout = 'autoupdate_agent_rollout',
+ AutoUpdateConfig = 'autoupdate_config',
+ AutoUpdateVersion = 'autoupdate_version',
+ Bot = 'bot',
+ BotInstance = 'bot_instance',
+ CertAuthority = 'cert_authority',
+ ClusterAlert = 'cluster_alert',
+ ClusterAuditConfig = 'cluster_audit_config',
+ ClusterAuthPreference = 'cluster_auth_preference',
+ ClusterMaintenanceConfig = 'cluster_maintenance_config',
+ ClusterName = 'cluster_name',
+ ClusterNetworkingConfig = 'cluster_networking_config',
+ ConnectionDiagnostic = 'connection_diagnostic',
+ CrownJewel = 'crown_jewel',
+ Database = 'db',
+ DatabaseObject = 'db_object',
+ DatabaseObjectImportRule = 'db_object_import_rule',
+ DatabaseServer = 'db_server',
+ DatabaseService = 'db_service',
+ Device = 'device',
+ DiscoveryConfig = 'discovery_config',
+ DynamicWindowsDesktop = 'dynamic_windows_desktop',
+ ExternalAuditStorage = 'external_audit_storage',
+ GitServer = 'git_server',
+ // Ignoring duplicate: KindGithub = "github"
+ GithubConnector = 'github',
+ GlobalNotification = 'global_notification',
+ HeadlessAuthentication = 'headless_authentication',
+ Identity = 'identity',
+ IdentityCenterAccount = 'aws_ic_account',
+ IdentityCenterAccountAssignment = 'aws_ic_account_assignment',
+ IdentityCenterPermissionSet = 'aws_ic_permission_set',
+ IdentityCenterPrincipalAssignment = 'aws_ic_principal_assignment',
+ Installer = 'installer',
+ Instance = 'instance',
+ Integration = 'integration',
+ KubeCertificateSigningRequest = 'certificatesigningrequest',
+ KubeClusterRole = 'clusterrole',
+ KubeClusterRoleBinding = 'clusterrolebinding',
+ KubeConfigmap = 'configmap',
+ KubeCronjob = 'cronjob',
+ KubeDaemonSet = 'daemonset',
+ KubeDeployment = 'deployment',
+ KubeIngress = 'ingress',
+ KubeJob = 'job',
+ KubeNamespace = 'namespace',
+ KubeNode = 'kube_node',
+ KubePersistentVolume = 'persistentvolume',
+ KubePersistentVolumeClaim = 'persistentvolumeclaim',
+ KubePod = 'pod',
+ KubeReplicaSet = 'replicaset',
+ KubeRole = 'kube_role',
+ KubeRoleBinding = 'rolebinding',
+ KubeSecret = 'secret',
+ KubeServer = 'kube_server',
+ KubeService = 'service',
+ KubeServiceAccount = 'serviceaccount',
+ KubeStatefulset = 'statefulset',
+ KubeWaitingContainer = 'kube_ephemeral_container',
+ KubernetesCluster = 'kube_cluster',
+ License = 'license',
+ Lock = 'lock',
+ LoginRule = 'login_rule',
+ MFADevice = 'mfa_device',
+ // Ignoring duplicate: KindNamespace = "namespace"
+ NetworkRestrictions = 'network_restrictions',
+ Node = 'node',
+ Notification = 'notification',
+ // Ignoring duplicate: KindOIDC = "oidc"
+ OIDCConnector = 'oidc',
+ OktaAssignment = 'okta_assignment',
+ OktaImportRule = 'okta_import_rule',
+ Plugin = 'plugin',
+ PluginData = 'plugin_data',
+ PluginStaticCredentials = 'plugin_static_credentials',
+ ProvisioningPrincipalState = 'provisioning_principal_state',
+ Proxy = 'proxy',
+ RecoveryCodes = 'recovery_codes',
+ RemoteCluster = 'remote_cluster',
+ ReverseTunnel = 'tunnel',
+ Role = 'role',
+ // Ignoring duplicate: KindSAML = "saml"
+ SAMLConnector = 'saml',
+ SAMLIdPServiceProvider = 'saml_idp_service_provider',
+ SPIFFEFederation = 'spiffe_federation',
+ SecurityReport = 'security_report',
+ SecurityReportCostLimiter = 'security_report_cost_limiter',
+ SecurityReportState = 'security_report_state',
+ Semaphore = 'semaphore',
+ ServerInfo = 'server_info',
+ SessionRecordingConfig = 'session_recording_config',
+ SessionTracker = 'session_tracker',
+ State = 'state',
+ StaticHostUser = 'static_host_user',
+ StaticTokens = 'static_tokens',
+ Token = 'token',
+ TrustedCluster = 'trusted_cluster',
+ TunnelConnection = 'tunnel_connection',
+ UIConfig = 'ui_config',
+ User = 'user',
+ UserGroup = 'user_group',
+ UserLastSeenNotification = 'user_last_seen_notification',
+ UserLoginState = 'user_login_state',
+ UserNotificationState = 'user_notification_state',
+ UserTask = 'user_task',
+ UserToken = 'user_token',
+ UserTokenSecrets = 'user_token_secrets',
+ VnetConfig = 'vnet_config',
+ WatchStatus = 'watch_status',
+ WebSession = 'web_session',
+ WebToken = 'web_token',
+ WindowsDesktop = 'windows_desktop',
+ WindowsDesktopService = 'windows_desktop_service',
+
+ // Resources that have no actual data representation, but serve for checking
+ // access to various features.
+ AccessGraph = 'access_graph',
+ AccessPluginData = 'access_plugin_data',
+ AuthConnector = 'auth_connector',
+ Billing = 'billing',
+ ClusterConfig = 'cluster_config',
+ Connectors = 'connectors',
+ DatabaseCertificate = 'database_certificate',
+ Download = 'download',
+ Event = 'event',
+ GithubRequest = 'github_request',
+ HostCert = 'host_cert',
+ IdentityCenter = 'aws_identity_center',
+ JWT = 'jwt',
+ OIDCRequest = 'oidc_request',
+ SAMLRequest = 'saml_request',
+ SSHSession = 'ssh_session',
+ Session = 'session',
+ UnifiedResource = 'unified_resource',
+ UsageEvent = 'usage_event',
+
+ // For completeness: these kind constants were not included here, as they
+ // refer to resource subkind names that are not used for access control.
+ //
+ // KindAppSession = "app_session"
+ // KindSAMLIdPSession = "saml_idp_session"
+ // KindSnowflakeSession = "snowflake_session"
+}
+
+export type Verb =
+ | '*'
+ | 'create'
+ | 'create_enroll_token'
+ | 'delete'
+ | 'enroll'
+ | 'list'
+ | 'read'
+ | 'readnosecrets'
+ | 'rotate'
+ | 'update'
+ | 'use';
+
/**
* Teleport role options in full format, as returned from Teleport API. Note
* that its fields follow the snake case convention to match the wire format.