Skip to content

RFC: SCIM 2.0 / Directory Sync #513

@lakhansamani

Description

@lakhansamani

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, DeleteUser on 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 engineer role 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

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions