Sync Google Workspace directory users and groups to any SCIM v2 endpoint. Reads from the Google Directory API, maps to SCIM resources, and pushes creates, updates, and deactivations. Runs as a scheduled Cloud Run job or locally.
Install · Quick start · Commands · Config · How it works · Deploy
Binary — grab the latest from Releases:
# macOS / Linux
tar xzf richmond_*.tar.gz
sudo mv richmond /usr/local/bin/Container:
docker pull ghcr.io/misfitdev/richmond:latestFrom source:
go install github.com/misfitdev/richmond@latest# Preview what would change (no writes)
richmond --log-level debug diff -c config.yaml
# Run the sync
richmond sync -c config.yamlRichmond needs three things: a Google Cloud service account with domain-wide delegation, your Workspace customer ID, and a SCIM endpoint with a bearer token. See Setup for step-by-step instructions.
| Command | Description |
|---|---|
richmond sync |
Run a full or incremental sync |
richmond sync --dry-run |
Show what would change without writing |
richmond diff |
Preview changes in tabular format |
Global flags:
| Flag | Default | Description |
|---|---|---|
-c, --config |
Config file path | |
--log-level |
info |
debug, info, warn, error |
-v, --version |
Print version |
YAML file with env var overrides. See config.example.yaml for all options.
google:
credentials_file: /path/to/sa-key.json
admin_email: admin@example.com
customer_id: C01234567
scim:
endpoint: https://your-scim-endpoint/v2
bearer_token: your-token-here
sync:
state_file: /tmp/richmond-state.jsonEnv vars override YAML. Useful for Cloud Run where everything is env-based.
| Env var | Config field |
|---|---|
GOOGLE_CREDENTIALS_FILE |
google.credentials_file |
GOOGLE_ADMIN_EMAIL |
google.admin_email |
GOOGLE_CUSTOMER_ID |
google.customer_id |
GOOGLE_DOMAIN |
google.domain |
GOOGLE_USER_QUERY |
google.user_query |
GOOGLE_EXCLUDE_ORG_UNITS |
google.exclude_org_units (comma-separated) |
GOOGLE_INCLUDE_GROUPS |
google.include_groups (comma-separated) |
GOOGLE_EXCLUDE_GROUPS |
google.exclude_groups (comma-separated) |
GOOGLE_INCLUDE_DERIVED_MEMBERSHIP |
google.include_derived_membership |
SCIM_ENDPOINT |
scim.endpoint |
SCIM_BEARER_TOKEN |
scim.bearer_token |
SCIM_ATTRIBUTES |
scim.attributes (comma-separated) |
STATE_FILE |
sync.state_file |
DRY_RUN |
sync.dry_run |
SYNC_GROUPS |
sync.sync_groups |
ADOPT_EXISTING |
sync.adopt_existing |
Exclude users by org unit (hierarchical — /Limited also excludes /Limited/Temps):
google:
exclude_org_units:
- /Limited
- /ContractorsFilter groups by email (glob patterns, set include or exclude, not both):
google:
include_groups:
- engineering@example.com
- team-*@example.comCore attributes (external_id, user_name, active) are always synced. Optional:
| Attribute | Google source | SCIM target |
|---|---|---|
name |
Name | name.givenName, name.familyName |
emails |
PrimaryEmail | emails[0] |
title |
Organizations[0].Title | title |
department |
Organizations[0].Department | enterprise extension |
phone_numbers |
Phones | phoneNumbers |
Only the Google API fields needed for configured attributes are requested (partial responses). At startup, Richmond queries the SCIM /Schemas endpoint and auto-removes attributes the provider doesn't support.
- Discover SCIM provider capabilities via
/Schemas(auto-filter unsupported attributes) - Fetch users and groups from Google Workspace Directory API
- Apply configured filters (OU exclusion, group include/exclude)
- Load previous sync state
- Drift detection: list all SCIM users and clear stale state entries for users deleted out-of-band
- Diff: create new users, update changed users, deactivate removed/suspended/archived users
- Same for groups (auto-detected — skipped if SCIM endpoint doesn't support them)
- Save state for next run
State tracks SCIM-assigned IDs and content hashes for incremental sync. Failed operations record a last_error in state and are retried on the next run. The error clears automatically on success. To force a clean retry, remove the last_error field from the state JSON.
When a user already exists at the SCIM endpoint (e.g. JIT-provisioned), Richmond adopts the existing account by patching it with externalId instead of failing with 409 Conflict. Disable with adopt_existing: false.
gcloud iam service-accounts create richmond \
--display-name="Richmond SCIM Sync" \
--project=YOUR_PROJECT_ID
# Local dev only -- use Workload Identity on Cloud Run
gcloud iam service-accounts keys create richmond-sa-key.json \
--iam-account=richmond@YOUR_PROJECT_ID.iam.gserviceaccount.comDomain-wide delegation:
- admin.google.com > Security > Access and data control > API controls
- Manage Domain Wide Delegation > Add new
- Enter the service account Client ID
- Add scopes:
https://www.googleapis.com/auth/admin.directory.user.readonly https://www.googleapis.com/auth/admin.directory.group.readonly https://www.googleapis.com/auth/admin.directory.group.member.readonly
admin.google.com > Account > Account settings > copy the Customer ID (starts with C).
Zitadel: https://your-domain/scim/v2/YOUR_ORG_ID with a service user PAT. Zitadel supports SCIM Users only; Richmond auto-detects this and skips groups.
Generic SCIM v2: your provider's base URL + a bearer token with user/group management permissions.
just build
./bin/richmond sync -c config.yamldocker run --rm \
-v /path/to/config.yaml:/config.yaml \
ghcr.io/misfitdev/richmond:latest richmond sync -c /config.yamlgcloud run jobs create richmond-sync \
--image=ghcr.io/misfitdev/richmond:latest \
--set-env-vars="GOOGLE_CUSTOMER_ID=C01234567,SCIM_ENDPOINT=https://...,STATE_FILE=gs://bucket/state.json" \
--set-secrets="SCIM_BEARER_TOKEN=richmond-scim-token:latest" \
--service-account=richmond@YOUR_PROJECT.iam.gserviceaccount.com
gcloud scheduler jobs create http richmond-sync-schedule \
--schedule="0 */6 * * *" \
--uri="https://REGION-run.googleapis.com/apis/run.googleapis.com/v1/namespaces/PROJECT/jobs/richmond-sync:run" \
--http-method=POST \
--oauth-service-account-email=richmond@YOUR_PROJECT.iam.gserviceaccount.comjust build # build binary
just test # run tests
just lint # golangci-lint
just check # lint + vet + test + govulncheck
just fuzz # fuzz SCIM parsing (30s)