Skip to content

Support connected apps as an alternative to personal access tokens - #52

Open
aberoham wants to merge 5 commits into
ConductorOne:mainfrom
aberoham:connected-app-jwt
Open

Support connected apps as an alternative to personal access tokens#52
aberoham wants to merge 5 commits into
ConductorOne:mainfrom
aberoham:connected-app-jwt

Conversation

@aberoham

@aberoham aberoham commented Aug 11, 2026

Copy link
Copy Markdown

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.go builds 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.

Login now 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.go drops required from 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:content:read        tableau:sites:read
tableau:users:*             tableau:groups:*
tableau:permissions:read    tableau:permissions:update
tableau:permissions:delete

tableau:permissions:read is 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 and tableau:content:read does not cover them.

The set leaves out tableau:projects:* and tableau: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 with 401002, where a personal access token succeeds. The fallback added in #41 caught only a 404, so account creation failed before it reached AddUserToSite.

selectIDPConfiguration now 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 ./... and golangci-lint run pass. config_schema.json and baton_capabilities.json are 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.go talks to a real site and is skipped unless BATON_TABLEAU_LIVE is 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.

aberoham and others added 3 commits August 11, 2026 18:25
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 and others added 2 commits August 14, 2026 19:19
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support Tableau Connected Apps (Direct Trust) as an alternative to PATs

1 participant