Skip to content
Open
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
48 changes: 48 additions & 0 deletions baton_capabilities.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,54 @@
{
"@type": "type.googleapis.com/c1.connector.v2.ConnectorCapabilities",
"resourceTypeCapabilities": [
{
"resourceType": {
"id": "channel",
"displayName": "Channel",
"traits": [
"TRAIT_GROUP"
],
"annotations": [
{
"@type": "type.googleapis.com/c1.connector.v2.CapabilityPermissions",
"permissions": [
{
"permission": "channels:read"
},
{
"permission": "channels:join"
},
{
"permission": "groups:read"
},
{
"permission": "channels:manage"
}
]
}
]
},
"capabilities": [
"CAPABILITY_SYNC",
"CAPABILITY_PROVISION"
],
"permissions": {
"permissions": [
{
"permission": "channels:read"
},
{
"permission": "channels:join"
},
{
"permission": "groups:read"
},
{
"permission": "channels:manage"
}
]
}
},
{
"resourceType": {
"id": "group",
Expand Down
15 changes: 9 additions & 6 deletions docs/connector.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,23 +20,25 @@ If you want to install the ConductorOne Slack app, so that you and your colleagu

| Resource | Sync | Provision |
| :--- | :--- | :--- |
| Accounts | <Icon icon="square-check" iconType="solid" color="#65DE23"/> | |
| Workspaces | <Icon icon="square-check" iconType="solid" color="#65DE23"/> | |
| Accounts | <Icon icon="square-check" iconType="solid" color="#65DE23"/> | |
| Workspaces | <Icon icon="square-check" iconType="solid" color="#65DE23"/> | |
| Workspace roles | <Icon icon="square-check" iconType="solid" color="#65DE23"/> | |
| Groups | <Icon icon="square-check" iconType="solid" color="#65DE23"/> | |
| Channels | <Icon icon="square-check" iconType="solid" color="#65DE23"/> | <Icon icon="square-check" iconType="solid" color="#65DE23"/> |

A limitation in the Slack APIs means that [automatic account provisioning](/product/admin/account-provisioning) is not currently supported for Slack Pro workspaces.
A limitation in the Slack APIs means that [automatic account provisioning](/product/admin/account-provisioning) is not currently supported for Slack Pro workspaces.

### Slack Business+

| Resource | Sync | Provision |
| :--- | :--- | :--- |
| Accounts | <Icon icon="square-check" iconType="solid" color="#65DE23"/> | |
| Workspaces | <Icon icon="square-check" iconType="solid" color="#65DE23"/> | |
| Accounts | <Icon icon="square-check" iconType="solid" color="#65DE23"/> | |
| Workspaces | <Icon icon="square-check" iconType="solid" color="#65DE23"/> | |
| Workspace roles | <Icon icon="square-check" iconType="solid" color="#65DE23"/> | |
| Business+ roles | <Icon icon="square-check" iconType="solid" color="#65DE23"/> | |
| Groups | <Icon icon="square-check" iconType="solid" color="#65DE23"/> | |
| IDP groups* | <Icon icon="square-check" iconType="solid" color="#65DE23"/> | <Icon icon="square-check" iconType="solid" color="#65DE23"/> |
| Channels | <Icon icon="square-check" iconType="solid" color="#65DE23"/> | <Icon icon="square-check" iconType="solid" color="#65DE23"/> |

*The connector can sync and provision IdP groups only if SSO is configured for your Slack Business+ instance. IDP group provisioning is not supported for GovSlack instances.

Expand Down Expand Up @@ -80,7 +82,8 @@ Your new app opens. From the menu on the left, select **OAuth & Permissions**.
<Step>
Scroll down to the **Scopes** section of the page. In the **Bot Token Scopes** area, add the following OAuth Scopes:

- channels:join
- channels:join
- channels:manage (only include if you want ConductorOne to provision channel membership)
- channels:read
- groups:read
- team:read
Expand Down
242 changes: 242 additions & 0 deletions pkg/connector/channel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
package connector

import (
"context"
"errors"
"fmt"

v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2"
"github.com/conductorone/baton-sdk/pkg/annotations"
"github.com/conductorone/baton-sdk/pkg/types/entitlement"
"github.com/conductorone/baton-sdk/pkg/types/grant"
resources "github.com/conductorone/baton-sdk/pkg/types/resource"

"github.com/conductorone/baton-slack/pkg"
"github.com/conductorone/baton-slack/pkg/connector/client"
"github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap"
"github.com/slack-go/slack"
"go.uber.org/zap"
)

const channelPageSize = 200

type channelResourceType struct {
resourceType *v2.ResourceType
client *slack.Client
}

func (c *channelResourceType) ResourceType(_ context.Context) *v2.ResourceType {
return c.resourceType
}

func channelBuilder(slackClient *slack.Client) *channelResourceType {
return &channelResourceType{
resourceType: resourceTypeChannel,
client: slackClient,
}
}

// channelResource creates a new connector resource for a Slack channel.
func channelResource(
_ context.Context,
channel slack.Channel,
parentResourceID *v2.ResourceId,
) (*v2.Resource, error) {
profile := map[string]interface{}{
"channel_id": channel.ID,
"channel_name": channel.Name,
}
if channel.Topic.Value != "" {
profile["channel_topic"] = channel.Topic.Value
}
if channel.Purpose.Value != "" {
profile["channel_purpose"] = channel.Purpose.Value
}

return resources.NewGroupResource(
channel.Name,
resourceTypeChannel,
channel.ID,
[]resources.GroupTraitOption{
resources.WithGroupProfile(profile),
},
resources.WithParentResourceID(parentResourceID),
)
}

func (c *channelResourceType) List(
ctx context.Context,
parentResourceID *v2.ResourceId,
attrs resources.SyncOpAttrs,
) ([]*v2.Resource, *resources.SyncOpResults, error) {
if parentResourceID == nil {
return nil, &resources.SyncOpResults{}, nil
}

bag, err := pkg.ParsePageToken(attrs.PageToken.Token, &v2.ResourceId{ResourceType: resourceTypeChannel.Id})
if err != nil {
return nil, nil, fmt.Errorf("parsing page token: %w", err)
}

params := &slack.GetConversationsParameters{
TeamID: parentResourceID.Resource,
Cursor: bag.PageToken(),
ExcludeArchived: true,
Limit: channelPageSize,
Types: []string{"public_channel", "private_channel"},
}

channels, nextCursor, err := c.client.GetConversationsContext(ctx, params)
if err != nil {
return nil, nil, client.WrapError(err, fmt.Sprintf("listing channels for team %s", parentResourceID.Resource))
}

rv := make([]*v2.Resource, 0, len(channels))
for _, ch := range channels {
resource, err := channelResource(ctx, ch, parentResourceID)
if err != nil {
return nil, nil, fmt.Errorf("creating channel resource: %w", err)
}
rv = append(rv, resource)
}

pageToken, err := bag.NextToken(nextCursor)
if err != nil {
return nil, nil, fmt.Errorf("creating next page token: %w", err)
}

return rv, &resources.SyncOpResults{NextPageToken: pageToken}, nil
}

func (c *channelResourceType) Entitlements(
_ context.Context,
resource *v2.Resource,
_ resources.SyncOpAttrs,
) ([]*v2.Entitlement, *resources.SyncOpResults, error) {
return []*v2.Entitlement{
entitlement.NewAssignmentEntitlement(
resource,
memberEntitlement,
entitlement.WithGrantableTo(resourceTypeUser),
entitlement.WithDescription(
fmt.Sprintf(
"Member of %s channel",
resource.DisplayName,
),
),
entitlement.WithDisplayName(
fmt.Sprintf(
"%s channel %s",
resource.DisplayName,
memberEntitlement,
),
),
),
}, &resources.SyncOpResults{}, nil
}

func (c *channelResourceType) Grants(
ctx context.Context,
resource *v2.Resource,
attrs resources.SyncOpAttrs,
) ([]*v2.Grant, *resources.SyncOpResults, error) {
bag, err := pkg.ParsePageToken(attrs.PageToken.Token, &v2.ResourceId{ResourceType: resourceTypeUser.Id})
if err != nil {
return nil, nil, fmt.Errorf("parsing page token: %w", err)
}

params := &slack.GetUsersInConversationParameters{
ChannelID: resource.Id.Resource,
Cursor: bag.PageToken(),
Limit: channelPageSize,
}

members, nextCursor, err := c.client.GetUsersInConversationContext(ctx, params)
if err != nil {
return nil, nil, client.WrapError(err, fmt.Sprintf("fetching channel members for channel %s", resource.Id.Resource))
}

var rv []*v2.Grant
for _, memberID := range members {
userID, err := resources.NewResourceID(resourceTypeUser, memberID)
if err != nil {
return nil, nil, fmt.Errorf("creating user resource ID: %w", err)
}
rv = append(rv, grant.NewGrant(resource, memberEntitlement, userID))
}

pageToken, err := bag.NextToken(nextCursor)
if err != nil {
return nil, nil, fmt.Errorf("creating next page token: %w", err)
}

return rv, &resources.SyncOpResults{NextPageToken: pageToken}, nil
}

func (c *channelResourceType) Grant(
ctx context.Context,
principal *v2.Resource,
ent *v2.Entitlement,
) (annotations.Annotations, error) {
logger := ctxzap.Extract(ctx)

if principal.Id.ResourceType != resourceTypeUser.Id {
logger.Warn(
"baton-slack: only users can be added to a channel",
zap.String("principal_type", principal.Id.ResourceType),
zap.String("principal_id", principal.Id.Resource),
)
return nil, fmt.Errorf("only users can be granted channel membership")
}

channelID := ent.Resource.Id.Resource
userID := principal.Id.Resource

_, err := c.client.InviteUsersToConversationContext(ctx, channelID, userID)
if err != nil {
// already_in_channel means the user is already a member - treat as success.
var slackErr slack.SlackErrorResponse
if errors.As(err, &slackErr) && slackErr.Err == "already_in_channel" {
return nil, nil
}
return nil, fmt.Errorf("inviting user to channel: %w", err)
}

return nil, nil
}

func (c *channelResourceType) Revoke(
ctx context.Context,
grantToRevoke *v2.Grant,
) (annotations.Annotations, error) {
logger := ctxzap.Extract(ctx)

principal := grantToRevoke.Principal
ent := grantToRevoke.Entitlement

if principal.Id.ResourceType != resourceTypeUser.Id {
logger.Warn(
"baton-slack: only users can be removed from a channel",
zap.String("principal_type", principal.Id.ResourceType),
zap.String("principal_id", principal.Id.Resource),
)
return nil, fmt.Errorf("only users can have channel membership revoked")
}

channelID := ent.Resource.Id.Resource
userID := principal.Id.Resource

err := c.client.KickUserFromConversationContext(ctx, channelID, userID)
if err != nil {
// not_in_channel means the user is already not a member - treat as already revoked.
var slackErr slack.SlackErrorResponse
if errors.As(err, &slackErr) && slackErr.Err == "not_in_channel" {
outputAnnotations := annotations.New()
outputAnnotations.Append(&v2.GrantAlreadyRevoked{})
return outputAnnotations, nil
}
return nil, fmt.Errorf("removing user from channel: %w", err)
}

return nil, nil
}
3 changes: 2 additions & 1 deletion pkg/connector/connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const govSlackApiUrl = "https://api.slack-gov.com/api/"
func (c *Slack) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) {
return &v2.ConnectorMetadata{
DisplayName: "Slack",
Description: "Connector syncing users, workspaces, user groups and workspace roles from Slack to Baton.",
Description: "Connector syncing users, workspaces, user groups, workspace roles and channels from Slack to Baton.",
AccountCreationSchema: &v2.ConnectorAccountCreationSchema{
FieldMap: map[string]*v2.ConnectorAccountCreationSchema_Field{
"channel_ids": {
Expand Down Expand Up @@ -163,5 +163,6 @@ func (s *Slack) ResourceSyncers(ctx context.Context) []connectorbuilder.Resource
userGroupBuilder(s.client, s.businessPlusClient),
groupBuilder(s.businessPlusClient, s.govEnv),
workspaceRoleBuilder(s.businessPlusClient),
channelBuilder(s.client),
}
}
17 changes: 17 additions & 0 deletions pkg/connector/resource_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,23 @@ var (
),
}

resourceTypeChannel = &v2.ResourceType{
Id: "channel",
DisplayName: "Channel",
Traits: []v2.ResourceType_Trait{
v2.ResourceType_TRAIT_GROUP,
},
Annotations: annotations.New(
capabilityPermissions(
// Bot Token Scopes
"channels:read",
"channels:join",
"groups:read",
"channels:manage",
),
),
}

resourceTypeWorkspaceRole = &v2.ResourceType{
Id: "workspaceRole",
DisplayName: "Workspace Role",
Expand Down
1 change: 1 addition & 0 deletions pkg/connector/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ func workspaceResource(
&v2.ChildResourceType{ResourceTypeId: resourceTypeUser.Id},
&v2.ChildResourceType{ResourceTypeId: resourceTypeUserGroup.Id},
&v2.ChildResourceType{ResourceTypeId: resourceTypeWorkspaceRole.Id},
&v2.ChildResourceType{ResourceTypeId: resourceTypeChannel.Id},
),
)
}
Expand Down
Loading