Skip to main content

Building a Directory Sync Integration

This guide covers what's additionally required on top of the core integration builder for integrations that implement Directory Sync — the process of collecting accounts, groups, and memberships from a provider and emitting them through the ingest pipeline.

Google Workspace is used as the reference example throughout.


What Directory Sync Produces

Directory Sync integrations collect and emit three normalized schemas:

SchemaWhat It Represents
DirectoryAccountA user or service account in the provider directory
DirectoryGroupA group or distribution list
DirectoryMembershipA user's membership in a group

Additional UserInput Fields

Directory Sync integrations can include a fields on the UserInput config:

type UserInput struct {
// DisableGroupSync will just sync users and no groups or group memberships
DisableGroupSync bool `json:"disableGroupSync,omitempty" jsonschema:"title=Disable Group Sync,description=Only sync users, disable groups sync operations"`
// FilterExpr limits imported records to envelopes matching a CEL expression
FilterExpr string `json:"filterExpr,omitempty" jsonschema:"title=Filter Expression,description=Optional CEL expression to apply to records before ingesting"`
// PrimaryDirectory marks this installation as the authoritative source for identity holder sync
PrimaryDirectory bool `json:"primaryDirectory,omitempty" jsonschema:"title=Primary Directory"`
}
  • DisableGroupSync - when true, only users are synced, skipping group and group membership ingestion
  • FilterExpr — a CEL expression evaluated against each envelope before ingest allowing for users to only ingest relevant data; see CEL Expressions
  • PrimaryDirectory — when true, this installation's data drives identity holder enrichment and lifecycle derivation; see Identity Holder Sync below. This should only be used on directories that are token issuers, and not secondary directories. For example, Google Workspace is a primary directory, but GitHub would not be.

Registering the Directory Sync Operation

In builder.go, register the directory sync operation alongside the health check. Note Reconcile: true — directory sync runs on a schedule, not inline during connection.

{
Name: directorySyncOperation.Name(),
Description: "Collect directory users, groups, and memberships and emit directory ingest envelopes",
Topic: definitionID.OperationTopic(directorySyncOperation.Name()),
ClientRef: myClient.ID(),
ConfigSchema: directorySyncSchema,
Policy: types.ExecutionPolicy{Reconcile: true},
Ingest: []types.IngestContract{
{Schema: integrationgenerated.IntegrationMappingSchemaDirectoryAccount},
{Schema: integrationgenerated.IntegrationMappingSchemaDirectoryGroup},
{Schema: integrationgenerated.IntegrationMappingSchemaDirectoryMembership},
},
IngestHandle: DirectorySync{}.IngestHandle(),
},

Collecting Directory Data - operation_directory_sync.go

The operation struct implements IngestHandle(), which wraps the provider API calls and returns one IngestPayloadSet per schema.

func (d DirectorySync) IngestHandle() types.IngestHandler {
return providerkit.WithClientRequest(myClient, func(ctx context.Context, req types.OperationRequest, client *sdk.Client) ([]types.IngestPayloadSet, error) {
// 1. Resolve installation metadata (e.g. tenant ID) stored at connection time
var meta InstallationMetadata
if req.Integration != nil {
_ = jsonx.UnmarshalIfPresent(req.Integration.InstallationMetadata.Attributes, &meta)
}

// 2. Guard on required metadata fields
if meta.TenantID == "" {
return nil, ErrTenantIDMissing
}

return d.Run(ctx, client, meta.TenantID)
})
}

Run performs the API calls and builds the payload sets:

func (DirectorySync) Run(ctx context.Context, client *sdk.Client, tenantID string) ([]types.IngestPayloadSet, error) {
users, err := listUsers(ctx, client, tenantID)
if err != nil {
return nil, err
}

accountEnvelopes := make([]types.MappingEnvelope, 0, len(users))
for _, user := range users {
envelope, err := providerkit.MarshalEnvelope(user.Email, user, ErrPayloadEncode)
if err != nil {
return nil, err
}
accountEnvelopes = append(accountEnvelopes, envelope)
}

// ... collect groups and memberships similarly

return []types.IngestPayloadSet{
{Schema: integrationgenerated.IntegrationMappingSchemaDirectoryAccount, Envelopes: accountEnvelopes},
{Schema: integrationgenerated.IntegrationMappingSchemaDirectoryGroup, Envelopes: groupEnvelopes},
{Schema: integrationgenerated.IntegrationMappingSchemaDirectoryMembership, Envelopes: membershipEnvelopes},
}, nil
}

providerkit.MarshalEnvelope(resourceID, rawProviderObject, errSentinel) serializes the provider object into a MappingEnvelope. The resourceID is a stable string that identifies the resource within the provider — for memberships, use the group identifier so membership envelopes can be associated back to their group via the resource CEL variable.


Identity Holder Sync

When a user sets UserInput.PrimaryDirectory = true, the integration's directory data is used to drive identity holder sync — the process that enriches and manages the lifecycle of identity holders in Openlane based on the provider's directory state.

This flag surfaces in UserInput and is read downstream by the identity holder sync hooks. No additional code is required in the integration package itself — declaring the field and including it in the UserInputRegistration schema is sufficient.

Only one installation per organization should be marked as the primary directory. This is enforced at the application layer.