Simplify SCIM types/error management

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-01-05 11:14:35 +01:00
parent 3e9df307b8
commit eb4024d878
4 changed files with 105 additions and 436 deletions

View File

@@ -44,18 +44,6 @@ func NewSCIMConfigurationAlreadyExistsError(organizationID gid.GID) *ErrSCIMConf
return &ErrSCIMConfigurationAlreadyExists{OrganizationID: organizationID} 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{} type ErrSCIMInvalidToken struct{}
func (e *ErrSCIMInvalidToken) Error() string { func (e *ErrSCIMInvalidToken) Error() string {
@@ -65,35 +53,3 @@ func (e *ErrSCIMInvalidToken) Error() string {
func NewSCIMInvalidTokenError() *ErrSCIMInvalidToken { func NewSCIMInvalidTokenError() *ErrSCIMInvalidToken {
return &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}
}
type ErrUnsupportedFilter struct {
Reason string
}
func (e *ErrUnsupportedFilter) Error() string {
return fmt.Sprintf("unsupported filter: %s", e.Reason)
}

View File

@@ -25,6 +25,7 @@ import (
"time" "time"
"github.com/elimity-com/scim" "github.com/elimity-com/scim"
scimerrors "github.com/elimity-com/scim/errors"
"github.com/elimity-com/scim/optional" "github.com/elimity-com/scim/optional"
"go.gearno.de/kit/log" "go.gearno.de/kit/log"
"go.gearno.de/kit/pg" "go.gearno.de/kit/pg"
@@ -93,19 +94,19 @@ func (s *Service) CreateUser(
config *coredata.SCIMConfiguration, config *coredata.SCIMConfiguration,
attributes scim.ResourceAttributes, attributes scim.ResourceAttributes,
ipAddress net.IP, ipAddress net.IP,
) (*coredata.Membership, error) { ) (scim.Resource, error) {
user := ParseUserFromAttributes(attributes) user := ParseUserFromAttributes(attributes)
email := user.GetPrimaryEmail() email := user.Email
if email == "" { if email == "" {
return nil, NewSCIMInvalidRequestError("userName or email is required") return scim.Resource{}, scimerrors.ScimErrorBadRequest("userName or email is required")
} }
emailAddr, err := mail.ParseAddr(email) emailAddr, err := mail.ParseAddr(email)
if err != nil { if err != nil {
return nil, NewSCIMInvalidRequestError("invalid email format") return scim.Resource{}, scimerrors.ScimErrorBadRequest("invalid email format")
} }
fullName := user.GetFullName() fullName := user.FullName
now := time.Now() now := time.Now()
var membership *coredata.Membership var membership *coredata.Membership
@@ -195,10 +196,10 @@ func (s *Service) CreateUser(
}) })
if err != nil { if err != nil {
return nil, err return scim.Resource{}, err
} }
return membership, nil return membershipToResource(membership, true), nil
} }
// GetUser gets a user by membership ID // GetUser gets a user by membership ID
@@ -207,12 +208,10 @@ func (s *Service) GetUser(
config *coredata.SCIMConfiguration, config *coredata.SCIMConfiguration,
membershipID gid.GID, membershipID gid.GID,
ipAddress net.IP, ipAddress net.IP,
) (*coredata.Membership, *coredata.Identity, *coredata.MembershipProfile, error) { ) (scim.Resource, error) {
scope := coredata.NewScopeFromObjectID(config.OrganizationID) scope := coredata.NewScopeFromObjectID(config.OrganizationID)
var membership *coredata.Membership var membership *coredata.Membership
var identity *coredata.Identity
var profile *coredata.MembershipProfile
err := s.pg.WithConn( err := s.pg.WithConn(
ctx, ctx,
@@ -221,26 +220,14 @@ func (s *Service) GetUser(
err := membership.LoadByID(ctx, conn, scope, membershipID) err := membership.LoadByID(ctx, conn, scope, membershipID)
if err != nil { if err != nil {
if err == coredata.ErrResourceNotFound { if err == coredata.ErrResourceNotFound {
return NewSCIMUserNotFoundError(membershipID) return scimerrors.ScimErrorResourceNotFound(membershipID.String())
} }
return fmt.Errorf("cannot load membership: %w", err) return fmt.Errorf("cannot load membership: %w", err)
} }
// Verify membership belongs to this organization // Verify membership belongs to this organization
if membership.OrganizationID != config.OrganizationID { if membership.OrganizationID != config.OrganizationID {
return NewSCIMUserNotFoundError(membershipID) return scimerrors.ScimErrorResourceNotFound(membershipID.String())
}
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 return nil
@@ -248,10 +235,10 @@ func (s *Service) GetUser(
) )
if err != nil { if err != nil {
return nil, nil, nil, err return scim.Resource{}, err
} }
return membership, identity, profile, nil return membershipToResource(membership, true), nil
} }
// ListUsers lists all users in an organization, with optional filter support // ListUsers lists all users in an organization, with optional filter support
@@ -262,7 +249,7 @@ func (s *Service) ListUsers(
startIndex int, startIndex int,
count int, count int,
ipAddress net.IP, ipAddress net.IP,
) ([]*coredata.Membership, int, error) { ) ([]scim.Resource, int, error) {
scope := coredata.NewScopeFromObjectID(config.OrganizationID) scope := coredata.NewScopeFromObjectID(config.OrganizationID)
var memberships coredata.Memberships var memberships coredata.Memberships
@@ -318,33 +305,44 @@ func (s *Service) ListUsers(
return nil, 0, err return nil, 0, err
} }
return memberships, totalCount, nil resources := make([]scim.Resource, 0, len(memberships))
for _, m := range memberships {
resources = append(resources, membershipToResource(m, true))
}
return resources, totalCount, nil
} }
// ReplaceUser replaces a user via SCIM PUT // ReplaceUser replaces a user via SCIM PUT
// Returns the membership, a boolean indicating if user was deactivated, and an error
func (s *Service) ReplaceUser( func (s *Service) ReplaceUser(
ctx context.Context, ctx context.Context,
config *coredata.SCIMConfiguration, config *coredata.SCIMConfiguration,
membershipID gid.GID, membershipID gid.GID,
attributes scim.ResourceAttributes, attributes scim.ResourceAttributes,
ipAddress net.IP, ipAddress net.IP,
) (*coredata.Membership, bool, error) { ) (scim.Resource, error) {
user := ParseUserFromReplaceAttributes(attributes) user := ParseUserFromReplaceAttributes(attributes)
return s.updateUser(ctx, config, membershipID, user, "PUT", ipAddress) membership, deactivated, err := s.updateUser(ctx, config, membershipID, user, "PUT", ipAddress)
if err != nil {
return scim.Resource{}, err
}
return membershipToResource(membership, !deactivated), nil
} }
// PatchUser patches a user via SCIM PATCH // PatchUser patches a user via SCIM PATCH
// Returns the membership, a boolean indicating if user was deactivated, and an error
func (s *Service) PatchUser( func (s *Service) PatchUser(
ctx context.Context, ctx context.Context,
config *coredata.SCIMConfiguration, config *coredata.SCIMConfiguration,
membershipID gid.GID, membershipID gid.GID,
operations []scim.PatchOperation, operations []scim.PatchOperation,
ipAddress net.IP, ipAddress net.IP,
) (*coredata.Membership, bool, error) { ) (scim.Resource, error) {
user := ParseUserFromPatchOperations(operations) user := ParseUserFromPatchOperations(operations)
return s.updateUser(ctx, config, membershipID, user, "PATCH", ipAddress) membership, deactivated, err := s.updateUser(ctx, config, membershipID, user, "PATCH", ipAddress)
if err != nil {
return scim.Resource{}, err
}
return membershipToResource(membership, !deactivated), nil
} }
func (s *Service) updateUser( func (s *Service) updateUser(
@@ -366,14 +364,14 @@ func (s *Service) updateUser(
err := membership.LoadByID(ctx, tx, scope, membershipID) err := membership.LoadByID(ctx, tx, scope, membershipID)
if err != nil { if err != nil {
if err == coredata.ErrResourceNotFound { if err == coredata.ErrResourceNotFound {
return NewSCIMUserNotFoundError(membershipID) return scimerrors.ScimErrorResourceNotFound(membershipID.String())
} }
return fmt.Errorf("cannot load membership: %w", err) return fmt.Errorf("cannot load membership: %w", err)
} }
// Verify membership belongs to this organization // Verify membership belongs to this organization
if membership.OrganizationID != config.OrganizationID { if membership.OrganizationID != config.OrganizationID {
return NewSCIMUserNotFoundError(membershipID) return scimerrors.ScimErrorResourceNotFound(membershipID.String())
} }
// Handle deactivation - Okta sends PATCH with active=false to deprovision users // Handle deactivation - Okta sends PATCH with active=false to deprovision users
@@ -410,7 +408,7 @@ func (s *Service) updateUser(
profile := &coredata.MembershipProfile{} profile := &coredata.MembershipProfile{}
err = profile.LoadByMembershipID(ctx, tx, scope, membershipID) err = profile.LoadByMembershipID(ctx, tx, scope, membershipID)
if err == nil { if err == nil {
fullName := user.GetFullName() fullName := user.FullName
if fullName != "" { if fullName != "" {
profile.FullName = fullName profile.FullName = fullName
profile.UpdatedAt = now profile.UpdatedAt = now
@@ -453,14 +451,14 @@ func (s *Service) DeleteUser(
err := membership.LoadByID(ctx, tx, scope, membershipID) err := membership.LoadByID(ctx, tx, scope, membershipID)
if err != nil { if err != nil {
if err == coredata.ErrResourceNotFound { if err == coredata.ErrResourceNotFound {
return NewSCIMUserNotFoundError(membershipID) return scimerrors.ScimErrorResourceNotFound(membershipID.String())
} }
return fmt.Errorf("cannot load membership: %w", err) return fmt.Errorf("cannot load membership: %w", err)
} }
// Verify membership belongs to this organization // Verify membership belongs to this organization
if membership.OrganizationID != config.OrganizationID { if membership.OrganizationID != config.OrganizationID {
return NewSCIMUserNotFoundError(membershipID) return scimerrors.ScimErrorResourceNotFound(membershipID.String())
} }
err = membership.Delete(ctx, tx, scope, membershipID) err = membership.Delete(ctx, tx, scope, membershipID)
@@ -537,6 +535,7 @@ func (s *Service) createEvent(
} }
// ParseUserFromAttributes extracts a User from SCIM resource attributes // ParseUserFromAttributes extracts a User from SCIM resource attributes
// ParseUserFromAttributes extracts user data from SCIM create attributes
func ParseUserFromAttributes(attributes scim.ResourceAttributes) *User { func ParseUserFromAttributes(attributes scim.ResourceAttributes) *User {
userName, _ := attributes["userName"].(string) userName, _ := attributes["userName"].(string)
displayName, _ := attributes["displayName"].(string) displayName, _ := attributes["displayName"].(string)
@@ -570,7 +569,7 @@ func ParseUserFromAttributes(attributes scim.ResourceAttributes) *User {
} }
} }
// Build full name // Build full name: prefer displayName, then given+family, then userName
fullName := displayName fullName := displayName
if fullName == "" { if fullName == "" {
fullName = strings.TrimSpace(givenName + " " + familyName) fullName = strings.TrimSpace(givenName + " " + familyName)
@@ -579,26 +578,13 @@ func ParseUserFromAttributes(attributes scim.ResourceAttributes) *User {
fullName = userName fullName = userName
} }
user := &User{ return &User{
UserName: userName, Email: email,
DisplayName: displayName, FullName: fullName,
Name: &Name{ }
GivenName: givenName,
FamilyName: familyName,
Formatted: fullName,
},
Emails: []Email{
{
Value: email,
Primary: true,
},
},
} }
return user // ParseUserFromReplaceAttributes extracts user data from SCIM replace (PUT) attributes
}
// ParseUserFromReplaceAttributes extracts a User from SCIM replace attributes
func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) *User { func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) *User {
displayName, _ := attributes["displayName"].(string) displayName, _ := attributes["displayName"].(string)
@@ -619,19 +605,16 @@ func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) *User {
} }
return &User{ return &User{
DisplayName: fullName, FullName: fullName,
Active: &active, Active: &active,
Name: &Name{
GivenName: givenName,
FamilyName: familyName,
Formatted: fullName,
},
} }
} }
// ParseUserFromPatchOperations extracts a User from SCIM patch operations // ParseUserFromPatchOperations extracts user data from SCIM patch operations
func ParseUserFromPatchOperations(operations []scim.PatchOperation) *User { func ParseUserFromPatchOperations(operations []scim.PatchOperation) *User {
user := &User{} user := &User{}
var givenName, familyName string
for _, op := range operations { for _, op := range operations {
if strings.EqualFold(op.Op, "replace") || strings.EqualFold(op.Op, "add") { if strings.EqualFold(op.Op, "replace") || strings.EqualFold(op.Op, "add") {
path := "" path := ""
@@ -645,37 +628,29 @@ func ParseUserFromPatchOperations(operations []scim.PatchOperation) *User {
} }
case "displayname": case "displayname":
if name, ok := op.Value.(string); ok { if name, ok := op.Value.(string); ok {
user.DisplayName = name user.FullName = name
} }
case "name.givenname": case "name.givenname":
if user.Name == nil {
user.Name = &Name{}
}
if name, ok := op.Value.(string); ok { if name, ok := op.Value.(string); ok {
user.Name.GivenName = name givenName = name
} }
case "name.familyname": case "name.familyname":
if user.Name == nil {
user.Name = &Name{}
}
if name, ok := op.Value.(string); ok { if name, ok := op.Value.(string); ok {
user.Name.FamilyName = name familyName = name
} }
} }
} }
} }
// If no displayName was set but we have name parts, build full name
if user.FullName == "" && (givenName != "" || familyName != "") {
user.FullName = strings.TrimSpace(givenName + " " + familyName)
}
return user return user
} }
// MembershipToResource converts a Membership to a SCIM resource func membershipToResource(m *coredata.Membership, active bool) 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{ return scim.Resource{
ID: m.ID.String(), ID: m.ID.String(),
ExternalID: optional.NewString(m.ID.String()), ExternalID: optional.NewString(m.ID.String()),
@@ -695,42 +670,8 @@ func MembershipToResourceWithActive(m *coredata.Membership, active bool) scim.Re
}, },
}, },
Meta: scim.Meta{ Meta: scim.Meta{
Created: &created, Created: &m.CreatedAt,
LastModified: &modified, LastModified: &m.UpdatedAt,
},
}
}
// 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,
}, },
} }
} }

