Support connected apps as an alternative to personal access tokens - #52
Open
aberoham wants to merge 5 commits into
Open
Support connected apps as an alternative to personal access tokens#52aberoham wants to merge 5 commits into
aberoham wants to merge 5 commits into
Conversation
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 ConductorOne#51
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.
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019tSKhw6At6c1S2oNLqr4zg
aberoham
force-pushed
the
connected-app-jwt
branch
from
August 14, 2026 18:09
71709d7 to
db1428e
Compare
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019tSKhw6At6c1S2oNLqr4zg
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019tSKhw6At6c1S2oNLqr4zg
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #51.
The connector can only authenticate with a personal access token. That token belongs to a person, so the integration depends on that person's account. Tableau expires every one of these tokens anyway, after 1 to 365 days depending on site policy, and after 15 days without use. Nothing makes one permanent.
This adds a second option: a direct trust connected app, which is a trust relationship held by the site rather than a token owned by a user. The connector signs a short-lived token for each sign-in and posts it to
/auth/signin. You rotate its secret when you choose to.What changed
pkg/client/jwt.gobuilds and signs the token with the connected app's secret. That is a few lines of signing rather than a new dependency, because the connector only ever creates these tokens and never has to check one.Loginnow takes a config struct and picks the credential from it. Both credential types post to the same endpoint and differ only in what they put in the request.pkg/config/config.godropsrequiredfrom the two personal access token fields and adds four for the connected app. Validation accepts one complete set or the other, so existing configurations keep working unchanged. Tests cover that, along with the incomplete and mixed combinations.Scopes
A direct trust app has no scope list of its own, so the scopes travel in the token. The connector asks for a fixed set:
tableau:permissions:readis the easy one to overlook. Sign-in works without it, and the sync then fails on the first site holding any content, because reading project, workbook and view permissions needs it andtableau:content:readdoes not cover them.The set leaves out
tableau:projects:*andtableau:workbooks:*on purpose. The connector reads those objects and edits their permissions, but never creates, changes, moves, publishes or deletes one. Including the wildcards would only let a stolen session delete projects and publish workbooks. A full sync with the shorter list read the same 320 pages of resources as the longer one, with no failures.The user and group scopes stay broad because Tableau publishes no narrower ones that cover what provisioning does.
Identity provider lookup
Tableau publishes no scope for
/site-auth-configurations. It refuses a connected app there with401002, where a personal access token succeeds. The fallback added in #41 caught only a 404, so account creation failed before it reachedAddUserToSite.selectIDPConfigurationnow reads that refusal as the lookup being unavailable and falls back to the site default. Two limits apply. It only does so when the request has not named a provider, because naming one and then ignoring it would give the account the wrong sign-in method. And a 401 or 403 only counts as unavailable for a connected app: a personal access token is entitled to call this endpoint, so the same response means that credential is genuinely failing and the connector reports it.Testing
Run against a live Tableau Cloud site, not only mock servers. A full sync finished with no errors. Creating an account, moving its licence up and down, adding and removing it from a group, and deleting it all worked. Permissions were granted and revoked on a project, on a project's default workbook permissions, on a workbook and on a view, each one read back afterwards to confirm the change took effect.
Taking the fallback out makes account creation fail against the live site with the 401 above, and putting it back makes creation succeed, so it is doing real work rather than guarding against nothing.
go test ./...andgolangci-lint runpass.config_schema.jsonandbaton_capabilities.jsonare regenerated and match the binary output.Two things for you to decide
A difference in behaviour. On a site with more than one identity provider, a personal access token makes the connector stop and ask you to name one. A connected app now takes the site default instead. If you would rather it stopped and asked in both cases, say so and I will change it.
An optional live test.
pkg/connector/live_test.gotalks to a real site and is skipped unlessBATON_TABLEAU_LIVEis set, because it creates and deletes a real account. It uses no licence seat and removes what it creates. I am happy to drop the file if you would rather not carry it.One note now in the setup instructions: Tableau creates a connected app switched off, and a disabled app refuses every sign-in.