Gopher says
Time to build train cars! Each resource type - users, groups, roles - gets its own builder. Same pattern every time: define the type, implement List, add entitlements and grants. The sync pulls data from the target app and converts it into SDK types.
In baton-junction, users.go, groups.go, and roles.go each follow this blueprint. Once you’ve built one builder, the others are the same skeleton with different API calls.
Resource Type Definitions
Before you can sync users or groups, the platform needs to know what types of things your connector supports. Resource type definitions are that declaration - they live in one place and every builder references them. Start with resource_types.go:
var (
userResourceType = &v2.ResourceType{
Id: "user",
DisplayName: "User",
Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_USER},
}
groupResourceType = &v2.ResourceType{
Id: "group",
DisplayName: "Group",
Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_GROUP},
}
roleResourceType = &v2.ResourceType{
Id: "role",
DisplayName: "Role",
Traits: []v2.ResourceType_Trait{v2.ResourceType_TRAIT_ROLE},
}
)
const memberEntitlement = "member"
The Id is stable and stored in sync output - never change it. Traits determine which SDK helpers are available (NewUserResource requires TRAIT_USER).
The User Builder
Builders wrap the HTTP client and implement the SDK’s sync interface. Each one is a thin struct that holds the client and does the conversion work. The user builder looks like this:
type userBuilder struct {
client *client.Client
}
func newUserBuilder(c *client.Client) *userBuilder {
return &userBuilder{client: c}
}
The List Method
When C1 asks for a page of users (or groups, or roles), the List method fetches from the API, converts to SDK types, and returns. It’s the heart of the sync. Every List follows this skeleton, starting with extracting the logger:
func (o *userBuilder) List(ctx context.Context, _ *v2.ResourceId, attrs resource.SyncOpAttrs,
) ([]*v2.Resource, *resource.SyncOpResults, error) {
l := ctxzap.Extract(ctx)
bag, cursor, err := parsePageToken(...)
users, nextCursor, err := o.client.GetUsers(ctx, cursor)
l.Debug("listed users", zap.Int("count", len(users)))
... convert each user to v2.Resource ...
bag.Next(nextCursor)
return resources, &resource.SyncOpResults{NextPageToken: nextPageToken}, nil
} The conversion step uses a helper function with the builder pattern:
func userResource(u *client.User) (*v2.Resource, error) {
opts := []resource.UserTraitOption{
resource.WithEmail(u.Email, true),
resource.WithUserLogin(u.Username),
resource.WithStatus(mapUserStatus(u.Status)),
resource.WithUserProfile(profile),
}
r, err := resource.NewUserResource(
displayName(u.FirstName, u.LastName, u.Email, u.ID),
userResourceType,
u.ID,
opts,
resource.WithAnnotation(&v2.RawId{Id: u.ID}),
)
return r, err
}
Group & Role Builders
Users are the people in your system, but they’re only half the picture. Groups and roles are the containers that organize access. Each one gets its own builder file, following the same struct-and-constructor pattern you just saw:
type groupBuilder struct {
client *client.Client
}
func newGroupBuilder(c *client.Client) *groupBuilder {
return &groupBuilder{client: c}
}
type roleBuilder struct {
client *client.Client
}
func newRoleBuilder(c *client.Client) *roleBuilder {
return &roleBuilder{client: c}
}
Every builder implements ResourceType() to declare which type it handles, and List() to sync resources from the API. The List method follows the same five-step pagination skeleton as the user builder - parse the page token, fetch a page, convert each item, advance the cursor, return. The only differences are which API endpoint you call and which New*Resource helper you use for conversion.
The key distinction between users and the other builders comes next: groups and roles can have entitlements and grants, while users cannot.
Entitlements
Entitlements are grantable permissions or memberships on resources. Each resource can have entitlements declared for it.
An entitlement has a slug - a short identifier. The standard slug for groups is "member". For roles, "member" or "assigned" are both common. This connector uses "member" for both groups and roles, defined once as a constant in resource_types.go.
The SDK generates an entitlement ID from three parts: {resourceType}:{resourceId}:{slug}. For example, group:g1:member or role:r4:member. You don’t construct these IDs yourself - the SDK builds them from the resource and slug you provide.
WithGrantableTo declares which resource types can be principals for this entitlement. Group entitlements use WithGrantableTo(userResourceType) - only users can be group members. Role entitlements use WithGrantableTo(userResourceType, groupResourceType, roleResourceType) - users, groups, and other roles can all be assigned to a role.
Here’s the group builder’s Entitlements() method:
func (o *groupBuilder) Entitlements(_ context.Context, res *v2.Resource, _ resource.SyncOpAttrs,
) ([]*v2.Entitlement, *resource.SyncOpResults, error) {
e := entitlement.NewAssignmentEntitlement(
res,
memberEntitlement,
entitlement.WithGrantableTo(userResourceType),
entitlement.WithDisplayName(fmt.Sprintf("%s Member", res.DisplayName)),
)
return []*v2.Entitlement{e}, nil, nil
}
The user builder’s Entitlements() and Grants() return empty lists because users in this connector receive access - they don’t have entitlements others can be granted.
Grants
A grant is the core access relationship in Baton. Every grant has three parts:
- Principal (P) - the resource that receives access
- Entitlement (E) - the specific permission being granted
- Resource (R) - the resource that owns the entitlement
Any resource type can be a principal. Users are the most common, but groups and roles can be principals too. When Regional Pass (a group) is granted the “member” entitlement on Standard Day (a role), the group is the principal. This is what makes grant expansion possible - the platform sees a non-user principal, follows its members, and resolves effective access.
Grants list who holds each entitlement. Same pagination skeleton, building v2.Grant objects:
for _, m := range members {
g := grant.NewGrant(
res,
memberEntitlement,
&v2.ResourceId{
ResourceType: userResourceType.Id,
Resource: m.UserID,
},
)
grants = append(grants, g)
}
Implement the List method for groupBuilder: extract the logger, parse the page token, fetch groups from the API, log the result, convert each group to a resource, advance pagination, and return the results. Handle errors at each step.
Build a grant that links a user to a group’s member entitlement. Use grant.NewGrant with the resource, the entitlement, and the principal’s resource ID.
Direct Grants
Think of a direct grant like a single train ticket. Dave bought a Standard Day ticket. Frank bought a Single Ride ticket. Each ticket was issued individually, and each one can be torn up. The connector’s Grants() method lists these tickets, and the Grant() and Revoke() methods issue or tear them up.
Direct grants are mutable - you can change them. If Dave no longer needs Standard Day access, you revoke his membership grant. This is the default, and it’s all you need when your system has flat, simple access.
Inherited Grants (Grant Expansion)
In baton-junction, some roles have direct user members (Dave holds a Standard Day ticket directly), but some roles are also assigned to groups or other roles. The Regional Pass group is assigned to the Standard Day role. First Class is assigned to the Dining Access role, because first-class tickets include dining. The connector reports these relationships, and the platform walks the graph to figure out who has effective access.
This is grant expansion. A grant on one entitlement implies grants on another. An example of this can be found in baton-junction: Alice holds an All-Access Pass, which grants First Class, which includes Dining Access. The platform follows the entire chain automatically.
Deep expansion: Alice -> All-Access Pass -> First Class -> Dining Access
Without expansion, the platform only sees Bob, Carol, Dave, and the First Class role (but not its assignments) as Dining Access assignments. With expansion, it follows the chain: First Class holders (Eve directly, Alice via All-Access Pass) also get effective Dining Access.
How Your Connector Declares Expansion
Your connector doesn’t compute this transitive access itself. Instead, you attach a GrantExpandable annotation to any grant where the principal has its own entitlements worth following. The grant.WithAnnotation helper attaches it directly to the grant.
In roles.go, the Grants() method fetches direct user members, group assignments, and role-to-role assignments. For non-user principals, it attaches the annotation:
Annotations are typed protobuf messages you can attach to resources, grants, and entitlements without changing the core schema. You’ve already seen v2.RawId used to preserve external IDs on resources via resource.WithAnnotation. The SDK provides the same helper on all three types: resource.WithAnnotation, entitlement.WithAnnotation, and grant.WithAnnotation.
The annotations package gives you tools for working with annotation slices: Append adds a new one, Update replaces one of the same type, Contains checks for a type, and Pick extracts and unmarshals a specific type.
// Group-to-role grants (e.g., Regional Pass assigned to Standard Day)
for _, ga := range roleGroups {
g := grant.NewGrant(
res,
memberEntitlement,
&v2.ResourceId{
ResourceType: groupResourceType.Id,
Resource: ga.GroupID,
},
grant.WithAnnotation(&v2.GrantExpandable{
EntitlementIds: []string{fmt.Sprintf("group:%s:member", ga.GroupID)},
}),
)
grants = append(grants, g)
}
// Role-to-role grants (e.g., First Class assigned to Dining Access)
for _, ra := range roleRoles {
g := grant.NewGrant(
res,
memberEntitlement,
&v2.ResourceId{
ResourceType: roleResourceType.Id,
Resource: ra.RoleID,
},
grant.WithAnnotation(&v2.GrantExpandable{
EntitlementIds: []string{fmt.Sprintf("role:%s:member", ra.RoleID)},
}),
)
grants = append(grants, g)
}
The EntitlementIds field tells the platform: “This grant’s principal has a ‘member’ entitlement. Go look at who holds that entitlement and give them effective access here too.” The platform follows the chain as deep as it goes.
What the Platform Does With It
You declare the relationships. The platform does all the heavy lifting:
- Collects your grants - your connector returns grants as usual, some tagged with
GrantExpandable - Builds a graph - the syncer creates a directed graph where each
GrantExpandableannotation becomes an edge from one entitlement to another - Detects cycles - if two groups reference each other circularly, the syncer detects and breaks the loop (no infinite expansion)
- Walks the graph - for each edge, it finds all grants on the source entitlement and creates corresponding expanded grants on the destination
- Tracks the source - every expanded grant gets a
GrantSourcesfield recording where the access came from, so the platform knows it’s inherited
You write maybe 5 extra lines of code (the annotation). The SDK does the graph traversal, cycle detection, and expansion. That’s a good trade.
Shallow vs Deep: How Far Does the Pass Reach?
By default, expansion is deep - it follows the chain as far as it goes. In baton-junction, the chain All-Access Pass -> First Class -> Dining Access works because deep expansion follows every hop: Alice’s All-Access Pass membership expands into First Class, and that expanded First Class membership expands again into Dining Access.
Shallow expansion (Shallow: true) limits expansion to one level. It only expands grants that were directly assigned to the source entitlement - not grants that were themselves inherited from another expansion. If baton-junction used shallow expansion on the Dining Access -> First Class relationship, Eve (direct First Class holder) would inherit Dining Access, but Alice (who inherited First Class through her All-Access Pass) would not.
There’s also a ResourceTypeIds filter that restricts which principal types get expanded. For example, ResourceTypeIds: []string{"user"} means “only expand user principals, skip group and role principals.” Neither of these options is needed in baton-junction - we want the full deep expansion chain with all principal types.
Here’s the full GrantExpandable at a glance:
| Field | Type | Default | What it does |
|---|---|---|---|
EntitlementIds | []string | (required) | Which entitlements to look at for expansion |
Shallow | bool | false | If true, only expand direct grants (one hop) |
ResourceTypeIds | []string | (all types) | Only expand grants for these principal types |
Not all grants can be revoked directly. A mutable grant is a single ticket - Dave’s Standard Day membership. Someone issued it, and someone can tear it up. The connector’s Grant() adds it, Revoke() removes it. This is the default for every grant your connector returns.
An immutable grant is one that exists because of expansion. When the platform creates an expanded grant (Bob’s inherited Standard Day access through Regional Pass), it automatically attaches a GrantImmutable annotation. Bob doesn’t have his own Standard Day ticket - his access comes from his Regional Pass membership. To remove Bob’s Standard Day access, you revoke the source grant (his Regional Pass membership).
Think of it like canceling a monthly pass: you don’t cancel each individual ride separately. You cancel the pass, and all the rides it covered go away.
Exercise: Add Grant Expansion
Write expandableGrant: the Express Pass group is assigned to the Express role. Create the grant that represents this relationship, and tag it with a GrantExpandable annotation so the platform knows to expand Express Pass’s members into Express role membership. Use gt.NewGrant with gt.WithAnnotation and the GrantExpandable struct.
Gopher says
You’ve covered the full sync picture: resource types, List methods, entitlements, direct grants, inherited grants via expansion. Three builders, one pattern, and the same annotations and expansion concepts apply to every Baton connector.
In baton-junction, users.go, groups.go, and roles.go follow the
same structure. The role builder adds grant expansion for group-to-role
and role-to-role assignments - a few extra lines that give the platform
full visibility into inherited access.
Next: provisioning - implementing Grant and Revoke so the platform can actually change access, not just read it.
Next Lesson
Provisioning

