From 50ac4d882c8588ceece2824b94b3dab1ac50c5fa Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Tue, 11 Aug 2026 18:25:00 +0200 Subject: [PATCH 1/5] Support connected apps as an alternative to personal access tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A personal access token belongs to an individual, so the connector inherits that account's lifecycle, and Tableau expires every token eventually — after 1 to 365 days by site policy, and after 15 consecutive days of non-use. There is no configuration in which the credential survives indefinitely. Direct trust connected apps are a site-level trust instead. The connector signs a short-lived HS256 assertion per sign-in and passes it to /auth/signin as the jwt credential, so rotation becomes a deliberate act against a site object rather than a surprise outage. Credentials are now one complete set or the other, enforced by schema constraints so an ambiguous or half-filled configuration fails before it reaches Tableau. Existing personal access token deployments validate unchanged. The assertion encoder is thirty lines of HMAC rather than a dependency, since nothing here ever verifies a token. Refs #51 --- README.md | 29 +++++- config_schema.json | 75 +++++++++++++--- docs/connector.mdx | 38 +++++++- pkg/client/client.go | 89 ++++++++++++++----- pkg/client/jwt.go | 120 +++++++++++++++++++++++++ pkg/client/jwt_test.go | 110 +++++++++++++++++++++++ pkg/client/login_test.go | 131 +++++++++++++++++++++++++++ pkg/config/conf.gen.go | 4 + pkg/config/config.go | 51 ++++++++++- pkg/config/config_test.go | 132 ++++++++++++++++++++++++++++ pkg/connector/client_config_test.go | 55 ++++++++++++ pkg/connector/connector.go | 31 ++++++- 12 files changed, 821 insertions(+), 44 deletions(-) create mode 100644 pkg/client/jwt.go create mode 100644 pkg/client/jwt_test.go create mode 100644 pkg/client/login_test.go create mode 100644 pkg/config/config_test.go create mode 100644 pkg/connector/client_config_test.go diff --git a/README.md b/README.md index 9f645f6e..4b78664e 100644 --- a/README.md +++ b/README.md @@ -86,16 +86,41 @@ baton resources > **Important**: The user account must have **Site Administrator Explorer** (read-only sync) or **Site Administrator Creator** (sync + provisioning) role. PAT creation must be enabled by a site administrator. +> **Note**: A PAT belongs to an individual, so the connector inherits that account's lifecycle. Tableau also expires every PAT — after 1 to 365 days depending on site settings, and after 15 consecutive days of non-use. For a permanent integration, prefer a connected app. + **Documentation:** - Tableau Cloud: https://help.tableau.com/current/online/en-us/security_personal_access_tokens.htm - Tableau Server: https://help.tableau.com/current/server/en-us/security_personal_access_tokens.htm +## Connected app (direct trust) + +Tableau Cloud only, October 2023 and later. A connected app is a site-level trust rather than a user's token, so its secret is rotated deliberately instead of expiring underneath you. + +1. Sign in as a site administrator and go to **Settings** > **Connected Apps** +2. Click **New Connected App** > **Direct Trust**, name it, and click **Create** +3. Copy the **Client ID**, then generate a secret and copy its **Secret ID** and **Secret Value** — the value is displayed only once +4. Enable the app and grant these access scopes: + `tableau:content:read`, `tableau:sites:read`, `tableau:users:*`, `tableau:groups:*`, `tableau:projects:*`, `tableau:workbooks:*`, `tableau:permissions:update`, `tableau:permissions:delete` +5. Choose the Tableau user the connector acts as, and note its email address + +> **Important**: A missing scope does not fail at sign-in. It surfaces as a 403 partway through a sync or a provisioning action, so grant the full set. The acting user needs the same site administrator role a PAT owner would. + +> **Known gap**: Tableau publishes no scope covering site authentication configurations, so IDP discovery during account creation may fail under a connected app where it succeeds under a PAT. The connector treats that lookup as non-fatal and falls back to the site's default authentication setting. + +**Documentation:** https://help.tableau.com/current/online/en-us/connected_apps_direct.htm + ## Configuration Flags +Authenticate with **either** a personal access token **or** a connected app, never both. Each set is all-or-nothing; a partial or mixed configuration is rejected before the connector contacts Tableau. + | Flag | Required | Description | |------|----------|-------------| -| `--access-token-name` | Yes | Name of the Personal Access Token | -| `--access-token-secret` | Yes | Secret value of the Personal Access Token | +| `--access-token-name` | With PAT | Name of the Personal Access Token. Tableau treats the name and secret as a pair, so a stale name alongside a fresh secret fails exactly as an expired token does | +| `--access-token-secret` | With PAT | Secret value of the Personal Access Token | +| `--connected-app-client-id` | With connected app | Client ID of a direct trust connected app | +| `--connected-app-secret-id` | With connected app | Secret ID of the connected app | +| `--connected-app-secret-value` | With connected app | Secret value of the connected app | +| `--connected-app-username` | With connected app | Email address of the Tableau user the connector acts as | | `--server-path` | Yes | Base URL **without** `/api/` suffix. Examples: `us-east-1.online.tableau.com` (Cloud), `your-server-hostname` (Server) | | `--site-id` | No | Content URL of the site (e.g., `mycompany`). Can be found after `/site/` in the browser URL. Leave empty for the default site on Tableau Server | | `--api-version` | No | Tableau REST API version (default: `3.27`). Can be changed to match your server's supported version — see [API version reference](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_versions.htm) | diff --git a/config_schema.json b/config_schema.json index 11083238..f8c6ac64 100644 --- a/config_schema.json +++ b/config_schema.json @@ -95,25 +95,40 @@ { "name": "access-token-name", "displayName": "Access Token Name", - "description": "Access token name used to connect to the Tableau API", - "isRequired": true, - "stringField": { - "rules": { - "isRequired": true - } - } + "description": "Access token name used to connect to the Tableau API. Required unless connected app credentials are supplied", + "stringField": {} }, { "name": "access-token-secret", "displayName": "Access Token Secret", - "description": "Access token secret used to connect to the Tableau API", - "isRequired": true, + "description": "Access token secret used to connect to the Tableau API. Required unless connected app credentials are supplied", "isSecret": true, - "stringField": { - "rules": { - "isRequired": true - } - } + "stringField": {} + }, + { + "name": "connected-app-client-id", + "displayName": "Connected App Client ID", + "description": "Client ID of a Tableau connected app configured for direct trust. Use instead of a personal access token", + "stringField": {} + }, + { + "name": "connected-app-secret-id", + "displayName": "Connected App Secret ID", + "description": "Secret ID of the Tableau connected app", + "stringField": {} + }, + { + "name": "connected-app-secret-value", + "displayName": "Connected App Secret Value", + "description": "Secret value of the Tableau connected app", + "isSecret": true, + "stringField": {} + }, + { + "name": "connected-app-username", + "displayName": "Connected App Username", + "description": "Email address of the Tableau user the connector acts as. Needs the same site administrator rights as a personal access token owner", + "stringField": {} }, { "name": "server-path", @@ -141,6 +156,38 @@ } } ], + "constraints": [ + { + "kind": "CONSTRAINT_KIND_REQUIRED_TOGETHER", + "fieldNames": [ + "access-token-name", + "access-token-secret" + ] + }, + { + "kind": "CONSTRAINT_KIND_REQUIRED_TOGETHER", + "fieldNames": [ + "connected-app-client-id", + "connected-app-secret-id", + "connected-app-secret-value", + "connected-app-username" + ] + }, + { + "kind": "CONSTRAINT_KIND_AT_LEAST_ONE", + "fieldNames": [ + "access-token-name", + "connected-app-client-id" + ] + }, + { + "kind": "CONSTRAINT_KIND_MUTUALLY_EXCLUSIVE", + "fieldNames": [ + "access-token-name", + "connected-app-client-id" + ] + } + ], "displayName": "Tableau", "helpUrl": "/docs/baton/tableau", "iconUrl": "/static/app-icons/tableau.svg" diff --git a/docs/connector.mdx b/docs/connector.mdx index 44107359..fd520265 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -66,10 +66,42 @@ In the menu bar at the top of the page, click your profile image or initials and In the **Personal Access Tokens** area of the page, enter a name for your new token (such as "C1 integration") and then click **Create**. -Carefully copy and save the newly generated token and its name. +Carefully copy and save the newly generated token and its name. Tableau treats the name and the secret as a pair, so both are required and both must belong to the same token. +### Or set up a connected app + +A personal access token belongs to an individual, so the connector inherits that person's account lifecycle. Tableau also expires every token eventually — after 1 to 365 days depending on your site settings, and after 15 consecutive days of non-use. A connected app is a site-level trust instead, so its secret is rotated deliberately rather than expiring underneath you. + +Configure either a personal access token or a connected app, not both. + + + +Sign into Tableau Cloud as a site administrator and navigate to **Settings** > **Connected Apps**. + + +Click **New Connected App** > **Direct Trust**, give it a name, and click **Create**. + + +Copy the **Client ID**. Then generate a secret and copy both its **Secret ID** and **Secret Value**. The secret value is shown only once. + + +Enable the connected app and grant it these access scopes: + +`tableau:content:read`, `tableau:sites:read`, `tableau:users:*`, `tableau:groups:*`, `tableau:projects:*`, `tableau:workbooks:*`, `tableau:permissions:update`, `tableau:permissions:delete` + +A missing scope does not fail at sign-in. It surfaces later as a 403 partway through a sync or a provisioning action, so grant the full set. + + +Decide which Tableau user the connector acts as, usually a service account. It needs the same site administrator rights a personal access token owner would need. Save its email address. + + + + +Tableau publishes no access scope covering site authentication configurations. Under a connected app, IDP discovery during account creation may fail where it would succeed under a personal access token. The connector already treats that lookup as non-fatal and falls back to the site's default authentication setting. + + ### Locate your server path and site ID @@ -133,10 +165,10 @@ Find the **Settings** area of the page and click **Edit**. Enter the site ID and server path into the **Site ID** and **Server path** fields. -Enter the name of the personal access token into the **Access token name** field. +If you are using a personal access token, enter its name into the **Access token name** field and its value into the **Access token secret** field. -Enter the personal access token value into the **Access token secret** field. +If you are using a connected app instead, leave the access token fields empty and fill in **Connected app client ID**, **Connected app secret ID**, **Connected app secret value**, and **Connected app username**. The username is the email address of the Tableau user the connector acts as. Click **Save**. diff --git a/pkg/client/client.go b/pkg/client/client.go index 12c638f5..5631427e 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -3,7 +3,7 @@ // API Endpoints Used: // // Authentication: -// - POST /api/{version}/auth/signin - Sign in with Personal Access Token +// - POST /api/{version}/auth/signin - Sign in with a personal access token or connected app JWT // // Sites: // - GET /api/{version}/sites/{siteId} - Get site details @@ -54,7 +54,7 @@ // - GET /api/{version}/sites/{siteId}/site-auth-configurations - List IDP configurations // // Authentication: -// - Personal Access Token (PAT) via /auth/signin, then X-Tableau-Auth header +// - Personal access token or connected app JWT via /auth/signin, then X-Tableau-Auth header // // Pagination: // - Uses pageSize and pageNumber query parameters (1-based, default pageSize=100) @@ -69,6 +69,7 @@ import ( "net/url" "strconv" "strings" + "time" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" @@ -90,16 +91,38 @@ type Client struct { baseUrl string } +// Config describes how to reach a Tableau site and how to authenticate to it. +// Exactly one of PersonalAccessToken or ConnectedApp must be set. +type Config struct { + ServerPath string + SiteID string + APIVersion string + + // BaseURLOverride, when non-empty, is used directly instead of being built + // from ServerPath and APIVersion. This is intended for testability (e.g. + // pointing the connector at a local test server). + BaseURLOverride string + + PersonalAccessToken *PersonalAccessToken + ConnectedApp *ConnectedApp +} + +// PersonalAccessToken holds a Tableau personal access token. Tableau treats +// the name and secret as a pair, so a stale name alongside a fresh secret +// fails in exactly the way an expired token does. +type PersonalAccessToken struct { + Name string + Secret string +} + // New creates an authenticated Tableau API client. -// If baseURLOverride is non-empty, it is used directly instead of building from serverPath/apiVersion. -// This is intended for testability (e.g. pointing the connector at a local test server). -func New(ctx context.Context, serverPath, siteID, accessTokenName, accessTokenSecret, apiVersion, baseURLOverride string) (*Client, error) { +func New(ctx context.Context, cfg Config) (*Client, error) { var baseURL string - if baseURLOverride != "" { - baseURL = baseURLOverride + if cfg.BaseURLOverride != "" { + baseURL = cfg.BaseURLOverride } else { var err error - baseURL, err = BuildBaseURL(serverPath, apiVersion) + baseURL, err = BuildBaseURL(cfg.ServerPath, cfg.APIVersion) if err != nil { return nil, fmt.Errorf("failed to build base URL: %w", err) } @@ -110,7 +133,7 @@ func New(ctx context.Context, serverPath, siteID, accessTokenName, accessTokenSe return nil, fmt.Errorf("failed to create http client: %w", err) } - credentials, err := Login(ctx, httpClient, baseURL, siteID, accessTokenSecret, accessTokenName) + credentials, err := Login(ctx, httpClient, baseURL, cfg) if err != nil { return nil, fmt.Errorf("failed to authenticate: %w", err) } @@ -132,22 +155,22 @@ func New(ctx context.Context, serverPath, siteID, accessTokenName, accessTokenSe // Authentication // ============================================================================= -// Login authenticates with a Personal Access Token and returns session credentials. -func Login(ctx context.Context, httpClient *http.Client, baseUrl, contentUrl, accessToken, tokenName string) (*Credentials, error) { +// Login exchanges the configured credential for a Tableau session and returns +// the resulting credentials. Both credential types post to the same endpoint +// and differ only in the attributes carried on the credentials object. +func Login(ctx context.Context, httpClient *http.Client, baseUrl string, cfg Config) (*Credentials, error) { loginURL, err := url.JoinPath(baseUrl, authSignin) if err != nil { return nil, fmt.Errorf("failed to build login URL: %w", err) } - body, err := json.Marshal(map[string]any{ - "credentials": map[string]any{ - "personalAccessTokenName": tokenName, - "personalAccessTokenSecret": accessToken, - "site": map[string]string{ - "contentUrl": contentUrl, - }, - }, - }) + credentials, err := loginCredentials(cfg) + if err != nil { + return nil, err + } + credentials["site"] = map[string]string{"contentUrl": cfg.SiteID} + + body, err := json.Marshal(map[string]any{"credentials": credentials}) if err != nil { return nil, fmt.Errorf("failed to marshal login body: %w", err) } @@ -183,6 +206,32 @@ func Login(ctx context.Context, httpClient *http.Client, baseUrl, contentUrl, ac return &res.Credentials, nil } +// loginCredentials builds the credential attributes for the sign-in request. +// Requiring exactly one credential here keeps the ambiguity out of the request +// rather than letting Tableau arbitrate between two sets. +func loginCredentials(cfg Config) (map[string]any, error) { + switch { + case cfg.PersonalAccessToken != nil && cfg.ConnectedApp != nil: + return nil, fmt.Errorf("both a personal access token and connected app credentials were supplied; use one or the other") + + case cfg.PersonalAccessToken != nil: + return map[string]any{ + "personalAccessTokenName": cfg.PersonalAccessToken.Name, + "personalAccessTokenSecret": cfg.PersonalAccessToken.Secret, + }, nil + + case cfg.ConnectedApp != nil: + assertion, err := newConnectedAppJWT(*cfg.ConnectedApp, time.Now()) + if err != nil { + return nil, err + } + return map[string]any{"jwt": assertion}, nil + + default: + return nil, fmt.Errorf("no credentials supplied; set either a personal access token or connected app credentials") + } +} + // ============================================================================= // Site API // ============================================================================= diff --git a/pkg/client/jwt.go b/pkg/client/jwt.go new file mode 100644 index 00000000..3d26d44f --- /dev/null +++ b/pkg/client/jwt.go @@ -0,0 +1,120 @@ +package client + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "time" +) + +// Tableau connected apps sign their JWTs with HS256 and nothing else, and the +// signature covers only the two base64url segments. Reaching for a JWT library +// to emit thirty bytes of HMAC would pull a dependency into the module graph +// for a code path that never verifies a token, so the encoder lives here. + +// jwtLifetime is how long a generated assertion stays valid. Tableau rejects +// anything beyond ten minutes; the remainder is headroom for clock skew +// between this connector and Tableau Cloud. +const jwtLifetime = 5 * time.Minute + +// connectedAppScopes are the access scopes requested when signing in with a +// connected app. Tableau bounds the resulting session by the intersection of +// this list and the scopes enabled on the connected app itself, so the list +// must cover every endpoint this package calls: sites and content reads, the +// full user and group lifecycle for provisioning, and permission writes for +// projects, workbooks, and views. +// +// Tableau publishes no scope covering /site-auth-configurations. IDP discovery +// may therefore fail under a connected app where it succeeds under a personal +// access token; the account-creation path already treats that as non-fatal. +var connectedAppScopes = []string{ + "tableau:content:read", + "tableau:sites:read", + "tableau:users:*", + "tableau:groups:*", + "tableau:projects:*", + "tableau:workbooks:*", + "tableau:permissions:update", + "tableau:permissions:delete", +} + +// ConnectedApp holds the direct trust credentials for a Tableau connected app. +// Username is the email address of the Tableau user the connector acts as; it +// needs the same site administrator rights a personal access token owner does. +type ConnectedApp struct { + ClientID string + SecretID string + SecretValue string + Username string +} + +// newConnectedAppJWT signs a direct trust assertion for the given connected +// app. Tableau expects the issuer and secret identifier in the header rather +// than the claim set, and repeats the issuer as a claim. +func newConnectedAppJWT(app ConnectedApp, now time.Time) (string, error) { + header := map[string]any{ + "alg": "HS256", + "typ": "JWT", + "kid": app.SecretID, + "iss": app.ClientID, + } + + jti, err := newJWTID() + if err != nil { + return "", err + } + + claims := map[string]any{ + "iss": app.ClientID, + "sub": app.Username, + "aud": "tableau", + "jti": jti, + "exp": now.Add(jwtLifetime).Unix(), + "scp": connectedAppScopes, + } + + headerSegment, err := encodeJWTSegment(header) + if err != nil { + return "", fmt.Errorf("failed to encode JWT header: %w", err) + } + + claimsSegment, err := encodeJWTSegment(claims) + if err != nil { + return "", fmt.Errorf("failed to encode JWT claims: %w", err) + } + + signingInput := headerSegment + "." + claimsSegment + + mac := hmac.New(sha256.New, []byte(app.SecretValue)) + if _, err := mac.Write([]byte(signingInput)); err != nil { + return "", fmt.Errorf("failed to sign JWT: %w", err) + } + + signature := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + + return signingInput + "." + signature, nil +} + +// newJWTID returns a unique value for the jti claim. Tableau rejects replayed +// identifiers, so this must differ on every sign-in. +func newJWTID() (string, error) { + buf := make([]byte, 16) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("failed to generate JWT ID: %w", err) + } + + return hex.EncodeToString(buf), nil +} + +func encodeJWTSegment(v any) (string, error) { + encoded, err := json.Marshal(v) + if err != nil { + return "", err + } + + return base64.RawURLEncoding.EncodeToString(encoded), nil +} diff --git a/pkg/client/jwt_test.go b/pkg/client/jwt_test.go new file mode 100644 index 00000000..1a8c62d3 --- /dev/null +++ b/pkg/client/jwt_test.go @@ -0,0 +1,110 @@ +package client + +import ( + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +var testApp = ConnectedApp{ + ClientID: "11111111-2222-3333-4444-555555555555", + SecretID: "66666666-7777-8888-9999-000000000000", + SecretValue: "s3cr3t-value", + Username: "svc.tableau@example.com", +} + +func decodeSegment(t *testing.T, segment string) map[string]any { + t.Helper() + + raw, err := base64.RawURLEncoding.DecodeString(segment) + require.NoError(t, err, "segment must be unpadded base64url") + + var out map[string]any + require.NoError(t, json.Unmarshal(raw, &out)) + + return out +} + +// TestNewConnectedAppJWT_Structure asserts the header and claims Tableau +// requires for direct trust. Tableau rejects the assertion outright if the +// issuer or key identifier are missing from the header, so both are checked +// there rather than only in the claim set. +func TestNewConnectedAppJWT_Structure(t *testing.T) { + t.Parallel() + + now := time.Date(2026, 8, 11, 12, 0, 0, 0, time.UTC) + + token, err := newConnectedAppJWT(testApp, now) + require.NoError(t, err) + + parts := strings.Split(token, ".") + require.Len(t, parts, 3, "a JWT has three segments") + + header := decodeSegment(t, parts[0]) + require.Equal(t, "HS256", header["alg"]) + require.Equal(t, "JWT", header["typ"]) + require.Equal(t, testApp.SecretID, header["kid"]) + require.Equal(t, testApp.ClientID, header["iss"]) + + claims := decodeSegment(t, parts[1]) + require.Equal(t, testApp.ClientID, claims["iss"]) + require.Equal(t, testApp.Username, claims["sub"]) + require.Equal(t, "tableau", claims["aud"]) + require.NotEmpty(t, claims["jti"]) + + exp, ok := claims["exp"].(float64) + require.True(t, ok, "exp must be a numeric date") + require.Equal(t, now.Add(jwtLifetime).Unix(), int64(exp)) + require.LessOrEqual(t, int64(exp)-now.Unix(), int64(10*time.Minute/time.Second), + "Tableau rejects assertions valid for more than ten minutes") + + scopes, ok := claims["scp"].([]any) + require.True(t, ok, "scp must be a list") + require.Len(t, scopes, len(connectedAppScopes)) + require.Contains(t, scopes, "tableau:users:*") + require.Contains(t, scopes, "tableau:groups:*") +} + +// TestNewConnectedAppJWT_Signature recomputes the HMAC over the signing input +// to prove the signature covers exactly the two encoded segments. +func TestNewConnectedAppJWT_Signature(t *testing.T) { + t.Parallel() + + token, err := newConnectedAppJWT(testApp, time.Now()) + require.NoError(t, err) + + parts := strings.Split(token, ".") + require.Len(t, parts, 3) + + mac := hmac.New(sha256.New, []byte(testApp.SecretValue)) + _, err = mac.Write([]byte(parts[0] + "." + parts[1])) + require.NoError(t, err) + + want := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + require.Equal(t, want, parts[2]) +} + +// TestNewConnectedAppJWT_UniqueID guards the jti claim. Tableau rejects a +// replayed identifier, so two assertions from the same app must differ. +func TestNewConnectedAppJWT_UniqueID(t *testing.T) { + t.Parallel() + + now := time.Now() + + first, err := newConnectedAppJWT(testApp, now) + require.NoError(t, err) + + second, err := newConnectedAppJWT(testApp, now) + require.NoError(t, err) + + firstID := decodeSegment(t, strings.Split(first, ".")[1])["jti"] + secondID := decodeSegment(t, strings.Split(second, ".")[1])["jti"] + + require.NotEqual(t, firstID, secondID) +} diff --git a/pkg/client/login_test.go b/pkg/client/login_test.go new file mode 100644 index 00000000..38c09488 --- /dev/null +++ b/pkg/client/login_test.go @@ -0,0 +1,131 @@ +package client + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +const signinResponse = `{"credentials":{"token":"session-token","site":{"id":"site-id","contentUrl":"thg_plc"}}}` + +// signinRecorder stands in for Tableau's /auth/signin endpoint and captures the +// credentials object the client sent. +func signinRecorder(t *testing.T, captured *map[string]any) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.True(t, strings.HasSuffix(r.URL.Path, "/auth/signin")) + + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + + var payload struct { + Credentials map[string]any `json:"credentials"` + } + require.NoError(t, json.Unmarshal(body, &payload)) + *captured = payload.Credentials + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(signinResponse)) + })) +} + +func TestLogin_PersonalAccessToken(t *testing.T) { + t.Parallel() + + var captured map[string]any + server := signinRecorder(t, &captured) + defer server.Close() + + cfg := Config{ + SiteID: "thg_plc", + BaseURLOverride: server.URL, + PersonalAccessToken: &PersonalAccessToken{Name: "conductor1", Secret: "token-secret"}, + } + + credentials, err := Login(context.Background(), server.Client(), server.URL, cfg) + require.NoError(t, err) + require.Equal(t, "session-token", credentials.Token) + + require.Equal(t, "conductor1", captured["personalAccessTokenName"]) + require.Equal(t, "token-secret", captured["personalAccessTokenSecret"]) + require.NotContains(t, captured, "jwt") + + site, ok := captured["site"].(map[string]any) + require.True(t, ok) + require.Equal(t, "thg_plc", site["contentUrl"]) +} + +func TestLogin_ConnectedApp(t *testing.T) { + t.Parallel() + + var captured map[string]any + server := signinRecorder(t, &captured) + defer server.Close() + + cfg := Config{ + SiteID: "thg_plc", + BaseURLOverride: server.URL, + ConnectedApp: &testApp, + } + + credentials, err := Login(context.Background(), server.Client(), server.URL, cfg) + require.NoError(t, err) + require.Equal(t, "session-token", credentials.Token) + + assertion, ok := captured["jwt"].(string) + require.True(t, ok, "connected app sign-in sends a jwt attribute") + require.Len(t, strings.Split(assertion, "."), 3) + require.NotContains(t, captured, "personalAccessTokenName") + + site, ok := captured["site"].(map[string]any) + require.True(t, ok) + require.Equal(t, "thg_plc", site["contentUrl"]) +} + +// TestLoginCredentials_Ambiguous covers the two configurations the schema +// constraints are meant to prevent. Failing here keeps an ambiguous or empty +// credential from reaching Tableau as a confusing 401. +func TestLoginCredentials_Ambiguous(t *testing.T) { + t.Parallel() + + _, err := loginCredentials(Config{}) + require.ErrorContains(t, err, "no credentials supplied") + + _, err = loginCredentials(Config{ + PersonalAccessToken: &PersonalAccessToken{Name: "n", Secret: "s"}, + ConnectedApp: &testApp, + }) + require.ErrorContains(t, err, "use one or the other") +} + +// TestLogin_SurfacesTableauError asserts the upstream message survives. A +// generic failure here is what turns a dead credential into a multi-day +// outage, because the operator never learns the token was rejected. +func TestLogin_SurfacesTableauError(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"code":"401001","summary":"Signin Error","detail":"The personal access token you provided is invalid."}}`)) + })) + defer server.Close() + + cfg := Config{ + SiteID: "thg_plc", + BaseURLOverride: server.URL, + PersonalAccessToken: &PersonalAccessToken{Name: "stale", Secret: "fresh"}, + } + + _, err := Login(context.Background(), server.Client(), server.URL, cfg) + require.ErrorContains(t, err, "401") + require.ErrorContains(t, err, "personal access token you provided is invalid") +} diff --git a/pkg/config/conf.gen.go b/pkg/config/conf.gen.go index 9af6515b..f0b86ce8 100644 --- a/pkg/config/conf.gen.go +++ b/pkg/config/conf.gen.go @@ -6,6 +6,10 @@ import "reflect" type Tableau struct { AccessTokenName string `mapstructure:"access-token-name"` AccessTokenSecret string `mapstructure:"access-token-secret"` + ConnectedAppClientId string `mapstructure:"connected-app-client-id"` + ConnectedAppSecretId string `mapstructure:"connected-app-secret-id"` + ConnectedAppSecretValue string `mapstructure:"connected-app-secret-value"` + ConnectedAppUsername string `mapstructure:"connected-app-username"` ServerPath string `mapstructure:"server-path"` SiteId string `mapstructure:"site-id"` ApiVersion string `mapstructure:"api-version"` diff --git a/pkg/config/config.go b/pkg/config/config.go index 116a7a21..42e451f3 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,19 +7,42 @@ import ( var ( AccessTokenName = field.StringField( "access-token-name", - field.WithRequired(true), field.WithDisplayName("Access Token Name"), - field.WithDescription("Access token name used to connect to the Tableau API"), + field.WithDescription("Access token name used to connect to the Tableau API. Required unless connected app credentials are supplied"), ) AccessTokenSecret = field.StringField( "access-token-secret", - field.WithRequired(true), field.WithDisplayName("Access Token Secret"), - field.WithDescription("Access token secret used to connect to the Tableau API"), + field.WithDescription("Access token secret used to connect to the Tableau API. Required unless connected app credentials are supplied"), field.WithIsSecret(true), ) + ConnectedAppClientID = field.StringField( + "connected-app-client-id", + field.WithDisplayName("Connected App Client ID"), + field.WithDescription("Client ID of a Tableau connected app configured for direct trust. Use instead of a personal access token"), + ) + + ConnectedAppSecretID = field.StringField( + "connected-app-secret-id", + field.WithDisplayName("Connected App Secret ID"), + field.WithDescription("Secret ID of the Tableau connected app"), + ) + + ConnectedAppSecretValue = field.StringField( + "connected-app-secret-value", + field.WithDisplayName("Connected App Secret Value"), + field.WithDescription("Secret value of the Tableau connected app"), + field.WithIsSecret(true), + ) + + ConnectedAppUsername = field.StringField( + "connected-app-username", + field.WithDisplayName("Connected App Username"), + field.WithDescription("Email address of the Tableau user the connector acts as. Needs the same site administrator rights as a personal access token owner"), + ) + ServerPath = field.StringField( "server-path", field.WithRequired(true), @@ -51,16 +74,36 @@ var ( ConfigurationFields = []field.SchemaField{ AccessTokenName, AccessTokenSecret, + ConnectedAppClientID, + ConnectedAppSecretID, + ConnectedAppSecretValue, + ConnectedAppUsername, ServerPath, SiteID, APIVersion, BaseURL, } + + // Credentials come as one complete set or the other. Naming a single field + // from each group in the exclusivity and presence constraints is enough, + // because the two RequiredTogether rules force each group to be whole. + ConfigurationConstraints = []field.SchemaFieldRelationship{ + field.FieldsRequiredTogether(AccessTokenName, AccessTokenSecret), + field.FieldsRequiredTogether( + ConnectedAppClientID, + ConnectedAppSecretID, + ConnectedAppSecretValue, + ConnectedAppUsername, + ), + field.FieldsAtLeastOneUsed(AccessTokenName, ConnectedAppClientID), + field.FieldsMutuallyExclusive(AccessTokenName, ConnectedAppClientID), + } ) //go:generate go run -tags=generate ./gen var Config = field.NewConfiguration( ConfigurationFields, + field.WithConstraints(ConfigurationConstraints...), field.WithConnectorDisplayName("Tableau"), field.WithHelpUrl("/docs/baton/tableau"), field.WithIconUrl("/static/app-icons/tableau.svg"), diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go new file mode 100644 index 00000000..23ff9370 --- /dev/null +++ b/pkg/config/config_test.go @@ -0,0 +1,132 @@ +package config + +import ( + "testing" + + "github.com/conductorone/baton-sdk/pkg/field" + "github.com/stretchr/testify/require" +) + +// base returns a configuration with everything but credentials filled in, so +// each case below varies only the thing under test. +func base() *Tableau { + return &Tableau{ + ServerPath: "https://prod-uk-a.online.tableau.com", + SiteId: "example", + ApiVersion: "3.27", + } +} + +// TestConfigConstraints pins the credential rules. The personal access token +// case is the backward-compatibility guard: existing deployments supply only +// those two fields and must keep validating after connected apps were added. +func TestConfigConstraints(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + mutate func(*Tableau) + wantErr string + }{ + { + name: "personal access token alone is accepted", + mutate: func(c *Tableau) { + c.AccessTokenName = "conductor1" + c.AccessTokenSecret = "secret" + }, + }, + { + name: "connected app alone is accepted", + mutate: func(c *Tableau) { + c.ConnectedAppClientId = "client-id" + c.ConnectedAppSecretId = "secret-id" + c.ConnectedAppSecretValue = "secret-value" + c.ConnectedAppUsername = "svc.tableau@example.com" + }, + }, + { + name: "no credentials at all is rejected", + mutate: func(c *Tableau) {}, + wantErr: "access-token-name", + }, + { + name: "half a personal access token is rejected", + mutate: func(c *Tableau) { + c.AccessTokenName = "conductor1" + }, + wantErr: "access-token-secret", + }, + { + name: "half a connected app is rejected", + mutate: func(c *Tableau) { + c.ConnectedAppClientId = "client-id" + c.ConnectedAppSecretId = "secret-id" + }, + wantErr: "connected-app-secret-value", + }, + { + // The exclusivity rule names only one field per credential set, so + // these cases lean on the required-together rules to reject a + // partial set alongside a complete one. + name: "a stray token secret beside a connected app is rejected", + mutate: func(c *Tableau) { + c.AccessTokenSecret = "secret" + c.ConnectedAppClientId = "client-id" + c.ConnectedAppSecretId = "secret-id" + c.ConnectedAppSecretValue = "secret-value" + c.ConnectedAppUsername = "svc.tableau@example.com" + }, + wantErr: "access-token-name", + }, + { + name: "a stray connected app field beside a token is rejected", + mutate: func(c *Tableau) { + c.AccessTokenName = "conductor1" + c.AccessTokenSecret = "secret" + c.ConnectedAppSecretValue = "secret-value" + }, + wantErr: "connected-app-client-id", + }, + { + name: "both credential sets together are rejected", + mutate: func(c *Tableau) { + c.AccessTokenName = "conductor1" + c.AccessTokenSecret = "secret" + c.ConnectedAppClientId = "client-id" + c.ConnectedAppSecretId = "secret-id" + c.ConnectedAppSecretValue = "secret-value" + c.ConnectedAppUsername = "svc.tableau@example.com" + }, + wantErr: "mutually exclusive", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + cfg := base() + test.mutate(cfg) + + err := field.Validate(Config, cfg) + if test.wantErr == "" { + require.NoError(t, err) + return + } + + require.ErrorContains(t, err, test.wantErr) + }) + } +} + +// TestConfigConstraintsAreWellFormed catches a silently disabled rule. The SDK +// returns an Invalid relationship rather than an error when a constraint is +// built wrongly — for instance if a field named in a mutually exclusive rule +// is also marked required — and an Invalid rule is never enforced. +func TestConfigConstraintsAreWellFormed(t *testing.T) { + t.Parallel() + + for i, constraint := range ConfigurationConstraints { + require.NotEqual(t, field.Invalid, constraint.Kind, "constraint %d is invalid and would not be enforced", i) + } +} diff --git a/pkg/connector/client_config_test.go b/pkg/connector/client_config_test.go new file mode 100644 index 00000000..1f56e8ab --- /dev/null +++ b/pkg/connector/client_config_test.go @@ -0,0 +1,55 @@ +package connector + +import ( + "testing" + + cfg "github.com/conductorone/baton-tableau/pkg/config" + "github.com/stretchr/testify/require" +) + +// TestClientConfig covers the mapping from connector configuration to client +// credentials. Picking the wrong branch here is invisible until Tableau +// rejects the sign-in, so both paths are pinned. +func TestClientConfig(t *testing.T) { + t.Parallel() + + t.Run("personal access token", func(t *testing.T) { + t.Parallel() + + got := clientConfig(&cfg.Tableau{ + ServerPath: "https://prod-uk-a.online.tableau.com", + SiteId: "example", + ApiVersion: "3.27", + AccessTokenName: "conductor1", + AccessTokenSecret: "token-secret", + }) + + require.Nil(t, got.ConnectedApp) + require.NotNil(t, got.PersonalAccessToken) + require.Equal(t, "conductor1", got.PersonalAccessToken.Name) + require.Equal(t, "token-secret", got.PersonalAccessToken.Secret) + require.Equal(t, "example", got.SiteID) + require.Equal(t, "3.27", got.APIVersion) + }) + + t.Run("connected app", func(t *testing.T) { + t.Parallel() + + got := clientConfig(&cfg.Tableau{ + ServerPath: "https://prod-uk-a.online.tableau.com", + SiteId: "example", + ApiVersion: "3.27", + ConnectedAppClientId: "client-id", + ConnectedAppSecretId: "secret-id", + ConnectedAppSecretValue: "secret-value", + ConnectedAppUsername: "svc.tableau@example.com", + }) + + require.Nil(t, got.PersonalAccessToken) + require.NotNil(t, got.ConnectedApp) + require.Equal(t, "client-id", got.ConnectedApp.ClientID) + require.Equal(t, "secret-id", got.ConnectedApp.SecretID) + require.Equal(t, "secret-value", got.ConnectedApp.SecretValue) + require.Equal(t, "svc.tableau@example.com", got.ConnectedApp.Username) + }) +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 41bf2ccd..f445e974 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -43,7 +43,7 @@ func New(ctx context.Context, tc *cfg.Tableau, _ *cli.ConnectorOpts) (connectorb return nil, nil, err } - tableauClient, err := client.New(ctx, tc.ServerPath, tc.SiteId, tc.AccessTokenName, tc.AccessTokenSecret, tc.ApiVersion, tc.BaseUrl) + tableauClient, err := client.New(ctx, clientConfig(tc)) if err != nil { return nil, nil, fmt.Errorf("failed to create client: %w", err) } @@ -51,6 +51,35 @@ func New(ctx context.Context, tc *cfg.Tableau, _ *cli.ConnectorOpts) (connectorb return &Connector{client: tableauClient}, nil, nil } +// clientConfig selects the credential the operator configured. The schema +// constraints guarantee exactly one complete set is present, so presence of +// the client ID is enough to distinguish them. +func clientConfig(tc *cfg.Tableau) client.Config { + c := client.Config{ + ServerPath: tc.ServerPath, + SiteID: tc.SiteId, + APIVersion: tc.ApiVersion, + BaseURLOverride: tc.BaseUrl, + } + + if tc.ConnectedAppClientId != "" { + c.ConnectedApp = &client.ConnectedApp{ + ClientID: tc.ConnectedAppClientId, + SecretID: tc.ConnectedAppSecretId, + SecretValue: tc.ConnectedAppSecretValue, + Username: tc.ConnectedAppUsername, + } + return c + } + + c.PersonalAccessToken = &client.PersonalAccessToken{ + Name: tc.AccessTokenName, + Secret: tc.AccessTokenSecret, + } + + return c +} + func (c *Connector) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { return &v2.ConnectorMetadata{ DisplayName: "Tableau", From ef83aee82aa6d3dcd27c64aeddc0bebff92f2ca0 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:54:32 +0200 Subject: [PATCH 2/5] Request permission reads and degrade IDP discovery gracefully Two gaps in the connected app path, both of which authenticate fine and then fail later. The requested scope list omitted tableau:permissions:read. Grant sync enumerates project, workbook, and view ACLs on every run and Tableau gates those reads behind that scope rather than tableau:content:read, so any site with content would have failed partway through a sync. IDP discovery only fell back when site-auth-configurations answered 404, the shape an older Tableau Server gives. Tableau publishes no scope covering that endpoint, so a connected app is refused with 401 or 403 instead and account creation failed before reaching AddUserToSite. The implicit path now treats an authorization failure the same as the endpoint being absent and falls back to the site default; naming an IDP explicitly still fails, since silently ignoring the name would put the account on the wrong authentication type. --- README.md | 6 +- docs/connector.mdx | 10 +-- pkg/client/jwt.go | 17 ++++-- pkg/client/jwt_test.go | 4 ++ pkg/connector/idp_discovery_test.go | 94 +++++++++++++++++++++++++++++ pkg/connector/user.go | 31 ++++++++-- 6 files changed, 144 insertions(+), 18 deletions(-) create mode 100644 pkg/connector/idp_discovery_test.go diff --git a/README.md b/README.md index 4b78664e..13ab558c 100644 --- a/README.md +++ b/README.md @@ -100,12 +100,12 @@ Tableau Cloud only, October 2023 and later. A connected app is a site-level trus 2. Click **New Connected App** > **Direct Trust**, name it, and click **Create** 3. Copy the **Client ID**, then generate a secret and copy its **Secret ID** and **Secret Value** — the value is displayed only once 4. Enable the app and grant these access scopes: - `tableau:content:read`, `tableau:sites:read`, `tableau:users:*`, `tableau:groups:*`, `tableau:projects:*`, `tableau:workbooks:*`, `tableau:permissions:update`, `tableau:permissions:delete` + `tableau:content:read`, `tableau:sites:read`, `tableau:users:*`, `tableau:groups:*`, `tableau:projects:*`, `tableau:workbooks:*`, `tableau:permissions:read`, `tableau:permissions:update`, `tableau:permissions:delete` 5. Choose the Tableau user the connector acts as, and note its email address -> **Important**: A missing scope does not fail at sign-in. It surfaces as a 403 partway through a sync or a provisioning action, so grant the full set. The acting user needs the same site administrator role a PAT owner would. +> **Important**: A missing scope does not fail at sign-in. It surfaces as a 403 partway through a sync or a provisioning action, so grant the full set. `tableau:permissions:read` is the one to check twice — grant sync reads project, workbook, and view ACLs on every run, and `tableau:content:read` does not cover them. The acting user needs the same site administrator role a PAT owner would. -> **Known gap**: Tableau publishes no scope covering site authentication configurations, so IDP discovery during account creation may fail under a connected app where it succeeds under a PAT. The connector treats that lookup as non-fatal and falls back to the site's default authentication setting. +> **Known gap**: Tableau publishes no scope covering site authentication configurations, so IDP discovery during account creation is refused under a connected app where it succeeds under a PAT. When no `idpConfigurationName` is given the connector treats the refusal as discovery being unavailable and falls back to the site's default authentication setting. On a site with several IDPs that is a real behaviour difference: a PAT would stop and ask you to name one, a connected app takes the site default. Set `idpConfigurationName` explicitly if that matters, and provisioning will fail loudly rather than guess. **Documentation:** https://help.tableau.com/current/online/en-us/connected_apps_direct.htm diff --git a/docs/connector.mdx b/docs/connector.mdx index fd520265..1b07e217 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -89,9 +89,9 @@ Copy the **Client ID**. Then generate a secret and copy both its **Secret ID** a Enable the connected app and grant it these access scopes: -`tableau:content:read`, `tableau:sites:read`, `tableau:users:*`, `tableau:groups:*`, `tableau:projects:*`, `tableau:workbooks:*`, `tableau:permissions:update`, `tableau:permissions:delete` +`tableau:content:read`, `tableau:sites:read`, `tableau:users:*`, `tableau:groups:*`, `tableau:projects:*`, `tableau:workbooks:*`, `tableau:permissions:read`, `tableau:permissions:update`, `tableau:permissions:delete` -A missing scope does not fail at sign-in. It surfaces later as a 403 partway through a sync or a provisioning action, so grant the full set. +A missing scope does not fail at sign-in. It surfaces later as a 403 partway through a sync or a provisioning action, so grant the full set. Check `tableau:permissions:read` twice: grant sync reads project, workbook, and view ACLs on every run, and `tableau:content:read` does not cover those reads. Decide which Tableau user the connector acts as, usually a service account. It needs the same site administrator rights a personal access token owner would need. Save its email address. @@ -99,7 +99,7 @@ Decide which Tableau user the connector acts as, usually a service account. It n -Tableau publishes no access scope covering site authentication configurations. Under a connected app, IDP discovery during account creation may fail where it would succeed under a personal access token. The connector already treats that lookup as non-fatal and falls back to the site's default authentication setting. +Tableau publishes no access scope covering site authentication configurations, so under a connected app that lookup is refused where it would succeed under a personal access token. With no **IDP Configuration Name** set, the connector treats the refusal as discovery being unavailable and falls back to the site's default authentication setting. On a site with more than one IDP that differs from personal access token behaviour, which stops and asks you to name one. Set **IDP Configuration Name** explicitly if the choice matters; provisioning then fails with an explicit error instead of guessing. ### Locate your server path and site ID @@ -326,8 +326,10 @@ These fields appear in the **provisioning mapping** for the Licenses entitlement | **IDP Configuration Name** set, IDP found | Account created with the named IDP | | **IDP Configuration Name** set, IDP not found | Provisioning fails with an explicit error naming the missing IDP | | **IDP Configuration Name** set, API version < 3.22 | Provisioning fails with an explicit error — upgrade your Tableau Server or remove the field | +| **IDP Configuration Name** set, connected app authentication | Provisioning fails with an explicit error — Tableau refuses IDP discovery to a JWT session, so use a personal access token or remove the field | +| Neither field set, IDP discovery unavailable or refused | Account created with Tableau site default authentication | | **With MFA** = `true` | Account created with Tableau MFA — **IDP Configuration Name** is ignored regardless of API version | -If your Tableau Server uses an API version older than 3.22 and you do not set **IDP Configuration Name**, account provisioning uses the site default authentication without error. The IDP endpoint is only required when you explicitly configure an IDP name. +The IDP endpoint is only consulted when it can be. If your Tableau Server predates API 3.22, or you sign in with a connected app, and you do not set **IDP Configuration Name**, account provisioning falls back to the site default authentication without error. diff --git a/pkg/client/jwt.go b/pkg/client/jwt.go index 3d26d44f..debb4321 100644 --- a/pkg/client/jwt.go +++ b/pkg/client/jwt.go @@ -25,12 +25,18 @@ const jwtLifetime = 5 * time.Minute // connected app. Tableau bounds the resulting session by the intersection of // this list and the scopes enabled on the connected app itself, so the list // must cover every endpoint this package calls: sites and content reads, the -// full user and group lifecycle for provisioning, and permission writes for -// projects, workbooks, and views. +// full user and group lifecycle for provisioning, and reading and writing +// permissions on projects, workbooks, and views. // -// Tableau publishes no scope covering /site-auth-configurations. IDP discovery -// may therefore fail under a connected app where it succeeds under a personal -// access token; the account-creation path already treats that as non-fatal. +// Permission reads need their own scope. Grant sync enumerates project, +// workbook, and view ACLs on every run, and Tableau gates those GETs behind +// tableau:permissions:read rather than tableau:content:read — omit it and +// sign-in succeeds while the first site with content fails mid-sync. +// +// Tableau publishes no scope covering /site-auth-configurations, so IDP +// discovery may be refused under a connected app where it succeeds under a +// personal access token. The account-creation path treats that refusal as +// discovery being unavailable and falls back to the site default. var connectedAppScopes = []string{ "tableau:content:read", "tableau:sites:read", @@ -38,6 +44,7 @@ var connectedAppScopes = []string{ "tableau:groups:*", "tableau:projects:*", "tableau:workbooks:*", + "tableau:permissions:read", "tableau:permissions:update", "tableau:permissions:delete", } diff --git a/pkg/client/jwt_test.go b/pkg/client/jwt_test.go index 1a8c62d3..6ab9e570 100644 --- a/pkg/client/jwt_test.go +++ b/pkg/client/jwt_test.go @@ -69,6 +69,10 @@ func TestNewConnectedAppJWT_Structure(t *testing.T) { require.Len(t, scopes, len(connectedAppScopes)) require.Contains(t, scopes, "tableau:users:*") require.Contains(t, scopes, "tableau:groups:*") + // Grant sync reads project, workbook, and view ACLs unconditionally, and + // Tableau gates those reads behind their own scope. Its absence would not + // show up until a sync against a site with content. + require.Contains(t, scopes, "tableau:permissions:read") } // TestNewConnectedAppJWT_Signature recomputes the HMAC over the signing input diff --git a/pkg/connector/idp_discovery_test.go b/pkg/connector/idp_discovery_test.go new file mode 100644 index 00000000..b43d073f --- /dev/null +++ b/pkg/connector/idp_discovery_test.go @@ -0,0 +1,94 @@ +package connector + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/conductorone/baton-tableau/pkg/client" + "github.com/stretchr/testify/require" +) + +// idpDiscoveryServer answers sign-in normally and refuses the IDP discovery +// endpoint with the given status. That is the shape Tableau presents to a +// connected app: the session is valid, but no published scope covers +// site-auth-configurations, so the lookup alone is turned away. +func idpDiscoveryServer(t *testing.T, discoveryStatus int) *httptest.Server { + t.Helper() + + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasSuffix(r.URL.Path, "/auth/signin"): + _, _ = w.Write([]byte(`{"credentials":{"token":"session-token","site":{"id":"site-id","contentUrl":"thg_plc"}}}`)) + case strings.HasSuffix(r.URL.Path, "/site-auth-configurations"): + w.WriteHeader(discoveryStatus) + _, _ = w.Write([]byte(`{"error":{"code":"403004","summary":"Forbidden","detail":"insufficient scope"}}`)) + default: + t.Errorf("unexpected request to %s", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + } + })) +} + +func newTestUserBuilder(t *testing.T, server *httptest.Server) *userBuilder { + t.Helper() + + c, err := client.New(context.Background(), client.Config{ + SiteID: "thg_plc", + BaseURLOverride: server.URL, + ConnectedApp: &client.ConnectedApp{ + ClientID: "client-id", + SecretID: "secret-id", + SecretValue: "secret-value", + Username: "svc.tableau@example.com", + }, + }) + require.NoError(t, err) + + return newUserBuilder(c) +} + +// TestSelectIDPConfiguration_DiscoveryRefused pins the behaviour that decides +// whether connected app account provisioning works at all. Without an explicit +// IDP name there is nothing to resolve, so a refused lookup must degrade to the +// site default rather than fail the whole account creation. +func TestSelectIDPConfiguration_DiscoveryRefused(t *testing.T) { + t.Parallel() + + for _, discoveryStatus := range []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound} { + t.Run(http.StatusText(discoveryStatus), func(t *testing.T) { + t.Parallel() + + server := idpDiscoveryServer(t, discoveryStatus) + defer server.Close() + + idpID, err := newTestUserBuilder(t, server).selectIDPConfiguration(context.Background(), "") + require.NoError(t, err) + require.Empty(t, idpID, "an unresolvable lookup falls back to the site default auth setting") + }) + } +} + +// TestSelectIDPConfiguration_DiscoveryRefusedWithExplicitName is the other half: +// once the caller names an IDP, silently ignoring it would put the account on +// the wrong authentication type, so the refusal has to surface. +func TestSelectIDPConfiguration_DiscoveryRefusedWithExplicitName(t *testing.T) { + t.Parallel() + + for _, discoveryStatus := range []int{http.StatusUnauthorized, http.StatusForbidden, http.StatusNotFound} { + t.Run(http.StatusText(discoveryStatus), func(t *testing.T) { + t.Parallel() + + server := idpDiscoveryServer(t, discoveryStatus) + defer server.Close() + + _, err := newTestUserBuilder(t, server).selectIDPConfiguration(context.Background(), "Okta") + require.ErrorContains(t, err, "cannot be resolved") + require.ErrorContains(t, err, "Okta") + }) + } +} diff --git a/pkg/connector/user.go b/pkg/connector/user.go index d9e46672..96c15fbe 100644 --- a/pkg/connector/user.go +++ b/pkg/connector/user.go @@ -164,10 +164,11 @@ func (u *userBuilder) selectIDPConfiguration(ctx context.Context, idpConfigName if idpConfigName != "" { cfg, _, err := u.client.FindIdpConfigurationByName(ctx, idpConfigName) if err != nil { - if status.Code(err) == codes.NotFound { + if idpDiscoveryUnavailable(err) { return "", uhttp.WrapErrors(codes.InvalidArgument, fmt.Sprintf("IDP configuration '%s' cannot be resolved: site-auth-configurations endpoint unavailable;"+ - " upgrade to Tableau Server 2023.3+ (API 3.22+) or remove idpConfigurationName", idpConfigName)) + " upgrade to Tableau Server 2023.3+ (API 3.22+), switch to a personal access token if signing in"+ + " with a connected app, or remove idpConfigurationName", idpConfigName)) } return "", fmt.Errorf("failed to find IDP configuration: %w", err) } @@ -185,10 +186,14 @@ func (u *userBuilder) selectIDPConfiguration(ctx context.Context, idpConfigName enabledConfigs, _, err := u.client.ListEnabledIdpConfigurations(ctx) if err != nil { - if status.Code(err) == codes.NotFound { - // Endpoint unavailable on older Tableau Server (<2023.3 / API <3.22). - // No idpConfigurationName was requested, so preserve original behavior: - // proceed with no auth fields and let Tableau use the site default. + if idpDiscoveryUnavailable(err) { + // Discovery is out of reach: the endpoint does not exist on older + // Tableau Server (<2023.3 / API <3.22), and Tableau publishes no + // connected app scope covering it, so a JWT session is refused + // outright. No idpConfigurationName was requested, so preserve the + // original behavior in both cases: proceed with no auth fields and + // let Tableau use the site default. A credential that is broken + // rather than merely unscoped fails on the very next call. return "", nil } return "", fmt.Errorf("failed to list IDP configurations: %w", err) @@ -220,6 +225,20 @@ func newUserBuilder(client *client.Client) *userBuilder { } } +// idpDiscoveryUnavailable reports whether err means the site-auth-configurations +// endpoint cannot answer at all, as opposed to answering with a result the +// caller dislikes. Tableau Server before 2023.3 does not route the path, and +// Tableau publishes no connected app scope for it, so a JWT session is turned +// away with an authorization failure rather than a 404. +func idpDiscoveryUnavailable(err error) bool { + switch status.Code(err) { + case codes.NotFound, codes.Unauthenticated, codes.PermissionDenied: + return true + default: + return false + } +} + // buildAvailableIDPsError formats an actionable error listing the given IDP configurations. func buildAvailableIDPsError(configs []*client.IdpConfiguration) error { var b strings.Builder From db1428eb067f1020d23a6d51cb8e2aab165e0312 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:06:34 +0100 Subject: [PATCH 3/5] Correct connected app scope guidance and add a live integration test A test against a real Tableau Cloud site showed that the documentation on this branch was wrong in one place. A direct trust connected app has no scope list of its own. The create form offers only a name, an access level and a domain allowlist. The app detail page has no scopes section. Tableau's documentation also states that access level and domain allowlist do not apply to REST API authorization. The scp claim in the token is the only way to set scopes, so the instruction to grant scopes on the app described a step that does not exist. The list of scopes is unchanged and is still required. The same test confirmed the endpoint behaviour behind the identity provider fallback, which until now came from reading the documentation rather than from observation. Tableau refuses a connected app session at /site-auth-configurations with 401 401002, where a personal access token succeeds. If idpDiscoveryUnavailable accepts only codes.NotFound, account creation against the live site fails with that error. If it also accepts codes.Unauthenticated, account creation succeeds. The fallback is therefore necessary. The new live test produced that evidence. It does not run unless BATON_TABLEAU_LIVE is set, because it creates and deletes a real account. It creates the account as unlicensed so a run uses no seat, changes the licence in both directions to test that path, adds and removes the account from a group when BATON_TABLEAU_TEST_GROUP_ID is set, and deletes the account in a cleanup step so a failed assertion does not leave an account on the site. This also records that Tableau creates a connected app in the disabled state. A disabled app refuses every sign-in, and nothing in the interface says so. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019tSKhw6At6c1S2oNLqr4zg --- README.md | 11 ++-- docs/connector.mdx | 10 ++-- pkg/client/jwt.go | 17 +++--- pkg/connector/live_test.go | 114 +++++++++++++++++++++++++++++++++++++ 4 files changed, 135 insertions(+), 17 deletions(-) create mode 100644 pkg/connector/live_test.go diff --git a/README.md b/README.md index 13ab558c..646d2e64 100644 --- a/README.md +++ b/README.md @@ -94,18 +94,19 @@ baton resources ## Connected app (direct trust) -Tableau Cloud only, October 2023 and later. A connected app is a site-level trust rather than a user's token, so its secret is rotated deliberately instead of expiring underneath you. +Tableau Cloud only, October 2023 and later. A connected app is a site-level trust rather than a user's token. Its secret does not expire on its own; you rotate it when you choose to. 1. Sign in as a site administrator and go to **Settings** > **Connected Apps** 2. Click **New Connected App** > **Direct Trust**, name it, and click **Create** 3. Copy the **Client ID**, then generate a secret and copy its **Secret ID** and **Secret Value** — the value is displayed only once -4. Enable the app and grant these access scopes: - `tableau:content:read`, `tableau:sites:read`, `tableau:users:*`, `tableau:groups:*`, `tableau:projects:*`, `tableau:workbooks:*`, `tableau:permissions:read`, `tableau:permissions:update`, `tableau:permissions:delete` +4. Set the app's status to **Enabled** — it is created disabled, and a disabled app refuses every sign-in 5. Choose the Tableau user the connector acts as, and note its email address -> **Important**: A missing scope does not fail at sign-in. It surfaces as a 403 partway through a sync or a provisioning action, so grant the full set. `tableau:permissions:read` is the one to check twice — grant sync reads project, workbook, and view ACLs on every run, and `tableau:content:read` does not cover them. The acting user needs the same site administrator role a PAT owner would. +> **Important**: The acting user needs the same site administrator role a PAT owner would. Access level and domain allowlist can be left at their defaults; they restrict embedded content, not REST API calls. -> **Known gap**: Tableau publishes no scope covering site authentication configurations, so IDP discovery during account creation is refused under a connected app where it succeeds under a PAT. When no `idpConfigurationName` is given the connector treats the refusal as discovery being unavailable and falls back to the site's default authentication setting. On a site with several IDPs that is a real behaviour difference: a PAT would stop and ask you to name one, a connected app takes the site default. Set `idpConfigurationName` explicitly if that matters, and provisioning will fail loudly rather than guess. +> **Note on scopes**: A direct trust app has no scope list of its own. The connector requests scopes in the signed assertion, and that claim is the only scope control, so there is nothing to grant here. The requested set is fixed in code — a Tableau release that renames or splits a scope would surface as a 403 partway through a sync rather than at sign-in. + +> **Known gap**: Tableau publishes no scope covering site authentication configurations, so IDP discovery during account creation is refused under a connected app — observed as `401002 Unauthorized Access` against a Tableau Cloud site where the same call succeeds under a PAT. When no `idpConfigurationName` is given the connector treats the refusal as discovery being unavailable and falls back to the site's default authentication setting. On a site with several IDPs that is a real behaviour difference: a PAT would stop and ask you to name one, a connected app takes the site default. Set `idpConfigurationName` explicitly if that matters, and provisioning will fail loudly rather than guess. **Documentation:** https://help.tableau.com/current/online/en-us/connected_apps_direct.htm diff --git a/docs/connector.mdx b/docs/connector.mdx index 1b07e217..46b7c9b1 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -72,7 +72,7 @@ Carefully copy and save the newly generated token and its name. Tableau treats t ### Or set up a connected app -A personal access token belongs to an individual, so the connector inherits that person's account lifecycle. Tableau also expires every token eventually — after 1 to 365 days depending on your site settings, and after 15 consecutive days of non-use. A connected app is a site-level trust instead, so its secret is rotated deliberately rather than expiring underneath you. +A personal access token belongs to an individual, so the connector follows that person's account lifecycle. Tableau also expires every token: after 1 to 365 days, depending on your site settings, and after 15 days without use. A connected app is a site-level trust instead. Its secret does not expire on its own; you rotate it when you choose to. Configure either a personal access token or a connected app, not both. @@ -87,11 +87,13 @@ Click **New Connected App** > **Direct Trust**, give it a name, and click **Crea Copy the **Client ID**. Then generate a secret and copy both its **Secret ID** and **Secret Value**. The secret value is shown only once. -Enable the connected app and grant it these access scopes: +Set the connected app's status to **Enabled**. It is created disabled, and a disabled app refuses every sign-in. + +There are no scopes to grant here. A direct trust app carries no scope list of its own — the connector requests these in the signed assertion, and that claim is the only scope control: `tableau:content:read`, `tableau:sites:read`, `tableau:users:*`, `tableau:groups:*`, `tableau:projects:*`, `tableau:workbooks:*`, `tableau:permissions:read`, `tableau:permissions:update`, `tableau:permissions:delete` -A missing scope does not fail at sign-in. It surfaces later as a 403 partway through a sync or a provisioning action, so grant the full set. Check `tableau:permissions:read` twice: grant sync reads project, workbook, and view ACLs on every run, and `tableau:content:read` does not cover those reads. +Leave access level and domain allowlist at their defaults; both restrict embedded content rather than REST API calls. Decide which Tableau user the connector acts as, usually a service account. It needs the same site administrator rights a personal access token owner would need. Save its email address. @@ -99,7 +101,7 @@ Decide which Tableau user the connector acts as, usually a service account. It n -Tableau publishes no access scope covering site authentication configurations, so under a connected app that lookup is refused where it would succeed under a personal access token. With no **IDP Configuration Name** set, the connector treats the refusal as discovery being unavailable and falls back to the site's default authentication setting. On a site with more than one IDP that differs from personal access token behaviour, which stops and asks you to name one. Set **IDP Configuration Name** explicitly if the choice matters; provisioning then fails with an explicit error instead of guessing. +Tableau publishes no access scope covering site authentication configurations, so under a connected app that lookup is refused — observed as `401002 Unauthorized Access` against a Tableau Cloud site where it succeeds under a personal access token. With no **IDP Configuration Name** set, the connector treats the refusal as discovery being unavailable and falls back to the site's default authentication setting. On a site with more than one IDP that differs from personal access token behaviour, which stops and asks you to name one. Set **IDP Configuration Name** explicitly if the choice matters; provisioning then fails with an explicit error instead of guessing. ### Locate your server path and site ID diff --git a/pkg/client/jwt.go b/pkg/client/jwt.go index debb4321..eea5b5bc 100644 --- a/pkg/client/jwt.go +++ b/pkg/client/jwt.go @@ -22,10 +22,11 @@ import ( const jwtLifetime = 5 * time.Minute // connectedAppScopes are the access scopes requested when signing in with a -// connected app. Tableau bounds the resulting session by the intersection of -// this list and the scopes enabled on the connected app itself, so the list -// must cover every endpoint this package calls: sites and content reads, the -// full user and group lifecycle for provisioning, and reading and writing +// connected app. For direct trust this claim is the only scope control there +// is: the app itself carries no scope list, and its access level and domain +// allowlist govern embedding rather than the REST API. The list must therefore +// cover every endpoint this package calls — sites and content reads, the full +// user and group lifecycle for provisioning, and reading and writing // permissions on projects, workbooks, and views. // // Permission reads need their own scope. Grant sync enumerates project, @@ -33,10 +34,10 @@ const jwtLifetime = 5 * time.Minute // tableau:permissions:read rather than tableau:content:read — omit it and // sign-in succeeds while the first site with content fails mid-sync. // -// Tableau publishes no scope covering /site-auth-configurations, so IDP -// discovery may be refused under a connected app where it succeeds under a -// personal access token. The account-creation path treats that refusal as -// discovery being unavailable and falls back to the site default. +// Tableau publishes no scope covering /site-auth-configurations. A connected +// app session is refused there with 401 401002 where a personal access token +// succeeds, so the account-creation path treats that refusal as discovery +// being unavailable and falls back to the site default. var connectedAppScopes = []string{ "tableau:content:read", "tableau:sites:read", diff --git a/pkg/connector/live_test.go b/pkg/connector/live_test.go new file mode 100644 index 00000000..5c0bd42a --- /dev/null +++ b/pkg/connector/live_test.go @@ -0,0 +1,114 @@ +package connector + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-tableau/pkg/client" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +// liveClient builds a client against a real Tableau site from the environment. +// The whole file is inert unless BATON_TABLEAU_LIVE is set, because these tests +// create and delete a real account on a real site. +func liveClient(t *testing.T) *client.Client { + t.Helper() + + if os.Getenv("BATON_TABLEAU_LIVE") == "" { + t.Skip("set BATON_TABLEAU_LIVE=1 to run against a live Tableau site") + } + + c, err := client.New(context.Background(), client.Config{ + ServerPath: os.Getenv("BATON_SERVER_PATH"), + SiteID: os.Getenv("BATON_SITE_ID"), + APIVersion: "3.27", + ConnectedApp: &client.ConnectedApp{ + ClientID: os.Getenv("BATON_CONNECTED_APP_CLIENT_ID"), + SecretID: os.Getenv("BATON_CONNECTED_APP_SECRET_ID"), + SecretValue: os.Getenv("BATON_CONNECTED_APP_SECRET_VALUE"), + Username: os.Getenv("BATON_CONNECTED_APP_USERNAME"), + }, + }) + require.NoError(t, err) + + return c +} + +// TestLiveAccountLifecycle exercises the provisioning path a connected app is +// most likely to break on: account creation reaches for IDP discovery, which +// Tableau refuses to a JWT session, and the connector must fall back to the +// site default rather than abort. The account is created unlicensed so the run +// consumes no seat, then promoted and demoted to exercise the license path. +func TestLiveAccountLifecycle(t *testing.T) { + c := liveClient(t) + ctx := context.Background() + + users := newUserBuilder(c) + licenses := newLicenseBuilder(c) + + // Tableau uses the address as the login name, so it must sit in a domain the + // site will accept. Override it for sites that reject the default. + domain := os.Getenv("BATON_TABLEAU_TEST_EMAIL_DOMAIN") + if domain == "" { + domain = "example.com" + } + + email := fmt.Sprintf("baton-connector-test-%d@%s", time.Now().Unix(), domain) + + profile, err := structpb.NewStruct(map[string]any{ + "email": email, + "siteRole": "Unlicensed", + "withMFA": false, + }) + require.NoError(t, err) + + created, _, _, err := users.CreateAccount(ctx, &v2.AccountInfo{Profile: profile}, nil) + require.NoError(t, err, "account creation must survive Tableau refusing IDP discovery") + + success, ok := created.(*v2.CreateAccountResponse_SuccessResult) + require.True(t, ok, "expected a success result") + + userID := success.Resource.Id.Resource + t.Logf("created user %s (%s)", email, userID) + + // Always clean up, even if an assertion below fails. + t.Cleanup(func() { + if _, err := c.RemoveUserFromSite(context.Background(), userID); err != nil { + t.Errorf("failed to remove test user %s: %v", userID, err) + } + }) + + licenseRes, err := licenseResource("Explorer", nil, 0) + require.NoError(t, err) + + _, err = licenses.Grant(ctx, success.Resource, &v2.Entitlement{Resource: licenseRes}) + require.NoError(t, err, "granting a license must work under a connected app") + + _, err = licenses.Revoke(ctx, &v2.Grant{Principal: success.Resource}) + require.NoError(t, err, "revoking a license must work under a connected app") + + // Group membership needs a group to write to, and this test will not invent + // one on someone's site. Point BATON_TABLEAU_TEST_GROUP_ID at a throwaway + // group to cover the tableau:groups:* half of the scope list as well. + groupID := os.Getenv("BATON_TABLEAU_TEST_GROUP_ID") + if groupID == "" { + t.Log("BATON_TABLEAU_TEST_GROUP_ID unset; skipping group membership") + return + } + + groups := newGroupBuilder(c) + groupEnt := &v2.Entitlement{Resource: &v2.Resource{ + Id: &v2.ResourceId{ResourceType: resourceTypeGroup.Id, Resource: groupID}, + }} + + _, err = groups.Grant(ctx, success.Resource, groupEnt) + require.NoError(t, err, "adding a user to a group must work under a connected app") + + _, err = groups.Revoke(ctx, &v2.Grant{Principal: success.Resource, Entitlement: groupEnt}) + require.NoError(t, err, "removing a user from a group must work under a connected app") +} From 895eb422763bf9230fc869eda8d3234b3ebc6d76 Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:19:30 +0100 Subject: [PATCH 4/5] Cover project, workbook and view permissions in the live test The live test covered users, licences and groups but not the content permission paths. This adds project permissions, the default workbook permissions attached to a project, workbook permissions and view permissions. Each capability is granted to a throwaway user and then revoked, so a run that completes leaves the target as it found it. Each target is opt-in through its own environment variable, because these operations write to real content. Two constraints decide what you can point them at. Tableau refuses all per-workbook and per-view permission changes when the parent project locks permissions to the project, and it returns 403039 in that case. The connector also refuses view permissions when the workbook has showTabs enabled. A scratch project with a workbook published into it satisfies both. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019tSKhw6At6c1S2oNLqr4zg --- pkg/connector/live_test.go | 104 +++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/pkg/connector/live_test.go b/pkg/connector/live_test.go index 5c0bd42a..3b27bcfe 100644 --- a/pkg/connector/live_test.go +++ b/pkg/connector/live_test.go @@ -8,6 +8,7 @@ import ( "time" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-tableau/pkg/client" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/structpb" @@ -112,3 +113,106 @@ func TestLiveAccountLifecycle(t *testing.T) { _, err = groups.Revoke(ctx, &v2.Grant{Principal: success.Resource, Entitlement: groupEnt}) require.NoError(t, err, "removing a user from a group must work under a connected app") } + +// viewCapabilitySlug is the display name of the Read capability, which is the +// cheapest permission to grant and revoke on every content type. +const viewCapabilitySlug = "View" + +// permissionEntitlement builds the entitlement shape the permission builders +// expect. Grant and Revoke read the capability from the third colon-separated +// field of the entitlement ID, and the target from the entitlement resource. +func permissionEntitlement(resourceType, resourceID, displaySlug string) *v2.Entitlement { + return &v2.Entitlement{ + Id: fmt.Sprintf("%s:%s:%s", resourceType, resourceID, displaySlug), + Resource: &v2.Resource{ + Id: &v2.ResourceId{ResourceType: resourceType, Resource: resourceID}, + }, + } +} + +// TestLivePermissions covers the content permission paths against a live site: +// projects, the default workbook permissions attached to a project, workbooks, +// and views. Each capability is granted to a throwaway user and then revoked, +// so a completed run leaves the target's permissions as it found them. +// +// Every target is opt-in. Point BATON_TABLEAU_TEST_PROJECT_ID at a project you +// are willing to write to — a scratch project is the obvious choice. Views are +// only writable when their workbook has showTabs disabled; the connector +// refuses the rest by design. +func TestLivePermissions(t *testing.T) { + c := liveClient(t) + ctx := context.Background() + + projectID := os.Getenv("BATON_TABLEAU_TEST_PROJECT_ID") + workbookID := os.Getenv("BATON_TABLEAU_TEST_WORKBOOK_ID") + viewID := os.Getenv("BATON_TABLEAU_TEST_VIEW_ID") + + if projectID == "" && workbookID == "" && viewID == "" { + t.Skip("set BATON_TABLEAU_TEST_PROJECT_ID, _WORKBOOK_ID or _VIEW_ID to exercise permissions") + } + + domain := os.Getenv("BATON_TABLEAU_TEST_EMAIL_DOMAIN") + if domain == "" { + domain = "example.com" + } + + user, _, err := c.AddUserToSite(ctx, client.CreateUserRequest{ + Email: fmt.Sprintf("baton-perm-test-%d@%s", time.Now().Unix(), domain), + SiteRole: "Viewer", + }) + require.NoError(t, err) + + t.Cleanup(func() { + if _, err := c.RemoveUserFromSite(context.Background(), user.ID); err != nil { + t.Errorf("failed to remove permission test user %s: %v", user.ID, err) + } + }) + + principal := &v2.Resource{ + Id: &v2.ResourceId{ResourceType: resourceTypeUser.Id, Resource: user.ID}, + } + + cases := []struct { + name string + resourceType string + resourceID string + slug string + grant func(context.Context, *v2.Resource, *v2.Entitlement) (annotations.Annotations, error) + revoke func(context.Context, *v2.Grant) (annotations.Annotations, error) + }{ + { + name: "project", resourceType: resourceTypeProject.Id, resourceID: projectID, slug: viewCapabilitySlug, + grant: newProjectBuilder(c).Grant, revoke: newProjectBuilder(c).Revoke, + }, + { + // "Workbook / View" is a default workbook capability on the project, + // which takes a different Tableau endpoint to a plain project grant. + name: "project default workbook", resourceType: resourceTypeProject.Id, resourceID: projectID, slug: "Workbook / View", + grant: newProjectBuilder(c).Grant, revoke: newProjectBuilder(c).Revoke, + }, + { + name: "workbook", resourceType: resourceTypeWorkbook.Id, resourceID: workbookID, slug: viewCapabilitySlug, + grant: newWorkbookBuilder(c).Grant, revoke: newWorkbookBuilder(c).Revoke, + }, + { + name: "view", resourceType: resourceTypeView.Id, resourceID: viewID, slug: viewCapabilitySlug, + grant: newViewBuilder(c).Grant, revoke: newViewBuilder(c).Revoke, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.resourceID == "" { + t.Skipf("no id supplied for %s", tc.name) + } + + entitlement := permissionEntitlement(tc.resourceType, tc.resourceID, tc.slug) + + _, err := tc.grant(ctx, principal, entitlement) + require.NoError(t, err, "granting %s permission must work under a connected app", tc.name) + + _, err = tc.revoke(ctx, &v2.Grant{Principal: principal, Entitlement: entitlement}) + require.NoError(t, err, "revoking %s permission must work under a connected app", tc.name) + }) + } +} From e44d0c4a33fe71914f421e859912e2ecbb9977eb Mon Sep 17 00:00:00 2001 From: Abraham Ingersoll <586805+aberoham@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:06:25 +0100 Subject: [PATCH 5/5] Narrow connected app scopes and limit the IDP fallback to connected apps Three changes from an adversarial review of this branch. The assertion no longer requests tableau:projects:* or tableau:workbooks:*. This package never creates, updates, moves, publishes, downloads or deletes a project or a workbook. It reads them, which tableau:content:read covers, and edits their permissions, which the permission scopes cover. The wildcards only widened what a leaked session could do. A full sync against a live site with the shorter list read the same 320 resource pages with no authorization failure. The user and group scopes stay as wildcards, because Tableau documents no granular alternative for the site role update the licence path performs, and nothing granular for groups at all. The identity provider fallback now depends on which credential opened the session. A personal access token is entitled to site-auth-configurations, so 401 and 403 mean that credential is genuinely failing and are reported rather than absorbed; treating them as a missing feature would create the account on the site default and give it the wrong authentication type. A connected app is refused there whatever its configuration, so for it those statuses still mean unavailable. A 404 still means unavailable for both, since an unrouted path is a missing feature whichever credential asked. The live permission test now verifies through the read path. After each grant and each revoke it calls the resource's Grants method and checks the principal appears and then disappears, which exercises the permission reads rather than only the writes. Two defects surfaced while making that work. The view entitlement carried no parent workbook, so the showTabs guard never ran and the test passed without touching the branch it was meant to cover. The SDK also caches GET responses, so a read after a revoke returned the stale grant and looked exactly like a revoke that did nothing; the test disables that cache. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_019tSKhw6At6c1S2oNLqr4zg --- README.md | 4 +- docs/connector.mdx | 6 +- pkg/client/client.go | 20 ++++- pkg/client/jwt.go | 22 ++++-- pkg/client/jwt_test.go | 6 ++ pkg/connector/idp_discovery_test.go | 61 ++++++++++++++- pkg/connector/live_test.go | 110 ++++++++++++++++++++++------ pkg/connector/user.go | 39 ++++++---- 8 files changed, 217 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 646d2e64..72b46e64 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,9 @@ Tableau Cloud only, October 2023 and later. A connected app is a site-level trus > **Important**: The acting user needs the same site administrator role a PAT owner would. Access level and domain allowlist can be left at their defaults; they restrict embedded content, not REST API calls. -> **Note on scopes**: A direct trust app has no scope list of its own. The connector requests scopes in the signed assertion, and that claim is the only scope control, so there is nothing to grant here. The requested set is fixed in code — a Tableau release that renames or splits a scope would surface as a 403 partway through a sync rather than at sign-in. +> **Note on scopes**: A direct trust app has no scope list of its own. The connector requests scopes in the signed assertion, and that claim is the only scope control, so there is nothing to grant here. It asks for `tableau:content:read`, `tableau:sites:read`, `tableau:users:*`, `tableau:groups:*` and the three `tableau:permissions:*` scopes. It deliberately does not ask for `tableau:projects:*` or `tableau:workbooks:*`: it reads those objects, which `tableau:content:read` covers, and edits their permissions, which the permission scopes cover, so the wildcards would only add project deletion and workbook publishing to what a leaked session could do. -> **Known gap**: Tableau publishes no scope covering site authentication configurations, so IDP discovery during account creation is refused under a connected app — observed as `401002 Unauthorized Access` against a Tableau Cloud site where the same call succeeds under a PAT. When no `idpConfigurationName` is given the connector treats the refusal as discovery being unavailable and falls back to the site's default authentication setting. On a site with several IDPs that is a real behaviour difference: a PAT would stop and ask you to name one, a connected app takes the site default. Set `idpConfigurationName` explicitly if that matters, and provisioning will fail loudly rather than guess. +> **Known gap**: Tableau publishes no scope covering site authentication configurations, so IDP discovery during account creation is refused under a connected app — observed as `401002 Unauthorized Access` against a Tableau Cloud site where the same call succeeds under a PAT. The connector treats that refusal as discovery being unavailable only when it signed in with a connected app; under a PAT the same status is a real failure and is reported. When no `idpConfigurationName` is given the connector treats the refusal as discovery being unavailable and falls back to the site's default authentication setting. On a site with several IDPs that is a real behaviour difference: a PAT would stop and ask you to name one, a connected app takes the site default. Set `idpConfigurationName` explicitly if that matters, and provisioning will fail loudly rather than guess. **Documentation:** https://help.tableau.com/current/online/en-us/connected_apps_direct.htm diff --git a/docs/connector.mdx b/docs/connector.mdx index 46b7c9b1..2da7b6b4 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -91,7 +91,9 @@ Set the connected app's status to **Enabled**. It is created disabled, and a dis There are no scopes to grant here. A direct trust app carries no scope list of its own — the connector requests these in the signed assertion, and that claim is the only scope control: -`tableau:content:read`, `tableau:sites:read`, `tableau:users:*`, `tableau:groups:*`, `tableau:projects:*`, `tableau:workbooks:*`, `tableau:permissions:read`, `tableau:permissions:update`, `tableau:permissions:delete` +`tableau:content:read`, `tableau:sites:read`, `tableau:users:*`, `tableau:groups:*`, `tableau:permissions:read`, `tableau:permissions:update`, `tableau:permissions:delete` + +The connector does not request `tableau:projects:*` or `tableau:workbooks:*`. It reads those objects, which `tableau:content:read` covers, and edits their permissions, which the permission scopes cover. The wildcards would only add project deletion and workbook publishing to what a leaked session could do. Leave access level and domain allowlist at their defaults; both restrict embedded content rather than REST API calls. @@ -101,7 +103,7 @@ Decide which Tableau user the connector acts as, usually a service account. It n -Tableau publishes no access scope covering site authentication configurations, so under a connected app that lookup is refused — observed as `401002 Unauthorized Access` against a Tableau Cloud site where it succeeds under a personal access token. With no **IDP Configuration Name** set, the connector treats the refusal as discovery being unavailable and falls back to the site's default authentication setting. On a site with more than one IDP that differs from personal access token behaviour, which stops and asks you to name one. Set **IDP Configuration Name** explicitly if the choice matters; provisioning then fails with an explicit error instead of guessing. +Tableau publishes no access scope covering site authentication configurations, so under a connected app that lookup is refused — observed as `401002 Unauthorized Access` against a Tableau Cloud site where it succeeds under a personal access token. The connector reads that refusal as discovery being unavailable only when it signed in with a connected app. Under a personal access token the same status is a genuine failure and is reported rather than absorbed. With no **IDP Configuration Name** set, the connector treats the refusal as discovery being unavailable and falls back to the site's default authentication setting. On a site with more than one IDP that differs from personal access token behaviour, which stops and asks you to name one. Set **IDP Configuration Name** explicitly if the choice matters; provisioning then fails with an explicit error instead of guessing. ### Locate your server path and site ID diff --git a/pkg/client/client.go b/pkg/client/client.go index 5631427e..564c6800 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -89,6 +89,17 @@ type Client struct { authToken string siteId string baseUrl string + + // usesConnectedApp records which credential opened this session. A connected + // app is refused at endpoints Tableau publishes no scope for, so callers + // need to tell that refusal apart from a genuine authorization failure. + usesConnectedApp bool +} + +// UsesConnectedApp reports whether this session was opened with a connected app +// rather than a personal access token. +func (c *Client) UsesConnectedApp() bool { + return c.usesConnectedApp } // Config describes how to reach a Tableau site and how to authenticate to it. @@ -144,10 +155,11 @@ func New(ctx context.Context, cfg Config) (*Client, error) { } return &Client{ - httpClient: baseHttpClient, - baseUrl: baseURL, - authToken: credentials.Token, - siteId: credentials.Site.ID, + httpClient: baseHttpClient, + baseUrl: baseURL, + authToken: credentials.Token, + siteId: credentials.Site.ID, + usesConnectedApp: cfg.ConnectedApp != nil, }, nil } diff --git a/pkg/client/jwt.go b/pkg/client/jwt.go index eea5b5bc..36dae796 100644 --- a/pkg/client/jwt.go +++ b/pkg/client/jwt.go @@ -25,26 +25,36 @@ const jwtLifetime = 5 * time.Minute // connected app. For direct trust this claim is the only scope control there // is: the app itself carries no scope list, and its access level and domain // allowlist govern embedding rather than the REST API. The list must therefore -// cover every endpoint this package calls — sites and content reads, the full -// user and group lifecycle for provisioning, and reading and writing -// permissions on projects, workbooks, and views. +// cover every endpoint this package calls, and no more. A leaked session is +// bounded by this claim, so each entry has to earn its place. +// +// The list deliberately omits tableau:projects:* and tableau:workbooks:*. This +// package never creates, updates, moves, publishes, downloads or deletes a +// project or a workbook: it only reads them, which tableau:content:read covers, +// and changes their permissions, which the permission scopes cover. Those two +// wildcards would add project deletion and workbook publishing to the blast +// radius for no gain. // // Permission reads need their own scope. Grant sync enumerates project, // workbook, and view ACLs on every run, and Tableau gates those GETs behind // tableau:permissions:read rather than tableau:content:read — omit it and // sign-in succeeds while the first site with content fails mid-sync. // +// The user and group scopes stay as wildcards because Tableau documents no +// granular alternative that covers what provisioning does. It publishes +// tableau:users:create and tableau:users:delete but nothing for the site role +// update the licence path performs, and nothing granular for groups at all. +// // Tableau publishes no scope covering /site-auth-configurations. A connected // app session is refused there with 401 401002 where a personal access token // succeeds, so the account-creation path treats that refusal as discovery -// being unavailable and falls back to the site default. +// being unavailable — but only for a connected app — and falls back to the +// site default. var connectedAppScopes = []string{ "tableau:content:read", "tableau:sites:read", "tableau:users:*", "tableau:groups:*", - "tableau:projects:*", - "tableau:workbooks:*", "tableau:permissions:read", "tableau:permissions:update", "tableau:permissions:delete", diff --git a/pkg/client/jwt_test.go b/pkg/client/jwt_test.go index 6ab9e570..102f1a0c 100644 --- a/pkg/client/jwt_test.go +++ b/pkg/client/jwt_test.go @@ -73,6 +73,12 @@ func TestNewConnectedAppJWT_Structure(t *testing.T) { // Tableau gates those reads behind their own scope. Its absence would not // show up until a sync against a site with content. require.Contains(t, scopes, "tableau:permissions:read") + + // The connector reads projects and workbooks and edits their permissions, + // but never writes the objects themselves. Requesting these wildcards would + // hand a leaked session project deletion and workbook publishing. + require.NotContains(t, scopes, "tableau:projects:*") + require.NotContains(t, scopes, "tableau:workbooks:*") } // TestNewConnectedAppJWT_Signature recomputes the HMAC over the signing input diff --git a/pkg/connector/idp_discovery_test.go b/pkg/connector/idp_discovery_test.go index b43d073f..b760452d 100644 --- a/pkg/connector/idp_discovery_test.go +++ b/pkg/connector/idp_discovery_test.go @@ -37,7 +37,7 @@ func idpDiscoveryServer(t *testing.T, discoveryStatus int) *httptest.Server { func newTestUserBuilder(t *testing.T, server *httptest.Server) *userBuilder { t.Helper() - c, err := client.New(context.Background(), client.Config{ + cfg := client.Config{ SiteID: "thg_plc", BaseURLOverride: server.URL, ConnectedApp: &client.ConnectedApp{ @@ -46,6 +46,27 @@ func newTestUserBuilder(t *testing.T, server *httptest.Server) *userBuilder { SecretValue: "secret-value", Username: "svc.tableau@example.com", }, + } + + c, err := client.New(context.Background(), cfg) + require.NoError(t, err) + + return newUserBuilder(c) +} + +// newTestUserBuilderWithPAT is the personal access token counterpart. Tableau +// answers sign-in identically for both credentials, so the only difference that +// reaches selectIDPConfiguration is which credential opened the session. +func newTestUserBuilderWithPAT(t *testing.T, server *httptest.Server) *userBuilder { + t.Helper() + + c, err := client.New(context.Background(), client.Config{ + SiteID: "thg_plc", + BaseURLOverride: server.URL, + PersonalAccessToken: &client.PersonalAccessToken{ + Name: "conductor1", + Secret: "secret", + }, }) require.NoError(t, err) @@ -73,6 +94,44 @@ func TestSelectIDPConfiguration_DiscoveryRefused(t *testing.T) { } } +// TestSelectIDPConfiguration_PersonalAccessToken draws the line the connected +// app fallback must not cross. A personal access token is entitled to the +// site-auth-configurations endpoint, so 401 and 403 mean that credential is +// genuinely failing and must surface. Creating the account on the site default +// instead would silently give it the wrong authentication type. Only the 404, +// which means the endpoint is not routed at all, still degrades. +func TestSelectIDPConfiguration_PersonalAccessToken(t *testing.T) { + t.Parallel() + + tests := []struct { + discoveryStatus int + wantFallback bool + }{ + {http.StatusUnauthorized, false}, + {http.StatusForbidden, false}, + {http.StatusNotFound, true}, + } + + for _, test := range tests { + t.Run(http.StatusText(test.discoveryStatus), func(t *testing.T) { + t.Parallel() + + server := idpDiscoveryServer(t, test.discoveryStatus) + defer server.Close() + + idpID, err := newTestUserBuilderWithPAT(t, server).selectIDPConfiguration(context.Background(), "") + if test.wantFallback { + require.NoError(t, err) + require.Empty(t, idpID) + return + } + + require.Error(t, err, "an authorization failure on a personal access token is a real error") + require.ErrorContains(t, err, "failed to list IDP configurations") + }) + } +} + // TestSelectIDPConfiguration_DiscoveryRefusedWithExplicitName is the other half: // once the caller names an IDP, silently ignoring it would put the account on // the wrong authentication type, so the refusal has to surface. diff --git a/pkg/connector/live_test.go b/pkg/connector/live_test.go index 3b27bcfe..47c89e43 100644 --- a/pkg/connector/live_test.go +++ b/pkg/connector/live_test.go @@ -9,6 +9,8 @@ import ( v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/pagination" + rs "github.com/conductorone/baton-sdk/pkg/types/resource" "github.com/conductorone/baton-tableau/pkg/client" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/structpb" @@ -24,6 +26,12 @@ func liveClient(t *testing.T) *client.Client { t.Skip("set BATON_TABLEAU_LIVE=1 to run against a live Tableau site") } + // These tests read state back immediately after changing it, and the SDK + // caches GET responses. Left enabled, a permission read issued after a + // revoke is served from that cache and still reports the grant, which is + // indistinguishable from a revoke that did nothing. + t.Setenv("BATON_DISABLE_HTTP_CACHE", "true") + c, err := client.New(context.Background(), client.Config{ ServerPath: os.Getenv("BATON_SERVER_PATH"), SiteID: os.Getenv("BATON_SITE_ID"), @@ -121,12 +129,15 @@ const viewCapabilitySlug = "View" // permissionEntitlement builds the entitlement shape the permission builders // expect. Grant and Revoke read the capability from the third colon-separated // field of the entitlement ID, and the target from the entitlement resource. -func permissionEntitlement(resourceType, resourceID, displaySlug string) *v2.Entitlement { +// +// The resource is passed through rather than rebuilt from its identifiers, +// because the view builder reaches for the parent workbook on this resource to +// decide whether showTabs blocks the write. Rebuilding it would drop the parent +// and silently skip that check. +func permissionEntitlement(resource *v2.Resource, displaySlug string) *v2.Entitlement { return &v2.Entitlement{ - Id: fmt.Sprintf("%s:%s:%s", resourceType, resourceID, displaySlug), - Resource: &v2.Resource{ - Id: &v2.ResourceId{ResourceType: resourceType, Resource: resourceID}, - }, + Id: fmt.Sprintf("%s:%s:%s", resource.Id.ResourceType, resource.Id.Resource, displaySlug), + Resource: resource, } } @@ -172,47 +183,104 @@ func TestLivePermissions(t *testing.T) { Id: &v2.ResourceId{ResourceType: resourceTypeUser.Id, Resource: user.ID}, } + // The view resource carries its parent workbook, because the view builder + // only consults showTabs when the parent is present. Omitting it would skip + // the guard that production always runs. + viewResource := &v2.Resource{ + Id: &v2.ResourceId{ResourceType: resourceTypeView.Id, Resource: viewID}, + } + if workbookID != "" { + viewResource.ParentResourceId = &v2.ResourceId{ + ResourceType: resourceTypeWorkbook.Id, Resource: workbookID, + } + } + cases := []struct { - name string - resourceType string - resourceID string - slug string - grant func(context.Context, *v2.Resource, *v2.Entitlement) (annotations.Annotations, error) - revoke func(context.Context, *v2.Grant) (annotations.Annotations, error) + name string + resource *v2.Resource + slug string + grant func(context.Context, *v2.Resource, *v2.Entitlement) (annotations.Annotations, error) + revoke func(context.Context, *v2.Grant) (annotations.Annotations, error) + grants func(context.Context, *v2.Resource, rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error) }{ { - name: "project", resourceType: resourceTypeProject.Id, resourceID: projectID, slug: viewCapabilitySlug, - grant: newProjectBuilder(c).Grant, revoke: newProjectBuilder(c).Revoke, + name: "project", + resource: &v2.Resource{Id: &v2.ResourceId{ResourceType: resourceTypeProject.Id, Resource: projectID}}, + slug: viewCapabilitySlug, + grant: newProjectBuilder(c).Grant, revoke: newProjectBuilder(c).Revoke, grants: newProjectBuilder(c).Grants, }, { // "Workbook / View" is a default workbook capability on the project, // which takes a different Tableau endpoint to a plain project grant. - name: "project default workbook", resourceType: resourceTypeProject.Id, resourceID: projectID, slug: "Workbook / View", - grant: newProjectBuilder(c).Grant, revoke: newProjectBuilder(c).Revoke, + name: "project default workbook", + resource: &v2.Resource{Id: &v2.ResourceId{ResourceType: resourceTypeProject.Id, Resource: projectID}}, + slug: "Workbook / View", + grant: newProjectBuilder(c).Grant, revoke: newProjectBuilder(c).Revoke, grants: newProjectBuilder(c).Grants, }, { - name: "workbook", resourceType: resourceTypeWorkbook.Id, resourceID: workbookID, slug: viewCapabilitySlug, - grant: newWorkbookBuilder(c).Grant, revoke: newWorkbookBuilder(c).Revoke, + name: "workbook", + resource: &v2.Resource{Id: &v2.ResourceId{ResourceType: resourceTypeWorkbook.Id, Resource: workbookID}}, + slug: viewCapabilitySlug, + grant: newWorkbookBuilder(c).Grant, revoke: newWorkbookBuilder(c).Revoke, grants: newWorkbookBuilder(c).Grants, }, { - name: "view", resourceType: resourceTypeView.Id, resourceID: viewID, slug: viewCapabilitySlug, - grant: newViewBuilder(c).Grant, revoke: newViewBuilder(c).Revoke, + name: "view", + resource: viewResource, + slug: viewCapabilitySlug, + grant: newViewBuilder(c).Grant, revoke: newViewBuilder(c).Revoke, grants: newViewBuilder(c).Grants, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - if tc.resourceID == "" { + if tc.resource.Id.Resource == "" { t.Skipf("no id supplied for %s", tc.name) } - entitlement := permissionEntitlement(tc.resourceType, tc.resourceID, tc.slug) + entitlement := permissionEntitlement(tc.resource, tc.slug) _, err := tc.grant(ctx, principal, entitlement) require.NoError(t, err, "granting %s permission must work under a connected app", tc.name) + require.True(t, principalHasGrant(ctx, t, tc.grants, tc.resource, user.ID), + "the granted %s permission must be visible through the permission read path", tc.name) + _, err = tc.revoke(ctx, &v2.Grant{Principal: principal, Entitlement: entitlement}) require.NoError(t, err, "revoking %s permission must work under a connected app", tc.name) + + require.False(t, principalHasGrant(ctx, t, tc.grants, tc.resource, user.ID), + "the revoked %s permission must be gone from the permission read path", tc.name) }) } } + +// principalHasGrant reports whether the permission read path reports any grant +// for the given principal. Reading the permissions back is what exercises +// tableau:permissions:read, and it verifies the revoke actually undid the grant +// rather than merely returning without an error. +func principalHasGrant( + ctx context.Context, + t *testing.T, + grants func(context.Context, *v2.Resource, rs.SyncOpAttrs) ([]*v2.Grant, *rs.SyncOpResults, error), + resource *v2.Resource, + principalID string, +) bool { + t.Helper() + + var token string + for { + found, results, err := grants(ctx, resource, rs.SyncOpAttrs{PageToken: pagination.Token{Token: token}}) + require.NoError(t, err, "reading permissions back must work under a connected app") + + for _, g := range found { + if g.Principal.GetId().GetResource() == principalID { + return true + } + } + + if results == nil || results.NextPageToken == "" { + return false + } + token = results.NextPageToken + } +} diff --git a/pkg/connector/user.go b/pkg/connector/user.go index 96c15fbe..a9dc2f77 100644 --- a/pkg/connector/user.go +++ b/pkg/connector/user.go @@ -164,7 +164,7 @@ func (u *userBuilder) selectIDPConfiguration(ctx context.Context, idpConfigName if idpConfigName != "" { cfg, _, err := u.client.FindIdpConfigurationByName(ctx, idpConfigName) if err != nil { - if idpDiscoveryUnavailable(err) { + if idpDiscoveryUnavailable(err, u.client.UsesConnectedApp()) { return "", uhttp.WrapErrors(codes.InvalidArgument, fmt.Sprintf("IDP configuration '%s' cannot be resolved: site-auth-configurations endpoint unavailable;"+ " upgrade to Tableau Server 2023.3+ (API 3.22+), switch to a personal access token if signing in"+ @@ -186,14 +186,13 @@ func (u *userBuilder) selectIDPConfiguration(ctx context.Context, idpConfigName enabledConfigs, _, err := u.client.ListEnabledIdpConfigurations(ctx) if err != nil { - if idpDiscoveryUnavailable(err) { - // Discovery is out of reach: the endpoint does not exist on older - // Tableau Server (<2023.3 / API <3.22), and Tableau publishes no - // connected app scope covering it, so a JWT session is refused - // outright. No idpConfigurationName was requested, so preserve the - // original behavior in both cases: proceed with no auth fields and - // let Tableau use the site default. A credential that is broken - // rather than merely unscoped fails on the very next call. + if idpDiscoveryUnavailable(err, u.client.UsesConnectedApp()) { + // Discovery is out of reach: either the endpoint is not routed, or a + // connected app has been refused at an endpoint Tableau publishes no + // scope for. No idpConfigurationName was requested, so preserve the + // original behavior and let Tableau apply the site default. A + // credential that is genuinely broken rather than merely unscoped + // does not reach here, and fails on the very next call regardless. return "", nil } return "", fmt.Errorf("failed to list IDP configurations: %w", err) @@ -226,14 +225,24 @@ func newUserBuilder(client *client.Client) *userBuilder { } // idpDiscoveryUnavailable reports whether err means the site-auth-configurations -// endpoint cannot answer at all, as opposed to answering with a result the -// caller dislikes. Tableau Server before 2023.3 does not route the path, and -// Tableau publishes no connected app scope for it, so a JWT session is turned -// away with an authorization failure rather than a 404. -func idpDiscoveryUnavailable(err error) bool { +// endpoint cannot answer, as opposed to answering with a result the caller +// dislikes. +// +// A 404 means the path is not routed, which is how Tableau Server before 2023.3 +// (REST API 3.22) behaves, and is unavailability whichever credential asked. +// +// An authorization failure is only unavailability under a connected app. +// Tableau publishes no access scope covering this endpoint, so a connected app +// session is refused with 401 401002 no matter how the app is configured. A +// personal access token is entitled to the endpoint, so the same status means +// something is genuinely wrong with that credential and must not be mistaken +// for a missing feature. +func idpDiscoveryUnavailable(err error, usesConnectedApp bool) bool { switch status.Code(err) { - case codes.NotFound, codes.Unauthenticated, codes.PermissionDenied: + case codes.NotFound: return true + case codes.Unauthenticated, codes.PermissionDenied: + return usesConnectedApp default: return false }