Level 16 /

Provisioning

The tracks are built and trains run on schedule. Now passengers need their tickets. Gopher stands at the ticket window, issuing passes and collecting them back. Grant gives access, Revoke takes it away, and sometimes you need to create an account for a brand-new rider.

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:

extract loggerentity source: WHATentity source: WHOlog intentAPI call
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:

OperationRetry ScenarioCorrect Behavior
GrantUser already a memberReturn success (treat “already exists” as done)
RevokeMembership already removedReturn 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
}

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:

extract account infoAPI callreturn 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.

Explore the related connector code files

Next Lesson

Integration Tests

Continue