feat: add billing accounts sync as entitlements - #34
Conversation
Add support for syncing Coupa billing accounts as entitlements in ConductorOne, with grant/revoke provisioning support. Changes: - New `account` resource type for Coupa billing accounts - GraphQL queries for listing accounts and user-account grants - REST API client for setting/unsetting user default account - Account builder with List, Entitlements, Grants, Grant, and Revoke - Added core.accounting.read/write OAuth scopes - Updated connector metadata and capabilities
|
Claude finished @c1-dev-bot[bot]'s task in 3m 2s —— View job PR Review: feat: add billing accounts sync as entitlements
I've reviewed all 9 changed files against the existing connector patterns (roles, groups, licenses) and the CLAUDE.md guidelines. Overall the implementation is well-structured and follows the established patterns. Here are my findings: Issues1. Revoke doesn't verify the API response (Medium)
_, _, err = o.client.SetUserAccount(ctx, userId, nil)
if err != nil {
return nil, err
}
return nil, nil // No verification that default_account was actually clearedCompare with if userResponse.DefaultAccount == nil || userResponse.DefaultAccount.ID != accountIdToSet {
return nil, nil, errors.New("baton-coupa: account not set on user")
}Recommendation: Add a post-revoke verification that 2. Grant silently replaces existing default account (Medium - Design)Since
// Only checks if the SAME account is already set
if user.DefaultAccount != nil && user.DefaultAccount.ID == accountIdToSet {
return []*v2.Grant{}, annotations.New(&v2.GrantAlreadyExists{}), nil
}
// Sets new account, silently replacing any existing one
userResponse, _, err := o.client.SetUserAccount(ctx, userId, &accountIdToSet)Recommendation: Consider logging a warning when replacing an existing default account so operators have visibility into the replacement: if user.DefaultAccount != nil && user.DefaultAccount.ID != accountIdToSet {
logger.Warn("baton-coupa: replacing existing default account",
zap.Int("old_account_id", user.DefaultAccount.ID),
zap.Int("new_account_id", accountIdToSet),
)
}3. Revoke sets
|
| Priority | Issue | Action |
|---|---|---|
| Medium | Post-revoke verification missing | Add response check after clearing default account |
| Medium | Silent account replacement | Add warning log when replacing existing default account |
| Low | Nil default_account risk | Document in release notes |
| Low | Missing error prefixes | Pre-existing pattern, optional fix |
The PR description's caveats about field name verification (default_account, GraphQL field names) are appropriate - these should be validated against a live Coupa sandbox before merging.
Move core.accounting.read/write scopes to a separate ScopesAccounting variable instead of adding them to the default ScopesReadOnly/ReadWrite. This prevents breaking existing connector functionality when the Coupa OAuth client does not have accounting scopes configured. Customers must grant these scopes to their OAuth client separately to enable billing account sync.
If the Coupa instance does not expose accounts via GraphQL or the OAuth client lacks core.accounting.read scope, log a warning and return empty results instead of failing the entire sync. This ensures existing resource types (users, groups, roles, licenses) continue to sync normally.
| // ScopesAccounting are the additional OAuth scopes required for | ||
| // billing account sync. Customers must grant these scopes on their | ||
| // Coupa OAuth client to use billing account features. | ||
| // If the OAuth client does not have these scopes, billing account | ||
| // sync will not work, but the rest of the connector will function | ||
| // normally. | ||
| ScopesAccounting = []string{ | ||
| "core.accounting.read", | ||
| "core.accounting.write", | ||
| } | ||
| ) |
There was a problem hiding this comment.
Bug (High): ScopesAccounting is defined but never used — account sync will fail at runtime
The fix in 7f182b9 correctly moved accounting scopes out of the default token requests to avoid breaking existing customers. However, ScopesAccounting is now dead code — it's never referenced in client.go or anywhere else. Neither the read-only token (used for GraphQL queries: AccountsQuery, AccountGrantQuery, GetUserAccounts) nor the read-write token (used for REST: SetUserAccount) will include the core.accounting.read or core.accounting.write scopes.
This means all account-related operations will likely fail with authorization errors at runtime.
A mechanism is needed to actually use these scopes. Some options:
- Append to existing token requests — simplest but may break customers whose OAuth clients don't have these scopes authorized
- CLI flag/config option (e.g.,
--enable-billing-accounts) — opt-in, customer controls when to enable - Separate token source for account operations — most flexible, allows graceful degradation
| return nil, err | ||
| } | ||
|
|
||
| user, err := o.getUserAccounts(ctx, userId) | ||
| if err != nil { | ||
| return nil, err |
There was a problem hiding this comment.
Medium: Revoke doesn't verify the API response (flagged in prior review, still unaddressed)
Grant() verifies the response at line 197-199, but Revoke() fires and forgets. Should capture and verify the response:
| return nil, err | |
| } | |
| user, err := o.getUserAccounts(ctx, userId) | |
| if err != nil { | |
| return nil, err | |
| userResponse, _, err := o.client.SetUserAccount(ctx, userId, nil) | |
| if err != nil { | |
| return nil, err | |
| } | |
| if userResponse.DefaultAccount != nil { | |
| return nil, errors.New("baton-coupa: account not cleared on user") | |
| } | |
| return nil, nil |
| return nil, nil, err | ||
| } | ||
|
|
||
| userId, err := strconv.Atoi(resource.Id.Resource) | ||
| if err != nil { | ||
| return nil, nil, err | ||
| } | ||
|
|
There was a problem hiding this comment.
Medium (Design): Grant silently replaces existing default account (flagged in prior review, still unaddressed)
Since default_account is single-valued, granting account B to a user who already has account A silently replaces A. The old grant for A will remain stale in ConductorOne until the next sync cycle.
Consider adding a warning log when replacing an existing account so operators have visibility:
if user.DefaultAccount != nil && user.DefaultAccount.ID != accountIdToSet {
l := ctxzap.Extract(ctx)
l.Warn("baton-coupa: replacing existing default account on user",
zap.Int("user_id", userId),
zap.Int("old_account_id", user.DefaultAccount.ID),
zap.Int("new_account_id", accountIdToSet),
)
}|
Claude finished @c1-dev-bot[bot]'s task in 2m 25s —— View job PR Review: feat: add billing accounts sync as entitlements
I've reviewed all 9 changed files across 3 commits ( Commit-by-commit assessment
Issues1.
|
| Priority | Issue | Status | Action Required |
|---|---|---|---|
| High | ScopesAccounting never wired — feature is dead code |
Unaddressed | Wire scopes into token source (opt-in flag recommended) |
| Medium | Revoke doesn't verify API response | Unaddressed | Capture and check response |
| Medium | Grant silently replaces existing account | Unaddressed | Add warning log |
The implementation structure is solid and follows established patterns, but the scope wiring gap (issue #1) means the feature cannot work at runtime. This must be resolved before the PR is functional.

Summary
Add support for syncing Coupa billing accounts as entitlements in ConductorOne, with full grant/revoke provisioning support.
/api/accounts) as a newaccountresource typedefault_accountfieldcore.accounting.readandcore.accounting.writeOAuth scopesFiles Changed
pkg/connector/resource_types.goaccountResourceTypedefinitionpkg/connector/accounts.gopkg/connector/client/accounts.goSetUserAccountREST API client methodpkg/connector/client/query.gopkg/connector/client/models.gopkg/connector/client/path.gopkg/connector/client/auth.gocore.accounting.read/writeOAuth scopespkg/connector/connector.goaccountBuilderinResourceSyncersbaton_capabilities.jsonImplementation Notes
default_accountfield for the user-to-account relationship. This field name is based on the standard Coupa Users API and should be verified against the customer's Coupa instance.accounts,defaultAccount) follow the patterns established by the existing connector for groups and roles. These may need adjustment based on testing./api/account_groups), which could be an alternative or additional mechanism. The current implementation focuses on direct account assignment.Fixes: CXH-1352
Test Plan
default_accountfield is the correct user-account relationship field for the customer's use caseAutomated PR Notice
This PR was automatically created by c1-dev-bot as a potential implementation.
This code requires: