-
-
Notifications
You must be signed in to change notification settings - Fork 204
Description
RFC: SCIM 2.0 / Directory Sync
Phase: 3 — Enterprise SSO & Federation
Priority: P2 — High
Estimated Effort: High
Depends on: Organizations (#511)
Problem Statement
Authorizer has no automated user provisioning. Enterprise customers manage thousands of users in directories (Azure AD, Okta, Google Workspace). Without SCIM, adding/removing users in Authorizer is manual. When an employee is offboarded in the directory, their Authorizer access persists — a security risk. WorkOS Directory Sync and Keycloak LDAP federation address this.
Current Architecture Context
- User CRUD:
AddUser,UpdateUser,DeleteUseron storage provider - No external directory integration
- User schema supports: email, phone, name, roles, custom metadata
- Organization membership planned in RFC: Organization & Multi-Tenancy Enhancements #511
- Webhook events exist for user lifecycle (
user.created,user.deleted)
Proposed Solution
1. SCIM 2.0 Server Implementation
Authorizer implements a SCIM 2.0 server (RFC 7644) that enterprise directories push changes to.
New package: internal/scim/
SCIM endpoints mounted at /scim/v2/:
| Endpoint | Method | Purpose |
|---|---|---|
/scim/v2/Users |
GET | List/search users (with filter support) |
/scim/v2/Users |
POST | Create user |
/scim/v2/Users/:id |
GET | Get user by ID |
/scim/v2/Users/:id |
PUT | Replace user (full update) |
/scim/v2/Users/:id |
PATCH | Update user attributes (partial) |
/scim/v2/Users/:id |
DELETE | Deactivate user |
/scim/v2/Groups |
GET | List groups |
/scim/v2/Groups |
POST | Create group |
/scim/v2/Groups/:id |
GET | Get group |
/scim/v2/Groups/:id |
PATCH | Update group (add/remove members) |
/scim/v2/Groups/:id |
DELETE | Delete group |
/scim/v2/ServiceProviderConfig |
GET | SCIM capabilities |
/scim/v2/Schemas |
GET | Supported schemas |
/scim/v2/ResourceTypes |
GET | Resource types |
2. SCIM Directory Connection Schema
type SCIMConnection struct {
ID string `json:"id" gorm:"primaryKey;type:char(36)"`
OrganizationID string `json:"organization_id" gorm:"type:char(36);uniqueIndex"`
BearerToken string `json:"-" gorm:"type:varchar(256)"` // hashed SCIM bearer token
TokenPrefix string `json:"token_prefix" gorm:"type:varchar(12)"`
IsActive bool `json:"is_active" gorm:"type:bool;default:true"`
LastSyncAt int64 `json:"last_sync_at"`
UserCount int64 `json:"user_count"`
GroupCount int64 `json:"group_count"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"`
UpdatedAt int64 `json:"updated_at" gorm:"autoUpdateTime"`
}Authentication: SCIM endpoints are authenticated via Authorization: Bearer {scim_token}. Each organization gets its own SCIM bearer token. The token is generated when the SCIM connection is created and shown only once.
3. SCIM User Resource Mapping
SCIM → Authorizer attribute mapping:
| SCIM Attribute | Authorizer Field |
|---|---|
userName |
email |
name.givenName |
given_name |
name.familyName |
family_name |
name.middleName |
middle_name |
displayName |
nickname |
emails[primary].value |
email |
phoneNumbers[primary].value |
phone_number |
photos[primary].value |
picture |
active |
is_active (revoke if false) |
externalId |
stored in app_data.scim_external_id |
SCIM User response format:
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:User"],
"id": "user_123",
"externalId": "ext_456",
"userName": "john@example.com",
"name": {
"givenName": "John",
"familyName": "Doe"
},
"emails": [{"value": "john@example.com", "primary": true}],
"active": true,
"meta": {
"resourceType": "User",
"created": "2026-01-01T00:00:00Z",
"lastModified": "2026-03-01T00:00:00Z",
"location": "https://auth.example.com/scim/v2/Users/user_123"
}
}4. SCIM Group → Role Mapping
SCIM Groups map to Authorizer roles within the organization:
type SCIMGroupMapping struct {
ID string `json:"id" gorm:"primaryKey;type:char(36)"`
OrganizationID string `json:"organization_id" gorm:"type:char(36);index"`
SCIMGroupID string `json:"scim_group_id" gorm:"type:varchar(256);index"`
SCIMGroupName string `json:"scim_group_name" gorm:"type:varchar(256)"`
AuthorizerRole string `json:"authorizer_role" gorm:"type:varchar(100)"`
CreatedAt int64 `json:"created_at" gorm:"autoCreateTime"`
}When directory pushes group membership changes:
- User added to group "Engineering" → assign
engineerrole in org - User removed from group → remove role
5. SCIM Filtering Support
SCIM clients send filter queries (RFC 7644 §3.4.2.2):
GET /scim/v2/Users?filter=userName eq "john@example.com"
GET /scim/v2/Users?filter=name.familyName co "Doe"
GET /scim/v2/Users?filter=active eq true
Supported filter operators: eq, ne, co (contains), sw (starts with), pr (present)
Supported attributes: userName, email, name.givenName, name.familyName, active, externalId
Parse filters and translate to storage queries:
func parseSCIMFilter(filter string) (map[string]interface{}, error) {
// Parse "userName eq \"john@example.com\"" into storage query params
}6. SCIM PATCH Operations
SCIM PATCH (RFC 7644 §3.5.2) supports atomic attribute updates:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{"op": "replace", "path": "name.givenName", "value": "Jonathan"},
{"op": "replace", "path": "active", "value": false}
]
}For Groups, PATCH handles membership:
{
"Operations": [
{"op": "add", "path": "members", "value": [{"value": "user_123"}]},
{"op": "remove", "path": "members[value eq \"user_456\"]"}
]
}7. Webhook Events for Provisioning
New webhook events emitted on SCIM operations:
| Event | When |
|---|---|
user.provisioned |
User created via SCIM |
user.deprovisioned |
User deactivated via SCIM |
user.scim_updated |
User attributes updated via SCIM |
group.created |
Group created via SCIM |
group.updated |
Group membership changed via SCIM |
group.deleted |
Group deleted via SCIM |
8. GraphQL Admin API
type SCIMConnection {
id: ID!
organization_id: String!
is_active: Boolean!
last_sync_at: Int64
user_count: Int64!
group_count: Int64!
endpoint_url: String! # SCIM base URL for this org
created_at: Int64!
}
type SCIMConnectionWithToken {
connection: SCIMConnection!
bearer_token: String! # Shown only once
}
type Mutation {
_create_scim_connection(organization_id: ID!): SCIMConnectionWithToken!
_regenerate_scim_token(id: ID!): SCIMConnectionWithToken!
_delete_scim_connection(id: ID!): Response!
}
type Query {
_scim_connections(params: PaginatedInput): SCIMConnections!
_scim_connection(id: ID!): SCIMConnection!
}SCIM Authentication Middleware
func SCIMAuthMiddleware(store storage.Provider) gin.HandlerFunc {
return func(c *gin.Context) {
// Extract org slug from URL path
orgSlug := c.Param("org_slug")
// Validate bearer token
token := extractBearerToken(c)
tokenHash := sha256Hash(token)
conn, err := store.GetSCIMConnectionByOrgSlug(ctx, orgSlug)
if err != nil || conn == nil || conn.BearerToken != tokenHash || !conn.IsActive {
c.AbortWithStatusJSON(401, scimError("unauthorized"))
return
}
c.Set("scim_connection", conn)
c.Set("organization_id", conn.OrganizationID)
c.Next()
}
}SCIM routes mounted with separate middleware (no ClientCheckMiddleware — SCIM has its own auth):
/scim/v2/:org_slug/* → SCIMAuthMiddleware → SCIM handlers
Testing Plan
- Integration tests for each SCIM endpoint (CRUD operations)
- Test SCIM filter parsing and query translation
- Test PATCH operations (add/remove/replace attributes, group members)
- Test user provisioning creates Authorizer user with correct attributes
- Test user deprovisioning revokes access
- Test group-to-role mapping
- Test SCIM bearer token authentication
- Test with Okta SCIM test suite (Okta provides a SCIM compliance tester)
- Test webhook events fired on provisioning operations