Skip to content
Open
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
2 changes: 1 addition & 1 deletion cmd/baton-tableau/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ func getConnector(ctx context.Context, tc *cfg.Tableau) (types.ConnectorServer,
return nil, err
}

apiVersion := "3.19"
apiVersion := "3.27"
if tc.ApiVersion != "" {
apiVersion = tc.ApiVersion
}
Expand Down
24 changes: 23 additions & 1 deletion pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,14 +88,36 @@ func (tb *Tableau) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error)
"siteRole": {
DisplayName: "Site Role",
Required: true,
Description: `The role to assign to the user on the site. Possible values are:
Description: `The role to assign to the user on the site. Possible values are:
Creator, Explorer, ExplorerCanPublish, SiteAdministratorExplorer, SiteAdministratorCreator, Unlicensed, or Viewer.`,
Field: &v2.ConnectorAccountCreationSchema_Field_StringField{
StringField: &v2.ConnectorAccountCreationSchema_StringField{},
},
Placeholder: "Site Role",
Order: 2,
},
"withMFA": {
DisplayName: "With MFA",
Required: false,
Description: `If true, creates users with TableauIDWithMFA authentication instead of using the site's IDP configuration (SAML). Defaults to false.`,
Field: &v2.ConnectorAccountCreationSchema_Field_BoolField{
BoolField: &v2.ConnectorAccountCreationSchema_BoolField{},
},
Order: 3,
},
"idpConfigurationName": {
DisplayName: "IDP Configuration Name",
Required: false,
Description: `The name of the SAML IDP configuration to use for user authentication.
Only required when multiple SAML IDP configurations are enabled on the site.
If only one SAML IDP exists, it will be used automatically.
If no SAML IDPs exist, set withMFA=true instead.`,
Field: &v2.ConnectorAccountCreationSchema_Field_StringField{
StringField: &v2.ConnectorAccountCreationSchema_StringField{},
},
Placeholder: "IDP Configuration Name",
Order: 4,
},
},
},
}, nil
Expand Down
89 changes: 83 additions & 6 deletions pkg/connector/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package connector

