Add SCIM handler draft

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-01-05 10:03:55 +01:00
parent 9eedf7aa1e
commit 31f79fccce
12 changed files with 1634 additions and 6 deletions

91
pkg/iam/scim/errors.go Normal file
View File

@@ -0,0 +1,91 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package scim
import (
"fmt"
"go.probo.inc/probo/pkg/gid"
)
type ErrSCIMConfigurationNotFound struct {
ID gid.GID
}
func (e *ErrSCIMConfigurationNotFound) Error() string {
return fmt.Sprintf("SCIM configuration %s not found", e.ID)
}
func NewSCIMConfigurationNotFoundError(id gid.GID) *ErrSCIMConfigurationNotFound {
return &ErrSCIMConfigurationNotFound{ID: id}
}
type ErrSCIMConfigurationAlreadyExists struct {
OrganizationID gid.GID
}
func (e *ErrSCIMConfigurationAlreadyExists) Error() string {
return fmt.Sprintf("SCIM configuration already exists for organization %s", e.OrganizationID)
}
func NewSCIMConfigurationAlreadyExistsError(organizationID gid.GID) *ErrSCIMConfigurationAlreadyExists {
return &ErrSCIMConfigurationAlreadyExists{OrganizationID: organizationID}
}
type ErrSCIMUserNotFound struct {
ID gid.GID
}
func (e *ErrSCIMUserNotFound) Error() string {
return fmt.Sprintf("SCIM user %s not found", e.ID)
}
func NewSCIMUserNotFoundError(id gid.GID) *ErrSCIMUserNotFound {
return &ErrSCIMUserNotFound{ID: id}
}
type ErrSCIMInvalidToken struct{}
func (e *ErrSCIMInvalidToken) Error() string {
return "invalid SCIM bearer token"
}
func NewSCIMInvalidTokenError() *ErrSCIMInvalidToken {
return &ErrSCIMInvalidToken{}
}
type ErrSCIMInvalidRequest struct {
Detail string
}
func (e *ErrSCIMInvalidRequest) Error() string {
return fmt.Sprintf("invalid SCIM request: %s", e.Detail)
}
func NewSCIMInvalidRequestError(detail string) *ErrSCIMInvalidRequest {
return &ErrSCIMInvalidRequest{Detail: detail}
}
type ErrSCIMUserAlreadyExists struct {
Email string
}
func (e *ErrSCIMUserAlreadyExists) Error() string {
return fmt.Sprintf("user with email %s already exists in this organization", e.Email)
}
func NewSCIMUserAlreadyExistsError(email string) *ErrSCIMUserAlreadyExists {
return &ErrSCIMUserAlreadyExists{Email: email}
}

72
pkg/iam/scim/schema.go Normal file
View File

@@ -0,0 +1,72 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package scim
import (
"github.com/elimity-com/scim/optional"
"github.com/elimity-com/scim/schema"
)
// UserSchema returns the SCIM User schema definition
func UserSchema() schema.Schema {
return schema.Schema{
ID: schema.UserSchema,
Name: optional.NewString("User"),
Description: optional.NewString("User Account"),
Attributes: []schema.CoreAttribute{
schema.SimpleCoreAttribute(schema.SimpleStringParams(schema.StringParams{
Name: "userName",
Required: true,
Uniqueness: schema.AttributeUniquenessServer(),
})),
schema.SimpleCoreAttribute(schema.SimpleStringParams(schema.StringParams{
Name: "displayName",
})),
schema.ComplexCoreAttribute(schema.ComplexParams{
Name: "name",
SubAttributes: []schema.SimpleParams{
schema.SimpleStringParams(schema.StringParams{
Name: "formatted",
}),
schema.SimpleStringParams(schema.StringParams{
Name: "familyName",
}),
schema.SimpleStringParams(schema.StringParams{
Name: "givenName",
}),
},
}),
schema.SimpleCoreAttribute(schema.SimpleBooleanParams(schema.BooleanParams{
Name: "active",
})),
schema.ComplexCoreAttribute(schema.ComplexParams{
Name: "emails",
MultiValued: true,
SubAttributes: []schema.SimpleParams{
schema.SimpleStringParams(schema.StringParams{
Name: "value",
}),
schema.SimpleStringParams(schema.StringParams{
Name: "type",
}),
schema.SimpleBooleanParams(schema.BooleanParams{
Name: "primary",
}),
},
}),
},
}
}

