From d61b48b71f374d1f9093b1a54e92c307fa330b27 Mon Sep 17 00:00:00 2001 From: "c1-dev-bot[bot]" <2740113+c1-dev-bot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:06:05 +0000 Subject: [PATCH 1/3] Add channel membership as a provisionable resource type Add a new "channel" resource type to the Slack connector that syncs Slack channels (public and private) and supports provisioning channel membership via Grant and Revoke operations. Changes: - Add channel resource type definition with TRAIT_GROUP - Add channelResourceType syncer with List, Entitlements, Grants, Grant (conversations.invite), and Revoke (conversations.kick) - Register channel syncer in connector's ResourceSyncers - Add channel as a child resource of workspace - Update baton_capabilities.json with channel resource type Required bot token scopes: channels:read, channels:join, groups:read, channels:manage Fixes: CXH-1229 --- baton_capabilities.json | 48 +++++++ pkg/connector/channel.go | 243 ++++++++++++++++++++++++++++++++ pkg/connector/connector.go | 3 +- pkg/connector/resource_types.go | 17 +++ pkg/connector/workspace.go | 1 + 5 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 pkg/connector/channel.go diff --git a/baton_capabilities.json b/baton_capabilities.json index 6fc847ef..a9d02176 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -236,6 +236,54 @@ } ] } + }, + { + "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" + } + ] + } } ], "connectorCapabilities": [ diff --git a/pkg/connector/channel.go b/pkg/connector/channel.go new file mode 100644 index 00000000..60d71cec --- /dev/null +++ b/pkg/connector/channel.go @@ -0,0 +1,243 @@ +package connector + +import ( + "context" + "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. + if slackErr, ok := err.(slack.SlackErrorResponse); ok { + if 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. + if slackErr, ok := err.(slack.SlackErrorResponse); ok { + if 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}, ), ) } From 2b0260e26db5ab9bb700aa07683fbd61ff94bcc2 Mon Sep 17 00:00:00 2001 From: "c1-dev-bot[bot]" <2740113+c1-dev-bot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:08:43 +0000 Subject: [PATCH 2/3] Update capabilities and docs for channel resource type Regenerate baton_capabilities.json from binary output to match expected CI format. Update docs/connector.mdx capability tables to include the new Channels resource type with sync and provision support, and add channels:manage to the documented bot token scopes. --- baton_capabilities.json | 96 ++++++++++++++++++++--------------------- docs/connector.mdx | 15 ++++--- 2 files changed, 57 insertions(+), 54 deletions(-) diff --git a/baton_capabilities.json b/baton_capabilities.json index a9d02176..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", @@ -236,54 +284,6 @@ } ] } - }, - { - "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" - } - ] - } } ], "connectorCapabilities": [ 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 From ff1a1880ee5d129159f6593d19c304adc504db0d Mon Sep 17 00:00:00 2001 From: "c1-dev-bot[bot]" <2740113+c1-dev-bot[bot]@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:15:08 +0000 Subject: [PATCH 3/3] Fix lint: use errors.As instead of type assertion Replace direct type assertions on error with errors.As to properly handle wrapped errors, fixing errorlint violations. --- pkg/connector/channel.go | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/pkg/connector/channel.go b/pkg/connector/channel.go index 60d71cec..89cf3619 100644 --- a/pkg/connector/channel.go +++ b/pkg/connector/channel.go @@ -2,6 +2,7 @@ package connector import ( "context" + "errors" "fmt" v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" @@ -194,10 +195,9 @@ func (c *channelResourceType) Grant( _, err := c.client.InviteUsersToConversationContext(ctx, channelID, userID) if err != nil { // already_in_channel means the user is already a member - treat as success. - if slackErr, ok := err.(slack.SlackErrorResponse); ok { - if slackErr.Err == "already_in_channel" { - return nil, nil - } + 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) } @@ -229,12 +229,11 @@ func (c *channelResourceType) Revoke( 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. - if slackErr, ok := err.(slack.SlackErrorResponse); ok { - if slackErr.Err == "not_in_channel" { - outputAnnotations := annotations.New() - outputAnnotations.Append(&v2.GrantAlreadyRevoked{}) - return outputAnnotations, nil - } + 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) }