diff --git a/baton_capabilities.json b/baton_capabilities.json index f9587692..96827b38 100644 --- a/baton_capabilities.json +++ b/baton_capabilities.json @@ -40,6 +40,17 @@ ], "permissions": {} }, + { + "resourceType": { + "id": "billing_account", + "displayName": "Billing Account" + }, + "capabilities": [ + "CAPABILITY_SYNC", + "CAPABILITY_PROVISION" + ], + "permissions": {} + }, { "resourceType": { "id": "user", diff --git a/pkg/connector/billing_accounts.go b/pkg/connector/billing_accounts.go new file mode 100644 index 00000000..d62abee2 --- /dev/null +++ b/pkg/connector/billing_accounts.go @@ -0,0 +1,307 @@ +package connector + +import ( + "context" + "errors" + "fmt" + "slices" + "strconv" + + "github.com/conductorone/baton-coupa/pkg/connector/client" + 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/conductorone/baton-sdk/pkg/types/entitlement" + "github.com/conductorone/baton-sdk/pkg/types/grant" + resourceSdk "github.com/conductorone/baton-sdk/pkg/types/resource" + "github.com/grpc-ecosystem/go-grpc-middleware/logging/zap/ctxzap" + "go.uber.org/zap" +) + +const billingAccountMemberEntitlementName = "member" + +type billingAccountBuilder struct { + client *client.Client +} + +func (o *billingAccountBuilder) ResourceType(ctx context.Context) *v2.ResourceType { + return billingAccountResourceType +} + +func billingAccountResource(account *client.Account, parentResourceID *v2.ResourceId) (*v2.Resource, error) { + displayName := account.Name + if account.Code != "" { + displayName = fmt.Sprintf("%s (%s)", account.Name, account.Code) + } + + return resourceSdk.NewResource( + displayName, + billingAccountResourceType, + account.ID, + resourceSdk.WithParentResourceID(parentResourceID), + ) +} + +func (o *billingAccountBuilder) List( + ctx context.Context, + parentResourceID *v2.ResourceId, + pToken *pagination.Token, +) ( + []*v2.Resource, + string, + annotations.Annotations, + error, +) { + logger := ctxzap.Extract(ctx) + logger.Debug("Starting Billing Accounts List", zap.String("token", pToken.Token)) + + outputResources := make([]*v2.Resource, 0) + var outputAnnotations annotations.Annotations + + var target client.AccountsQueryResponse + response, ratelimitData, err := o.client.Query( + ctx, + client.AccountsQuery(pToken.Token), + &target, + ) + outputAnnotations.WithRateLimiting(ratelimitData) + if err != nil { + return nil, "", outputAnnotations, err + } + defer response.Body.Close() + + lastId := "" + for _, account := range target.Accounts { + resource, err := billingAccountResource(account, parentResourceID) + if err != nil { + return nil, "", nil, err + } + outputResources = append(outputResources, resource) + lastId = strconv.Itoa(account.ID) + } + + return outputResources, lastId, outputAnnotations, nil +} + +func (o *billingAccountBuilder) Entitlements( + _ context.Context, + resource *v2.Resource, + _ *pagination.Token, +) ( + []*v2.Entitlement, + string, + annotations.Annotations, + error, +) { + return []*v2.Entitlement{ + entitlement.NewAssignmentEntitlement( + resource, + billingAccountMemberEntitlementName, + entitlement.WithGrantableTo(userResourceType), + entitlement.WithDisplayName( + fmt.Sprintf("%s Billing Account", resource.DisplayName), + ), + entitlement.WithDescription( + fmt.Sprintf("%s billing account in Coupa", resource.DisplayName), + ), + ), + }, "", nil, nil +} + +func (o *billingAccountBuilder) Grants( + ctx context.Context, + resource *v2.Resource, + pToken *pagination.Token, +) ( + []*v2.Grant, + string, + annotations.Annotations, + error, +) { + logger := ctxzap.Extract(ctx) + + accountId := resource.Id.Resource + + logger.Debug( + "Starting Billing Account Grants", + zap.String("account_id", accountId), + zap.String("token", pToken.Token), + ) + + outputGrants := make([]*v2.Grant, 0) + var outputAnnotations annotations.Annotations + + var target client.AccountGrantsQueryResponse + response, ratelimitData, err := o.client.Query( + ctx, + client.AccountGrantQuery(accountId, pToken.Token), + &target, + ) + outputAnnotations.WithRateLimiting(ratelimitData) + if err != nil { + return nil, "", outputAnnotations, err + } + defer response.Body.Close() + + lastId := "" + for _, user := range target.Users { + outputGrants = append( + outputGrants, + grant.NewGrant( + resource, + billingAccountMemberEntitlementName, + &v2.ResourceId{ + ResourceType: userResourceType.Id, + Resource: strconv.Itoa(user.Id), + }, + ), + ) + lastId = strconv.Itoa(user.Id) + } + + return outputGrants, lastId, outputAnnotations, nil +} + +func (o *billingAccountBuilder) Grant(ctx context.Context, resource *v2.Resource, entitlement *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) { + accountIdToAdd, err := strconv.Atoi(entitlement.Resource.Id.Resource) + if err != nil { + return nil, nil, err + } + + userId, err := strconv.Atoi(resource.Id.Resource) + if err != nil { + return nil, nil, err + } + + user, err := o.getUserAccounts(ctx, userId) + if err != nil { + return nil, nil, err + } + + for _, account := range user.Accounts { + if account.ID == accountIdToAdd { + return []*v2.Grant{}, annotations.New(&v2.GrantAlreadyExists{}), nil + } + } + + accountIDs := make([]int, 0) + for _, account := range user.Accounts { + accountIDs = append(accountIDs, account.ID) + } + accountIDs = append(accountIDs, accountIdToAdd) + + userResponse, _, err := o.client.SetUserAccounts(ctx, userId, accountIDs) + if err != nil { + return nil, nil, err + } + + if len(userResponse.Accounts) != len(accountIDs) { + return nil, nil, errors.New("baton-coupa: billing accounts not set") + } + + newGrant := grant.NewGrant( + resource, + billingAccountMemberEntitlementName, + &v2.ResourceId{ + ResourceType: userResourceType.Id, + Resource: strconv.Itoa(user.Id), + }, + ) + + return []*v2.Grant{newGrant}, nil, nil +} + +func (o *billingAccountBuilder) Revoke(ctx context.Context, grant *v2.Grant) (annotations.Annotations, error) { + l := ctxzap.Extract(ctx) + + if grant.Principal.Id.ResourceType != userResourceType.Id { + return nil, fmt.Errorf("baton-coupa: principal resource type is not %s", userResourceType.Id) + } + + accountIdToRemove, err := strconv.Atoi(grant.Entitlement.Resource.Id.Resource) + if err != nil { + return nil, err + } + + userId, err := strconv.Atoi(grant.Principal.Id.Resource) + if err != nil { + return nil, err + } + + user, err := o.getUserAccounts(ctx, userId) + if err != nil { + return nil, err + } + + index := slices.IndexFunc(user.Accounts, func(c client.Account) bool { + return c.ID == accountIdToRemove + }) + if index < 0 { + l.Info("baton-coupa: billing account not found in user") + return annotations.New(&v2.GrantAlreadyRevoked{}), nil + } + + if index == 0 { + user.Accounts = user.Accounts[1:] + } else { + user.Accounts = append(user.Accounts[:index], user.Accounts[index+1:]...) + } + + newAccountIDs := make([]int, 0) + for _, account := range user.Accounts { + newAccountIDs = append(newAccountIDs, account.ID) + } + + // Clear all accounts first, then re-set desired accounts. + // This follows the same two-step pattern used for roles and groups, + // as Coupa may require clearing before re-assignment. + _, _, err = o.client.SetUserAccounts(ctx, userId, make([]int, 0)) + if err != nil { + return nil, err + } + + userResponse, _, err := o.client.SetUserAccounts(ctx, userId, newAccountIDs) + if err != nil { + l.Error( + "baton-coupa: error setting billing accounts", + zap.Error(err), + zap.Ints("accounts", newAccountIDs), + ) + return nil, err + } + + if len(userResponse.Accounts) != len(newAccountIDs) { + return nil, errors.New("baton-coupa: billing account was not removed") + } + + return nil, nil +} + +func (o *billingAccountBuilder) getUserAccounts(ctx context.Context, userId int) (*client.UserAccounts, error) { + var target client.UserAccountsResponse + response, _, err := o.client.Query( + ctx, + client.GetUserAccounts(userId), + &target, + ) + if err != nil { + return nil, err + } + defer response.Body.Close() + + if len(target.Users) == 0 { + return nil, errors.New("baton-coupa: user not found") + } + + if len(target.Users) > 1 { + return nil, fmt.Errorf("baton-coupa: multiple users found for id %d", userId) + } + + return &target.Users[0], nil +} + +func newBillingAccountBuilder(ctx context.Context, client *client.Client) *billingAccountBuilder { + return &billingAccountBuilder{ + client: client, + } +} diff --git a/pkg/connector/client/auth.go b/pkg/connector/client/auth.go index 5d278167..233e191f 100644 --- a/pkg/connector/client/auth.go +++ b/pkg/connector/client/auth.go @@ -10,6 +10,7 @@ import ( var ( ScopesReadOnly = []string{ + "core.accounting.read", "core.business_entity.read", "core.common.read", "core.user_group.read", @@ -20,6 +21,7 @@ var ( } ScopesReadWrite = append( ScopesReadOnly, + "core.accounting.write", "core.user_group.write", "core.user.write", ) diff --git a/pkg/connector/client/billing_accounts.go b/pkg/connector/client/billing_accounts.go new file mode 100644 index 00000000..6f1d7ff9 --- /dev/null +++ b/pkg/connector/client/billing_accounts.go @@ -0,0 +1,56 @@ +package client + +import ( + "context" + "fmt" + "net/http" + + v2 "github.com/conductorone/baton-sdk/pb/c1/connector/v2" +) + +// SetUserAccounts sets the billing account assignments for a user. +// This follows the same pattern as SetRoles and SetUserGroups. +func (c *Client) SetUserAccounts( + ctx context.Context, + userId int, + accountIDs []int, +) ( + *UserAccountsPutResponse, + *v2.RateLimitDescription, + error, +) { + err := c.Initialize(ctx) + if err != nil { + return nil, nil, err + } + + request := struct { + Accounts []ResourceId `json:"account"` + }{} + + if len(accountIDs) == 0 { + request.Accounts = nil + } else { + for _, accountID := range accountIDs { + request.Accounts = append(request.Accounts, ResourceId{Id: accountID}) + } + } + + var userResponse UserAccountsPutResponse + + response, rateLimit, err := c.doRestRequest( + ctx, + http.MethodPut, + c.baseUrl.JoinPath(fmt.Sprintf(setAccountPath, userId)), + request, + &userResponse, + ) + + if err != nil { + return nil, rateLimit, err + } + + defer response.Body.Close() + + return &userResponse, rateLimit, nil +} diff --git a/pkg/connector/client/models.go b/pkg/connector/client/models.go index facd91c0..1e5fd37d 100644 --- a/pkg/connector/client/models.go +++ b/pkg/connector/client/models.go @@ -69,6 +69,38 @@ type License struct { Description string } +type Account struct { + ID int `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Active bool `json:"active"` + AccountType *string `json:"accountType,omitempty"` +} + +type AccountsQueryResponse struct { + Accounts []*Account `json:"accounts"` +} + +type AccountGrantsQueryResponse struct { + Users []struct { + Id int `json:"id"` + } `json:"users"` +} + +type UserAccounts struct { + Id int `json:"id"` + Accounts []Account `json:"account"` +} + +type UserAccountsResponse struct { + Users []UserAccounts `json:"users"` +} + +type UserAccountsPutResponse struct { + ResourceId + Accounts []Account `json:"account"` +} + type UserRoles struct { Id int `json:"id"` Roles []Role `json:"roles"` diff --git a/pkg/connector/client/path.go b/pkg/connector/client/path.go index 2bcb3250..2a4f8859 100644 --- a/pkg/connector/client/path.go +++ b/pkg/connector/client/path.go @@ -15,6 +15,9 @@ const ( `"riskAssessUser","sourcingUser","spendGuardUser","supplyChainUser",` + `"travelUser","treasuryUser"]` + // setAccountPath set user id in the path. + setAccountPath = `/api/users/%d?fields=["id",{"account":["id","name","code"]}]` + // updateUserPath set user id in the path. updateUserPath = `/api/users/%d` diff --git a/pkg/connector/client/query.go b/pkg/connector/client/query.go index 87e54be3..996c4479 100644 --- a/pkg/connector/client/query.go +++ b/pkg/connector/client/query.go @@ -56,10 +56,33 @@ const ( ` getUserGroups = `query getUsers { - users(query: "id=%d") { + users(query: "id=%d") { id userGroups { id name description } } } +` + + getAccountsQuery = `query getAccounts { + accounts(query: "%s") { + id + name + code + active + accountType + } +}` + + getAccountGrantListQuery = `query getAccountGrants { + users(query: "account[id]=%s%s") { + id + } +}` + + getUserAccountsQuery = `query getUsers { + users(query: "id=%d") { + id account { id name code } + } +} ` ) @@ -109,3 +132,15 @@ func GetUserRoles(userId int) string { func GetUserGroups(userId int) string { return fmt.Sprintf(getUserGroups, userId) } + +func AccountsQuery(pg string) string { + return fmt.Sprintf(getAccountsQuery, pagination(pg)) +} + +func AccountGrantQuery(accountID string, pg string) string { + return fmt.Sprintf(getAccountGrantListQuery, accountID, appendedPagination(pg)) +} + +func GetUserAccounts(userId int) string { + return fmt.Sprintf(getUserAccountsQuery, userId) +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 1007f9e9..12a0959e 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -24,6 +24,7 @@ func (d *Connector) ResourceSyncers(ctx context.Context) []connectorbuilder.Reso newGroupBuilder(ctx, d.client), newRoleBuilder(ctx, d.client), newLicenseBuilder(ctx, d.client), + newBillingAccountBuilder(ctx, d.client), } } @@ -37,7 +38,7 @@ func (d *Connector) Asset(ctx context.Context, asset *v2.AssetRef) (string, io.R func (d *Connector) Metadata(ctx context.Context) (*v2.ConnectorMetadata, error) { return &v2.ConnectorMetadata{ DisplayName: "Coupa Connector", - Description: "Connector syncing Coupa users, groups, roles, and licenses", + Description: "Connector syncing Coupa users, groups, roles, licenses, and billing accounts", }, nil } diff --git a/pkg/connector/resource_types.go b/pkg/connector/resource_types.go index 989118d9..1b34a2f0 100644 --- a/pkg/connector/resource_types.go +++ b/pkg/connector/resource_types.go @@ -27,3 +27,8 @@ var licenseResourceType = &v2.ResourceType{ Id: "license", DisplayName: "license", } + +var billingAccountResourceType = &v2.ResourceType{ + Id: "billing_account", + DisplayName: "Billing Account", +}