diff --git a/api/client/webclient/webconfig.go b/api/client/webclient/webconfig.go index c7ac4ffa1fce5..90fa9436951c8 100644 --- a/api/client/webclient/webconfig.go +++ b/api/client/webclient/webconfig.go @@ -188,6 +188,8 @@ type WebConfigAuthSettings struct { AllowPasswordless bool `json:"allowPasswordless,omitempty"` // AuthType is the authentication type. AuthType string `json:"authType"` + // DefaultConnectorName is the name of the default connector in the auth preferences. This will be empty if the default is "local". + DefaultConnectorName string `json:"defaultConnectorName"` // PreferredLocalMFA is a server-side hint for clients to pick an MFA method // when various options are available. // It is empty if there is nothing to suggest. diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index d28b29f109aa3..95e776bdc9c21 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -975,6 +975,9 @@ func (h *Handler) bindDefaultEndpoints() { h.PUT("/webapi/github/:name", h.WithAuth(h.updateGithubConnectorHandle)) h.DELETE("/webapi/github/:name", h.WithAuth(h.deleteGithubConnector)) + // Sets the default connector in the auth preference. + h.PUT("/webapi/authconnector/default", h.WithAuth(h.setDefaultConnectorHandle)) + h.GET("/webapi/trustedcluster", h.WithAuth(h.getTrustedClustersHandle)) h.POST("/webapi/trustedcluster", h.WithAuth(h.upsertTrustedClusterHandle)) h.PUT("/webapi/trustedcluster/:name", h.WithAuth(h.upsertTrustedClusterHandle)) @@ -1792,21 +1795,25 @@ func (h *Handler) getWebConfig(w http.ResponseWriter, r *http.Request, p httprou } else { authType := cap.GetType() var localConnectorName string + var defaultConnectorName string if authType == constants.Local { localConnectorName = cap.GetConnectorName() + } else { + defaultConnectorName = cap.GetConnectorName() } authSettings = webclient.WebConfigAuthSettings{ - Providers: authProviders, - SecondFactor: types.LegacySecondFactorFromSecondFactors(cap.GetSecondFactors()), - LocalAuthEnabled: cap.GetAllowLocalAuth(), - AllowPasswordless: cap.GetAllowPasswordless(), - AuthType: authType, - PreferredLocalMFA: cap.GetPreferredLocalMFA(), - LocalConnectorName: localConnectorName, - PrivateKeyPolicy: cap.GetPrivateKeyPolicy(), - MOTD: cap.GetMessageOfTheDay(), + Providers: authProviders, + SecondFactor: types.LegacySecondFactorFromSecondFactors(cap.GetSecondFactors()), + LocalAuthEnabled: cap.GetAllowLocalAuth(), + AllowPasswordless: cap.GetAllowPasswordless(), + AuthType: authType, + DefaultConnectorName: defaultConnectorName, + PreferredLocalMFA: cap.GetPreferredLocalMFA(), + LocalConnectorName: localConnectorName, + PrivateKeyPolicy: cap.GetPrivateKeyPolicy(), + MOTD: cap.GetMessageOfTheDay(), } } @@ -3667,6 +3674,32 @@ func (h *Handler) siteNodeConnect( return nil, nil } +func (h *Handler) setDefaultConnectorHandle(w http.ResponseWriter, r *http.Request, params httprouter.Params, ctx *SessionContext) (interface{}, error) { + var req ui.SetDefaultAuthConnectorRequest + if err := httplib.ReadJSON(r, &req); err != nil { + return nil, trace.Wrap(err) + } + + clt, err := ctx.GetClient() + if err != nil { + return nil, trace.Wrap(err) + } + authPref, err := clt.GetAuthPreference(r.Context()) + if err != nil { + return nil, trace.Wrap(err, "failed to get auth preference") + } + + authPref.SetConnectorName(req.Name) + authPref.SetType(req.Type) + + _, err = clt.UpsertAuthPreference(r.Context(), authPref) + if err != nil { + return nil, trace.Wrap(err) + } + + return OK(), nil +} + type podConnectParams struct { // Term is the initial PTY size. Term session.TerminalParams `json:"term"` diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go index c6b204ce129fe..05b208ac4e752 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -10652,10 +10652,10 @@ func TestGithubConnector(t *testing.T) { resp, err = pack.clt.Get(ctx, pack.clt.Endpoint("webapi", "github"), nil) assert.NoError(t, err, "unexpected error listing github connectors") - var item []webui.ResourceItem - require.NoError(t, json.Unmarshal(resp.Bytes(), &item), "invalid resource item received") + authConnectorsResp := webui.ListAuthConnectorsResponse{} + require.NoError(t, json.Unmarshal(resp.Bytes(), &authConnectorsResp), "invalid response received") - assert.Empty(t, item) + assert.Empty(t, authConnectorsResp.Connectors) assert.Equal(t, http.StatusOK, resp.Code(), "unexpected status code getting connectors") } diff --git a/lib/web/resources.go b/lib/web/resources.go index f5d00f5ae4fcd..f6f3807dac72f 100644 --- a/lib/web/resources.go +++ b/lib/web/resources.go @@ -29,10 +29,12 @@ import ( kyaml "k8s.io/apimachinery/pkg/util/yaml" "github.com/gravitational/teleport/api/client/proto" + "github.com/gravitational/teleport/api/constants" kubeproto "github.com/gravitational/teleport/api/gen/proto/go/teleport/kube/v1" "github.com/gravitational/teleport/api/mfa" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/auth/authclient" "github.com/gravitational/teleport/lib/client" "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/httplib" @@ -212,7 +214,66 @@ func (h *Handler) getGithubConnectorsHandle(w http.ResponseWriter, r *http.Reque return nil, trace.Wrap(err) } - return getGithubConnectors(r.Context(), clt) + connectors, err := getGithubConnectors(r.Context(), clt) + if err != nil { + return nil, trace.Wrap(err) + } + + defaultConnectorName, defaultConnectorType, err := ProcessDefaultConnector(r.Context(), clt, connectors) + if err != nil { + return nil, trace.Wrap(err) + } + + return &ui.ListAuthConnectorsResponse{ + DefaultConnectorName: defaultConnectorName, + DefaultConnectorType: defaultConnectorType, + Connectors: connectors, + }, nil +} + +// ProcessDefaultConnector returns the default connector type and validates that the provided connectors list contains the default connector that is set in the auth preference. +// If it isn't, it will return a fallback connector which should be used as the default, as well as update the actual auth preference to refect the change. +func ProcessDefaultConnector(ctx context.Context, clt authclient.ClientI, connectors []ui.ResourceItem) (connectorName string, connectorType string, err error) { + authPref, err := clt.GetAuthPreference(ctx) + if err != nil { + return "", "", trace.Wrap(err, "failed to get auth preference") + } + + defaultConnectorName := authPref.GetConnectorName() + defaultConnectorType := authPref.GetType() + + if len(connectors) == 0 || defaultConnectorType == constants.Local { + // If there are no connectors or the default is already local, default to 'local' as the default connector. + defaultConnectorType = constants.Local + defaultConnectorName = "" + } else { + // Ensure that the default connector set in the auth preference exists in the list. + found := false + for _, c := range connectors { + if c.Name == defaultConnectorName && c.Kind == defaultConnectorType { + found = true + break + } + } + // If the default connector set in the auth preference doesn't exist, use the last connector in the list as the default. + if !found { + defaultConnectorName = connectors[len(connectors)-1].Name + defaultConnectorType = connectors[len(connectors)-1].Kind + } + } + + // If the default connector we are returning here is different from the initial, also update the actual auth preference so that it's in sync. + if defaultConnectorName != authPref.GetConnectorName() || defaultConnectorType != authPref.GetType() { + authPref.SetConnectorName(defaultConnectorName) + authPref.SetType(defaultConnectorType) + + _, err = clt.UpsertAuthPreference(ctx, authPref) + if err != nil { + return "", "", trace.Wrap(err, "failed to set fallback auth preference") + } + } + + return defaultConnectorName, defaultConnectorType, nil } func getGithubConnectors(ctx context.Context, clt resourcesAPIGetter) ([]ui.ResourceItem, error) { @@ -235,6 +296,26 @@ func (h *Handler) deleteGithubConnector(w http.ResponseWriter, r *http.Request, return nil, trace.Wrap(err) } + authPref, err := clt.GetAuthPreference(r.Context()) + if err != nil { + return nil, trace.Wrap(err, "failed to get auth preference") + } + + defaultConnectorName := authPref.GetConnectorName() + defaultConnectorType := authPref.GetType() + // If the connector being deleted is the default, have the auth preference fallback to another connector. + if defaultConnectorType == constants.Github && defaultConnectorName == connectorName { + connectors, err := getGithubConnectors(r.Context(), clt) + if err != nil { + return nil, trace.Wrap(err) + } + + _, _, err = ProcessDefaultConnector(r.Context(), clt, connectors) + if err != nil { + return nil, trace.Wrap(err) + } + } + return OK(), nil } diff --git a/lib/web/resources_test.go b/lib/web/resources_test.go index 3a593cc0df1d7..4876b7d8a5fd2 100644 --- a/lib/web/resources_test.go +++ b/lib/web/resources_test.go @@ -36,6 +36,7 @@ import ( "k8s.io/apimachinery/pkg/util/yaml" "github.com/gravitational/teleport/api/client/proto" + "github.com/gravitational/teleport/api/constants" kubeproto "github.com/gravitational/teleport/api/gen/proto/go/teleport/kube/v1" "github.com/gravitational/teleport/api/types" "github.com/gravitational/teleport/lib/defaults" @@ -417,30 +418,125 @@ func TestRoleCRUD(t *testing.T) { } } -func TestGetGithubConnectors(t *testing.T) { +func TestGithubConnectorsCRUD(t *testing.T) { ctx := context.Background() - m := &mockedResourceAPIGetter{} + env := newWebPack(t, 1) + proxy := env.proxies[0] - m.mockGetGithubConnectors = func(ctx context.Context, withSecrets bool) ([]types.GithubConnector, error) { - connector, err := types.NewGithubConnector("test", types.GithubConnectorSpecV3{ - TeamsToLogins: []types.TeamMapping{ - { - Organization: "octocats", - Team: "dummy", - Logins: []string{"dummy"}, - }, - }, - }) - require.NoError(t, err) + pack := proxy.authPack(t, "test-user@example.com", nil) - return []types.GithubConnector{connector}, nil + tests := []struct { + name string + connectors []types.GithubConnector + setDefaultReq *ui.SetDefaultAuthConnectorRequest + wantConnectorName string + wantConnectorType string + }{ + { + name: "no connectors defaults to local auth", + connectors: []types.GithubConnector{}, + wantConnectorName: "", + wantConnectorType: constants.Local, + }, + { + name: "default connector exists in list", + connectors: []types.GithubConnector{ + makeGithubConnector(t, "github-1"), + }, + setDefaultReq: &ui.SetDefaultAuthConnectorRequest{ + Name: "github-1", + Type: constants.Github, + }, + wantConnectorName: "github-1", + wantConnectorType: constants.Github, + }, + { + name: "default connector missing defaults to last in list", + connectors: []types.GithubConnector{ + makeGithubConnector(t, "github-1"), + makeGithubConnector(t, "github-2"), + }, + setDefaultReq: &ui.SetDefaultAuthConnectorRequest{ + Name: "missing", + Type: constants.Github, + }, + wantConnectorName: "github-2", + wantConnectorType: constants.Github, + }, + { + name: "local auth type always defaults to local", + connectors: []types.GithubConnector{ + makeGithubConnector(t, "github-1"), + }, + setDefaultReq: &ui.SetDefaultAuthConnectorRequest{ + Name: "local", + Type: constants.Local, + }, + wantConnectorName: "", + wantConnectorType: constants.Local, + }, + { + name: "missing default with no connectors defaults to local", + connectors: []types.GithubConnector{}, + setDefaultReq: &ui.SetDefaultAuthConnectorRequest{ + Name: "missing", + Type: constants.Github, + }, + wantConnectorName: "", + wantConnectorType: constants.Local, + }, } - // Test response is converted to ui objects. - connectors, err := getGithubConnectors(ctx, m) - require.NoError(t, err) - require.Len(t, connectors, 1) - require.Contains(t, connectors[0].Content, "name: test") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup initial connectors + for _, conn := range tt.connectors { + raw, err := services.MarshalGithubConnector(conn) + require.NoError(t, err) + resp, err := pack.clt.PostJSON(ctx, pack.clt.Endpoint("webapi", "github"), ui.ResourceItem{ + Kind: types.KindGithubConnector, + Name: conn.GetName(), + Content: string(raw), + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + } + + // Set default connector if specified + if tt.setDefaultReq != nil { + resp, err := pack.clt.PutJSON(ctx, pack.clt.Endpoint("webapi", "authconnector", "default"), tt.setDefaultReq) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + } + + // Get connectors and verify response + resp, err := pack.clt.Get(ctx, pack.clt.Endpoint("webapi", "github"), url.Values{}) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.Code()) + + var connResponse ui.ListAuthConnectorsResponse + err = json.Unmarshal(resp.Bytes(), &connResponse) + require.NoError(t, err) + + // Verify connector name and type + assert.Equal(t, tt.wantConnectorName, connResponse.DefaultConnectorName) + assert.Equal(t, tt.wantConnectorType, connResponse.DefaultConnectorType) + + // Verify connectors list + require.Equal(t, len(tt.connectors), len(connResponse.Connectors)) + for i, conn := range tt.connectors { + expectedItem, err := ui.NewResourceItem(conn) + require.NoError(t, err) + require.Equal(t, expectedItem.Name, connResponse.Connectors[i].Name) + } + + // Cleanup connectors + for _, conn := range tt.connectors { + _, err := pack.clt.Delete(ctx, pack.clt.Endpoint("webapi", "github", conn.GetName())) + require.NoError(t, err) + } + }) + } } func TestGetTrustedClusters(t *testing.T) { @@ -622,6 +718,8 @@ type mockedResourceAPIGetter struct { mockGetTrustedClusters func(ctx context.Context) ([]types.TrustedCluster, error) mockDeleteTrustedCluster func(ctx context.Context, name string) error mockListResources func(ctx context.Context, req proto.ListResourcesRequest) (*types.ListResourcesResponse, error) + mockGetAuthPreference func(ctx context.Context) (types.AuthPreference, error) + mockUpsertAuthPreference func(ctx context.Context, pref types.AuthPreference) (types.AuthPreference, error) } func (m *mockedResourceAPIGetter) GetRole(ctx context.Context, name string) (types.Role, error) { @@ -717,6 +815,21 @@ func (m *mockedResourceAPIGetter) ListResources(ctx context.Context, req proto.L return nil, trace.NotImplemented("mockListResources not implemented") } +// Add new mock methods +func (m *mockedResourceAPIGetter) GetAuthPreference(ctx context.Context) (types.AuthPreference, error) { + if m.mockGetAuthPreference != nil { + return m.mockGetAuthPreference(ctx) + } + return nil, trace.NotImplemented("mockGetAuthPreference not implemented") +} + +func (m *mockedResourceAPIGetter) UpsertAuthPreference(ctx context.Context, pref types.AuthPreference) (types.AuthPreference, error) { + if m.mockUpsertAuthPreference != nil { + return m.mockUpsertAuthPreference(ctx, pref) + } + return nil, trace.NotImplemented("mockUpsertAuthPreference not implemented") +} + func Test_newKubeListRequest(t *testing.T) { type args struct { query string @@ -793,3 +906,17 @@ func Test_newKubeListRequest(t *testing.T) { }) } } + +func makeGithubConnector(t *testing.T, name string) types.GithubConnector { + connector, err := types.NewGithubConnector(name, types.GithubConnectorSpecV3{ + TeamsToRoles: []types.TeamRolesMapping{ + { + Organization: "octocats", + Team: "dummy", + Roles: []string{"dummy"}, + }, + }, + }) + require.NoError(t, err) + return connector +} diff --git a/lib/web/ui/auth_connectors.go b/lib/web/ui/auth_connectors.go new file mode 100644 index 0000000000000..b28aa2f2144d2 --- /dev/null +++ b/lib/web/ui/auth_connectors.go @@ -0,0 +1,69 @@ +/* + * 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 . + */ + +package ui + +import ( + "slices" + + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/api/constants" +) + +// ListAuthConnectorsResponse describes a response to an auth connectors listing request +type ListAuthConnectorsResponse struct { + // DefaultConnectorName is the name of the default auth connector in this cluster's auth preference. + DefaultConnectorName string `json:"defaultConnectorName,omitempty"` + // DefaultConnectorName is the name of the default auth connector in this cluster's auth preference. + DefaultConnectorType string `json:"defaultConnectorType,omitempty"` + // Connectors is the list of auth connectors. + Connectors []ResourceItem `json:"connectors"` +} + +// SetDefaultAuthConnectorRequest describes a request to set a default auth connector. +type SetDefaultAuthConnectorRequest struct { + // Name is the name of the auth connector to set as default. + Name string `json:"name"` + // Type is the type of the auth connector to set as default. + Type string `json:"type"` +} + +// ValidConnectorTypes defines the allowed auth connector types +var ValidConnectorTypes = []string{ + constants.SAML, + constants.OIDC, + constants.Github, + constants.LocalConnector, +} + +// CheckAndSetDefaults checks if the provided values are valid. +func (r *SetDefaultAuthConnectorRequest) CheckAndSetDefaults() error { + if r.Name == "" && r.Type != "local" { + return trace.BadParameter("missing connector name") + } + if r.Type == "" { + return trace.BadParameter("missing connector type") + } + + if !slices.Contains(ValidConnectorTypes, r.Type) { + return trace.BadParameter("unsupported connector type: %q", r.Type) + } + + return nil +} diff --git a/web/packages/teleport/src/AuthConnectors/AuthConnectorTile.test.tsx b/web/packages/teleport/src/AuthConnectors/AuthConnectorTile.test.tsx new file mode 100644 index 0000000000000..656c1aac4629e --- /dev/null +++ b/web/packages/teleport/src/AuthConnectors/AuthConnectorTile.test.tsx @@ -0,0 +1,108 @@ +/** + * 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 { screen } from '@testing-library/react'; + +import { fireEvent, render } from 'design/utils/testing'; +import { AuthType } from 'shared/services'; + +import { AuthConnectorTile } from './AuthConnectorTile'; +import getSsoIcon from './ssoIcons/getSsoIcon'; + +test('default, real connector, renders properly', () => { + render(); + + expect(screen.getByText('Okta')).toBeInTheDocument(); + expect(screen.queryByText('Default')).not.toBeInTheDocument(); + + const optionsButton = screen.getByTestId('button'); + fireEvent.click(optionsButton); + + expect(screen.getByText('Set as default')).toBeInTheDocument(); + expect(screen.getByText('Edit')).toBeInTheDocument(); + expect(screen.getByText('Delete')).toBeInTheDocument(); +}); + +test('non-default, real connector, renders properly', () => { + render(); + + expect(screen.getByText('Okta')).toBeInTheDocument(); + expect(screen.getByText('Default')).toBeInTheDocument(); + + const optionsButton = screen.getByTestId('button'); + fireEvent.click(optionsButton); + + expect(screen.queryByText('Set as default')).not.toBeInTheDocument(); + expect(screen.getByText('Edit')).toBeInTheDocument(); + expect(screen.getByText('Delete')).toBeInTheDocument(); +}); + +// "local" connector for has no edit/delete functionality, only set as default +test('non-default, real connector, with no edit/delete functionality renders properly', () => { + render(); + + expect(screen.getByText('Okta')).toBeInTheDocument(); + expect(screen.queryByText('Default')).not.toBeInTheDocument(); + + const optionsButton = screen.getByTestId('button'); + fireEvent.click(optionsButton); + + expect(screen.getByText('Set as default')).toBeInTheDocument(); + expect(screen.queryByText('Edit')).not.toBeInTheDocument(); + expect(screen.queryByText('Delete')).not.toBeInTheDocument(); +}); + +test('default, real connector, with no edit/delete functionality renders properly', () => { + render( + + ); + + expect(screen.getByText('Okta')).toBeInTheDocument(); + expect(screen.getByText('Default')).toBeInTheDocument(); + + expect(screen.queryByTestId('button')).not.toBeInTheDocument(); +}); + +test('placeholder renders properly', () => { + render(); + + expect(screen.getByText('Okta')).toBeInTheDocument(); + expect(screen.queryByText('Default')).not.toBeInTheDocument(); + + expect(screen.getByText('Set Up')).toBeInTheDocument(); + + expect(screen.queryByTestId('button')).not.toBeInTheDocument(); +}); + +const props = { + name: 'Okta', + id: 'okta-connector', + kind: 'saml' as AuthType, + Icon: getSsoIcon('saml', 'okta'), + isDefault: false, + isPlaceholder: false, + onSetup: () => null, + onEdit: () => null, + onDelete: () => null, + onSetAsDefault: () => null, +}; diff --git a/web/packages/teleport/src/AuthConnectors/AuthConnectorTile.tsx b/web/packages/teleport/src/AuthConnectors/AuthConnectorTile.tsx index acd9bcb600c65..338630a8a0fc7 100644 --- a/web/packages/teleport/src/AuthConnectors/AuthConnectorTile.tsx +++ b/web/packages/teleport/src/AuthConnectors/AuthConnectorTile.tsx @@ -19,7 +19,7 @@ import styled, { useTheme } from 'styled-components'; import { Box, ButtonSecondary, Flex, H2, P3, Subtitle2 } from 'design'; -import { ArrowRight, CircleCheck, Password } from 'design/Icon'; +import { ArrowRight, CircleCheck, Password, Pencil, Trash } from 'design/Icon'; import { MenuIcon, MenuItem } from 'shared/components/MenuAction'; import { AuthType } from 'shared/services'; @@ -36,6 +36,7 @@ export function AuthConnectorTile({ customDesc, onEdit, onDelete, + onSetAsDefault, }: { name: string; id: string; @@ -50,6 +51,7 @@ export function AuthConnectorTile({ customDesc?: string; onEdit?: ResourceState['edit']; onDelete?: ResourceState['remove']; + onSetAsDefault?: () => void; }) { const theme = useTheme(); const onClickEdit = () => onEdit(id); @@ -81,8 +83,8 @@ export function AuthConnectorTile({ > - -

{name}

+ +

{name}

{isDefault && }
)} - {!isPlaceholder && !!onEdit && !!onDelete && ( - - Edit - Delete - - )} + {!isPlaceholder && + (!!onEdit || !!onDelete || (!!onSetAsDefault && !isDefault)) && ( + + {!!onEdit && ( + + + Edit + + )} + {!!onSetAsDefault && !isDefault && ( + + + Set as default + + )} + {!!onDelete && ( + + + Delete + + )} + + )}
); @@ -124,7 +154,13 @@ export function AuthConnectorTile({ /** * LocalConnectorTile is a hardcoded "auth connector" which represents local auth. */ -export function LocalConnectorTile() { +export function LocalConnectorTile({ + isDefault = false, + setAsDefault, +}: { + isDefault?: boolean; + setAsDefault?: () => void; +}) { return (
)} - isDefault={true} + isDefault={isDefault} + onSetAsDefault={setAsDefault} isPlaceholder={false} name="Local Connector" customDesc="Manual auth w/ users local to Teleport" diff --git a/web/packages/teleport/src/AuthConnectors/AuthConnectors.tsx b/web/packages/teleport/src/AuthConnectors/AuthConnectors.tsx index d4f4e32af9c34..ca8145a680ed2 100644 --- a/web/packages/teleport/src/AuthConnectors/AuthConnectors.tsx +++ b/web/packages/teleport/src/AuthConnectors/AuthConnectors.tsx @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { useHistory } from 'react-router'; import { Alert, Box, Flex, H3, Indicator, Link } from 'design'; @@ -33,11 +33,11 @@ import { FeatureBox, FeatureHeaderTitle } from 'teleport/components/Layout'; import { Route, Switch } from 'teleport/components/Router'; import useResources from 'teleport/components/useResources'; import cfg from 'teleport/config'; -import { Resource } from 'teleport/services/resources'; +import { DefaultAuthConnector, Resource } from 'teleport/services/resources'; import useTeleport from 'teleport/useTeleport'; import { GitHubConnectorEditor } from './AuthConnectorEditor'; -import ConnectorList from './ConnectorList'; +import { ConnectorList } from './ConnectorList'; import { CtaConnectors } from './ConnectorList/CTAConnectors'; import DeleteConnectorDialog from './DeleteConnectorDialog'; import EmptyList from './EmptyList'; @@ -78,13 +78,33 @@ export function AuthConnectorsContainer() { export function AuthConnectors() { const ctx = useTeleport(); const [items, setItems] = useState[]>([]); + const [defaultConnector, setDefaultConnector] = + useState(); const [fetchAttempt, fetchConnectors] = useAsync( useCallback(async () => { - return await ctx.resourceService.fetchGithubConnectors().then(setItems); + return await ctx.resourceService.fetchGithubConnectors().then(res => { + setItems(res.connectors); + setDefaultConnector(res.defaultConnector); + }); }, [ctx.resourceService]) ); + const [setDefaultAttempt, updateDefaultConnector] = useAsync( + async (connector: DefaultAuthConnector) => + await ctx.resourceService.setDefaultAuthConnector(connector) + ); + + function onUpdateDefaultConnector(connector: DefaultAuthConnector) { + const originalDefault = defaultConnector; + setDefaultConnector(connector); + updateDefaultConnector(connector).catch(err => { + // Revert back to the original default if the operation failed. + setDefaultConnector(originalDefault); + throw err; + }); + } + function remove(name: string) { return ctx.resourceService .deleteGithubConnector(name) @@ -102,6 +122,21 @@ export function AuthConnectors() { const isEmpty = items.length === 0; const resources = useResources(items, templates); + // Calculate the next default connector. + const nextDefaultConnector = useMemo(() => { + // If there is only one (or no) connectors, the fallback will always be "local" + if (items.length < 2) { + return 'Local Connector'; + } + // If the connector being removed is last in the list, the next default will be the second last connector. + if (items[items.length - 1].name === resources?.item?.name) { + return items[items.length - 2].name; + } else { + // If the connector being removed isn't the last connector, the next default will always be the last connector. + return items[items.length - 1].name; + } + }, [items, resources.item]); + return ( @@ -129,14 +164,26 @@ export function AuthConnectors() {

Your Connectors

+ {setDefaultAttempt.status === 'error' && ( + + Failed to set connector as default:{' '} + {setDefaultAttempt.statusText} + + )} {isEmpty ? ( history.push(cfg.getCreateAuthConnectorRoute('github')) } + isLocalDefault={defaultConnector.type === 'local'} /> ) : ( - + )}
@@ -165,6 +212,8 @@ export function AuthConnectors() { kind={resources.item.kind} onClose={resources.disregard} onDelete={() => remove(resources.item.name)} + isDefault={defaultConnector.name === resources.item.name} + nextDefault={nextDefaultConnector} /> )}
diff --git a/web/packages/teleport/src/AuthConnectors/ConnectorList/ConnectorList.tsx b/web/packages/teleport/src/AuthConnectors/ConnectorList/ConnectorList.tsx index 4bf08a10c1aad..902540a3a0709 100644 --- a/web/packages/teleport/src/AuthConnectors/ConnectorList/ConnectorList.tsx +++ b/web/packages/teleport/src/AuthConnectors/ConnectorList/ConnectorList.tsx @@ -23,12 +23,21 @@ import { Box } from 'design'; import { State as ResourceState } from 'teleport/components/useResources'; import cfg from 'teleport/config'; -import { Resource } from 'teleport/services/resources'; +import { + DefaultAuthConnector, + KindAuthConnectors, + Resource, +} from 'teleport/services/resources'; import { AuthConnectorTile, LocalConnectorTile } from '../AuthConnectorTile'; import getSsoIcon from '../ssoIcons/getSsoIcon'; -export default function ConnectorList({ items, onDelete }: Props) { +export function ConnectorList({ + items, + defaultConnector, + setAsDefault, + onDelete, +}: Props) { const history = useHistory(); items = items || []; const $items = items.map(item => { @@ -42,7 +51,10 @@ export default function ConnectorList({ items, onDelete }: Props) { kind={kind} id={id} Icon={Icon} - isDefault={false} + isDefault={ + defaultConnector.name === name && defaultConnector.type === kind + } + onSetAsDefault={() => setAsDefault({ type: kind, name })} isPlaceholder={false} onEdit={() => history.push(cfg.getEditAuthConnectorRoute(kind, name))} onDelete={onDelete} @@ -53,14 +65,19 @@ export default function ConnectorList({ items, onDelete }: Props) { return ( - + setAsDefault({ type: 'local' })} + /> {$items} ); } -type Props = { - items: Resource<'github'>[]; +type Props = { + items: Resource[]; + defaultConnector: DefaultAuthConnector; + setAsDefault: (defaultConnector: DefaultAuthConnector) => void; onDelete: ResourceState['remove']; }; diff --git a/web/packages/teleport/src/AuthConnectors/ConnectorList/index.ts b/web/packages/teleport/src/AuthConnectors/ConnectorList/index.ts index bf974fdd4e3ac..5d67a06b76a47 100644 --- a/web/packages/teleport/src/AuthConnectors/ConnectorList/index.ts +++ b/web/packages/teleport/src/AuthConnectors/ConnectorList/index.ts @@ -16,8 +16,6 @@ * along with this program. If not, see . */ -import ConnectorList from './ConnectorList'; +export { ConnectorList } from './ConnectorList'; export { CtaConnectors } from './CTAConnectors'; - -export default ConnectorList; diff --git a/web/packages/teleport/src/AuthConnectors/DeleteConnectorDialog/DeleteConnectorDialog.story.tsx b/web/packages/teleport/src/AuthConnectors/DeleteConnectorDialog/DeleteConnectorDialog.story.tsx index 798ca0a579f8d..4eac2bb55358b 100644 --- a/web/packages/teleport/src/AuthConnectors/DeleteConnectorDialog/DeleteConnectorDialog.story.tsx +++ b/web/packages/teleport/src/AuthConnectors/DeleteConnectorDialog/DeleteConnectorDialog.story.tsx @@ -26,6 +26,10 @@ export default { export const Loaded = () => ; +export const LoadedDefault = () => ( + +); + const props = { name: 'sample-connector-role', kind: 'github' as KindAuthConnectors, @@ -33,4 +37,6 @@ const props = { return Promise.reject(new Error('server error')); }, onClose: () => null, + isDefault: false, + nextDefault: 'okta', }; diff --git a/web/packages/teleport/src/AuthConnectors/DeleteConnectorDialog/DeleteConnectorDialog.tsx b/web/packages/teleport/src/AuthConnectors/DeleteConnectorDialog/DeleteConnectorDialog.tsx index cc7c15312f30f..41b6b55cd0259 100644 --- a/web/packages/teleport/src/AuthConnectors/DeleteConnectorDialog/DeleteConnectorDialog.tsx +++ b/web/packages/teleport/src/AuthConnectors/DeleteConnectorDialog/DeleteConnectorDialog.tsx @@ -39,7 +39,7 @@ import { KindAuthConnectors } from 'teleport/services/resources'; import getSsoIcon from '../ssoIcons/getSsoIcon'; export default function DeleteConnectorDialog(props: Props) { - const { name, kind, onClose, onDelete } = props; + const { name, kind, onClose, onDelete, isDefault, nextDefault } = props; const { attempt, run } = useAttempt(); const isDisabled = attempt.status === 'processing'; @@ -59,7 +59,7 @@ export default function DeleteConnectorDialog(props: Props) { Remove Connector? - + {attempt.status === 'failed' && } @@ -73,10 +73,22 @@ export default function DeleteConnectorDialog(props: Props) { ? + {isDefault && ( + + + This is the currently the default auth connector. Deleting this + will cause{' '} + + {nextDefault} + {' '} + to become the new default. + + + )} - Yes, Remove Connector + Delete Connector Cancel @@ -91,4 +103,6 @@ type Props = { kind: KindAuthConnectors; onClose: ResourceState['disregard']; onDelete(): Promise; + isDefault: boolean; + nextDefault: string; }; diff --git a/web/packages/teleport/src/AuthConnectors/EmptyList/EmptyList.tsx b/web/packages/teleport/src/AuthConnectors/EmptyList/EmptyList.tsx index 0751eb31347f8..60743996a13ae 100644 --- a/web/packages/teleport/src/AuthConnectors/EmptyList/EmptyList.tsx +++ b/web/packages/teleport/src/AuthConnectors/EmptyList/EmptyList.tsx @@ -22,10 +22,10 @@ import { State as ResourceState } from 'teleport/components/useResources'; import { AuthConnectorTile, LocalConnectorTile } from '../AuthConnectorTile'; import { AuthConnectorsGrid } from '../ConnectorList/ConnectorList'; -export default function EmptyList({ onCreate }: Props) { +export default function EmptyList({ onCreate, isLocalDefault }: Props) { return ( - + (() => { @@ -121,12 +122,18 @@ export default function useLogin() { history.push(ssoUri, true); } + // Move the default connector to the front of the list so that it shows up at the top. + const sortedProviders = moveToFront( + authProviders, + p => p.name === defaultConnectorName + ); + return { attempt, onLogin, checkingValidSession, onLoginWithSso, - authProviders, + authProviders: sortedProviders, auth2faType, preferredMfaType: cfg.getPreferredMfaType(), isLocalAuthEnabled, @@ -188,3 +195,19 @@ export type State = ReturnType & { isRecoveryEnabled?: boolean; onRecover?: (isRecoverPassword: boolean) => void; }; + +/** + * moveToFront returns a copy of an array with the element that matches the condition to the front of it. + */ +function moveToFront(arr: T[], condition: (item: T) => boolean): T[] { + console.log(arr); + const copy = [...arr]; + const index = copy.findIndex(condition); + + if (index > 0) { + const [item] = copy.splice(index, 1); + copy.unshift(item); + } + + return copy; +} diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts index c4a162c35339d..3d25bd56380b7 100644 --- a/web/packages/teleport/src/config.ts +++ b/web/packages/teleport/src/config.ts @@ -125,6 +125,8 @@ const cfg = { providers: [] as AuthProvider[], second_factor: 'off' as Auth2faType, authType: 'local' as AuthType, + /** defaultConnectorName is the name of the default connector from the cluster's auth preferences. This will empty if the auth type is "local" */ + defaultConnectorName: '', preferredLocalMfa: '' as PreferredMfaType, // motd is the message of the day, displayed to users before login. motd: '', @@ -429,6 +431,8 @@ const cfg = { msTeamsAppZipPath: '/v1/webapi/sites/:clusterId/plugins/:plugin/files/msteams_app.zip', + defaultConnectorPath: '/v1/webapi/authconnector/default', + yaml: { parse: '/v1/webapi/yaml/parse/:kind', stringify: '/v1/webapi/yaml/stringify/:kind', @@ -501,6 +505,10 @@ const cfg = { return cfg.auth ? cfg.auth.second_factor : null; }, + getDefaultConnectorName() { + return cfg.auth ? cfg.auth.defaultConnectorName : ''; + }, + getPreferredMfaType() { return cfg.auth ? cfg.auth.preferredLocalMfa : null; }, diff --git a/web/packages/teleport/src/services/resources/resource.ts b/web/packages/teleport/src/services/resources/resource.ts index f2654ba3a767f..b8b2bfe639b6d 100644 --- a/web/packages/teleport/src/services/resources/resource.ts +++ b/web/packages/teleport/src/services/resources/resource.ts @@ -20,7 +20,14 @@ import cfg, { UrlListRolesParams, UrlResourcesParams } from 'teleport/config'; import api from 'teleport/services/api'; import { ResourcesResponse, UnifiedResource } from '../agents'; -import { makeResource, makeResourceList, RoleResource } from './'; +import auth, { MfaChallengeScope } from '../auth/auth'; +import { + DefaultAuthConnector, + makeResource, + makeResourceList, + Resource, + RoleResource, +} from './'; import { makeUnifiedResource } from './makeUnifiedResource'; class ResourceService { @@ -48,10 +55,32 @@ class ResourceService { }); } - fetchGithubConnectors() { - return api - .get(cfg.getGithubConnectorsUrl()) - .then(res => makeResourceList<'github'>(res)); + fetchGithubConnectors(): Promise<{ + defaultConnector: DefaultAuthConnector; + connectors: Resource<'github'>[]; + }> { + return api.get(cfg.getGithubConnectorsUrl()).then(res => ({ + defaultConnector: { + name: res.defaultConnectorName, + type: res.defaultConnectorType, + }, + connectors: makeResourceList<'github'>(res.connectors), + })); + } + + async setDefaultAuthConnector(req: DefaultAuthConnector | { type: 'local' }) { + // This is an admin action that needs an mfa challenge with reuse allowed. + const challenge = await auth.getMfaChallenge({ + scope: MfaChallengeScope.ADMIN_ACTION, + allowReuse: true, + isMfaRequiredRequest: { + admin_action: {}, + }, + }); + + const challengeResponse = await auth.getMfaChallengeResponse(challenge); + + return api.put(cfg.api.defaultConnectorPath, req, challengeResponse); } async fetchRoles(params?: UrlListRolesParams): Promise<{ diff --git a/web/packages/teleport/src/services/resources/types.ts b/web/packages/teleport/src/services/resources/types.ts index a4634798897cb..045c75945c406 100644 --- a/web/packages/teleport/src/services/resources/types.ts +++ b/web/packages/teleport/src/services/resources/types.ts @@ -16,6 +16,8 @@ * along with this program. If not, see . */ +import { AuthType } from 'shared/services'; + export type Resource = { id: string; kind: T; @@ -89,6 +91,11 @@ export type RoleConditions = { export type Labels = Record; +export type DefaultAuthConnector = { + name?: string; + type: AuthType; +}; + export type KubernetesResource = { kind?: KubernetesResourceKind; name?: string;