Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ baton resources
- Users
- Groups
- Licenses
- Views (Dashboards)

# Contributing, Support and Issues

Expand Down
6 changes: 6 additions & 0 deletions pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ var (
v2.ResourceType_TRAIT_ROLE,
},
}
resourceTypeView = &v2.ResourceType{
Id: "view",
DisplayName: "View",
Description: "A Tableau dashboard/view",
}
)

type Tableau struct {
Expand Down Expand Up @@ -138,5 +143,6 @@ func (tb *Tableau) ResourceSyncers(ctx context.Context) []connectorbuilder.Resou
siteBuilder(tb.client),
groupBuilder(tb.client),
licenseBuilder(tb.client),
viewBuilder(tb.client),
}
}
19 changes: 16 additions & 3 deletions pkg/connector/group.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,10 @@ import (
"go.uber.org/zap"
)

const memberEntitlement = "member"
const (
memberEntitlement = "member"
siteRoleServerAdmin = "ServerAdministrator"
)

type groupResourceType struct {
resourceType *v2.ResourceType
Expand Down Expand Up @@ -105,7 +108,17 @@ func (g *groupResourceType) Grants(ctx context.Context, resource *v2.Resource, t
return nil, "", nil, err
}

l := ctxzap.Extract(ctx)
for _, user := range users {
if user.SiteRole == siteRoleServerAdmin {
l.Debug(
"baton-tableau: skipping server administrator in group membership (server-level admins are not site-scoped users)",
zap.String("group_id", groupId),
zap.String("user_id", user.ID),
)
continue
}

userCopy := user
ur, err := userResource(&userCopy, resource.Id)
if err != nil {
Expand All @@ -123,7 +136,7 @@ func (o *groupResourceType) Grant(ctx context.Context, principal *v2.Resource, e
l := ctxzap.Extract(ctx)

if principal.Id.ResourceType != resourceTypeUser.Id {
l.Warn(
l.Debug(
"baton-tableau: only users can be granted group membership",
zap.String("principal_type", principal.Id.ResourceType),
zap.String("principal_id", principal.Id.Resource),
Expand All @@ -146,7 +159,7 @@ func (o *groupResourceType) Revoke(ctx context.Context, grant *v2.Grant) (annota
principal := grant.Principal

if principal.Id.ResourceType != resourceTypeUser.Id {
l.Warn(
l.Debug(
"baton-tableau: only users can have group membership revoked",
zap.String("principal_type", principal.Id.ResourceType),
zap.String("principal_id", principal.Id.Resource),
Expand Down
3 changes: 2 additions & 1 deletion pkg/connector/site.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ func siteResource(site tableau.Site) (*v2.Resource, error) {
rs.WithAnnotation(
&v2.ChildResourceType{ResourceTypeId: resourceTypeUser.Id},
&v2.ChildResourceType{ResourceTypeId: resourceTypeGroup.Id},
&v2.ChildResourceType{ResourceTypeId: resourceTypeView.Id},
),
}
ret, err := rs.NewResource(site.Name, resourceTypeSite, site.ID, siteOptions...)
Expand Down Expand Up @@ -107,7 +108,7 @@ func (o *siteResourceType) Grants(ctx context.Context, resource *v2.Resource, pt
for _, user := range users {
roleName := roles[user.SiteRole]
if roleName == "" {
ctxzap.Extract(ctx).Warn("Unknown Tableau Role Name",
ctxzap.Extract(ctx).Debug("Unknown Tableau Role Name",
zap.String("role_name", user.SiteRole),
zap.String("user", user.FullName),
)
Expand Down
239 changes: 239 additions & 0 deletions pkg/connector/view.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
package connector

import (
"context"
"fmt"
"strings"

v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
"github.com/conductorone/baton-sdk/pkg/annotations"
"github.com/conductorone/baton-sdk/pkg/pagination"
ent "github.com/conductorone/baton-sdk/pkg/types/entitlement"
grant "github.com/conductorone/baton-sdk/pkg/types/grant"
rs "github.com/conductorone/baton-sdk/pkg/types/resource"
"github.com/conductorone/baton-tableau/pkg/tableau"
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
"go.uber.org/zap"
)

const (
viewRead = "Read"
viewFilter = "Filter"
viewViewComments = "ViewComments"
viewAddComment = "AddComment"
viewExportImage = "ExportImage"
viewExportData = "ExportData"
viewShareView = "ShareView"
viewViewUnderlyingData = "ViewUnderlyingData"
viewWebAuthoring = "WebAuthoring"
)

var viewCapabilities = map[string]string{
viewRead: "view",
viewFilter: "filter",
viewViewComments: "view comments",
viewAddComment: "add comment",
viewExportImage: "export image",
viewExportData: "export data",
viewShareView: "share view",
viewViewUnderlyingData: "view underlying data",
viewWebAuthoring: "web authoring",
}

type viewResourceType struct {
resourceType *v2.ResourceType
client *tableau.Client
}

func (v *viewResourceType) ResourceType(_ context.Context) *v2.ResourceType {
return v.resourceType
}

func viewResource(view *tableau.View, parentResourceID *v2.ResourceId) (*v2.Resource, error) {
ret, err := rs.NewResource(
view.Name,
resourceTypeView,
view.ID,
rs.WithParentResourceID(parentResourceID),
)
if err != nil {
return nil, fmt.Errorf("tableau-connector: failed to create view resource: %w", err)
}

return ret, nil
}

func (v *viewResourceType) List(ctx context.Context, parentId *v2.ResourceId, token *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) {
if parentId == nil {
return nil, "", nil, nil
}

views, err := v.client.GetPaginatedViews(ctx)
if err != nil {
return nil, "", nil, fmt.Errorf("tableau-connector: failed to list views: %w", err)
}

var rv []*v2.Resource
for _, view := range views {
viewCopy := view
vr, err := viewResource(&viewCopy, parentId)
if err != nil {
return nil, "", nil, fmt.Errorf("tableau-connector: failed to create view resource for %s: %w", view.Name, err)
}
rv = append(rv, vr)
}

return rv, "", nil, nil
}

func (v *viewResourceType) Entitlements(_ context.Context, resource *v2.Resource, _ *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be static entitlements?

var rv []*v2.Entitlement

for capabilityName, displayName := range viewCapabilities {
permissionOptions := []ent.EntitlementOption{
ent.WithGrantableTo(resourceTypeUser, resourceTypeGroup),
ent.WithDescription(fmt.Sprintf("%s permission for %s View", displayName, resource.DisplayName)),
ent.WithDisplayName(fmt.Sprintf("%s permission %s View", capabilityName, resource.DisplayName)),
}

en := ent.NewPermissionEntitlement(resource, capabilityName, permissionOptions...)
rv = append(rv, en)
}

return rv, "", nil, nil
}

func (v *viewResourceType) Grants(ctx context.Context, resource *v2.Resource, token *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) {
l := ctxzap.Extract(ctx)
var rv []*v2.Grant

viewID := resource.Id.Resource

permissions, err := v.client.GetViewPermissions(ctx, viewID)
if err != nil {
l.Debug(
"baton-tableau: failed to get view permissions, skipping grants for this view",
zap.String("view_id", viewID),
zap.String("view_name", resource.DisplayName),
zap.Error(err),
)
return rv, "", nil, nil
}

for _, grantee := range permissions {
for _, capability := range grantee.Capabilities.Capability {
if capability.Mode != "Allow" {
continue
}

if grantee.User != nil {
principalID, err := rs.NewResourceID(resourceTypeUser, grantee.User.ID)
if err != nil {
return nil, "", nil, fmt.Errorf("tableau-connector: failed to create user resource ID: %w", err)
}
g := grant.NewGrant(resource, capability.Name, principalID)
rv = append(rv, g)
}

if grantee.Group != nil {
groupID := grantee.Group.ID
principalID, err := rs.NewResourceID(resourceTypeGroup, groupID)
if err != nil {
return nil, "", nil, fmt.Errorf("tableau-connector: failed to create group resource ID: %w", err)
}
g := grant.NewGrant(
resource,
capability.Name,
principalID,
grant.WithAnnotation(&v2.GrantExpandable{
EntitlementIds: []string{fmt.Sprintf("group:%s:%s", groupID, memberEntitlement)},
Shallow: true,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I noticed I think that views can also be assignable to Group Sets, which we don't support as a resource type, but maybe we should?

https://help.tableau.com/current/online/en-us/group_sets.htm

For example, you might restrict access to different workbook views based on a user’s regional group affiliation. Suppose you have users who are, contractors, contractor managers, full time employees, and full time managers accessing these workbook views.

Groups that have default permissions to view the workbook: All, North, South, East, West, Full Time, and Managers.

For the North Region Detailed view:
Permissions are based on group set: North Region
Groups in the group set: Full Time, North
Outcome: Only full time employees and full time managers in the north can see the data in the North Region Detailed view.

For the All Region Detailed view:
Group set is called Managers
Groups in the group set: All, Managers
Outcome: Only contractor managers and full time managers can see the data in the All Region Detailed view.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am unsure about this.

}),
)
rv = append(rv, g)
}
}
}

return rv, "", nil, nil
}
Comment on lines +106 to +159
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Filter grants to known capability entitlements.

If Tableau returns a capability not present in viewCapabilities, this will emit grants for entitlements that don’t exist, which can break reconciliation. Consider filtering to known capabilities before creating grants.

✅ Suggested filter
 for _, grantee := range permissions {
     for _, capability := range grantee.Capabilities.Capability {
+        if _, ok := viewCapabilities[capability.Name]; !ok {
+            continue
+        }
         if capability.Mode != "Allow" {
             continue
         }
🤖 Prompt for AI Agents
In `@pkg/connector/view.go` around lines 106 - 159, The Grants method on
viewResourceType may emit grants for unknown capability names; update Grants to
filter capability.Name against the known viewCapabilities set before creating
grants. Specifically, in Grants (function viewResourceType.Grants) build or
reuse a lookup (e.g., map[string]struct{} from viewCapabilities) and skip any
capability whose Name is not present; apply this check before the user grant
creation block (where rs.NewResourceID/resourceTypeUser is used) and before the
group grant creation block (where resourceTypeGroup and grant.WithAnnotation are
used) so only known entitlement names produce Grant objects.


func (v *viewResourceType) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) {
l := ctxzap.Extract(ctx)

viewID := entitlement.Resource.Id.Resource
principalID := principal.Id.Resource
capabilityName, err := parseCapabilityFromEntitlementID(entitlement.Id)
if err != nil {
return nil, fmt.Errorf("tableau-connector: failed to parse capability from entitlement ID: %w", err)
}

switch principal.Id.ResourceType {
case resourceTypeUser.Id:
err = v.client.AddViewPermission(ctx, viewID, principalID, capabilityName, "Allow")
case resourceTypeGroup.Id:
err = v.client.AddViewGroupPermission(ctx, viewID, principalID, capabilityName, "Allow")
default:
l.Debug(
"baton-tableau: only users and groups can be granted view permissions",
zap.String("principal_type", principal.Id.ResourceType),
zap.String("principal_id", principal.Id.Resource),
)
return nil, fmt.Errorf("baton-tableau: only users and groups can be granted view permissions")
}

if err != nil {
return nil, fmt.Errorf("baton-tableau: failed to grant view permission: %w", err)
}

return nil, nil
}

func (v *viewResourceType) Revoke(ctx context.Context, grant *v2.Grant) (annotations.Annotations, error) {
l := ctxzap.Extract(ctx)

entitlement := grant.Entitlement
principal := grant.Principal

viewID := entitlement.Resource.Id.Resource
principalID := principal.Id.Resource
capabilityName, err := parseCapabilityFromEntitlementID(entitlement.Id)
if err != nil {
return nil, fmt.Errorf("tableau-connector: failed to parse capability from entitlement ID: %w", err)
}

switch principal.Id.ResourceType {
case resourceTypeUser.Id:
err = v.client.DeleteViewPermission(ctx, viewID, principalID, capabilityName, "Allow")
case resourceTypeGroup.Id:
err = v.client.DeleteViewGroupPermission(ctx, viewID, principalID, capabilityName, "Allow")
default:
l.Debug(
"baton-tableau: only users and groups can have view permissions revoked",
zap.String("principal_type", principal.Id.ResourceType),
zap.String("principal_id", principal.Id.Resource),
)
return nil, fmt.Errorf("baton-tableau: only users and groups can have view permissions revoked")
}

if err != nil {
return nil, fmt.Errorf("baton-tableau: failed to revoke view permission: %w", err)
}

return nil, nil
}

func viewBuilder(client *tableau.Client) *viewResourceType {
return &viewResourceType{
resourceType: resourceTypeView,
client: client,
}
}

func parseCapabilityFromEntitlementID(entitlementID string) (string, error) {
parts := strings.Split(entitlementID, ":")
if len(parts) != 3 {
return "", fmt.Errorf("tableau-connector: invalid entitlement ID: %s", entitlementID)
}
return parts[2], nil
}
Loading
Loading