diff --git a/cmd/baton-slack/main.go b/cmd/baton-slack/main.go index 68a48d74..fbd1b42a 100644 --- a/cmd/baton-slack/main.go +++ b/cmd/baton-slack/main.go @@ -43,7 +43,7 @@ func main() { } func getConnector(ctx context.Context, v *viper.Viper) (types.ConnectorServer, error) { - l := ctxzap.Extract(ctx) + logger := ctxzap.Extract(ctx) cb, err := connector.New( ctx, v.GetString(AccessTokenField.FieldName), @@ -51,13 +51,13 @@ func getConnector(ctx context.Context, v *viper.Viper) (types.ConnectorServer, e v.GetBool(SSOEnabledField.FieldName), ) if err != nil { - l.Error("error creating connector", zap.Error(err)) + logger.Error("error creating connector", zap.Error(err)) return nil, err } c, err := connectorbuilder.NewConnector(ctx, cb) if err != nil { - l.Error("error creating connector", zap.Error(err)) + logger.Error("error creating connector", zap.Error(err)) return nil, err } diff --git a/pkg/slack/models.go b/pkg/connector/client/models.go similarity index 89% rename from pkg/slack/models.go rename to pkg/connector/client/models.go index 26aef44b..2d50b271 100644 --- a/pkg/slack/models.go +++ b/pkg/connector/client/models.go @@ -2,6 +2,27 @@ package enterprise import "github.com/slack-go/slack" +type BaseResponse struct { + Ok bool `json:"ok"` + Error string `json:"error"` + Needed string `json:"needed"` + Provided string `json:"provided"` +} + +type Pagination struct { + ResponseMetadata struct { + NextCursor string `json:"next_cursor"` + } `json:"response_metadata"` +} + +type SCIMResponse[T any] struct { + Schemas []string `json:"schemas"` + Resources []T `json:"Resources"` + TotalResults int `json:"totalResults"` + ItemsPerPage int `json:"itemsPerPage"` + StartIndex int `json:"startIndex"` +} + type UserAdmin struct { ID string `json:"id"` Email string `json:"email"` @@ -67,7 +88,7 @@ type EnterpriseUser struct { Teams []string `json:"teams"` } -// SCIM resources. +// UserResource SCIM resources. type UserResource struct { Schemas []string `json:"schemas"` ID string `json:"id"` @@ -151,3 +172,18 @@ type GroupResource struct { DisplayName string `json:"displayName"` Members []Member `json:"members"` } + +type PatchOp struct { + Schemas []string `json:"schemas"` + Operations []ScimOperate `json:"Operations"` +} + +type ScimOperate struct { + Op string `json:"op"` + Path string `json:"path"` + Value []UserID `json:"value"` +} + +type UserID struct { + Value string `json:"value"` +} diff --git a/pkg/connector/client/path.go b/pkg/connector/client/path.go new file mode 100644 index 00000000..46b164a5 --- /dev/null +++ b/pkg/connector/client/path.go @@ -0,0 +1,38 @@ +package enterprise + +import ( + "fmt" + + "github.com/conductorone/baton-slack/pkg" +) + +const ( + UrlPathGetRoleAssignments = "/api/admin.roles.listAssignments" + UrlPathGetTeams = "/api/admin.teams.list" + UrlPathGetUserGroupMembers = "/api/usergroups.users.list" + UrlPathGetUserGroups = "/api/usergroups.list" + UrlPathGetUserInfo = "/api/users.info" + UrlPathGetUsers = "/api/users.list" + UrlPathGetUsersAdmin = "/api/admin.users.list" + UrlPathIDPGroup = "/scim/v2/Groups/%s" + UrlPathIDPGroups = "/scim/v2/Groups" + UrlPathSetAdmin = "/api/admin.users.setAdmin" + UrlPathSetOwner = "/api/admin.users.setOwner" + UrlPathSetRegular = "/api/admin.users.setRegular" + baseScimUrl = "https://api.slack.com" + baseUrl = "https://slack.com" +) + +func getWorkspaceUrlPathByRole(roleID string) (string, error) { + role, _ := pkg.ParseID(roleID) + switch role { + case "owner": + return UrlPathSetOwner, nil + case "admin": + return UrlPathSetAdmin, nil + case "": + return UrlPathSetRegular, nil + default: + return "", fmt.Errorf("invalid role type: %s", role) + } +} diff --git a/pkg/connector/client/request.go b/pkg/connector/client/request.go new file mode 100644 index 00000000..ecf74ef7 --- /dev/null +++ b/pkg/connector/client/request.go @@ -0,0 +1,169 @@ +package enterprise + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/uhttp" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +func toValues(queryParameters map[string]interface{}) string { + params := url.Values{} + for key, valueAny := range queryParameters { + switch value := valueAny.(type) { + case string: + params.Add(key, value) + case int: + params.Add(key, strconv.Itoa(value)) + case bool: + params.Add(key, strconv.FormatBool(value)) + default: + continue + } + } + return params.Encode() +} + +func (c *Client) getUrl( + path string, + queryParameters map[string]interface{}, + useScim bool, +) *url.URL { + var output *url.URL + if useScim { + output = c.baseScimUrl.JoinPath(path) + } else { + output = c.baseUrl.JoinPath(path) + } + + output.RawQuery = toValues(queryParameters) + return output +} + +// WithBearerToken - TODO(marcos): move this function to `baton-sdk`. +func WithBearerToken(token string) uhttp.RequestOption { + return uhttp.WithHeader("Authorization", fmt.Sprintf("Bearer %s", token)) +} + +func (c *Client) post( + ctx context.Context, + path string, + target interface{}, + payload map[string]interface{}, + useBotToken bool, +) ( + *v2.RateLimitDescription, + error, +) { + token := c.token + if useBotToken { + token = c.botToken + } + + return c.doRequest( + ctx, + http.MethodPost, + c.getUrl(path, nil, false), + &target, + WithBearerToken(token), + uhttp.WithFormBody(toValues(payload)), + ) +} + +func (c *Client) getScim( + ctx context.Context, + path string, + target interface{}, + queryParameters map[string]interface{}, +) ( + *v2.RateLimitDescription, + error, +) { + return c.doRequest( + ctx, + http.MethodGet, + c.getUrl(path, queryParameters, true), + &target, + WithBearerToken(c.token), + ) +} + +func (c *Client) patchScim( + ctx context.Context, + path string, + target interface{}, + payload []byte, +) ( + *v2.RateLimitDescription, + error, +) { + return c.doRequest( + ctx, + http.MethodPatch, + c.getUrl(path, nil, true), + &target, + WithBearerToken(c.token), + uhttp.WithJSONBody(payload), + ) +} + +func (c *Client) doRequest( + ctx context.Context, + method string, + url *url.URL, + target interface{}, + options ...uhttp.RequestOption, +) ( + *v2.RateLimitDescription, + error, +) { + logger := ctxzap.Extract(ctx) + logger.Debug( + "making request", + zap.String("method", method), + zap.String("url", url.String()), + ) + + options = append( + options, + uhttp.WithAcceptJSONHeader(), + ) + + request, err := c.wrapper.NewRequest( + ctx, + method, + url, + options..., + ) + if err != nil { + return nil, err + } + var ratelimitData v2.RateLimitDescription + response, err := c.wrapper.Do( + request, + uhttp.WithRatelimitData(&ratelimitData), + ) + if err != nil { + return &ratelimitData, err + } + defer response.Body.Close() + + bodyBytes, err := io.ReadAll(response.Body) + if err != nil { + return &ratelimitData, err + } + + if err := json.Unmarshal(bodyBytes, &target); err != nil { + return nil, err + } + + return &ratelimitData, nil +} diff --git a/pkg/connector/client/slack.go b/pkg/connector/client/slack.go new file mode 100644 index 00000000..823fc5a9 --- /dev/null +++ b/pkg/connector/client/slack.go @@ -0,0 +1,515 @@ +package enterprise + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/uhttp" + "github.com/slack-go/slack" +) + +const ( + PageSizeDefault = 100 +) + +type Client struct { + baseScimUrl *url.URL + baseUrl *url.URL + token string + enterpriseID string + botToken string + ssoEnabled bool + wrapper *uhttp.BaseHttpClient +} + +func NewClient( + httpClient *http.Client, + token string, + botToken string, + enterpriseID string, + ssoEnabled bool, +) (*Client, error) { + baseUrl0, err := url.Parse(baseUrl) + if err != nil { + return nil, err + } + + baseScimUrl0, err := url.Parse(baseScimUrl) + if err != nil { + return nil, err + } + + return &Client{ + baseUrl: baseUrl0, + baseScimUrl: baseScimUrl0, + token: token, + enterpriseID: enterpriseID, + botToken: botToken, + ssoEnabled: ssoEnabled, + wrapper: uhttp.NewBaseHttpClient(httpClient), + }, nil +} + +// handleError - Slack can return a 200 with an error in the JSON body. +// Generally, it is bad practice to use interpolation in error message +// construction. It makes it difficult to find the failing code when debugging. +func (a BaseResponse) handleError(err error, action string) error { + if err != nil { + return fmt.Errorf("baton-slack: error %s: %w", action, err) + } + + if a.Error != "" { + return fmt.Errorf( + "baton-slack: error %s: error %v needed %v provided %v", + action, + a.Error, + a.Needed, + a.Provided, + ) + } + return nil +} + +// GetUserInfo returns the user info for the given user ID. +func (c *Client) GetUserInfo( + ctx context.Context, + userID string, +) ( + *User, + *v2.RateLimitDescription, + error, +) { + var response struct { + BaseResponse + User *User `json:"user"` + } + + ratelimitData, err := c.post( + ctx, + UrlPathGetUserInfo, + &response, + map[string]interface{}{"user": userID}, + true, + ) + if err := response.handleError(err, "fetching user info"); err != nil { + return nil, ratelimitData, err + } + + return response.User, ratelimitData, nil +} + +// GetUserGroupMembers returns the members of the given user group from a given team. +func (c *Client) GetUserGroupMembers( + ctx context.Context, + userGroupID string, + teamID string, +) ( + []string, + *v2.RateLimitDescription, + error, +) { + var response struct { + BaseResponse + Users []string `json:"users"` + } + + ratelimitData, err := c.post( + ctx, + UrlPathGetUserGroupMembers, + &response, + map[string]interface{}{ + "team_id": teamID, + "usergroup": userGroupID, + }, + true, + ) + if err := response.handleError(err, "fetching user group members"); err != nil { + return nil, ratelimitData, err + } + + return response.Users, ratelimitData, nil +} + +// GetUsersAdmin returns all users in Enterprise grid. +func (c *Client) GetUsersAdmin( + ctx context.Context, + cursor string, +) ( + []UserAdmin, + string, + *v2.RateLimitDescription, + error, +) { + values := map[string]interface{}{} + + // We need to check if cursor is empty because API throws error if empty string is passed. + if cursor != "" { + values["cursor"] = cursor + } + + var response struct { + BaseResponse + Users []UserAdmin `json:"users"` + Pagination + } + + ratelimitData, err := c.post( + ctx, + UrlPathGetUsersAdmin, + &response, + values, + false, + ) + if err := response.handleError(err, "fetching users"); err != nil { + return nil, "", ratelimitData, err + } + + nextToken := response.ResponseMetadata.NextCursor + return response.Users, nextToken, ratelimitData, nil +} + +// GetUsers returns the users of the given team. +func (c *Client) GetUsers( + ctx context.Context, + teamID string, + cursor string, +) ( + []User, + string, + *v2.RateLimitDescription, + error, +) { + values := map[string]interface{}{"team_id": teamID} + + // need to check if cursor is empty because API throws error if empty string is passed + if cursor != "" { + values["cursor"] = cursor + } + + var response struct { + BaseResponse + Users []User `json:"members"` + Pagination + } + + ratelimitData, err := c.post( + ctx, + UrlPathGetUsers, + &response, + values, + true, + ) + if err := response.handleError(err, "fetching users"); err != nil { + return nil, "", ratelimitData, err + } + + return response.Users, + response.ResponseMetadata.NextCursor, + ratelimitData, + nil +} + +// GetTeams returns the teams of the given enterprise. +func (c *Client) GetTeams( + ctx context.Context, + cursor string, +) ( + []slack.Team, + string, + *v2.RateLimitDescription, + error, +) { + values := map[string]interface{}{} + + if cursor != "" { + values["cursor"] = cursor + } + + var response struct { + BaseResponse + Teams []slack.Team `json:"teams"` + Pagination + } + + ratelimitData, err := c.post( + ctx, + UrlPathGetTeams, + &response, + values, + false, + ) + + if err := response.handleError(err, "fetching teams"); err != nil { + return nil, "", ratelimitData, err + } + + return response.Teams, + response.ResponseMetadata.NextCursor, + ratelimitData, + nil +} + +// GetRoleAssignments returns the role assignments for the given role ID. +func (c *Client) GetRoleAssignments( + ctx context.Context, + roleID string, + cursor string, +) ( + []RoleAssignment, + string, + *v2.RateLimitDescription, + error, +) { + values := map[string]interface{}{} + + if roleID != "" { + values["role_ids"] = roleID + } + + if cursor != "" { + values["cursor"] = cursor + } + + var response struct { + BaseResponse + RoleAssignments []RoleAssignment `json:"role_assignments"` + Pagination + } + + ratelimitData, err := c.post( + ctx, + UrlPathGetRoleAssignments, + &response, + values, + false, + ) + if err := response.handleError(err, "fetching role assignments"); err != nil { + return nil, "", ratelimitData, err + } + + return response.RoleAssignments, + response.ResponseMetadata.NextCursor, + ratelimitData, + nil +} + +// GetUserGroups returns the user groups for the given team. +func (c *Client) GetUserGroups( + ctx context.Context, + teamID string, +) ( + []slack.UserGroup, + *v2.RateLimitDescription, + error, +) { + var response struct { + BaseResponse + UserGroups []slack.UserGroup `json:"usergroups"` + } + + ratelimitData, err := c.post( + ctx, + UrlPathGetUserGroups, + &response, + map[string]interface{}{"team_id": teamID}, + // The bot token needed here because user token doesn't work unless user + // is in all workspaces. + true, + ) + if err := response.handleError(err, "fetching user groups"); err != nil { + return nil, ratelimitData, err + } + + return response.UserGroups, ratelimitData, nil +} + +// SetWorkspaceRole sets the role for the given user in the given team. +func (c *Client) SetWorkspaceRole( + ctx context.Context, + teamID string, + userID string, + roleID string, +) ( + *v2.RateLimitDescription, + error, +) { + actionUrl, err := getWorkspaceUrlPathByRole(roleID) + if err != nil { + return nil, err + } + + var response BaseResponse + + ratelimitData, err := c.post( + ctx, + actionUrl, + &response, + map[string]interface{}{ + "team_id": teamID, + "user_id": userID, + }, + false, + ) + return ratelimitData, response.handleError(err, "setting user role") +} + +// ListIDPGroups returns all IDP groups from the SCIM API. +func (c *Client) ListIDPGroups( + ctx context.Context, + startIndex int, + count int, +) ( + *SCIMResponse[GroupResource], + *v2.RateLimitDescription, + error, +) { + var response SCIMResponse[GroupResource] + ratelimitData, err := c.getScim( + ctx, + UrlPathIDPGroups, + &response, + map[string]interface{}{ + "startIndex": startIndex, + "count": count, + }, + ) + if err != nil { + return nil, ratelimitData, fmt.Errorf("error fetching IDP groups: %w", err) + } + + return &response, ratelimitData, nil +} + +// GetIDPGroup returns a single IDP group from the SCIM API. +func (c *Client) GetIDPGroup( + ctx context.Context, + groupID string, +) ( + *GroupResource, + *v2.RateLimitDescription, + error, +) { + var response GroupResource + ratelimitData, err := c.getScim( + ctx, + fmt.Sprintf(UrlPathIDPGroup, groupID), + &response, + nil, + ) + if err != nil { + return nil, ratelimitData, fmt.Errorf("error fetching IDP group: %w", err) + } + + return &response, ratelimitData, nil +} + +// AddUserToGroup patches a group by adding a user to it. +func (c *Client) AddUserToGroup( + ctx context.Context, + groupID string, + user string, +) ( + *v2.RateLimitDescription, + error, +) { + requestBody := PatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: []ScimOperate{ + { + Op: "add", + Path: "members", + Value: []UserID{ + {Value: user}, + }, + }, + }, + } + + ratelimitData, err := c.patchGroup(ctx, groupID, requestBody) + if err != nil { + return ratelimitData, fmt.Errorf("error adding user to IDP group: %w", err) + } + + return ratelimitData, nil +} + +// RemoveUserFromGroup patches a group by removing a user from it. +func (c *Client) RemoveUserFromGroup( + ctx context.Context, + groupID string, + user string, +) ( + bool, + *v2.RateLimitDescription, + error, +) { + // First, we need to fetch group to get existing members. + group, ratelimitData, err := c.GetIDPGroup(ctx, groupID) + if err != nil { + return false, ratelimitData, fmt.Errorf("error fetching IDP group: %w", err) + } + + found := false + var result []UserID + for _, member := range group.Members { + if member.Value == user { + found = true + } else { + result = append(result, UserID{Value: member.Value}) + } + } + + // If we don't find the user, we can short-circuit here. + if !found { + return false, ratelimitData, nil + } + + requestBody := PatchOp{ + Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, + Operations: []ScimOperate{ + { + Op: "replace", + Path: "members", + Value: result, + }, + }, + } + + ratelimitData, err = c.patchGroup(ctx, groupID, requestBody) + if err != nil { + return false, ratelimitData, fmt.Errorf("error removing user from IDP group: %w", err) + } + + return true, ratelimitData, nil +} + +func (c *Client) patchGroup( + ctx context.Context, + groupID string, + requestBody PatchOp, +) ( + *v2.RateLimitDescription, + error, +) { + payload, err := json.Marshal(requestBody) + if err != nil { + return nil, err + } + + var response *GroupResource + ratelimitData, err := c.patchScim( + ctx, + fmt.Sprintf(UrlPathIDPGroup, groupID), + &response, + payload, + ) + if err != nil { + return ratelimitData, fmt.Errorf("error patching IDP group: %w", err) + } + + return ratelimitData, nil +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 23fc2746..c76d8397 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -8,7 +8,7 @@ import ( "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/connectorbuilder" "github.com/conductorone/baton-sdk/pkg/uhttp" - enterprise "github.com/conductorone/baton-slack/pkg/slack" + enterprise "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" @@ -22,51 +22,6 @@ type Slack struct { ssoEnabled bool } -var ( - resourceTypeUser = &v2.ResourceType{ - Id: "user", - DisplayName: "User", - Traits: []v2.ResourceType_Trait{ - v2.ResourceType_TRAIT_USER, - }, - } - resourceTypeWorkspace = &v2.ResourceType{ - Id: "workspace", - DisplayName: "Workspace", - Traits: []v2.ResourceType_Trait{ - v2.ResourceType_TRAIT_GROUP, - }, - } - resourceTypeUserGroup = &v2.ResourceType{ - Id: "userGroup", - DisplayName: "User Group", - Traits: []v2.ResourceType_Trait{ - v2.ResourceType_TRAIT_GROUP, - }, - } - resourceTypeGroup = &v2.ResourceType{ - Id: "group", - DisplayName: "IDP Group", - Traits: []v2.ResourceType_Trait{ - v2.ResourceType_TRAIT_GROUP, - }, - } - resourceTypeWorkspaceRole = &v2.ResourceType{ - Id: "workspaceRole", - DisplayName: "Workspace Role", - Traits: []v2.ResourceType_Trait{ - v2.ResourceType_TRAIT_ROLE, - }, - } - resourceTypeEnterpriseRole = &v2.ResourceType{ - Id: "enterpriseRole", - DisplayName: "Enterprise Role", - Traits: []v2.ResourceType_Trait{ - v2.ResourceType_TRAIT_ROLE, - }, - } -) - // Metadata returns metadata about the connector. func (c *Slack) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { return &v2.ConnectorMetadata{ @@ -98,7 +53,7 @@ type slackLogger struct { ZapLog *zap.Logger } -// Needed to prevent slack client from logging in its own format. +// Output Needed to prevent slack client from logging in its own format. func (s *slackLogger) Output(callDepth int, msg string) error { s.ZapLog.Info(msg, zap.Int("callDepth", callDepth)) return nil @@ -132,7 +87,16 @@ func New(ctx context.Context, apiKey, enterpriseKey string, ssoEnabled bool) (*S return nil, fmt.Errorf("slack-connector: enterprise account detected, but no enterprise token specified") } } - enterpriseClient := enterprise.NewClient(httpClient, enterpriseKey, apiKey, res.EnterpriseID, ssoEnabled) + enterpriseClient, err := enterprise.NewClient( + httpClient, + enterpriseKey, + apiKey, + res.EnterpriseID, + ssoEnabled, + ) + if err != nil { + return nil, fmt.Errorf("slack-connector: failed to create enterprise client. Error: %w", err) + } return &Slack{ client: client, diff --git a/pkg/connector/enterprise_roles.go b/pkg/connector/enterprise_roles.go index 8f7d4dac..311e163a 100644 --- a/pkg/connector/enterprise_roles.go +++ b/pkg/connector/enterprise_roles.go @@ -7,11 +7,11 @@ import ( 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" + "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" - - enterprise "github.com/conductorone/baton-slack/pkg/slack" + "github.com/conductorone/baton-slack/pkg" + enterprise "github.com/conductorone/baton-slack/pkg/connector/client" ) const ( @@ -82,7 +82,11 @@ func enterpriseRoleBuilder(enterpriseID string, enterpriseClient *enterprise.Cli } } -func enterpriseRoleResource(roleID string, parentResourceID *v2.ResourceId) (*v2.Resource, error) { +func enterpriseRoleResource( + _ context.Context, + roleID string, + _ *v2.ResourceId, +) (*v2.Resource, error) { var roleName string systemRoleName, ok := systemRoles[roleID] if !ok { @@ -96,35 +100,39 @@ func enterpriseRoleResource(roleID string, parentResourceID *v2.ResourceId) (*v2 roleName = systemRoleName } - r, err := resources.NewRoleResource( + return resources.NewRoleResource( roleName, resourceTypeEnterpriseRole, roleID, - nil) - - if err != nil { - return nil, err - } - - return r, nil + nil, + ) } -func (o *enterpriseRoleType) List(ctx context.Context, parentResourceID *v2.ResourceId, pt *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { +func (o *enterpriseRoleType) List( + ctx context.Context, + parentResourceID *v2.ResourceId, + pt *pagination.Token, +) ( + []*v2.Resource, + string, + annotations.Annotations, + error, +) { var ret []*v2.Resource - // no need to sync roles if we don't have an enterprise plan + // There is no need to sync roles if we don't have an enterprise plan. if o.enterpriseID == "" { return nil, "", nil, nil } - bag, err := parseRolesPageToken(pt.Token, &v2.ResourceId{ResourceType: resourceTypeEnterpriseRole.Id}) + bag, err := pkg.ParseRolesPageToken(pt.Token) if err != nil { return nil, "", nil, err } - // We only want to do this once + // We only want to do this once. if bag.Cursor == "" { for orgRoleID := range organizationRoles { - r, err := enterpriseRoleResource(orgRoleID, parentResourceID) + r, err := enterpriseRoleResource(ctx, orgRoleID, parentResourceID) if err != nil { return nil, "", nil, err } @@ -133,10 +141,11 @@ func (o *enterpriseRoleType) List(ctx context.Context, parentResourceID *v2.Reso } } - roleAssignments, nextPage, err := o.enterpriseClient.GetRoleAssignments(ctx, "", bag.Cursor) + outputAnnotations := annotations.New() + roleAssignments, nextPage, ratelimitData, err := o.enterpriseClient.GetRoleAssignments(ctx, "", bag.Cursor) + outputAnnotations.WithRateLimiting(ratelimitData) if err != nil { - annos, err := annotationsForError(err) - return nil, "", annos, err + return nil, "", outputAnnotations, err } bag.Cursor = nextPage @@ -150,7 +159,7 @@ func (o *enterpriseRoleType) List(ctx context.Context, parentResourceID *v2.Reso continue } - r, err := enterpriseRoleResource(roleAssignment.RoleID, parentResourceID) + r, err := enterpriseRoleResource(ctx, roleAssignment.RoleID, parentResourceID) if err != nil { return nil, "", nil, err } @@ -165,40 +174,75 @@ func (o *enterpriseRoleType) List(ctx context.Context, parentResourceID *v2.Reso return nil, "", nil, err } - return ret, nextPageToken, nil, nil + return ret, nextPageToken, outputAnnotations, nil } -func (o *enterpriseRoleType) Entitlements(ctx context.Context, resource *v2.Resource, _ *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { - rv := []*v2.Entitlement{ - ent.NewAssignmentEntitlement( - resource, - RoleAssignmentEntitlement, - ent.WithGrantableTo(resourceTypeUser), - ent.WithDescription(fmt.Sprintf("Has the %s role in the Slack enterprise", resource.DisplayName)), - ent.WithDisplayName(fmt.Sprintf("%s Enterprise Role", resource.DisplayName)), - ), - } - - return rv, "", nil, nil +func (o *enterpriseRoleType) Entitlements( + _ context.Context, + resource *v2.Resource, + _ *pagination.Token, +) ( + []*v2.Entitlement, + string, + annotations.Annotations, + error, +) { + return []*v2.Entitlement{ + entitlement.NewAssignmentEntitlement( + resource, + RoleAssignmentEntitlement, + entitlement.WithGrantableTo(resourceTypeUser), + entitlement.WithDescription( + fmt.Sprintf( + "Has the %s role in the Slack enterprise", + resource.DisplayName, + ), + ), + entitlement.WithDisplayName( + fmt.Sprintf( + "%s Enterprise Role", + resource.DisplayName, + ), + ), + ), + }, + "", + nil, + nil } -func (o *enterpriseRoleType) Grants(ctx context.Context, resource *v2.Resource, pt *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { +func (o *enterpriseRoleType) Grants( + ctx context.Context, + resource *v2.Resource, + pt *pagination.Token, +) ( + []*v2.Grant, + string, + annotations.Annotations, + error, +) { var rv []*v2.Grant - bag, err := parsePageToken(pt.Token, &v2.ResourceId{ResourceType: resourceTypeEnterpriseRole.Id}) + bag, err := pkg.ParsePageToken(pt.Token, &v2.ResourceId{ResourceType: resourceTypeEnterpriseRole.Id}) if err != nil { return nil, "", nil, err } - // if current role is one of organization roles, don't return any grants since we grant those on the user itself + // If current role is one of organization roles, don't return any grants + // since we grant those on the user itself. if _, ok := organizationRoles[resource.Id.Resource]; ok { return nil, "", nil, nil } - roleAssignments, nextPage, err := o.enterpriseClient.GetRoleAssignments(ctx, resource.Id.Resource, bag.PageToken()) + outputAnnotations := annotations.New() + roleAssignments, nextPage, ratelimitData, err := o.enterpriseClient.GetRoleAssignments( + ctx, + resource.Id.Resource, + bag.PageToken(), + ) + outputAnnotations.WithRateLimiting(ratelimitData) if err != nil { - annos, err := annotationsForError(err) - return nil, "", annos, err + return nil, "", outputAnnotations, err } pageToken, err := bag.NextToken(nextPage) @@ -215,5 +259,5 @@ func (o *enterpriseRoleType) Grants(ctx context.Context, resource *v2.Resource, rv = append(rv, grant.NewGrant(resource, RoleAssignmentEntitlement, userID)) } - return rv, pageToken, nil, nil + return rv, pageToken, outputAnnotations, nil } diff --git a/pkg/connector/group.go b/pkg/connector/group.go index ddd19909..49f3d624 100644 --- a/pkg/connector/group.go +++ b/pkg/connector/group.go @@ -3,18 +3,23 @@ package connector import ( "context" "fmt" + "strconv" 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" + "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" - enterprise "github.com/conductorone/baton-slack/pkg/slack" + "github.com/conductorone/baton-slack/pkg" + enterprise "github.com/conductorone/baton-slack/pkg/connector/client" "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" "go.uber.org/zap" ) +// TODO(marcos): Is this actually a bug? +const StartingOffset = 1 + type groupResourceType struct { resourceType *v2.ResourceType enterpriseID string @@ -36,64 +41,151 @@ func groupBuilder(enterpriseClient *enterprise.Client, enterpriseID string, ssoE } // Create a new connector resource for a Slack IDP group. -func groupResource(ctx context.Context, group enterprise.GroupResource) (*v2.Resource, error) { - profile := make(map[string]interface{}) - profile["group_id"] = group.ID - profile["group_name"] = group.DisplayName +func groupResource( + _ context.Context, + group enterprise.GroupResource, + _ *v2.ResourceId, +) (*v2.Resource, error) { + return resources.NewGroupResource( + group.DisplayName, + resourceTypeGroup, + group.ID, + []resources.GroupTraitOption{ + resources.WithGroupProfile( + map[string]interface{}{ + "group_id": group.ID, + "group_name": group.DisplayName, + }, + ), + }, + ) +} - groupTrait := []resources.GroupTraitOption{resources.WithGroupProfile(profile)} - ret, err := resources.NewGroupResource(group.DisplayName, resourceTypeGroup, group.ID, groupTrait) - if err != nil { - return nil, err +// parsePaginationToken - takes as pagination token and returns offset and limit +// in that order. TODO(marcos): move this to a util. +func parsePaginationToken(pToken *pagination.Token) (int, int, error) { + var ( + limit = enterprise.PageSizeDefault + offset = StartingOffset + ) + + if pToken != nil { + if pToken.Size > 0 { + limit = pToken.Size + } + + if pToken.Token != "" { + parsedOffset, err := strconv.Atoi(pToken.Token) + if err != nil { + return 0, 0, err + } + offset = parsedOffset + } } + return offset, limit, nil +} - return ret, nil +func getNextToken(offset int, limit int, total int) string { + nextOffset := offset + limit + if nextOffset >= total { + return "" + } + return strconv.Itoa(nextOffset) } -func (g *groupResourceType) List(ctx context.Context, _ *v2.ResourceId, pt *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { +func (g *groupResourceType) List( + ctx context.Context, + parentResourceId *v2.ResourceId, + pageToken *pagination.Token, +) ( + []*v2.Resource, + string, + annotations.Annotations, + error, +) { if !g.ssoEnabled { return nil, "", nil, nil } - groups, err := g.enterpriseClient.ListIDPGroups(ctx) + offset, limit, err := parsePaginationToken(pageToken) if err != nil { - annos, err := annotationsForError(err) - return nil, "", annos, err + return nil, "", nil, err } - rv := make([]*v2.Resource, 0, len(groups)) - for _, group := range groups { - cr, err := groupResource(ctx, group) - if err != nil { - return nil, "", nil, err - } - rv = append(rv, cr) + outputAnnotations := annotations.New() + groupsResponse, ratelimitData, err := g.enterpriseClient.ListIDPGroups(ctx, limit, offset) + outputAnnotations.WithRateLimiting(ratelimitData) + if err != nil { + return nil, "", outputAnnotations, err } - return rv, "", nil, nil -} - -func (g *groupResourceType) Entitlements(ctx context.Context, resource *v2.Resource, _ *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { - var rv []*v2.Entitlement - - assigmentOptions := []ent.EntitlementOption{ - ent.WithGrantableTo(resourceTypeUser), - ent.WithDescription(fmt.Sprintf("Member of %s IDP group", resource.DisplayName)), - ent.WithDisplayName(fmt.Sprintf("%s IDP group %s", resource.DisplayName, memberEntitlement)), + groups, err := pkg.MakeResourceList( + ctx, + groupsResponse.Resources, + parentResourceId, + groupResource, + ) + if err != nil { + return nil, "", nil, err } - en := ent.NewAssignmentEntitlement(resource, memberEntitlement, assigmentOptions...) - rv = append(rv, en) + nextToken := getNextToken(offset, limit, groupsResponse.TotalResults) - return rv, "", nil, nil + return groups, nextToken, outputAnnotations, nil } -func (g *groupResourceType) Grants(ctx context.Context, resource *v2.Resource, pt *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { +func (g *groupResourceType) Entitlements( + ctx context.Context, + resource *v2.Resource, + _ *pagination.Token, +) ( + []*v2.Entitlement, + string, + annotations.Annotations, + error, +) { + return []*v2.Entitlement{ + entitlement.NewAssignmentEntitlement( + resource, + memberEntitlement, + entitlement.WithGrantableTo(resourceTypeUser), + entitlement.WithDescription( + fmt.Sprintf( + "Member of %s IDP group", + resource.DisplayName, + ), + ), + entitlement.WithDisplayName( + fmt.Sprintf( + "%s IDP group %s", + resource.DisplayName, + memberEntitlement, + ), + ), + ), + }, + "", + nil, + nil +} + +func (g *groupResourceType) Grants( + ctx context.Context, + resource *v2.Resource, + _ *pagination.Token, +) ( + []*v2.Grant, + string, + annotations.Annotations, + error, +) { + outputAnnotations := annotations.New() + var rv []*v2.Grant - group, err := g.enterpriseClient.GetIDPGroup(ctx, resource.Id.Resource) + group, ratelimitData, err := g.enterpriseClient.GetIDPGroup(ctx, resource.Id.Resource) + outputAnnotations.WithRateLimiting(ratelimitData) if err != nil { - annos, err := annotationsForError(err) - return nil, "", annos, err + return nil, "", outputAnnotations, err } for _, member := range group.Members { @@ -106,14 +198,21 @@ func (g *groupResourceType) Grants(ctx context.Context, resource *v2.Resource, p rv = append(rv, grant) } - return rv, "", nil, nil + return rv, "", outputAnnotations, nil } -func (g *groupResourceType) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { - l := ctxzap.Extract(ctx) +func (g *groupResourceType) Grant( + ctx context.Context, + principal *v2.Resource, + entitlement *v2.Entitlement, +) ( + annotations.Annotations, + error, +) { + logger := ctxzap.Extract(ctx) if principal.Id.ResourceType != resourceTypeUser.Id { - l.Warn( + logger.Warn( "baton-slack: only users can be added to an IDP group", zap.String("principal_type", principal.Id.ResourceType), zap.String("principal_id", principal.Id.Resource), @@ -121,22 +220,34 @@ func (g *groupResourceType) Grant(ctx context.Context, principal *v2.Resource, e return nil, fmt.Errorf("baton-slack: only users can be added to an IDP group") } - err := g.enterpriseClient.AddUserToGroup(ctx, entitlement.Resource.Id.Resource, principal.Id.Resource) + outputAnnotations := annotations.New() + ratelimitData, err := g.enterpriseClient.AddUserToGroup( + ctx, + entitlement.Resource.Id.Resource, + principal.Id.Resource, + ) + outputAnnotations.WithRateLimiting(ratelimitData) if err != nil { - return nil, fmt.Errorf("baton-slack: failed to add user to an IDP group: %w", err) + return outputAnnotations, fmt.Errorf("baton-slack: failed to add user to an IDP group: %w", err) } - return nil, nil + return outputAnnotations, nil } -func (g *groupResourceType) Revoke(ctx context.Context, grant *v2.Grant) (annotations.Annotations, error) { - l := ctxzap.Extract(ctx) +func (g *groupResourceType) Revoke( + ctx context.Context, + grant *v2.Grant, +) ( + annotations.Annotations, + error, +) { + logger := ctxzap.Extract(ctx) principal := grant.Principal entitlement := grant.Entitlement if principal.Id.ResourceType != resourceTypeUser.Id { - l.Warn( + logger.Warn( "baton-slack: only users can be removed from an IDP group", zap.String("principal_type", principal.Id.ResourceType), zap.String("principal_id", principal.Id.Resource), @@ -144,11 +255,21 @@ func (g *groupResourceType) Revoke(ctx context.Context, grant *v2.Grant) (annota return nil, fmt.Errorf("baton-slack: only users can be removed from an IDP group") } - err := g.enterpriseClient.RemoveUserFromGroup(ctx, entitlement.Resource.Id.Resource, principal.Id.Resource) + outputAnnotations := annotations.New() + wasRevoked, ratelimitData, err := g.enterpriseClient.RemoveUserFromGroup( + ctx, + entitlement.Resource.Id.Resource, + principal.Id.Resource, + ) + outputAnnotations.WithRateLimiting(ratelimitData) if err != nil { - return nil, fmt.Errorf("baton-slack: failed to remove user from IDP group: %w", err) + return outputAnnotations, fmt.Errorf("baton-slack: failed to remove user from IDP group: %w", err) + } + + if !wasRevoked { + outputAnnotations.Append(&v2.GrantAlreadyRevoked{}) } - return nil, nil + return outputAnnotations, nil } diff --git a/pkg/connector/helpers.go b/pkg/connector/helpers.go deleted file mode 100644 index 2a298aa3..00000000 --- a/pkg/connector/helpers.go +++ /dev/null @@ -1,102 +0,0 @@ -package connector - -import ( - "encoding/json" - "errors" - "time" - - v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" - "github.com/conductorone/baton-sdk/pkg/annotations" - "github.com/conductorone/baton-sdk/pkg/pagination" - "github.com/slack-go/slack" - "google.golang.org/protobuf/types/known/timestamppb" - - enterprise "github.com/conductorone/baton-slack/pkg/slack" -) - -type enterpriseRolesPagination struct { - Cursor string `json:"cursor"` - FoundMap map[string]bool `json:"foundMap"` -} - -func (e *enterpriseRolesPagination) Marshal() (string, error) { - if e.Cursor == "" { - return "", nil - } - bytes, err := json.Marshal(e) - if err != nil { - return "", err - } - - return string(bytes), nil -} - -func (e *enterpriseRolesPagination) Unmarshal(input string) error { - if input == "" { - e.FoundMap = make(map[string]bool) - return nil - } - - err := json.Unmarshal([]byte(input), e) - if err != nil { - return err - } - - return nil -} - -func parseRolesPageToken(i string, resourceID *v2.ResourceId) (*enterpriseRolesPagination, error) { - b := &enterpriseRolesPagination{} - err := b.Unmarshal(i) - if err != nil { - return nil, err - } - - if b.FoundMap == nil { - b.FoundMap = make(map[string]bool) - } - - return b, nil -} - -func parsePageToken(i string, resourceID *v2.ResourceId) (*pagination.Bag, error) { - b := &pagination.Bag{} - err := b.Unmarshal(i) - if err != nil { - return nil, err - } - - if b.Current() == nil { - b.Push(pagination.PageState{ - ResourceTypeID: resourceID.ResourceType, - ResourceID: resourceID.Resource, - }) - } - - return b, nil -} - -func annotationsForError(err error) (annotations.Annotations, error) { - annos := annotations.Annotations{} - var rateLimitErr *slack.RateLimitedError - if errors.As(err, &rateLimitErr) { - annos.WithRateLimiting(&v2.RateLimitDescription{ - Limit: 0, - Remaining: 0, - ResetAt: timestamppb.New(time.Now().Add(rateLimitErr.RetryAfter)), - }) - return annos, nil - } - - var enterpriseRateLimitErr *enterprise.RateLimitError - if errors.As(err, &enterpriseRateLimitErr) { - annos.WithRateLimiting(&v2.RateLimitDescription{ - Limit: 0, - Remaining: 0, - ResetAt: timestamppb.New(time.Now().Add(enterpriseRateLimitErr.RetryAfter)), - }) - return annos, nil - } - - return annos, err -} diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go new file mode 100644 index 00000000..e1ee6996 --- /dev/null +++ b/pkg/connector/resource_types.go @@ -0,0 +1,48 @@ +package connector + +import v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + +var ( + resourceTypeUser = &v2.ResourceType{ + Id: "user", + DisplayName: "User", + Traits: []v2.ResourceType_Trait{ + v2.ResourceType_TRAIT_USER, + }, + } + resourceTypeWorkspace = &v2.ResourceType{ + Id: "workspace", + DisplayName: "Workspace", + Traits: []v2.ResourceType_Trait{ + v2.ResourceType_TRAIT_GROUP, + }, + } + resourceTypeUserGroup = &v2.ResourceType{ + Id: "userGroup", + DisplayName: "User Group", + Traits: []v2.ResourceType_Trait{ + v2.ResourceType_TRAIT_GROUP, + }, + } + resourceTypeGroup = &v2.ResourceType{ + Id: "group", + DisplayName: "IDP Group", + Traits: []v2.ResourceType_Trait{ + v2.ResourceType_TRAIT_GROUP, + }, + } + resourceTypeWorkspaceRole = &v2.ResourceType{ + Id: "workspaceRole", + DisplayName: "Workspace Role", + Traits: []v2.ResourceType_Trait{ + v2.ResourceType_TRAIT_ROLE, + }, + } + resourceTypeEnterpriseRole = &v2.ResourceType{ + Id: "enterpriseRole", + DisplayName: "Enterprise Role", + Traits: []v2.ResourceType_Trait{ + v2.ResourceType_TRAIT_ROLE, + }, + } +) diff --git a/pkg/connector/roles.go b/pkg/connector/roles.go index 03f6bb6d..4dfbd8a3 100644 --- a/pkg/connector/roles.go +++ b/pkg/connector/roles.go @@ -3,14 +3,16 @@ package connector import ( "context" "fmt" - "strings" + "maps" + "slices" 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" + "github.com/conductorone/baton-sdk/pkg/types/entitlement" resources "github.com/conductorone/baton-sdk/pkg/types/resource" - enterprise "github.com/conductorone/baton-slack/pkg/slack" + "github.com/conductorone/baton-slack/pkg" + enterprise "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" @@ -58,7 +60,11 @@ func workspaceRoleBuilder(client *slack.Client, enterpriseClient *enterprise.Cli } } -func roleResource(roleID string, parentResourceID *v2.ResourceId) (*v2.Resource, error) { +func roleResource( + _ context.Context, + roleID string, + parentResourceID *v2.ResourceId, +) (*v2.Resource, error) { roleName, ok := roles[roleID] if !ok { return nil, fmt.Errorf("invalid roleID: %s", roleID) @@ -79,56 +85,102 @@ func roleResource(roleID string, parentResourceID *v2.ResourceId) (*v2.Resource, return r, nil } -func (o *workspaceRoleType) List(ctx context.Context, parentResourceID *v2.ResourceId, pt *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { +func (o *workspaceRoleType) List( + ctx context.Context, + parentResourceID *v2.ResourceId, + _ *pagination.Token, +) ( + []*v2.Resource, + string, + annotations.Annotations, + error, +) { if parentResourceID == nil { return nil, "", nil, nil } - var ret []*v2.Resource - - for roleID := range roles { - r, err := roleResource(roleID, parentResourceID) - if err != nil { - return nil, "", nil, err - } - - ret = append(ret, r) + output, err := pkg.MakeResourceList( + ctx, + slices.Collect(maps.Keys(roles)), + parentResourceID, + roleResource, + ) + if err != nil { + return nil, "", nil, err } - - return ret, "", nil, nil + return output, "", nil, nil } -func (o *workspaceRoleType) Entitlements(ctx context.Context, resource *v2.Resource, _ *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { - var rv []*v2.Entitlement - - workspaceName, ok := workspacesMap[resource.ParentResourceId.Resource] +func (o *workspaceRoleType) Entitlements( + _ context.Context, + resource *v2.Resource, + _ *pagination.Token, +) ( + []*v2.Entitlement, + string, + annotations.Annotations, + error, +) { + workspaceName, ok := workspacesNameCache[resource.ParentResourceId.Resource] if !ok { return nil, "", nil, fmt.Errorf("invalid workspace: %s", resource.ParentResourceId.Resource) } - - options := []ent.EntitlementOption{ - ent.WithGrantableTo(resourceTypeUser), - ent.WithDescription(fmt.Sprintf("Has the %s role in the Slack %s workspace", resource.DisplayName, workspaceName)), - ent.WithDisplayName(fmt.Sprintf("%s workspace %s role", workspaceName, resource.DisplayName)), - } - - roleEntitlement := ent.NewAssignmentEntitlement(resource, RoleAssignmentEntitlement, options...) - rv = append(rv, roleEntitlement) - - return rv, "", nil, nil + return []*v2.Entitlement{ + entitlement.NewAssignmentEntitlement( + resource, + RoleAssignmentEntitlement, + entitlement.WithGrantableTo(resourceTypeUser), + entitlement.WithDescription( + fmt.Sprintf( + "Has the %s role in the Slack %s workspace", + resource.DisplayName, + workspaceName, + ), + ), + entitlement.WithDisplayName( + fmt.Sprintf( + "%s workspace %s role", + workspaceName, + resource.DisplayName, + ), + ), + ), + }, + "", + nil, + nil } -// Grants would normally return the grants for each role resource. Due to how the Slack API works, it is more efficient to emit these roles while listing -// grants for each individual user. Instead of having to list users for each role we can divine which roles a user should be granted when calculating their grants. -func (o *workspaceRoleType) Grants(ctx context.Context, resource *v2.Resource, pt *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { +// Grants would normally return the grants for each role resource. Due to how +// the Slack API works, it is more efficient to emit these roles while listing +// grants for each individual user. Instead of having to list users for each +// role we can divine which roles a user should be granted when calculating +// their grants. +func (o *workspaceRoleType) Grants( + _ context.Context, + _ *v2.Resource, + _ *pagination.Token, +) ( + []*v2.Grant, + string, + annotations.Annotations, + error, +) { return nil, "", nil, nil } -func (o *workspaceRoleType) Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error) { - l := ctxzap.Extract(ctx) +func (o *workspaceRoleType) Grant( + ctx context.Context, + principal *v2.Resource, + entitlement *v2.Entitlement, +) ( + annotations.Annotations, + error, +) { + logger := ctxzap.Extract(ctx) if principal.Id.ResourceType != resourceTypeUser.Id { - l.Warn( + logger.Warn( "baton-slack: only users can be assigned a role", zap.String("principal_type", principal.Id.ResourceType), zap.String("principal_id", principal.Id.Resource), @@ -136,29 +188,40 @@ func (o *workspaceRoleType) Grant(ctx context.Context, principal *v2.Resource, e return nil, fmt.Errorf("baton-slack: only users can be assigned a role") } - parts := strings.Split(entitlement.Id, ":") - if len(parts) < 2 { - return nil, fmt.Errorf("baton-slack: invalid entitlement ID: %s", entitlement.Id) - } - // teamID is in the entitlement ID at second position - teamID := parts[1] + teamID, err := pkg.ParseID(entitlement.Id) + if err != nil { + return nil, err + } - err := o.enterpriseClient.SetWorkspaceRole(ctx, teamID, principal.Id.Resource, entitlement.Resource.Id.Resource) + outputAnnotations := annotations.New() + ratelimitData, err := o.enterpriseClient.SetWorkspaceRole( + ctx, + teamID, + principal.Id.Resource, + entitlement.Resource.Id.Resource, + ) + outputAnnotations.WithRateLimiting(ratelimitData) if err != nil { - return nil, fmt.Errorf("baton-slack: failed to assign user role: %w", err) + return outputAnnotations, fmt.Errorf("baton-slack: failed to assign user role: %w", err) } - return nil, nil + return outputAnnotations, nil } -func (o *workspaceRoleType) Revoke(ctx context.Context, grant *v2.Grant) (annotations.Annotations, error) { - l := ctxzap.Extract(ctx) +func (o *workspaceRoleType) Revoke( + ctx context.Context, + grant *v2.Grant, +) ( + annotations.Annotations, + error, +) { + logger := ctxzap.Extract(ctx) principal := grant.Principal if principal.Id.ResourceType != resourceTypeUser.Id { - l.Warn( + logger.Warn( "baton-slack: only users can have role revoked", zap.String("principal_type", principal.Id.ResourceType), zap.String("principal_id", principal.Id.Resource), @@ -166,20 +229,26 @@ func (o *workspaceRoleType) Revoke(ctx context.Context, grant *v2.Grant) (annota return nil, fmt.Errorf("baton-slack: only users can have role revoked") } - parts := strings.Split(grant.Id, ":") - if len(parts) < 2 { - return nil, fmt.Errorf("baton-slack: invalid grant ID: %s", grant.Id) + // teamID is in the grant ID at second position + teamID, err := pkg.ParseID(grant.Id) + if err != nil { + return nil, err } - // teamID is in the grant ID at second position - teamID := parts[1] + outputAnnotations := annotations.New() // empty role type means regular user - err := o.enterpriseClient.SetWorkspaceRole(ctx, teamID, principal.Id.Resource, "") + ratelimitData, err := o.enterpriseClient.SetWorkspaceRole( + ctx, + teamID, + principal.Id.Resource, + "", + ) + outputAnnotations.WithRateLimiting(ratelimitData) if err != nil { - return nil, fmt.Errorf("baton-slack: failed to revoke user role: %w", err) + return outputAnnotations, fmt.Errorf("baton-slack: failed to revoke user role: %w", err) } - return nil, nil + return outputAnnotations, nil } diff --git a/pkg/connector/user.go b/pkg/connector/user.go index 09c70cd9..39db7e70 100644 --- a/pkg/connector/user.go +++ b/pkg/connector/user.go @@ -7,7 +7,8 @@ import ( "github.com/conductorone/baton-sdk/pkg/annotations" "github.com/conductorone/baton-sdk/pkg/pagination" "github.com/conductorone/baton-sdk/pkg/types/resource" - enterprise "github.com/conductorone/baton-slack/pkg/slack" + "github.com/conductorone/baton-slack/pkg" + enterprise "github.com/conductorone/baton-slack/pkg/connector/client" "github.com/slack-go/slack" ) @@ -23,7 +24,11 @@ func (o *userResourceType) ResourceType(_ context.Context) *v2.ResourceType { } // Create a new connector resource for a Slack user. -func userResource(ctx context.Context, user *slack.User, parentResourceID *v2.ResourceId) (*v2.Resource, error) { +func userResource( + _ context.Context, + user *slack.User, + parentResourceID *v2.ResourceId, +) (*v2.Resource, error) { profile := make(map[string]interface{}) profile["first_name"] = user.Profile.FirstName profile["last_name"] = user.Profile.LastName @@ -43,39 +48,51 @@ func userResource(ctx context.Context, user *slack.User, parentResourceID *v2.Re profile["is_stranger"] = user.IsStranger profile["is_deleted"] = user.Deleted - userTraitOptions := []resource.UserTraitOption{ - resource.WithUserProfile(profile), - resource.WithEmail(user.Profile.Email, true), - } - userStatus := v2.UserTrait_Status_STATUS_ENABLED if user.Deleted { userStatus = v2.UserTrait_Status_STATUS_DELETED } - userTraitOptions = append(userTraitOptions, resource.WithStatus(userStatus)) + + userTraitOptions := []resource.UserTraitOption{ + resource.WithUserProfile(profile), + resource.WithEmail(user.Profile.Email, true), + resource.WithStatus(userStatus), + } if user.IsBot { - userTraitOptions = append(userTraitOptions, resource.WithAccountType(v2.UserTrait_ACCOUNT_TYPE_SERVICE)) + userTraitOptions = append( + userTraitOptions, + resource.WithAccountType(v2.UserTrait_ACCOUNT_TYPE_SERVICE), + ) } - // If the credentials we're hitting the API with don't have admin, this can be false even if the user has mfa enabled + // If the credentials we're hitting the API with don't have admin, this can + // be false even if the user has mfa enabled. // See https://api.slack.com/types/user for more info if user.Has2FA { - userTraitOptions = append(userTraitOptions, resource.WithMFAStatus(&v2.UserTrait_MFAStatus{MfaEnabled: true})) + userTraitOptions = append( + userTraitOptions, + resource.WithMFAStatus(&v2.UserTrait_MFAStatus{MfaEnabled: true}), + ) } - ret, err := resource.NewUserResource(user.Name, resourceTypeUser, user.ID, userTraitOptions, resource.WithParentResourceID(parentResourceID)) - if err != nil { - return nil, err - } - - return ret, nil + return resource.NewUserResource( + user.Name, + resourceTypeUser, + user.ID, + userTraitOptions, + resource.WithParentResourceID(parentResourceID), + ) } -// Create a new connector resource for a base Slack user. -// Admin API doesn't return the same values as the user API. -// We need to create a base resource for users without workspace that are fetched by the Admin API. -func baseUserResource(ctx context.Context, user enterprise.UserAdmin) (*v2.Resource, error) { +// baseUserResource Create a new connector resource for a base Slack user. Admin +// API doesn't return the same values as the user API. We need to create a base +// resource for users without workspace that are fetched by the Admin API. +func baseUserResource( + _ context.Context, + user enterprise.UserAdmin, + _ *v2.ResourceId, +) (*v2.Resource, error) { firstname, lastname := resource.SplitFullName(user.FullName) profile := make(map[string]interface{}) profile["first_name"] = firstname @@ -91,64 +108,103 @@ func baseUserResource(ctx context.Context, user enterprise.UserAdmin) (*v2.Resou userStatus = v2.UserTrait_Status_STATUS_DISABLED } + ssoStatus := &v2.UserTrait_SSOStatus{SsoEnabled: false} + if user.HasSso { + ssoStatus = &v2.UserTrait_SSOStatus{SsoEnabled: true} + } + userTraitOptions := []resource.UserTraitOption{ resource.WithUserProfile(profile), resource.WithEmail(user.Email, true), resource.WithStatus(userStatus), resource.WithUserLogin(user.Username), + resource.WithSSOStatus(ssoStatus), } if user.IsBot { - userTraitOptions = append(userTraitOptions, resource.WithAccountType(v2.UserTrait_ACCOUNT_TYPE_SERVICE)) + userTraitOptions = append( + userTraitOptions, + resource.WithAccountType(v2.UserTrait_ACCOUNT_TYPE_SERVICE), + ) } - // If the credentials we're hitting the API with don't have admin, this can be false even if the user has mfa enabled + // If the credentials we're hitting the API with don't have admin, this can + // be false even if the user has mfa enabled. // See https://api.slack.com/types/user for more info if user.Has2Fa { - userTraitOptions = append(userTraitOptions, resource.WithMFAStatus(&v2.UserTrait_MFAStatus{MfaEnabled: true})) + userTraitOptions = append( + userTraitOptions, + resource.WithMFAStatus(&v2.UserTrait_MFAStatus{MfaEnabled: true}), + ) } - ssoStatus := &v2.UserTrait_SSOStatus{SsoEnabled: false} - if user.HasSso { - ssoStatus = &v2.UserTrait_SSOStatus{SsoEnabled: true} - } - userTraitOptions = append(userTraitOptions, resource.WithSSOStatus(ssoStatus)) - - ret, err := resource.NewUserResource(user.FullName, resourceTypeUser, user.ID, userTraitOptions) - if err != nil { - return nil, err - } - - return ret, nil + return resource.NewUserResource( + user.FullName, + resourceTypeUser, + user.ID, + userTraitOptions, + ) } -func (o *userResourceType) Entitlements(_ context.Context, _ *v2.Resource, _ *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { +func (o *userResourceType) Entitlements( + _ context.Context, + _ *v2.Resource, + _ *pagination.Token, +) ( + []*v2.Entitlement, + string, + annotations.Annotations, + error, +) { return nil, "", nil, nil } -func (o *userResourceType) Grants(ctx context.Context, resource *v2.Resource, pt *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { +func (o *userResourceType) Grants( + _ context.Context, + _ *v2.Resource, + _ *pagination.Token, +) ( + []*v2.Grant, + string, + annotations.Annotations, + error, +) { return nil, "", nil, nil } -func (o *userResourceType) List(ctx context.Context, parentResourceID *v2.ResourceId, pt *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { +func (o *userResourceType) List( + ctx context.Context, + parentResourceID *v2.ResourceId, + pt *pagination.Token, +) ( + []*v2.Resource, + string, + annotations.Annotations, + error, +) { if parentResourceID == nil { return nil, "", nil, nil } - var allUsers []enterprise.UserAdmin - var pageToken string - var nextCursor string - + var ( + allUsers []enterprise.UserAdmin + pageToken string + nextCursor string + ratelimitData *v2.RateLimitDescription + ) + outputAnnotations := annotations.New() if o.enterpriseID != "" { - bag, err := parsePageToken(pt.Token, &v2.ResourceId{ResourceType: resourceTypeUser.Id}) + bag, err := pkg.ParsePageToken(pt.Token, &v2.ResourceId{ResourceType: resourceTypeUser.Id}) if err != nil { return nil, "", nil, err } - // need to fetch all users because users without workspace won't be fetched by GetUsersContext - allUsers, nextCursor, err = o.enterpriseClient.GetUsersAdmin(ctx, bag.PageToken()) + + // We need to fetch all users because users without workspace won't be + // fetched by GetUsersContext. + allUsers, nextCursor, ratelimitData, err = o.enterpriseClient.GetUsersAdmin(ctx, bag.PageToken()) + outputAnnotations.WithRateLimiting(ratelimitData) if err != nil { - annos, err := annotationsForError(err) - return nil, "", annos, err + return nil, "", outputAnnotations, err } pageToken, err = bag.NextToken(nextCursor) if err != nil { @@ -156,39 +212,51 @@ func (o *userResourceType) List(ctx context.Context, parentResourceID *v2.Resour } } - users, err := o.client.GetUsersContext(ctx, slack.GetUsersOptionTeamID(parentResourceID.Resource)) + options := slack.GetUsersOptionTeamID(parentResourceID.Resource) + users, err := o.client.GetUsersContext(ctx, options) if err != nil { - annos, err := annotationsForError(err) + annos, err := pkg.AnnotationsForError(err) return nil, "", annos, err } - var rv []*v2.Resource - - // create a base resource if user has no workspace - for _, user := range allUsers { - if len(user.Workspaces) == 0 { - ur, err := baseUserResource(ctx, user) - if err != nil { - return nil, "", nil, err - } - rv = append(rv, ur) - } + // Create a base resource if user has no workspace. + rv0, err := pkg.MakeResourceList( + ctx, + allUsers, + nil, + baseUserResource, + ) + if err != nil { + return nil, "", nil, err } - // users without workspace won't be part of users array - for _, user := range users { - userCopy := user - ur, err := userResource(ctx, &userCopy, parentResourceID) - if err != nil { - return nil, "", nil, err - } - rv = append(rv, ur) + // Users without workspace won't be part of users array. + rv1, err := pkg.MakeResourceList( + ctx, + users, + parentResourceID, + func( + ctx context.Context, + object slack.User, + parentResourceID *v2.ResourceId, + ) ( + *v2.Resource, + error, + ) { + return userResource(ctx, &object, parentResourceID) + }, + ) + if err != nil { + return nil, "", nil, err } - - return rv, pageToken, nil, nil + return append(rv0, rv1...), pageToken, outputAnnotations, nil } -func userBuilder(client *slack.Client, enterpriseID string, enterpriseClient *enterprise.Client) *userResourceType { +func userBuilder( + client *slack.Client, + enterpriseID string, + enterpriseClient *enterprise.Client, +) *userResourceType { return &userResourceType{ resourceType: resourceTypeUser, client: client, diff --git a/pkg/connector/user_group.go b/pkg/connector/user_group.go index f5fe4d7a..c9fe4c29 100644 --- a/pkg/connector/user_group.go +++ b/pkg/connector/user_group.go @@ -7,10 +7,11 @@ import ( 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" - resource "github.com/conductorone/baton-sdk/pkg/types/resource" - enterprise "github.com/conductorone/baton-slack/pkg/slack" + "github.com/conductorone/baton-sdk/pkg/types/entitlement" + "github.com/conductorone/baton-sdk/pkg/types/grant" + "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/conductorone/baton-slack/pkg" + enterprise "github.com/conductorone/baton-slack/pkg/connector/client" "github.com/slack-go/slack" ) @@ -25,7 +26,11 @@ func (o *userGroupResourceType) ResourceType(_ context.Context) *v2.ResourceType return o.resourceType } -func userGroupBuilder(client *slack.Client, enterpriseID string, enterpriseClient *enterprise.Client) *userGroupResourceType { +func userGroupBuilder( + client *slack.Client, + enterpriseID string, + enterpriseClient *enterprise.Client, +) *userGroupResourceType { return &userGroupResourceType{ resourceType: resourceTypeUserGroup, client: client, @@ -35,88 +40,145 @@ func userGroupBuilder(client *slack.Client, enterpriseID string, enterpriseClien } // Create a new connector resource for a Slack user group. -func userGroupResource(ctx context.Context, userGroup slack.UserGroup, parentResourceID *v2.ResourceId) (*v2.Resource, error) { - profile := make(map[string]interface{}) - profile["userGroup_id"] = userGroup.ID - profile["userGroup_name"] = userGroup.Name - profile["userGroup_handle"] = userGroup.Handle - - groupTrait := []resource.GroupTraitOption{resource.WithGroupProfile(profile)} - ret, err := resource.NewGroupResource(userGroup.Name, resourceTypeUserGroup, userGroup.ID, groupTrait, resource.WithParentResourceID(parentResourceID)) - if err != nil { - return nil, err - } - - return ret, nil +func userGroupResource( + ctx context.Context, + userGroup slack.UserGroup, + parentResourceID *v2.ResourceId, +) (*v2.Resource, error) { + return resource.NewGroupResource( + userGroup.Name, + resourceTypeUserGroup, + userGroup.ID, + []resource.GroupTraitOption{ + resource.WithGroupProfile( + map[string]interface{}{ + "userGroup_id": userGroup.ID, + "userGroup_name": userGroup.Name, + "userGroup_handle": userGroup.Handle, + }, + ), + }, + resource.WithParentResourceID(parentResourceID), + ) } -func (o *userGroupResourceType) List(ctx context.Context, parentResourceID *v2.ResourceId, pt *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { +func (o *userGroupResourceType) List( + ctx context.Context, + parentResourceID *v2.ResourceId, + _ *pagination.Token, +) ( + []*v2.Resource, + string, + annotations.Annotations, + error, +) { if parentResourceID == nil { return nil, "", nil, nil } - var userGroups []slack.UserGroup - var err error - // different method here because we need to pass a teamID, but it's not supported by the slack-go library + var ( + userGroups []slack.UserGroup + ratelimitData *v2.RateLimitDescription + err error + ) + outputAnnotations := annotations.New() + // We use different method here because we need to pass a teamID, but it's + // not supported by the slack-go library. if o.enterpriseID != "" { - userGroups, err = o.enterpriseClient.GetUserGroups(ctx, parentResourceID.Resource) + userGroups, ratelimitData, err = o.enterpriseClient.GetUserGroups(ctx, parentResourceID.Resource) + outputAnnotations.WithRateLimiting(ratelimitData) if err != nil { - annos, err := annotationsForError(err) - return nil, "", annos, err + return nil, "", outputAnnotations, err } } else { opts := []slack.GetUserGroupsOption{ slack.GetUserGroupsOptionIncludeUsers(true), - // We need to add a way to signify disabled resources in baton in order to include disabled groups - // We should also be doing this for both enterprise and non-enterprise groups + // We need to add a way to signify disabled resources in baton in + // order to include disabled groups. We should also be doing this + // for both enterprise and non-enterprise groups. // slack.GetUserGroupsOptionIncludeDisabled(true), } userGroups, err = o.client.GetUserGroupsContext(ctx, opts...) if err != nil { - annos, err := annotationsForError(err) + annos, err := pkg.AnnotationsForError(err) return nil, "", annos, err } } - rv := make([]*v2.Resource, 0, len(userGroups)) - for _, userGroup := range userGroups { - cr, err := userGroupResource(ctx, userGroup, parentResourceID) - if err != nil { - return nil, "", nil, err - } - rv = append(rv, cr) + output, err := pkg.MakeResourceList( + ctx, + userGroups, + parentResourceID, + userGroupResource, + ) + if err != nil { + return nil, "", nil, err } - - return rv, "", nil, nil + return output, "", outputAnnotations, nil } -func (o *userGroupResourceType) Entitlements(ctx context.Context, resource *v2.Resource, _ *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { - var rv []*v2.Entitlement - - assigmentOptions := []ent.EntitlementOption{ - ent.WithGrantableTo(resourceTypeUser), - ent.WithDescription(fmt.Sprintf("Member of %s User group", resource.DisplayName)), - ent.WithDisplayName(fmt.Sprintf("%s User group %s", resource.DisplayName, memberEntitlement)), - } - - en := ent.NewAssignmentEntitlement(resource, memberEntitlement, assigmentOptions...) - rv = append(rv, en) - - return rv, "", nil, nil +func (o *userGroupResourceType) Entitlements( + _ context.Context, + resource *v2.Resource, + _ *pagination.Token, +) ( + []*v2.Entitlement, + string, + annotations.Annotations, + error, +) { + return []*v2.Entitlement{ + entitlement.NewAssignmentEntitlement( + resource, + memberEntitlement, + entitlement.WithGrantableTo(resourceTypeUser), + entitlement.WithDescription( + fmt.Sprintf( + "Member of %s User group", + resource.DisplayName, + ), + ), + entitlement.WithDisplayName( + fmt.Sprintf( + "%s User group %s", + resource.DisplayName, + memberEntitlement, + ), + ), + ), + }, + "", + nil, + nil } -func (o *userGroupResourceType) Grants(ctx context.Context, resource *v2.Resource, pt *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { - groupMembers, err := o.enterpriseClient.GetUserGroupMembers(ctx, resource.Id.Resource, resource.ParentResourceId.Resource) +func (o *userGroupResourceType) Grants( + ctx context.Context, + resource *v2.Resource, + _ *pagination.Token, +) ( + []*v2.Grant, + string, + annotations.Annotations, + error, +) { + outputAnnotations := annotations.New() + // TODO(marcos): This should use 2D pagination. + groupMembers, ratelimitData, err := o.enterpriseClient.GetUserGroupMembers( + ctx, + resource.Id.Resource, + resource.ParentResourceId.Resource, + ) + outputAnnotations.WithRateLimiting(ratelimitData) if err != nil { - annos, err := annotationsForError(err) - return nil, "", annos, err + return nil, "", outputAnnotations, err } var rv []*v2.Grant for _, member := range groupMembers { user, err := o.client.GetUserInfoContext(ctx, member) if err != nil { - annos, err := annotationsForError(err) + annos, err := pkg.AnnotationsForError(err) return nil, "", annos, err } ur, err := userResource(ctx, user, resource.Id) diff --git a/pkg/connector/workspace.go b/pkg/connector/workspace.go index 629542d7..8101237c 100644 --- a/pkg/connector/workspace.go +++ b/pkg/connector/workspace.go @@ -7,14 +7,15 @@ import ( 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" + "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" - enterprise "github.com/conductorone/baton-slack/pkg/slack" + "github.com/conductorone/baton-slack/pkg" + enterprise "github.com/conductorone/baton-slack/pkg/connector/client" "github.com/slack-go/slack" ) -var workspacesMap = make(map[string]string) +var workspacesNameCache = make(map[string]string) const memberEntitlement = "member" @@ -29,7 +30,11 @@ func (o *workspaceResourceType) ResourceType(_ context.Context) *v2.ResourceType return o.resourceType } -func workspaceBuilder(client *slack.Client, enterpriseID string, enterpriseClient *enterprise.Client) *workspaceResourceType { +func workspaceBuilder( + client *slack.Client, + enterpriseID string, + enterpriseClient *enterprise.Client, +) *workspaceResourceType { return &workspaceResourceType{ resourceType: resourceTypeWorkspace, client: client, @@ -39,49 +44,64 @@ func workspaceBuilder(client *slack.Client, enterpriseID string, enterpriseClien } // Create a new connector resource for a Slack workspace. -func workspaceResource(ctx context.Context, workspace slack.Team) (*v2.Resource, error) { - profile := make(map[string]interface{}) - profile["workspace_id"] = workspace.ID - profile["workspace_name"] = workspace.Name - profile["workspace_domain"] = workspace.Domain - - groupTrait := []resources.GroupTraitOption{ - resources.WithGroupProfile(profile), - } - workspaceOptions := []resources.ResourceOption{ +func workspaceResource( + _ context.Context, + workspace slack.Team, + _ *v2.ResourceId, +) (*v2.Resource, error) { + return resources.NewGroupResource( + workspace.Name, + resourceTypeWorkspace, + workspace.ID, + []resources.GroupTraitOption{ + resources.WithGroupProfile( + map[string]interface{}{ + "workspace_id": workspace.ID, + "workspace_name": workspace.Name, + "workspace_domain": workspace.Domain, + }, + ), + }, resources.WithAnnotation( &v2.ChildResourceType{ResourceTypeId: resourceTypeUser.Id}, &v2.ChildResourceType{ResourceTypeId: resourceTypeUserGroup.Id}, &v2.ChildResourceType{ResourceTypeId: resourceTypeWorkspaceRole.Id}, ), - } - - ret, err := resources.NewGroupResource(workspace.Name, resourceTypeWorkspace, workspace.ID, groupTrait, workspaceOptions...) - if err != nil { - return nil, err - } - - return ret, nil + ) } -func (o *workspaceResourceType) List(ctx context.Context, resourceId *v2.ResourceId, pt *pagination.Token) ([]*v2.Resource, string, annotations.Annotations, error) { - bag, err := parsePageToken(pt.Token, &v2.ResourceId{ResourceType: resourceTypeWorkspace.Id}) +func (o *workspaceResourceType) List( + ctx context.Context, + _ *v2.ResourceId, + pt *pagination.Token, +) ( + []*v2.Resource, + string, + annotations.Annotations, + error, +) { + bag, err := pkg.ParsePageToken(pt.Token, &v2.ResourceId{ResourceType: resourceTypeWorkspace.Id}) if err != nil { return nil, "", nil, err } - var workspaces []slack.Team - var nextCursor string + var ( + workspaces []slack.Team + nextCursor string + ratelimitData *v2.RateLimitDescription + ) if o.enterpriseID != "" { - workspaces, nextCursor, err = o.enterpriseClient.GetTeams(ctx, bag.PageToken()) + outputAnnotations := annotations.New() + workspaces, nextCursor, ratelimitData, err = o.enterpriseClient.GetTeams(ctx, bag.PageToken()) + outputAnnotations.WithRateLimiting(ratelimitData) if err != nil { - annos, err := annotationsForError(err) - return nil, "", annos, err + return nil, "", outputAnnotations, err } } else { - workspaces, nextCursor, err = o.client.ListTeamsContext(ctx, slack.ListTeamsParameters{Cursor: bag.PageToken()}) + params := slack.ListTeamsParameters{Cursor: bag.PageToken()} + workspaces, nextCursor, err = o.client.ListTeamsContext(ctx, params) if err != nil { - annos, err := annotationsForError(err) + annos, err := pkg.AnnotationsForError(err) return nil, "", annos, err } } @@ -91,44 +111,82 @@ func (o *workspaceResourceType) List(ctx context.Context, resourceId *v2.Resourc return nil, "", nil, err } - rv := make([]*v2.Resource, 0, len(workspaces)) + // Seed the cache. for _, workspace := range workspaces { - workspacesMap[workspace.ID] = workspace.Name - wr, err := workspaceResource(ctx, workspace) - if err != nil { - return nil, "", nil, err - } - rv = append(rv, wr) + workspacesNameCache[workspace.ID] = workspace.Name } - return rv, pageToken, nil, nil -} - -func (o *workspaceResourceType) Entitlements(ctx context.Context, resource *v2.Resource, _ *pagination.Token) ([]*v2.Entitlement, string, annotations.Annotations, error) { - var rv []*v2.Entitlement - - assigmentOptions := []ent.EntitlementOption{ - ent.WithGrantableTo(resourceTypeUser), - ent.WithDescription(fmt.Sprintf("Member of the %s workspace", resource.DisplayName)), - ent.WithDisplayName(fmt.Sprintf("%s workspace member", resource.DisplayName)), + output, err := pkg.MakeResourceList( + ctx, + workspaces, + nil, + workspaceResource, + ) + if err != nil { + return nil, "", nil, err } - en := ent.NewAssignmentEntitlement(resource, memberEntitlement, assigmentOptions...) - rv = append(rv, en) + return output, pageToken, nil, nil +} - return rv, "", nil, nil +func (o *workspaceResourceType) Entitlements( + _ context.Context, + resource *v2.Resource, + _ *pagination.Token, +) ( + []*v2.Entitlement, + string, + annotations.Annotations, + error, +) { + return []*v2.Entitlement{ + entitlement.NewAssignmentEntitlement( + resource, + memberEntitlement, + entitlement.WithGrantableTo(resourceTypeUser), + entitlement.WithDescription( + fmt.Sprintf( + "Member of the %s workspace", + resource.DisplayName, + ), + ), + entitlement.WithDisplayName( + fmt.Sprintf( + "%s workspace member", + resource.DisplayName, + ), + ), + ), + }, + "", + nil, + nil } -func (o *workspaceResourceType) Grants(ctx context.Context, resource *v2.Resource, pt *pagination.Token) ([]*v2.Grant, string, annotations.Annotations, error) { - bag, err := parsePageToken(pt.Token, &v2.ResourceId{ResourceType: resourceTypeUser.Id}) +func (o *workspaceResourceType) Grants( + ctx context.Context, + resource *v2.Resource, + pt *pagination.Token, +) ( + []*v2.Grant, + string, + annotations.Annotations, + error, +) { + bag, err := pkg.ParsePageToken(pt.Token, &v2.ResourceId{ResourceType: resourceTypeUser.Id}) if err != nil { return nil, "", nil, err } - users, nextCursor, err := o.enterpriseClient.GetUsers(ctx, resource.Id.Resource, bag.PageToken()) + outputAnnotations := annotations.New() + users, nextCursor, ratelimitData, err := o.enterpriseClient.GetUsers( + ctx, + resource.Id.Resource, + bag.PageToken(), + ) + outputAnnotations.WithRateLimiting(ratelimitData) if err != nil { - annos, err := annotationsForError(err) - return nil, "", annos, err + return nil, "", outputAnnotations, err } pageToken, err := bag.NextToken(nextCursor) @@ -147,7 +205,7 @@ func (o *workspaceResourceType) Grants(ctx context.Context, resource *v2.Resourc } if user.IsPrimaryOwner { - rr, err := roleResource(PrimaryOwnerRoleID, resource.Id) + rr, err := roleResource(ctx, PrimaryOwnerRoleID, resource.Id) if err != nil { return nil, "", nil, err } @@ -155,7 +213,7 @@ func (o *workspaceResourceType) Grants(ctx context.Context, resource *v2.Resourc } if user.IsOwner { - rr, err := roleResource(OwnerRoleID, resource.Id) + rr, err := roleResource(ctx, OwnerRoleID, resource.Id) if err != nil { return nil, "", nil, err } @@ -163,7 +221,7 @@ func (o *workspaceResourceType) Grants(ctx context.Context, resource *v2.Resourc } if user.IsAdmin { - rr, err := roleResource(AdminRoleID, resource.Id) + rr, err := roleResource(ctx, AdminRoleID, resource.Id) if err != nil { return nil, "", nil, err } @@ -172,13 +230,13 @@ func (o *workspaceResourceType) Grants(ctx context.Context, resource *v2.Resourc if user.IsRestricted { if user.IsUltraRestricted { - rr, err := roleResource(SingleChannelGuestRoleID, resource.Id) + rr, err := roleResource(ctx, SingleChannelGuestRoleID, resource.Id) if err != nil { return nil, "", nil, err } rv = append(rv, grant.NewGrant(rr, RoleAssignmentEntitlement, userID)) } else { - rr, err := roleResource(MultiChannelGuestRoleID, resource.Id) + rr, err := roleResource(ctx, MultiChannelGuestRoleID, resource.Id) if err != nil { return nil, "", nil, err } @@ -187,7 +245,7 @@ func (o *workspaceResourceType) Grants(ctx context.Context, resource *v2.Resourc } if user.IsInvitedUser { - rr, err := roleResource(InvitedMemberRoleID, resource.Id) + rr, err := roleResource(ctx, InvitedMemberRoleID, resource.Id) if err != nil { return nil, "", nil, err } @@ -195,7 +253,7 @@ func (o *workspaceResourceType) Grants(ctx context.Context, resource *v2.Resourc } if !user.IsRestricted && !user.IsUltraRestricted && !user.IsInvitedUser && !user.IsBot && !user.Deleted { - rr, err := roleResource(MemberRoleID, resource.Id) + rr, err := roleResource(ctx, MemberRoleID, resource.Id) if err != nil { return nil, "", nil, err } @@ -203,7 +261,7 @@ func (o *workspaceResourceType) Grants(ctx context.Context, resource *v2.Resourc } if user.IsBot { - rr, err := roleResource(BotRoleID, resource.Id) + rr, err := roleResource(ctx, BotRoleID, resource.Id) if err != nil { return nil, "", nil, err } @@ -212,21 +270,21 @@ func (o *workspaceResourceType) Grants(ctx context.Context, resource *v2.Resourc if o.enterpriseID != "" { if user.Enterprise.IsPrimaryOwner { - rr, err := enterpriseRoleResource(OrganizationPrimaryOwnerID, resource.Id) + rr, err := enterpriseRoleResource(ctx, OrganizationPrimaryOwnerID, resource.Id) if err != nil { return nil, "", nil, err } rv = append(rv, grant.NewGrant(rr, RoleAssignmentEntitlement, userID)) } if user.Enterprise.IsOwner { - rr, err := enterpriseRoleResource(OrganizationOwnerID, resource.Id) + rr, err := enterpriseRoleResource(ctx, OrganizationOwnerID, resource.Id) if err != nil { return nil, "", nil, err } rv = append(rv, grant.NewGrant(rr, RoleAssignmentEntitlement, userID)) } if user.Enterprise.IsAdmin { - rr, err := enterpriseRoleResource(OrganizationAdminID, resource.Id) + rr, err := enterpriseRoleResource(ctx, OrganizationAdminID, resource.Id) if err != nil { return nil, "", nil, err } @@ -237,5 +295,5 @@ func (o *workspaceResourceType) Grants(ctx context.Context, resource *v2.Resourc rv = append(rv, grant.NewGrant(resource, memberEntitlement, userID)) } - return rv, pageToken, nil, nil + return rv, pageToken, outputAnnotations, nil } diff --git a/pkg/helpers.go b/pkg/helpers.go new file mode 100644 index 00000000..a39fd339 --- /dev/null +++ b/pkg/helpers.go @@ -0,0 +1,131 @@ +package pkg + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" + "github.com/conductorone/baton-sdk/pkg/annotations" + "github.com/conductorone/baton-sdk/pkg/pagination" + "github.com/slack-go/slack" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type EnterpriseRolesPagination struct { + Cursor string `json:"cursor"` + FoundMap map[string]bool `json:"foundMap"` +} + +func ParseID(id string) (string, error) { + parts := strings.Split(id, ":") + if len(parts) < 2 { + return "", fmt.Errorf("baton-slack: invalid ID: %s", id) + } + return parts[1], nil +} + +// MakeResourceList - turning arbitrary data into Resource slices is and +// incredibly common thing. TODO(marcos): move to baton-sdk +func MakeResourceList[T any]( + ctx context.Context, + objects []T, + parentResourceID *v2.ResourceId, + toResource func( + ctx context.Context, + object T, + parentResourceID *v2.ResourceId, + ) ( + *v2.Resource, + error, + ), +) ([]*v2.Resource, error) { + outputSlice := make([]*v2.Resource, 0, len(objects)) + for _, object := range objects { + nextResource, err := toResource(ctx, object, parentResourceID) + if err != nil { + return nil, err + } + outputSlice = append(outputSlice, nextResource) + } + return outputSlice, nil +} + +func (e *EnterpriseRolesPagination) Marshal() (string, error) { + if e.Cursor == "" { + return "", nil + } + bytes, err := json.Marshal(e) + if err != nil { + return "", err + } + + return string(bytes), nil +} + +func (e *EnterpriseRolesPagination) Unmarshal(input string) error { + if input == "" { + e.FoundMap = make(map[string]bool) + return nil + } + + err := json.Unmarshal([]byte(input), e) + if err != nil { + return err + } + + return nil +} + +func ParseRolesPageToken(i string) (*EnterpriseRolesPagination, error) { + b := &EnterpriseRolesPagination{} + err := b.Unmarshal(i) + if err != nil { + return nil, err + } + + if b.FoundMap == nil { + b.FoundMap = make(map[string]bool) + } + + return b, nil +} + +func ParsePageToken(i string, resourceID *v2.ResourceId) (*pagination.Bag, error) { + b := &pagination.Bag{} + err := b.Unmarshal(i) + if err != nil { + return nil, err + } + + if b.Current() == nil { + b.Push(pagination.PageState{ + ResourceTypeID: resourceID.ResourceType, + ResourceID: resourceID.Resource, + }) + } + + return b, nil +} + +// AnnotationsForError - Intercept ratelimit errors from Slack and create and +// annotation instead. +// TODO(marcos): maybe this should actually still forward along the error. +func AnnotationsForError(err error) (annotations.Annotations, error) { + annos := annotations.Annotations{} + var rateLimitErr *slack.RateLimitedError + if errors.As(err, &rateLimitErr) { + annos.WithRateLimiting( + &v2.RateLimitDescription{ + Limit: 0, + Remaining: 0, + ResetAt: timestamppb.New(time.Now().Add(rateLimitErr.RetryAfter)), + }, + ) + return annos, nil + } + return annos, err +} diff --git a/pkg/slack/client.go b/pkg/slack/client.go deleted file mode 100644 index d4685a33..00000000 --- a/pkg/slack/client.go +++ /dev/null @@ -1,561 +0,0 @@ -package enterprise - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "strconv" - "strings" - "time" - - "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" - "github.com/slack-go/slack" - "go.uber.org/zap" -) - -const baseUrl = "https://slack.com/api/" -const baseScimUrl = "https://api.slack.com/scim/v2/" - -type Client struct { - httpClient *http.Client - token string - enterpriseID string - botToken string - ssoEnabled bool -} - -type BaseResponse struct { - Ok bool `json:"ok"` - Error string `json:"error"` - Needed string `json:"needed"` - Provided string `json:"provided"` -} - -type Pagination struct { - ResponseMetadata struct { - NextCursor string `json:"next_cursor"` - } `json:"response_metadata"` -} - -type SCIMResponse[T any] struct { - Schemas []string `json:"schemas"` - Resources []T `json:"Resources"` - TotalResults int `json:"totalResults"` - ItemsPerPage int `json:"itemsPerPage"` - StartIndex int `json:"startIndex"` -} - -func NewClient(httpClient *http.Client, token, botToken, enterpriseID string, ssoEnabled bool) *Client { - return &Client{ - httpClient: httpClient, - token: token, - enterpriseID: enterpriseID, - botToken: botToken, - ssoEnabled: ssoEnabled, - } -} - -// GetUserInfo returns the user info for the given user ID. -func (c *Client) GetUserInfo(ctx context.Context, userID string) (*User, error) { - values := url.Values{ - "token": {c.botToken}, - "user": {userID}, - } - - usersUrl, err := url.JoinPath(baseUrl, "users.info") - if err != nil { - return nil, err - } - - var res struct { - BaseResponse - User *User `json:"user"` - } - - err = c.doRequest(ctx, usersUrl, &res, http.MethodPost, nil, values) - if err != nil { - return nil, fmt.Errorf("error fetching user info: %w", err) - } - - if res.Error != "" { - return nil, fmt.Errorf("error fetching user info: %v", res.Error) - } - - return res.User, nil -} - -// GetUserGroupMembers returns the members of the given user group from a given team. -func (c *Client) GetUserGroupMembers(ctx context.Context, userGroupID, teamID string) ([]string, error) { - values := url.Values{ - "token": {c.botToken}, - "team_id": {teamID}, - "usergroup": {userGroupID}, - } - - usersUrl, err := url.JoinPath(baseUrl, "usergroups.users.list") - if err != nil { - return nil, err - } - - var res struct { - BaseResponse - Users []string `json:"users"` - } - - err = c.doRequest(ctx, usersUrl, &res, http.MethodPost, nil, values) - if err != nil { - return nil, fmt.Errorf("error fetching user group members: %w", err) - } - - if res.Error != "" { - return nil, fmt.Errorf("error fetching user group members: %v", res.Error) - } - - return res.Users, nil -} - -// GetUsers returns all users in Enterprise grid. -func (c *Client) GetUsersAdmin(ctx context.Context, cursor string) ([]UserAdmin, string, error) { - values := url.Values{ - "token": {c.token}, - } - - // need to check if cursor is empty because API throws error if empty string is passed - if cursor != "" { - values.Add("cursor", cursor) - } - - usersUrl, err := url.JoinPath(baseUrl, "admin.users.list") - if err != nil { - return nil, "", err - } - - var res struct { - BaseResponse - Users []UserAdmin `json:"users"` - Pagination - } - - err = c.doRequest(ctx, usersUrl, &res, http.MethodPost, nil, values) - if err != nil { - return nil, "", fmt.Errorf("error fetching users: %w", err) - } - - if res.Error != "" { - return nil, "", fmt.Errorf("%s", res.Error) - } - - if res.ResponseMetadata.NextCursor != "" { - return res.Users, res.ResponseMetadata.NextCursor, nil - } - - return res.Users, "", nil -} - -// GetUsers returns the users of the given team. -func (c *Client) GetUsers(ctx context.Context, teamID, cursor string) ([]User, string, error) { - values := url.Values{ - "token": {c.botToken}, - "team_id": {teamID}, - } - - // need to check if cursor is empty because API throws error if empty string is passed - if cursor != "" { - values.Add("cursor", cursor) - } - - usersUrl, err := url.JoinPath(baseUrl, "users.list") - if err != nil { - return nil, "", err - } - - var res struct { - BaseResponse - Users []User `json:"members"` - Pagination - } - - err = c.doRequest(ctx, usersUrl, &res, http.MethodPost, nil, values) - if err != nil { - return nil, "", fmt.Errorf("error fetching users: %w", err) - } - - if res.Error != "" { - return nil, "", fmt.Errorf("error fetching users: %v", res.Error) - } - - if res.ResponseMetadata.NextCursor != "" { - return res.Users, res.ResponseMetadata.NextCursor, nil - } - - return res.Users, "", nil -} - -// GetTeams returns the teams of the given enterprise. -func (c *Client) GetTeams(ctx context.Context, cursor string) ([]slack.Team, string, error) { - values := url.Values{ - "token": {c.token}, - } - - if cursor != "" { - values.Add("cursor", cursor) - } - - teamsUrl, err := url.JoinPath(baseUrl, "admin.teams.list") - if err != nil { - return nil, "", err - } - - var res struct { - BaseResponse - Teams []slack.Team `json:"teams"` - Pagination - } - - err = c.doRequest(ctx, teamsUrl, &res, http.MethodPost, nil, values) - if err != nil { - return nil, "", fmt.Errorf("error fetching teams: %w", err) - } - - if res.Error != "" { - return nil, "", fmt.Errorf("error fetching teams: %v", res.Error) - } - - if res.ResponseMetadata.NextCursor != "" { - return res.Teams, res.ResponseMetadata.NextCursor, nil - } - - return res.Teams, "", nil -} - -// GetRoleAssignments returns the role assignments for the given role ID. -func (c *Client) GetRoleAssignments(ctx context.Context, roleID string, cursor string) ([]RoleAssignment, string, error) { - values := url.Values{ - "token": {c.token}, - } - - if roleID != "" { - values.Add("role_ids", roleID) - } - - if cursor != "" { - values.Add("cursor", cursor) - } - - teamsUrl, err := url.JoinPath(baseUrl, "admin.roles.listAssignments") - if err != nil { - return nil, "", err - } - - var res struct { - BaseResponse - RoleAssignments []RoleAssignment `json:"role_assignments"` - Pagination - } - - err = c.doRequest(ctx, teamsUrl, &res, http.MethodPost, nil, values) - if err != nil { - return nil, "", fmt.Errorf("error fetching role assignments: %w", err) - } - - if res.Error != "" { - return nil, "", fmt.Errorf("error fetching role assignments: %v", res.Error) - } - - if res.ResponseMetadata.NextCursor != "" { - return res.RoleAssignments, res.ResponseMetadata.NextCursor, nil - } - - return res.RoleAssignments, "", nil -} - -// GetUserGroups returns the user groups for the given team. -func (c *Client) GetUserGroups(ctx context.Context, teamID string) ([]slack.UserGroup, error) { - // bot token needed here cause user token doesn't work unless user is in all workspaces - values := url.Values{ - "token": {c.botToken}, - "team_id": {teamID}, - } - - userGroupsUrl, err := url.JoinPath(baseUrl, "usergroups.list") - if err != nil { - return nil, err - } - - var res struct { - BaseResponse - UserGroups []slack.UserGroup `json:"usergroups"` - } - - err = c.doRequest(ctx, userGroupsUrl, &res, http.MethodPost, nil, values) - if err != nil { - return nil, fmt.Errorf("error fetching user groups: %w", err) - } - - if res.Error != "" { - return nil, fmt.Errorf("error fetching user groups: %v", res.Error) - } - - return res.UserGroups, nil -} - -// SetWorkspaceRole sets the role for the given user in the given team. -func (c *Client) SetWorkspaceRole(ctx context.Context, teamID, userID, roleID string) error { - values := url.Values{ - "token": {c.token}, - "team_id": {teamID}, - "user_id": {userID}, - } - - var role string - - if roleID != "" { - roleSplit := strings.Split(roleID, ":") - if len(roleSplit) >= 2 { - role = roleSplit[1] - } - } - - var action string - switch role { - case "owner": - action = "setOwner" - case "admin": - action = "setAdmin" - case "": - action = "setRegular" - default: - return fmt.Errorf("invalid role type: %s", role) - } - - userGroupsUrl, err := url.JoinPath(baseUrl, "admin.users."+action) - if err != nil { - return err - } - - var res BaseResponse - - err = c.doRequest(ctx, userGroupsUrl, &res, http.MethodPost, nil, values) - if err != nil { - return fmt.Errorf("error setting user role: %w", err) - } - - if res.Error != "" { - return fmt.Errorf("error setting user role: error %v needed %v provided %v", res.Error, res.Needed, res.Provided) - } - - return nil -} - -// ListIDPGroups returns all IDP groups from the SCIM API. -func (c *Client) ListIDPGroups(ctx context.Context) ([]GroupResource, error) { - groupsUrl, err := url.JoinPath(baseScimUrl, "Groups") - if err != nil { - return nil, err - } - - var allGroups []GroupResource - startIndex := 1 - count := 100 - - urlParams := url.Values{} - var res SCIMResponse[GroupResource] - - for { - urlParams.Add("startIndex", fmt.Sprint(startIndex)) - urlParams.Add("count", fmt.Sprint(count)) - err = c.doRequest(ctx, groupsUrl, &res, http.MethodGet, nil, urlParams) - if err != nil { - return nil, fmt.Errorf("error fetching IDP groups: %w", err) - } - - allGroups = append(allGroups, res.Resources...) - - startIndex += res.ItemsPerPage - - if res.TotalResults < startIndex { - break - } - } - - return allGroups, nil -} - -// GetIDPGroup returns a single IDP group from the SCIM API. -func (c *Client) GetIDPGroup(ctx context.Context, groupID string) (*GroupResource, error) { - groupUrl, err := url.JoinPath(baseScimUrl, "Groups", groupID) - if err != nil { - return nil, err - } - - var res GroupResource - - err = c.doRequest(ctx, groupUrl, &res, http.MethodGet, nil, nil) - if err != nil { - return nil, fmt.Errorf("error fetching IDP group: %w", err) - } - - return &res, nil -} - -type PatchOp struct { - Schemas []string `json:"schemas"` - Operations []ScimOperate `json:"Operations"` -} - -type ScimOperate struct { - Op string `json:"op"` - Path string `json:"path"` - Value []UserID `json:"value"` -} - -type UserID struct { - Value string `json:"value"` -} - -// AddUserToGroup patches a group by adding a user to it. -func (c *Client) AddUserToGroup(ctx context.Context, groupID string, user string) error { - requestBody := PatchOp{ - Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, - Operations: []ScimOperate{ - { - Op: "add", - Path: "members", - Value: []UserID{ - {Value: user}, - }, - }, - }, - } - - err := c.patchGroup(ctx, groupID, requestBody) - if err != nil { - return fmt.Errorf("error adding user to IDP group: %w", err) - } - - return nil -} - -// RemoveUserFromGroup patches a group by removing a user from it. -func (c *Client) RemoveUserFromGroup(ctx context.Context, groupID string, user string) error { - // need to fetch group to get existing members - group, err := c.GetIDPGroup(ctx, groupID) - if err != nil { - return fmt.Errorf("error fetching IDP group: %w", err) - } - - var result []UserID - for _, member := range group.Members { - if member.Value != user { - result = append(result, UserID{Value: member.Value}) - } - } - - requestBody := PatchOp{ - Schemas: []string{"urn:ietf:params:scim:api:messages:2.0:PatchOp"}, - Operations: []ScimOperate{ - { - Op: "replace", - Path: "members", - Value: result, - }, - }, - } - - err = c.patchGroup(ctx, groupID, requestBody) - if err != nil { - return fmt.Errorf("error removing user from IDP group: %w", err) - } - - return nil -} - -func (c *Client) patchGroup(ctx context.Context, groupID string, requestBody PatchOp) error { - groupSCIMUrl, err := url.JoinPath(baseScimUrl, "Groups", groupID) - if err != nil { - return err - } - - payload, err := json.Marshal(requestBody) - if err != nil { - return err - } - - var res *GroupResource - err = c.doRequest(ctx, groupSCIMUrl, &res, http.MethodPatch, payload, nil) - if err != nil { - return fmt.Errorf("error patching IDP group: %w", err) - } - - return nil -} - -type RateLimitError struct { - RetryAfter time.Duration -} - -func (r *RateLimitError) Error() string { - return fmt.Sprintf("rate limited, retry after: %s", r.RetryAfter.String()) -} - -func (c *Client) doRequest(ctx context.Context, url string, res interface{}, method string, payload []byte, values url.Values) error { - l := ctxzap.Extract(ctx) - var reqBody io.Reader - - if strings.HasPrefix(url, baseScimUrl) { - reqBody = bytes.NewReader(payload) - } else { - reqBody = strings.NewReader(values.Encode()) - } - - l.Debug("making request", zap.String("method", method), zap.String("url", url)) - req, err := http.NewRequestWithContext(ctx, method, url, reqBody) - if err != nil { - return err - } - - req.Header.Add("Accept", "application/json") - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - // different headers for SCIM API - if strings.HasPrefix(url, baseScimUrl) { - req.Header.Add("Authorization", "Bearer "+c.token) - req.Header.Set("Content-Type", "application/json") - - if values != nil { - req.URL.RawQuery = values.Encode() - } - } - - resp, err := c.httpClient.Do(req) - if err != nil { - return err - } - - defer resp.Body.Close() - - // even though it's stated in the docs that PATCH request should return a resource - // for some reason when adding user in IDP group response is 204, but when removing a user it returns Group resource - if resp.StatusCode == http.StatusNoContent && method == http.MethodPatch { - return nil - } else { - if err := json.NewDecoder(resp.Body).Decode(&res); err != nil { - return err - } - } - - if resp.StatusCode == http.StatusTooManyRequests { - retryAfter := resp.Header.Get("Retry-After") - retryAfterSec, err := strconv.Atoi(retryAfter) - if err != nil { - return fmt.Errorf("error parsing retry after header: %w", err) - } - return &RateLimitError{RetryAfter: time.Second * time.Duration(retryAfterSec)} - } - - return nil -}