Instructions for AI assistants working with this Baton connector.
Detailed connector documentation is in .claude/skills/connector/:
INDEX.md- Skills overview and selection guideconcepts-identifiers.md- ResourceId vs ExternalId (CRITICAL for provisioning)build-provisioning.md- Grant/Revoke implementation patternsbuild-pagination.md- Pagination strategiesref-unused-features.md- SDK feature usage notes
Change-Type Specific Guidance: See CHANGE_TYPES.md for guidance based on what type of change you're making (SDK upgrade, pagination fix, panic fix, provisioning, etc.).
A ConductorOne Baton connector that syncs identity and access data from a downstream service. Connectors implement the ResourceSyncer interface to expose users, groups, roles, and their relationships.
go build ./cmd/baton-* # Build connector
go test ./... # Run tests
go test -v ./... -count=1 # Verbose, no cacheSDK Inversion of Control: The connector implements interfaces; the SDK orchestrates execution.
Four Sync Phases (SDK-driven):
ResourceType()- Declare what resource types existList()- Fetch all resources of each typeEntitlements()- Fetch available permissions per resourceGrants()- Fetch who has what access
Key Interfaces:
ResourceSyncer- Main interface for sync (List, Entitlements, Grants)ResourceBuilder- Creates resources with traitsConnectorBuilder- Wires everything together
// WRONG - stops after first page
return resources, "", nil, nil
// CORRECT - pass through API's token
if resp.NextPage == "" {
return resources, "", nil, nil
}
return resources, resp.NextPage, nil, nilfunc (g *groupBuilder) Grant(ctx context.Context, principal *v2.Resource,
entitlement *v2.Entitlement) ([]*v2.Grant, annotations.Annotations, error) {
// WHO - get native ID from ExternalId (required for API calls)
externalId := principal.GetExternalId()
if externalId == nil {
return nil, nil, fmt.Errorf("baton-service: principal missing external ID")
}
nativeUserID := externalId.Id // Use this for API calls
// Fallback: principal.Id.Resource if you set ExternalId to same value during sync
// WHAT - from entitlement
groupID := entitlement.Resource.Id.Resource
// CONTEXT (workspace/org) - from principal, NOT entitlement
workspaceID := principal.ParentResourceId.Resource // CORRECT
// workspaceID := entitlement.Resource.ParentResourceId.Resource // WRONG
}Note on ExternalId: During sync, set WithExternalID() with the native system identifier. During Grant/Revoke, retrieve it via GetExternalId() to make API calls. ConductorOne assigns its own resource IDs that differ from the target system's native IDs.
resp, err := client.Do(req)
if err != nil {
if resp != nil { // resp may be nil on network errors
defer resp.Body.Close()
}
return fmt.Errorf("baton-service: request failed: %w", err)
}
defer resp.Body.Close() // After error check// Always include connector prefix and use %w
return fmt.Errorf("baton-service: failed to list users: %w", err)
// Never swallow errors
if err != nil {
log.Println(err) // WRONG - continues with bad state
return nil, "", nil, err // CORRECT - propagate
}// WRONG - fails if API returns {"id": 12345}
type Group struct {
ID string `json:"id"`
}
// CORRECT - handles both string and number
type Group struct {
ID json.Number `json:"id"`
}
// Usage
groupID := group.ID.String()For complex cases, use a custom unmarshaler:
type FlexibleID string
func (f *FlexibleID) UnmarshalJSON(data []byte) error {
var s string
if json.Unmarshal(data, &s) == nil {
*f = FlexibleID(s)
return nil
}
var n int64
if json.Unmarshal(data, &n) == nil {
*f = FlexibleID(strconv.FormatInt(n, 10))
return nil
}
return fmt.Errorf("id must be string or number")
}// Grant "already exists" = success, not error
if isAlreadyExistsError(err) {
return nil, annotations.New(&v2.GrantAlreadyExists{}), nil
}
// Revoke "not found" = success, not error
if isNotFoundError(err) {
return annotations.New(&v2.GrantAlreadyRevoked{}), nil
}Key point: "Already exists" and "already revoked" are NOT errors - return nil error with the annotation.
// Connectors that create clients MUST close them
func (c *Connector) Close() error {
if c.client != nil {
return c.client.Close()
}
return nil
}- Pagination bugs - Infinite loop (hardcoded token) or early termination (always empty token)
- Entity confusion - Getting workspace from entitlement instead of principal
- Swapped arguments - Multiple string params in wrong order (check API docs!)
- Nil pointer panic - Accessing resp.Body when resp is nil
- Error swallowing - Logging but not returning errors (exception: intentional skip-and-continue with Warn log is OK — see
patterns-error-handling.md) - Unstable IDs - Using email instead of stable API ID
- JSON type mismatch - API returns number, Go expects string (use json.Number)
- Wrong trait type - Using User for service accounts (use App)
- Pagination bag not init - Forgetting to initialize bag on first call
- Resource leak - Close() returns nil without closing client connections
- Non-idempotent grants - Returning error on "already exists" instead of success
- Missing ExternalId - Not setting WithExternalID() during sync; provisioning then fails
- Scientific notation - Using
%vwith large numeric IDs produces1.23e+15instead of1234567890123456
| Type | Trait | Typical Use |
|---|---|---|
| User | TRAIT_USER |
Human identities |
| Group | TRAIT_GROUP |
Collections with membership |
| Role | TRAIT_ROLE |
Permission sets |
| App | TRAIT_APP |
Service accounts, API keys |
- Don't buffer all pages in memory (OOM risk)
- Don't ignore context cancellation
- Don't log secrets
- Don't hardcode API URLs (breaks testing)
- Don't use
%vfor error wrapping (use%w) - Don't return nil from Close() if you created clients
- Don't return error on "already exists" for Grant operations
- Don't use
%vor%gwith large numeric IDs (use%dorstrconvto avoid scientific notation)
Required for provisioning:
WithExternalID()- REQUIRED for Grant/Revoke to work. Stores the native system ID that provisioning operations need to call the target API. During Grant, retrieve viaprincipal.GetExternalId().Id.
Rarely used (but valid):
WithMFAStatus(),WithSSOStatus()- Only relevant for IDP connectorsWithStructuredName()- Rarely needed; DisplayName usually sufficient- Complex user profile fields beyond basics - Only if downstream needs them
Connectors should support:
--base-urlflag for mock server testing--insecureflag for self-signed certs in tests
cmd/baton-*/main.go # Entry point
pkg/connector/connector.go # ConnectorBuilder implementation
pkg/connector/*_builder.go # ResourceSyncer implementations
pkg/client/client.go # API client
# Run with debug logging
LOG_LEVEL=debug ./baton-* --config-file=config.yaml
# Output to specific file
./baton-* --file=sync.c1z
# Inspect output
baton resources --file=sync.c1z
baton grants --file=sync.c1z