diff --git a/lib/web/users.go b/lib/web/users.go index cada9a3996c97..8594b501b0ac6 100644 --- a/lib/web/users.go +++ b/lib/web/users.go @@ -104,7 +104,16 @@ func createUser(r *http.Request, m userAPIGetter, createdBy string) (*ui.User, e user.SetRoles(req.Roles) - updateUserTraits(req, user) + // checkAndSetDefaults makes sure either TraitsPreset + // or AllTraits field to be populated. Since empty + // AllTraits is also used to delete all user traits, + // we explicitly check if TraitsPreset is empty so + // to prevent traits deletion. + if req.TraitsPreset == nil { + user.SetTraits(req.AllTraits) + } else { + updateUserTraitsPreset(req, user) + } user.SetCreatedBy(types.CreatedBy{ User: types.UserRef{Name: createdBy}, @@ -119,30 +128,30 @@ func createUser(r *http.Request, m userAPIGetter, createdBy string) (*ui.User, e return ui.NewUser(created) } -// updateUserTraits receives a saveUserRequest and updates the user traits accordingly -// It only updates the traits that have a non-nil value in saveUserRequest -// This allows the partial update of the properties -func updateUserTraits(req *saveUserRequest, user types.User) { - if req.Traits.Logins != nil { - user.SetLogins(*req.Traits.Logins) +// updateUserTraitsPreset receives a saveUserRequest and updates the user traits +// accordingly. It only updates the traits that have a non-nil value in +// saveUserRequest. This allows the partial update of the properties +func updateUserTraitsPreset(req *saveUserRequest, user types.User) { + if req.TraitsPreset.Logins != nil { + user.SetLogins(*req.TraitsPreset.Logins) } - if req.Traits.DatabaseUsers != nil { - user.SetDatabaseUsers(*req.Traits.DatabaseUsers) + if req.TraitsPreset.DatabaseUsers != nil { + user.SetDatabaseUsers(*req.TraitsPreset.DatabaseUsers) } - if req.Traits.DatabaseNames != nil { - user.SetDatabaseNames(*req.Traits.DatabaseNames) + if req.TraitsPreset.DatabaseNames != nil { + user.SetDatabaseNames(*req.TraitsPreset.DatabaseNames) } - if req.Traits.KubeUsers != nil { - user.SetKubeUsers(*req.Traits.KubeUsers) + if req.TraitsPreset.KubeUsers != nil { + user.SetKubeUsers(*req.TraitsPreset.KubeUsers) } - if req.Traits.KubeGroups != nil { - user.SetKubeGroups(*req.Traits.KubeGroups) + if req.TraitsPreset.KubeGroups != nil { + user.SetKubeGroups(*req.TraitsPreset.KubeGroups) } - if req.Traits.WindowsLogins != nil { - user.SetWindowsLogins(*req.Traits.WindowsLogins) + if req.TraitsPreset.WindowsLogins != nil { + user.SetWindowsLogins(*req.TraitsPreset.WindowsLogins) } - if req.Traits.AWSRoleARNs != nil { - user.SetAWSRoleARNs(*req.Traits.AWSRoleARNs) + if req.TraitsPreset.AWSRoleARNs != nil { + user.SetAWSRoleARNs(*req.TraitsPreset.AWSRoleARNs) } } @@ -169,7 +178,16 @@ func updateUser(r *http.Request, m userAPIGetter) (*ui.User, error) { user.SetRoles(req.Roles) - updateUserTraits(req, user) + // checkAndSetDefaults makes sure either TraitsPreset + // or AllTraits field to be populated. Since empty + // AllTraits is also used to delete all user traits, + // we explicitly check if TraitsPreset is empty so + // to prevent traits deletion. + if req.TraitsPreset == nil { + user.SetTraits(req.AllTraits) + } else { + updateUserTraitsPreset(req, user) + } updated, err := m.UpdateUser(r.Context(), user) if err != nil { @@ -287,7 +305,8 @@ type userAPIGetter interface { DeleteUser(ctx context.Context, user string) error } -type userTraits struct { +// traitsPreset are user traits that are pre-defined in Teleport +type traitsPreset struct { Logins *[]string `json:"logins,omitempty"` DatabaseUsers *[]string `json:"databaseUsers,omitempty"` DatabaseNames *[]string `json:"databaseNames,omitempty"` @@ -303,11 +322,21 @@ type userTraits struct { // They are optional and respect the following logic: // - if the value is nil, we ignore it // - if the value is an empty array we remove every element from the trait -// - otherwise, we replace the list for that trait +// - otherwise, we replace the list for that trait. +// Use TraitsPreset to selectively update traits. +// Use AllTraits to fully replace existing traits. type saveUserRequest struct { - Name string `json:"name"` - Roles []string `json:"roles"` - Traits userTraits `json:"traits"` + // Name is username. + Name string `json:"name"` + // Roles is slice of user roles assigned to user. + Roles []string `json:"roles"` + // TraitsPreset holds traits that are pre-defined in Teleport. + // Clients may use TraitsPreset to selectively update user traits. + TraitsPreset *traitsPreset `json:"traits"` + // AllTraits may hold all the user traits, including traits key defined + // in TraitsPreset and/or new trait key values defined by Teleport admin. + // AllTraits should be used to fully replace and update user traits. + AllTraits map[string][]string `json:"allTraits"` } func (r *saveUserRequest) checkAndSetDefaults() error { @@ -317,5 +346,8 @@ func (r *saveUserRequest) checkAndSetDefaults() error { if len(r.Roles) == 0 { return trace.BadParameter("missing roles") } + if len(r.AllTraits) != 0 && r.TraitsPreset != nil { + return trace.BadParameter("either traits or allTraits must be provided") + } return nil } diff --git a/lib/web/users_test.go b/lib/web/users_test.go index 5cd9beb80f985..9d8d729f0544e 100644 --- a/lib/web/users_test.go +++ b/lib/web/users_test.go @@ -34,40 +34,100 @@ import ( ) func TestRequestParameters(t *testing.T) { - r := saveUserRequest{ - Name: "", - Roles: nil, - Traits: userTraits{}, - } - require.True(t, trace.IsBadParameter(r.checkAndSetDefaults())) - - r = saveUserRequest{ - Name: "", - Roles: []string{"testrole"}, - Traits: userTraits{}, + tests := []struct { + name string + username string + role []string + traitsPreset *traitsPreset + allTraits map[string][]string + errAssertion require.ErrorAssertionFunc + }{ + { + name: "empty request", + username: "", + role: nil, + traitsPreset: nil, + allTraits: nil, + errAssertion: func(t require.TestingT, err error, i ...interface{}) { + require.ErrorIs(t, err, trace.BadParameter("missing user name")) + }, + }, + { + name: "empty name", + username: "", + role: []string{"testrole"}, + traitsPreset: nil, + allTraits: nil, + errAssertion: func(t require.TestingT, err error, i ...interface{}) { + require.ErrorIs(t, err, trace.BadParameter("missing user name")) + }, + }, + { + name: "empty role", + username: "testuser", + role: nil, + traitsPreset: nil, + allTraits: nil, + errAssertion: func(t require.TestingT, err error, i ...interface{}) { + require.ErrorIs(t, err, trace.BadParameter("missing roles")) + }, + }, + { + name: "both traitsPreset and allTraits", + username: "testuser", + role: []string{"testrole"}, + traitsPreset: &traitsPreset{Logins: &[]string{"root"}}, + allTraits: map[string][]string{"logins": {"root"}}, + errAssertion: func(t require.TestingT, err error, i ...interface{}) { + require.ErrorIs(t, err, trace.BadParameter("either traits or allTraits must be provided")) + }, + }, + { + name: "user without traits", + username: "testuser", + role: []string{"testrole"}, + traitsPreset: nil, + allTraits: nil, + errAssertion: require.NoError, + }, + { + name: "user with traitsPreset", + username: "testuser", + role: []string{"testrole"}, + traitsPreset: &traitsPreset{Logins: &[]string{"root"}}, + allTraits: map[string][]string{}, + errAssertion: require.NoError, + }, + { + name: "user with allTraits", + username: "testuser", + role: []string{"testrole"}, + traitsPreset: nil, + allTraits: map[string][]string{"logins": {"root"}}, + errAssertion: require.NoError, + }, } - require.True(t, trace.IsBadParameter(r.checkAndSetDefaults())) - r = saveUserRequest{ - Name: "username", - Roles: nil, - Traits: userTraits{}, - } - require.True(t, trace.IsBadParameter(r.checkAndSetDefaults())) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + r := saveUserRequest{ + Name: test.username, + Roles: test.role, + TraitsPreset: test.traitsPreset, + AllTraits: test.allTraits, + } - r = saveUserRequest{ - Name: "username", - Roles: []string{"testrole"}, - Traits: userTraits{}, + err := r.checkAndSetDefaults() + test.errAssertion(t, err) + }) } - require.NoError(t, r.checkAndSetDefaults()) } func TestCRUDs(t *testing.T) { u := saveUserRequest{ - Name: "testname", - Roles: []string{"testrole"}, - Traits: userTraits{}, + Name: "testname", + Roles: []string{"testrole"}, + TraitsPreset: nil, } m := &mockedUserAPIGetter{} @@ -120,7 +180,7 @@ func TestCRUDs(t *testing.T) { require.NoError(t, err) } -func TestUpdateUser_setTraits(t *testing.T) { +func TestUpdateUser_updateUserTraitsPreset(t *testing.T) { defaultRoles := []string{"role1"} defaultLogins := []string{"login1"} tests := []struct { @@ -131,9 +191,9 @@ func TestUpdateUser_setTraits(t *testing.T) { { name: "Logins", updateReq: saveUserRequest{ - Name: "setlogins", - Roles: defaultRoles, - Traits: userTraits{Logins: &[]string{"login1", "login2"}}, + Name: "setlogins", + Roles: defaultRoles, + TraitsPreset: &traitsPreset{Logins: &[]string{"login1", "login2"}}, }, expectedTraits: map[string][]string{ constants.TraitLogins: {"login1", "login2"}, @@ -144,7 +204,7 @@ func TestUpdateUser_setTraits(t *testing.T) { updateReq: saveUserRequest{ Name: "setdb", Roles: defaultRoles, - Traits: userTraits{ + TraitsPreset: &traitsPreset{ Logins: &defaultLogins, DatabaseUsers: &[]string{"dbuser1", "dbuser2"}, DatabaseNames: &[]string{"dbname1", "dbname2"}, @@ -161,7 +221,7 @@ func TestUpdateUser_setTraits(t *testing.T) { updateReq: saveUserRequest{ Name: "setkube", Roles: defaultRoles, - Traits: userTraits{ + TraitsPreset: &traitsPreset{ Logins: &defaultLogins, KubeUsers: &[]string{"kubeuser1", "kubeuser2"}, KubeGroups: &[]string{"kubegroup1", "kubegroup2"}, @@ -178,7 +238,7 @@ func TestUpdateUser_setTraits(t *testing.T) { updateReq: saveUserRequest{ Name: "setwindowslogins", Roles: defaultRoles, - Traits: userTraits{ + TraitsPreset: &traitsPreset{ Logins: &defaultLogins, WindowsLogins: &[]string{"login1", "login2"}, }, @@ -193,7 +253,7 @@ func TestUpdateUser_setTraits(t *testing.T) { updateReq: saveUserRequest{ Name: "setawsrolearns", Roles: defaultRoles, - Traits: userTraits{ + TraitsPreset: &traitsPreset{ Logins: &defaultLogins, AWSRoleARNs: &[]string{"arn1", "arn2"}, }, @@ -206,9 +266,9 @@ func TestUpdateUser_setTraits(t *testing.T) { { name: "Deduplicates", updateReq: saveUserRequest{ - Name: "deduplicates", - Roles: defaultRoles, - Traits: userTraits{Logins: &[]string{"login1", "login2", "login1"}}, + Name: "deduplicates", + Roles: defaultRoles, + TraitsPreset: &traitsPreset{Logins: &[]string{"login1", "login2", "login1"}}, }, expectedTraits: map[string][]string{ constants.TraitLogins: {"login1", "login2"}, @@ -217,9 +277,9 @@ func TestUpdateUser_setTraits(t *testing.T) { { name: "RemovesAll", updateReq: saveUserRequest{ - Name: "removesall", - Roles: defaultRoles, - Traits: userTraits{Logins: &[]string{}}, + Name: "removesall", + Roles: defaultRoles, + TraitsPreset: &traitsPreset{Logins: &[]string{}}, }, expectedTraits: map[string][]string{ constants.TraitLogins: {}, @@ -268,6 +328,47 @@ func TestUpdateUser_setTraits(t *testing.T) { } } +func TestUpdateUser_setTraitsWithAllTraits(t *testing.T) { + defaultRoles := []string{"role1"} + + // create user + user, err := types.NewUser("alice") + require.NoError(t, err) + user.SetRoles(defaultRoles) + + m := &mockedUserAPIGetter{} + m.mockGetUser = func(ctx context.Context, name string, withSecrets bool) (types.User, error) { + return user, nil + } + m.mockUpdateUser = func(ctx context.Context, user types.User) (types.User, error) { + return user, nil + } + + // update user with AllTraits + allTraitsWithValue := saveUserRequest{ + Name: "setlogins", + Roles: defaultRoles, + AllTraits: map[string][]string{"logins": {"root", "admin"}}, + } + _, err = updateUser(newRequest(t, allTraitsWithValue), m) + require.NoError(t, err) + require.Equal(t, allTraitsWithValue.AllTraits, user.GetTraits()) + + // verify other fields dont't change + require.ElementsMatch(t, user.GetRoles(), defaultRoles) + + // update user with empty AllTraits + emptyAllTraits := saveUserRequest{ + Name: "setlogins", + Roles: defaultRoles, + AllTraits: map[string][]string{}, + } + _, err = updateUser(newRequest(t, emptyAllTraits), m) + require.NoError(t, err) + // empty AllTraits field should delete existing traits + require.Equal(t, emptyAllTraits.AllTraits, user.GetTraits()) +} + func TestCRUDErrors(t *testing.T) { m := &mockedUserAPIGetter{} m.mockCreateUser = func(ctx context.Context, user types.User) (types.User, error) { @@ -291,9 +392,9 @@ func TestCRUDErrors(t *testing.T) { } u := saveUserRequest{ - Name: "testname", - Roles: []string{"testrole"}, - Traits: userTraits{Logins: nil}, + Name: "testname", + Roles: []string{"testrole"}, + TraitsPreset: &traitsPreset{Logins: nil}, } // update errors diff --git a/web/packages/teleport/src/Users/UserAddEdit/TraitsEditor.test.tsx b/web/packages/teleport/src/Users/UserAddEdit/TraitsEditor.test.tsx new file mode 100644 index 0000000000000..0a89e6c79f344 --- /dev/null +++ b/web/packages/teleport/src/Users/UserAddEdit/TraitsEditor.test.tsx @@ -0,0 +1,88 @@ +/** + * Teleport + * Copyright (C) 2023 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React from 'react'; +import { fireEvent, render, screen } from 'design/utils/testing'; + +import Validation from 'shared/components/Validation'; + +import { AllUserTraits } from 'teleport/services/user'; + +import { TraitsEditor, emptyTrait } from './TraitsEditor'; + +import { traitsToTraitsOption } from './useDialog'; + +import type { TraitsOption } from './TraitsEditor'; + +test('Available traits are rendered', async () => { + const setConfiguredTraits = jest.fn(); + const userTraits: AllUserTraits = { + logins: ['root', 'ubuntu'], + db_roles: ['dbadmin', 'postgres'], + db_names: ['postgres', 'aurora'], + }; + + render( + + + + ); + + expect(screen.getByText('User Traits')).toBeInTheDocument(); + expect(screen.getAllByTestId('trait-key')).toHaveLength(3); + expect(screen.getAllByTestId('trait-value')).toHaveLength(3); +}); + +test('Add and remove Trait', async () => { + const configuredTraits: TraitsOption[] = []; + const setConfiguredTraits = jest.fn(); + + const { rerender } = render( + + + + ); + expect(screen.queryAllByTestId('trait-key')).toHaveLength(0); + + const addButtonEl = screen.getByRole('button', { name: /Add user trait/i }); + expect(addButtonEl).toBeInTheDocument(); + fireEvent.click(addButtonEl); + + expect(setConfiguredTraits).toHaveBeenLastCalledWith([emptyTrait]); + + const singleTrait = { logins: ['root', 'ubuntu'] }; + rerender( + + + + ); + fireEvent.click(screen.getByTitle('Remove Trait')); + expect(setConfiguredTraits).toHaveBeenCalledWith([]); +}); diff --git a/web/packages/teleport/src/Users/UserAddEdit/TraitsEditor.tsx b/web/packages/teleport/src/Users/UserAddEdit/TraitsEditor.tsx new file mode 100644 index 0000000000000..b4a8bab0c8966 --- /dev/null +++ b/web/packages/teleport/src/Users/UserAddEdit/TraitsEditor.tsx @@ -0,0 +1,259 @@ +/** + * Teleport + * Copyright (C) 2024 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React, { Dispatch, SetStateAction } from 'react'; +import { ButtonBorder, Box, Flex, Text, ButtonIcon } from 'design'; +import { Add, Trash } from 'design/Icon'; +import { FieldSelectCreatable } from 'shared/components/FieldSelect'; +import { Option } from 'shared/components/Select'; +import { requiredField, requiredAll } from 'shared/components/Validation/rules'; +import { Attempt } from 'shared/hooks/useAttemptNext'; + +/** + * traitsPreset is a list of system defined traits in Teleport. + * The list is used to populate traits key option. + */ +const traitsPreset = [ + 'aws_role_arns', + 'azure_identities', + 'db_names', + 'db_roles', + 'db_users', + 'gcp_service_accounts', + 'host_user_gid', + 'host_user_uid', + 'kubernetes_groups', + 'kubernetes_users', + 'logins', + 'windows_logins', +]; + +/** + * TraitsEditor supports add, edit or remove traits functionality. + * @param attempt attempt is Attempt status. + * @param configuredTraits holds traits configured for user in current editor. + * @param setConfiguredTraits sets user traits in current editor. + */ +export function TraitsEditor({ + attempt, + configuredTraits, + setConfiguredTraits, +}: TraitEditorProps) { + function handleInputChange(i: InputOption | InputOptionArray) { + const newTraits = [...configuredTraits]; + if (i.labelField === 'traitValues') { + const traitValue: Option[] = i.option; + if (traitValue) { + if (traitValue[traitValue.length - 1].value.trim() === '') { + return; + } + traitValue[traitValue.length - 1].label = + traitValue[traitValue.length - 1].label.trim(); + traitValue[traitValue.length - 1].value = + traitValue[traitValue.length - 1].value.trim(); + } + newTraits[i.index] = { + ...newTraits[i.index], + [i.labelField]: traitValue ?? [], + }; + setConfiguredTraits(newTraits); + } else { + const traitName: Option = i.option; + traitName.label = traitName.label.trim(); + traitName.value = traitName.value.trim(); + newTraits[i.index] = { + ...newTraits[i.index], + [i.labelField]: traitName, + }; + setConfiguredTraits(newTraits); + } + } + + function addNewTraitPair() { + setConfiguredTraits([...configuredTraits, emptyTrait]); + } + + function removeTraitPair(index: number) { + const newTraits = [...configuredTraits]; + newTraits.splice(index, 1); + setConfiguredTraits(newTraits); + } + + const addLabelText = + configuredTraits.length > 0 ? 'Add another user trait' : 'Add user trait'; + + return ( + + User Traits + + {configuredTraits.map(({ traitKey, traitValues }, index) => { + return ( + + + + ({ + value: r, + label: r, + }))} + placeholder="Type a trait name and press enter" + autoFocus + isSearchable + value={traitKey} + label="Key" + rule={requiredAll( + requiredField('Trait key is required'), + requireNoDuplicateTraits(configuredTraits) + )} + onChange={e => { + handleInputChange({ + option: e as Option, + labelField: 'traitKey', + index: index, + }); + }} + createOptionPosition="last" + isDisabled={attempt.status === 'processing'} + /> + + + props.theme.colors.levels.surface}; + `} + placeholder="Type a trait value and press enter" + defaultValue={traitValues.map(r => ({ + value: r, + label: r, + }))} + label="Value" + isMulti + isSearchable + isClearable={false} + value={traitValues} + rule={requiredField('Trait value cannot be empty')} + onChange={e => { + handleInputChange({ + option: e as Option[], + labelField: 'traitValues', + index: index, + }); + }} + formatCreateLabel={(i: string) => + 'Trait value: ' + `"${i}"` + } + isDisabled={attempt.status === 'processing'} + /> + + removeTraitPair(index)} + css={` + &:disabled { + opacity: 0.65; + pointer-events: none; + } + `} + disabled={attempt.status === 'processing'} + > + + + + + ); + })} + + + + + + {addLabelText} + + + + ); +} + +type InputOption = { + labelField: 'traitKey'; + option: Option; + index: number; +}; + +type InputOptionArray = { + labelField: 'traitValues'; + option: Option[]; + index: number; +}; + +const requireNoDuplicateTraits = + (configuredTraits: TraitsOption[]) => (enteredTrait: Option) => () => { + const traitKey = configuredTraits.map(trait => + trait.traitKey.value.toLowerCase() + ); + let occurance = 0; + traitKey.forEach(key => { + if (key === enteredTrait.value.toLowerCase()) { + occurance++; + } + }); + if (occurance > 1) { + return { valid: false, message: 'Trait key should be unique for a user' }; + } + return { valid: true }; + }; + +export const emptyTrait = { + traitKey: { value: '', label: 'Type a trait name and press enter' }, + traitValues: [], +}; + +export type TraitsOption = { traitKey: Option; traitValues: Option[] }; + +export type TraitEditorProps = { + setConfiguredTraits: Dispatch>; + configuredTraits: TraitsOption[]; + attempt: Attempt; +}; diff --git a/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.story.tsx b/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.story.tsx index 67a316c4e2bb8..e47b534c07cae 100644 --- a/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.story.tsx +++ b/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.story.tsx @@ -16,10 +16,14 @@ * along with this program. If not, see . */ -import React from 'react'; +import React, { useState } from 'react'; + +import { AllUserTraits } from 'teleport/services/user'; import { UserAddEdit } from './UserAddEdit'; +import type { TraitsOption } from './TraitsEditor'; + export default { title: 'Teleport/Users/UserAddEdit', }; @@ -38,7 +42,15 @@ export const Create = () => { }; export const Edit = () => { - return ; + const [configuredTraits, setConfiguredTraits] = useState([]); + return ( + + ); }; export const Processing = () => { @@ -72,4 +84,12 @@ const props = { expires: new Date('2050-12-20T17:28:20.93Z'), username: 'Lester', }, + allTraits: { ['logins']: ['root', 'ubuntu'] } as AllUserTraits, + configuredTraits: [ + { + traitKey: { value: 'logins', label: 'logins' }, + traitValues: [{ value: 'root', label: 'root' }], + }, + ] as TraitsOption[], + setConfiguredTraits: () => null, }; diff --git a/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.tsx b/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.tsx index cefb4c64a3528..b2a1504a8fe7e 100644 --- a/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.tsx +++ b/web/packages/teleport/src/Users/UserAddEdit/UserAddEdit.tsx @@ -17,7 +17,7 @@ */ import React from 'react'; -import { ButtonPrimary, ButtonSecondary, Alert } from 'design'; +import { ButtonPrimary, ButtonSecondary, Alert, Box } from 'design'; import Dialog, { DialogHeader, DialogTitle, @@ -33,6 +33,8 @@ import { requiredField } from 'shared/components/Validation/rules'; import UserTokenLink from './../UserTokenLink'; import useDialog, { Props } from './useDialog'; +import { TraitsEditor } from './TraitsEditor'; + export default function Container(props: Props) { const dialog = useDialog(props); return ; @@ -44,12 +46,14 @@ export function UserAddEdit(props: ReturnType) { onChangeRoles, onClose, fetchRoles, + setConfiguredTraits, attempt, name, selectedRoles, onSave, isNew, token, + configuredTraits, } = props; if (attempt.status === 'success' && isNew) { @@ -69,9 +73,9 @@ export function UserAddEdit(props: ReturnType) { {({ validator }) => ( ({ - maxWidth: '500px', + maxWidth: '700px', width: '100%', - overflow: 'initial', + height: '70%', })} disableEscapeKeyDown={false} onClose={onClose} @@ -80,37 +84,46 @@ export function UserAddEdit(props: ReturnType) { {isNew ? 'Create User' : 'Edit User'} - + {attempt.status === 'failed' && ( )} - onChangeName(e.target.value)} - readonly={isNew ? false : true} - /> - onChangeRoles(values as Option[])} - noOptionsMessage={() => 'No roles found'} - loadOptions={async input => { - const roles = await fetchRoles(input); - return roles.map(r => ({ value: r, label: r })); - }} - elevated={true} - /> + + onChangeName(e.target.value)} + readonly={isNew ? false : true} + /> + onChangeRoles(values as Option[])} + noOptionsMessage={() => 'No roles found'} + loadOptions={async input => { + const roles = await fetchRoles(input); + return roles.map(r => ({ value: r, label: r })); + }} + elevated={true} + /> + + . + */ + +import { traitsToTraitsOption } from './useDialog'; + +describe('Test traitsToTraitsOption', () => { + test.each` + name | trait | expected + ${'trait with values (valid)'} | ${{ t: ['a', 'b'] }} | ${[{ traitKey: { label: 't', value: 't' }, traitValues: [{ label: 'a', value: 'a' }, { label: 'b', value: 'b' }] }]} + ${'empty trait (invalid)'} | ${{ t: [] }} | ${[]} + ${'trait with empty string (invalid)'} | ${{ t: [''] }} | ${[]} + ${'trait with null value (invalid)'} | ${{ t: null }} | ${[]} + ${'trait with null array (invalid)'} | ${{ t: [null] }} | ${[]} + ${'trait with first empty string (invalid)'} | ${{ t: ['', 'a'] }} | ${[{ traitKey: { label: 't', value: 't' }, traitValues: [{ label: '', value: '' }, { label: 'a', value: 'a' }] }]} + ${'trait with last empty element (valid)'} | ${{ t: ['a', ''] }} | ${[{ traitKey: { label: 't', value: 't' }, traitValues: [{ label: 'a', value: 'a' }, { label: '', value: '' }] }]} + `('$name', ({ trait, expected }) => { + const result = traitsToTraitsOption(trait); + expect(result).toEqual(expected); + }); +}); diff --git a/web/packages/teleport/src/Users/UserAddEdit/useDialog.tsx b/web/packages/teleport/src/Users/UserAddEdit/useDialog.tsx index 8c65c90b9178f..acfe3451b6fe0 100644 --- a/web/packages/teleport/src/Users/UserAddEdit/useDialog.tsx +++ b/web/packages/teleport/src/Users/UserAddEdit/useDialog.tsx @@ -20,7 +20,9 @@ import { useState } from 'react'; import { useAttemptNext } from 'shared/hooks'; import { Option } from 'shared/components/Select'; -import { ResetToken, User } from 'teleport/services/user'; +import { ResetToken, User, AllUserTraits } from 'teleport/services/user'; + +import type { TraitsOption } from './TraitsEditor'; export default function useUserDialog(props: Props) { const { attempt, setAttempt } = useAttemptNext(''); @@ -32,6 +34,9 @@ export default function useUserDialog(props: Props) { label: r, })) ); + const [configuredTraits, setConfiguredTraits] = useState(() => + traitsToTraitsOption(props.user.allTraits) + ); function onChangeName(name = '') { setName(name); @@ -42,9 +47,16 @@ export default function useUserDialog(props: Props) { } function onSave() { + const traitsToSave = {}; + for (const traitKV of configuredTraits) { + traitsToSave[traitKV.traitKey.value] = traitKV.traitValues.map( + t => t.value + ); + } const u = { name, roles: selectedRoles.map(r => r.value), + allTraits: traitsToSave, }; const handleError = (err: Error) => @@ -75,11 +87,13 @@ export default function useUserDialog(props: Props) { onChangeName, onChangeRoles, fetchRoles: props.fetchRoles, + setConfiguredTraits, isNew: props.isNew, attempt, name, selectedRoles, token, + configuredTraits, }; } @@ -91,3 +105,25 @@ export type Props = { onCreate(user: User): Promise; onUpdate(user: User): Promise; }; + +export function traitsToTraitsOption(allTraits: AllUserTraits): TraitsOption[] { + const newTrait = []; + for (let trait in allTraits) { + if (!allTraits[trait]) { + continue; + } + if (allTraits[trait].length === 1 && !allTraits[trait][0]) { + continue; + } + if (allTraits[trait].length > 0) { + newTrait.push({ + traitKey: { value: trait, label: trait }, + traitValues: allTraits[trait].map(t => ({ + value: t, + label: t, + })), + }); + } + } + return newTrait; +} diff --git a/web/packages/teleport/src/services/user/types.ts b/web/packages/teleport/src/services/user/types.ts index 9c5a45537d58f..9ef3736dd27f1 100644 --- a/web/packages/teleport/src/services/user/types.ts +++ b/web/packages/teleport/src/services/user/types.ts @@ -125,12 +125,12 @@ export interface User { isLocal?: boolean; // isBot is true if the user is a Bot User. isBot?: boolean; - // traits existed before field "externalTraits" - // and returns only "specific" traits. + // traits are preset traits defined in Teleport, such as + // logins, db_role etc. These traits are defiend in UserTraits interface. traits?: UserTraits; - // externalTraits came after field "traits" - // and contains ALL the traits defined for - // this user. + // allTraits contains both preset traits, as well as externalTraits + // such as those created by external IdP attributes to roles mapping + // or new values as set by Teleport admin. allTraits?: AllUserTraits; } diff --git a/web/packages/teleport/src/services/user/user.ts b/web/packages/teleport/src/services/user/user.ts index c0fa25c888073..488e3506ebd36 100644 --- a/web/packages/teleport/src/services/user/user.ts +++ b/web/packages/teleport/src/services/user/user.ts @@ -58,10 +58,24 @@ const service = { return api.get(cfg.getUsersUrl()).then(makeUsers); }, + /** + * Update user. + * use allTraits to create new or replace entire user traits. + * use traits to selectively add/update user traits. + * @param user + * @returns user + */ updateUser(user: User) { return api.put(cfg.getUsersUrl(), user).then(makeUser); }, + /** + * Create user. + * use allTraits to create new or replace entire user traits. + * use traits to selectively add/update user traits. + * @param user + * @returns user + */ createUser(user: User, webauthnResponse?: WebauthnAssertionResponse) { return api .post(cfg.getUsersUrl(), user, null, webauthnResponse)