Gopher says
Syncing tells the platform what access exists. Provisioning adds and removes access. Grant adds a user to a resource, Revoke removes them, and account creation creates new users.
In baton-junction, groups.go and roles.go implement Grant and Revoke, while account creation is implemented in users.go. Same builders, new interfaces.
The ResourceProvisioner Interface
Builders that support membership changes implement the ResourceProvisioner interface. The SDK detects this via type assertion - if your builder has Grant() and Revoke() methods with the right signatures, the SDK automatically enables provisioning for that resource type. No registration needed.
type ResourceProvisioner interface {
Grant(ctx context.Context, principal *v2.Resource, entitlement *v2.Entitlement) (annotations.Annotations, error)
Revoke(ctx context.Context, grant *v2.Grant) (annotations.Annotations, error)
}
Grant receives the principal (who is receiving access) and the entitlement (what access is being granted). Revoke receives the full Grant object, which contains both.
Grant Implementation
Here’s the full Grant method from groups.go:
func (o *groupBuilder) Grant(
ctx context.Context,
principal *v2.Resource,
en *v2.Entitlement,
) (annotations.Annotations, error) {
l := ctxzap.Extract(ctx)
groupID := en.Resource.Id.Resource
userID := principal.Id.Resource
l.Info("granting group membership",
zap.String("group_id", groupID),
zap.String("user_id", userID),
)
err := o.client.AddGroupMember(ctx, groupID, userID)
if err != nil {
return nil, fmt.Errorf("baton-junction: failed to grant group membership: %w", err)
}
return nil, nil
} The pattern is always the same: extract the logger, pull IDs from the right entities, log the intent, call the API, handle errors.
Revoke Implementation
Revoke follows the same structure, but with one critical addition - idempotent error handling:
func (o *groupBuilder) Revoke(
ctx context.Context,
g *v2.Grant,
) (annotations.Annotations, error) {
l := ctxzap.Extract(ctx)
groupID := g.Entitlement.Resource.Id.Resource
userID := g.Principal.Id.Resource
l.Info("revoking group membership",
zap.String("group_id", groupID),
zap.String("user_id", userID),
)
if err := o.client.RemoveGroupMember(ctx, groupID, userID); err != nil {
if isNotFound(err) {
l.Debug("group membership already removed",
zap.String("group_id", groupID),
zap.String("user_id", userID))
return nil, nil
}
return nil, fmt.Errorf("baton-junction: failed to revoke group membership: %w", err)
}
return nil, nil
}
Notice the isNotFound check. If the membership was already removed (HTTP 404), that’s success - the desired state is achieved. This is idempotency in action.
Idempotency
Provisioning operations may be retried by the platform. Your Grant and Revoke must handle repeated calls gracefully:
| Operation | Retry Scenario | Correct Behavior |
|---|---|---|
| Grant | User already a member | Return success (treat “already exists” as done) |
| Revoke | Membership already removed | Return success (treat “not found” as done) |
Failing on “already done” causes unnecessary retry storms. The isNotFound helper in baton-junction converts gRPC codes.NotFound (mapped from HTTP 404) into a simple boolean:
func isNotFound(err error) bool {
st, ok := grpcstatus.FromError(err)
return ok && st.Code() == codes.NotFound
}
Roles follow the exact same Grant/Revoke pattern as groups. The roleBuilder
implements Grant() and Revoke() with AddRoleMember and
RemoveRoleMember API calls. The entity source rule is identical: entitlement
provides the role ID, principal provides the user ID. If you’ve built group
provisioning, role provisioning is copy-paste with different method names.
Account Creation
Sometimes the platform needs to create a user who doesn’t exist yet. The AccountManagerLimited interface adds this capability to a builder:
type AccountManagerLimited interface {
CreateAccountCapabilityDetails(ctx context.Context) (
*v2.CredentialDetailsAccountProvisioning, annotations.Annotations, error)
CreateAccount(ctx context.Context, accountInfo *v2.AccountInfo,
credentialOptions *v2.LocalCredentialOptions) (
CreateAccountResponse, []*v2.PlaintextData, annotations.Annotations, error)
}
Capability Details
CreateAccountCapabilityDetails tells the platform what credential options your connector supports. Most connectors use NO_PASSWORD - the target app manages its own authentication:
func (o *userBuilder) CreateAccountCapabilityDetails(
_ context.Context,
) (*v2.CredentialDetailsAccountProvisioning, annotations.Annotations, error) {
return v2.CredentialDetailsAccountProvisioning_builder{
SupportedCredentialOptions: []v2.CapabilityDetailCredentialOption{
v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_NO_PASSWORD,
},
PreferredCredentialOption: v2.CapabilityDetailCredentialOption_CAPABILITY_DETAIL_CREDENTIAL_OPTION_NO_PASSWORD,
}.Build(), nil, nil
}
CreateAccount
The CreateAccount method extracts user information from AccountInfo, creates the user via the API, and returns a success result:
func (o *userBuilder) CreateAccount(
ctx context.Context,
accountInfo *v2.AccountInfo,
_ *v2.LocalCredentialOptions,
) (connectorbuilder.CreateAccountResponse, []*v2.PlaintextData, annotations.Annotations, error) {
l := ctxzap.Extract(ctx)
login := accountInfo.GetLogin()
emails := accountInfo.GetEmails()
var email string
if len(emails) > 0 {
email = emails[0].GetAddress()
}
req := &client.CreateUserRequest{
Email: email,
FirstName: accountInfo.GetProfile().GetFields()["first_name"].GetStringValue(),
LastName: accountInfo.GetProfile().GetFields()["last_name"].GetStringValue(),
Username: login,
}
user, err := o.client.CreateUser(ctx, req)
if err != nil {
return nil, nil, nil, fmt.Errorf("baton-junction: failed to create account: %w", err)
}
l.Info("created user account",
zap.String("user_id", user.ID),
zap.String("email", user.Email),
)
result := v2.CreateAccountResponse_SuccessResult_builder{
IsCreateAccountResult: true,
}.Build()
return result, nil, nil, nil
} AccountInfo provides the login, emails (as a slice of AccountInfo_Email), and a profile structpb.Struct with arbitrary fields. The CreateAccountResponse_SuccessResult tells the platform the account was created successfully.
Implement groupBuilder.Grant: extract the group ID from the entitlement and the user ID from the principal, then call the API to add the member. Return success on completion.
Implement groupBuilder.Revoke: extract the group ID and user ID from the grant, then call the API to remove the member. If the member was already removed (not found), treat it as success. Otherwise propagate the error.
Gopher says
Your connector can now change access, not just read it. Grant adds memberships, Revoke removes them, and CreateAccount creates users.
In baton-junction, groups and roles both follow the same Grant/Revoke pattern, and account creation lives on the user builder.
Next: integration testing to verify everything works together.
Next Lesson
Integration Tests

