Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions api/proto/teleport/legacy/types/types.proto
Original file line number Diff line number Diff line change
Expand Up @@ -6010,6 +6010,9 @@ message PluginEntraIDSyncSettings {

// DefaultOwners are the default owners for all imported access lists.
repeated string default_owners = 1;

// SSOConnectorID is the name of the Teleport SSO connector created and used by the Entra ID plugin
string sso_connector_id = 2;
}

// AccessGraphSettings controls settings for syncing access graph specific data.
Expand Down
3 changes: 3 additions & 0 deletions api/types/plugin.go
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,9 @@ func (c *PluginEntraIDSettings) Validate() error {
if len(c.SyncSettings.DefaultOwners) == 0 {
return trace.BadParameter("sync_settings.default_owners must be set")
}
if c.SyncSettings.SsoConnectorId == "" {
return trace.BadParameter("sync_settings.sso_connector_id must be set")
}

return nil
}
Expand Down
10 changes: 9 additions & 1 deletion api/types/plugin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -855,7 +855,8 @@ func TestPluginEntraIDValidation(t *testing.T) {
return &PluginSpecV1_EntraId{
EntraId: &PluginEntraIDSettings{
SyncSettings: &PluginEntraIDSyncSettings{
DefaultOwners: []string{"admin"},
DefaultOwners: []string{"admin"},
SsoConnectorId: "myconnector",
},
},
}
Expand Down Expand Up @@ -891,6 +892,13 @@ func TestPluginEntraIDValidation(t *testing.T) {
},
assertErr: requireNamedBadParameterError("sync_settings.default_owners"),
},
{
name: "missing sso connector name",
mutateSettings: func(s *PluginSpecV1_EntraId) {
s.EntraId.SyncSettings.SsoConnectorId = ""
},
assertErr: requireNamedBadParameterError("sync_settings.sso_connector_id"),
},
}

