Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
33b2565
[CXH-2368] - Fix license grant rewriting the site role of a user who …
sergiocorral-conductorone Oct 16, 2026
d721c26
[CXH-2368] - Address review feedback on license grant/revoke
sergiocorral-conductorone Oct 20, 2026
a9a7016
[CXH-2368] - Reword comments and docs to describe current behavior
sergiocorral-conductorone Oct 21, 2026
d3273cc
[CXH-2368] - Bypass response cache in GetUser and cover stale-read cases
sergiocorral-conductorone Oct 27, 2026
06ef62d
[CXH-2368] - Reword ServerAdministrator notes to match Tableau docs
sergiocorral-conductorone Oct 27, 2026
094978f
[CXH-2368] - Drop duplicate warn log on admin license grant refusal
sergiocorral-conductorone Oct 27, 2026
491f8ad
fix: treat Unlicensed license revoke as an annotated no-op
sergiocorral-conductorone Oct 27, 2026
7871521
docs: clarify tier-aware license provisioning and floor-role no-op
sergiocorral-conductorone Oct 27, 2026
2b4009a
[CXH-2368] - Declare license tiers as one exclusion group and tighten…
sergiocorral-conductorone Oct 29, 2026
42f1601
[CXH-2368] - Consolidate license tests in license_test.go
sergiocorral-conductorone Oct 29, 2026
9977f65
[CXH-2368] - Address review: wrap NotFound, simplify replaced-grant c…
sergiocorral-conductorone Nov 4, 2026
94370d8
Merge main into sergiocorral/cxh-2368-baton-tableau-fix-license-grant…
sergiocorral-conductorone Nov 4, 2026
e04a03f
[CXH-2368] - Return InvalidArgument for an unknown license tier
sergiocorral-conductorone Nov 4, 2026
9955e0d
Merge origin/main into sergiocorral/cxh-2368-baton-tableau-fix-licens…
sergiocorral-conductorone Nov 6, 2026
fad2cab
[CXH-2368] - Return the new grant from license Grant (V2 signature)
sergiocorral-conductorone Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,8 @@ baton resources
- **Server Administrators**: Users with `ServerAdministrator` site role are skipped during group membership sync (server-level admins are not site-scoped).
- **Grant expansion**: Group-based permissions on projects, workbooks, and views are supported. When a group has a permission, all members inherit it. Revoking such a permission from a single member does not remove their access: Tableau has no user-level assignment to remove, so the revoke returns `GrantAlreadyRevoked` and the next sync derives the permission from the group again. Revoke from the group, or remove the user from the group.
- **"All Users" group**: Tableau automatically adds every user to the built-in "All Users" group. This group cannot be modified via the API: Grant is a no-op because the user is already a member, and Revoke returns an error. The connector syncs its membership to show each user's base site role.
- **License revoke constraints**: Revoking a license (setting a user to "Unlicensed") will fail if the user belongs to a group with a "Grant role on sign in" minimum site role. Remove the user from the constraining group first, then revoke the license.
- **License grant is tier-aware**: A tier is a view of the user's single site role (for example, `SiteAdministratorCreator` already satisfies Creator), and the four tiers form one exclusion group (`tableau-license`) with no default tier or ranking. Granting a tier the user already holds is a no-op that keeps their role and returns `GrantAlreadyExists`. Granting a different tier to a user holding `SiteAdministrator`, `SiteAdministratorCreator`, `SiteAdministratorExplorer`, or `ServerAdministrator` fails with `FailedPrecondition`: change those roles through the Site Role entitlement (it assigns `SiteAdministrator*`; this connector does not assign `ServerAdministrator`). Any other user is set to the tier's base role, with `GrantReplaced` only when the previous role belonged to a recognized tier. A missing user returns `NotFound`.
- **License revoke sets the user to Unlicensed**: A user who still holds the tier is set to `Unlicensed`, including the four admin roles above. A missing user, or one who no longer holds the tier, returns `GrantAlreadyRevoked` with no write; so does revoking Unlicensed from an Unlicensed user, and the next sync still shows that user as Unlicensed. Re-assign `SiteAdministrator*` roles through the Site Role entitlement. `ServerAdministrator` exists only on Tableau Server and this connector does not assign it, so a revoked Server Administrator stays Unlicensed until the role is restored in Tableau. Revoke also fails if the user belongs to a group with a "Grant role on sign in" minimum site role; remove them from that group first.
- **Locked project names**: Inherited workbook grants for locked projects are found by filtering workbooks on the project name, and Tableau rejects filters containing commas (`,`) or ampersands (`&`). Locked projects whose names contain these characters are skipped for inherited workbook grants and a warning is logged. Rename those projects to sync their inherited access.
- **Idempotency**: Granting a permission that already exists returns success (not an error). Revoking a permission that is already removed also returns success.