739
pkg/iam/scim/service.go Normal file
View File

@@ -0,0 +1,739 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package scim
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"net"
"strings"
"time"
"github.com/elimity-com/scim"
"github.com/elimity-com/scim/optional"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/mail"
"go.probo.inc/probo/pkg/page"
)
type (
Service struct {
pg *pg.Client
logger *log.Logger
}
)
func NewService(
pg *pg.Client,
logger *log.Logger,
) *Service {
return &Service{
pg: pg,
logger: logger,
}
}
// HashToken creates a FIPS 140 compliant SHA-256 hash of the token
func HashToken(token string) []byte {
hash := sha256.Sum256([]byte(token))
return hash[:]
}
// GenerateToken creates a cryptographically secure random token
func GenerateToken() (string, error) {
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("cannot generate random token: %w", err)
}
return hex.EncodeToString(bytes), nil
}
// ValidateToken validates a bearer token and returns the SCIM configuration
func (s *Service) ValidateToken(ctx context.Context, token string) (*coredata.SCIMConfiguration, error) {
hashedToken := HashToken(token)
config := &coredata.SCIMConfiguration{}
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
err := config.LoadByHashedToken(ctx, conn, hashedToken)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSCIMInvalidTokenError()
}
return fmt.Errorf("cannot load SCIM configuration: %w", err)
}
return nil
})
if err != nil {
return nil, err
}
return config, nil
}
// CreateUser creates a new user via SCIM provisioning
func (s *Service) CreateUser(
ctx context.Context,
config *coredata.SCIMConfiguration,
attributes scim.ResourceAttributes,
ipAddress net.IP,
) (*coredata.Membership, error) {
user := ParseUserFromAttributes(attributes)
email := user.GetPrimaryEmail()
if email == "" {
return nil, NewSCIMInvalidRequestError("userName or email is required")
}
emailAddr, err := mail.ParseAddr(email)
if err != nil {
return nil, NewSCIMInvalidRequestError("invalid email format")
}
fullName := user.GetFullName()
now := time.Now()
var membership *coredata.Membership
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
err = s.pg.WithTx(ctx, func(tx pg.Conn) error {
// Check if identity exists
identity := &coredata.Identity{}
err := identity.LoadByEmail(ctx, tx, emailAddr)
if err == coredata.ErrResourceNotFound {
// Create new identity
identity = &coredata.Identity{
ID: gid.New(gid.NilTenant, coredata.IdentityEntityType),
EmailAddress: emailAddr,
FullName: fullName,
HashedPassword: nil,
EmailAddressVerified: false,
CreatedAt: now,
UpdatedAt: now,
}
err = identity.Insert(ctx, tx)
if err != nil {
return fmt.Errorf("cannot insert identity: %w", err)
}
} else if err != nil {
return fmt.Errorf("cannot load identity: %w", err)
}
// Check if membership exists
membership = &coredata.Membership{}
err = membership.LoadByIdentityAndOrg(ctx, tx, scope, identity.ID, config.OrganizationID)
if err == coredata.ErrResourceNotFound {
// Create new membership
membership = &coredata.Membership{
ID: gid.New(config.OrganizationID.TenantID(), coredata.MembershipEntityType),
IdentityID: identity.ID,
OrganizationID: config.OrganizationID,
Role: coredata.MembershipRoleViewer,
Source: coredata.MembershipSourceSCIM,
CreatedAt: now,
UpdatedAt: now,
}
err = membership.Insert(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot insert membership: %w", err)
}
// Create membership profile
membershipProfile := &coredata.MembershipProfile{
ID: gid.New(membership.ID.TenantID(), coredata.MembershipProfileEntityType),
MembershipID: membership.ID,
FullName: fullName,
CreatedAt: now,
UpdatedAt: now,
}
err = membershipProfile.Insert(ctx, tx)
if err != nil {
return fmt.Errorf("cannot insert membership profile: %w", err)
}
} else if err != nil {
return fmt.Errorf("cannot load membership: %w", err)
} else {
// Update existing membership source to SCIM
membership.Source = coredata.MembershipSourceSCIM
membership.UpdatedAt = now
err = membership.Update(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot update membership: %w", err)
}
}
// Log SCIM event
event := s.createEvent(config, "POST", "/Users", membership.ID, ipAddress, 201, nil)
err = event.Insert(ctx, tx, scope)
if err != nil {
s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err))
}
return nil
})
if err != nil {
return nil, err
}
return membership, nil
}
// GetUser gets a user by membership ID
func (s *Service) GetUser(
ctx context.Context,
config *coredata.SCIMConfiguration,
membershipID gid.GID,
ipAddress net.IP,
) (*coredata.Membership, *coredata.Identity, *coredata.MembershipProfile, error) {
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
var membership *coredata.Membership
var identity *coredata.Identity
var profile *coredata.MembershipProfile
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
membership = &coredata.Membership{}
err := membership.LoadByID(ctx, conn, scope, membershipID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSCIMUserNotFoundError(membershipID)
}
return fmt.Errorf("cannot load membership: %w", err)
}
// Verify membership belongs to this organization
if membership.OrganizationID != config.OrganizationID {
return NewSCIMUserNotFoundError(membershipID)
}
identity = &coredata.Identity{}
err = identity.LoadByID(ctx, conn, membership.IdentityID)
if err != nil {
return fmt.Errorf("cannot load identity: %w", err)
}
profile = &coredata.MembershipProfile{}
err = profile.LoadByMembershipID(ctx, conn, scope, membershipID)
if err != nil && err != coredata.ErrResourceNotFound {
return fmt.Errorf("cannot load membership profile: %w", err)
}
return nil
},
)
if err != nil {
return nil, nil, nil, err
}
return membership, identity, profile, nil
}
// ListUsers lists all users in an organization, with optional filter support
func (s *Service) ListUsers(
ctx context.Context,
config *coredata.SCIMConfiguration,
filter *UserFilter,
startIndex int,
count int,
ipAddress net.IP,
) ([]*coredata.Membership, int, error) {
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
var memberships coredata.Memberships
var totalCount int
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
// If we have a userName filter, query by email directly
if filter != nil && filter.UserName != nil {
emailAddr, err := mail.ParseAddr(*filter.UserName)
if err != nil {
// Invalid email format - return empty result
totalCount = 0
return nil
}
membership := &coredata.Membership{}
err = membership.LoadByEmailAndOrganization(ctx, conn, scope, emailAddr, config.OrganizationID)
if err == coredata.ErrResourceNotFound {
totalCount = 0
return nil
}
if err != nil {
return fmt.Errorf("cannot load membership by email: %w", err)
}
memberships = append(memberships, membership)
totalCount = 1
return nil
}
// No filter - return all memberships with pagination
var err error
totalCount, err = memberships.CountByOrganizationID(ctx, conn, scope, config.OrganizationID)
if err != nil {
return fmt.Errorf("cannot count memberships: %w", err)
}
orderBy := page.OrderBy[coredata.MembershipOrderField]{
Field: coredata.MembershipOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := page.NewCursor(count, nil, page.Head, orderBy)
err = memberships.LoadByOrganizationID(ctx, conn, scope, config.OrganizationID, cursor)
if err != nil {
return fmt.Errorf("cannot load memberships: %w", err)
}
return nil
})
if err != nil {
return nil, 0, err
}
return memberships, totalCount, nil
}
// ReplaceUser replaces a user via SCIM PUT
// Returns the membership, a boolean indicating if user was deactivated, and an error
func (s *Service) ReplaceUser(
ctx context.Context,
config *coredata.SCIMConfiguration,
membershipID gid.GID,
attributes scim.ResourceAttributes,
ipAddress net.IP,
) (*coredata.Membership, bool, error) {
user := ParseUserFromReplaceAttributes(attributes)
return s.updateUser(ctx, config, membershipID, user, "PUT", ipAddress)
}
// PatchUser patches a user via SCIM PATCH
// Returns the membership, a boolean indicating if user was deactivated, and an error
func (s *Service) PatchUser(
ctx context.Context,
config *coredata.SCIMConfiguration,
membershipID gid.GID,
operations []scim.PatchOperation,
ipAddress net.IP,
) (*coredata.Membership, bool, error) {
user := ParseUserFromPatchOperations(operations)
return s.updateUser(ctx, config, membershipID, user, "PATCH", ipAddress)
}
func (s *Service) updateUser(
ctx context.Context,
config *coredata.SCIMConfiguration,
membershipID gid.GID,
user *User,
method string,
ipAddress net.IP,
) (*coredata.Membership, bool, error) {
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
now := time.Now()
var membership *coredata.Membership
var deactivated bool
err := s.pg.WithTx(ctx, func(tx pg.Conn) error {
membership = &coredata.Membership{}
err := membership.LoadByID(ctx, tx, scope, membershipID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSCIMUserNotFoundError(membershipID)
}
return fmt.Errorf("cannot load membership: %w", err)
}
// Verify membership belongs to this organization
if membership.OrganizationID != config.OrganizationID {
return NewSCIMUserNotFoundError(membershipID)
}
// Handle deactivation - Okta sends PATCH with active=false to deprovision users
if user.Active != nil && !*user.Active {
err = membership.Delete(ctx, tx, scope, membershipID)
if err != nil {
return fmt.Errorf("cannot delete membership: %w", err)
}
deactivated = true
// Log SCIM event for deactivation
event := s.createEvent(config, method, fmt.Sprintf("/Users/%s", membershipID), membershipID, ipAddress, 200, nil)
err = event.Insert(ctx, tx, scope)
if err != nil {
s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err))
}
return nil
}
// Update membership source to SCIM if not already
if membership.Source != coredata.MembershipSourceSCIM {
membership.Source = coredata.MembershipSourceSCIM
membership.UpdatedAt = now
err = membership.Update(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot update membership: %w", err)
}
}
// Update membership profile
profile := &coredata.MembershipProfile{}
err = profile.LoadByMembershipID(ctx, tx, scope, membershipID)
if err == nil {
fullName := user.GetFullName()
if fullName != "" {
profile.FullName = fullName
profile.UpdatedAt = now
err = profile.Update(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot update membership profile: %w", err)
}
}
}
// Log SCIM event
event := s.createEvent(config, method, fmt.Sprintf("/Users/%s", membershipID), membership.ID, ipAddress, 200, nil)
err = event.Insert(ctx, tx, scope)
if err != nil {
s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err))
}
return nil
})
if err != nil {
return nil, false, err
}
return membership, deactivated, nil
}
// DeleteUser removes a user's membership from the organization
func (s *Service) DeleteUser(
ctx context.Context,
config *coredata.SCIMConfiguration,
membershipID gid.GID,
ipAddress net.IP,
) error {
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
return s.pg.WithTx(ctx, func(tx pg.Conn) error {
membership := &coredata.Membership{}
err := membership.LoadByID(ctx, tx, scope, membershipID)
if err != nil {
if err == coredata.ErrResourceNotFound {
return NewSCIMUserNotFoundError(membershipID)
}
return fmt.Errorf("cannot load membership: %w", err)
}
// Verify membership belongs to this organization
if membership.OrganizationID != config.OrganizationID {
return NewSCIMUserNotFoundError(membershipID)
}
err = membership.Delete(ctx, tx, scope, membershipID)
if err != nil {
return fmt.Errorf("cannot delete membership: %w", err)
}
// Log SCIM event
event := s.createEvent(config, "DELETE", fmt.Sprintf("/Users/%s", membershipID), membershipID, ipAddress, 204, nil)
err = event.Insert(ctx, tx, scope)
if err != nil {
s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err))
}
return nil
})
}
// LogEvent logs a SCIM event
func (s *Service) LogEvent(
ctx context.Context,
config *coredata.SCIMConfiguration,
method string,
path string,
membershipID *gid.GID,
ipAddress net.IP,
statusCode int,
errorMessage *string,
) {
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
var mID gid.GID
if membershipID != nil {
mID = *membershipID
}
event := s.createEvent(config, method, path, mID, ipAddress, statusCode, errorMessage)
err := s.pg.WithConn(ctx, func(conn pg.Conn) error {
return event.Insert(ctx, conn, scope)
})
if err != nil {
s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err))
}
}
func (s *Service) createEvent(
config *coredata.SCIMConfiguration,
method string,
path string,
membershipID gid.GID,
ipAddress net.IP,
statusCode int,
errorMessage *string,
) *coredata.SCIMEvent {
event := &coredata.SCIMEvent{
ID: gid.New(config.OrganizationID.TenantID(), coredata.SCIMEventEntityType),
OrganizationID: config.OrganizationID,
SCIMConfigurationID: config.ID,
Method: method,
Path: path,
StatusCode: statusCode,
ErrorMessage: errorMessage,
IPAddress: ipAddress,
CreatedAt: time.Now(),
}
if membershipID != gid.Nil {
event.MembershipID = &membershipID
}
return event
}
// ParseUserFromAttributes extracts a User from SCIM resource attributes
func ParseUserFromAttributes(attributes scim.ResourceAttributes) *User {
userName, _ := attributes["userName"].(string)
displayName, _ := attributes["displayName"].(string)
var givenName, familyName string
if name, ok := attributes["name"].(map[string]interface{}); ok {
givenName, _ = name["givenName"].(string)
familyName, _ = name["familyName"].(string)
}
// Get email from emails array or use userName
email := userName
if emails, ok := attributes["emails"].([]interface{}); ok && len(emails) > 0 {
for _, e := range emails {
if emailMap, ok := e.(map[string]interface{}); ok {
if primary, _ := emailMap["primary"].(bool); primary {
if value, ok := emailMap["value"].(string); ok {
email = value
break
}
}
}
}
// If no primary email found, use the first one
if email == userName {
if emailMap, ok := emails[0].(map[string]interface{}); ok {
if value, ok := emailMap["value"].(string); ok {
email = value
}
}
}
}
// Build full name
fullName := displayName
if fullName == "" {
fullName = strings.TrimSpace(givenName + " " + familyName)
}
if fullName == "" {
fullName = userName
}
user := &User{
UserName: userName,
DisplayName: displayName,
Name: &Name{
GivenName: givenName,
FamilyName: familyName,
Formatted: fullName,
},
Emails: []Email{
{
Value: email,
Type: "work",
Primary: true,
},
},
}
return user
}
// ParseUserFromReplaceAttributes extracts a User from SCIM replace attributes
func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) *User {
displayName, _ := attributes["displayName"].(string)
var givenName, familyName string
if name, ok := attributes["name"].(map[string]interface{}); ok {
givenName, _ = name["givenName"].(string)
familyName, _ = name["familyName"].(string)
}
fullName := displayName
if fullName == "" {
fullName = strings.TrimSpace(givenName + " " + familyName)
}
active := true
if a, ok := attributes["active"].(bool); ok {
active = a
}
return &User{
DisplayName: fullName,
Active: &active,
Name: &Name{
GivenName: givenName,
FamilyName: familyName,
Formatted: fullName,
},
}
}
// ParseUserFromPatchOperations extracts a User from SCIM patch operations
func ParseUserFromPatchOperations(operations []scim.PatchOperation) *User {
user := &User{}
for _, op := range operations {
if strings.EqualFold(op.Op, "replace") || strings.EqualFold(op.Op, "add") {
path := ""
if op.Path != nil {
path = op.Path.String()
}
switch strings.ToLower(path) {
case "active":
if active, ok := op.Value.(bool); ok {
user.Active = &active
}
case "displayname":
if name, ok := op.Value.(string); ok {
user.DisplayName = name
}
case "name.givenname":
if user.Name == nil {
user.Name = &Name{}
}
if name, ok := op.Value.(string); ok {
user.Name.GivenName = name
}
case "name.familyname":
if user.Name == nil {
user.Name = &Name{}
}
if name, ok := op.Value.(string); ok {
user.Name.FamilyName = name
}
}
}
}
return user
}
// MembershipToResource converts a Membership to a SCIM resource
func MembershipToResource(m *coredata.Membership) scim.Resource {
return MembershipToResourceWithActive(m, true)
}
// MembershipToResourceWithActive converts a Membership to a SCIM resource with a custom active state
func MembershipToResourceWithActive(m *coredata.Membership, active bool) scim.Resource {
created := m.CreatedAt
modified := m.UpdatedAt
return scim.Resource{
ID: m.ID.String(),
ExternalID: optional.NewString(m.ID.String()),
Attributes: scim.ResourceAttributes{
"userName": m.EmailAddress.String(),
"displayName": m.FullName,
"active": active,
"name": map[string]interface{}{
"formatted": m.FullName,
},
"emails": []map[string]interface{}{
{
"value": m.EmailAddress.String(),
"type": "work",
"primary": true,
},
},
},
Meta: scim.Meta{
Created: &created,
LastModified: &modified,
},
}
}
// MembershipToResourceFull converts a Membership with full identity and profile to a SCIM resource
func MembershipToResourceFull(m *coredata.Membership, identity *coredata.Identity, profile *coredata.MembershipProfile) scim.Resource {
fullName := identity.FullName
if profile != nil && profile.FullName != "" {
fullName = profile.FullName
}
created := m.CreatedAt
modified := m.UpdatedAt
return scim.Resource{
ID: m.ID.String(),
ExternalID: optional.NewString(m.ID.String()),
Attributes: scim.ResourceAttributes{
"userName": identity.EmailAddress.String(),
"displayName": fullName,
"active": true,
"name": map[string]interface{}{
"formatted": fullName,
},
"emails": []map[string]interface{}{
{
"value": identity.EmailAddress.String(),
"type": "work",
"primary": true,
},
},
},
Meta: scim.Meta{
Created: &created,
LastModified: &modified,
},
}
}

