mirror of
https://github.com/phishingclub/phishingclub.git
synced 2026-07-03 10:58:18 +02:00
02f87b3e3a
Signed-off-by: Ronni Skansing <rskansing@gmail.com>
2417 lines
84 KiB
Go
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 ""
|
|
}
|