diff --git a/README.md b/README.md index 2b72439..619f60e 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ baton resources - Users - Groups - Licenses +- Views (Dashboards) # Contributing, Support and Issues diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 9e85846..9cc9f27 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -39,6 +39,11 @@ var ( v2.ResourceType_TRAIT_ROLE, }, } + resourceTypeView = &v2.ResourceType{ + Id: "view", + DisplayName: "View", + Description: "A Tableau dashboard/view", + } ) type Tableau struct { @@ -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), } } diff --git a/pkg/connector/group.go b/pkg/connector/group.go index 32af0ef..dd4fae5 100644 --- a/pkg/connector/group.go +++ b/pkg/connector/group.go @@ -15,7 +15,10 @@ import ( "go.uber.org/zap" ) -const memberEntitlement = "member" +const ( + memberEntitlement = "member" + siteRoleServerAdmin = "ServerAdministrator" +) type groupResourceType struct { resourceType *v2.ResourceType @@ -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 { @@ -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), @@ -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), diff --git a/pkg/connector/site.go b/pkg/connector/site.go index 44d17f8..dca884d 100644 --- a/pkg/connector/site.go +++ b/pkg/connector/site.go @@ -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...) @@ -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), ) diff --git a/pkg/connector/view.go b/pkg/connector/view.go new file mode 100644 index 0000000..98fa596 --- /dev/null +++ b/pkg/connector/view.go @@ -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) { + 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, + }), + ) + rv = append(rv, g) + } + } + } + + return rv, "", nil, nil +} + +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 +} diff --git a/pkg/tableau/client.go b/pkg/tableau/client.go index 6f870aa..25a52b5 100644 --- a/pkg/tableau/client.go +++ b/pkg/tableau/client.go @@ -220,12 +220,7 @@ func (c *Client) GetPaginatedUsers(ctx context.Context) ([]User, error) { return nil, fmt.Errorf("tableau-connector: failed to list users: %w", err) } - pageSizeInt, err := strconv.Atoi(paginationData.PageSize) - if err != nil { - return nil, err - } - - totalReturned += pageSizeInt + totalReturned += len(allUsers) totalAvailableInt, err := strconv.Atoi(paginationData.TotalAvailable) if err != nil { return nil, err @@ -254,12 +249,7 @@ func (c *Client) GetPaginatedGroups(ctx context.Context) ([]Group, error) { return nil, fmt.Errorf("tableau-connector: failed to list groups: %w", err) } - pageSizeInt, err := strconv.Atoi(paginationData.PageSize) - if err != nil { - return nil, err - } - - totalReturned += pageSizeInt + totalReturned += len(allGroups) totalAvailableInt, err := strconv.Atoi(paginationData.TotalAvailable) if err != nil { return nil, err @@ -288,12 +278,7 @@ func (c *Client) GetPaginatedGroupUsers(ctx context.Context, groupId string) ([] return nil, fmt.Errorf("tableau-connector: failed to list group users: %w", err) } - pageSizeInt, err := strconv.Atoi(paginationData.PageSize) - if err != nil { - return nil, err - } - - totalReturned += pageSizeInt + totalReturned += len(allUsers) totalAvailableInt, err := strconv.Atoi(paginationData.TotalAvailable) if err != nil { return nil, err @@ -463,6 +448,176 @@ func (c *Client) UpdateUserSiteRole(ctx context.Context, userId string, siteRole return nil } +func (c *Client) GetViews(ctx context.Context, pageSize int, pageNumber int) ([]View, Pagination, error) { + endpoint, err := url.JoinPath(c.baseUrl, "sites", c.siteId, "views") + if err != nil { + return nil, Pagination{}, fmt.Errorf("tableau-connector: failed to build URL: %w", err) + } + q := paginationQuery(pageSize, pageNumber) + + var res struct { + Pagination Pagination `json:"pagination"` + Views struct { + View []View `json:"view"` + } `json:"views"` + } + + if err := c.doRequest(ctx, endpoint, &res, q, nil, http.MethodGet); err != nil { + return nil, Pagination{}, fmt.Errorf("tableau-connector: failed to get views: %w", err) + } + + return res.Views.View, res.Pagination, nil +} + +func (c *Client) GetPaginatedViews(ctx context.Context) ([]View, error) { + var views []View + pageNumber := defaultPageNumber + totalReturned := 0 + + for { + allViews, paginationData, err := c.GetViews(ctx, defaultPageSize, pageNumber) + if err != nil { + return nil, fmt.Errorf("tableau-connector: failed to list views: %w", err) + } + + totalReturned += len(allViews) + totalAvailableInt, err := strconv.Atoi(paginationData.TotalAvailable) + if err != nil { + return nil, fmt.Errorf("tableau-connector: failed to parse total available: %w", err) + } + + views = append(views, allViews...) + + if totalReturned >= totalAvailableInt { + break + } + pageNumber += 1 + } + + return views, nil +} + +func (c *Client) GetViewPermissions(ctx context.Context, viewID string) ([]GranteeCapabilities, error) { + endpoint, err := url.JoinPath(c.baseUrl, "sites", c.siteId, "views", viewID, "permissions") + if err != nil { + return nil, fmt.Errorf("tableau-connector: failed to build URL: %w", err) + } + + var res struct { + Permissions struct { + GranteeCapabilities []GranteeCapabilities `json:"granteeCapabilities"` + } `json:"permissions"` + } + + if err := c.doRequest(ctx, endpoint, &res, nil, nil, http.MethodGet); err != nil { + return nil, fmt.Errorf("tableau-connector: failed to get view permissions: %w", err) + } + + return res.Permissions.GranteeCapabilities, nil +} + +func (c *Client) AddViewPermission(ctx context.Context, viewID, userID, capabilityName, capabilityMode string) error { + endpoint, err := url.JoinPath(c.baseUrl, "sites", c.siteId, "views", viewID, "permissions") + if err != nil { + return fmt.Errorf("tableau-connector: failed to build URL: %w", err) + } + + requestBody, err := json.Marshal(map[string]interface{}{ + "permissions": map[string]interface{}{ + "granteeCapabilities": []map[string]interface{}{ + { + "user": map[string]interface{}{ + "id": userID, + }, + "capabilities": map[string]interface{}{ + "capability": []map[string]interface{}{ + { + "name": capabilityName, + "mode": capabilityMode, + }, + }, + }, + }, + }, + }, + }) + + if err != nil { + return fmt.Errorf("tableau-connector: failed to marshal view permission request: %w", err) + } + + var res struct{} + if err := c.doRequest(ctx, endpoint, &res, nil, requestBody, http.MethodPut); err != nil { + return fmt.Errorf("tableau-connector: failed to add view permission: %w", err) + } + + return nil +} + +func (c *Client) DeleteViewPermission(ctx context.Context, viewID, userID, capabilityName, capabilityMode string) error { + endpoint, err := url.JoinPath(c.baseUrl, "sites", c.siteId, "views", viewID, "permissions", "users", userID, capabilityName, capabilityMode) + if err != nil { + return fmt.Errorf("tableau-connector: failed to build URL: %w", err) + } + + if err := c.doRequest(ctx, endpoint, nil, nil, nil, http.MethodDelete); err != nil { + return fmt.Errorf("tableau-connector: failed to delete view permission: %w", err) + } + + return nil +} + +func (c *Client) AddViewGroupPermission(ctx context.Context, viewID, groupID, capabilityName, capabilityMode string) error { + endpoint, err := url.JoinPath(c.baseUrl, "sites", c.siteId, "views", viewID, "permissions") + if err != nil { + return fmt.Errorf("tableau-connector: failed to build URL: %w", err) + } + + requestBody, err := json.Marshal(map[string]interface{}{ + "permissions": map[string]interface{}{ + "granteeCapabilities": []map[string]interface{}{ + { + "group": map[string]interface{}{ + "id": groupID, + }, + "capabilities": map[string]interface{}{ + "capability": []map[string]interface{}{ + { + "name": capabilityName, + "mode": capabilityMode, + }, + }, + }, + }, + }, + }, + }) + + if err != nil { + return fmt.Errorf("tableau-connector: failed to marshal view group permission request: %w", err) + } + + var res struct{} + if err := c.doRequest(ctx, endpoint, &res, nil, requestBody, http.MethodPut); err != nil { + return fmt.Errorf("tableau-connector: failed to add view group permission: %w", err) + } + + return nil +} + +func (c *Client) DeleteViewGroupPermission(ctx context.Context, viewID, groupID, capabilityName, capabilityMode string) error { + endpoint, err := url.JoinPath(c.baseUrl, "sites", c.siteId, "views", viewID, "permissions", "groups", groupID, capabilityName, capabilityMode) + if err != nil { + return fmt.Errorf("tableau-connector: failed to build URL: %w", err) + } + + if err := c.doRequest(ctx, endpoint, nil, nil, nil, http.MethodDelete); err != nil { + return fmt.Errorf("tableau-connector: failed to delete view group permission: %w", err) + } + + return nil +} + func (c *Client) ListIdpConfigurations(ctx context.Context) ([]IdpConfiguration, error) { endpoint, err := url.JoinPath(c.baseUrl, "sites", c.siteId, "site-auth-configurations") if err != nil { @@ -481,7 +636,6 @@ func (c *Client) ListIdpConfigurations(ctx context.Context) ([]IdpConfiguration, return res.SiteAuthConfigurations.SiteAuthConfiguration, nil } - func buildResourceURL(baseURL string, endpoint string, elems ...string) (string, error) { joined, err := url.JoinPath(baseURL, append([]string{endpoint}, elems...)...) if err != nil { diff --git a/pkg/tableau/models.go b/pkg/tableau/models.go index e47bdff..37fbd32 100644 --- a/pkg/tableau/models.go +++ b/pkg/tableau/models.go @@ -44,3 +44,32 @@ type Group struct { ID string `json:"id"` Name string `json:"name"` } + +type View struct { + ID string `json:"id"` + Name string `json:"name"` + ContentURL string `json:"contentUrl"` +} + +type GranteeCapabilities struct { + User *UserRef `json:"user,omitempty"` + Group *GroupRef `json:"group,omitempty"` + Capabilities Capabilities `json:"capabilities"` +} + +type UserRef struct { + ID string `json:"id"` +} + +type GroupRef struct { + ID string `json:"id"` +} + +type Capabilities struct { + Capability []Capability `json:"capability"` +} + +type Capability struct { + Name string `json:"name"` + Mode string `json:"mode"` +}