Simplify SCIM types/error management
Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
@@ -44,18 +44,6 @@ func NewSCIMConfigurationAlreadyExistsError(organizationID gid.GID) *ErrSCIMConf
|
||||
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 {
|
||||
@@ -65,35 +53,3 @@ func (e *ErrSCIMInvalidToken) Error() string {
|
||||
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}
|
||||
}
|
||||
|
||||
type ErrUnsupportedFilter struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *ErrUnsupportedFilter) Error() string {
|
||||
return fmt.Sprintf("unsupported filter: %s", e.Reason)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/elimity-com/scim"
|
||||
scimerrors "github.com/elimity-com/scim/errors"
|
||||
"github.com/elimity-com/scim/optional"
|
||||
"go.gearno.de/kit/log"
|
||||
"go.gearno.de/kit/pg"
|
||||
@@ -93,19 +94,19 @@ func (s *Service) CreateUser(
|
||||
config *coredata.SCIMConfiguration,
|
||||
attributes scim.ResourceAttributes,
|
||||
ipAddress net.IP,
|
||||
) (*coredata.Membership, error) {
|
||||
) (scim.Resource, error) {
|
||||
user := ParseUserFromAttributes(attributes)
|
||||
email := user.GetPrimaryEmail()
|
||||
email := user.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)
|
||||
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()
|
||||
|
||||
var membership *coredata.Membership
|
||||
@@ -195,10 +196,10 @@ func (s *Service) CreateUser(
|
||||
})
|
||||
|
||||
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
|
||||
@@ -207,12 +208,10 @@ func (s *Service) GetUser(
|
||||
config *coredata.SCIMConfiguration,
|
||||
membershipID gid.GID,
|
||||
ipAddress net.IP,
|
||||
) (*coredata.Membership, *coredata.Identity, *coredata.MembershipProfile, error) {
|
||||
) (scim.Resource, error) {
|
||||
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
|
||||
|
||||
var membership *coredata.Membership
|
||||
var identity *coredata.Identity
|
||||
var profile *coredata.MembershipProfile
|
||||
|
||||
err := s.pg.WithConn(
|
||||
ctx,
|
||||
@@ -221,26 +220,14 @@ func (s *Service) GetUser(
|
||||
err := membership.LoadByID(ctx, conn, scope, membershipID)
|
||||
if err != nil {
|
||||
if err == coredata.ErrResourceNotFound {
|
||||
return NewSCIMUserNotFoundError(membershipID)
|
||||
return scimerrors.ScimErrorResourceNotFound(membershipID.String())
|
||||
}
|
||||
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 scimerrors.ScimErrorResourceNotFound(membershipID.String())
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -248,10 +235,10 @@ func (s *Service) GetUser(
|
||||
)
|
||||
|
||||
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
|
||||
@@ -262,7 +249,7 @@ func (s *Service) ListUsers(
|
||||
startIndex int,
|
||||
count int,
|
||||
ipAddress net.IP,
|
||||
) ([]*coredata.Membership, int, error) {
|
||||
) ([]scim.Resource, int, error) {
|
||||
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
|
||||
|
||||
var memberships coredata.Memberships
|
||||
@@ -318,33 +305,44 @@ func (s *Service) ListUsers(
|
||||
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
|
||||
// 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) {
|
||||
) (scim.Resource, error) {
|
||||
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
|
||||
// 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) {
|
||||
) (scim.Resource, error) {
|
||||
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(
|
||||
@@ -366,14 +364,14 @@ func (s *Service) updateUser(
|
||||
err := membership.LoadByID(ctx, tx, scope, membershipID)
|
||||
if err != nil {
|
||||
if err == coredata.ErrResourceNotFound {
|
||||
return NewSCIMUserNotFoundError(membershipID)
|
||||
return scimerrors.ScimErrorResourceNotFound(membershipID.String())
|
||||
}
|
||||
return fmt.Errorf("cannot load membership: %w", err)
|
||||
}
|
||||
|
||||
// Verify membership belongs to this organization
|
||||
if membership.OrganizationID != config.OrganizationID {
|
||||
return NewSCIMUserNotFoundError(membershipID)
|
||||
return scimerrors.ScimErrorResourceNotFound(membershipID.String())
|
||||
}
|
||||
|
||||
// Handle deactivation - Okta sends PATCH with active=false to deprovision users
|
||||
@@ -410,7 +408,7 @@ func (s *Service) updateUser(
|
||||
profile := &coredata.MembershipProfile{}
|
||||
err = profile.LoadByMembershipID(ctx, tx, scope, membershipID)
|
||||
if err == nil {
|
||||
fullName := user.GetFullName()
|
||||
fullName := user.FullName
|
||||
if fullName != "" {
|
||||
profile.FullName = fullName
|
||||
profile.UpdatedAt = now
|
||||
@@ -453,14 +451,14 @@ func (s *Service) DeleteUser(
|
||||
err := membership.LoadByID(ctx, tx, scope, membershipID)
|
||||
if err != nil {
|
||||
if err == coredata.ErrResourceNotFound {
|
||||
return NewSCIMUserNotFoundError(membershipID)
|
||||
return scimerrors.ScimErrorResourceNotFound(membershipID.String())
|
||||
}
|
||||
return fmt.Errorf("cannot load membership: %w", err)
|
||||
}
|
||||
|
||||
// Verify membership belongs to this organization
|
||||
if membership.OrganizationID != config.OrganizationID {
|
||||
return NewSCIMUserNotFoundError(membershipID)
|
||||
return scimerrors.ScimErrorResourceNotFound(membershipID.String())
|
||||
}
|
||||
|
||||
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 user data from SCIM create attributes
|
||||
func ParseUserFromAttributes(attributes scim.ResourceAttributes) *User {
|
||||
userName, _ := attributes["userName"].(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
|
||||
if fullName == "" {
|
||||
fullName = strings.TrimSpace(givenName + " " + familyName)
|
||||
@@ -579,26 +578,13 @@ func ParseUserFromAttributes(attributes scim.ResourceAttributes) *User {
|
||||
fullName = userName
|
||||
}
|
||||
|
||||
user := &User{
|
||||
UserName: userName,
|
||||
DisplayName: displayName,
|
||||
Name: &Name{
|
||||
GivenName: givenName,
|
||||
FamilyName: familyName,
|
||||
Formatted: fullName,
|
||||
},
|
||||
Emails: []Email{
|
||||
{
|
||||
Value: email,
|
||||
Primary: true,
|
||||
},
|
||||
},
|
||||
return &User{
|
||||
Email: email,
|
||||
FullName: fullName,
|
||||
}
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
// ParseUserFromReplaceAttributes extracts a User from SCIM replace attributes
|
||||
// ParseUserFromReplaceAttributes extracts user data from SCIM replace (PUT) attributes
|
||||
func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) *User {
|
||||
displayName, _ := attributes["displayName"].(string)
|
||||
|
||||
@@ -619,19 +605,16 @@ func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) *User {
|
||||
}
|
||||
|
||||
return &User{
|
||||
DisplayName: fullName,
|
||||
FullName: fullName,
|
||||
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 {
|
||||
user := &User{}
|
||||
var givenName, familyName string
|
||||
|
||||
for _, op := range operations {
|
||||
if strings.EqualFold(op.Op, "replace") || strings.EqualFold(op.Op, "add") {
|
||||
path := ""
|
||||
@@ -645,37 +628,29 @@ func ParseUserFromPatchOperations(operations []scim.PatchOperation) *User {
|
||||
}
|
||||
case "displayname":
|
||||
if name, ok := op.Value.(string); ok {
|
||||
user.DisplayName = name
|
||||
user.FullName = name
|
||||
}
|
||||
case "name.givenname":
|
||||
if user.Name == nil {
|
||||
user.Name = &Name{}
|
||||
}
|
||||
if name, ok := op.Value.(string); ok {
|
||||
user.Name.GivenName = name
|
||||
givenName = name
|
||||
}
|
||||
case "name.familyname":
|
||||
if user.Name == nil {
|
||||
user.Name = &Name{}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
func membershipToResource(m *coredata.Membership, active bool) scim.Resource {
|
||||
return scim.Resource{
|
||||
ID: 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{
|
||||
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,
|
||||
Created: &m.CreatedAt,
|
||||
LastModified: &m.UpdatedAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
scimerrors "github.com/elimity-com/scim/errors"
|
||||
scimfilter "github.com/scim2/filter-parser/v2"
|
||||
)
|
||||
|
||||
@@ -44,17 +45,17 @@ func ParseUserFilter(expr scimfilter.Expression) (*UserFilter, error) {
|
||||
}
|
||||
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)}
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
case *scimfilter.NotExpression:
|
||||
return nil, &ErrUnsupportedFilter{Reason: "NOT expressions are not supported"}
|
||||
return nil, scimerrors.ScimErrorBadRequest("NOT expressions are not supported")
|
||||
case *scimfilter.ValuePath:
|
||||
return nil, &ErrUnsupportedFilter{Reason: "value path expressions are not supported"}
|
||||
return nil, scimerrors.ScimErrorBadRequest("value path expressions are not supported")
|
||||
default:
|
||||
return nil, &ErrUnsupportedFilter{Reason: "unknown filter expression type"}
|
||||
return nil, scimerrors.ScimErrorBadRequest("unknown filter expression type")
|
||||
}
|
||||
|
||||
return filter, nil
|
||||
@@ -63,7 +64,7 @@ func ParseUserFilter(expr scimfilter.Expression) (*UserFilter, error) {
|
||||
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)}
|
||||
return scimerrors.ScimErrorBadRequest(fmt.Sprintf("operator '%s' is not supported, only 'eq' is supported", e.Operator))
|
||||
}
|
||||
|
||||
// Get the attribute name (lowercase for comparison)
|
||||
@@ -72,14 +73,14 @@ func parseAttributeExpression(e *scimfilter.AttributeExpression, filter *UserFil
|
||||
// Extract the string value
|
||||
value, ok := e.CompareValue.(string)
|
||||
if !ok {
|
||||
return &ErrUnsupportedFilter{Reason: "filter value must be a string"}
|
||||
return scimerrors.ScimErrorBadRequest("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 scimerrors.ScimErrorBadRequest(fmt.Sprintf("attribute '%s' is not supported for filtering, only 'userName' is supported", e.AttributePath.AttributeName))
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -92,7 +93,7 @@ func parseLogicalExpression(e *scimfilter.LogicalExpression, filter *UserFilter)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return &ErrUnsupportedFilter{Reason: "nested logical expressions are not supported"}
|
||||
return scimerrors.ScimErrorBadRequest("nested logical expressions are not supported")
|
||||
}
|
||||
|
||||
// Process right expression
|
||||
@@ -101,224 +102,15 @@ func parseLogicalExpression(e *scimfilter.LogicalExpression, filter *UserFilter)
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return &ErrUnsupportedFilter{Reason: "nested logical expressions are not supported"}
|
||||
return scimerrors.ScimErrorBadRequest("nested logical expressions are not supported")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SCIM 2.0 User Resource
|
||||
// https://datatracker.ietf.org/doc/html/rfc7643#section-4.1
|
||||
// User represents parsed SCIM user attributes with extracted values.
|
||||
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
|
||||
Email string
|
||||
FullName string
|
||||
Active *bool
|
||||
}
|
||||
|
||||
@@ -131,24 +131,17 @@ func (h *scimResourceHandler) Create(r *http.Request, attributes scim.ResourceAt
|
||||
ctx := r.Context()
|
||||
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 {
|
||||
var invalidReq *scimservice.ErrSCIMInvalidRequest
|
||||
var alreadyExists *scimservice.ErrSCIMUserAlreadyExists
|
||||
|
||||
if errors.As(err, &invalidReq) {
|
||||
return scim.Resource{}, scimerrors.ScimErrorBadRequest(invalidReq.Detail)
|
||||
var scimErr scimerrors.ScimError
|
||||
if errors.As(err, &scimErr) {
|
||||
return scim.Resource{}, err
|
||||
}
|
||||
|
||||
if errors.As(err, &alreadyExists) {
|
||||
return scim.Resource{}, scimerrors.ScimErrorUniqueness
|
||||
}
|
||||
|
||||
h.handler.logger.ErrorCtx(ctx, "cannot create user", log.Error(err))
|
||||
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) {
|
||||
@@ -160,18 +153,17 @@ func (h *scimResourceHandler) Get(r *http.Request, id string) (scim.Resource, er
|
||||
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 {
|
||||
var notFound *scimservice.ErrSCIMUserNotFound
|
||||
if errors.As(err, ¬Found) {
|
||||
return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id)
|
||||
var scimErr scimerrors.ScimError
|
||||
if errors.As(err, &scimErr) {
|
||||
return scim.Resource{}, err
|
||||
}
|
||||
|
||||
h.handler.logger.ErrorCtx(ctx, "cannot get user", log.Error(err))
|
||||
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) {
|
||||
@@ -185,25 +177,24 @@ func (h *scimResourceHandler) GetAll(r *http.Request, params scim.ListRequestPar
|
||||
// Parse SCIM filter AST into our filter type
|
||||
filter, err := scimservice.ParseUserFilter(params.FilterValidator.GetFilter())
|
||||
if err != nil {
|
||||
var unsupportedFilter *scimservice.ErrUnsupportedFilter
|
||||
if errors.As(err, &unsupportedFilter) {
|
||||
return scim.Page{}, scimerrors.ScimErrorBadRequest(err.Error())
|
||||
var scimErr scimerrors.ScimError
|
||||
if errors.As(err, &scimErr) {
|
||||
return scim.Page{}, err
|
||||
}
|
||||
h.handler.logger.ErrorCtx(ctx, "cannot parse filter", log.Error(err))
|
||||
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 {
|
||||
var scimErr scimerrors.ScimError
|
||||
if errors.As(err, &scimErr) {
|
||||
return scim.Page{}, err
|
||||
}
|
||||
h.handler.logger.ErrorCtx(ctx, "cannot list users", log.Error(err))
|
||||
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{
|
||||
TotalResults: totalCount,
|
||||
Resources: resources,
|
||||
@@ -219,22 +210,17 @@ func (h *scimResourceHandler) Replace(r *http.Request, id string, attributes sci
|
||||
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 {
|
||||
var notFound *scimservice.ErrSCIMUserNotFound
|
||||
if errors.As(err, ¬Found) {
|
||||
return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id)
|
||||
var scimErr scimerrors.ScimError
|
||||
if errors.As(err, &scimErr) {
|
||||
return scim.Resource{}, err
|
||||
}
|
||||
|
||||
h.handler.logger.ErrorCtx(ctx, "cannot update user", log.Error(err))
|
||||
return scim.Resource{}, scimerrors.ScimErrorInternal
|
||||
}
|
||||
|
||||
if deactivated {
|
||||
return scimservice.MembershipToResourceWithActive(membership, false), nil
|
||||
}
|
||||
|
||||
return scimservice.MembershipToResource(membership), nil
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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 {
|
||||
var notFound *scimservice.ErrSCIMUserNotFound
|
||||
if errors.As(err, ¬Found) {
|
||||
return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id)
|
||||
var scimErr scimerrors.ScimError
|
||||
if errors.As(err, &scimErr) {
|
||||
return scim.Resource{}, err
|
||||
}
|
||||
|
||||
h.handler.logger.ErrorCtx(ctx, "cannot patch user", log.Error(err))
|
||||
return scim.Resource{}, scimerrors.ScimErrorInternal
|
||||
}
|
||||
|
||||
if deactivated {
|
||||
return scimservice.MembershipToResourceWithActive(membership, false), nil
|
||||
}
|
||||
|
||||
return scimservice.MembershipToResource(membership), nil
|
||||
return resource, nil
|
||||
}
|
||||
|
||||
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))
|
||||
if err != nil {
|
||||
var notFound *scimservice.ErrSCIMUserNotFound
|
||||
if errors.As(err, ¬Found) {
|
||||
return scimerrors.ScimErrorResourceNotFound(id)
|
||||
var scimErr scimerrors.ScimError
|
||||
if errors.As(err, &scimErr) {
|
||||
return err
|
||||
}
|
||||
|
||||
h.handler.logger.ErrorCtx(ctx, "cannot delete user", log.Error(err))
|
||||
return scimerrors.ScimErrorInternal
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user