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 && (
-
-
-
-
- )}
+ {!isPlaceholder &&
+ (!!onEdit || !!onDelete || (!!onSetAsDefault && !isDefault)) && (
+
+ {!!onEdit && (
+
+ )}
+ {!!onSetAsDefault && !isDefault && (
+
+ )}
+ {!!onDelete && (
+
+ )}
+
+ )}
);
@@ -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;