Cross-cutting conventions, API patterns, and Snipe-IT quirks live in CLAUDE.md.
This file contains only what is specific to the Okta → Snipe-IT integration.
Syncs active users from an Okta-Gov (or commercial Okta) organisation into
Snipe-IT as license seat assignments. Designed to follow
the same pattern as the other *2snipe integrations in this org.
GitHub: https://github.com/jackvaughanjr/okta2snipe
Module: github.com/jackvaughanjr/okta2snipe
- Language: Go 1.22+
- CLI framework:
cobra+viper - Config:
settings.yaml(gitignored), env var overrides - Logging:
log/slog— structured, levelled, text or JSON output - Rate limiting:
golang.org/x/time/rate— 2 req/s to Snipe-IT
main.go # version injection + cmd.Execute()
cmd/
root.go # CLI setup, cobra/viper init, logging, env var bindings
sync.go # sync command: --dry-run, --force, --email; Slack notifications
test.go # test command: active user count, role holders, license state
internal/
okta/
client.go # Okta REST client (see below)
slack/
client.go # Slack incoming-webhook client
snipeit/
client.go # Snipe-IT API client (see below)
sync/
syncer.go # core sync logic
result.go # Result struct
.github/
workflows/
release.yml # builds 4-platform binaries on v* tag push
go.mod
go.sum
settings.example.yaml
README.md
CONTEXT.md
.gitignore # excludes: settings.yaml, okta2snipe binary, .cache/, .DS_Store
settings.yaml (never committed — see settings.example.yaml for template):
okta:
url: "https://your-org.okta-gov.com" # or *.okta.com for commercial
api_token: "" # Okta API token (SSWS auth)
snipe_it:
url: "https://your-snipe-it-instance.example.com"
api_key: ""
license_name: "Okta" # created automatically if missing
license_category_id: 0 # required: Snipe-IT category ID
license_manufacturer_id: 0 # optional: 0 = auto find/create "Okta" manufacturer
license_supplier_id: 0 # optional: 0 = omit from license
slack:
webhook_url: "" # optional: see CLAUDE.md for Slack behavior
sync:
dry_run: false
force: false
rate_limit_ms: 500| Variable | Config key |
|---|---|
OKTA_URL |
okta.url |
OKTA_TOKEN |
okta.api_token |
SNIPE_URL |
snipe_it.url |
SNIPE_TOKEN |
snipe_it.api_key |
SLACK_WEBHOOK |
slack.webhook_url |
Lightweight Okta REST API client. No external Okta SDK — plain net/http.
Authorization: SSWS <api_token> header on every request.
Works identically against *.okta-gov.com and *.okta.com.
| Method | Endpoint | Notes |
|---|---|---|
ListActiveUsers(ctx) |
GET /api/v1/users?filter=status%20eq%20%22ACTIVE%22&limit=200 |
Follows Link: rel="next" pagination. Quotes must be percent-encoded — literal " causes a 400. |
ListAllUsers(ctx) |
GET /api/v1/users?limit=200 |
All statuses; used for checkin pass |
GetUserByEmail(ctx, email) |
GET /api/v1/users/{email} |
Single user lookup |
GetUserRoles(ctx, userID) |
GET /api/v1/users/{id}/roles |
Returns []Role; Okta 403 → treated as no roles (not an error) |
- Filter URL encoding: Filter query strings must use percent-encoding (
%20for space,%22for quotes). Literal quotes in the URL cause a 400 error. - 403 on roles:
GET /api/v1/users/{id}/rolesreturns HTTP 403 for regular (non-admin) users in some org configurations. This is not an error — treat it as an empty role list and continue. - Pagination: Okta uses
Link: <url>; rel="next"response headers. The client parses this header and follows it automatically — no cursor tracking needed in callers.
type User struct {
ID string
Status string
Profile UserProfile // Login, Email, FirstName, LastName
}
type Role struct {
ID string
Type string // e.g. "SUPER_ADMIN", "ORG_ADMIN", "APP_ADMIN"
Label string // human-readable
}Snipe-IT REST API client. Rate-limited to 2 req/s via golang.org/x/time/rate.
See CLAUDE.md for envelope behavior, rate limiting, checkout/checkin rules, and
FindOrCreate patterns — those apply to all integrations.
| Method | Description |
|---|---|
FindLicenseByName(ctx, name) |
Search by exact name; returns nil, nil if not found |
FindLicenseByID(ctx, id) |
Fetch by numeric ID |
CreateLicense(ctx, name, seats, categoryID, manufacturerID, supplierID) |
Create a new license; categoryID required, pass 0 to omit optional IDs |
FindOrCreateLicense(ctx, name, initialSeats, categoryID, manufacturerID, supplierID) |
Find or create |
UpdateLicenseSeats(ctx, licenseID, seats) |
Expand (or change) seat count |
ListLicenseSeats(ctx, licenseID) |
Returns []LicenseSeat (up to 500) |
CheckoutSeat(ctx, licenseID, seatID, userID, notes) |
Assign seat to user via PATCH |
CheckinSeat(ctx, licenseID, seatID) |
Return seat (DELETE endpoint) |
UpdateSeatNotes(ctx, licenseID, seatID, notes) |
PATCH notes on an existing checkout |
FindUserByEmail(ctx, email) |
Search Snipe-IT users; returns nil, nil if not found |
FindManufacturerByName(ctx, name) |
Search by exact name; returns nil, nil if not found |
CreateManufacturer(ctx, name, url) |
Create a new manufacturer record |
FindOrCreateManufacturer(ctx, name, url) |
Find or create |
type License struct {
ID int
Name string
Seats int
FreeSeatsCount int
}
type LicenseSeat struct {
ID int
LicenseID int
AssignedTo *AssignedTo // nil if free
Notes string
}
type SnipeUser struct {
ID int
Name string
Username string
Email string
}
type Manufacturer struct {
ID int
Name string
URL string
}type Config struct {
DryRun bool
Force bool
LicenseName string
LicenseCategoryID int // required
ManufacturerID int // 0 = auto find/create "Okta" manufacturer
SupplierID int // 0 = omit
}- Fetch active Okta users —
okta.ListActiveUsers(), paginated - Build active email set — used for the checkin pass in step 10
- Apply
--emailfilter — if set, narrow to one user - Fetch roles per user —
okta.GetUserRoles(); 403 → no roles (not fatal) - Resolve manufacturer — if
ManufacturerID == 0, callsnipe.FindOrCreateManufacturer("Okta", "https://www.okta.com"). Skipped in dry-run. - Find or create license — dry-run uses
FindLicenseByNameonly; synthesizes a placeholder&License{Name: ..., Seats: activeCount}(id=0) if not found. Production usesFindOrCreateLicense. - Expand seats if needed — if
activeCount > license.Seats, callUpdateLicenseSeats(activeCount). Seats are never shrunk automatically. - Load current seat assignments —
snipe.ListLicenseSeats(); partition intocheckedOutByEmailmap andfreeSeatsslice. Skipped for synthetic dry-run license (id=0). Fails fast in production if id=0. - Checkout / update loop — for each active user:
- Find Snipe-IT user by email; warn + skip + append to
result.UnmatchedEmailsif not found - If already checked out: compare notes; update if changed (or
--force) - If not checked out: dry-run logs and counts; production pops a free seat and
calls
CheckoutSeat
- Find Snipe-IT user by email; warn + skip + append to
- Checkin loop (skipped when
--emailis set) — for each seat checked out to an email not in the active set:CheckinSeat
Okta role labels are written to the seat's notes field, sorted alphabetically:
Okta roles: Label1, Label2, Label3
Empty string if the user has no roles.
emailKey(user) prefers profile.email; falls back to profile.login.
All comparisons are lowercased.
Messages sent by cmd/sync.go for this integration (see CLAUDE.md for the
general Slack pattern — when to send, how errors are handled, dry-run suppression):
| Event | Message |
|---|---|
| Sync failure | okta2snipe sync failed: <error> |
| Unmatched user | okta2snipe: no Snipe-IT account found for Okta user — <email> (one per user) |
| Sync success | okta2snipe sync complete — checked out: N, notes updated: N, checked in: N, skipped: N, warnings: N |
Releases are published automatically by .github/workflows/release.yml when a
v* tag is pushed. See CLAUDE.md for the full workflow template and pattern.
Each release attaches four pre-built binaries:
| Asset | Platform |
|---|---|
okta2snipe-darwin-arm64 |
macOS (Apple Silicon) |
okta2snipe-linux-amd64 |
Linux x86-64 |
okta2snipe-linux-arm64 |
Linux ARM64 |
okta2snipe-windows-amd64.exe |
Windows x86-64 |
git tag v1.2.3
git push origin v1.2.3Or trigger manually from the Actions tab with a tag input.
# From a release binary
curl -L https://github.com/jackvaughanjr/okta2snipe/releases/latest/download/okta2snipe-darwin-arm64 -o okta2snipe
chmod +x okta2snipe
# From source
git clone https://github.com/jackvaughanjr/okta2snipe
cd okta2snipe
go build -o okta2snipe .
# Check version
./okta2snipe --version
# Validate connections
./okta2snipe test
# Dry run (no changes)
./okta2snipe sync --dry-run -v
# Full sync
./okta2snipe sync -v
# Single user
./okta2snipe sync --email user@example.com -v