for _, tc := range testCases {
Expand Down
571 changes: 310 additions & 261 deletions api/types/types.pb.go

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions lib/config/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,10 @@ type CommandLineFlags struct {
// `teleport integration configure access-graph aws-iam` command
IntegrationConfAccessGraphAWSSyncArguments IntegrationConfAccessGraphAWSSync

// IntegrationConfAzureOIDCArguments contains the arguments of
// `teleport integration configure azure-oidc` command
IntegrationConfAzureOIDCArguments IntegrationConfAzureOIDC

// IntegrationConfSAMLIdPGCPWorkforceArguments contains the arguments of
// `teleport integration configure samlidp gcp-workforce` command
IntegrationConfSAMLIdPGCPWorkforceArguments samlidpconfig.GCPWorkforceAPIParams
Expand All @@ -263,6 +267,22 @@ type IntegrationConfAccessGraphAWSSync struct {
Role string
}

// IntegrationConfAzureOIDC contains the arguments of
// `teleport integration configure azure-oidc` command
type IntegrationConfAzureOIDC struct {
// ProxyPublicAddr is the publicly-reachable URL of the Teleport Proxy.
// It is used as the OIDC issuer URL, as well as for SAML URIs.
ProxyPublicAddr string

// AuthConnectorName is the name of the SAML connector that will be created on Teleport side.
AuthConnectorName string

// AccessGraphEnabled is a flag indicating that access graph integration is requested.
// When this is true, the integration script will produce
// a cache file necessary for TAG synchronization.
AccessGraphEnabled bool
}

// IntegrationConfDeployServiceIAM contains the arguments of
// `teleport integration configure deployservice-iam` command
type IntegrationConfDeployServiceIAM struct {
Expand Down
167 changes: 167 additions & 0 deletions lib/integrations/azureoidc/accessgraph.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
// 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 <http://www.gnu.org/licenses/>.

package azureoidc

import (
"bytes"
"compress/gzip"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"os"
"path"

"github.com/gravitational/trace"
msgraphsdk "github.com/microsoftgraph/msgraph-sdk-go"

"github.com/gravitational/teleport/api/types"
)

var errNonSSOApp = errors.New("app does not have SSO set up")

// singleSignOnMode represents the possible values for `currentSingleSignOnMode` in `adSingleSignOn`
type singleSignOnMode string

const (
// singleSignOnModeNone indicates that the application does not have SSO set up.
singleSignOnModeNone singleSignOnMode = "none" //nolint:unused // this serves as documentation of a possible value.
// singleSignOnModeFederated indicates federated SSO such as SAML.
singleSignOnModeFederated singleSignOnMode = "federated"
)

// adSingleSignOn represents the response from https://main.iam.ad.ext.azure.com/api/ApplicationSso/{servicePrincipalID}/SingleSignOn
type adSingleSignOn struct {
CurrentSingleSignOnMode singleSignOnMode `json:"currentSingleSignOnMode"`
}

// tagInfoCache is the format for the file produced by CreateTAGCacheFile.
type tagInfoCache struct {
AppSsoSettingsCache []*types.PluginEntraIDAppSSOSettings `json:"app_sso_settings_cache"`
}

// getSingleSignOn uses Azure private API to get basic information about an enterprise applications single sign on mode.
func getSingleSignOn(ctx context.Context, token string, servicePrincipalID string) (*adSingleSignOn, error) {
payload, err := privateAPIGet(ctx, token, path.Join("ApplicationSso", servicePrincipalID, "SingleSignOn"))
if err != nil {
return nil, trace.Wrap(err)
}

var result adSingleSignOn
if err := json.Unmarshal(payload, &result); err != nil {
return nil, trace.Wrap(err, "failed to deserialize SingleSignOn")
}

return &result, nil
}

// getFederatedSSOV2Compressed retrieves the FederatedSsoV2 payload for the given AppId
// and returns it as gzipped bytes.
func getFederatedSSOV2Compressed(ctx context.Context, graphClient *msgraphsdk.GraphServiceClient, appID string, token string) ([]byte, error) {
sp, err := graphClient.ServicePrincipalsWithAppId(&appID).Get(ctx, nil)
if err != nil {
return nil, trace.Wrap(err, "could not retrieve service principal")
}
spID := sp.GetId()
if spID == nil {
return nil, trace.BadParameter("service principal ID is nil")
}

sso, err := getSingleSignOn(ctx, token, *spID)
if err != nil {
return nil, trace.Wrap(err, "failed to get single sign on data for app_id %s", appID)
} else if sso.CurrentSingleSignOnMode != singleSignOnModeFederated {
return nil, trace.Wrap(errNonSSOApp)
}

federatedSSOV2, err := privateAPIGet(ctx, token, path.Join("ApplicationSso", *spID, "FederatedSsoV2"))
if err != nil {
return nil, trace.Wrap(err, "getting federated SSO v2 info failed", "error", err)
}

federatedSSOV2Compressed, err := gzipBytes(federatedSSOV2)
return federatedSSOV2Compressed, trace.Wrap(err)
}

// CreateTAGCacheFile populates a file containing the information necessary for Access Graph to analyze Azure SSO.
func CreateTAGCacheFile(ctx context.Context) error {
graphClient, err := createGraphClient()
if err != nil {
return trace.Wrap(err)
}

// Get information about enterprise apps
appResp, err := graphClient.Applications().Get(ctx, nil)
if err != nil {
return trace.Wrap(err)
}

// Authorize to the private API
tenantID, err := getTenantID()
if err != nil {
return trace.Wrap(err)
}
token, err := getPrivateAPIToken(ctx, tenantID)
if err != nil {
return trace.Wrap(err)
}

cache := &tagInfoCache{}

for _, app := range appResp.GetValue() {
appID := app.GetAppId()
if appID == nil {
slog.WarnContext(ctx, "app ID is nil", "app", app)
continue
}
federatedSSOV2Compressed, err := getFederatedSSOV2Compressed(ctx, graphClient, *appID, token)
if errors.Is(err, errNonSSOApp) {
slog.DebugContext(ctx, "sso not set up for app, will skip it", "app_id", *appID)
continue
} else if err != nil {
slog.WarnContext(ctx, "failed to retrieve SSO info", "app_id", *appID, "error", err)
}
cache.AppSsoSettingsCache = append(cache.AppSsoSettingsCache, &types.PluginEntraIDAppSSOSettings{
AppId: *appID,
FederatedSsoV2: federatedSSOV2Compressed,
})
}

payload, err := json.Marshal(cache)
if err != nil {
return trace.Wrap(err)
}
return trace.Wrap(os.WriteFile("cache.json", payload, 0600), "failed to write the TAG cache file")
}

// gzipBytes compresses the given byte slice, returning the result as a new byte slice.
func gzipBytes(src []byte) ([]byte, error) {
out := new(bytes.Buffer)
writer := gzip.NewWriter(out)

_, err := io.Copy(writer, bytes.NewReader(src))
if err != nil {
return nil, trace.Wrap(err)
}

err = writer.Close()
if err != nil {
return nil, trace.Wrap(err)
}
return out.Bytes(), nil
}
Loading