import (
"context"
"errors"
"fmt"
"strings"

Expand All @@ -10,7 +11,9 @@ import (
"github.com/conductorone/baton-sdk/pkg/connectorbuilder"
"github.com/conductorone/baton-sdk/pkg/pagination"
rs "github.com/conductorone/baton-sdk/pkg/types/resource"
"github.com/conductorone/baton-sdk/pkg/uhttp"
"github.com/conductorone/baton-tableau/pkg/tableau"
"google.golang.org/grpc/codes"
)

var _ connectorbuilder.AccountManager = &userResourceType{}
Expand All @@ -37,10 +40,11 @@ func userResource(user *tableau.User, parentResourceID *v2.ResourceId) (*v2.Reso
}

profile := map[string]interface{}{
"first_name": firstName,
"last_name": lastName,
"login": user.Email,
"user_id": user.ID,
"first_name": firstName,
"last_name": lastName,
"login": user.Email,
"user_id": user.ID,
"auth_setting": user.AuthSetting,
}

userTraitOptions := []rs.UserTraitOption{
Expand Down Expand Up @@ -121,9 +125,26 @@ func (o *userResourceType) CreateAccount(ctx context.Context, accountInfo *v2.Ac
return nil, nil, nil, fmt.Errorf("baton-tableau: siteRole not found in profile")
}

withMFA, _ := pMap["withMFA"].(bool)

var authSetting string
var idpConfigId string

if withMFA {
authSetting = "TableauIDWithMFA"
} else {
id, err := o.selectIDPConfiguration(ctx, pMap)
if err != nil {
return nil, nil, nil, err
}
idpConfigId = id
}

user, err := o.client.AddUserToSite(ctx, tableau.CreateUserRequest{
Email: email,
SiteRole: siteRole,
Email: email,
SiteRole: siteRole,
AuthSetting: authSetting,
IdpConfigurationId: idpConfigId,
})
if err != nil {
return nil, nil, nil, fmt.Errorf("baton-tableau: failed to create user %s: %w", email, err)
Expand All @@ -139,6 +160,41 @@ func (o *userResourceType) CreateAccount(ctx context.Context, accountInfo *v2.Ac
}, nil, nil, nil
}

func (o *userResourceType) selectIDPConfiguration(ctx context.Context, pMap map[string]interface{}) (string, error) {
idpConfigName, _ := pMap["idpConfigurationName"].(string)

idpConfigs, err := o.client.ListIdpConfigurations(ctx)
if err != nil {
return "", fmt.Errorf("baton-tableau: failed to list IDP configurations: %w", err)
}

var enabledSAMLConfigs []tableau.IdpConfiguration
for i := range idpConfigs {
if idpConfigs[i].AuthSetting == "SAML" && idpConfigs[i].Enabled {
enabledSAMLConfigs = append(enabledSAMLConfigs, idpConfigs[i])
}
}

if len(enabledSAMLConfigs) == 0 {
return "", fmt.Errorf("baton-tableau: you need to pass the MFA flag since no IDP is configured in Tableau")
}

if len(enabledSAMLConfigs) == 1 {
return enabledSAMLConfigs[0].IdpConfigurationId, nil
}

if idpConfigName == "" {
return "", uhttp.WrapErrors(codes.InvalidArgument, "multiple SAML IDPs available", buildMultipleIDPError(enabledSAMLConfigs))
}

selectedConfig, err := findIDPByName(enabledSAMLConfigs, idpConfigName)
if err != nil {
return "", uhttp.WrapErrors(codes.InvalidArgument, fmt.Sprintf("IDP configuration '%s' not found", idpConfigName), buildMultipleIDPError(enabledSAMLConfigs))
}

return selectedConfig.IdpConfigurationId, nil
Comment on lines +178 to +195
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Honor explicit idpConfigurationName even when only one config exists.
If a caller provides idpConfigurationName but there’s only one enabled SAML config, the current logic ignores the name and silently selects the only config. That can mask typos or misconfigurations.

🔧 Suggested adjustment
-	if len(enabledSAMLConfigs) == 1 {
-		return enabledSAMLConfigs[0].IdpConfigurationId, nil
-	}
-
-	if idpConfigName == "" {
-		return "", uhttp.WrapErrors(codes.InvalidArgument, "multiple SAML IDPs available", buildMultipleIDPError(enabledSAMLConfigs))
-	}
-
-	selectedConfig, err := findIDPByName(enabledSAMLConfigs, idpConfigName)
-	if err != nil {
-		return "", uhttp.WrapErrors(codes.InvalidArgument, fmt.Sprintf("IDP configuration '%s' not found", idpConfigName), buildMultipleIDPError(enabledSAMLConfigs))
-	}
-
-	return selectedConfig.IdpConfigurationId, nil
+	if idpConfigName != "" {
+		selectedConfig, err := findIDPByName(enabledSAMLConfigs, idpConfigName)
+		if err != nil {
+			return "", uhttp.WrapErrors(codes.InvalidArgument, fmt.Sprintf("IDP configuration '%s' not found", idpConfigName), buildMultipleIDPError(enabledSAMLConfigs))
+		}
+		return selectedConfig.IdpConfigurationId, nil
+	}
+
+	if len(enabledSAMLConfigs) == 1 {
+		return enabledSAMLConfigs[0].IdpConfigurationId, nil
+	}
+
+	return "", uhttp.WrapErrors(codes.InvalidArgument, "multiple SAML IDPs available", buildMultipleIDPError(enabledSAMLConfigs))
🤖 Prompt for AI Agents
In `@pkg/connector/user.go` around lines 178 - 195, The code currently returns the
sole enabledSAMLConfigs[0].IdpConfigurationId without honoring idpConfigName;
change the logic in the selection block so that when len(enabledSAMLConfigs) ==
1 you still check idpConfigName: if idpConfigName is empty, return
enabledSAMLConfigs[0].IdpConfigurationId, otherwise call
findIDPByName(enabledSAMLConfigs, idpConfigName) and return an InvalidArgument
error (use uhttp.WrapErrors with buildMultipleIDPError) if the name doesn't
match, otherwise return the matched selectedConfig.IdpConfigurationId; this
preserves current behavior for empty names but surfaces typos/misconfigurations
when a name is provided.

}

func (o *userResourceType) Delete(ctx context.Context, resourceId *v2.ResourceId) (annotations.Annotations, error) {
userID := resourceId.Resource
err := o.client.RemoveUserFromSite(ctx, userID)
Expand All @@ -155,3 +211,24 @@ func userBuilder(client *tableau.Client) *userResourceType {
client: client,
}
}

func findIDPByName(configs []tableau.IdpConfiguration, name string) (*tableau.IdpConfiguration, error) {
lowerName := strings.ToLower(name)
for i := range configs {
if strings.ToLower(configs[i].IdpConfigurationName) == lowerName {
return &configs[i], nil
}
}
return nil, fmt.Errorf("IDP configuration with name '%s' not found", name)
}

func buildMultipleIDPError(configs []tableau.IdpConfiguration) error {
msg := fmt.Sprintf(`baton-tableau: multiple SAML IDP configurations found (%d available). Please specify idpConfigurationName in the account profile. Available IDPs:
`, len(configs))

for _, config := range configs {
msg += fmt.Sprintf(" - \"%s\" (ID: %s)\n", config.IdpConfigurationName, config.IdpConfigurationId)
}

return errors.New(msg)
}
31 changes: 27 additions & 4 deletions pkg/tableau/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -400,11 +400,19 @@ func (c *Client) AddUserToSite(ctx context.Context, user CreateUserRequest) (*Us
User *User `json:"user"`
}

userMap := map[string]interface{}{
"name": user.Email,
"siteRole": user.SiteRole,
}

if user.IdpConfigurationId != "" {
userMap["idpConfigurationId"] = user.IdpConfigurationId
} else if user.AuthSetting != "" {
userMap["authSetting"] = user.AuthSetting
}

requestBody, err := json.Marshal(map[string]interface{}{
"user": map[string]interface{}{
"name": user.Email,
"siteRole": user.SiteRole,
},
"user": userMap,
})

if err != nil {
Expand Down Expand Up @@ -457,6 +465,21 @@ func (c *Client) UpdateUserSiteRole(ctx context.Context, userId string, siteRole
return nil
}

func (c *Client) ListIdpConfigurations(ctx context.Context) ([]IdpConfiguration, error) {
url := fmt.Sprint(c.baseUrl, "/sites/", c.siteId, "/site-auth-configurations")
var res struct {
SiteAuthConfigurations struct {
SiteAuthConfiguration []IdpConfiguration `json:"siteAuthConfiguration"`
} `json:"siteAuthConfigurations"`
}

if err := c.doRequest(ctx, url, &res, nil, nil, http.MethodGet); err != nil {
return nil, err
}

return res.SiteAuthConfigurations.SiteAuthConfiguration, nil
}

func buildResourceURL(baseURL string, endpoint string, elems ...string) (string, error) {
joined, err := url.JoinPath(baseURL, append([]string{endpoint}, elems...)...)
if err != nil {
Expand Down
26 changes: 18 additions & 8 deletions pkg/tableau/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,28 @@ type Site struct {
}

type User struct {
Email string `json:"email"`
ID string `json:"id"`
FullName string `json:"fullName"`
Name string `json:"name"`
SiteRole string `json:"siteRole"`
LastLogin time.Time `json:"lastLogin"`
Email string `json:"email"`
ID string `json:"id"`
FullName string `json:"fullName"`
Name string `json:"name"`
SiteRole string `json:"siteRole"`
AuthSetting string `json:"authSetting"`
LastLogin time.Time `json:"lastLogin"`
}

type CreateUserRequest struct {
// has to be name in the payload
Email string `json:"name"`
SiteRole string `json:"siteRole"`
Email string `json:"name"`
SiteRole string `json:"siteRole"`
AuthSetting string `json:"authSetting,omitempty"`
IdpConfigurationId string `json:"idpConfigurationId,omitempty"`
}

type IdpConfiguration struct {
IdpConfigurationId string `json:"idpConfigurationId"`
IdpConfigurationName string `json:"idpConfigurationName"`
AuthSetting string `json:"authSetting"`
Enabled bool `json:"enabled"`
}

type Group struct {
Expand Down