333
pkg/iam/scim/types.go Normal file
View File

@@ -0,0 +1,333 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package scim
import (
"fmt"
"strings"
scimfilter "github.com/scim2/filter-parser/v2"
)
// UserFilter represents filter criteria for listing SCIM users
type UserFilter struct {
// UserName filters by userName (email) with exact match
UserName *string
}
// ErrUnsupportedFilter is returned when a SCIM filter uses unsupported operators or attributes
type ErrUnsupportedFilter struct {
Reason string
}
func (e *ErrUnsupportedFilter) Error() string {
return fmt.Sprintf("unsupported filter: %s", e.Reason)
}
// ParseUserFilter converts a SCIM filter AST expression to a UserFilter.
// Returns (nil, nil) if no filter is provided.
// Returns an error if the filter uses unsupported operators or attributes.
func ParseUserFilter(expr scimfilter.Expression) (*UserFilter, error) {
if expr == nil {
return nil, nil
}
filter := &UserFilter{}
switch e := expr.(type) {
case *scimfilter.AttributeExpression:
if err := parseAttributeExpression(e, filter); err != nil {
return nil, err
}
case *scimfilter.LogicalExpression:
if e.Operator != scimfilter.AND {
return nil, &ErrUnsupportedFilter{Reason: fmt.Sprintf("logical operator '%s' is not supported, only 'and' is supported", e.Operator)}
}
if err := parseLogicalExpression(e, filter); err != nil {
return nil, err
}
case *scimfilter.NotExpression:
return nil, &ErrUnsupportedFilter{Reason: "NOT expressions are not supported"}
case *scimfilter.ValuePath:
return nil, &ErrUnsupportedFilter{Reason: "value path expressions are not supported"}
default:
return nil, &ErrUnsupportedFilter{Reason: "unknown filter expression type"}
}
return filter, nil
}
func parseAttributeExpression(e *scimfilter.AttributeExpression, filter *UserFilter) error {
// Only support "eq" operator
if e.Operator != scimfilter.EQ {
return &ErrUnsupportedFilter{Reason: fmt.Sprintf("operator '%s' is not supported, only 'eq' is supported", e.Operator)}
}
// Get the attribute name (lowercase for comparison)
attrName := strings.ToLower(e.AttributePath.AttributeName)
// Extract the string value
value, ok := e.CompareValue.(string)
if !ok {
return &ErrUnsupportedFilter{Reason: "filter value must be a string"}
}
switch attrName {
case "username":
filter.UserName = &value
default:
return &ErrUnsupportedFilter{Reason: fmt.Sprintf("attribute '%s' is not supported for filtering, only 'userName' is supported", e.AttributePath.AttributeName)}
}
return nil
}
func parseLogicalExpression(e *scimfilter.LogicalExpression, filter *UserFilter) error {
// Process left expression
if left, ok := e.Left.(*scimfilter.AttributeExpression); ok {
if err := parseAttributeExpression(left, filter); err != nil {
return err
}
} else {
return &ErrUnsupportedFilter{Reason: "nested logical expressions are not supported"}
}
// Process right expression
if right, ok := e.Right.(*scimfilter.AttributeExpression); ok {
if err := parseAttributeExpression(right, filter); err != nil {
return err
}
} else {
return &ErrUnsupportedFilter{Reason: "nested logical expressions are not supported"}
}
return nil
}
// SCIM 2.0 User Resource
// https://datatracker.ietf.org/doc/html/rfc7643#section-4.1
type User struct {
Schemas []string `json:"schemas"`
ID string `json:"id,omitempty"`
ExternalID string `json:"externalId,omitempty"`
UserName string `json:"userName"`
Name *Name `json:"name,omitempty"`
DisplayName string `json:"displayName,omitempty"`
Emails []Email `json:"emails,omitempty"`
Active *bool `json:"active,omitempty"`
Meta *Meta `json:"meta,omitempty"`
}
type Name struct {
Formatted string `json:"formatted,omitempty"`
FamilyName string `json:"familyName,omitempty"`
GivenName string `json:"givenName,omitempty"`
MiddleName string `json:"middleName,omitempty"`
HonorificPrefix string `json:"honorificPrefix,omitempty"`
HonorificSuffix string `json:"honorificSuffix,omitempty"`
}
type Email struct {
Value string `json:"value"`
Type string `json:"type,omitempty"`
Primary bool `json:"primary,omitempty"`
}
type Meta struct {
ResourceType string `json:"resourceType,omitempty"`
Created string `json:"created,omitempty"`
LastModified string `json:"lastModified,omitempty"`
Location string `json:"location,omitempty"`
Version string `json:"version,omitempty"`
}
// SCIM 2.0 List Response
// https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2
type ListResponse struct {
Schemas []string `json:"schemas"`
TotalResults int `json:"totalResults"`
StartIndex int `json:"startIndex,omitempty"`
ItemsPerPage int `json:"itemsPerPage,omitempty"`
Resources []User `json:"Resources"`
}
// SCIM 2.0 Error Response
// https://datatracker.ietf.org/doc/html/rfc7644#section-3.12
type ErrorResponse struct {
Schemas []string `json:"schemas"`
Detail string `json:"detail,omitempty"`
Status string `json:"status"`
ScimType string `json:"scimType,omitempty"`
}
// SCIM 2.0 Patch Operation
// https://datatracker.ietf.org/doc/html/rfc7644#section-3.5.2
type PatchOp struct {
Schemas []string `json:"schemas"`
Operations []Operation `json:"Operations"`
}
type Operation struct {
Op string `json:"op"`
Path string `json:"path,omitempty"`
Value interface{} `json:"value,omitempty"`
}
// SCIM 2.0 Service Provider Config
// https://datatracker.ietf.org/doc/html/rfc7643#section-5
type ServiceProviderConfig struct {
Schemas []string `json:"schemas"`
DocumentationUri string `json:"documentationUri,omitempty"`
Patch Supported `json:"patch"`
Bulk BulkSupported `json:"bulk"`
Filter FilterSupported `json:"filter"`
ChangePassword Supported `json:"changePassword"`
Sort Supported `json:"sort"`
Etag Supported `json:"etag"`
AuthenticationSchemes []AuthScheme `json:"authenticationSchemes"`
Meta *Meta `json:"meta,omitempty"`
}
type Supported struct {
Supported bool `json:"supported"`
}
type BulkSupported struct {
Supported bool `json:"supported"`
MaxOperations int `json:"maxOperations"`
MaxPayloadSize int `json:"maxPayloadSize"`
}
type FilterSupported struct {
Supported bool `json:"supported"`
MaxResults int `json:"maxResults"`
}
type AuthScheme struct {
Type string `json:"type"`
Name string `json:"name"`
Description string `json:"description"`
SpecUri string `json:"specUri,omitempty"`
DocumentationUri string `json:"documentationUri,omitempty"`
Primary bool `json:"primary,omitempty"`
}
// SCIM 2.0 Schemas response
type SchemasResponse struct {
Schemas []string `json:"schemas"`
TotalResults int `json:"totalResults"`
Resources []Schema `json:"Resources"`
}
type Schema struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Attributes []SchemaAttribute `json:"attributes,omitempty"`
Meta *Meta `json:"meta,omitempty"`
}
type SchemaAttribute struct {
Name string `json:"name"`
Type string `json:"type"`
MultiValued bool `json:"multiValued"`
Description string `json:"description,omitempty"`
Required bool `json:"required"`
CaseExact bool `json:"caseExact,omitempty"`
Mutability string `json:"mutability,omitempty"`
Returned string `json:"returned,omitempty"`
Uniqueness string `json:"uniqueness,omitempty"`
SubAttributes []SchemaAttribute `json:"subAttributes,omitempty"`
}
// SCIM Schema URIs
const (
SchemaURIUser = "urn:ietf:params:scim:schemas:core:2.0:User"
SchemaURIListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse"
SchemaURIError = "urn:ietf:params:scim:api:messages:2.0:Error"
SchemaURIPatchOp = "urn:ietf:params:scim:api:messages:2.0:PatchOp"
SchemaURIServiceProviderConfig = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"
SchemaURISchema = "urn:ietf:params:scim:schemas:core:2.0:Schema"
)
func NewUser() *User {
return &User{
Schemas: []string{SchemaURIUser},
}
}
func NewListResponse(users []User, totalResults int) *ListResponse {
return &ListResponse{
Schemas: []string{SchemaURIListResponse},
TotalResults: totalResults,
StartIndex: 1,
ItemsPerPage: len(users),
Resources: users,
}
}
func NewErrorResponse(status int, detail string, scimType string) *ErrorResponse {
return &ErrorResponse{
Schemas: []string{SchemaURIError},
Detail: detail,
Status: fmt.Sprintf("%d", status),
ScimType: scimType,
}
}
func (u *User) GetPrimaryEmail() string {
for _, email := range u.Emails {
if email.Primary {
return email.Value
}
}
if len(u.Emails) > 0 {
return u.Emails[0].Value
}
return u.UserName
}
func (u *User) GetFullName() string {
if u.DisplayName != "" {
return u.DisplayName
}
if u.Name != nil {
if u.Name.Formatted != "" {
return u.Name.Formatted
}
parts := []string{}
if u.Name.GivenName != "" {
parts = append(parts, u.Name.GivenName)
}
if u.Name.FamilyName != "" {
parts = append(parts, u.Name.FamilyName)
}
if len(parts) > 0 {
return join(parts, " ")
}
}
return u.UserName
}
func join(parts []string, sep string) string {
if len(parts) == 0 {
return ""
}
result := parts[0]
for i := 1; i < len(parts); i++ {
result += sep + parts[i]
}
return result
}

View File

@@ -16,6 +16,7 @@ import (
"go.probo.inc/probo/pkg/filemanager"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/iam/saml"
"go.probo.inc/probo/pkg/iam/scim"
"golang.org/x/sync/errgroup"
)
@@ -41,6 +42,7 @@ type (
SessionService *SessionService
AuthService *AuthService
SAMLService *saml.Service
SCIMService *scim.Service
APIKeyService *APIKeyService
Authorizer *Authorizer
@@ -119,6 +121,8 @@ func NewService(
}
svc.SAMLService = samlService
svc.SCIMService = scim.NewService(svc.pg, cfg.Logger.Named("scim"))
svc.samlDomainVerifier = NewSAMLDomainVerifier(
pgClient,
cfg.Logger,