Files
2026-06-12 10:30:52 +02:00

2417 lines
84 KiB
Go

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 \"<id>\"]" (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 \"<id>\"]" used by some IdPs for remove ops.
func groupMembersFromPatchPath(path string, v any) []ScimGroupMember {
// filter path form: members[value eq "<uuid>"]
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 <value>" and "externalId eq <value>" 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 <value>" 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 ""
}