diff --git a/api/types/constants.go b/api/types/constants.go
index 4940e67999bf9..f79bf23cadd1e 100644
--- a/api/types/constants.go
+++ b/api/types/constants.go
@@ -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"
diff --git a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.story.tsx b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.story.tsx
index bf8313ceb4122..abd6ed5393f69 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.story.tsx
+++ b/web/packages/teleport/src/Roles/RoleEditor/RoleEditor.story.tsx
@@ -42,7 +42,7 @@ export default {
}
return (
-
+
diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.test.tsx
index 59a7af9928081..68ff3009a9298 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 {
+ AdminRules,
AppAccessSpecSection,
DatabaseAccessSpecSection,
KubernetesAccessSpecSection,
@@ -48,7 +51,12 @@ import {
StandardEditorProps,
WindowsDesktopAccessSpecSection,
} from './StandardEditor';
-import { validateAccessSpec } from './validation';
+import {
+ AccessSpecValidationResult,
+ AdminRuleValidationResult,
+ validateAccessSpec,
+ validateAdminRule,
+} from './validation';
const TestStandardEditor = (props: Partial) => {
const ctx = createTeleportContext();
@@ -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 = ({
+function StatefulSection({
defaultValue,
component: Component,
onChange,
validatorRef,
+ validate,
}: {
defaultValue: S;
component: React.ComponentType>;
onChange(spec: S): void;
validatorRef?(v: Validator): void;
-}) => {
+ validate(arg: S): V;
+}) {
const [model, setModel] = useState(defaultValue);
- const validation = validateAccessSpec(model);
+ const validation = validate(model);
return (
{({ validator }) => {
@@ -196,20 +206,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 +269,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 +411,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 +489,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 +546,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 +584,63 @@ describe('WindowsDesktopAccessSpecSection', () => {
});
});
+describe('AdminRules', () => {
+ const setup = () => {
+ const onChange = jest.fn();
+ let validator: Validator;
+ render(
+
+ 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');
diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx
index bf1567ee235cd..46a53abf472b6 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx
+++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx
@@ -69,6 +69,10 @@ import {
AppAccessSpec,
DatabaseAccessSpec,
WindowsDesktopAccessSpec,
+ RuleModel,
+ resourceKindOptions,
+ verbOptions,
+ newRuleModel,
} from './standardmodel';
import {
validateRoleEditorModel,
@@ -80,6 +84,7 @@ import {
AppSpecValidationResult,
DatabaseSpecValidationResult,
WindowsDesktopSpecValidationResult,
+ AdminRuleValidationResult,
} from './validation';
import { EditorSaveCancelButton } from './Shared';
import { RequiresResetToStandard } from './RequiresResetToStandard';
@@ -188,6 +193,13 @@ export const StandardEditor = ({
});
}
+ function setRules(rules: RuleModel[]) {
+ handleChange({
+ ...standardEditorModel.roleModel,
+ rules,
+ });
+ }
+
return (
{({ validator }) => (
@@ -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,
@@ -306,6 +323,20 @@ export const StandardEditor = ({
+
handleSave(validator)}
@@ -896,6 +927,75 @@ export function WindowsDesktopAccessSpecSection({
);
}
+export function AdminRules({
+ value,
+ isProcessing,
+ validation,
+ onChange,
+}: SectionProps) {
+ function addRule() {
+ onChange?.([...value, newRuleModel()]);
+ }
+ function setRule(rule: RuleModel) {
+ onChange?.(value.map(r => (r.id === rule.id ? rule : r)));
+ }
+ return (
+
+ {value.map((rule, i) => (
+
+ ))}
+
+
+ Add New
+
+
+ );
+}
+
+function AdminRule({
+ value,
+ isProcessing,
+ validation,
+ onChange,
+}: SectionProps) {
+ const { resources, verbs } = value;
+ const theme = useTheme();
+ 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..75fdb18277263 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
@@ -283,8 +336,11 @@ export function roleToRoleEditorModel(
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 {
+ accessSpecs,
+ rules,
+ requiresReset: allowRequiresReset,
+ } = roleConditionsToModel(allow);
return {
metadata: {
@@ -293,6 +349,7 @@ export function roleToRoleEditorModel(
revision: originalRole?.metadata?.revision,
},
accessSpecs,
+ rules,
requiresReset:
revision !== originalRole?.metadata?.revision ||
version !== roleVersion ||
@@ -311,8 +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): {
+function roleConditionsToModel(conditions: RoleConditions): {
accessSpecs: AccessSpec[];
+ rules: RuleModel[];
requiresReset: boolean;
} {
const {
@@ -335,6 +393,9 @@ function roleConditionsToAccessSpecs(conditions: RoleConditions): {
windows_desktop_labels,
windows_desktop_logins,
+
+ rules,
+
...rest
} = conditions;
@@ -352,7 +413,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 +473,14 @@ function roleConditionsToAccessSpecs(conditions: RoleConditions): {
});
}
+ const { model: rulesModel, requiresReset: rulesRequireReset } =
+ rulesToModel(rules);
+
return {
accessSpecs,
- requiresReset: !isEmpty(rest),
+ rules: rulesModel,
+ requiresReset:
+ kubernetesResourcesRequireReset || rulesRequireReset || !isEmpty(rest),
};
}
@@ -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 = [], ...rest } = 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(rest),
+ };
+}
+
+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 = [], ...others } = 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(others) ||
+ 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..158ad619553cb 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(validateAdminRule),
};
}
@@ -166,3 +169,14 @@ const windowsDesktopSpecValidationRules = {
export type WindowsDesktopSpecValidationResult = RuleSetValidationResult<
typeof windowsDesktopSpecValidationRules
>;
+
+export const validateAdminRule = (adminRule: RuleModel) =>
+ runRules(adminRule, adminRuleValidationRules);
+
+const adminRuleValidationRules = {
+ resources: requiredField('At least one resource kind is required'),
+ verbs: requiredField('At least one permission is required'),
+};
+export type AdminRuleValidationResult = RuleSetValidationResult<
+ typeof adminRuleValidationRules
+>;
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.