Expand Down
3 changes: 3 additions & 0 deletions docs/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ In Tableau, licensing is role-based: each user's **site role** determines their
When your Tableau site has per-tier seat capacities configured, C1 tracks the purchased and consumed seat counts for each license tier, so you can review license utilization alongside who holds each tier.
</Note>

Because a tier is a view of the user's single site role, several roles can satisfy one tier (for example, a Site Administrator Creator counts as Creator), and the four tiers are mutually exclusive in C1 with no default tier or ranking. Granting a tier the user already holds changes nothing and keeps their current role. Administrators are refused when you grant them a different tier: change their role through the Site Role entitlement instead. For any other user, granting a tier sets their site role to that tier's base role, and C1 replaces their previous tier grant when the previous role belonged to a recognized tier. Granting to a user that no longer exists returns a not-found error.

Revoking a tier a user still holds sets them to Unlicensed, including users with an administrator role. Revoking from a user who no longer exists, or who no longer holds the tier, changes nothing; the same applies to revoking Unlicensed from an Unlicensed user, and the next sync still shows that user as Unlicensed. To give a revoked administrator their role back, assign it through the Site Role entitlement. Server Administrator exists only on Tableau Server and this connector cannot assign it, so a revoked Server Administrator stays Unlicensed until a server administrator restores the role in Tableau. Revoke also fails if the user belongs to a group with a "Grant role on sign in" minimum site role; remove them from that group first.
<Note>
Revoking a license fails if the user belongs to a group with a **Grant role on sign in** minimum site role. Tableau returns `Site role of link user that belongs to license upon login cannot be lower than minimum site role of group`. Remove the user from that group first, then revoke the license.
</Note>
Expand Down
13 changes: 8 additions & 5 deletions docs/docs-info.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
> | Viewer | `Viewer` | Yes | |
> | Unlicensed | `Unlicensed` | Yes | |
> | Site Administrator | `SiteAdministrator` | Yes | Legacy role, maps to SiteAdministratorExplorer |
> | Server Administrator | `ServerAdministrator` | Sync only | Server-level role, appears in sync but cannot be assigned via the REST API |
> | Server Administrator | `ServerAdministrator` | Sync only | Exists only on Tableau Server; appears in sync, and this connector does not assign it |

### Users