View File

@@ -18,6 +18,7 @@ import (
"fmt" "fmt"
"strings" "strings"
scimerrors "github.com/elimity-com/scim/errors"
scimfilter "github.com/scim2/filter-parser/v2" scimfilter "github.com/scim2/filter-parser/v2"
) )
@@ -44,17 +45,17 @@ func ParseUserFilter(expr scimfilter.Expression) (*UserFilter, error) {
} }
case *scimfilter.LogicalExpression: case *scimfilter.LogicalExpression:
if e.Operator != scimfilter.AND { if e.Operator != scimfilter.AND {
return nil, &ErrUnsupportedFilter{Reason: fmt.Sprintf("logical operator '%s' is not supported, only 'and' is supported", e.Operator)} return nil, scimerrors.ScimErrorBadRequest(fmt.Sprintf("logical operator '%s' is not supported, only 'and' is supported", e.Operator))
} }
if err := parseLogicalExpression(e, filter); err != nil { if err := parseLogicalExpression(e, filter); err != nil {
return nil, err return nil, err
} }
case *scimfilter.NotExpression: case *scimfilter.NotExpression:
return nil, &ErrUnsupportedFilter{Reason: "NOT expressions are not supported"} return nil, scimerrors.ScimErrorBadRequest("NOT expressions are not supported")
case *scimfilter.ValuePath: case *scimfilter.ValuePath:
return nil, &ErrUnsupportedFilter{Reason: "value path expressions are not supported"} return nil, scimerrors.ScimErrorBadRequest("value path expressions are not supported")
default: default:
return nil, &ErrUnsupportedFilter{Reason: "unknown filter expression type"} return nil, scimerrors.ScimErrorBadRequest("unknown filter expression type")
} }
return filter, nil return filter, nil
@@ -63,7 +64,7 @@ func ParseUserFilter(expr scimfilter.Expression) (*UserFilter, error) {
func parseAttributeExpression(e *scimfilter.AttributeExpression, filter *UserFilter) error { func parseAttributeExpression(e *scimfilter.AttributeExpression, filter *UserFilter) error {
// Only support "eq" operator // Only support "eq" operator
if e.Operator != scimfilter.EQ { if e.Operator != scimfilter.EQ {
return &ErrUnsupportedFilter{Reason: fmt.Sprintf("operator '%s' is not supported, only 'eq' is supported", e.Operator)} return scimerrors.ScimErrorBadRequest(fmt.Sprintf("operator '%s' is not supported, only 'eq' is supported", e.Operator))
} }
// Get the attribute name (lowercase for comparison) // Get the attribute name (lowercase for comparison)
@@ -72,14 +73,14 @@ func parseAttributeExpression(e *scimfilter.AttributeExpression, filter *UserFil
// Extract the string value // Extract the string value
value, ok := e.CompareValue.(string) value, ok := e.CompareValue.(string)
if !ok { if !ok {
return &ErrUnsupportedFilter{Reason: "filter value must be a string"} return scimerrors.ScimErrorBadRequest("filter value must be a string")
} }
switch attrName { switch attrName {
case "username": case "username":
filter.UserName = &value filter.UserName = &value
default: default:
return &ErrUnsupportedFilter{Reason: fmt.Sprintf("attribute '%s' is not supported for filtering, only 'userName' is supported", e.AttributePath.AttributeName)} return scimerrors.ScimErrorBadRequest(fmt.Sprintf("attribute '%s' is not supported for filtering, only 'userName' is supported", e.AttributePath.AttributeName))
} }
return nil return nil
@@ -92,7 +93,7 @@ func parseLogicalExpression(e *scimfilter.LogicalExpression, filter *UserFilter)
return err return err
} }
} else { } else {
return &ErrUnsupportedFilter{Reason: "nested logical expressions are not supported"} return scimerrors.ScimErrorBadRequest("nested logical expressions are not supported")
} }
// Process right expression // Process right expression
@@ -101,224 +102,15 @@ func parseLogicalExpression(e *scimfilter.LogicalExpression, filter *UserFilter)
return err return err
} }
} else { } else {
return &ErrUnsupportedFilter{Reason: "nested logical expressions are not supported"} return scimerrors.ScimErrorBadRequest("nested logical expressions are not supported")
} }
return nil return nil
} }
// SCIM 2.0 User Resource // User represents parsed SCIM user attributes with extracted values.
// https://datatracker.ietf.org/doc/html/rfc7643#section-4.1
type User struct { type User struct {
Schemas []string `json:"schemas"` Email string
ID string `json:"id,omitempty"` FullName string
ExternalID string `json:"externalId,omitempty"` Active *bool
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

@@ -131,24 +131,17 @@ func (h *scimResourceHandler) Create(r *http.Request, attributes scim.ResourceAt
ctx := r.Context() ctx := r.Context()
config := scimConfigFromContext(ctx) config := scimConfigFromContext(ctx)
membership, err := h.handler.iam.SCIMService.CreateUser(ctx, config, attributes, getIPAddress(r)) resource, err := h.handler.iam.SCIMService.CreateUser(ctx, config, attributes, getIPAddress(r))
if err != nil { if err != nil {
var invalidReq *scimservice.ErrSCIMInvalidRequest var scimErr scimerrors.ScimError
var alreadyExists *scimservice.ErrSCIMUserAlreadyExists if errors.As(err, &scimErr) {
return scim.Resource{}, err
if errors.As(err, &invalidReq) {
return scim.Resource{}, scimerrors.ScimErrorBadRequest(invalidReq.Detail)
} }
if errors.As(err, &alreadyExists) {
return scim.Resource{}, scimerrors.ScimErrorUniqueness
}
h.handler.logger.ErrorCtx(ctx, "cannot create user", log.Error(err)) h.handler.logger.ErrorCtx(ctx, "cannot create user", log.Error(err))
return scim.Resource{}, scimerrors.ScimErrorInternal return scim.Resource{}, scimerrors.ScimErrorInternal
} }
return scimservice.MembershipToResource(membership), nil return resource, nil
} }
func (h *scimResourceHandler) Get(r *http.Request, id string) (scim.Resource, error) { func (h *scimResourceHandler) Get(r *http.Request, id string) (scim.Resource, error) {
@@ -160,18 +153,17 @@ func (h *scimResourceHandler) Get(r *http.Request, id string) (scim.Resource, er
return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id) return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id)
} }
membership, identity, profile, err := h.handler.iam.SCIMService.GetUser(ctx, config, membershipID, getIPAddress(r)) resource, err := h.handler.iam.SCIMService.GetUser(ctx, config, membershipID, getIPAddress(r))
if err != nil { if err != nil {
var notFound *scimservice.ErrSCIMUserNotFound var scimErr scimerrors.ScimError
if errors.As(err, &notFound) { if errors.As(err, &scimErr) {
return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id) return scim.Resource{}, err
} }
h.handler.logger.ErrorCtx(ctx, "cannot get user", log.Error(err)) h.handler.logger.ErrorCtx(ctx, "cannot get user", log.Error(err))
return scim.Resource{}, scimerrors.ScimErrorInternal return scim.Resource{}, scimerrors.ScimErrorInternal
} }
return scimservice.MembershipToResourceFull(membership, identity, profile), nil return resource, nil
} }
func (h *scimResourceHandler) GetAll(r *http.Request, params scim.ListRequestParams) (scim.Page, error) { func (h *scimResourceHandler) GetAll(r *http.Request, params scim.ListRequestParams) (scim.Page, error) {
@@ -185,25 +177,24 @@ func (h *scimResourceHandler) GetAll(r *http.Request, params scim.ListRequestPar
// Parse SCIM filter AST into our filter type // Parse SCIM filter AST into our filter type
filter, err := scimservice.ParseUserFilter(params.FilterValidator.GetFilter()) filter, err := scimservice.ParseUserFilter(params.FilterValidator.GetFilter())
if err != nil { if err != nil {
var unsupportedFilter *scimservice.ErrUnsupportedFilter var scimErr scimerrors.ScimError
if errors.As(err, &unsupportedFilter) { if errors.As(err, &scimErr) {
return scim.Page{}, scimerrors.ScimErrorBadRequest(err.Error()) return scim.Page{}, err
} }
h.handler.logger.ErrorCtx(ctx, "cannot parse filter", log.Error(err)) h.handler.logger.ErrorCtx(ctx, "cannot parse filter", log.Error(err))
return scim.Page{}, scimerrors.ScimErrorInternal return scim.Page{}, scimerrors.ScimErrorInternal
} }
memberships, totalCount, err := h.handler.iam.SCIMService.ListUsers(ctx, config, filter, params.StartIndex, params.Count, getIPAddress(r)) resources, totalCount, err := h.handler.iam.SCIMService.ListUsers(ctx, config, filter, params.StartIndex, params.Count, getIPAddress(r))
if err != nil { if err != nil {
var scimErr scimerrors.ScimError
if errors.As(err, &scimErr) {
return scim.Page{}, err
}
h.handler.logger.ErrorCtx(ctx, "cannot list users", log.Error(err)) h.handler.logger.ErrorCtx(ctx, "cannot list users", log.Error(err))
return scim.Page{}, scimerrors.ScimErrorInternal return scim.Page{}, scimerrors.ScimErrorInternal
} }
resources := make([]scim.Resource, 0, len(memberships))
for _, m := range memberships {
resources = append(resources, scimservice.MembershipToResource(m))
}
return scim.Page{ return scim.Page{
TotalResults: totalCount, TotalResults: totalCount,
Resources: resources, Resources: resources,
@@ -219,22 +210,17 @@ func (h *scimResourceHandler) Replace(r *http.Request, id string, attributes sci
return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id) return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id)
} }
membership, deactivated, err := h.handler.iam.SCIMService.ReplaceUser(ctx, config, membershipID, attributes, getIPAddress(r)) resource, err := h.handler.iam.SCIMService.ReplaceUser(ctx, config, membershipID, attributes, getIPAddress(r))
if err != nil { if err != nil {
var notFound *scimservice.ErrSCIMUserNotFound var scimErr scimerrors.ScimError
if errors.As(err, &notFound) { if errors.As(err, &scimErr) {
return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id) return scim.Resource{}, err
} }
h.handler.logger.ErrorCtx(ctx, "cannot update user", log.Error(err)) h.handler.logger.ErrorCtx(ctx, "cannot update user", log.Error(err))
return scim.Resource{}, scimerrors.ScimErrorInternal return scim.Resource{}, scimerrors.ScimErrorInternal
} }
if deactivated { return resource, nil
return scimservice.MembershipToResourceWithActive(membership, false), nil
}
return scimservice.MembershipToResource(membership), nil
} }
func (h *scimResourceHandler) Patch(r *http.Request, id string, operations []scim.PatchOperation) (scim.Resource, error) { func (h *scimResourceHandler) Patch(r *http.Request, id string, operations []scim.PatchOperation) (scim.Resource, error) {
@@ -246,22 +232,17 @@ func (h *scimResourceHandler) Patch(r *http.Request, id string, operations []sci
return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id) return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id)
} }
membership, deactivated, err := h.handler.iam.SCIMService.PatchUser(ctx, config, membershipID, operations, getIPAddress(r)) resource, err := h.handler.iam.SCIMService.PatchUser(ctx, config, membershipID, operations, getIPAddress(r))
if err != nil { if err != nil {
var notFound *scimservice.ErrSCIMUserNotFound var scimErr scimerrors.ScimError
if errors.As(err, &notFound) { if errors.As(err, &scimErr) {
return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id) return scim.Resource{}, err
} }
h.handler.logger.ErrorCtx(ctx, "cannot patch user", log.Error(err)) h.handler.logger.ErrorCtx(ctx, "cannot patch user", log.Error(err))
return scim.Resource{}, scimerrors.ScimErrorInternal return scim.Resource{}, scimerrors.ScimErrorInternal
} }
if deactivated { return resource, nil
return scimservice.MembershipToResourceWithActive(membership, false), nil
}
return scimservice.MembershipToResource(membership), nil
} }
func (h *scimResourceHandler) Delete(r *http.Request, id string) error { func (h *scimResourceHandler) Delete(r *http.Request, id string) error {
@@ -275,11 +256,10 @@ func (h *scimResourceHandler) Delete(r *http.Request, id string) error {
err = h.handler.iam.SCIMService.DeleteUser(ctx, config, membershipID, getIPAddress(r)) err = h.handler.iam.SCIMService.DeleteUser(ctx, config, membershipID, getIPAddress(r))
if err != nil { if err != nil {
var notFound *scimservice.ErrSCIMUserNotFound var scimErr scimerrors.ScimError
if errors.As(err, &notFound) { if errors.As(err, &scimErr) {
return scimerrors.ScimErrorResourceNotFound(id) return err
} }
h.handler.logger.ErrorCtx(ctx, "cannot delete user", log.Error(err)) h.handler.logger.ErrorCtx(ctx, "cannot delete user", log.Error(err))
return scimerrors.ScimErrorInternal return scimerrors.ScimErrorInternal
} }