diff --git a/README.md b/README.md index 9f645f6e..72b46e64 100644 --- a/README.md +++ b/README.md @@ -86,16 +86,42 @@ 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. 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. 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**: 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. 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. 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 + ## 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..2da7b6b4 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -66,10 +66,46 @@ 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 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. + + + +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. + + +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: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. + + +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, 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 @@ -133,10 +169,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**. @@ -294,8 +330,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/client.go b/pkg/client/client.go index 12c638f5..564c6800 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" @@ -88,18 +89,51 @@ 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. +// 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 +144,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) } @@ -121,10 +155,11 @@ func New(ctx context.Context, serverPath, siteID, accessTokenName, accessTokenSe } 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 } @@ -132,22 +167,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 +218,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..36dae796 --- /dev/null +++ b/pkg/client/jwt.go @@ -0,0 +1,138 @@ +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. 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, 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 — 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:permissions:read", + "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..102f1a0c --- /dev/null +++ b/pkg/client/jwt_test.go @@ -0,0 +1,120 @@ +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:*") + // 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") + + // 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 +// 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", diff --git a/pkg/connector/idp_discovery_test.go b/pkg/connector/idp_discovery_test.go new file mode 100644 index 00000000..b760452d --- /dev/null +++ b/pkg/connector/idp_discovery_test.go @@ -0,0 +1,153 @@ +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() + + cfg := 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", + }, + } + + 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) + + 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_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. +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/live_test.go b/pkg/connector/live_test.go new file mode 100644 index 00000000..47c89e43 --- /dev/null +++ b/pkg/connector/live_test.go @@ -0,0 +1,286 @@ +package connector + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + 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" +) + +// 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") + } + + // 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"), + 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") +} + +// 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. +// +// 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", resource.Id.ResourceType, resource.Id.Resource, displaySlug), + Resource: resource, + } +} + +// 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}, + } + + // 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 + 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", + 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", + 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", + 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", + 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.resource.Id.Resource == "" { + t.Skipf("no id supplied for %s", tc.name) + } + + 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 d9e46672..a9dc2f77 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, 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+) 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,13 @@ 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, 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) @@ -220,6 +224,30 @@ func newUserBuilder(client *client.Client) *userBuilder { } } +// idpDiscoveryUnavailable reports whether err means the site-auth-configurations +// 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: + return true + case codes.Unauthenticated, codes.PermissionDenied: + return usesConnectedApp + default: + return false + } +} + // buildAvailableIDPsError formats an actionable error listing the given IDP configurations. func buildAvailableIDPsError(configs []*client.IdpConfiguration) error { var b strings.Builder