Expand Down Expand Up @@ -187,7 +187,7 @@
- `Viewer` — View and interact with published dashboards
- `Unlicensed` — No access to Tableau site features
- **Entitlements**:
- `member` — License is assigned to user (static entitlement)
- `member` — License is assigned to user (static entitlement); all four tiers share exclusion group `tableau-license` (unscoped, no default or order)
- **Seat counts** (License Profile trait):
- **Purchased** — read from the site's per-tier capacity fields (`tierCreatorCapacity`, `tierExplorerCapacity`, `tierViewerCapacity`), which the Tableau API returns as JSON strings. Attached only when the value is a positive number; a `0` or absent capacity attaches no seats (avoids rendering "0 of 0"). The `Unlicensed` floor tier is never capped.
- **Consumed** — counted server-side per tier via the users `siteRole` filter, reading `pagination.totalAvailable` (one request per tier instead of walking every user page).
Expand All @@ -205,7 +205,7 @@
> | Viewer | `Viewer` |
> | Unlicensed | `Unlicensed` |
>
> `SiteAdministrator` is a legacy role (maps to `SiteAdministratorExplorer`) that cannot be used with the Tableau REST API filter endpoint, so the connector fetches all users and filters client-side when processing Explorer license grants. `ServerAdministrator` consumes a Creator license per Tableau's licensing model and is counted under the Creator tier (sync only — it cannot be assigned via the sites API). See [Tableau REST API Site Roles](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_new_site_roles.htm) for the full role reference.
> `SiteAdministrator` is a legacy role (maps to `SiteAdministratorExplorer`) that cannot be used with the Tableau REST API filter endpoint, so the connector fetches all users and filters client-side when processing Explorer license grants. `ServerAdministrator` consumes a Creator license per Tableau's licensing model and is counted under the Creator tier (sync only — this connector does not assign it). See [Tableau REST API Site Roles](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_new_site_roles.htm) for the full role reference.

> **Important — License Revoke Constraints**: Revoking a license (setting a user to "Unlicensed") will fail if the user belongs to a group with "Grant role on sign in" configured. Tableau enforces that a user's site role cannot be lower than the minimum site role required by any group they belong to. The error message from Tableau will be: *"Site role of link user that belongs to license upon login cannot be lower than minimum site role of group"*. To resolve this, remove the user from the constraining group first, then revoke the license.

Expand Down Expand Up @@ -319,12 +319,15 @@

