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 + onChange?.({ ...value, createHostUserMode: m })} + /> + + Database + + Create Database User + onChange({ ...value, createDBUser: c })} + /> + + {/* TODO(bl-nero): a bug in YAML unmarshalling backend breaks the + createDBUserMode field. Fix it and add the field here. */} + + Desktop + + Create Desktop User + onChange({ ...value, createDesktopUser: c })} + /> + + Allow Clipboard Sharing + onChange({ ...value, desktopClipboard: c })} + /> + + Allow Directory Sharing + onChange({ ...value, desktopDirectorySharing: s })} + /> + + ); +} + +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; /**