diff --git a/api/utils/entraid/federation_metadata.go b/api/utils/entraid/federation_metadata.go new file mode 100644 index 0000000000000..2dfa76080cdeb --- /dev/null +++ b/api/utils/entraid/federation_metadata.go @@ -0,0 +1,33 @@ +/* +Copyright 2024 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +package entraid + +import ( + "net/url" + "path" +) + +// FederationMetadataURL returns the URL for the federation metadata endpoint +func FederationMetadataURL(tenantID, appID string) string { + return (&url.URL{ + Scheme: "https", + Host: "login.microsoftonline.com", + Path: path.Join(tenantID, "federationmetadata", "2007-06", "federationmetadata.xml"), + RawQuery: url.Values{ + "appid": {appID}, + }.Encode(), + }).String() +} diff --git a/go.mod b/go.mod index 25a8fa25ac4e1..5597b1f7884fe 100644 --- a/go.mod +++ b/go.mod @@ -82,6 +82,7 @@ require ( github.com/elastic/go-elasticsearch/v8 v8.13.1 github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 github.com/evanphx/json-patch v5.9.0+incompatible + github.com/fatih/color v1.17.0 github.com/fsouza/fake-gcs-server v1.48.0 github.com/fxamacker/cbor/v2 v2.6.0 github.com/ghodss/yaml v1.0.0 @@ -313,7 +314,6 @@ require ( github.com/evanphx/json-patch/v5 v5.9.0 // indirect github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect github.com/fatih/camelcase v1.0.0 // indirect - github.com/fatih/color v1.16.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/form3tech-oss/jwt-go v3.2.5+incompatible // indirect github.com/fsnotify/fsnotify v1.7.0 diff --git a/go.sum b/go.sum index 3588e1d39cfe2..ca4fc55beb9ca 100644 --- a/go.sum +++ b/go.sum @@ -1164,8 +1164,8 @@ github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8 github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= diff --git a/integrations/event-handler/go.mod b/integrations/event-handler/go.mod index 930c9f3977a53..72bb9c208a88a 100644 --- a/integrations/event-handler/go.mod +++ b/integrations/event-handler/go.mod @@ -124,7 +124,7 @@ require ( github.com/evanphx/json-patch v5.9.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect - github.com/fatih/color v1.16.0 // indirect + github.com/fatih/color v1.17.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fxamacker/cbor/v2 v2.6.0 // indirect diff --git a/integrations/event-handler/go.sum b/integrations/event-handler/go.sum index 03d199d801fbd..67762e55c087f 100644 --- a/integrations/event-handler/go.sum +++ b/integrations/event-handler/go.sum @@ -927,8 +927,8 @@ github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d h1:105gxyaGwC github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZMPRZwes7CROmyNKgQzC3XPs6L/G2EJLHddWejkmf4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= diff --git a/integrations/terraform/go.mod b/integrations/terraform/go.mod index e7a9c0c3bc950..4a1e307345f4d 100644 --- a/integrations/terraform/go.mod +++ b/integrations/terraform/go.mod @@ -143,7 +143,7 @@ require ( github.com/evanphx/json-patch v5.9.0+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.0 // indirect github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d // indirect - github.com/fatih/color v1.16.0 // indirect + github.com/fatih/color v1.17.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fxamacker/cbor/v2 v2.6.0 // indirect diff --git a/integrations/terraform/go.sum b/integrations/terraform/go.sum index 6392da56d26e6..9353f809d1114 100644 --- a/integrations/terraform/go.sum +++ b/integrations/terraform/go.sum @@ -1017,8 +1017,8 @@ github.com/exponent-io/jsonpath v0.0.0-20151013193312-d6023ce2651d/go.mod h1:ZZM github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= -github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= -github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= +github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= +github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= diff --git a/lib/config/configuration.go b/lib/config/configuration.go index 48e543f212feb..892134b498e4b 100644 --- a/lib/config/configuration.go +++ b/lib/config/configuration.go @@ -296,6 +296,9 @@ type IntegrationConfAzureOIDC struct { // When this is true, the integration script will produce // a cache file necessary for TAG synchronization. AccessGraphEnabled bool + + // SkipOIDCConfiguration is a flag indicating that OIDC configuration should be skipped. + SkipOIDCConfiguration bool } // IntegrationConfDeployServiceIAM contains the arguments of diff --git a/lib/integrations/azureoidc/enterprise_app.go b/lib/integrations/azureoidc/enterprise_app.go index e159470d0bb39..e7de09225ec58 100644 --- a/lib/integrations/azureoidc/enterprise_app.go +++ b/lib/integrations/azureoidc/enterprise_app.go @@ -52,7 +52,7 @@ var appRoles = []string{ // - Provides Teleport with OIDC authentication to Azure // - Is given the permissions to access certain Microsoft Graph API endpoints for this tenant. // - Provides SSO to the Teleport cluster via SAML. -func SetupEnterpriseApp(ctx context.Context, proxyPublicAddr string, authConnectorName string) (string, string, error) { +func SetupEnterpriseApp(ctx context.Context, proxyPublicAddr string, authConnectorName string, skipOIDCSetup bool) (string, string, error) { var appID, tenantID string tenantID, err := getTenantID() @@ -120,8 +120,12 @@ func SetupEnterpriseApp(ctx context.Context, proxyPublicAddr string, authConnect } } - if err := createFederatedAuthCredential(ctx, graphClient, *app.ID, proxyPublicAddr); err != nil { - return appID, tenantID, trace.Wrap(err, "failed to create an OIDC federated auth credential") + // Skip OIDC setup if requested. + // This is useful for clusters that can't use OIDC because they are not reachable from the public internet. + if !skipOIDCSetup { + if err := createFederatedAuthCredential(ctx, graphClient, *app.ID, proxyPublicAddr); err != nil { + return appID, tenantID, trace.Wrap(err, "failed to create an OIDC federated auth credential") + } } acsURL, err := url.Parse(proxyPublicAddr) diff --git a/lib/integrations/azureoidc/provision_sso.go b/lib/integrations/azureoidc/provision_sso.go index 07d4366040752..9bb17aa5771dd 100644 --- a/lib/integrations/azureoidc/provision_sso.go +++ b/lib/integrations/azureoidc/provision_sso.go @@ -48,6 +48,9 @@ func setupSSO(ctx context.Context, graphClient *msgraph.Client, appObjectID stri webApp := &msgraph.WebApplication{} webApp.RedirectURIs = &uris app.Web = webApp + securityGroups := new(string) + *securityGroups = "SecurityGroup" + app.GroupMembershipClaims = securityGroups err = graphClient.UpdateApplication(ctx, appObjectID, app) diff --git a/lib/msgraph/models.go b/lib/msgraph/models.go index f867ecbb634c5..829d55a040464 100644 --- a/lib/msgraph/models.go +++ b/lib/msgraph/models.go @@ -18,6 +18,7 @@ package msgraph import ( "encoding/json" + "slices" "github.com/gravitational/trace" ) @@ -34,6 +35,12 @@ type DirectoryObject struct { type Group struct { DirectoryObject + GroupTypes []string `json:"groupTypes,omitempty"` +} + +func (g *Group) IsOffice365Group() bool { + const office365Group = "Unified" + return slices.Contains(g.GroupTypes, office365Group) } func (g *Group) isGroupMember() {} @@ -53,9 +60,10 @@ func (u *User) GetID() *string { return u.ID } type Application struct { DirectoryObject - AppID *string `json:"appId,omitempty"` - IdentifierURIs *[]string `json:"identifierUris,omitempty"` - Web *WebApplication `json:"web,omitempty"` + AppID *string `json:"appId,omitempty"` + IdentifierURIs *[]string `json:"identifierUris,omitempty"` + Web *WebApplication `json:"web,omitempty"` + GroupMembershipClaims *string `json:"groupMembershipClaims,omitempty"` } type WebApplication struct { diff --git a/tool/tctl/common/plugin/entraid.go b/tool/tctl/common/plugin/entraid.go new file mode 100644 index 0000000000000..ea5010504ca9f --- /dev/null +++ b/tool/tctl/common/plugin/entraid.go @@ -0,0 +1,419 @@ +/* + * 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 plugin + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/alecthomas/kingpin/v2" + "github.com/fatih/color" + "github.com/google/safetext/shsprintf" + "github.com/google/uuid" + "github.com/gravitational/trace" + + pluginspb "github.com/gravitational/teleport/api/gen/proto/go/teleport/plugins/v1" + "github.com/gravitational/teleport/api/types" + entraapiutils "github.com/gravitational/teleport/api/utils/entraid" + "github.com/gravitational/teleport/lib/integrations/azureoidc" + "github.com/gravitational/teleport/lib/utils/oidc" + "github.com/gravitational/teleport/lib/web/scripts/oneoff" +) + +var ( + bold = color.New(color.Bold).SprintFunc() + boldRed = color.New(color.Bold, color.FgRed).SprintFunc() + + step1Template = bold("Step 1: Run the Setup Script") + ` + +1. Open ` + bold("Azure Cloud Shell") + ` (Bash) [https://portal.azure.com/#cloudshell/] using ` + bold("Google Chrome") + ` or ` + bold("Safari") + ` for the best compatibility. +2. Upload the setup script in ` + boldRed("%s") + ` using the ` + bold("Upload") + ` button in the Cloud Shell toolbar. +3. Once uploaded, execute the script by running the following command: + $ bash %s + +` + bold("Important Considerations") + `: +- You must have ` + bold("Azure privileged administrator permissions") + ` to complete the integration. +- Ensure you're using the ` + bold("Bash") + ` environment in Cloud Shell. +- During the script execution, you'll be prompted to run 'az login' to authenticate with Azure. ` + bold("Teleport") + ` does not store or persist your credentials. +- ` + bold("Mozilla Firefox") + ` users may experience connectivity issues in Azure Cloud Shell; using Chrome or Safari is recommended. + +To rerun the script, type 'exit' to close and then restart the process. + +` + + step2Template = ` + +` + bold("Step 2: Input Tenant ID and Client ID") + ` + +With the output of Step 1, please copy and paste the following information: +` +) + +type entraArgs struct { + cmd *kingpin.CmdClause + authConnectorName string + defaultOwners []string + useSystemCredentials bool + accessGraph bool + force bool +} + +func (p *PluginsCommand) initInstallEntra(parent *kingpin.CmdClause) { + p.install.entraID.cmd = parent.Command("entraid", "Install an EntraId integration.") + cmd := p.install.entraID.cmd + cmd. + Flag("name", "Name of the plugin resource to create"). + Default("entra-id"). + StringVar(&p.install.name) + + cmd. + Flag("auth-connector-name", "Name of the SAML connector resource to create"). + Default("entra-id-default"). + StringVar(&p.install.entraID.authConnectorName) + + cmd. + Flag("use-system-credentials", "Uses system credentials instead of OIDC."). + BoolVar(&p.install.entraID.useSystemCredentials) + + cmd.Flag("default-owner", "List of Teleport users that are default owners for the imported access lists. Multiple flags allowed."). + Required(). + StringsVar(&p.install.entraID.defaultOwners) + + cmd. + Flag("access-graph", "Enables Access Graph cache build."). + Default("true"). + BoolVar(&p.install.entraID.accessGraph) + + cmd. + Flag("force", "Proceed with installation even if plugin already exists."). + Short('f'). + Default("false"). + BoolVar(&p.install.entraID.force) +} + +type entraSettings struct { + accessGraphCache *azureoidc.TAGInfoCache + clientID string + tenantID string +} + +var ( + errCancel = trace.BadParameter("operation canceled") +) + +func (p *PluginsCommand) entraSetupGuide(proxyPublicAddr string) (entraSettings, error) { + pwd, err := os.Getwd() + if err != nil { + return entraSettings{}, trace.Wrap(err, "failed to get working dir") + } + f, err := os.CreateTemp(pwd, "entraid-setup-*.sh") + if err != nil { + return entraSettings{}, trace.Wrap(err, "failed to create temp file") + } + + defer os.Remove(f.Name()) + + buildScript, err := buildScript(proxyPublicAddr, p.install.entraID) + if err != nil { + return entraSettings{}, trace.Wrap(err, "failed to build script") + } + + if _, err := f.Write([]byte(buildScript)); err != nil { + return entraSettings{}, trace.Wrap(err, "failed to write script to file") + } + + if err := f.Close(); err != nil { + return entraSettings{}, trace.Wrap(err, "failed to close file") + } + fileLoc := f.Name() + + fmt.Fprintf(os.Stdout, step1Template, fileLoc, filepath.Base(fileLoc)) + + op, err := readData(os.Stdin, os.Stdout, + `Once the script completes, type 'continue' to proceed, 'exit' to quit`, + func(input string) bool { + return input == "continue" || input == "exit" + }, "Invalid input. Please enter 'continue' or 'exit'.") + if err != nil { + return entraSettings{}, trace.Wrap(err, "failed to read operation") + } + if op == "exit" { // User chose to exit + return entraSettings{}, errCancel + } + + validUUID := func(input string) bool { + _, err := uuid.Parse(input) + return err == nil + } + + fmt.Fprint(os.Stdout, step2Template) + + var settings entraSettings + settings.tenantID, err = readData(os.Stdin, os.Stdout, "Enter the Tenant ID", validUUID, "Invalid Tenant ID") + if err != nil { + return settings, trace.Wrap(err, "failed to read Tenant ID") + } + + settings.clientID, err = readData(os.Stdin, os.Stdout, "Enter the Client ID", validUUID, "Invalid Client ID") + if err != nil { + return settings, trace.Wrap(err, "failed to read Client ID") + } + + if p.install.entraID.accessGraph { + dataValidator := func(input string) bool { + settings.accessGraphCache, err = readTAGCache(input) + return err == nil + } + _, err = readData(os.Stdin, os.Stdout, "Enter the Access Graph Cache file location", dataValidator, "File does not exist or is invalid") + if err != nil { + return settings, trace.Wrap(err, "failed to read Access Graph Cache file") + } + } + return settings, nil +} + +// InstallEntra is the entry point for the `tctl plugins install entraid` command. +// This function guides users through an interactive setup process to configure EntraID integration, +// directing them to execute a script in Azure Cloud Shell and provide the required configuration inputs. +// The script creates an Azure EntraID Enterprise Application, enabling SAML logins in Teleport with +// the following claims: +// - givenname: user.givenname +// - surname: user.surname +// - emailaddress: user.mail +// - name: user.userprincipalname +// - groups: user.groups +// Additionally, the script establishes a Trust Policy in the application to allow Teleport +// to be recognized as a credential issuer when system credentials are not used. +// If system credentials are present, the script will skip the Trust policy creation using +// system credentials for EntraID authentication. +// Finally, if no system credentials are in use, the script will set up an Azure OIDC integration +// in Teleport and a Teleport plugin to synchronize access lists from EntraID to Teleport. +func (p *PluginsCommand) InstallEntra(ctx context.Context, args installPluginArgs) error { + inputs := p.install + + proxyPublicAddr, err := getProxyPublicAddr(ctx, args.authClient) + if err != nil { + return trace.Wrap(err) + } + + settings, err := p.entraSetupGuide(proxyPublicAddr) + if err != nil { + if errors.Is(err, errCancel) { + return nil + } + return trace.Wrap(err) + } + + var tagSyncSettings *types.PluginEntraIDAccessGraphSettings + if settings.accessGraphCache != nil { + tagSyncSettings = &types.PluginEntraIDAccessGraphSettings{ + AppSsoSettingsCache: settings.accessGraphCache.AppSsoSettingsCache, + } + } + + saml, err := types.NewSAMLConnector(inputs.entraID.authConnectorName, types.SAMLConnectorSpecV2{ + AssertionConsumerService: strings.TrimRight(proxyPublicAddr, "/") + "/v1/webapi/saml/acs/" + inputs.entraID.authConnectorName, + AllowIDPInitiated: true, + // AttributesToRoles is required, but Entra ID does not have a default group (like Okta's "Everyone"), + // so we add a dummy claim that will always be fulfilled and map them to the "requester" role. + AttributesToRoles: []types.AttributeMapping{ + { + Name: "http://schemas.microsoft.com/ws/2008/06/identity/claims/groups", + Value: "*", + Roles: []string{"requester"}, + }, + }, + Display: "Entra ID", + EntityDescriptorURL: entraapiutils.FederationMetadataURL(settings.tenantID, settings.clientID), + }) + if err != nil { + return trace.Wrap(err, "failed to create SAML connector") + } + + if _, err = args.authClient.CreateSAMLConnector(ctx, saml); err != nil { + if !trace.IsAlreadyExists(err) || !inputs.entraID.force { + return trace.Wrap(err, "failed to create SAML connector") + } + if _, err = args.authClient.UpsertSAMLConnector(ctx, saml); err != nil { + return trace.Wrap(err, "failed to upsert SAML connector") + } + } + + if !inputs.entraID.useSystemCredentials { + integrationSpec, err := types.NewIntegrationAzureOIDC( + types.Metadata{Name: inputs.name}, + &types.AzureOIDCIntegrationSpecV1{ + TenantID: settings.tenantID, + ClientID: settings.clientID, + }, + ) + if err != nil { + return trace.Wrap(err, "failed to create Azure OIDC integration") + } + + if _, err = args.authClient.CreateIntegration(ctx, integrationSpec); err != nil { + if !trace.IsAlreadyExists(err) || !inputs.entraID.force { + return trace.Wrap(err, "failed to create Azure OIDC integration") + } + + integration, err := args.authClient.GetIntegration(ctx, integrationSpec.GetName()) + if err != nil { + return trace.Wrap(err, "failed to get Azure OIDC integration") + } + integration.SetAWSOIDCIntegrationSpec(integrationSpec.GetAWSOIDCIntegrationSpec()) + if _, err = args.authClient.UpdateIntegration(ctx, integration); err != nil { + return trace.Wrap(err, "failed to create Azure OIDC integration") + } + } + } + + credentialsSource := types.EntraIDCredentialsSource_ENTRAID_CREDENTIALS_SOURCE_OIDC + if inputs.entraID.useSystemCredentials { + credentialsSource = types.EntraIDCredentialsSource_ENTRAID_CREDENTIALS_SOURCE_SYSTEM_CREDENTIALS + } + req := &pluginspb.CreatePluginRequest{ + Plugin: &types.PluginV1{ + Metadata: types.Metadata{ + Name: inputs.name, + Labels: map[string]string{ + "teleport.dev/hosted-plugin": "true", + }, + }, + Spec: types.PluginSpecV1{ + Settings: &types.PluginSpecV1_EntraId{ + EntraId: &types.PluginEntraIDSettings{ + SyncSettings: &types.PluginEntraIDSyncSettings{ + DefaultOwners: inputs.entraID.defaultOwners, + SsoConnectorId: inputs.entraID.authConnectorName, + CredentialsSource: credentialsSource, + TenantId: settings.tenantID, + }, + AccessGraphSettings: tagSyncSettings, + }, + }, + }, + }, + } + + _, err = args.plugins.CreatePlugin(ctx, req) + if err != nil { + if !trace.IsAlreadyExists(err) || !inputs.entraID.force { + return trace.Wrap(err) + } + plugin := req.GetPlugin() + { + oldPlugin, err := args.plugins.GetPlugin(ctx, &pluginspb.GetPluginRequest{ + Name: inputs.name, + }) + if err != nil { + return trace.Wrap(err) + } + plugin.Metadata.Revision = oldPlugin.GetMetadata().Revision + } + if _, err = args.plugins.UpdatePlugin(ctx, &pluginspb.UpdatePluginRequest{ + Plugin: plugin, + }); err != nil { + return trace.Wrap(err) + } + } + + fmt.Printf("Successfully created EntraID plugin %q\n\n", p.install.name) + + return nil +} + +func buildScript(proxyPublicAddr string, entraCfg entraArgs) (string, error) { + // The script must execute the following command: + argsList := []string{ + "integration", "configure", "azure-oidc", + fmt.Sprintf("--proxy-public-addr=%s", shsprintf.EscapeDefaultContext(proxyPublicAddr)), + fmt.Sprintf("--auth-connector-name=%s", shsprintf.EscapeDefaultContext(entraCfg.authConnectorName)), + } + + if entraCfg.accessGraph { + argsList = append(argsList, "--access-graph") + } + + if entraCfg.useSystemCredentials { + argsList = append(argsList, "--skip-oidc-integration") + } + + script, err := oneoff.BuildScript(oneoff.OneOffScriptParams{ + TeleportArgs: strings.Join(argsList, " "), + SuccessMessage: "Success! You can now go back to the Teleport Web UI to use the integration with Azure.", + }) + if err != nil { + return "", trace.Wrap(err) + } + return script, nil +} + +func getProxyPublicAddr(ctx context.Context, authClient authClient) (string, error) { + pingResp, err := authClient.Ping(ctx) + if err != nil { + return "", trace.Wrap(err, "failed fetching cluster info") + } + proxyPublicAddr := pingResp.GetProxyPublicAddr() + oidcIssuer, err := oidc.IssuerFromPublicAddress(proxyPublicAddr, "") + return oidcIssuer, trace.Wrap(err) +} + +// readTAGCache reads the TAG cache file and returns the TAGInfoCache object. +// azureoidc.TAGInfoCache is a struct that contains the information necessary for Access Graph to analyze Azure SSO. +// It contains a list of AppID and their corresponding FederatedSsoV2 information. +func readTAGCache(fileLoc string) (*azureoidc.TAGInfoCache, error) { + if fileLoc == "" { + return nil, trace.BadParameter("no TAG cache file specified") + } + + file, err := os.Open(fileLoc) + if err != nil { + return nil, trace.Wrap(err) + } + defer file.Close() + + var result azureoidc.TAGInfoCache + if err := json.NewDecoder(file).Decode(&result); err != nil { + return nil, trace.Wrap(err) + } + + return &result, nil +} + +func readData(r io.Reader, w io.Writer, message string, validate func(string) bool, errorMessage string) (string, error) { + reader := bufio.NewReader(r) + for { + fmt.Fprintf(w, "%s: ", message) + input, _ := reader.ReadString('\n') + input = strings.TrimSpace(input) // Clean up any extra newlines or spaces + + if !validate(input) { + fmt.Fprintf(w, "%s\n", errorMessage) + continue + } + return input, nil + } +} diff --git a/tool/tctl/common/plugin/plugins_command.go b/tool/tctl/common/plugin/plugins_command.go index ba6c92f7ae5a9..df8b9eeb4ed3b 100644 --- a/tool/tctl/common/plugin/plugins_command.go +++ b/tool/tctl/common/plugin/plugins_command.go @@ -49,10 +49,11 @@ func logErrorMessage(err error) slog.Attr { } type pluginInstallArgs struct { - cmd *kingpin.CmdClause - name string - okta oktaArgs - scim scimArgs + cmd *kingpin.CmdClause + name string + okta oktaArgs + scim scimArgs + entraID entraArgs } type scimArgs struct { @@ -98,6 +99,7 @@ func (p *PluginsCommand) initInstall(parent *kingpin.CmdClause, config *servicec p.initInstallOkta(p.install.cmd) p.initInstallSCIM(p.install.cmd) + p.initInstallEntra(p.install.cmd) } func (p *PluginsCommand) initInstallSCIM(parent *kingpin.CmdClause) { @@ -200,11 +202,18 @@ func (p *PluginsCommand) Cleanup(ctx context.Context, clusterAPI *authclient.Cli type authClient interface { GetSAMLConnector(ctx context.Context, id string, withSecrets bool) (types.SAMLConnector, error) + CreateSAMLConnector(ctx context.Context, connector types.SAMLConnector) (types.SAMLConnector, error) + UpsertSAMLConnector(ctx context.Context, connector types.SAMLConnector) (types.SAMLConnector, error) + CreateIntegration(ctx context.Context, ig types.Integration) (types.Integration, error) + GetIntegration(ctx context.Context, name string) (types.Integration, error) + UpdateIntegration(ctx context.Context, ig types.Integration) (types.Integration, error) Ping(ctx context.Context) (proto.PingResponse, error) } type pluginsClient interface { CreatePlugin(ctx context.Context, in *pluginsv1.CreatePluginRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) + GetPlugin(ctx context.Context, in *pluginsv1.GetPluginRequest, opts ...grpc.CallOption) (*types.PluginV1, error) + UpdatePlugin(ctx context.Context, in *pluginsv1.UpdatePluginRequest, opts ...grpc.CallOption) (*types.PluginV1, error) } type installPluginArgs struct { @@ -310,6 +319,9 @@ func (p *PluginsCommand) TryRun(ctx context.Context, cmd string, client *authcli err = p.InstallOkta(ctx, args) case p.install.scim.cmd.FullCommand(): err = p.InstallSCIM(ctx, client) + case p.install.entraID.cmd.FullCommand(): + args := installPluginArgs{authClient: client, plugins: client.PluginsClient()} + err = p.InstallEntra(ctx, args) case p.delete.cmd.FullCommand(): err = p.Delete(ctx, client) default: diff --git a/tool/tctl/common/plugin/plugins_command_test.go b/tool/tctl/common/plugin/plugins_command_test.go index e42f21e26310f..9033311f3272c 100644 --- a/tool/tctl/common/plugin/plugins_command_test.go +++ b/tool/tctl/common/plugin/plugins_command_test.go @@ -449,6 +449,16 @@ func (m *mockPluginsClient) CreatePlugin(ctx context.Context, in *pluginsv1.Crea return result.Get(0).(*emptypb.Empty), result.Error(1) } +func (m *mockPluginsClient) GetPlugin(ctx context.Context, in *pluginsv1.GetPluginRequest, opts ...grpc.CallOption) (*types.PluginV1, error) { + result := m.Called(ctx, in, opts) + return result.Get(0).(*types.PluginV1), result.Error(1) +} + +func (m *mockPluginsClient) UpdatePlugin(ctx context.Context, in *pluginsv1.UpdatePluginRequest, opts ...grpc.CallOption) (*types.PluginV1, error) { + result := m.Called(ctx, in, opts) + return result.Get(0).(*types.PluginV1), result.Error(1) +} + type mockAuthClient struct { mock.Mock } @@ -457,6 +467,27 @@ func (m *mockAuthClient) GetSAMLConnector(ctx context.Context, id string, withSe result := m.Called(ctx, id, withSecrets) return result.Get(0).(types.SAMLConnector), result.Error(1) } +func (m *mockAuthClient) CreateSAMLConnector(ctx context.Context, connector types.SAMLConnector) (types.SAMLConnector, error) { + result := m.Called(ctx, connector) + return result.Get(0).(types.SAMLConnector), result.Error(1) +} +func (m *mockAuthClient) UpsertSAMLConnector(ctx context.Context, connector types.SAMLConnector) (types.SAMLConnector, error) { + result := m.Called(ctx, connector) + return result.Get(0).(types.SAMLConnector), result.Error(1) +} +func (m *mockAuthClient) CreateIntegration(ctx context.Context, ig types.Integration) (types.Integration, error) { + result := m.Called(ctx, ig) + return result.Get(0).(types.Integration), result.Error(1) +} +func (m *mockAuthClient) UpdateIntegration(ctx context.Context, ig types.Integration) (types.Integration, error) { + result := m.Called(ctx, ig) + return result.Get(0).(types.Integration), result.Error(1) +} + +func (m *mockAuthClient) GetIntegration(ctx context.Context, name string) (types.Integration, error) { + result := m.Called(ctx, name) + return result.Get(0).(types.Integration), result.Error(1) +} func (m *mockAuthClient) Ping(ctx context.Context) (proto.PingResponse, error) { result := m.Called(ctx) diff --git a/tool/teleport/common/integration_configure.go b/tool/teleport/common/integration_configure.go index b79d285010dab..7edb6ad0e810d 100644 --- a/tool/teleport/common/integration_configure.go +++ b/tool/teleport/common/integration_configure.go @@ -250,7 +250,7 @@ func onIntegrationConfAzureOIDCCmd(ctx context.Context, params config.Integratio fmt.Println("Teleport is setting up the Azure integration. This may take a few minutes.") - appID, tenantID, err := azureoidc.SetupEnterpriseApp(ctx, params.ProxyPublicAddr, params.AuthConnectorName) + appID, tenantID, err := azureoidc.SetupEnterpriseApp(ctx, params.ProxyPublicAddr, params.AuthConnectorName, params.SkipOIDCConfiguration) if err != nil { return trace.Wrap(err) } diff --git a/tool/teleport/common/teleport.go b/tool/teleport/common/teleport.go index 3ef745f4afc0e..6ab4e4b0e7af2 100644 --- a/tool/teleport/common/teleport.go +++ b/tool/teleport/common/teleport.go @@ -551,6 +551,7 @@ func Run(options Options) (app *kingpin.Application, executedCommand string, con integrationConfAzureOIDCCmd.Flag("proxy-public-addr", "The public address of Teleport Proxy Service").Required().StringVar(&ccf.IntegrationConfAzureOIDCArguments.ProxyPublicAddr) integrationConfAzureOIDCCmd.Flag("auth-connector-name", "The name of Entra ID SAML Auth connector in Teleport.").Required().StringVar(&ccf.IntegrationConfAzureOIDCArguments.AuthConnectorName) integrationConfAzureOIDCCmd.Flag("access-graph", "Enable Access Graph integration.").BoolVar(&ccf.IntegrationConfAzureOIDCArguments.AccessGraphEnabled) + integrationConfAzureOIDCCmd.Flag("skip-oidc-integration", "Skip OIDC integration.").BoolVar(&ccf.IntegrationConfAzureOIDCArguments.SkipOIDCConfiguration) integrationConfSAMLIdP := integrationConfigureCmd.Command("samlidp", "Manage SAML IdP integrations.") integrationSAMLIdPGCPWorkforce := integrationConfSAMLIdP.Command("gcp-workforce", "Configures GCP Workforce Identity Federation pool and SAML provider.")