- Users with unknown or unexpected site roles (not in the connector's role map) are **skipped** during site grant sync instead of creating malformed grants. A warning is logged.
- The role map covers all valid [Tableau REST API site roles](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_new_site_roles.htm): `SiteAdministratorCreator`, `SiteAdministratorExplorer`, `SiteAdministrator`, `Creator`, `Explorer`, `ExplorerCanPublish`, `Viewer`, `Unlicensed`.
- `ServerAdministrator` is synced (read-only) and consumes a Creator license, but it is never a grant target — the assignable license is Creator, and the `ServerAdministrator` role itself cannot be set via the sites API.
- `ServerAdministrator` is synced (read-only) and consumes a Creator license, but it is never a grant target — the assignable license is Creator, and this connector does not assign the `ServerAdministrator` role itself. Revoking a tier from a Server Administrator leaves the user Unlicensed, and the role has to be restored by a server administrator in Tableau.
Comment thread
sergiocorral-conductorone marked this conversation as resolved.
Resolved

### License Grants

- The Explorer license includes `SiteAdministrator` (legacy role). This role is not supported by the Tableau API's `siteRole` filter, so the connector falls back to fetching all users and filtering client-side for the Explorer license.
- For Creator, Viewer, and Unlicensed licenses, server-side filtering is used for efficiency.
- **Grant** is tier-aware: a user whose site role already belongs to the target tier gets `GrantAlreadyExists` with no write and keeps their role. A different tier for a user holding `SiteAdministrator`, `SiteAdministratorCreator`, `SiteAdministratorExplorer`, or `ServerAdministrator` fails with `FailedPrecondition` (the Site Role entitlement assigns the `SiteAdministrator*` roles; this connector does not assign `ServerAdministrator`). Any other user is set to the tier's base role, with `GrantReplaced` only when the previous role belonged to a recognized tier. A missing user returns `NotFound`.
- **Revoke** sets a user who still holds the tier to `Unlicensed`, including the four admin roles above. A missing user, or one who no longer holds the tier, returns `GrantAlreadyRevoked` without a write. Revoking the Unlicensed tier from an already Unlicensed user is the same successful no-op; the next sync still shows Unlicensed in C1 because it is the floor role and current state.
- Grant and Revoke read the user first without caching and merge the read's rate-limit annotations into every result.
- Consumed-seat counts reuse the same server-side `siteRole` filter but read only `pagination.totalAvailable` per tier (no full user walk). The Explorer count excludes the unfilterable legacy `SiteAdministrator` role, which does not exist on Tableau Cloud.

### View Grants and showTabs
Expand Down Expand Up @@ -417,7 +420,7 @@ Tested against: `us-east-1.online.tableau.com` (Tableau Cloud)
| viewer | OK | OK | |
| unlicensed | OK | OK | |
| site administrator | OK | OK | Legacy role (maps to SiteAdministratorExplorer) |
| server administrator | Blocked | N/A | Read-only: server-level role, cannot be assigned via REST API |
| server administrator | Blocked | N/A | Read-only: exists only on Tableau Server; this connector does not assign it |

### Group Membership

Expand Down
35 changes: 32 additions & 3 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
//
// Users:
// - GET /api/{version}/sites/{siteId}/users - List users (paginated)
// - GET /api/{version}/sites/{siteId}/users/{userId} - Verify current user
// - GET /api/{version}/sites/{siteId}/users/{userId} - Verify current user / get a single user
// - POST /api/{version}/sites/{siteId}/users - Add user to site
// - PUT /api/{version}/sites/{siteId}/users/{userId} - Update user site role
// - DELETE /api/{version}/sites/{siteId}/users/{userId} - Remove user from site
Expand Down Expand Up @@ -73,6 +73,8 @@ import (
"github.com/conductorone/baton-sdk/pkg/annotations"
"github.com/conductorone/baton-sdk/pkg/uhttp"
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// =============================================================================
Expand Down Expand Up @@ -278,6 +280,25 @@ func (c *Client) AddUserToSite(ctx context.Context, user CreateUserRequest) (*Us
return res.User, annos, nil
}

// GetUser returns a single user by ID. Callers can distinguish a missing
// user from other failures with IsNotFoundError.
func (c *Client) GetUser(ctx context.Context, userId string) (*User, annotations.Annotations, error) {
urlGetUser, err := c.buildSiteURL(pathUsers, userId)
if err != nil {
return nil, nil, err
}

var res userResponse
// Bypass the SDK response cache: provisioning reads the role it is about to
// act on, and a cached copy goes stale after this client's own writes.
annos, err := c.doRequest(ctx, http.MethodGet, urlGetUser, &res, nil, uhttp.WithNoCache())
if err != nil {
return nil, annos, err
}

return &res.User, annos, nil
}

// UpdateUserSiteRole updates a user's site role.
func (c *Client) UpdateUserSiteRole(ctx context.Context, userId, siteRole string) (annotations.Annotations, error) {
urlUpdateUserSiteRole, err := c.buildSiteURL(pathUsers, userId)
Expand Down Expand Up @@ -752,8 +773,15 @@ func permissionBody(granteeType, granteeID, capabilityName, capabilityMode strin
}
}

// doRequest executes an HTTP request against the Tableau API.
func (c *Client) doRequest(ctx context.Context, method string, url *url.URL, res, body any) (annotations.Annotations, error) {
// IsNotFoundError reports whether err is the error doRequest returns for an
// HTTP 404 response (e.g. GetUser on a deleted/unknown user ID).
func IsNotFoundError(err error) bool {
return status.Code(err) == codes.NotFound
}

// doRequest executes an HTTP request against the Tableau API. Extra request
// options are applied after the defaults.
func (c *Client) doRequest(ctx context.Context, method string, url *url.URL, res, body any, extraOpts ...uhttp.RequestOption) (annotations.Annotations, error) {
reqOpts := []uhttp.RequestOption{
uhttp.WithAcceptJSONHeader(),
uhttp.WithContentTypeJSONHeader(),
Expand All @@ -762,6 +790,7 @@ func (c *Client) doRequest(ctx context.Context, method string, url *url.URL, res
if body != nil {
reqOpts = append(reqOpts, uhttp.WithJSONBody(body))
}
reqOpts = append(reqOpts, extraOpts...)

req, err := c.httpClient.NewRequest(ctx, method, url, reqOpts...)
if err != nil {
Expand Down
71 changes: 71 additions & 0 deletions pkg/client/client_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package client

import (
"context"
"net/http"
"net/http/httptest"
"testing"

"github.com/conductorone/baton-sdk/pkg/uhttp"
"github.com/stretchr/testify/require"
)

const testSiteId = "site-1"

// newTestClient builds a Client wired directly to srv, bypassing New's
// Login/auth-signin flow so tests only exercise the endpoint under test.
func newTestClient(t *testing.T, srv *httptest.Server) *Client {
t.Helper()

base, err := uhttp.NewBaseHttpClientWithContext(context.Background(), srv.Client())
require.NoError(t, err)

return &Client{
httpClient: base,
baseUrl: srv.URL,
authToken: "test-token",
siteId: testSiteId,
}
}

func TestGetUser_Found(t *testing.T) {
t.Parallel()

wantPath := "/sites/" + testSiteId + "/users/user-1"

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != wantPath {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"user":{"id":"user-1","email":"user1@example.com","siteRole":"SiteAdministratorCreator"}}`))
}))
t.Cleanup(srv.Close)

c := newTestClient(t, srv)

user, _, err := c.GetUser(context.Background(), "user-1")
require.NoError(t, err)
require.NotNil(t, user)
require.Equal(t, "user-1", user.ID)
require.Equal(t, "SiteAdministratorCreator", user.SiteRole)
}

func TestGetUser_NotFound(t *testing.T) {
t.Parallel()

srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"summary":"Not Found","detail":"User not found","code":"0x1"}}`))
}))
t.Cleanup(srv.Close)

c := newTestClient(t, srv)

user, _, err := c.GetUser(context.Background(), "missing-user")
require.Error(t, err)
require.Nil(t, user)
require.True(t, IsNotFoundError(err), "expected IsNotFoundError(err) to be true, got err: %v", err)
}
2 changes: 1 addition & 1 deletion pkg/connector/group.go
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ func (g *groupBuilder) Revoke(ctx context.Context, grant *v2.Grant) (annotations

annos, err := g.client.RemoveUserFromGroup(ctx, entitlement.Resource.Id.Resource, principal.Id.Resource)
if err != nil {
if isNotFoundError(err) {
if client.IsNotFoundError(err) {
return annotations.New(&v2.GrantAlreadyRevoked{}), nil
}
return annos, fmt.Errorf("failed to remove user from group: %w", err)
Expand Down
24 changes: 20 additions & 4 deletions pkg/connector/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package connector
import (
"context"
"fmt"
"slices"
"strings"

v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
Expand All @@ -13,6 +14,7 @@ import (
"github.com/conductorone/baton-tableau/pkg/client"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/anypb"
)

const (
Expand Down Expand Up @@ -208,9 +210,23 @@ func isAlreadyExistsError(err error) bool {
return status.Code(err) == codes.AlreadyExists
}

// isNotFoundError returns true if the error indicates the resource was not found (HTTP 404).
func isNotFoundError(err error) bool {
return status.Code(err) == codes.NotFound
// mergeAnnotations concatenates annotation sets in order. When the same
// message type appears more than once (e.g. a RateLimitDescription from each
// of several API calls), the most recent one wins so a result carries at most
// one entry per type.
func mergeAnnotations(sets ...annotations.Annotations) annotations.Annotations {
var merged annotations.Annotations
for _, set := range sets {
for _, a := range set {
idx := slices.IndexFunc(merged, func(m *anypb.Any) bool { return m.GetTypeUrl() == a.GetTypeUrl() })
if idx >= 0 {
merged[idx] = a
continue
}
merged = append(merged, a)
}
}
return merged
}

// permissionFunc is the signature shared by all Add/Delete permission client methods.
Expand Down Expand Up @@ -297,7 +313,7 @@ func revokePermission(
}

if err != nil {
if isNotFoundError(err) {
if client.IsNotFoundError(err) {
return annotations.New(&v2.GrantAlreadyRevoked{}), nil
}
return annos, fmt.Errorf("failed to revoke permission: %w", err)
Expand Down
Loading
Loading