diff --git a/web/packages/design/src/index.ts b/web/packages/design/src/index.ts
index f5260ba2ac1c7..89c45fa08a326 100644
--- a/web/packages/design/src/index.ts
+++ b/web/packages/design/src/index.ts
@@ -34,7 +34,7 @@ import CardSuccess, { CardSuccessLogin } from './CardSuccess';
import { Indicator } from './Indicator';
import Input from './Input';
import Label from './Label';
-import LabelInput from './LabelInput';
+import { LabelInput } from './LabelInput';
import LabelState from './LabelState';
import Link from './Link';
import { Mark } from './Mark';
diff --git a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx
index f17dce9ff179f..332283e0a57d4 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx
+++ b/web/packages/teleport/src/Roles/RoleEditor/StandardEditor.tsx
@@ -24,6 +24,8 @@ import {
Flex,
H3,
H4,
+ Input,
+ LabelInput,
Mark,
Text,
} from 'design';
@@ -43,6 +45,10 @@ import {
} from 'shared/components/FieldSelect';
import { SlideTabs } from 'design/SlideTabs';
+import { RadioGroup } from 'design/RadioGroup';
+
+import Select from 'shared/components/Select';
+
import { Role, RoleWithYaml } from 'teleport/services/resources';
import { LabelsInput } from 'teleport/components/LabelsInput';
@@ -70,6 +76,9 @@ import {
resourceKindOptions,
verbOptions,
newRuleModel,
+ OptionsModel,
+ requireMFATypeOptions,
+ createHostUserModeOptions,
} from './standardmodel';
import {
validateRoleEditorModel,
@@ -199,6 +208,13 @@ export const StandardEditor = ({
});
}
+ function setOptions(options: OptionsModel) {
+ handleChange({
+ ...standardEditorModel,
+ options,
+ });
+ }
+
return (
<>
{roleModel.requiresReset && (
@@ -252,7 +268,7 @@ export const StandardEditor = ({
onChange={setCurrentTab}
/>
-
handleChange({ ...roleModel, metadata })}
/>
-
-
+
-
-
+
-
+
+
+
+
handleSave()}
@@ -996,6 +1024,157 @@ function AccessRule({
);
}
+function Options({
+ value,
+ isProcessing,
+ onChange,
+}: SectionProps) {
+ const theme = useTheme();
+ const id = useId();
+ const maxSessionTTLId = `${id}-max-session-ttl`;
+ const clientIdleTimeoutId = `${id}-client-idle-timeout`;
+ const requireMFATypeId = `${id}-require-mfa-type`;
+ const createHostUserModeId = `${id}-create-host-user-mode`;
+ return (
+
+ Global Settings
+
+ Max Session TTL
+ onChange({ ...value, maxSessionTTL: e.target.value })}
+ />
+
+
+ Client Idle Timeout
+
+
+ onChange({ ...value, clientIdleTimeout: e.target.value })
+ }
+ />
+
+ Disconnect When Certificate Expires
+ onChange({ ...value, disconnectExpiredCert: d })}
+ />
+
+ Require Session MFA
+
+ );
+}
+
+const OptionsGridContainer = styled(Box)`
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ align-items: baseline;
+ row-gap: ${props => props.theme.space[3]}px;
+`;
+
+const OptionsHeader = styled(H4)<{ separator?: boolean }>`
+ grid-column: 1/3;
+ border-top: ${props =>
+ props.separator
+ ? `${props.theme.borders[1]} ${props.theme.colors.interactive.tonal.neutral[0]}`
+ : 'none'};
+ padding-top: ${props =>
+ props.separator ? `${props.theme.space[3]}px` : '0'};
+`;
+
+function BoolRadioGroup({
+ name,
+ value,
+ onChange,
+}: {
+ name: string;
+ value: boolean;
+ onChange(b: boolean): void;
+}) {
+ return (
+ onChange(d === 'true')}
+ />
+ );
+}
+
+const OptionLabel = styled(LabelInput)`
+ ${props => props.theme.typography.body2}
+`;
+
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 157eee00c85e3..5116cdc32c410 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.test.ts
+++ b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.test.ts
@@ -17,7 +17,10 @@
*/
import {
+ CreateDBUserMode,
+ CreateHostUserMode,
KubernetesResource,
+ RequireMFAType,
ResourceKind,
Role,
Rule,
@@ -45,6 +48,27 @@ const minimalRoleModel = (): RoleEditorModel => ({
accessSpecs: [],
rules: [],
requiresReset: false,
+ options: {
+ maxSessionTTL: '30h0m0s',
+ clientIdleTimeout: '',
+ disconnectExpiredCert: false,
+ requireMFAType: {
+ value: false,
+ label: 'No',
+ },
+ createHostUserMode: {
+ value: '',
+ label: 'Unspecified',
+ },
+ createDBUser: false,
+ createDBUserMode: {
+ value: '',
+ label: 'Unspecified',
+ },
+ desktopClipboard: true,
+ createDesktopUser: false,
+ desktopDirectorySharing: true,
+ },
});
// These tests make sure that role to model and model to role conversions are
@@ -207,6 +231,47 @@ describe.each<{ name: string; role: Role; model: RoleEditorModel }>([
],
},
},
+
+ {
+ name: 'Options object',
+ role: {
+ ...minimalRole(),
+ spec: {
+ ...minimalRole().spec,
+ options: {
+ ...minimalRole().spec.options,
+ max_session_ttl: '1h15m30s',
+ client_idle_timeout: '2h30m45s',
+ disconnect_expired_cert: true,
+ require_session_mfa: 'hardware_key',
+ create_host_user_mode: 'keep',
+ create_db_user: true,
+ create_db_user_mode: 'best_effort_drop',
+ desktop_clipboard: false,
+ create_desktop_user: true,
+ desktop_directory_sharing: false,
+ },
+ },
+ },
+ model: {
+ ...minimalRoleModel(),
+ options: {
+ maxSessionTTL: '1h15m30s',
+ clientIdleTimeout: '2h30m45s',
+ disconnectExpiredCert: true,
+ requireMFAType: { value: 'hardware_key', label: 'Hardware Key' },
+ createHostUserMode: { value: 'keep', label: 'Keep' },
+ createDBUser: true,
+ createDBUserMode: {
+ value: 'best_effort_drop',
+ label: 'Drop (best effort)',
+ },
+ desktopClipboard: false,
+ createDesktopUser: true,
+ desktopDirectorySharing: false,
+ },
+ },
+ },
])('$name', ({ role, model }) => {
it('is converted to a model', () => {
expect(roleToRoleEditorModel(role)).toEqual(model);
@@ -456,6 +521,69 @@ describe('roleToRoleEditorModel', () => {
},
} as Role,
},
+
+ {
+ name: 'unsupported value in spec.options.require_session_mfa',
+ role: {
+ ...minRole,
+ spec: {
+ ...minRole.spec,
+ options: {
+ ...minRole.spec.options,
+ require_session_mfa: 'bogus' as RequireMFAType,
+ },
+ },
+ },
+ model: {
+ ...roleModelWithReset,
+ options: {
+ ...roleModelWithReset.options,
+ requireMFAType: { value: false, label: 'No' },
+ },
+ },
+ },
+
+ {
+ name: 'unsupported value in spec.options.create_host_user_mode',
+ role: {
+ ...minRole,
+ spec: {
+ ...minRole.spec,
+ options: {
+ ...minRole.spec.options,
+ create_host_user_mode: 'bogus' as CreateHostUserMode,
+ },
+ },
+ },
+ model: {
+ ...roleModelWithReset,
+ options: {
+ ...roleModelWithReset.options,
+ createHostUserMode: { value: '', label: 'Unspecified' },
+ },
+ },
+ },
+
+ {
+ name: 'unsupported value in spec.options.create_db_user_mode',
+ role: {
+ ...minRole,
+ spec: {
+ ...minRole.spec,
+ options: {
+ ...minRole.spec.options,
+ create_db_user_mode: 'bogus' as CreateDBUserMode,
+ },
+ },
+ },
+ model: {
+ ...roleModelWithReset,
+ options: {
+ ...roleModelWithReset.options,
+ createDBUserMode: { value: '', label: 'Unspecified' },
+ },
+ },
+ },
])(
'requires reset because of $name',
({ role, model = roleModelWithReset }) => {
diff --git a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts
index 6826957c127c2..e55288056a80b 100644
--- a/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts
+++ b/web/packages/teleport/src/Roles/RoleEditor/standardmodel.ts
@@ -27,9 +27,13 @@ import {
} from 'teleport/services/resources';
import { Label as UILabel } from 'teleport/components/LabelsInput/LabelsInput';
import {
+ CreateDBUserMode,
+ CreateHostUserMode,
KubernetesResourceKind,
KubernetesVerb,
+ RequireMFAType,
ResourceKind,
+ RoleOptions,
Rule,
Verb,
} from 'teleport/services/resources/types';
@@ -53,6 +57,7 @@ export type RoleEditorModel = {
metadata: MetadataModel;
accessSpecs: AccessSpec[];
rules: RuleModel[];
+ options: OptionsModel;
/**
* Indicates whether the current resource, as described by YAML, is
* accurately represented by this editor model. If it's not, the user needs
@@ -251,6 +256,50 @@ export type RuleModel = {
verbs: readonly VerbOption[];
};
+export type OptionsModel = {
+ maxSessionTTL: string;
+ clientIdleTimeout: string;
+ disconnectExpiredCert: boolean;
+ requireMFAType: RequireMFATypeOption;
+ createHostUserMode: CreateHostUserModeOption;
+ createDBUser: boolean;
+ createDBUserMode: CreateDBUserModeOption;
+ desktopClipboard: boolean;
+ createDesktopUser: boolean;
+ desktopDirectorySharing: boolean;
+};
+
+type RequireMFATypeOption = Option;
+export const requireMFATypeOptions: RequireMFATypeOption[] = [
+ { value: false, label: 'No' },
+ { value: true, label: 'Yes' },
+ { value: 'hardware_key', label: 'Hardware Key' },
+ { value: 'hardware_key_touch', label: 'Hardware Key (touch)' },
+ {
+ value: 'hardware_key_touch_and_pin',
+ label: 'Hardware Key (touch and PIN)',
+ },
+];
+const requireMFATypeOptionsMap = optionsToMap(requireMFATypeOptions);
+
+type CreateHostUserModeOption = Option;
+export const createHostUserModeOptions: CreateHostUserModeOption[] = [
+ { value: '', label: 'Unspecified' },
+ { value: 'off', label: 'Off' },
+ { value: 'keep', label: 'Keep' },
+ { value: 'insecure-drop', label: 'Drop (insecure)' },
+];
+const createHostUserModeOptionsMap = optionsToMap(createHostUserModeOptions);
+
+type CreateDBUserModeOption = Option;
+export const createDBUserModeOptions: CreateDBUserModeOption[] = [
+ { value: '', label: 'Unspecified' },
+ { value: 'off', label: 'Off' },
+ { value: 'keep', label: 'Keep' },
+ { value: 'best_effort_drop', label: 'Drop (best effort)' },
+];
+const createDBUserModeOptionsMap = optionsToMap(createDBUserModeOptions);
+
const roleVersion = 'v7';
/**
@@ -341,6 +390,8 @@ export function roleToRoleEditorModel(
rules,
requiresReset: allowRequiresReset,
} = roleConditionsToModel(allow);
+ const { model: optionsModel, requiresReset: optionsRequireReset } =
+ optionsToModel(options);
return {
metadata: {
@@ -350,6 +401,7 @@ export function roleToRoleEditorModel(
},
accessSpecs,
rules,
+ options: optionsModel,
requiresReset:
revision !== originalRole?.metadata?.revision ||
version !== roleVersion ||
@@ -357,10 +409,10 @@ export function roleToRoleEditorModel(
isEmpty(unsupported) &&
isEmpty(unsupportedMetadata) &&
isEmpty(unsupportedSpecs) &&
- isEmpty(deny) &&
- equalsDeep(options, defaultOptions())
+ isEmpty(deny)
) ||
- allowRequiresReset,
+ allowRequiresReset ||
+ optionsRequireReset,
};
}
@@ -581,6 +633,80 @@ function ruleToModel(rule: Rule): { model: RuleModel; requiresReset: boolean } {
};
}
+function optionsToModel(options: RoleOptions): {
+ model: OptionsModel;
+ requiresReset: boolean;
+} {
+ const {
+ // Customizable options.
+ max_session_ttl,
+ client_idle_timeout = '',
+ disconnect_expired_cert = false,
+ require_session_mfa = false,
+ create_host_user_mode = '',
+ create_db_user,
+ create_db_user_mode = '',
+ desktop_clipboard,
+ create_desktop_user,
+ desktop_directory_sharing,
+
+ // These options must keep their default values, as we don't support them
+ // in the standard editor.
+ cert_format,
+ enhanced_recording,
+ forward_agent,
+ idp,
+ pin_source_ip,
+ port_forwarding,
+ record_session,
+ ssh_file_copy,
+
+ ...unsupported
+ } = options;
+
+ const requireMFATypeOption =
+ requireMFATypeOptionsMap.get(require_session_mfa);
+ const createHostUserModeOption = createHostUserModeOptionsMap.get(
+ create_host_user_mode
+ );
+ const createDBUserModeOption =
+ createDBUserModeOptionsMap.get(create_db_user_mode);
+
+ const defaultOpts = defaultOptions();
+
+ return {
+ model: {
+ maxSessionTTL: max_session_ttl,
+ clientIdleTimeout: client_idle_timeout,
+ disconnectExpiredCert: disconnect_expired_cert,
+ requireMFAType:
+ requireMFATypeOption ?? requireMFATypeOptionsMap.get(false),
+ createHostUserMode:
+ createHostUserModeOption ?? createHostUserModeOptionsMap.get(''),
+ createDBUser: create_db_user,
+ createDBUserMode:
+ createDBUserModeOption ?? createDBUserModeOptionsMap.get(''),
+ desktopClipboard: desktop_clipboard,
+ createDesktopUser: create_desktop_user,
+ desktopDirectorySharing: desktop_directory_sharing,
+ },
+
+ requiresReset:
+ cert_format !== defaultOpts.cert_format ||
+ !equalsDeep(enhanced_recording, defaultOpts.enhanced_recording) ||
+ forward_agent !== defaultOpts.forward_agent ||
+ !equalsDeep(idp, defaultOpts.idp) ||
+ pin_source_ip !== defaultOpts.pin_source_ip ||
+ port_forwarding !== defaultOpts.port_forwarding ||
+ !equalsDeep(record_session, defaultOpts.record_session) ||
+ ssh_file_copy !== defaultOpts.ssh_file_copy ||
+ requireMFATypeOption === undefined ||
+ createHostUserModeOption === undefined ||
+ createDBUserModeOption === undefined ||
+ !isEmpty(unsupported),
+ };
+}
+
function isEmpty(obj: object) {
return Object.keys(obj).length === 0;
}
@@ -603,7 +729,7 @@ export function roleEditorModelToRole(roleModel: RoleEditorModel): Role {
spec: {
allow: {},
deny: {},
- options: defaultOptions(),
+ options: optionsModelToRoleOptions(roleModel.options),
},
version: roleVersion,
};
@@ -683,6 +809,27 @@ export function labelsModelToLabels(uiLabels: UILabel[]): Labels {
return labels;
}
+function optionsModelToRoleOptions(model: OptionsModel): RoleOptions {
+ return {
+ ...defaultOptions(),
+
+ // Note: technically, coercing the optional fields to undefined is not
+ // necessary, but it's easier to test it this way, since we achieve
+ // symmetry between what goes into the model and what goes out of it, even
+ // if some fields are optional.
+ max_session_ttl: model.maxSessionTTL,
+ client_idle_timeout: model.clientIdleTimeout || undefined,
+ disconnect_expired_cert: model.disconnectExpiredCert || undefined,
+ require_session_mfa: model.requireMFAType.value || undefined,
+ create_host_user_mode: model.createHostUserMode.value || undefined,
+ create_db_user: model.createDBUser,
+ create_db_user_mode: model.createDBUserMode.value || undefined,
+ desktop_clipboard: model.desktopClipboard,
+ create_desktop_user: model.createDesktopUser,
+ desktop_directory_sharing: model.desktopDirectorySharing,
+ };
+}
+
function optionsToStrings(opts: readonly Option[]): T[] {
return opts.map(opt => opt.value);
}
diff --git a/web/packages/teleport/src/services/resources/types.ts b/web/packages/teleport/src/services/resources/types.ts
index c5a17f313e872..f99a5f45cf1fb 100644
--- a/web/packages/teleport/src/services/resources/types.ts
+++ b/web/packages/teleport/src/services/resources/types.ts
@@ -357,8 +357,24 @@ export type RoleOptions = {
desktop: boolean;
};
ssh_file_copy: boolean;
+ client_idle_timeout?: string;
+ disconnect_expired_cert?: boolean;
+ require_session_mfa?: RequireMFAType;
+ create_host_user_mode?: CreateHostUserMode;
+ create_db_user_mode?: CreateDBUserMode;
};
+export type RequireMFAType =
+ | boolean
+ | 'hardware_key'
+ | 'hardware_key_touch'
+ | 'hardware_key_pin'
+ | 'hardware_key_touch_and_pin';
+
+export type CreateHostUserMode = '' | 'off' | 'keep' | 'insecure-drop';
+
+export type CreateDBUserMode = '' | 'off' | 'keep' | 'best_effort_drop';
+
export type RoleWithYaml = {
object: Role;
/**