diff --git a/baton_capabilities.json b/baton_capabilities.json
index 6fc847ef..eda0b6cf 100644
--- a/baton_capabilities.json
+++ b/baton_capabilities.json
@@ -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",
diff --git a/docs/connector.mdx b/docs/connector.mdx
index 67dd0988..6ce30ec7 100644
--- a/docs/connector.mdx
+++ b/docs/connector.mdx
@@ -20,23 +20,25 @@ If you want to install the ConductorOne Slack app, so that you and your colleagu
| Resource | Sync | Provision |
| :--- | :--- | :--- |
-| Accounts | | |
-| Workspaces | | |
+| Accounts | | |
+| Workspaces | | |
| Workspace roles | | |
| Groups | | |
+| Channels | | |
-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 | | |
-| Workspaces | | |
+| Accounts | | |
+| Workspaces | | |
| Workspace roles | | |
| Business+ roles | | |
| Groups | | |
| IDP groups* | | |
+| Channels | | |
*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.
@@ -80,7 +82,8 @@ Your new app opens. From the menu on the left, select **OAuth & Permissions**.
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
diff --git a/pkg/connector/channel.go b/pkg/connector/channel.go
new file mode 100644
index 00000000..89cf3619
--- /dev/null
+++ b/pkg/connector/channel.go
@@ -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
+}
diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go
index 379f2113..c9580886 100644
--- a/pkg/connector/connector.go
+++ b/pkg/connector/connector.go
@@ -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": {
@@ -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),
}
}
diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go
index ee724466..511bb58e 100644
--- a/pkg/connector/resource_types.go
+++ b/pkg/connector/resource_types.go
@@ -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",
diff --git a/pkg/connector/workspace.go b/pkg/connector/workspace.go
index 952d5199..2f63c3a9 100644
--- a/pkg/connector/workspace.go
+++ b/pkg/connector/workspace.go
@@ -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},
),
)
}