package service import ( "context" "fmt" "strings" "time" "github.com/go-errors/errors" "github.com/google/uuid" "github.com/oapi-codegen/nullable" "github.com/phishingclub/phishingclub/data" "github.com/phishingclub/phishingclub/errs" "github.com/phishingclub/phishingclub/model" "github.com/phishingclub/phishingclub/repository" "github.com/phishingclub/phishingclub/vo" "gorm.io/gorm" ) // scim v2 resource types const ( scimResourceTypeUser = "User" scimResourceTypeGroup = "Group" ) // scim v2 schema URNs const ( scimSchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User" scimSchemaEnterpriseUser = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User" scimSchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group" scimSchemaCustomExtension = "urn:ietf:params:scim:schemas:extension:phishingclub:2.0:User" scimSchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse" scimSchemaError = "urn:ietf:params:scim:api:messages:2.0:Error" scimSchemaPatchOp = "urn:ietf:params:scim:api:messages:2.0:PatchOp" ) // ScimUser is the SCIM v2 User resource representation used for both // requests from the IdP and responses back to it. type ScimUser struct { // schemas must always be present in responses Schemas []string `json:"schemas"` ID string `json:"id,omitempty"` UserName string `json:"userName"` // name sub-object Name *ScimName `json:"name,omitempty"` // flat display name (used if name sub-object absent) DisplayName string `json:"displayName,omitempty"` // core title attribute — Microsoft Entra maps the directory jobTitle here by // default (not the enterprise extension), so this is the primary source for Position Title string `json:"title,omitempty"` // emails list — we treat the first primary (or first) as canonical Emails []ScimEmail `json:"emails,omitempty"` // phone numbers list PhoneNumbers []ScimPhoneNumber `json:"phoneNumbers,omitempty"` // enterprise extension fields (department, title/position) // division is intentionally omitted — it is not stored EnterpriseUser *ScimEnterpriseUser `json:"urn:ietf:params:scim:schemas:extension:enterprise:2.0:User,omitempty"` // addresses list — work address maps to city/country Addresses []ScimAddress `json:"addresses,omitempty"` // active flag — false means the account should be deprovisioned Active bool `json:"active"` // meta sub-object for responses Meta *ScimMeta `json:"meta,omitempty"` // externalId from IdP (stored in extra_identifier) ExternalID string `json:"externalId,omitempty"` // custom extension — misc/notes field CustomExtension *ScimCustomExtension `json:"urn:ietf:params:scim:schemas:extension:phishingclub:2.0:User,omitempty"` // groups the user is a member of — populated on responses, consumed on writes Groups []ScimUserGroup `json:"groups,omitempty"` } // ScimUserGroup is an entry in the groups array on a ScimUser resource. // value is the group ID, display is the group display name. type ScimUserGroup struct { Value string `json:"value"` Display string `json:"display,omitempty"` Ref string `json:"$ref,omitempty"` } // ScimName holds the structured name sub-object type ScimName struct { Formatted string `json:"formatted,omitempty"` GivenName string `json:"givenName,omitempty"` FamilyName string `json:"familyName,omitempty"` } // ScimEmail is a single email entry in the emails array type ScimEmail struct { Value string `json:"value"` Type string `json:"type,omitempty"` Primary bool `json:"primary,omitempty"` } // ScimPhoneNumber is a single phone number entry type ScimPhoneNumber struct { Value string `json:"value"` Type string `json:"type,omitempty"` Primary bool `json:"primary,omitempty"` } // ScimAddress is a single address entry in the addresses array type ScimAddress struct { Type string `json:"type,omitempty"` Locality string `json:"locality,omitempty"` // maps to city Country string `json:"country,omitempty"` // maps to country (ISO 3166-1) Primary bool `json:"primary,omitempty"` Formatted string `json:"formatted,omitempty"` } // ScimEnterpriseUser holds enterprise extension fields type ScimEnterpriseUser struct { Department string `json:"department,omitempty"` Title string `json:"title,omitempty"` } // ScimCustomExtension holds fields that have no standard SCIM home type ScimCustomExtension struct { // Misc maps to recipient.misc — free-form notes Misc string `json:"misc,omitempty"` } // ScimMeta is the meta sub-object returned in responses type ScimMeta struct { ResourceType string `json:"resourceType"` Location string `json:"location,omitempty"` } // ScimListResponse is the SCIM v2 ListResponse envelope type ScimListResponse struct { Schemas []string `json:"schemas"` TotalResults int `json:"totalResults"` StartIndex int `json:"startIndex"` ItemsPerPage int `json:"itemsPerPage"` Resources []ScimUser `json:"Resources"` } // ScimError is the SCIM v2 error response body type ScimError struct { Schemas []string `json:"schemas"` Status int `json:"status"` Detail string `json:"detail,omitempty"` ScimType string `json:"scimType,omitempty"` } // ScimPatchOp is the body of a PATCH request (RFC 7644 §3.5.2) type ScimPatchOp struct { Schemas []string `json:"schemas"` Operations []ScimPatchOpItem `json:"Operations"` } // ScimPatchOpItem is a single operation within a PatchOp type ScimPatchOpItem struct { Op string `json:"op"` Path string `json:"path,omitempty"` Value any `json:"value,omitempty"` } // ScimServiceProviderConfig is the ServiceProviderConfig response (RFC 7643 §5) type ScimServiceProviderConfig struct { Schemas []string `json:"schemas"` DocumentationURI string `json:"documentationUri,omitempty"` Patch ScimSupportedFeature `json:"patch"` Bulk ScimBulkFeature `json:"bulk"` Filter ScimFilterFeature `json:"filter"` ChangePassword ScimSupportedFeature `json:"changePassword"` Sort ScimSupportedFeature `json:"sort"` ETag ScimSupportedFeature `json:"etag"` AuthenticationSchemes []ScimAuthScheme `json:"authenticationSchemes"` Meta *ScimMeta `json:"meta,omitempty"` } // ScimSupportedFeature indicates whether a feature is supported type ScimSupportedFeature struct { Supported bool `json:"supported"` } // ScimBulkFeature describes bulk support type ScimBulkFeature struct { Supported bool `json:"supported"` MaxOperations int `json:"maxOperations"` MaxPayloadSize int `json:"maxPayloadSize"` } // ScimFilterFeature describes filter support type ScimFilterFeature struct { Supported bool `json:"supported"` MaxResults int `json:"maxResults"` } // ScimAuthScheme describes a supported authentication scheme type ScimAuthScheme struct { Type string `json:"type"` Name string `json:"name"` Description string `json:"description,omitempty"` SpecURI string `json:"specUri,omitempty"` DocumentationURI string `json:"documentationUri,omitempty"` Primary bool `json:"primary,omitempty"` } // ScimResourceType is an entry in /ResourceTypes type ScimResourceType struct { Schemas []string `json:"schemas"` ID string `json:"id"` Name string `json:"name"` Endpoint string `json:"endpoint"` Description string `json:"description,omitempty"` Schema string `json:"schema"` SchemaExtensions []ScimSchemaExtension `json:"schemaExtensions"` Meta *ScimMeta `json:"meta,omitempty"` } // ScimSchemaExtension is a schema extension reference within a ResourceType type ScimSchemaExtension struct { Schema string `json:"schema"` Required bool `json:"required"` } // ScimSchemaAttribute describes a single attribute within a Schema document type ScimSchemaAttribute 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"` Mutability string `json:"mutability"` Returned string `json:"returned"` Uniqueness string `json:"uniqueness"` SubAttributes []ScimSchemaAttribute `json:"subAttributes,omitempty"` ReferenceTypes []string `json:"referenceTypes,omitempty"` CanonicalValues []string `json:"canonicalValues,omitempty"` } // ScimSchema is a single Schema document returned by /Schemas type ScimSchema struct { Schemas []string `json:"schemas"` ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` Attributes []ScimSchemaAttribute `json:"attributes"` Meta *ScimMeta `json:"meta,omitempty"` } // ScimGroup is the SCIM v2 Group resource representation. // the IdP is the source of truth — groups are created, updated and deleted // directly via the /Groups endpoints. type ScimGroup struct { Schemas []string `json:"schemas"` ID string `json:"id,omitempty"` DisplayName string `json:"displayName"` Members []ScimGroupMember `json:"members,omitempty"` Meta *ScimMeta `json:"meta,omitempty"` } // ScimGroupMember is a member reference within a ScimGroup type ScimGroupMember struct { Value string `json:"value"` Display string `json:"display,omitempty"` Ref string `json:"$ref,omitempty"` } // ScimConfigResult is returned by VerifyAndLoadConfig to the controller layer type ScimConfigResult struct { Config *model.CompanyScimConfig CompanyID *uuid.UUID } // ScimGroupPatchOp is the body of a PATCH /Groups/:id request type ScimGroupPatchOp struct { Schemas []string `json:"schemas"` Operations []ScimGroupPatchOpItem `json:"Operations"` } // ScimGroupPatchOpItem is a single operation within a PATCH /Groups request. // op is "add", "remove", or "replace". path is optional. type ScimGroupPatchOpItem struct { Op string `json:"op"` Path string `json:"path,omitempty"` Value any `json:"value,omitempty"` } // GetSchemaByID returns a single schema document by its URN. // returns the schema and true if found, nil and false otherwise. func (s *Scim) GetSchemaByID(baseURL string, id string) (*ScimSchema, bool) { for _, schema := range s.Schemas(baseURL) { if schema.ID == id { return &schema, true } } return nil, false } // GetResourceTypeByID returns a single resource type by its ID (e.g. "User" or "Group"). // returns the resource type and true if found, nil and false otherwise. func (s *Scim) GetResourceTypeByID(baseURL string, id string) (*ScimResourceType, bool) { for _, rt := range s.ResourceTypes(baseURL) { if rt.ID == id { return &rt, true } } return nil, false } // Scim is the service that handles SCIM v2 protocol operations. // it is called by the SCIM HTTP handler (controller/scim.go) after // bearer-token authentication has already been verified. type Scim struct { Common CompanyScimConfigRepository *repository.CompanyScimConfig CompanyScimConfigService *CompanyScimConfig RecipientRepository *repository.Recipient RecipientGroupRepository *repository.RecipientGroup RecipientService *Recipient OptionService *Option CampaignRepository *repository.Campaign CampaignRecipientRepository *repository.CampaignRecipient } // ServiceProviderConfig returns the static service provider configuration // document describing what this SCIM implementation supports. func (s *Scim) ServiceProviderConfig(baseURL string) *ScimServiceProviderConfig { return &ScimServiceProviderConfig{ Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"}, Patch: ScimSupportedFeature{Supported: true}, Bulk: ScimBulkFeature{Supported: false, MaxOperations: 0, MaxPayloadSize: 0}, Filter: ScimFilterFeature{Supported: true, MaxResults: 200}, ChangePassword: ScimSupportedFeature{Supported: false}, Sort: ScimSupportedFeature{Supported: false}, ETag: ScimSupportedFeature{Supported: false}, AuthenticationSchemes: []ScimAuthScheme{ { Type: "oauthbearertoken", Name: "OAuth Bearer Token", Description: "authentication using a bearer token issued by this application", Primary: true, }, }, Meta: &ScimMeta{ ResourceType: "ServiceProviderConfig", Location: baseURL + "/ServiceProviderConfig", }, } } // ResourceTypes returns the list of supported resource types func (s *Scim) ResourceTypes(baseURL string) []ScimResourceType { return []ScimResourceType{ { Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, ID: "User", Name: "User", Endpoint: "/Users", Description: "user accounts", Schema: scimSchemaUser, SchemaExtensions: []ScimSchemaExtension{ {Schema: scimSchemaEnterpriseUser, Required: false}, {Schema: scimSchemaCustomExtension, Required: false}, }, Meta: &ScimMeta{ ResourceType: "ResourceType", Location: baseURL + "/ResourceTypes/User", }, }, { Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, ID: "Group", Name: "Group", Endpoint: "/Groups", Description: "recipient groups", Schema: scimSchemaGroup, SchemaExtensions: []ScimSchemaExtension{}, Meta: &ScimMeta{ ResourceType: "ResourceType", Location: baseURL + "/ResourceTypes/Group", }, }, } } // Schemas returns the hardcoded schema documents for all supported resource types. // these are static — no database required. func (s *Scim) Schemas(baseURL string) []ScimSchema { return []ScimSchema{ { Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, ID: scimSchemaUser, Name: "User", Description: "user account", Attributes: []ScimSchemaAttribute{ { Name: "userName", Type: "string", MultiValued: false, Description: "unique identifier for the user", Required: true, CaseExact: true, Mutability: "readWrite", Returned: "default", Uniqueness: "server", }, { Name: "name", Type: "complex", MultiValued: false, Description: "the components of the user's name", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", SubAttributes: []ScimSchemaAttribute{ {Name: "givenName", Type: "string", MultiValued: false, Description: "first name", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, {Name: "familyName", Type: "string", MultiValued: false, Description: "last name", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, {Name: "formatted", Type: "string", MultiValued: false, Description: "full name", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, }, }, { Name: "displayName", Type: "string", MultiValued: false, Description: "display name of the user", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", }, { Name: "emails", Type: "complex", MultiValued: true, Description: "email addresses for the user", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", SubAttributes: []ScimSchemaAttribute{ {Name: "value", Type: "string", MultiValued: false, Description: "email address", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, {Name: "type", Type: "string", MultiValued: false, Description: "type of email address", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", CanonicalValues: []string{"work", "home", "other"}}, {Name: "primary", Type: "boolean", MultiValued: false, Description: "primary email indicator", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, }, }, { Name: "phoneNumbers", Type: "complex", MultiValued: true, Description: "phone numbers for the user", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", SubAttributes: []ScimSchemaAttribute{ {Name: "value", Type: "string", MultiValued: false, Description: "phone number", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, {Name: "type", Type: "string", MultiValued: false, Description: "type of phone number", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", CanonicalValues: []string{"work", "home", "mobile", "other"}}, {Name: "primary", Type: "boolean", MultiValued: false, Description: "primary phone indicator", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, }, }, { Name: "addresses", Type: "complex", MultiValued: true, Description: "addresses for the user — work address maps to city and country fields", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", SubAttributes: []ScimSchemaAttribute{ {Name: "type", Type: "string", MultiValued: false, Description: "type of address", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", CanonicalValues: []string{"work", "home", "other"}}, {Name: "locality", Type: "string", MultiValued: false, Description: "city", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, {Name: "country", Type: "string", MultiValued: false, Description: "country (ISO 3166-1 alpha-2 or full name)", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, {Name: "primary", Type: "boolean", MultiValued: false, Description: "primary address indicator", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, {Name: "formatted", Type: "string", MultiValued: false, Description: "full mailing address formatted for display", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, }, }, { Name: "active", Type: "boolean", MultiValued: false, Description: "administrative status of the user — false removes them from all groups", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", }, { Name: "externalId", Type: "string", MultiValued: false, Description: "identifier from the provisioning client (stored as extraIdentifier — unique per company)", Required: false, CaseExact: true, Mutability: "readWrite", Returned: "default", Uniqueness: "server", }, { Name: "groups", Type: "complex", MultiValued: true, Description: "groups the user belongs to", Required: false, CaseExact: false, Mutability: "readOnly", Returned: "request", Uniqueness: "none", SubAttributes: []ScimSchemaAttribute{ {Name: "value", Type: "string", MultiValued: false, Description: "group ID", Required: false, CaseExact: false, Mutability: "readOnly", Returned: "default", Uniqueness: "none"}, {Name: "display", Type: "string", MultiValued: false, Description: "display name of the group", Required: false, CaseExact: false, Mutability: "readOnly", Returned: "default", Uniqueness: "none"}, {Name: "$ref", Type: "reference", MultiValued: false, Description: "URI of the group", Required: false, CaseExact: false, Mutability: "readOnly", Returned: "default", Uniqueness: "none"}, }, }, }, Meta: &ScimMeta{ ResourceType: "Schema", Location: baseURL + "/Schemas/" + scimSchemaUser, }, }, { Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, ID: scimSchemaEnterpriseUser, Name: "EnterpriseUser", Description: "enterprise user extension attributes", Attributes: []ScimSchemaAttribute{ { Name: "department", Type: "string", MultiValued: false, Description: "department the user belongs to (stored as department)", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", }, { Name: "title", Type: "string", MultiValued: false, Description: "job title / position (stored as position)", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", }, { Name: "manager", Type: "complex", MultiValued: false, Description: "the user's manager — not stored, accepted and silently ignored", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", SubAttributes: []ScimSchemaAttribute{ {Name: "value", Type: "string", MultiValued: false, Description: "manager user ID", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, {Name: "$ref", Type: "reference", MultiValued: false, Description: "URI of the manager", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none"}, {Name: "displayName", Type: "string", MultiValued: false, Description: "display name of the manager", Required: false, CaseExact: false, Mutability: "readOnly", Returned: "default", Uniqueness: "none"}, }, }, }, Meta: &ScimMeta{ ResourceType: "Schema", Location: baseURL + "/Schemas/" + scimSchemaEnterpriseUser, }, }, { Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, ID: scimSchemaCustomExtension, Name: "PhishingClubUser", Description: "phishingclub-specific user extension attributes", Attributes: []ScimSchemaAttribute{ { Name: "misc", Type: "string", MultiValued: false, Description: "free-form notes field (stored as misc)", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", }, }, Meta: &ScimMeta{ ResourceType: "Schema", Location: baseURL + "/Schemas/" + scimSchemaCustomExtension, }, }, { Schemas: []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, ID: scimSchemaGroup, Name: "Group", Description: "recipient group", Attributes: []ScimSchemaAttribute{ { Name: "displayName", Type: "string", MultiValued: false, Description: "name of the group (unique per company)", Required: true, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "server", }, { Name: "members", Type: "complex", MultiValued: true, Description: "members of the group", Required: false, CaseExact: false, Mutability: "readWrite", Returned: "default", Uniqueness: "none", SubAttributes: []ScimSchemaAttribute{ {Name: "value", Type: "string", MultiValued: false, Description: "recipient ID", Required: false, CaseExact: false, Mutability: "immutable", Returned: "default", Uniqueness: "none"}, {Name: "display", Type: "string", MultiValued: false, Description: "display name of the member", Required: false, CaseExact: false, Mutability: "readOnly", Returned: "default", Uniqueness: "none"}, {Name: "$ref", Type: "reference", MultiValued: false, Description: "URI of the member user resource", Required: false, CaseExact: false, Mutability: "readOnly", Returned: "default", Uniqueness: "none"}, }, }, }, Meta: &ScimMeta{ ResourceType: "Schema", Location: baseURL + "/Schemas/" + scimSchemaGroup, }, }, } } // ListGroupsRaw returns all recipient groups for this company wrapped in a // spec-compliant ListResponse. the IdP owns group creation so all company // groups are visible. supports startIndex and count query parameters. func (s *Scim) ListGroupsRaw( ctx context.Context, companyID *uuid.UUID, config *model.CompanyScimConfig, baseURL string, startIndex int, count int, filter string, excludedAttributes string, ) (any, error) { groups, err := s.RecipientGroupRepository.GetAllByCompanyID(ctx, companyID, &repository.RecipientGroupOption{ WithRecipients: true, }) if err != nil { s.Logger.Errorw("scim list groups: failed to list groups", "error", err) return nil, errs.Wrap(err) } excludeMembers := scimExcludesAttribute(excludedAttributes, "members") all := make([]ScimGroup, 0, len(groups)) for _, g := range groups { if filter != "" && !scimGroupFilterMatches(filter, g) { continue } sg := recipientGroupToScimGroup(g, baseURL) if excludeMembers { sg.Members = nil } all = append(all, sg) } total := len(all) // apply startIndex (1-based per rfc 7644 §3.4.2) if startIndex < 1 { startIndex = 1 } offset := startIndex - 1 if offset > total { offset = total } all = all[offset:] // apply count — 0 returns zero resources (RFC 7644 §3.4.2.4); a negative or // absent value means no limit if count == 0 { all = []ScimGroup{} } else if count > 0 && count < len(all) { all = all[:count] } return map[string]any{ "schemas": []string{scimSchemaListResponse}, "totalResults": total, "startIndex": startIndex, "itemsPerPage": len(all), "Resources": all, }, nil } // GetGroup returns a single group by ID as a SCIM Group resource. func (s *Scim) GetGroup( ctx context.Context, companyID *uuid.UUID, config *model.CompanyScimConfig, groupID *uuid.UUID, baseURL string, ) (*ScimGroup, error) { group, err := s.RecipientGroupRepository.GetByID(ctx, groupID, &repository.RecipientGroupOption{ WithRecipients: true, }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errs.Wrap(gorm.ErrRecordNotFound) } s.Logger.Errorw("scim get group: failed to get group", "error", err) return nil, errs.Wrap(err) } // ensure the group belongs to this company gCompanyID, err := group.CompanyID.Get() if err != nil || gCompanyID != *companyID { return nil, errs.Wrap(gorm.ErrRecordNotFound) } g := recipientGroupToScimGroup(group, baseURL) return &g, nil } // CreateGroup provisions a new recipient group from a SCIM Group resource. // the IdP is the source of truth — it chooses the display name and membership. func (s *Scim) CreateGroup( ctx context.Context, companyID *uuid.UUID, config *model.CompanyScimConfig, req *ScimGroup, baseURL string, ) (*ScimGroup, error) { if strings.TrimSpace(req.DisplayName) == "" { return nil, errs.NewSyntaxError(fmt.Errorf("displayName is required")) } nameVO, err := vo.NewString127(req.DisplayName) if err != nil { return nil, errs.NewSyntaxError(fmt.Errorf("displayName too long")) } rg := &model.RecipientGroup{ Name: nullable.NewNullableWithValue(*nameVO), CompanyID: nullable.NewNullableWithValue(*companyID), } groupID, err := s.RecipientGroupRepository.Insert(ctx, rg) if err != nil { if isScimUniqueConflict(err) { return nil, errs.NewConflictError(fmt.Errorf("a group named %q already exists", req.DisplayName)) } s.Logger.Errorw("scim create group: failed to insert group", "error", err) return nil, errs.Wrap(err) } // add any members supplied in the create request if len(req.Members) > 0 { if err := s.applyGroupMembers(ctx, companyID, groupID, req.Members); err != nil { return nil, err } } created, err := s.RecipientGroupRepository.GetByID(ctx, groupID, &repository.RecipientGroupOption{ WithRecipients: true, }) if err != nil { s.Logger.Errorw("scim create group: failed to reload group", "error", err) return nil, errs.Wrap(err) } s.auditScim("Scim.CreateGroup", config, map[string]any{"groupID": groupID.String()}) g := recipientGroupToScimGroup(created, baseURL) return &g, nil } // ReplaceGroup performs a full replacement (PUT) of an existing group. // the display name is updated and membership is replaced wholesale. func (s *Scim) ReplaceGroup( ctx context.Context, companyID *uuid.UUID, config *model.CompanyScimConfig, groupID *uuid.UUID, req *ScimGroup, baseURL string, ) (*ScimGroup, error) { existing, err := s.RecipientGroupRepository.GetByID(ctx, groupID, &repository.RecipientGroupOption{ WithRecipients: true, }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errs.Wrap(gorm.ErrRecordNotFound) } return nil, errs.Wrap(err) } // ensure the group belongs to this company gCompanyID, compErr := existing.CompanyID.Get() if compErr != nil || gCompanyID != *companyID { return nil, errs.Wrap(gorm.ErrRecordNotFound) } if strings.TrimSpace(req.DisplayName) != "" { nameVO, err := vo.NewString127(req.DisplayName) if err != nil { return nil, errs.NewValidationError(fmt.Errorf("displayName too long")) } existing.Name = nullable.NewNullableWithValue(*nameVO) if err := s.RecipientGroupRepository.UpdateByID(ctx, groupID, existing); err != nil { s.Logger.Errorw("scim replace group: failed to update group name", "error", err) return nil, errs.Wrap(err) } } // replace membership: remove everyone then add the supplied list if err := s.replaceGroupMembers(ctx, companyID, groupID, existing.Recipients, req.Members); err != nil { return nil, err } updated, err := s.RecipientGroupRepository.GetByID(ctx, groupID, &repository.RecipientGroupOption{ WithRecipients: true, }) if err != nil { return nil, errs.Wrap(err) } s.auditScim("Scim.ReplaceGroup", config, map[string]any{"groupID": groupID.String()}) g := recipientGroupToScimGroup(updated, baseURL) return &g, nil } // PatchGroup applies a SCIM PatchOp to an existing group. // supported operations: replace displayName, add/remove members. func (s *Scim) PatchGroup( ctx context.Context, companyID *uuid.UUID, config *model.CompanyScimConfig, groupID *uuid.UUID, patch *ScimGroupPatchOp, baseURL string, ) (*ScimGroup, error) { existing, err := s.RecipientGroupRepository.GetByID(ctx, groupID, &repository.RecipientGroupOption{ WithRecipients: true, }) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errs.Wrap(gorm.ErrRecordNotFound) } return nil, errs.Wrap(err) } // ensure the group belongs to this company gCompanyID, compErr := existing.CompanyID.Get() if compErr != nil || gCompanyID != *companyID { return nil, errs.Wrap(gorm.ErrRecordNotFound) } for _, op := range patch.Operations { switch strings.ToLower(op.Op) { case "replace": if err := s.applyGroupPatchReplace(ctx, companyID, existing, groupID, op); err != nil { return nil, err } case "add": members := groupMembersFromPatchValue(op.Value) if err := s.applyGroupMembers(ctx, companyID, groupID, members); err != nil { return nil, err } case "remove": // path can be "members" (value array) or "members[value eq \"\"]" (filter form) members := groupMembersFromPatchPath(op.Path, op.Value) if len(members) > 0 { if err := s.removeGroupMembers(ctx, companyID, groupID, members); err != nil { return nil, err } } } } updated, err := s.RecipientGroupRepository.GetByID(ctx, groupID, &repository.RecipientGroupOption{ WithRecipients: true, }) if err != nil { return nil, errs.Wrap(err) } s.auditScim("Scim.PatchGroup", config, map[string]any{"groupID": groupID.String()}) g := recipientGroupToScimGroup(updated, baseURL) return &g, nil } // DeleteGroup removes a recipient group provisioned via SCIM. // members are removed from the group but not deleted from the system. func (s *Scim) DeleteGroup( ctx context.Context, companyID *uuid.UUID, config *model.CompanyScimConfig, groupID *uuid.UUID, ) error { existing, err := s.RecipientGroupRepository.GetByID(ctx, groupID, &repository.RecipientGroupOption{}) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errs.Wrap(gorm.ErrRecordNotFound) } return errs.Wrap(err) } // ensure the group belongs to this company gCompanyID, compErr := existing.CompanyID.Get() if compErr != nil || gCompanyID != *companyID { return errs.Wrap(gorm.ErrRecordNotFound) } // unlink the group from any campaigns before deleting it so foreign key // constraints on campaign_recipient_groups do not block the delete if err := s.CampaignRepository.RemoveCampaignRecipientGroupByGroupID(ctx, groupID); err != nil { s.Logger.Errorw("scim delete group: failed to remove group from campaigns", "error", err) return errs.Wrap(err) } if err := s.RecipientGroupRepository.DeleteByID(ctx, groupID); err != nil { s.Logger.Errorw("scim delete group: failed to delete group", "error", err) return errs.Wrap(err) } s.auditScim("Scim.DeleteGroup", config, map[string]any{"groupID": groupID.String()}) return nil } // recipientGroupToScimGroup maps a model.RecipientGroup to a ScimGroup func recipientGroupToScimGroup(group *model.RecipientGroup, baseURL string) ScimGroup { id := "" if gid, err := group.ID.Get(); err == nil { id = gid.String() } name := "" if n, err := group.Name.Get(); err == nil { name = n.String() } members := make([]ScimGroupMember, 0, len(group.Recipients)) for _, r := range group.Recipients { rid, err := r.ID.Get() if err != nil { continue } display := "" if fn, err := r.FirstName.Get(); err == nil { display = fn.String() } if ln, err := r.LastName.Get(); err == nil { if display != "" { display += " " } display += ln.String() } if display == "" { if e, err := r.Email.Get(); err == nil { display = e.String() } } ref := "" if baseURL != "" { ref = baseURL + "/Users/" + rid.String() } members = append(members, ScimGroupMember{ Value: rid.String(), Display: display, Ref: ref, }) } g := ScimGroup{ Schemas: []string{scimSchemaGroup}, ID: id, DisplayName: name, Members: members, } if baseURL != "" && id != "" { g.Meta = &ScimMeta{ ResourceType: scimResourceTypeGroup, Location: baseURL + "/Groups/" + id, } } return g } // ListUsers returns a SCIM ListResponse of all recipients belonging to this // company. all provisioned users are visible regardless of group membership. // supports filter, startIndex, count, sortBy and sortOrder query parameters. func (s *Scim) ListUsers( ctx context.Context, companyID *uuid.UUID, config *model.CompanyScimConfig, baseURL string, filter string, startIndex int, count int, sortBy string, sortOrder string, ) (*ScimListResponse, error) { recipientResult, err := s.RecipientRepository.GetAllByCompanyID(ctx, companyID, &repository.RecipientOption{}) if err != nil { s.Logger.Errorw("scim list users: failed to list recipients", "error", err) return nil, errs.Wrap(err) } // load group memberships once so each user can report its groups groupsByRecipient := map[uuid.UUID][]ScimUserGroup{} if groupList, gErr := s.RecipientGroupRepository.GetAllByCompanyID(ctx, companyID, &repository.RecipientGroupOption{WithRecipients: true}); gErr != nil { s.Logger.Warnw("scim list users: failed to load group memberships", "error", gErr) } else { groupsByRecipient = buildGroupsByRecipient(groupList) } // build the full filtered list first so totalResults is accurate all := make([]ScimUser, 0, len(recipientResult.Rows)) for _, r := range recipientResult.Rows { u := recipientToScimUser(r, baseURL) if rid, idErr := r.ID.Get(); idErr == nil { u.Groups = groupsByRecipient[rid] } if filter != "" && !scimFilterMatchesUser(filter, u) { continue } all = append(all, u) } // sort if requested if sortBy != "" { scimSortUsers(all, sortBy, sortOrder) } total := len(all) // apply startIndex (1-based per rfc 7644 §3.4.2) if startIndex < 1 { startIndex = 1 } offset := startIndex - 1 if offset > total { offset = total } all = all[offset:] // apply count — 0 returns zero resources (RFC 7644 §3.4.2.4); a negative or // absent value means no limit if count == 0 { all = []ScimUser{} } else if count > 0 && count < len(all) { all = all[:count] } return &ScimListResponse{ Schemas: []string{scimSchemaListResponse}, TotalResults: total, StartIndex: startIndex, ItemsPerPage: len(all), Resources: all, }, nil } // GetUser returns a single SCIM User resource by recipient ID. func (s *Scim) GetUser( ctx context.Context, companyID *uuid.UUID, config *model.CompanyScimConfig, recipientID *uuid.UUID, baseURL string, ) (*ScimUser, error) { recipient, err := s.RecipientRepository.GetByID(ctx, recipientID, &repository.RecipientOption{}) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errs.Wrap(gorm.ErrRecordNotFound) } s.Logger.Errorw("scim get user: failed to get recipient", "error", err, "recipientID", recipientID.String()) return nil, errs.Wrap(err) } // ensure the recipient belongs to this company rCompanyID, compErr := recipient.CompanyID.Get() if compErr != nil || rCompanyID != *companyID { return nil, errs.Wrap(gorm.ErrRecordNotFound) } u := recipientToScimUser(recipient, baseURL) u.Groups = s.groupsForRecipient(ctx, companyID, recipientID, baseURL) return &u, nil } // CreateUser provisions a new recipient from a SCIM User resource. // duplicate userName within the same company returns a 409 ConflictError. // group membership from the groups array is applied after the recipient is persisted. func (s *Scim) CreateUser( ctx context.Context, companyID *uuid.UUID, config *model.CompanyScimConfig, scimUser *ScimUser, baseURL string, ) (*ScimUser, error) { email, err := canonicalEmail(scimUser) if err != nil { return nil, errs.NewValidationError(err) } emailVO, err := vo.NewEmail(email) if err != nil { return nil, errs.NewValidationError(fmt.Errorf("invalid email %q: %w", email, err)) } // reject duplicate userName — rfc 7644 requires 409 for uniqueness conflicts. // the lookup is case-insensitive so John@X.com and john@x.com collide. existingByEmail, err := s.RecipientRepository.GetByEmailLowerAndCompanyID(ctx, emailVO, companyID) if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { s.Logger.Errorw("scim create user: lookup by email failed", "error", err, "email", email) return nil, errs.Wrap(err) } if existingByEmail != nil { // if the existing recipient was SCIM soft-deleted, the IdP is re-provisioning // the same person — revive and update it instead of returning a conflict if existingByEmail.ScimSoftDeletedAt != nil { existingID := existingByEmail.ID.MustGet() if err := s.RecipientRepository.ClearScimSoftDeleted(ctx, &existingID); err != nil { return nil, errs.Wrap(err) } existingByEmail.ScimSoftDeletedAt = nil if err := s.applyScimUserToRecipient(ctx, existingByEmail, scimUser); err != nil { return nil, err } if err := s.syncUserGroupMembership(ctx, companyID, &existingID, scimUser.Groups); err != nil { s.Logger.Warnw("scim create user (revive): failed to sync group membership", "error", err) } revived, err := s.RecipientRepository.GetByID(ctx, &existingID, &repository.RecipientOption{}) if err != nil { return nil, errs.Wrap(err) } s.auditScim("Scim.CreateUser", config, map[string]any{"recipientID": existingID.String(), "revived": true}) u := recipientToScimUser(revived, baseURL) return &u, nil } return nil, errs.NewConflictError(fmt.Errorf("a user with userName %q already exists", scimUserNameFrom(scimUser))) } // create new recipient var recipientID *uuid.UUID r := scimUserToRecipient(scimUser, companyID) id, err := s.RecipientRepository.Insert(ctx, r) if err != nil { if isScimUniqueConflict(err) { return nil, errs.NewConflictError(fmt.Errorf("a user with userName %q already exists", scimUserNameFrom(scimUser))) } s.Logger.Errorw("scim create user: failed to insert recipient", "error", err) return nil, errs.Wrap(err) } recipientID = id // add to any groups specified in the request if err := s.syncUserGroupMembership(ctx, companyID, recipientID, scimUser.Groups); err != nil { s.Logger.Warnw("scim create user: failed to sync group membership", "error", err) } created, err := s.RecipientRepository.GetByID(ctx, recipientID, &repository.RecipientOption{}) if err != nil { s.Logger.Errorw("scim create user: failed to reload recipient", "error", err) return nil, errs.Wrap(err) } // note: active=false on create is not separately representable — a recipient // either exists (active) or is deprovisioned (deleted). the resource is still // created so the IdP receives a retrievable 201 response. s.auditScim("Scim.CreateUser", config, map[string]any{"recipientID": recipientID.String()}) u := recipientToScimUser(created, baseURL) return &u, nil } // deprovisionedUserResponse builds the SCIM body returned after a user has been // deprovisioned via active=false. Microsoft Entra sends a disable (PATCH // active=false) and expects a 200 response with the resource showing // active=false. Returning 404 makes Entra log the disable as a failure and retry // it every sync cycle, so the recipient is marked disabled (soft-deleted) and a // success body with active=false is returned. func deprovisionedUserResponse(existing *model.Recipient, baseURL string) *ScimUser { u := recipientToScimUser(existing, baseURL) u.Active = false return &u } // ReplaceUser performs a full replacement (PUT) of an existing recipient from // a SCIM User resource. group membership is replaced if a groups array is present. func (s *Scim) ReplaceUser( ctx context.Context, companyID *uuid.UUID, config *model.CompanyScimConfig, recipientID *uuid.UUID, scimUser *ScimUser, baseURL string, ) (*ScimUser, error) { existing, err := s.RecipientRepository.GetByID(ctx, recipientID, &repository.RecipientOption{}) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errs.Wrap(gorm.ErrRecordNotFound) } return nil, errs.Wrap(err) } // ensure the recipient belongs to this company rCompanyID, compErr := existing.CompanyID.Get() if compErr != nil || rCompanyID != *companyID { return nil, errs.Wrap(gorm.ErrRecordNotFound) } // a PUT with active=false is a deprovision request — mark the recipient disabled // but return 200 with active=false so the IdP records the disable as a success if !scimUser.Active { if err := s.deprovisionRecipient(ctx, recipientID); err != nil { return nil, errs.Wrap(err) } s.auditScim("Scim.DeprovisionUser", config, map[string]any{"recipientID": recipientID.String(), "via": "replace"}) return deprovisionedUserResponse(existing, baseURL), nil } // active=true: revive a previously soft-deleted recipient if existing.ScimSoftDeletedAt != nil { if err := s.RecipientRepository.ClearScimSoftDeleted(ctx, recipientID); err != nil { return nil, errs.Wrap(err) } existing.ScimSoftDeletedAt = nil } if err := s.applyScimUserToRecipient(ctx, existing, scimUser); err != nil { return nil, err } if len(scimUser.Groups) > 0 { if err := s.syncUserGroupMembership(ctx, companyID, recipientID, scimUser.Groups); err != nil { s.Logger.Warnw("scim replace user: failed to sync group membership", "error", err) } } updated, err := s.RecipientRepository.GetByID(ctx, recipientID, &repository.RecipientOption{}) if err != nil { return nil, errs.Wrap(err) } s.auditScim("Scim.ReplaceUser", config, map[string]any{"recipientID": recipientID.String()}) u := recipientToScimUser(updated, baseURL) return &u, nil } // PatchUser applies a SCIM PatchOp to an existing recipient. // supported operations: replace on top-level attributes and the active flag. // active=false removes the recipient from all scim-managed groups (safe deprovision). func (s *Scim) PatchUser( ctx context.Context, companyID *uuid.UUID, config *model.CompanyScimConfig, recipientID *uuid.UUID, patch *ScimPatchOp, baseURL string, ) (*ScimUser, error) { existing, err := s.RecipientRepository.GetByID(ctx, recipientID, &repository.RecipientOption{}) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, errs.Wrap(gorm.ErrRecordNotFound) } return nil, errs.Wrap(err) } // ensure the recipient belongs to this company rCompanyID, compErr := existing.CompanyID.Get() if compErr != nil || rCompanyID != *companyID { return nil, errs.Wrap(gorm.ErrRecordNotFound) } for _, op := range patch.Operations { switch strings.ToLower(op.Op) { case "replace", "add": deactivated, err := s.applyPatchOperation(ctx, existing, config, recipientID, op) if err != nil { return nil, err } // active=false marks the recipient disabled; return 200 with active=false // so the IdP records the disable as a success instead of retrying if deactivated { s.auditScim("Scim.DeprovisionUser", config, map[string]any{"recipientID": recipientID.String(), "via": "patch"}) return deprovisionedUserResponse(existing, baseURL), nil } case "remove": // remove op on "active" means deactivate — mark the recipient disabled if strings.EqualFold(op.Path, "active") { if err := s.deprovisionRecipient(ctx, recipientID); err != nil { return nil, errs.Wrap(err) } s.auditScim("Scim.DeprovisionUser", config, map[string]any{"recipientID": recipientID.String(), "via": "patch"}) return deprovisionedUserResponse(existing, baseURL), nil } } } updated, err := s.RecipientRepository.GetByID(ctx, recipientID, &repository.RecipientOption{}) if err != nil { return nil, errs.Wrap(err) } s.auditScim("Scim.PatchUser", config, map[string]any{"recipientID": recipientID.String()}) u := recipientToScimUser(updated, baseURL) return &u, nil } // DeprovisionUser marks a SCIM-provisioned recipient as disabled (soft-deleted) // during the retention grace period rather than deleting it outright. The // recipient is excluded from sending and targeting but stays retrievable; a // subsequent GET returns 200 with active=false until the prune window elapses. func (s *Scim) DeprovisionUser( ctx context.Context, companyID *uuid.UUID, config *model.CompanyScimConfig, recipientID *uuid.UUID, ) error { existing, err := s.RecipientRepository.GetByID(ctx, recipientID, &repository.RecipientOption{}) if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return errs.Wrap(gorm.ErrRecordNotFound) } return errs.Wrap(err) } // ensure the recipient belongs to this company rCompanyID, compErr := existing.CompanyID.Get() if compErr != nil || rCompanyID != *companyID { return errs.Wrap(gorm.ErrRecordNotFound) } if err := s.deprovisionRecipient(ctx, recipientID); err != nil { return errs.Wrap(err) } s.auditScim("Scim.DeprovisionUser", config, map[string]any{"recipientID": recipientID.String(), "via": "delete"}) return nil } // VerifyAndLoadConfig authenticates the bearer token against the stored hash // for the given company and returns the active SCIM config. // returns (config, authed, error). authed is false when the token is wrong. func (s *Scim) VerifyAndLoadConfig( ctx context.Context, companyID *uuid.UUID, plainToken string, ) (*model.CompanyScimConfig, bool, error) { ok, config, err := s.CompanyScimConfigService.VerifyToken(ctx, companyID, plainToken) if err != nil { return nil, false, err } if !ok { return nil, false, nil } if config == nil || !config.Enabled { return config, false, nil } return config, true, nil } // UpdateLastSync stamps the last sync time for the config func (s *Scim) UpdateLastSync(ctx context.Context, config *model.CompanyScimConfig) { id, err := config.ID.Get() if err != nil { return } if err := s.CompanyScimConfigRepository.UpdateLastSyncAt(ctx, &id); err != nil { s.Logger.Warnw("scim: failed to update last_sync_at", "error", err, "configID", id.String()) } } // ── helpers ─────────────────────────────────────────────────────────────────── // deprovisionRecipient marks a recipient disabled (soft-deleted) and cancels its // pending sends. shared by DELETE, PUT active=false and PATCH active=false. func (s *Scim) deprovisionRecipient(ctx context.Context, recipientID *uuid.UUID) error { // mark the recipient as SCIM soft-deleted; the anonymizing delete runs only // after the retention grace period (scheduled job or on-demand prune) if err := s.RecipientRepository.MarkScimSoftDeleted(ctx, recipientID, time.Now()); err != nil { return err } // cancel pending sends in active campaigns so no email reaches the disabled // recipient; the rows are kept (cancelled, not deleted) so stats and any // already-sent tracking links stay consistent return s.CampaignRecipientRepository.CancelInActiveCampaigns(ctx, recipientID) } // reviveIfSoftDeleted clears the soft-delete mark when the IdP re-activates a // recipient. It is a no-op when the recipient is not soft-deleted. func (s *Scim) reviveIfSoftDeleted(ctx context.Context, existing *model.Recipient, recipientID *uuid.UUID) error { if existing.ScimSoftDeletedAt == nil { return nil } if err := s.RecipientRepository.ClearScimSoftDeleted(ctx, recipientID); err != nil { return err } existing.ScimSoftDeletedAt = nil return nil } // pruneSoftDeleted runs the anonymizing delete for SCIM-disabled recipients whose // scim_soft_deleted_at is before the given cutoff. A nil companyID covers all // companies. No authorization check — callers are responsible for that. func (s *Scim) pruneSoftDeleted(ctx context.Context, companyID *uuid.UUID, before time.Time) (int, error) { recipients, err := s.RecipientRepository.GetScimSoftDeletedBefore(ctx, companyID, before) if err != nil { return 0, errs.Wrap(err) } pruned := 0 for _, r := range recipients { id, idErr := r.ID.Get() if idErr != nil { continue } if delErr := s.RecipientService.deleteRecipientByID(ctx, &id); delErr != nil { s.Logger.Errorw("scim prune: failed to delete soft-deleted recipient", "error", delErr, "recipientID", id.String()) continue } pruned++ } return pruned, nil } // PruneExpiredSoftDeleted prunes disabled recipients across all companies whose // retention window has elapsed. Used by the scheduled system task. func (s *Scim) PruneExpiredSoftDeleted(ctx context.Context, session *model.Session) (int, error) { ae := NewAuditEvent("Scim.PruneExpiredSoftDeleted", session) isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil { s.LogAuthError(err) return 0, errs.Wrap(err) } if !isAuthorized { s.AuditLogNotAuthorized(ae) return 0, errs.ErrAuthorizationFailed } days, err := s.OptionService.GetScimSoftDeleteRetentionDaysInternal(ctx) if err != nil { return 0, errs.Wrap(err) } cutoff := time.Now().Add(-time.Duration(days) * 24 * time.Hour) pruned, err := s.pruneSoftDeleted(ctx, nil, cutoff) if err != nil { return 0, err } if pruned > 0 { ae.Details["pruned"] = pruned s.AuditLogAuthorized(ae) } return pruned, nil } // PruneSoftDeletedAuthorized is the admin (session-authenticated) on-demand prune // for a company. Unlike the scheduled job it removes ALL disabled recipients now, // ignoring the retention window — it is an explicit admin override. func (s *Scim) PruneSoftDeletedAuthorized( ctx context.Context, session *model.Session, companyID *uuid.UUID, ) (int, error) { ae := NewAuditEvent("Scim.PruneSoftDeletedAuthorized", session) if companyID != nil { ae.Details["companyID"] = companyID.String() } isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil { s.LogAuthError(err) return 0, errs.Wrap(err) } if !isAuthorized { s.AuditLogNotAuthorized(ae) return 0, errs.ErrAuthorizationFailed } pruned, err := s.pruneSoftDeleted(ctx, companyID, time.Now()) if err != nil { return 0, err } ae.Details["pruned"] = pruned s.AuditLogAuthorized(ae) return pruned, nil } // RestoreSoftDeletedAuthorized is the admin (session-authenticated) on-demand // restore for a company. It clears the SCIM-disabled mark from all currently // disabled recipients, returning them to active without re-provisioning. func (s *Scim) RestoreSoftDeletedAuthorized( ctx context.Context, session *model.Session, companyID *uuid.UUID, ) (int, error) { ae := NewAuditEvent("Scim.RestoreSoftDeletedAuthorized", session) if companyID != nil { ae.Details["companyID"] = companyID.String() } isAuthorized, err := IsAuthorized(session, data.PERMISSION_ALLOW_GLOBAL) if err != nil { s.LogAuthError(err) return 0, errs.Wrap(err) } if !isAuthorized { s.AuditLogNotAuthorized(ae) return 0, errs.ErrAuthorizationFailed } restored, err := s.restoreSoftDeleted(ctx, companyID, time.Now()) if err != nil { return 0, err } ae.Details["restored"] = restored s.AuditLogAuthorized(ae) return restored, nil } // restoreSoftDeleted clears the disabled mark for every recipient of the company // whose scim_soft_deleted_at is set before the given time. func (s *Scim) restoreSoftDeleted(ctx context.Context, companyID *uuid.UUID, before time.Time) (int, error) { recipients, err := s.RecipientRepository.GetScimSoftDeletedBefore(ctx, companyID, before) if err != nil { return 0, errs.Wrap(err) } restored := 0 for _, r := range recipients { id, idErr := r.ID.Get() if idErr != nil { continue } if clearErr := s.RecipientRepository.ClearScimSoftDeleted(ctx, &id); clearErr != nil { s.Logger.Errorw("scim restore: failed to clear soft-deleted recipient", "error", clearErr, "recipientID", id.String()) continue } restored++ } return restored, nil } // auditScim emits an audit event for an externally driven SCIM mutation. // SCIM has no admin session, so the actor is identified by the company and the // token prefix instead of a user id. func (s *Scim) auditScim(name string, config *model.CompanyScimConfig, details map[string]any) { ae := NewAuditEvent(name, nil) ae.Details["actor"] = "scim" if config != nil { if cid, err := config.CompanyID.Get(); err == nil { ae.Details["companyID"] = cid.String() } if tp, err := config.TokenPrefix.Get(); err == nil { ae.Details["scimTokenPrefix"] = tp } } for k, v := range details { ae.Details[k] = v } s.AuditLogAuthorized(ae) } // groupsForRecipient returns the ScimUserGroup list for a recipient by scanning // all company groups for membership. func (s *Scim) groupsForRecipient( ctx context.Context, companyID *uuid.UUID, recipientID *uuid.UUID, baseURL string, ) []ScimUserGroup { groups, err := s.RecipientGroupRepository.GetAllByCompanyID(ctx, companyID, &repository.RecipientGroupOption{ WithRecipients: true, }) if err != nil { s.Logger.Warnw("scim: failed to load groups for recipient membership", "error", err) return nil } var result []ScimUserGroup for _, g := range groups { for _, r := range g.Recipients { rid, ridErr := r.ID.Get() if ridErr != nil { continue } if rid == *recipientID { gid, gidErr := g.ID.Get() if gidErr != nil { continue } name := "" if n, err := g.Name.Get(); err == nil { name = n.String() } ref := "" if baseURL != "" { ref = baseURL + "/Groups/" + gid.String() } result = append(result, ScimUserGroup{ Value: gid.String(), Display: name, Ref: ref, }) break } } } return result } // syncUserGroupMembership adds the recipient to all groups referenced in the // groups array. groups that do not belong to this company are silently skipped. func (s *Scim) syncUserGroupMembership( ctx context.Context, companyID *uuid.UUID, recipientID *uuid.UUID, groups []ScimUserGroup, ) error { for _, g := range groups { if g.Value == "" { continue } gid, err := uuid.Parse(g.Value) if err != nil { continue } // verify the group belongs to this company before adding group, err := s.RecipientGroupRepository.GetByID(ctx, &gid, &repository.RecipientGroupOption{}) if err != nil { s.Logger.Warnw("scim sync group membership: group not found, skipping", "groupID", gid.String()) continue } gCompanyID, compErr := group.CompanyID.Get() if compErr != nil || gCompanyID != *companyID { s.Logger.Warnw("scim sync group membership: group does not belong to company, skipping", "groupID", gid.String()) continue } if err := s.RecipientGroupRepository.AddRecipients(ctx, &gid, []*uuid.UUID{recipientID}); err != nil { return errs.Wrap(err) } } return nil } // applyGroupMembers adds a list of SCIM member entries to a group. // members referencing recipients that do not belong to companyID are skipped. func (s *Scim) applyGroupMembers( ctx context.Context, companyID *uuid.UUID, groupID *uuid.UUID, members []ScimGroupMember, ) error { for _, m := range members { if m.Value == "" { continue } rid, err := uuid.Parse(m.Value) if err != nil { continue } // verify the recipient belongs to the same company as the SCIM token recipient, err := s.RecipientRepository.GetByID(ctx, &rid, &repository.RecipientOption{}) if err != nil { s.Logger.Warnw("scim apply group members: recipient not found, skipping", "recipientID", rid.String()) continue } rCompanyID, compErr := recipient.CompanyID.Get() if compErr != nil || rCompanyID != *companyID { s.Logger.Warnw("scim apply group members: recipient does not belong to company, skipping", "recipientID", rid.String()) continue } if err := s.RecipientGroupRepository.AddRecipients(ctx, groupID, []*uuid.UUID{&rid}); err != nil { s.Logger.Errorw("scim apply group members: failed to add recipient", "error", err, "recipientID", rid.String()) return errs.Wrap(err) } } return nil } // removeGroupMembers removes a list of SCIM member entries from a group. // recipients that do not belong to companyID are skipped. func (s *Scim) removeGroupMembers( ctx context.Context, companyID *uuid.UUID, groupID *uuid.UUID, members []ScimGroupMember, ) error { ids := make([]*uuid.UUID, 0, len(members)) for _, m := range members { if m.Value == "" { continue } rid, err := uuid.Parse(m.Value) if err != nil { continue } // verify the recipient belongs to the same company as the SCIM token recipient, err := s.RecipientRepository.GetByID(ctx, &rid, &repository.RecipientOption{}) if err != nil { s.Logger.Warnw("scim remove group members: recipient not found, skipping", "recipientID", rid.String()) continue } rCompanyID, compErr := recipient.CompanyID.Get() if compErr != nil || rCompanyID != *companyID { s.Logger.Warnw("scim remove group members: recipient does not belong to company, skipping", "recipientID", rid.String()) continue } idCopy := rid ids = append(ids, &idCopy) } if len(ids) == 0 { return nil } return s.RecipientGroupRepository.RemoveRecipients(ctx, groupID, ids) } // replaceGroupMembers removes all existing members and adds the new set. func (s *Scim) replaceGroupMembers( ctx context.Context, companyID *uuid.UUID, groupID *uuid.UUID, existing []*model.Recipient, incoming []ScimGroupMember, ) error { // remove current members currentIDs := make([]*uuid.UUID, 0, len(existing)) for _, r := range existing { rid, err := r.ID.Get() if err != nil { continue } idCopy := rid currentIDs = append(currentIDs, &idCopy) } if len(currentIDs) > 0 { // existing members were already verified when they were added; remove directly if err := s.RecipientGroupRepository.RemoveRecipients(ctx, groupID, currentIDs); err != nil { return errs.Wrap(err) } } return s.applyGroupMembers(ctx, companyID, groupID, incoming) } // applyGroupPatchReplace handles a replace operation on a group patch. func (s *Scim) applyGroupPatchReplace( ctx context.Context, companyID *uuid.UUID, existing *model.RecipientGroup, groupID *uuid.UUID, op ScimGroupPatchOpItem, ) error { path := strings.ToLower(op.Path) switch path { case "displayname": name := stringFromPatchValue(op.Value) if name == "" { return nil } nameVO, err := vo.NewString127(name) if err != nil { return errs.NewValidationError(fmt.Errorf("displayName too long")) } existing.Name = nullable.NewNullableWithValue(*nameVO) if err := s.RecipientGroupRepository.UpdateByID(ctx, groupID, existing); err != nil { return errs.Wrap(err) } case "members": members := groupMembersFromPatchValue(op.Value) if err := s.replaceGroupMembers(ctx, companyID, groupID, existing.Recipients, members); err != nil { return err } case "": // no path — value is a map of attributes if m, ok := op.Value.(map[string]any); ok { if dn, ok := m["displayName"].(string); ok && dn != "" { nameVO, err := vo.NewString127(dn) if err != nil { return errs.NewValidationError(fmt.Errorf("displayName too long")) } existing.Name = nullable.NewNullableWithValue(*nameVO) if err := s.RecipientGroupRepository.UpdateByID(ctx, groupID, existing); err != nil { return errs.Wrap(err) } } } } return nil } // groupMembersFromPatchValue coerces a PatchOp value to []ScimGroupMember. // the IdP may send either a slice of objects or a single object. func groupMembersFromPatchValue(v any) []ScimGroupMember { if v == nil { return nil } // slice of maps if items, ok := v.([]any); ok { result := make([]ScimGroupMember, 0, len(items)) for _, item := range items { if m, ok := item.(map[string]any); ok { val, _ := m["value"].(string) display, _ := m["display"].(string) result = append(result, ScimGroupMember{Value: val, Display: display}) } } return result } // single map if m, ok := v.(map[string]any); ok { val, _ := m["value"].(string) display, _ := m["display"].(string) return []ScimGroupMember{{Value: val, Display: display}} } return nil } // groupMembersFromPatchPath handles both the plain value array form and the // filter path form "members[value eq \"\"]" used by some IdPs for remove ops. func groupMembersFromPatchPath(path string, v any) []ScimGroupMember { // filter path form: members[value eq ""] lower := strings.ToLower(strings.TrimSpace(path)) const filterPrefix = "members[value eq \"" if strings.HasPrefix(lower, filterPrefix) { inner := path[len(filterPrefix):] inner = strings.TrimSuffix(inner, "\"]") inner = strings.TrimSuffix(inner, "\"]") if inner != "" { return []ScimGroupMember{{Value: inner}} } } // plain "members" path — fall back to parsing the value array return groupMembersFromPatchValue(v) } // buildGroupsByRecipient builds a map from recipient UUID to the list of // ScimUserGroup entries the recipient belongs to. func buildGroupsByRecipient(groups []*model.RecipientGroup) map[uuid.UUID][]ScimUserGroup { result := make(map[uuid.UUID][]ScimUserGroup) for _, g := range groups { gid, err := g.ID.Get() if err != nil { continue } name := "" if n, err := g.Name.Get(); err == nil { name = n.String() } for _, r := range g.Recipients { rid, err := r.ID.Get() if err != nil { continue } result[rid] = append(result[rid], ScimUserGroup{ Value: gid.String(), Display: name, }) } } return result } // applyScimUserToRecipient writes the mutable SCIM User fields onto an existing // recipient model and persists the changes via the repository. func (s *Scim) applyScimUserToRecipient( ctx context.Context, existing *model.Recipient, scimUser *ScimUser, ) error { // PUT is a full replace (RFC 7644 §3.5.1): attributes absent from the // request are cleared. email is the one exception — it is required, so an // absent or invalid email leaves the existing address untouched. existing.ScimUserName.Set(*vo.NewOptionalString127Must(truncate(scimUserNameFrom(scimUser), 127))) // email — stored lowercased for case-insensitive matching if email, err := canonicalEmailLower(scimUser); err == nil && email != "" { if ev, err := vo.NewEmail(email); err == nil { existing.Email.Set(*ev) } } // first and last name existing.FirstName.Set(*vo.NewOptionalString127Must(truncate(firstNameFrom(scimUser), 127))) existing.LastName.Set(*vo.NewOptionalString127Must(truncate(lastNameFrom(scimUser), 127))) // phone existing.Phone.Set(*vo.NewOptionalString127Must(truncate(primaryPhoneFrom(scimUser), 127))) // department from the enterprise extension; job title from the core title // attribute (where Entra puts it by default) with the enterprise title as fallback department := "" if scimUser.EnterpriseUser != nil { department = scimUser.EnterpriseUser.Department } existing.Department.Set(*vo.NewOptionalString127Must(truncate(department, 127))) existing.Position.Set(*vo.NewOptionalString127Must(truncate(jobTitleFrom(scimUser), 127))) // addresses — city and country from primary/work address city, country := primaryAddressFrom(scimUser) existing.City.Set(*vo.NewOptionalString127Must(truncate(city, 127))) existing.Country.Set(*vo.NewOptionalString127Must(truncate(country, 127))) // externalId -> extra_identifier existing.ExtraIdentifier.Set(*vo.NewOptionalString127Must(truncate(scimUser.ExternalID, 127))) // misc from custom extension misc := "" if scimUser.CustomExtension != nil { misc = scimUser.CustomExtension.Misc } existing.Misc.Set(*vo.NewOptionalString127Must(truncate(misc, 127))) id := existing.ID.MustGet() if err := s.RecipientRepository.UpdateByID(ctx, &id, existing); err != nil { s.Logger.Errorw("scim apply user: failed to update recipient", "error", err) return errs.Wrap(err) } return nil } // primaryAddressFrom extracts city and country from the addresses array. // prefers primary=true, then type="work", then the first entry. func primaryAddressFrom(u *ScimUser) (city, country string) { var best *ScimAddress for i := range u.Addresses { a := &u.Addresses[i] if a.Primary { best = a break } } if best == nil { for i := range u.Addresses { a := &u.Addresses[i] if strings.EqualFold(a.Type, "work") { best = a break } } } if best == nil && len(u.Addresses) > 0 { best = &u.Addresses[0] } if best == nil { return "", "" } return best.Locality, best.Country } // applyPatchOperation handles a single replace/add PatchOp operation on a recipient. // returns (deactivated bool, error) — deactivated is true when active=false triggers // a hard-delete so the caller can short-circuit without trying to reload the recipient. func (s *Scim) applyPatchOperation( ctx context.Context, existing *model.Recipient, config *model.CompanyScimConfig, recipientID *uuid.UUID, op ScimPatchOpItem, ) (bool, error) { path := strings.ToLower(op.Path) // handle active flag — false means deprovision the recipient if path == "active" { active := boolFromPatchValue(op.Value) if !active { return true, s.deprovisionRecipient(ctx, recipientID) } // active=true revives a soft-deleted recipient; otherwise a no-op return false, s.reviveIfSoftDeleted(ctx, existing, recipientID) } // for no path, value is expected to be a map of attribute → value if op.Path == "" { if m, ok := op.Value.(map[string]any); ok { // check for active=false inside the map before applying other fields if rawActive, ok := m["active"]; ok && !boolFromPatchValue(rawActive) { return true, s.deprovisionRecipient(ctx, recipientID) } // active=true in the map revives a soft-deleted recipient if rawActive, ok := m["active"]; ok && boolFromPatchValue(rawActive) { if err := s.reviveIfSoftDeleted(ctx, existing, recipientID); err != nil { return false, err } } if err := s.applyAttributeMap(ctx, existing, config, recipientID, m); err != nil { return false, err } } id := existing.ID.MustGet() if err := s.RecipientRepository.UpdateByID(ctx, &id, existing); err != nil { return false, errs.Wrap(err) } return false, nil } // single attribute path — only apply values that map to our data model strVal := stringFromPatchValue(op.Value) switch path { case "username": existing.ScimUserName.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) case "emails[type eq \"work\"].value", "emails": if ev, err := vo.NewEmail(strings.ToLower(strings.TrimSpace(strVal))); err == nil { existing.Email.Set(*ev) } case "name.givenname": existing.FirstName.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) case "name.familyname": existing.LastName.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) case "name.formatted": // split formatted into first/last only when individual names are not already set parts := strings.SplitN(strVal, " ", 2) existingFirst := "" if v, err := existing.FirstName.Get(); err == nil { existingFirst = v.String() } existingLast := "" if v, err := existing.LastName.Get(); err == nil { existingLast = v.String() } if existingFirst == "" && len(parts) >= 1 && parts[0] != "" { existing.FirstName.Set(*vo.NewOptionalString127Must(truncate(parts[0], 127))) } if existingLast == "" && len(parts) == 2 && parts[1] != "" { existing.LastName.Set(*vo.NewOptionalString127Must(truncate(parts[1], 127))) } // home/other typed emails and phones are not stored — silently ignore case "phonenumbers[type eq \"work\"].value", "phonenumbers": existing.Phone.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:department": existing.Department.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) case "title", "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:title": existing.Position.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) case "addresses[type eq \"work\"].locality", "addresses.locality": existing.City.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) case "addresses[type eq \"work\"].country", "addresses.country": existing.Country.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) // home/other typed addresses are not stored — silently ignore case "externalid": existing.ExtraIdentifier.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) case "urn:ietf:params:scim:schemas:extension:phishingclub:2.0:user:misc": existing.Misc.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) } id := existing.ID.MustGet() if err := s.RecipientRepository.UpdateByID(ctx, &id, existing); err != nil { return false, errs.Wrap(err) } return false, nil } // applyAttributeMap applies a flat attribute map from a no-path PatchOp. // only attributes that map to our data model are persisted; others are silently ignored. func (s *Scim) applyAttributeMap( _ context.Context, existing *model.Recipient, config *model.CompanyScimConfig, recipientID *uuid.UUID, m map[string]any, ) error { // collect name sub-attributes first so we can merge them correctly givenName := "" familyName := "" formattedName := "" for k, v := range m { strVal := fmt.Sprintf("%v", v) switch strings.ToLower(k) { case "username": // update the stored scim userName so it round-trips exactly existing.ScimUserName.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) case "active": // active flag handled at the PatchUser call site after this map is applied case "externalid": existing.ExtraIdentifier.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) case "displayname": // displayname is not a stored field; use it as a fallback for name only // if the IdP also sends name.givenName / name.familyName those take priority _ = strVal case "name.givenname": givenName = strVal case "name.familyname": familyName = strVal case "name.formatted": formattedName = strVal case "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:department": existing.Department.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) case "title", "urn:ietf:params:scim:schemas:extension:enterprise:2.0:user:title": existing.Position.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) case "urn:ietf:params:scim:schemas:extension:phishingclub:2.0:user:misc": existing.Misc.Set(*vo.NewOptionalString127Must(truncate(strVal, 127))) } } // apply name fields — explicit sub-attributes take priority over formatted if givenName != "" { existing.FirstName.Set(*vo.NewOptionalString127Must(truncate(givenName, 127))) } if familyName != "" { existing.LastName.Set(*vo.NewOptionalString127Must(truncate(familyName, 127))) } // use formatted only as a fallback when explicit names were not provided if givenName == "" && familyName == "" && formattedName != "" { parts := strings.SplitN(formattedName, " ", 2) if len(parts) >= 1 && parts[0] != "" { existing.FirstName.Set(*vo.NewOptionalString127Must(truncate(parts[0], 127))) } if len(parts) == 2 && parts[1] != "" { existing.LastName.Set(*vo.NewOptionalString127Must(truncate(parts[1], 127))) } } return nil } // ── SCIM <-> model conversion helpers ───────────────────────────────────────── // recipientToScimUser maps a model.Recipient to a ScimUser func recipientToScimUser(r *model.Recipient, baseURL string) ScimUser { id := "" if rid, err := r.ID.Get(); err == nil { id = rid.String() } emailStr := "" if e, err := r.Email.Get(); err == nil { emailStr = e.String() } // prefer the stored scim userName; fall back to the email address userNameStr := emailStr if v, err := r.ScimUserName.Get(); err == nil && v.String() != "" { userNameStr = v.String() } firstName := "" if v, err := r.FirstName.Get(); err == nil { firstName = v.String() } lastName := "" if v, err := r.LastName.Get(); err == nil { lastName = v.String() } var name *ScimName if firstName != "" || lastName != "" { name = &ScimName{ GivenName: firstName, FamilyName: lastName, Formatted: strings.TrimSpace(firstName + " " + lastName), } } var emails []ScimEmail if emailStr != "" { emails = []ScimEmail{{Value: emailStr, Type: "work", Primary: true}} } var phones []ScimPhoneNumber if v, err := r.Phone.Get(); err == nil && v.String() != "" { phones = []ScimPhoneNumber{{Value: v.String(), Type: "work", Primary: true}} } var enterprise *ScimEnterpriseUser dept := "" if v, err := r.Department.Get(); err == nil { dept = v.String() } pos := "" if v, err := r.Position.Get(); err == nil { pos = v.String() } if dept != "" || pos != "" { enterprise = &ScimEnterpriseUser{ Department: dept, Title: pos, } } // addresses — map city + country to a single work address entry var addresses []ScimAddress city := "" if v, err := r.City.Get(); err == nil { city = v.String() } country := "" if v, err := r.Country.Get(); err == nil { country = v.String() } if city != "" || country != "" { addresses = []ScimAddress{{ Type: "work", Locality: city, Country: country, Primary: true, }} } externalID := "" if v, err := r.ExtraIdentifier.Get(); err == nil { externalID = v.String() } // custom extension — misc var custom *ScimCustomExtension if v, err := r.Misc.Get(); err == nil && v.String() != "" { custom = &ScimCustomExtension{Misc: v.String()} } schemas := []string{scimSchemaUser} if enterprise != nil { schemas = append(schemas, scimSchemaEnterpriseUser) } if custom != nil { schemas = append(schemas, scimSchemaCustomExtension) } u := ScimUser{ Schemas: schemas, ID: id, UserName: userNameStr, Name: name, Title: pos, Emails: emails, PhoneNumbers: phones, EnterpriseUser: enterprise, Addresses: addresses, Active: r.ScimSoftDeletedAt == nil, ExternalID: externalID, CustomExtension: custom, } if baseURL != "" && id != "" { u.Meta = &ScimMeta{ ResourceType: scimResourceTypeUser, Location: baseURL + "/Users/" + id, } } return u } // jobTitleFrom returns the user's job title. Microsoft Entra maps the directory // jobTitle to the core SCIM "title" attribute by default, so that is preferred; // the enterprise extension title is used as a fallback for IdPs that send it there. func jobTitleFrom(u *ScimUser) string { if u.Title != "" { return u.Title } if u.EnterpriseUser != nil { return u.EnterpriseUser.Title } return "" } // scimUserToRecipient creates a new model.Recipient from a ScimUser func scimUserToRecipient(scimUser *ScimUser, companyID *uuid.UUID) *model.Recipient { r := &model.Recipient{} // store the original userName so it round-trips exactly r.ScimUserName = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(scimUserNameFrom(scimUser), 127))) // email is stored lowercased so dedup and the unique index are case // insensitive; the original userName case is preserved in scim_user_name emailStr, _ := canonicalEmailLower(scimUser) if emailStr != "" { if ev, err := vo.NewEmail(emailStr); err == nil { r.Email = nullable.NewNullableWithValue(*ev) } } r.FirstName = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(firstNameFrom(scimUser), 127))) r.LastName = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(lastNameFrom(scimUser), 127))) if phone := primaryPhoneFrom(scimUser); phone != "" { r.Phone = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(phone, 127))) } else { r.Phone = nullable.NewNullableWithValue(*vo.NewOptionalString127Must("")) } department := "" if scimUser.EnterpriseUser != nil { department = scimUser.EnterpriseUser.Department } r.Department = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(department, 127))) r.Position = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(jobTitleFrom(scimUser), 127))) // addresses — prefer work, fall back to first entry city, country := primaryAddressFrom(scimUser) r.City = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(city, 127))) r.Country = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(country, 127))) if scimUser.ExternalID != "" { r.ExtraIdentifier = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(scimUser.ExternalID, 127))) } else { r.ExtraIdentifier = nullable.NewNullableWithValue(*vo.NewOptionalString127Must("")) } // custom extension — misc if scimUser.CustomExtension != nil && scimUser.CustomExtension.Misc != "" { r.Misc = nullable.NewNullableWithValue(*vo.NewOptionalString127Must(truncate(scimUser.CustomExtension.Misc, 127))) } else { r.Misc = nullable.NewNullableWithValue(*vo.NewOptionalString127Must("")) } if companyID != nil { r.CompanyID = nullable.NewNullableWithValue(*companyID) } return r } // scimUserNameFrom returns the raw userName value to persist as-is. func scimUserNameFrom(u *ScimUser) string { return strings.TrimSpace(u.UserName) } // isScimUniqueConflict returns true when the error indicates a unique constraint // violation, which means a resource with that identifier already exists. func isScimUniqueConflict(err error) bool { if err == nil { return false } msg := strings.ToLower(err.Error()) return strings.Contains(msg, "unique") || strings.Contains(msg, "duplicate") } // canonicalEmail extracts the canonical email address from a ScimUser, // preserving the original case sent by the IdP so userName round-trips exactly. // preference: first primary email, then first email, then userName. func canonicalEmail(u *ScimUser) (string, error) { for _, e := range u.Emails { if e.Primary && e.Value != "" { return strings.TrimSpace(e.Value), nil } } for _, e := range u.Emails { if e.Value != "" { return strings.TrimSpace(e.Value), nil } } if u.UserName != "" { return strings.TrimSpace(u.UserName), nil } return "", fmt.Errorf("scim user has no email or userName") } // canonicalEmailLower returns the lowercased canonical email, used only for // case-insensitive dedup lookups against existing recipients. func canonicalEmailLower(u *ScimUser) (string, error) { v, err := canonicalEmail(u) if err != nil { return "", err } return strings.ToLower(v), nil } // firstNameFrom extracts the given name from a ScimUser func firstNameFrom(u *ScimUser) string { if u.Name != nil && u.Name.GivenName != "" { return u.Name.GivenName } if u.DisplayName != "" { parts := strings.SplitN(u.DisplayName, " ", 2) if len(parts) >= 1 { return parts[0] } } return "" } // lastNameFrom extracts the family name from a ScimUser func lastNameFrom(u *ScimUser) string { if u.Name != nil && u.Name.FamilyName != "" { return u.Name.FamilyName } if u.DisplayName != "" { parts := strings.SplitN(u.DisplayName, " ", 2) if len(parts) == 2 { return parts[1] } } return "" } // primaryPhoneFrom extracts the primary (or first) phone number from a ScimUser func primaryPhoneFrom(u *ScimUser) string { for _, p := range u.PhoneNumbers { if p.Primary && p.Value != "" { return p.Value } } for _, p := range u.PhoneNumbers { if p.Value != "" { return p.Value } } return "" } // truncate truncates a string to max bytes without splitting a UTF-8 rune func truncate(s string, max int) string { if len(s) <= max { return s } // walk back to a valid rune boundary for i := max; i > 0; i-- { if s[i]&0xC0 != 0x80 { return s[:i] } } return "" } // scimFilterMatchesUser performs a very simple filter evaluation. // only "userName eq " and "externalId eq " are handled. // unrecognised filters always pass (return true) to be permissive. func scimFilterMatchesUser(filter string, u ScimUser) bool { lower := strings.ToLower(strings.TrimSpace(filter)) // e.g. userName eq "user@example.com" for _, attr := range []string{"username", "externalid"} { prefix := attr + " eq " if strings.HasPrefix(lower, prefix) { want := strings.Trim(lower[len(prefix):], `"' `) switch attr { case "username": return strings.EqualFold(u.UserName, want) case "externalid": return strings.EqualFold(u.ExternalID, want) } } } return true } // scimGroupFilterMatches evaluates a SCIM filter expression against a group. // only "displayName eq " is supported; unrecognised filters pass. func scimGroupFilterMatches(filter string, g *model.RecipientGroup) bool { lower := strings.ToLower(strings.TrimSpace(filter)) const prefix = "displayname eq " if strings.HasPrefix(lower, prefix) { want := strings.Trim(lower[len(prefix):], `"' `) name := "" if n, err := g.Name.Get(); err == nil { name = strings.ToLower(n.String()) } return name == want } return true } // scimExcludesAttribute returns true when the excludedAttributes query param // contains the named attribute (case-insensitive, comma-separated list). func scimExcludesAttribute(excludedAttributes, attr string) bool { if excludedAttributes == "" { return false } attrLower := strings.ToLower(attr) for _, part := range strings.Split(excludedAttributes, ",") { if strings.ToLower(strings.TrimSpace(part)) == attrLower { return true } } return false } // boolFromPatchValue coerces a PatchOp value to bool func boolFromPatchValue(v any) bool { switch val := v.(type) { case bool: return val case string: return strings.EqualFold(val, "true") case float64: return val != 0 } return false } // stringFromPatchValue coerces a PatchOp value to string func stringFromPatchValue(v any) string { if v == nil { return "" } return fmt.Sprintf("%v", v) } // scimSortUsers sorts a slice of ScimUser in-place by the given attribute. // only "username", "id", "name.familyname", and "name.givenname" are handled; // unrecognised attributes are ignored. sortOrder defaults to ascending. func scimSortUsers(users []ScimUser, sortBy string, sortOrder string) { descending := strings.EqualFold(sortOrder, "descending") key := strings.ToLower(sortBy) // insertion sort — swap when the left element is out of order relative to right for i := 1; i < len(users); i++ { for j := i; j > 0; j-- { a := strings.ToLower(scimUserSortKey(users[j-1], key)) b := strings.ToLower(scimUserSortKey(users[j], key)) // for ascending: swap when a > b (left is larger than right) // for descending: swap when a < b (left is smaller than right) outOfOrder := a > b if descending { outOfOrder = a < b } if outOfOrder { users[j-1], users[j] = users[j], users[j-1] } else { break } } } } // scimUserSortKey returns the string value of the requested sort attribute. func scimUserSortKey(u ScimUser, key string) string { switch key { case "username": return u.UserName case "id": return u.ID case "name.familyname": if u.Name != nil { return u.Name.FamilyName } case "name.givenname": if u.Name != nil { return u.Name.GivenName } case "displayname": return u.DisplayName } return "" }