From 086b28f4b6516f2d18c26b62c95771435f5eea60 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 10 Dec 2025 07:00:28 +0000 Subject: [PATCH 001/101] feat: add scim to sso providers, banner reason to users, and add scim_groups --- ...210100000_add_scim_to_sso_providers.up.sql | 10 ++++ ...10100001_add_banned_reason_to_users.up.sql | 6 +++ .../20251210100002_add_scim_groups.up.sql | 46 +++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 migrations/20251210100000_add_scim_to_sso_providers.up.sql create mode 100644 migrations/20251210100001_add_banned_reason_to_users.up.sql create mode 100644 migrations/20251210100002_add_scim_groups.up.sql diff --git a/migrations/20251210100000_add_scim_to_sso_providers.up.sql b/migrations/20251210100000_add_scim_to_sso_providers.up.sql new file mode 100644 index 000000000..a9f90195a --- /dev/null +++ b/migrations/20251210100000_add_scim_to_sso_providers.up.sql @@ -0,0 +1,10 @@ +-- Add SCIM provisioning support to SSO providers + +do $$ begin + alter table only {{ index .Options "Namespace" }}.sso_providers + add column if not exists scim_enabled boolean null default false, + add column if not exists scim_bearer_token_hash text null; +end $$; + +comment on column {{ index .Options "Namespace" }}.sso_providers.scim_enabled is 'Auth: Whether SCIM provisioning is enabled for this SSO provider'; +comment on column {{ index .Options "Namespace" }}.sso_providers.scim_bearer_token_hash is 'Auth: Bcrypt hash of the SCIM bearer token used by the IdP'; diff --git a/migrations/20251210100001_add_banned_reason_to_users.up.sql b/migrations/20251210100001_add_banned_reason_to_users.up.sql new file mode 100644 index 000000000..fbf6d8779 --- /dev/null +++ b/migrations/20251210100001_add_banned_reason_to_users.up.sql @@ -0,0 +1,6 @@ +do $$ begin + alter table only {{ index .Options "Namespace" }}.users + add column if not exists banned_reason text null; +end $$; + +comment on column {{ index .Options "Namespace" }}.users.banned_reason is 'Auth: Reason for user ban (e.g., SCIM_DEPROVISIONED)'; diff --git a/migrations/20251210100002_add_scim_groups.up.sql b/migrations/20251210100002_add_scim_groups.up.sql new file mode 100644 index 000000000..61094cdcd --- /dev/null +++ b/migrations/20251210100002_add_scim_groups.up.sql @@ -0,0 +1,46 @@ +-- Add SCIM Groups support for SSO identity providers + +create table if not exists {{ index .Options "Namespace" }}.scim_groups ( + id uuid not null, + sso_provider_id uuid not null, + external_id text not null, + display_name text not null, + created_at timestamptz null, + updated_at timestamptz null, + + constraint scim_groups_pkey primary key (id), + constraint scim_groups_sso_provider_fkey foreign key (sso_provider_id) + references {{ index .Options "Namespace" }}.sso_providers (id) on delete cascade, + constraint "external_id not empty" check (char_length(external_id) > 0), + constraint "display_name not empty" check (char_length(display_name) > 0) +); + +-- Unique index Scoped to SSO provider +create unique index if not exists scim_groups_sso_provider_external_id_idx + on {{ index .Options "Namespace" }}.scim_groups (sso_provider_id, external_id); + +-- Index for listing groups by SSO provider +create index if not exists scim_groups_sso_provider_id_idx + on {{ index .Options "Namespace" }}.scim_groups (sso_provider_id); + +comment on table {{ index .Options "Namespace" }}.scim_groups is 'Auth: Manages SCIM groups provisioned by SSO identity providers.'; +comment on column {{ index .Options "Namespace" }}.scim_groups.external_id is 'Auth: The group ID from the external identity provider.'; +comment on column {{ index .Options "Namespace" }}.scim_groups.display_name is 'Auth: Human-readable name of the group.'; + +create table if not exists {{ index .Options "Namespace" }}.scim_group_members ( + group_id uuid not null, + user_id uuid not null, + created_at timestamptz null, + + constraint scim_group_members_pkey primary key (group_id, user_id), + constraint scim_group_members_group_fkey foreign key (group_id) + references {{ index .Options "Namespace" }}.scim_groups (id) on delete cascade, + constraint scim_group_members_user_fkey foreign key (user_id) + references {{ index .Options "Namespace" }}.users (id) on delete cascade +); + +-- Index for groups that the user belong to +create index if not exists scim_group_members_user_id_idx + on {{ index .Options "Namespace" }}.scim_group_members (user_id); + +comment on table {{ index .Options "Namespace" }}.scim_group_members is 'Auth: Junction table for SCIM group membership.'; From 742aa8096e21131c3aaad20a98acb15a96fc28d5 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 10 Dec 2025 07:10:57 +0000 Subject: [PATCH 002/101] feat: add SCIM fields and methods to models --- internal/models/errors.go | 9 ++ internal/models/scim_group.go | 152 ++++++++++++++++++++++++++++++++++ internal/models/sso.go | 54 +++++++++++- internal/models/user.go | 19 +++-- 4 files changed, 225 insertions(+), 9 deletions(-) create mode 100644 internal/models/scim_group.go diff --git a/internal/models/errors.go b/internal/models/errors.go index 4f1c95e60..072eb26c9 100644 --- a/internal/models/errors.go +++ b/internal/models/errors.go @@ -23,6 +23,8 @@ func IsNotFoundError(err error) bool { return true case SAMLRelayStateNotFoundError, *SAMLRelayStateNotFoundError: return true + case SCIMGroupNotFoundError, *SCIMGroupNotFoundError: + return true case FlowStateNotFoundError, *FlowStateNotFoundError: return true case OneTimeTokenNotFoundError, *OneTimeTokenNotFoundError: @@ -108,6 +110,13 @@ func (e SAMLRelayStateNotFoundError) Error() string { return "SAML RelayState not found" } +// SCIMGroupNotFoundError represents an error when a SCIM group can't be found. +type SCIMGroupNotFoundError struct{} + +func (e SCIMGroupNotFoundError) Error() string { + return "SCIM Group not found" +} + // FlowStateNotFoundError represents an error when an FlowState can't be // found. type FlowStateNotFoundError struct{} diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go new file mode 100644 index 000000000..c8eb4619f --- /dev/null +++ b/internal/models/scim_group.go @@ -0,0 +1,152 @@ +package models + +import ( + "database/sql" + "time" + + "github.com/gofrs/uuid" + "github.com/pkg/errors" + "github.com/supabase/auth/internal/storage" +) + +type SCIMGroup struct { + ID uuid.UUID `db:"id" json:"id"` + SSOProviderID uuid.UUID `db:"sso_provider_id" json:"-"` + ExternalID string `db:"external_id" json:"external_id"` + DisplayName string `db:"display_name" json:"display_name"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + + SSOProvider *SSOProvider `belongs_to:"sso_providers" json:"-"` + Members []User `many_to_many:"scim_group_members" json:"members,omitempty"` +} + +func (SCIMGroup) TableName() string { + return "scim_groups" +} + +type SCIMGroupMember struct { + GroupID uuid.UUID `db:"group_id" json:"-"` + UserID uuid.UUID `db:"user_id" json:"-"` + CreatedAt time.Time `db:"created_at" json:"-"` +} + +func (SCIMGroupMember) TableName() string { + return "scim_group_members" +} + +func NewSCIMGroup(ssoProviderID uuid.UUID, externalID, displayName string) *SCIMGroup { + id := uuid.Must(uuid.NewV4()) + return &SCIMGroup{ + ID: id, + SSOProviderID: ssoProviderID, + ExternalID: externalID, + DisplayName: displayName, + } +} + +func FindSCIMGroupByID(tx *storage.Connection, id uuid.UUID) (*SCIMGroup, error) { + var group SCIMGroup + if err := tx.Find(&group, id); err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, SCIMGroupNotFoundError{} + } + return nil, errors.Wrap(err, "error finding SCIM group by ID") + } + return &group, nil +} + +func FindSCIMGroupByExternalID(tx *storage.Connection, ssoProviderID uuid.UUID, externalID string) (*SCIMGroup, error) { + var group SCIMGroup + if err := tx.Q().Where("sso_provider_id = ? AND external_id = ?", ssoProviderID, externalID).First(&group); err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, SCIMGroupNotFoundError{} + } + return nil, errors.Wrap(err, "error finding SCIM group by external ID") + } + return &group, nil +} + +func FindSCIMGroupsBySSOProvider(tx *storage.Connection, ssoProviderID uuid.UUID, page *Pagination) ([]*SCIMGroup, error) { + groups := []*SCIMGroup{} + q := tx.Q().Where("sso_provider_id = ?", ssoProviderID).Order("created_at ASC") + if page != nil { + q = q.Paginate(page.Page, page.PerPage) + } + if err := q.All(&groups); err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return []*SCIMGroup{}, nil + } + return nil, errors.Wrap(err, "error finding SCIM groups by SSO provider") + } + return groups, nil +} + +func FindSCIMGroupsForUser(tx *storage.Connection, userID uuid.UUID) ([]*SCIMGroup, error) { + groups := []*SCIMGroup{} + if err := tx.RawQuery(` + SELECT g.* FROM scim_groups g + INNER JOIN scim_group_members m ON g.id = m.group_id + WHERE m.user_id = ? + ORDER BY g.display_name ASC + `, userID).All(&groups); err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return []*SCIMGroup{}, nil + } + return nil, errors.Wrap(err, "error finding SCIM groups for user") + } + return groups, nil +} + +func (g *SCIMGroup) AddMember(tx *storage.Connection, userID uuid.UUID) error { + member := &SCIMGroupMember{ + GroupID: g.ID, + UserID: userID, + CreatedAt: time.Now(), + } + return tx.Create(member) +} + +func (g *SCIMGroup) RemoveMember(tx *storage.Connection, userID uuid.UUID) error { + return tx.RawQuery( + "DELETE FROM scim_group_members WHERE group_id = ? AND user_id = ?", + g.ID, userID, + ).Exec() +} + +func (g *SCIMGroup) GetMembers(tx *storage.Connection) ([]*User, error) { + users := []*User{} + if err := tx.RawQuery(` + SELECT u.* FROM users u + INNER JOIN scim_group_members m ON u.id = m.user_id + WHERE m.group_id = ? + ORDER BY u.email ASC + `, g.ID).All(&users); err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return []*User{}, nil + } + return nil, errors.Wrap(err, "error getting SCIM group members") + } + return users, nil +} + +func (g *SCIMGroup) SetMembers(tx *storage.Connection, userIDs []uuid.UUID) error { + if err := tx.RawQuery("DELETE FROM scim_group_members WHERE group_id = ?", g.ID).Exec(); err != nil { + return errors.Wrap(err, "error clearing SCIM group members") + } + + for _, userID := range userIDs { + if err := g.AddMember(tx, userID); err != nil { + return errors.Wrap(err, "error adding SCIM group member") + } + } + return nil +} + +func CountSCIMGroupsBySSOProvider(tx *storage.Connection, ssoProviderID uuid.UUID) (int, error) { + count, err := tx.Q().Where("sso_provider_id = ?", ssoProviderID).Count(&SCIMGroup{}) + if err != nil { + return 0, errors.Wrap(err, "error counting SCIM groups") + } + return count, nil +} diff --git a/internal/models/sso.go b/internal/models/sso.go index 3a5be7d97..3aa975e0c 100644 --- a/internal/models/sso.go +++ b/internal/models/sso.go @@ -1,6 +1,7 @@ package models import ( + "context" "database/sql" "database/sql/driver" "encoding/json" @@ -13,6 +14,7 @@ import ( "github.com/crewjam/saml/samlsp" "github.com/gofrs/uuid" "github.com/pkg/errors" + "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/storage" ) @@ -23,6 +25,9 @@ type SSOProvider struct { SAMLProvider SAMLProvider `has_one:"saml_providers" fk_id:"sso_provider_id" json:"saml,omitempty"` SSODomains []SSODomain `has_many:"sso_domains" fk_id:"sso_provider_id" json:"domains"` + SCIMEnabled *bool `db:"scim_enabled" json:"scim_enabled,omitempty"` + SCIMBearerTokenHash *string `db:"scim_bearer_token_hash" json:"-"` + CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } @@ -39,6 +44,35 @@ func (p SSOProvider) Type() string { return "saml" } +func (p SSOProvider) IsSCIMEnabled() bool { + return p.SCIMEnabled != nil && *p.SCIMEnabled +} + +func (p *SSOProvider) SetSCIMToken(ctx context.Context, token string) error { + hash, err := crypto.GenerateFromPassword(ctx, token) + if err != nil { + return err + } + p.SCIMBearerTokenHash = &hash + enabled := true + p.SCIMEnabled = &enabled + return nil +} + +func (p *SSOProvider) VerifySCIMToken(ctx context.Context, token string) bool { + if p.SCIMBearerTokenHash == nil { + return false + } + err := crypto.CompareHashAndPassword(ctx, *p.SCIMBearerTokenHash, token) + return err == nil +} + +func (p *SSOProvider) ClearSCIMToken() { + p.SCIMBearerTokenHash = nil + enabled := false + p.SCIMEnabled = &enabled +} + type SAMLAttribute struct { Name string `json:"name,omitempty"` Names []string `json:"names,omitempty"` @@ -266,12 +300,30 @@ func FindAllSSOProviders(tx *storage.Connection) ([]SSOProvider, error) { return providers, nil } +func FindSSOProviderBySCIMToken(ctx context.Context, tx *storage.Connection, token string) (*SSOProvider, error) { + var providers []SSOProvider + + if err := tx.Eager().Q().Where("scim_enabled = ? AND scim_bearer_token_hash IS NOT NULL", true).All(&providers); err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, SSOProviderNotFoundError{} + } + return nil, errors.Wrap(err, "error finding SCIM-enabled SSO providers") + } + + for i := range providers { + if providers[i].VerifySCIMToken(ctx, token) { + return &providers[i], nil + } + } + + return nil, SSOProviderNotFoundError{} +} + const ( resourceIDFilter = "resource_id" resourceIDPrefixFilter = "resource_id_prefix" ) -// FindAllSSOProvidersByFilter finds SSO Providers with the matching filter. func FindAllSSOProvidersByFilter( tx *storage.Connection, queryValues url.Values, diff --git a/internal/models/user.go b/internal/models/user.go index 3c706b80e..925632c48 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -65,11 +65,12 @@ type User struct { Factors []Factor `json:"factors,omitempty" has_many:"factors"` Identities []Identity `json:"identities" has_many:"identities"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` - BannedUntil *time.Time `json:"banned_until,omitempty" db:"banned_until"` - DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"` - IsAnonymous bool `json:"is_anonymous" db:"is_anonymous"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + BannedUntil *time.Time `json:"banned_until,omitempty" db:"banned_until"` + BannedReason *string `json:"banned_reason,omitempty" db:"banned_reason"` + DeletedAt *time.Time `json:"deleted_at,omitempty" db:"deleted_at"` + IsAnonymous bool `json:"is_anonymous" db:"is_anonymous"` DONTUSEINSTANCEID uuid.UUID `json:"-" db:"instance_id"` } @@ -838,15 +839,17 @@ func IsDuplicatedPhone(tx *storage.Connection, phone, aud string) (bool, error) return true, nil } -// Ban a user for a given duration. -func (u *User) Ban(tx *storage.Connection, duration time.Duration) error { +// Ban a user for a given duration with an optional reason. +func (u *User) Ban(tx *storage.Connection, duration time.Duration, reason *string) error { if duration == time.Duration(0) { u.BannedUntil = nil + u.BannedReason = nil } else { t := time.Now().Add(duration) u.BannedUntil = &t + u.BannedReason = reason } - return tx.UpdateOnly(u, "banned_until") + return tx.UpdateOnly(u, "banned_until", "banned_reason") } // IsBanned checks if a user is banned or not From d9e25cb42af5888b99020cb6d6839957dfeddfc6 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 10 Dec 2025 07:11:21 +0000 Subject: [PATCH 003/101] feat: add scim error codes --- internal/api/apierrors/errorcode.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/api/apierrors/errorcode.go b/internal/api/apierrors/errorcode.go index 58963eea3..f6613ad3f 100644 --- a/internal/api/apierrors/errorcode.go +++ b/internal/api/apierrors/errorcode.go @@ -61,6 +61,15 @@ const ( ErrorCodeUserAlreadyExists ErrorCode = "user_already_exists" ErrorCodeSSOProviderNotFound ErrorCode = "sso_provider_not_found" ErrorCodeSSOProviderDisabled ErrorCode = "sso_provider_disabled" + ErrorCodeSCIMDisabled ErrorCode = "scim_disabled" + ErrorCodeSCIMTokenInvalid ErrorCode = "scim_token_invalid" + ErrorCodeSCIMUserNotFound ErrorCode = "scim_user_not_found" + ErrorCodeSCIMUserAlreadyExists ErrorCode = "scim_user_already_exists" + ErrorCodeSCIMGroupNotFound ErrorCode = "scim_group_not_found" + ErrorCodeSCIMGroupAlreadyExists ErrorCode = "scim_group_already_exists" + ErrorCodeSCIMInvalidFilter ErrorCode = "scim_invalid_filter" + ErrorCodeSCIMInvalidSchema ErrorCode = "scim_invalid_schema" + ErrorCodeSCIMMutuallyExclusive ErrorCode = "scim_mutually_exclusive" ErrorCodeSAMLMetadataFetchFailed ErrorCode = "saml_metadata_fetch_failed" ErrorCodeSAMLIdPAlreadyExists ErrorCode = "saml_idp_already_exists" ErrorCodeSSODomainAlreadyExists ErrorCode = "sso_domain_already_exists" From 4d386673c6b20d947a8470fdfdc5d765d5e54c54 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 10 Dec 2025 07:32:11 +0000 Subject: [PATCH 004/101] fix: add findUserByProvider to user model --- internal/models/user.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/models/user.go b/internal/models/user.go index 925632c48..09136c2ad 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -631,6 +631,23 @@ func FindUserByID(tx *storage.Connection, id uuid.UUID) (*User, error) { return findUser(tx, "instance_id = ? and id = ?", uuid.Nil, id) } +// FindUsersByProvider finds all users with an identity for the given provider. +func FindUsersByProvider(tx *storage.Connection, provider string) ([]*User, error) { + users := []*User{} + err := tx.Eager().RawQuery(` + SELECT DISTINCT u.* FROM `+(&User{}).TableName()+` u + INNER JOIN identities i ON u.id = i.user_id + WHERE i.provider = ? AND u.instance_id = ? + `, provider, uuid.Nil).All(&users) + if err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return users, nil + } + return nil, errors.Wrap(err, "error finding users by provider") + } + return users, nil +} + // FindUserWithRefreshToken finds a user from the provided refresh token. If // forUpdate is set to true, then the SELECT statement used by the query has // the form SELECT ... FOR UPDATE SKIP LOCKED. This means that a FOR UPDATE From 4efc01ddebc50609633ffa3f2ad470228126264e Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 10 Dec 2025 07:33:16 +0000 Subject: [PATCH 005/101] chore: update Ban() calls in admin.go --- internal/api/admin.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/admin.go b/internal/api/admin.go index e75fbb353..c55a3b055 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -299,7 +299,7 @@ func (a *API) adminUserUpdate(w http.ResponseWriter, r *http.Request) error { } if banDuration != nil { - if terr := user.Ban(tx, *banDuration); terr != nil { + if terr := user.Ban(tx, *banDuration, nil); terr != nil { return terr } } @@ -493,7 +493,7 @@ func (a *API) adminUserCreate(w http.ResponseWriter, r *http.Request) error { } if banDuration != nil { - if terr := user.Ban(tx, *banDuration); terr != nil { + if terr := user.Ban(tx, *banDuration, nil); terr != nil { return terr } } From 210ebd1360c4fa88cbcf0f8aa1f6c3068e813036 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 10 Dec 2025 07:33:37 +0000 Subject: [PATCH 006/101] feat: Add SCIM v2 endpoints --- internal/api/scim.go | 1080 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1080 insertions(+) create mode 100644 internal/api/scim.go diff --git a/internal/api/scim.go b/internal/api/scim.go new file mode 100644 index 000000000..68838a435 --- /dev/null +++ b/internal/api/scim.go @@ -0,0 +1,1080 @@ +package api + +import ( + "context" + "encoding/json" + "math" + "net/http" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi/v5" + "github.com/gofrs/uuid" + "github.com/supabase/auth/internal/api/apierrors" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/storage" + "github.com/supabase/auth/internal/utilities" +) + +const ( + SCIMDefaultPageSize = 100 + SCIMMaxPageSize = 1000 + SCIMSchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User" + SCIMSchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group" + SCIMSchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse" + SCIMSchemaPatchOp = "urn:ietf:params:scim:api:messages:2.0:PatchOp" +) + +var scimDeprovisionedReason = "SCIM_DEPROVISIONED" + +// SCIM request/response types - using camelCase per SCIM v2 spec (RFC 7643) + +type SCIMUserParams struct { + Schemas []string `json:"schemas"` + ExternalID string `json:"externalId"` + UserName string `json:"userName"` + Name *SCIMName `json:"name,omitempty"` + Emails []SCIMEmail `json:"emails,omitempty"` + Active *bool `json:"active,omitempty"` +} + +type SCIMName struct { + Formatted string `json:"formatted,omitempty"` + FamilyName string `json:"familyName,omitempty"` + GivenName string `json:"givenName,omitempty"` +} + +type SCIMEmail struct { + Value string `json:"value"` + Type string `json:"type,omitempty"` + Primary bool `json:"primary,omitempty"` +} + +type SCIMGroupParams struct { + Schemas []string `json:"schemas"` + ExternalID string `json:"externalId"` + DisplayName string `json:"displayName"` + Members []SCIMGroupMemberRef `json:"members,omitempty"` +} + +type SCIMGroupMemberRef struct { + Value string `json:"value"` + Ref string `json:"$ref,omitempty"` + Display string `json:"display,omitempty"` +} + +type SCIMPatchRequest struct { + Schemas []string `json:"schemas"` + Operations []SCIMPatchOperation `json:"Operations"` +} + +type SCIMPatchOperation struct { + Op string `json:"op"` + Path string `json:"path,omitempty"` + Value interface{} `json:"value,omitempty"` +} + +type SCIMMeta struct { + ResourceType string `json:"resourceType"` + Created *time.Time `json:"created,omitempty"` + LastModified *time.Time `json:"lastModified,omitempty"` + Location string `json:"location,omitempty"` +} + +type SCIMUserResponse struct { + Schemas []string `json:"schemas"` + ID string `json:"id"` + ExternalID string `json:"externalId,omitempty"` + UserName string `json:"userName"` + Name *SCIMName `json:"name,omitempty"` + Emails []SCIMEmail `json:"emails,omitempty"` + Active bool `json:"active"` + Meta SCIMMeta `json:"meta"` +} + +type SCIMGroupResponse struct { + Schemas []string `json:"schemas"` + ID string `json:"id"` + ExternalID string `json:"externalId,omitempty"` + DisplayName string `json:"displayName"` + Members []SCIMGroupMemberRef `json:"members,omitempty"` + Meta SCIMMeta `json:"meta"` +} + +type SCIMListResponse struct { + Schemas []string `json:"schemas"` + TotalResults int `json:"totalResults"` + StartIndex int `json:"startIndex"` + ItemsPerPage int `json:"itemsPerPage"` + Resources []interface{} `json:"Resources"` +} + +// Validation methods + +func (p *SCIMUserParams) Validate() error { + if p.UserName == "" { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "userName is required") + } + return nil +} + +func (p *SCIMGroupParams) Validate() error { + if p.DisplayName == "" { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "displayName is required") + } + return nil +} + +// SCIM Authentication Middleware + +func (a *API) requireSCIMAuthentication(w http.ResponseWriter, r *http.Request) (context.Context, error) { + ctx := r.Context() + db := a.db.WithContext(ctx) + + token, err := a.extractBearerToken(r) + if err != nil { + return nil, apierrors.NewHTTPError(http.StatusUnauthorized, apierrors.ErrorCodeSCIMTokenInvalid, "Invalid or missing SCIM bearer token") + } + + provider, err := models.FindSSOProviderBySCIMToken(ctx, db, token) + if err != nil { + if models.IsNotFoundError(err) { + return nil, apierrors.NewHTTPError(http.StatusUnauthorized, apierrors.ErrorCodeSCIMTokenInvalid, "Invalid SCIM bearer token") + } + return nil, apierrors.NewInternalServerError("Error validating SCIM token").WithInternalError(err) + } + + if !provider.IsSCIMEnabled() { + return nil, apierrors.NewForbiddenError(apierrors.ErrorCodeSCIMDisabled, "SCIM provisioning is not enabled for this provider") + } + + if !provider.IsEnabled() { + return nil, apierrors.NewForbiddenError(apierrors.ErrorCodeSSOProviderDisabled, "SSO provider is disabled") + } + + return withSSOProvider(ctx, provider), nil +} + +// Helper functions + +func (a *API) getSCIMBaseURL() string { + return a.config.SiteURL +} + +func parseSCIMPagination(r *http.Request) (startIndex, count int) { + startIndex = 1 + count = SCIMDefaultPageSize + + if v := r.URL.Query().Get("startIndex"); v != "" { + if i, err := strconv.Atoi(v); err == nil && i > 0 { + startIndex = i + } + } + + if v := r.URL.Query().Get("count"); v != "" { + if i, err := strconv.Atoi(v); err == nil && i > 0 { + count = i + if count > SCIMMaxPageSize { + count = SCIMMaxPageSize + } + } + } + + return startIndex, count +} + +func userBelongsToProvider(user *models.User, providerID uuid.UUID) bool { + providerType := "sso:" + providerID.String() + for _, identity := range user.Identities { + if identity.Provider == providerType { + return true + } + } + return false +} + +func (a *API) userToSCIMResponse(user *models.User) *SCIMUserResponse { + baseURL := a.getSCIMBaseURL() + resp := &SCIMUserResponse{ + Schemas: []string{SCIMSchemaUser}, + ID: user.ID.String(), + UserName: user.GetEmail(), + Active: !user.IsBanned(), + Meta: SCIMMeta{ + ResourceType: "User", + Created: &user.CreatedAt, + LastModified: &user.UpdatedAt, + Location: baseURL + "/scim/v2/Users/" + user.ID.String(), + }, + } + + // Set external ID from identity if available + for _, identity := range user.Identities { + if identity.Provider != "" && identity.ProviderID != "" { + resp.ExternalID = identity.ProviderID + break + } + } + + if email := user.GetEmail(); email != "" { + resp.Emails = []SCIMEmail{{Value: email, Type: "work", Primary: true}} + } + + if user.UserMetaData != nil { + name := &SCIMName{} + hasName := false + if v, ok := user.UserMetaData["given_name"].(string); ok { + name.GivenName = v + hasName = true + } + if v, ok := user.UserMetaData["family_name"].(string); ok { + name.FamilyName = v + hasName = true + } + if v, ok := user.UserMetaData["full_name"].(string); ok { + name.Formatted = v + hasName = true + } + if hasName { + resp.Name = name + } + } + + return resp +} + +func (a *API) groupToSCIMResponse(group *models.SCIMGroup, members []*models.User) *SCIMGroupResponse { + baseURL := a.getSCIMBaseURL() + resp := &SCIMGroupResponse{ + Schemas: []string{SCIMSchemaGroup}, + ID: group.ID.String(), + ExternalID: group.ExternalID, + DisplayName: group.DisplayName, + Meta: SCIMMeta{ + ResourceType: "Group", + Created: &group.CreatedAt, + LastModified: &group.UpdatedAt, + Location: baseURL + "/scim/v2/Groups/" + group.ID.String(), + }, + } + + if members != nil { + resp.Members = make([]SCIMGroupMemberRef, len(members)) + for i, m := range members { + resp.Members[i] = SCIMGroupMemberRef{ + Value: m.ID.String(), + Ref: baseURL + "/scim/v2/Users/" + m.ID.String(), + Display: m.GetEmail(), + } + } + } + + return resp +} + +func (a *API) parseSCIMBody(r *http.Request, v interface{}) error { + body, err := utilities.GetBodyBytes(r) + if err != nil { + return apierrors.NewInternalServerError("Could not read request body").WithInternalError(err) + } + if err := json.Unmarshal(body, v); err != nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeBadJSON, "Invalid JSON: %v", err) + } + return nil +} + +// User Endpoints + +// scimListUsers handles GET /scim/v2/Users +func (a *API) scimListUsers(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + provider := getSSOProvider(ctx) + + startIndex, count := parseSCIMPagination(r) + + providerType := "sso:" + provider.ID.String() + users, err := models.FindUsersByProvider(db, providerType) + if err != nil { + return apierrors.NewInternalServerError("Error fetching users").WithInternalError(err) + } + + totalResults := len(users) + + // Apply pagination (SCIM uses 1-based indexing) + start := startIndex - 1 + if start < 0 { + start = 0 + } + if start > len(users) { + start = len(users) + } + end := start + count + if end > len(users) { + end = len(users) + } + pagedUsers := users[start:end] + + resources := make([]interface{}, len(pagedUsers)) + for i, user := range pagedUsers { + resources[i] = a.userToSCIMResponse(user) + } + + return sendJSON(w, http.StatusOK, &SCIMListResponse{ + Schemas: []string{SCIMSchemaListResponse}, + TotalResults: totalResults, + StartIndex: startIndex, + ItemsPerPage: len(pagedUsers), + Resources: resources, + }) +} + +// scimGetUser handles GET /scim/v2/Users/{id} +func (a *API) scimGetUser(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + provider := getSSOProvider(ctx) + + userID, err := uuid.FromString(chi.URLParam(r, "user_id")) + if err != nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid user ID format") + } + + user, err := models.FindUserByID(db, userID) + if err != nil { + if models.IsNotFoundError(err) { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMUserNotFound, "User not found") + } + return apierrors.NewInternalServerError("Error fetching user").WithInternalError(err) + } + + if !userBelongsToProvider(user, provider.ID) { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMUserNotFound, "User not found") + } + + return sendJSON(w, http.StatusOK, a.userToSCIMResponse(user)) +} + +// scimCreateUser handles POST /scim/v2/Users +func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + provider := getSSOProvider(ctx) + config := a.config + + var params SCIMUserParams + if err := a.parseSCIMBody(r, ¶ms); err != nil { + return err + } + if err := params.Validate(); err != nil { + return err + } + + email := strings.ToLower(params.UserName) + if err := a.validateEmail(email); err != nil { + return err + } + + providerType := "sso:" + provider.ID.String() + + var user *models.User + terr := db.Transaction(func(tx *storage.Connection) error { + // Check if user exists and was deprovisioned + existingUser, err := models.FindUserByEmailAndAudience(tx, email, config.JWT.Aud) + if err != nil && !models.IsNotFoundError(err) { + return apierrors.NewInternalServerError("Error checking existing user").WithInternalError(err) + } + + if existingUser != nil { + if existingUser.BannedReason != nil && *existingUser.BannedReason == scimDeprovisionedReason { + // Reactivate deprovisioned user + if err := existingUser.Ban(tx, 0, nil); err != nil { + return apierrors.NewInternalServerError("Error reactivating user").WithInternalError(err) + } + user = existingUser + return nil + } + return apierrors.NewHTTPError(http.StatusConflict, apierrors.ErrorCodeSCIMUserAlreadyExists, "User with this email already exists") + } + + // Create new user + user, err = models.NewUser("", email, "", config.JWT.Aud, nil) + if err != nil { + return apierrors.NewInternalServerError("Error creating user").WithInternalError(err) + } + user.IsSSOUser = true + + if params.Name != nil { + metadata := make(map[string]interface{}) + if params.Name.GivenName != "" { + metadata["given_name"] = params.Name.GivenName + } + if params.Name.FamilyName != "" { + metadata["family_name"] = params.Name.FamilyName + } + if params.Name.Formatted != "" { + metadata["full_name"] = params.Name.Formatted + } + if len(metadata) > 0 { + user.UserMetaData = metadata + } + } + + if err := tx.Create(user); err != nil { + return apierrors.NewInternalServerError("Error saving user").WithInternalError(err) + } + + identity, err := models.NewIdentity(user, providerType, map[string]interface{}{ + "sub": params.ExternalID, + "external_id": params.ExternalID, + }) + if err != nil { + return apierrors.NewInternalServerError("Error creating identity").WithInternalError(err) + } + identity.ProviderID = params.ExternalID + + if err := tx.Create(identity); err != nil { + return apierrors.NewInternalServerError("Error saving identity").WithInternalError(err) + } + + if err := tx.Eager().Find(user, user.ID); err != nil { + return apierrors.NewInternalServerError("Error reloading user").WithInternalError(err) + } + + return nil + }) + + if terr != nil { + return terr + } + + return sendJSON(w, http.StatusCreated, a.userToSCIMResponse(user)) +} + +// scimReplaceUser handles PUT /scim/v2/Users/{id} +func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + provider := getSSOProvider(ctx) + + userID, err := uuid.FromString(chi.URLParam(r, "user_id")) + if err != nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid user ID format") + } + + var params SCIMUserParams + if err := a.parseSCIMBody(r, ¶ms); err != nil { + return err + } + + var user *models.User + terr := db.Transaction(func(tx *storage.Connection) error { + var err error + user, err = models.FindUserByID(tx, userID) + if err != nil { + if models.IsNotFoundError(err) { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMUserNotFound, "User not found") + } + return apierrors.NewInternalServerError("Error fetching user").WithInternalError(err) + } + + if !userBelongsToProvider(user, provider.ID) { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMUserNotFound, "User not found") + } + + if params.Name != nil { + if user.UserMetaData == nil { + user.UserMetaData = make(map[string]interface{}) + } + if params.Name.GivenName != "" { + user.UserMetaData["given_name"] = params.Name.GivenName + } + if params.Name.FamilyName != "" { + user.UserMetaData["family_name"] = params.Name.FamilyName + } + if params.Name.Formatted != "" { + user.UserMetaData["full_name"] = params.Name.Formatted + } + } + + if params.Active != nil { + if *params.Active { + if err := user.Ban(tx, 0, nil); err != nil { + return apierrors.NewInternalServerError("Error unbanning user").WithInternalError(err) + } + } else { + if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { + return apierrors.NewInternalServerError("Error banning user").WithInternalError(err) + } + if err := models.Logout(tx, user.ID); err != nil { + return apierrors.NewInternalServerError("Error invalidating sessions").WithInternalError(err) + } + } + } + + if err := tx.UpdateOnly(user, "raw_user_meta_data"); err != nil { + return apierrors.NewInternalServerError("Error updating user").WithInternalError(err) + } + + return nil + }) + + if terr != nil { + return terr + } + + return sendJSON(w, http.StatusOK, a.userToSCIMResponse(user)) +} + +// scimPatchUser handles PATCH /scim/v2/Users/{id} +func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + provider := getSSOProvider(ctx) + + userID, err := uuid.FromString(chi.URLParam(r, "user_id")) + if err != nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid user ID format") + } + + var params SCIMPatchRequest + if err := a.parseSCIMBody(r, ¶ms); err != nil { + return err + } + + var user *models.User + terr := db.Transaction(func(tx *storage.Connection) error { + var err error + user, err = models.FindUserByID(tx, userID) + if err != nil { + if models.IsNotFoundError(err) { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMUserNotFound, "User not found") + } + return apierrors.NewInternalServerError("Error fetching user").WithInternalError(err) + } + + if !userBelongsToProvider(user, provider.ID) { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMUserNotFound, "User not found") + } + + for _, op := range params.Operations { + if err := a.applySCIMUserPatch(tx, user, op); err != nil { + return err + } + } + + return nil + }) + + if terr != nil { + return terr + } + + return sendJSON(w, http.StatusOK, a.userToSCIMResponse(user)) +} + +func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op SCIMPatchOperation) error { + switch strings.ToLower(op.Op) { + case "replace": + switch strings.ToLower(op.Path) { + case "active": + active, ok := op.Value.(bool) + if !ok { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "active must be a boolean") + } + if active { + return user.Ban(tx, 0, nil) + } + if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { + return err + } + return models.Logout(tx, user.ID) + case "": + // Replace entire resource + if valueMap, ok := op.Value.(map[string]interface{}); ok { + if active, ok := valueMap["active"].(bool); ok { + if active { + return user.Ban(tx, 0, nil) + } + if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { + return err + } + return models.Logout(tx, user.ID) + } + } + } + default: + return apierrors.NewBadRequestError(apierrors.ErrorCodeSCIMMutuallyExclusive, "Unsupported patch operation: %s", op.Op) + } + return nil +} + +// scimDeleteUser handles DELETE /scim/v2/Users/{id} +func (a *API) scimDeleteUser(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + provider := getSSOProvider(ctx) + + userID, err := uuid.FromString(chi.URLParam(r, "user_id")) + if err != nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid user ID format") + } + + terr := db.Transaction(func(tx *storage.Connection) error { + user, err := models.FindUserByID(tx, userID) + if err != nil { + if models.IsNotFoundError(err) { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMUserNotFound, "User not found") + } + return apierrors.NewInternalServerError("Error fetching user").WithInternalError(err) + } + + if !userBelongsToProvider(user, provider.ID) { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMUserNotFound, "User not found") + } + + // Soft delete: ban with infinity duration + if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { + return apierrors.NewInternalServerError("Error deprovisioning user").WithInternalError(err) + } + + return models.Logout(tx, user.ID) + }) + + if terr != nil { + return terr + } + + w.WriteHeader(http.StatusNoContent) + return nil +} + +// Group Endpoints + +// scimListGroups handles GET /scim/v2/Groups +func (a *API) scimListGroups(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + provider := getSSOProvider(ctx) + + startIndex, count := parseSCIMPagination(r) + + groups, err := models.FindSCIMGroupsBySSOProvider(db, provider.ID, nil) + if err != nil { + return apierrors.NewInternalServerError("Error fetching groups").WithInternalError(err) + } + + totalResults := len(groups) + + start := startIndex - 1 + if start < 0 { + start = 0 + } + if start > len(groups) { + start = len(groups) + } + end := start + count + if end > len(groups) { + end = len(groups) + } + pagedGroups := groups[start:end] + + resources := make([]interface{}, len(pagedGroups)) + for i, group := range pagedGroups { + members, _ := group.GetMembers(db) + resources[i] = a.groupToSCIMResponse(group, members) + } + + return sendJSON(w, http.StatusOK, &SCIMListResponse{ + Schemas: []string{SCIMSchemaListResponse}, + TotalResults: totalResults, + StartIndex: startIndex, + ItemsPerPage: len(pagedGroups), + Resources: resources, + }) +} + +// scimGetGroup handles GET /scim/v2/Groups/{id} +func (a *API) scimGetGroup(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + provider := getSSOProvider(ctx) + + groupID, err := uuid.FromString(chi.URLParam(r, "group_id")) + if err != nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid group ID format") + } + + group, err := models.FindSCIMGroupByID(db, groupID) + if err != nil { + if models.IsNotFoundError(err) { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMGroupNotFound, "Group not found") + } + return apierrors.NewInternalServerError("Error fetching group").WithInternalError(err) + } + + if group.SSOProviderID != provider.ID { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMGroupNotFound, "Group not found") + } + + members, err := group.GetMembers(db) + if err != nil { + return apierrors.NewInternalServerError("Error fetching group members").WithInternalError(err) + } + + return sendJSON(w, http.StatusOK, a.groupToSCIMResponse(group, members)) +} + +// scimCreateGroup handles POST /scim/v2/Groups +func (a *API) scimCreateGroup(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + provider := getSSOProvider(ctx) + + var params SCIMGroupParams + if err := a.parseSCIMBody(r, ¶ms); err != nil { + return err + } + if err := params.Validate(); err != nil { + return err + } + + var group *models.SCIMGroup + terr := db.Transaction(func(tx *storage.Connection) error { + if params.ExternalID != "" { + existing, err := models.FindSCIMGroupByExternalID(tx, provider.ID, params.ExternalID) + if err == nil && existing != nil { + return apierrors.NewHTTPError(http.StatusConflict, apierrors.ErrorCodeSCIMGroupAlreadyExists, "Group with this externalId already exists") + } + if err != nil && !models.IsNotFoundError(err) { + return apierrors.NewInternalServerError("Error checking existing group").WithInternalError(err) + } + } + + group = models.NewSCIMGroup(provider.ID, params.ExternalID, params.DisplayName) + if err := tx.Create(group); err != nil { + return apierrors.NewInternalServerError("Error creating group").WithInternalError(err) + } + + for _, member := range params.Members { + memberID, err := uuid.FromString(member.Value) + if err != nil { + continue + } + _ = group.AddMember(tx, memberID) + } + + return nil + }) + + if terr != nil { + return terr + } + + members, _ := group.GetMembers(db) + return sendJSON(w, http.StatusCreated, a.groupToSCIMResponse(group, members)) +} + +// scimReplaceGroup handles PUT /scim/v2/Groups/{id} +func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + provider := getSSOProvider(ctx) + + groupID, err := uuid.FromString(chi.URLParam(r, "group_id")) + if err != nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid group ID format") + } + + var params SCIMGroupParams + if err := a.parseSCIMBody(r, ¶ms); err != nil { + return err + } + + var group *models.SCIMGroup + terr := db.Transaction(func(tx *storage.Connection) error { + var err error + group, err = models.FindSCIMGroupByID(tx, groupID) + if err != nil { + if models.IsNotFoundError(err) { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMGroupNotFound, "Group not found") + } + return apierrors.NewInternalServerError("Error fetching group").WithInternalError(err) + } + + if group.SSOProviderID != provider.ID { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMGroupNotFound, "Group not found") + } + + group.DisplayName = params.DisplayName + if params.ExternalID != "" { + group.ExternalID = params.ExternalID + } + + if err := tx.Update(group); err != nil { + return apierrors.NewInternalServerError("Error updating group").WithInternalError(err) + } + + memberIDs := make([]uuid.UUID, 0, len(params.Members)) + for _, member := range params.Members { + memberID, err := uuid.FromString(member.Value) + if err != nil { + continue + } + memberIDs = append(memberIDs, memberID) + } + + return group.SetMembers(tx, memberIDs) + }) + + if terr != nil { + return terr + } + + members, _ := group.GetMembers(db) + return sendJSON(w, http.StatusOK, a.groupToSCIMResponse(group, members)) +} + +// scimPatchGroup handles PATCH /scim/v2/Groups/{id} +func (a *API) scimPatchGroup(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + provider := getSSOProvider(ctx) + + groupID, err := uuid.FromString(chi.URLParam(r, "group_id")) + if err != nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid group ID format") + } + + var params SCIMPatchRequest + if err := a.parseSCIMBody(r, ¶ms); err != nil { + return err + } + + var group *models.SCIMGroup + terr := db.Transaction(func(tx *storage.Connection) error { + var err error + group, err = models.FindSCIMGroupByID(tx, groupID) + if err != nil { + if models.IsNotFoundError(err) { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMGroupNotFound, "Group not found") + } + return apierrors.NewInternalServerError("Error fetching group").WithInternalError(err) + } + + if group.SSOProviderID != provider.ID { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMGroupNotFound, "Group not found") + } + + for _, op := range params.Operations { + if err := a.applySCIMGroupPatch(tx, group, op); err != nil { + return err + } + } + + return nil + }) + + if terr != nil { + return terr + } + + members, _ := group.GetMembers(db) + return sendJSON(w, http.StatusOK, a.groupToSCIMResponse(group, members)) +} + +func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGroup, op SCIMPatchOperation) error { + switch strings.ToLower(op.Op) { + case "add": + if strings.ToLower(op.Path) == "members" || op.Path == "" { + members, ok := op.Value.([]interface{}) + if !ok { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "members must be an array") + } + for _, m := range members { + memberMap, ok := m.(map[string]interface{}) + if !ok { + continue + } + value, ok := memberMap["value"].(string) + if !ok { + continue + } + memberID, err := uuid.FromString(value) + if err != nil { + continue + } + _ = group.AddMember(tx, memberID) + } + } + case "remove": + if strings.HasPrefix(strings.ToLower(op.Path), "members") && strings.Contains(op.Path, "[") { + start := strings.Index(op.Path, "\"") + end := strings.LastIndex(op.Path, "\"") + if start != -1 && end != -1 && start < end { + value := op.Path[start+1 : end] + memberID, err := uuid.FromString(value) + if err != nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid member ID in path") + } + return group.RemoveMember(tx, memberID) + } + } + case "replace": + switch strings.ToLower(op.Path) { + case "displayname": + displayName, ok := op.Value.(string) + if !ok { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "displayName must be a string") + } + group.DisplayName = displayName + return tx.UpdateOnly(group, "display_name") + case "members": + members, ok := op.Value.([]interface{}) + if !ok { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "members must be an array") + } + memberIDs := make([]uuid.UUID, 0, len(members)) + for _, m := range members { + memberMap, ok := m.(map[string]interface{}) + if !ok { + continue + } + value, ok := memberMap["value"].(string) + if !ok { + continue + } + memberID, err := uuid.FromString(value) + if err != nil { + continue + } + memberIDs = append(memberIDs, memberID) + } + return group.SetMembers(tx, memberIDs) + } + default: + return apierrors.NewBadRequestError(apierrors.ErrorCodeSCIMMutuallyExclusive, "Unsupported patch operation: %s", op.Op) + } + return nil +} + +// scimDeleteGroup handles DELETE /scim/v2/Groups/{id} +func (a *API) scimDeleteGroup(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + provider := getSSOProvider(ctx) + + groupID, err := uuid.FromString(chi.URLParam(r, "group_id")) + if err != nil { + return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid group ID format") + } + + terr := db.Transaction(func(tx *storage.Connection) error { + group, err := models.FindSCIMGroupByID(tx, groupID) + if err != nil { + if models.IsNotFoundError(err) { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMGroupNotFound, "Group not found") + } + return apierrors.NewInternalServerError("Error fetching group").WithInternalError(err) + } + + if group.SSOProviderID != provider.ID { + return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMGroupNotFound, "Group not found") + } + + return tx.Destroy(group) + }) + + if terr != nil { + return terr + } + + w.WriteHeader(http.StatusNoContent) + return nil +} + +// Service Provider Config Endpoints + +// scimServiceProviderConfig handles GET /scim/v2/ServiceProviderConfig +func (a *API) scimServiceProviderConfig(w http.ResponseWriter, r *http.Request) error { + baseURL := a.getSCIMBaseURL() + + return sendJSON(w, http.StatusOK, map[string]interface{}{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"}, + "documentationUri": "https://supabase.com/docs/guides/auth/enterprise-sso/scim", + "patch": map[string]interface{}{"supported": true}, + "bulk": map[string]interface{}{"supported": false, "maxOperations": 0, "maxPayloadSize": 0}, + "filter": map[string]interface{}{"supported": true, "maxResults": SCIMMaxPageSize}, + "changePassword": map[string]interface{}{"supported": false}, + "sort": map[string]interface{}{"supported": false}, + "etag": map[string]interface{}{"supported": false}, + "authenticationSchemes": []map[string]interface{}{ + { + "type": "oauthbearertoken", + "name": "OAuth Bearer Token", + "description": "Authentication scheme using the OAuth Bearer Token", + "specUri": "http://www.rfc-editor.org/info/rfc6750", + "primary": true, + }, + }, + "meta": map[string]interface{}{ + "resourceType": "ServiceProviderConfig", + "location": baseURL + "/scim/v2/ServiceProviderConfig", + }, + }) +} + +// scimResourceTypes handles GET /scim/v2/ResourceTypes +func (a *API) scimResourceTypes(w http.ResponseWriter, r *http.Request) error { + baseURL := a.getSCIMBaseURL() + + return sendJSON(w, http.StatusOK, []map[string]interface{}{ + { + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, + "id": "User", + "name": "User", + "endpoint": "/Users", + "description": "User Account", + "schema": SCIMSchemaUser, + "meta": map[string]interface{}{"resourceType": "ResourceType", "location": baseURL + "/scim/v2/ResourceTypes/User"}, + }, + { + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, + "id": "Group", + "name": "Group", + "endpoint": "/Groups", + "description": "Group", + "schema": SCIMSchemaGroup, + "meta": map[string]interface{}{"resourceType": "ResourceType", "location": baseURL + "/scim/v2/ResourceTypes/Group"}, + }, + }) +} + +// scimSchemas handles GET /scim/v2/Schemas +func (a *API) scimSchemas(w http.ResponseWriter, r *http.Request) error { + return sendJSON(w, http.StatusOK, []map[string]interface{}{ + { + "id": SCIMSchemaUser, + "name": "User", + "description": "User Account", + "attributes": []map[string]interface{}{ + {"name": "userName", "type": "string", "required": true, "uniqueness": "server"}, + {"name": "name", "type": "complex", "required": false}, + {"name": "emails", "type": "complex", "multiValued": true, "required": false}, + {"name": "active", "type": "boolean", "required": false}, + {"name": "externalId", "type": "string", "required": false}, + }, + }, + { + "id": SCIMSchemaGroup, + "name": "Group", + "description": "Group", + "attributes": []map[string]interface{}{ + {"name": "displayName", "type": "string", "required": true}, + {"name": "members", "type": "complex", "multiValued": true, "required": false}, + {"name": "externalId", "type": "string", "required": false}, + }, + }, + }) +} From 0659822a105d5dcee63a4ccfc8f033455a01456a Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 10 Dec 2025 07:38:44 +0000 Subject: [PATCH 007/101] feat: register SCIM endpoints --- internal/api/api.go | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/internal/api/api.go b/internal/api/api.go index c2536c0a7..3b7ce0961 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -201,6 +201,40 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne r.Post("/", api.ExternalProviderCallback) }) + // SCIM v2 API endpoints + r.Route("/scim/v2", func(r *router) { + r.Use(api.requireSCIMAuthentication) + + // Service Provider Configuration + r.Get("/ServiceProviderConfig", api.scimServiceProviderConfig) + r.Get("/ResourceTypes", api.scimResourceTypes) + r.Get("/Schemas", api.scimSchemas) + + // User endpoints + r.Route("/Users", func(r *router) { + r.Get("/", api.scimListUsers) + r.Post("/", api.scimCreateUser) + r.Route("/{user_id}", func(r *router) { + r.Get("/", api.scimGetUser) + r.Put("/", api.scimReplaceUser) + r.Patch("/", api.scimPatchUser) + r.Delete("/", api.scimDeleteUser) + }) + }) + + // Group endpoints + r.Route("/Groups", func(r *router) { + r.Get("/", api.scimListGroups) + r.Post("/", api.scimCreateGroup) + r.Route("/{group_id}", func(r *router) { + r.Get("/", api.scimGetGroup) + r.Put("/", api.scimReplaceGroup) + r.Patch("/", api.scimPatchGroup) + r.Delete("/", api.scimDeleteGroup) + }) + }) + }) + r.Route("/", func(r *router) { r.Use(api.isValidExternalHost) @@ -352,6 +386,14 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne r.Get("/", api.adminSSOProvidersGet) r.Put("/", api.adminSSOProvidersUpdate) r.Delete("/", api.adminSSOProvidersDelete) + + // SCIM management endpoints + r.Route("/scim", func(r *router) { + r.Get("/", api.adminSSOProviderGetSCIM) + r.Post("/", api.adminSSOProviderEnableSCIM) + r.Delete("/", api.adminSSOProviderDisableSCIM) + r.Post("/rotate", api.adminSSOProviderRotateSCIMToken) + }) }) }) }) From 00317da7ebb9afc84a7e68d8a0ad46f2a371cc4a Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 10 Dec 2025 07:39:55 +0000 Subject: [PATCH 008/101] fix: reuse existing helpers for user creation, add audit logging --- internal/api/scim.go | 47 +++++++++++++++++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 68838a435..2af806261 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -392,6 +392,13 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { if err := existingUser.Ban(tx, 0, nil); err != nil { return apierrors.NewInternalServerError("Error reactivating user").WithInternalError(err) } + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, existingUser, models.UserModifiedAction, "", map[string]interface{}{ + "provider": "scim", + "sso_provider_id": provider.ID, + "action": "reactivated", + }); terr != nil { + return apierrors.NewInternalServerError("Error recording audit log entry").WithInternalError(terr) + } user = existingUser return nil } @@ -425,17 +432,19 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewInternalServerError("Error saving user").WithInternalError(err) } - identity, err := models.NewIdentity(user, providerType, map[string]interface{}{ + if _, err := a.createNewIdentity(tx, user, providerType, map[string]interface{}{ "sub": params.ExternalID, "external_id": params.ExternalID, - }) - if err != nil { - return apierrors.NewInternalServerError("Error creating identity").WithInternalError(err) + "email": email, + }); err != nil { + return err } - identity.ProviderID = params.ExternalID - if err := tx.Create(identity); err != nil { - return apierrors.NewInternalServerError("Error saving identity").WithInternalError(err) + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserSignedUpAction, "", map[string]interface{}{ + "provider": "scim", + "sso_provider_id": provider.ID, + }); terr != nil { + return apierrors.NewInternalServerError("Error recording audit log entry").WithInternalError(terr) } if err := tx.Eager().Find(user, user.ID); err != nil { @@ -457,6 +466,7 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) provider := getSSOProvider(ctx) + config := a.config userID, err := uuid.FromString(chi.URLParam(r, "user_id")) if err != nil { @@ -517,6 +527,13 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewInternalServerError("Error updating user").WithInternalError(err) } + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserModifiedAction, "", map[string]interface{}{ + "provider": "scim", + "sso_provider_id": provider.ID, + }); terr != nil { + return apierrors.NewInternalServerError("Error recording audit log entry").WithInternalError(terr) + } + return nil }) @@ -532,6 +549,7 @@ func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) provider := getSSOProvider(ctx) + config := a.config userID, err := uuid.FromString(chi.URLParam(r, "user_id")) if err != nil { @@ -564,6 +582,13 @@ func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { } } + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserModifiedAction, "", map[string]interface{}{ + "provider": "scim", + "sso_provider_id": provider.ID, + }); terr != nil { + return apierrors.NewInternalServerError("Error recording audit log entry").WithInternalError(terr) + } + return nil }) @@ -615,6 +640,7 @@ func (a *API) scimDeleteUser(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) provider := getSSOProvider(ctx) + config := a.config userID, err := uuid.FromString(chi.URLParam(r, "user_id")) if err != nil { @@ -639,6 +665,13 @@ func (a *API) scimDeleteUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewInternalServerError("Error deprovisioning user").WithInternalError(err) } + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserDeletedAction, "", map[string]interface{}{ + "provider": "scim", + "sso_provider_id": provider.ID, + }); terr != nil { + return apierrors.NewInternalServerError("Error recording audit log entry").WithInternalError(terr) + } + return models.Logout(tx, user.ID) }) From 8ea1ec00ea2fd9e5f5564152a3ed0c90eac09e78 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 10 Dec 2025 07:40:16 +0000 Subject: [PATCH 009/101] feat: Add Admin SCIM management endpoints --- internal/api/ssoadmin.go | 87 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/internal/api/ssoadmin.go b/internal/api/ssoadmin.go index 1b1d9519c..e03681a0b 100644 --- a/internal/api/ssoadmin.go +++ b/internal/api/ssoadmin.go @@ -13,6 +13,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/gofrs/uuid" "github.com/supabase/auth/internal/api/apierrors" + "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/observability" "github.com/supabase/auth/internal/storage" @@ -460,3 +461,89 @@ func (a *API) adminSSOProvidersDelete(w http.ResponseWriter, r *http.Request) er return sendJSON(w, http.StatusOK, provider) } + +// adminSSOProviderGetSCIM returns the SCIM configuration for an SSO provider. +func (a *API) adminSSOProviderGetSCIM(w http.ResponseWriter, r *http.Request) error { + provider := getSSOProvider(r.Context()) + + return sendJSON(w, http.StatusOK, map[string]interface{}{ + "enabled": provider.IsSCIMEnabled(), + "token_set": provider.SCIMBearerTokenHash != nil, + "base_url": a.config.SiteURL + "/scim/v2", + }) +} + +// adminSSOProviderEnableSCIM enables SCIM for an SSO provider and generates a new token. +func (a *API) adminSSOProviderEnableSCIM(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + + provider := getSSOProvider(ctx) + + // Generate a new SCIM token with scim_ prefix + token := "scim_" + crypto.SecureAlphanumeric(32) + + if err := db.Transaction(func(tx *storage.Connection) error { + if err := provider.SetSCIMToken(ctx, token); err != nil { + return apierrors.NewInternalServerError("Error generating SCIM token").WithInternalError(err) + } + return tx.UpdateOnly(provider, "scim_enabled", "scim_bearer_token_hash") + }); err != nil { + return err + } + + return sendJSON(w, http.StatusOK, map[string]interface{}{ + "enabled": true, + "token": token, + "base_url": a.config.SiteURL + "/scim/v2", + }) +} + +// adminSSOProviderDisableSCIM disables SCIM for an SSO provider. +func (a *API) adminSSOProviderDisableSCIM(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + + provider := getSSOProvider(ctx) + provider.ClearSCIMToken() + + if err := db.Transaction(func(tx *storage.Connection) error { + return tx.UpdateOnly(provider, "scim_enabled", "scim_bearer_token_hash") + }); err != nil { + return err + } + + return sendJSON(w, http.StatusOK, map[string]interface{}{ + "enabled": false, + }) +} + +// adminSSOProviderRotateSCIMToken rotates the SCIM token for an SSO provider. +func (a *API) adminSSOProviderRotateSCIMToken(w http.ResponseWriter, r *http.Request) error { + ctx := r.Context() + db := a.db.WithContext(ctx) + + provider := getSSOProvider(ctx) + + if !provider.IsSCIMEnabled() { + return apierrors.NewBadRequestError(apierrors.ErrorCodeSCIMDisabled, "SCIM is not enabled for this provider") + } + + // Generate a new SCIM token with scim_ prefix + token := "scim_" + crypto.SecureAlphanumeric(32) + + if err := db.Transaction(func(tx *storage.Connection) error { + if err := provider.SetSCIMToken(ctx, token); err != nil { + return apierrors.NewInternalServerError("Error generating SCIM token").WithInternalError(err) + } + return tx.UpdateOnly(provider, "scim_bearer_token_hash") + }); err != nil { + return err + } + + return sendJSON(w, http.StatusOK, map[string]interface{}{ + "enabled": true, + "token": token, + "base_url": a.config.SiteURL + "/scim/v2", + }) +} From 8f8aa1bb155188e8f0550f8ce792ed6a83cb6904 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 10 Dec 2025 07:49:01 +0000 Subject: [PATCH 010/101] chore: make SCIM Token prefixed --- internal/api/ssoadmin.go | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/internal/api/ssoadmin.go b/internal/api/ssoadmin.go index e03681a0b..a29cef68b 100644 --- a/internal/api/ssoadmin.go +++ b/internal/api/ssoadmin.go @@ -20,6 +20,12 @@ import ( "github.com/supabase/auth/internal/utilities" ) +const SCIMTokenPrefix = "scim_" + +func generateSCIMToken() string { + return SCIMTokenPrefix + crypto.SecureAlphanumeric(32) +} + // loadSSOProvider looks for an idp_id and first checks it for a "resource_" // prefix, if present the provider is loaded by resource_id. Otherwise the // provider is loaded by id. @@ -480,8 +486,7 @@ func (a *API) adminSSOProviderEnableSCIM(w http.ResponseWriter, r *http.Request) provider := getSSOProvider(ctx) - // Generate a new SCIM token with scim_ prefix - token := "scim_" + crypto.SecureAlphanumeric(32) + token := generateSCIMToken() if err := db.Transaction(func(tx *storage.Connection) error { if err := provider.SetSCIMToken(ctx, token); err != nil { @@ -529,8 +534,7 @@ func (a *API) adminSSOProviderRotateSCIMToken(w http.ResponseWriter, r *http.Req return apierrors.NewBadRequestError(apierrors.ErrorCodeSCIMDisabled, "SCIM is not enabled for this provider") } - // Generate a new SCIM token with scim_ prefix - token := "scim_" + crypto.SecureAlphanumeric(32) + token := generateSCIMToken() if err := db.Transaction(func(tx *storage.Connection) error { if err := provider.SetSCIMToken(ctx, token); err != nil { From e9772768b397482b198a4914e40e44bfe4c44c29 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 10 Dec 2025 08:09:14 +0000 Subject: [PATCH 011/101] fix: several bugfixes, add db-pagination --- internal/api/router.go | 3 ++ internal/api/scim.go | 71 ++++++++++------------------------- internal/models/connection.go | 2 + internal/models/scim_group.go | 34 +++++++++++------ internal/models/user.go | 32 +++++++++++++--- 5 files changed, 73 insertions(+), 69 deletions(-) diff --git a/internal/api/router.go b/internal/api/router.go index 1feb66d3f..0a01f55fb 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -30,6 +30,9 @@ func (r *router) Post(pattern string, fn apiHandler) { func (r *router) Put(pattern string, fn apiHandler) { r.chi.Put(pattern, handler(fn)) } +func (r *router) Patch(pattern string, fn apiHandler) { + r.chi.Patch(pattern, handler(fn)) +} func (r *router) Delete(pattern string, fn apiHandler) { r.chi.Delete(pattern, handler(fn)) } diff --git a/internal/api/scim.go b/internal/api/scim.go index 2af806261..28bb989ed 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -286,7 +286,6 @@ func (a *API) parseSCIMBody(r *http.Request, v interface{}) error { // User Endpoints -// scimListUsers handles GET /scim/v2/Users func (a *API) scimListUsers(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) @@ -295,29 +294,19 @@ func (a *API) scimListUsers(w http.ResponseWriter, r *http.Request) error { startIndex, count := parseSCIMPagination(r) providerType := "sso:" + provider.ID.String() - users, err := models.FindUsersByProvider(db, providerType) + + totalResults, err := models.CountUsersByProvider(db, providerType) if err != nil { - return apierrors.NewInternalServerError("Error fetching users").WithInternalError(err) + return apierrors.NewInternalServerError("Error counting users").WithInternalError(err) } - totalResults := len(users) - - // Apply pagination (SCIM uses 1-based indexing) - start := startIndex - 1 - if start < 0 { - start = 0 - } - if start > len(users) { - start = len(users) - } - end := start + count - if end > len(users) { - end = len(users) + users, err := models.FindUsersByProvider(db, providerType, startIndex, count) + if err != nil { + return apierrors.NewInternalServerError("Error fetching users").WithInternalError(err) } - pagedUsers := users[start:end] - resources := make([]interface{}, len(pagedUsers)) - for i, user := range pagedUsers { + resources := make([]interface{}, len(users)) + for i, user := range users { resources[i] = a.userToSCIMResponse(user) } @@ -325,12 +314,11 @@ func (a *API) scimListUsers(w http.ResponseWriter, r *http.Request) error { Schemas: []string{SCIMSchemaListResponse}, TotalResults: totalResults, StartIndex: startIndex, - ItemsPerPage: len(pagedUsers), + ItemsPerPage: len(users), Resources: resources, }) } -// scimGetUser handles GET /scim/v2/Users/{id} func (a *API) scimGetUser(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) @@ -371,8 +359,8 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { return err } - email := strings.ToLower(params.UserName) - if err := a.validateEmail(email); err != nil { + email, err := a.validateEmail(params.UserName) + if err != nil { return err } @@ -461,7 +449,6 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { return sendJSON(w, http.StatusCreated, a.userToSCIMResponse(user)) } -// scimReplaceUser handles PUT /scim/v2/Users/{id} func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) @@ -544,7 +531,6 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { return sendJSON(w, http.StatusOK, a.userToSCIMResponse(user)) } -// scimPatchUser handles PATCH /scim/v2/Users/{id} func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) @@ -685,7 +671,6 @@ func (a *API) scimDeleteUser(w http.ResponseWriter, r *http.Request) error { // Group Endpoints -// scimListGroups handles GET /scim/v2/Groups func (a *API) scimListGroups(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) @@ -693,28 +678,18 @@ func (a *API) scimListGroups(w http.ResponseWriter, r *http.Request) error { startIndex, count := parseSCIMPagination(r) - groups, err := models.FindSCIMGroupsBySSOProvider(db, provider.ID, nil) + totalResults, err := models.CountSCIMGroupsBySSOProvider(db, provider.ID) if err != nil { - return apierrors.NewInternalServerError("Error fetching groups").WithInternalError(err) + return apierrors.NewInternalServerError("Error counting groups").WithInternalError(err) } - totalResults := len(groups) - - start := startIndex - 1 - if start < 0 { - start = 0 - } - if start > len(groups) { - start = len(groups) - } - end := start + count - if end > len(groups) { - end = len(groups) + groups, err := models.FindSCIMGroupsBySSOProvider(db, provider.ID, startIndex, count) + if err != nil { + return apierrors.NewInternalServerError("Error fetching groups").WithInternalError(err) } - pagedGroups := groups[start:end] - resources := make([]interface{}, len(pagedGroups)) - for i, group := range pagedGroups { + resources := make([]interface{}, len(groups)) + for i, group := range groups { members, _ := group.GetMembers(db) resources[i] = a.groupToSCIMResponse(group, members) } @@ -723,12 +698,11 @@ func (a *API) scimListGroups(w http.ResponseWriter, r *http.Request) error { Schemas: []string{SCIMSchemaListResponse}, TotalResults: totalResults, StartIndex: startIndex, - ItemsPerPage: len(pagedGroups), + ItemsPerPage: len(groups), Resources: resources, }) } -// scimGetGroup handles GET /scim/v2/Groups/{id} func (a *API) scimGetGroup(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) @@ -759,7 +733,6 @@ func (a *API) scimGetGroup(w http.ResponseWriter, r *http.Request) error { return sendJSON(w, http.StatusOK, a.groupToSCIMResponse(group, members)) } -// scimCreateGroup handles POST /scim/v2/Groups func (a *API) scimCreateGroup(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) @@ -809,7 +782,6 @@ func (a *API) scimCreateGroup(w http.ResponseWriter, r *http.Request) error { return sendJSON(w, http.StatusCreated, a.groupToSCIMResponse(group, members)) } -// scimReplaceGroup handles PUT /scim/v2/Groups/{id} func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) @@ -869,7 +841,6 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { return sendJSON(w, http.StatusOK, a.groupToSCIMResponse(group, members)) } -// scimPatchGroup handles PATCH /scim/v2/Groups/{id} func (a *API) scimPatchGroup(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) @@ -992,7 +963,6 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou return nil } -// scimDeleteGroup handles DELETE /scim/v2/Groups/{id} func (a *API) scimDeleteGroup(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) @@ -1029,7 +999,6 @@ func (a *API) scimDeleteGroup(w http.ResponseWriter, r *http.Request) error { // Service Provider Config Endpoints -// scimServiceProviderConfig handles GET /scim/v2/ServiceProviderConfig func (a *API) scimServiceProviderConfig(w http.ResponseWriter, r *http.Request) error { baseURL := a.getSCIMBaseURL() @@ -1058,7 +1027,6 @@ func (a *API) scimServiceProviderConfig(w http.ResponseWriter, r *http.Request) }) } -// scimResourceTypes handles GET /scim/v2/ResourceTypes func (a *API) scimResourceTypes(w http.ResponseWriter, r *http.Request) error { baseURL := a.getSCIMBaseURL() @@ -1084,7 +1052,6 @@ func (a *API) scimResourceTypes(w http.ResponseWriter, r *http.Request) error { }) } -// scimSchemas handles GET /scim/v2/Schemas func (a *API) scimSchemas(w http.ResponseWriter, r *http.Request) error { return sendJSON(w, http.StatusOK, []map[string]interface{}{ { diff --git a/internal/models/connection.go b/internal/models/connection.go index 82a5e8775..35fcccc35 100644 --- a/internal/models/connection.go +++ b/internal/models/connection.go @@ -50,6 +50,8 @@ func TruncateAll(conn *storage.Connection) error { (&pop.Model{Value: FlowState{}}).TableName(), (&pop.Model{Value: OneTimeToken{}}).TableName(), (&pop.Model{Value: OAuthServerClient{}}).TableName(), + (&pop.Model{Value: SCIMGroup{}}).TableName(), + (&pop.Model{Value: SCIMGroupMember{}}).TableName(), } for _, tableName := range tables { diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index c8eb4619f..3602508cb 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -67,13 +67,30 @@ func FindSCIMGroupByExternalID(tx *storage.Connection, ssoProviderID uuid.UUID, return &group, nil } -func FindSCIMGroupsBySSOProvider(tx *storage.Connection, ssoProviderID uuid.UUID, page *Pagination) ([]*SCIMGroup, error) { +func CountSCIMGroupsBySSOProvider(tx *storage.Connection, ssoProviderID uuid.UUID) (int, error) { + count, err := tx.Q().Where("sso_provider_id = ?", ssoProviderID).Count(&SCIMGroup{}) + if err != nil { + return 0, errors.Wrap(err, "error counting SCIM groups by SSO provider") + } + return count, nil +} + +// startIndex is 1-indexed per SCIM spec. count is the max number of results to return. +func FindSCIMGroupsBySSOProvider(tx *storage.Connection, ssoProviderID uuid.UUID, startIndex, count int) ([]*SCIMGroup, error) { groups := []*SCIMGroup{} - q := tx.Q().Where("sso_provider_id = ?", ssoProviderID).Order("created_at ASC") - if page != nil { - q = q.Paginate(page.Page, page.PerPage) + + offset := startIndex - 1 + if offset < 0 { + offset = 0 } - if err := q.All(&groups); err != nil { + + query := ` + SELECT * FROM scim_groups + WHERE sso_provider_id = ? + ORDER BY created_at ASC + LIMIT ? OFFSET ? + ` + if err := tx.RawQuery(query, ssoProviderID, count, offset).All(&groups); err != nil { if errors.Cause(err) == sql.ErrNoRows { return []*SCIMGroup{}, nil } @@ -143,10 +160,3 @@ func (g *SCIMGroup) SetMembers(tx *storage.Connection, userIDs []uuid.UUID) erro return nil } -func CountSCIMGroupsBySSOProvider(tx *storage.Connection, ssoProviderID uuid.UUID) (int, error) { - count, err := tx.Q().Where("sso_provider_id = ?", ssoProviderID).Count(&SCIMGroup{}) - if err != nil { - return 0, errors.Wrap(err, "error counting SCIM groups") - } - return count, nil -} diff --git a/internal/models/user.go b/internal/models/user.go index 09136c2ad..58782c6b1 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -631,14 +631,36 @@ func FindUserByID(tx *storage.Connection, id uuid.UUID) (*User, error) { return findUser(tx, "instance_id = ? and id = ?", uuid.Nil, id) } -// FindUsersByProvider finds all users with an identity for the given provider. -func FindUsersByProvider(tx *storage.Connection, provider string) ([]*User, error) { +func CountUsersByProvider(tx *storage.Connection, provider string) (int, error) { + var count int + err := tx.RawQuery(` + SELECT COUNT(DISTINCT u.id) FROM `+(&User{}).TableName()+` u + INNER JOIN identities i ON u.id = i.user_id + WHERE i.provider = ? AND u.instance_id = ? + `, provider, uuid.Nil).First(&count) + if err != nil { + return 0, errors.Wrap(err, "error counting users by provider") + } + return count, nil +} + +// startIndex is 1-indexed per SCIM spec. count is the max number of results to return. +func FindUsersByProvider(tx *storage.Connection, provider string, startIndex, count int) ([]*User, error) { users := []*User{} - err := tx.Eager().RawQuery(` - SELECT DISTINCT u.* FROM `+(&User{}).TableName()+` u + + offset := startIndex - 1 + if offset < 0 { + offset = 0 + } + + query := ` + SELECT DISTINCT u.* FROM ` + (&User{}).TableName() + ` u INNER JOIN identities i ON u.id = i.user_id WHERE i.provider = ? AND u.instance_id = ? - `, provider, uuid.Nil).All(&users) + ORDER BY u.created_at ASC + LIMIT ? OFFSET ? + ` + err := tx.Eager().RawQuery(query, provider, uuid.Nil, count, offset).All(&users) if err != nil { if errors.Cause(err) == sql.ErrNoRows { return users, nil From b3676aa3c8cd8c4d4e8cd2896811f01a5b8da61e Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 10 Dec 2025 10:18:55 +0000 Subject: [PATCH 012/101] fix: restore is_super_admin to the User model --- internal/models/user.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/internal/models/user.go b/internal/models/user.go index 58782c6b1..9fa2c0bdb 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -23,10 +23,11 @@ import ( type User struct { ID uuid.UUID `json:"id" db:"id"` - Aud string `json:"aud" db:"aud"` - Role string `json:"role" db:"role"` - Email storage.NullString `json:"email" db:"email"` - IsSSOUser bool `json:"-" db:"is_sso_user"` + Aud string `json:"aud" db:"aud"` + Role string `json:"role" db:"role"` + Email storage.NullString `json:"email" db:"email"` + IsSSOUser bool `json:"-" db:"is_sso_user"` + IsSuperAdmin bool `json:"-" db:"is_super_admin"` EncryptedPassword *string `json:"-" db:"encrypted_password"` EmailConfirmedAt *time.Time `json:"email_confirmed_at,omitempty" db:"email_confirmed_at"` From cac75ef4e8f2d18868894b873f4abee1e6f77150 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Thu, 11 Dec 2025 05:13:01 +0000 Subject: [PATCH 013/101] fix: RFC 7644 compliance --- internal/api/api.go | 5 +++ internal/api/apierrors/apierrors.go | 66 +++++++++++++++++++++++++++++ internal/api/errors.go | 7 +++ internal/api/router.go | 4 ++ 4 files changed, 82 insertions(+) diff --git a/internal/api/api.go b/internal/api/api.go index 3b7ce0961..d4c157107 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -205,10 +205,15 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne r.Route("/scim/v2", func(r *router) { r.Use(api.requireSCIMAuthentication) + // SCIM-specific NotFound handler for proper error format + r.NotFound(api.scimNotFound) + // Service Provider Configuration r.Get("/ServiceProviderConfig", api.scimServiceProviderConfig) r.Get("/ResourceTypes", api.scimResourceTypes) + r.Get("/ResourceTypes/{resource_type_id}", api.scimResourceTypeByID) r.Get("/Schemas", api.scimSchemas) + r.Get("/Schemas/{schema_id}", api.scimSchemaByID) // User endpoints r.Route("/Users", func(r *router) { diff --git a/internal/api/apierrors/apierrors.go b/internal/api/apierrors/apierrors.go index adab1d39c..4df76cfd4 100644 --- a/internal/api/apierrors/apierrors.go +++ b/internal/api/apierrors/apierrors.go @@ -120,3 +120,69 @@ func (e *HTTPError) WithInternalMessage(fmtString string, args ...any) *HTTPErro e.InternalMessage = fmt.Sprintf(fmtString, args...) return e } + +// SCIMHTTPError is an error with SCIM-specific format per RFC 7644 Section 3.12 +type SCIMHTTPError struct { + HTTPStatus int `json:"-"` + Schemas []string `json:"schemas"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + ScimType string `json:"scimType,omitempty"` + InternalError error `json:"-"` + InternalMessage string `json:"-"` +} + +const SCIMSchemaError = "urn:ietf:params:scim:api:messages:2.0:Error" + +func NewSCIMHTTPError(httpStatus int, detail string, scimType string) *SCIMHTTPError { + return &SCIMHTTPError{ + HTTPStatus: httpStatus, + Schemas: []string{SCIMSchemaError}, + Status: fmt.Sprintf("%d", httpStatus), + Detail: detail, + ScimType: scimType, + } +} + +func NewSCIMBadRequestError(detail string, scimType string) *SCIMHTTPError { + return NewSCIMHTTPError(http.StatusBadRequest, detail, scimType) +} + +func NewSCIMNotFoundError(detail string) *SCIMHTTPError { + return NewSCIMHTTPError(http.StatusNotFound, detail, "") +} + +func NewSCIMUnauthorizedError(detail string) *SCIMHTTPError { + return NewSCIMHTTPError(http.StatusUnauthorized, detail, "") +} + +func NewSCIMConflictError(detail string, scimType string) *SCIMHTTPError { + return NewSCIMHTTPError(http.StatusConflict, detail, scimType) +} + +func (e *SCIMHTTPError) Error() string { + if e.InternalMessage != "" { + return e.InternalMessage + } + return fmt.Sprintf("%d: %s", e.HTTPStatus, e.Detail) +} + +// Cause returns the root cause error +func (e *SCIMHTTPError) Cause() error { + if e.InternalError != nil { + return e.InternalError + } + return e +} + +// WithInternalError adds internal error information to the error +func (e *SCIMHTTPError) WithInternalError(err error) *SCIMHTTPError { + e.InternalError = err + return e +} + +// WithInternalMessage adds internal message information to the error +func (e *SCIMHTTPError) WithInternalMessage(fmtString string, args ...any) *SCIMHTTPError { + e.InternalMessage = fmt.Sprintf(fmtString, args...) + return e +} diff --git a/internal/api/errors.go b/internal/api/errors.go index a9b467f36..e2b9d6295 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -188,6 +188,13 @@ func HandleResponseError(err error, w http.ResponseWriter, r *http.Request) { log.WithError(jsonErr).Warn("Failed to send JSON on ResponseWriter") } + case *apierrors.SCIMHTTPError: + log.WithError(e.Cause()).Info(e.Error()) + w.Header().Set("Content-Type", "application/scim+json") + if jsonErr := sendJSON(w, e.HTTPStatus, e); jsonErr != nil && jsonErr != context.DeadlineExceeded { + log.WithError(jsonErr).Warn("Failed to send JSON on ResponseWriter") + } + case ErrorCause: HandleResponseError(e.Cause(), w, r) diff --git a/internal/api/router.go b/internal/api/router.go index 0a01f55fb..25fbae3d1 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -54,6 +54,10 @@ func (r *router) UseBypass(fn func(next http.Handler) http.Handler) { r.chi.Use(fn) } +func (r *router) NotFound(fn apiHandler) { + r.chi.NotFound(handler(fn)) +} + func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.chi.ServeHTTP(w, req) } From adb312585d16f7935bed15ff18be35b08e77cdb7 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Thu, 11 Dec 2025 07:06:55 +0000 Subject: [PATCH 014/101] chore: extract scim_parser out, minimal impl --- internal/api/scim_parser.go | 193 ++++++++++++++++++++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 internal/api/scim_parser.go diff --git a/internal/api/scim_parser.go b/internal/api/scim_parser.go new file mode 100644 index 000000000..1848a1499 --- /dev/null +++ b/internal/api/scim_parser.go @@ -0,0 +1,193 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/gofrs/uuid" + "github.com/supabase/auth/internal/api/apierrors" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/utilities" +) + +// parseSCIMPagination extracts startIndex and count from SCIM query parameters. +// startIndex is 1-indexed per SCIM spec, count defaults to SCIMDefaultPageSize. +func parseSCIMPagination(r *http.Request) (startIndex, count int) { + startIndex = 1 + count = SCIMDefaultPageSize + + if v := r.URL.Query().Get("startIndex"); v != "" { + if i, err := strconv.Atoi(v); err == nil && i > 0 { + startIndex = i + } + } + + if v := r.URL.Query().Get("count"); v != "" { + if i, err := strconv.Atoi(v); err == nil && i > 0 { + count = i + if count > SCIMMaxPageSize { + count = SCIMMaxPageSize + } + } + } + + return startIndex, count +} + +// parseSCIMFilter extracts a value from a SCIM filter expression for the given attribute. +// Supports: attributeName eq "value" (case-insensitive attribute name per RFC 7644) +// Returns empty string if no valid filter found. +func parseSCIMFilter(filter, attributeName string) string { + if filter == "" { + return "" + } + filter = strings.TrimSpace(filter) + lower := strings.ToLower(filter) + expectedPrefix := strings.ToLower(attributeName) + " eq " + + if strings.HasPrefix(lower, expectedPrefix) { + rest := filter[len(attributeName)+4:] // " eq " = 4 chars + rest = strings.TrimSpace(rest) + if len(rest) >= 2 && rest[0] == '"' && rest[len(rest)-1] == '"' { + return rest[1 : len(rest)-1] + } + } + return "" +} + +// parseSCIMBody parses the request body as JSON into the provided struct. +func (a *API) parseSCIMBody(r *http.Request, v interface{}) error { + body, err := utilities.GetBodyBytes(r) + if err != nil { + return apierrors.NewInternalServerError("Could not read request body").WithInternalError(err) + } + if err := json.Unmarshal(body, v); err != nil { + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid JSON: %v", err), "invalidSyntax") + } + return nil +} + +// userBelongsToProvider checks if a user has an identity linked to the given SSO provider. +func userBelongsToProvider(user *models.User, providerID uuid.UUID) bool { + providerType := "sso:" + providerID.String() + for _, identity := range user.Identities { + if identity.Provider == providerType { + return true + } + } + return false +} + +// userToSCIMResponse converts a User model to a SCIM User response. +func (a *API) userToSCIMResponse(user *models.User) *SCIMUserResponse { + baseURL := a.getSCIMBaseURL() + resp := &SCIMUserResponse{ + Schemas: []string{SCIMSchemaUser}, + ID: user.ID.String(), + UserName: user.GetEmail(), // Default to email, will be overwritten if userName stored in identity + Active: !user.IsBanned(), + Meta: SCIMMeta{ + ResourceType: "User", + Created: &user.CreatedAt, + LastModified: &user.UpdatedAt, + Location: baseURL + "/scim/v2/Users/" + user.ID.String(), + }, + } + + // Set external ID, userName, and email type from SSO identity if available + var emailType string + for _, identity := range user.Identities { + if strings.HasPrefix(identity.Provider, "sso:") { + if identity.ProviderID != "" { + resp.ExternalID = identity.ProviderID + } + // Get userName and email_type from identity metadata if stored + if identity.IdentityData != nil { + if userName, ok := identity.IdentityData["user_name"].(string); ok && userName != "" { + resp.UserName = userName + } + if et, ok := identity.IdentityData["email_type"].(string); ok { + emailType = et + } + } + break + } + } + + if email := user.GetEmail(); email != "" { + scimEmail := SCIMEmail{Value: email, Primary: true} + // Only include type if it was originally provided (not empty) + if emailType != "" { + scimEmail.Type = emailType + } + resp.Emails = []SCIMEmail{scimEmail} + } + + if user.UserMetaData != nil { + name := &SCIMName{} + hasName := false + if v, ok := user.UserMetaData["given_name"].(string); ok { + name.GivenName = v + hasName = true + } + if v, ok := user.UserMetaData["family_name"].(string); ok { + name.FamilyName = v + hasName = true + } + if v, ok := user.UserMetaData["full_name"].(string); ok { + name.Formatted = v + hasName = true + } + if hasName { + resp.Name = name + } + } + + return resp +} + +// groupToSCIMResponse converts a SCIMGroup model to a SCIM Group response. +func (a *API) groupToSCIMResponse(group *models.SCIMGroup, members []*models.User) *SCIMGroupResponse { + baseURL := a.getSCIMBaseURL() + resp := &SCIMGroupResponse{ + Schemas: []string{SCIMSchemaGroup}, + ID: group.ID.String(), + ExternalID: string(group.ExternalID), + DisplayName: group.DisplayName, + Members: []SCIMGroupMemberRef{}, // Always include members, empty array if none + Meta: SCIMMeta{ + ResourceType: "Group", + Created: &group.CreatedAt, + LastModified: &group.UpdatedAt, + Location: baseURL + "/scim/v2/Groups/" + group.ID.String(), + }, + } + + if len(members) > 0 { + resp.Members = make([]SCIMGroupMemberRef, len(members)) + for i, m := range members { + resp.Members[i] = SCIMGroupMemberRef{ + Value: m.ID.String(), + Ref: baseURL + "/scim/v2/Users/" + m.ID.String(), + Display: m.GetEmail(), + } + } + } + + return resp +} + +// getSCIMBaseURL returns the base URL for SCIM resource locations. +func (a *API) getSCIMBaseURL() string { + return a.config.SiteURL +} + +// sendSCIMJSON sends a JSON response with SCIM content type. +func sendSCIMJSON(w http.ResponseWriter, status int, obj interface{}) error { + w.Header().Set("Content-Type", "application/scim+json") + w.WriteHeader(status) + return json.NewEncoder(w).Encode(obj) +} From ae537dcf369f78aa380c98744e2f2ebbab3f4ed0 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 24 Dec 2025 04:41:51 +0000 Subject: [PATCH 015/101] chore: extract scim types out --- internal/api/scim_types.go | 151 +++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 internal/api/scim_types.go diff --git a/internal/api/scim_types.go b/internal/api/scim_types.go new file mode 100644 index 000000000..3a1fc14c5 --- /dev/null +++ b/internal/api/scim_types.go @@ -0,0 +1,151 @@ +package api + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/supabase/auth/internal/api/apierrors" +) + +const ( + SCIMDefaultPageSize = 100 + SCIMMaxPageSize = 1000 + SCIMSchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User" + SCIMSchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group" + SCIMSchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse" + SCIMSchemaPatchOp = "urn:ietf:params:scim:api:messages:2.0:PatchOp" + SCIMSchemaError = "urn:ietf:params:scim:api:messages:2.0:Error" +) + +// Must be var (not const) because it's passed by pointer to user.Ban() +var scimDeprovisionedReason = "SCIM_DEPROVISIONED" + +// FlexBool handles both bool and string ("true"/"false") - Azure AD sends strings +type FlexBool bool + +func (fb *FlexBool) UnmarshalJSON(data []byte) error { + var b bool + if err := json.Unmarshal(data, &b); err == nil { + *fb = FlexBool(b) + return nil + } + var s string + if err := json.Unmarshal(data, &s); err == nil { + *fb = FlexBool(strings.ToLower(s) == "true") + return nil + } + return fmt.Errorf("cannot unmarshal %s into FlexBool", string(data)) +} + +type SCIMUserParams struct { + Schemas []string `json:"schemas"` + ExternalID string `json:"externalId"` + UserName string `json:"userName"` + Name *SCIMName `json:"name,omitempty"` + Emails []SCIMEmail `json:"emails,omitempty"` + Active *bool `json:"active,omitempty"` +} + +func (p *SCIMUserParams) Validate() error { + if p.UserName == "" { + return apierrors.NewSCIMBadRequestError("userName is required", "invalidSyntax") + } + return nil +} + +type SCIMName struct { + Formatted string `json:"formatted,omitempty"` + FamilyName string `json:"familyName,omitempty"` + GivenName string `json:"givenName,omitempty"` +} + +type SCIMEmail struct { + Value string `json:"value"` + Type string `json:"type,omitempty"` + Primary FlexBool `json:"primary,omitempty"` +} + +type SCIMGroupParams struct { + Schemas []string `json:"schemas"` + ExternalID string `json:"externalId"` + DisplayName string `json:"displayName"` + Members []SCIMGroupMemberRef `json:"members,omitempty"` +} + +func (p *SCIMGroupParams) Validate() error { + if p.DisplayName == "" { + return apierrors.NewSCIMBadRequestError("displayName is required", "invalidSyntax") + } + return nil +} + +type SCIMGroupMemberRef struct { + Value string `json:"value"` + Ref string `json:"$ref,omitempty"` + Display string `json:"display,omitempty"` +} + +type SCIMPatchRequest struct { + Schemas []string `json:"schemas"` + Operations []SCIMPatchOperation `json:"Operations"` +} + +type SCIMPatchOperation struct { + Op string `json:"op"` + Path string `json:"path,omitempty"` + Value interface{} `json:"value,omitempty"` +} + +type SCIMMeta struct { + ResourceType string `json:"resourceType"` + Created *time.Time `json:"created,omitempty"` + LastModified *time.Time `json:"lastModified,omitempty"` + Location string `json:"location,omitempty"` +} + +type SCIMUserResponse struct { + Schemas []string `json:"schemas"` + ID string `json:"id"` + ExternalID string `json:"externalId,omitempty"` + UserName string `json:"userName"` + Name *SCIMName `json:"name,omitempty"` + Emails []SCIMEmail `json:"emails,omitempty"` + Active bool `json:"active"` + Meta SCIMMeta `json:"meta"` +} + +type SCIMGroupResponse struct { + Schemas []string `json:"schemas"` + ID string `json:"id"` + ExternalID string `json:"externalId,omitempty"` + DisplayName string `json:"displayName"` + Members []SCIMGroupMemberRef `json:"members,omitempty"` + Meta SCIMMeta `json:"meta"` +} + +type SCIMListResponse struct { + Schemas []string `json:"schemas"` + TotalResults int `json:"totalResults"` + StartIndex int `json:"startIndex"` + ItemsPerPage int `json:"itemsPerPage"` + Resources []interface{} `json:"Resources"` +} + +type SCIMErrorResponse struct { + Schemas []string `json:"schemas"` + Status string `json:"status"` + Detail string `json:"detail,omitempty"` + ScimType string `json:"scimType,omitempty"` +} + +func NewSCIMError(status int, detail string, scimType string) *SCIMErrorResponse { + return &SCIMErrorResponse{ + Schemas: []string{SCIMSchemaError}, + Status: strconv.Itoa(status), + Detail: detail, + ScimType: scimType, + } +} From 5d14e162afe8ab1195e5ec739010e306139f2e8d Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 24 Dec 2025 04:42:25 +0000 Subject: [PATCH 016/101] chore: add scim2/filter-parser as SCIM query parser --- internal/api/scim_helpers.go | 161 +++++++++++++++++++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 internal/api/scim_helpers.go diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go new file mode 100644 index 000000000..aa713bcab --- /dev/null +++ b/internal/api/scim_helpers.go @@ -0,0 +1,161 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/gofrs/uuid" + "github.com/supabase/auth/internal/api/apierrors" + "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/utilities" +) + +func parseSCIMPagination(r *http.Request) (startIndex, count int) { + startIndex = 1 + count = SCIMDefaultPageSize + + if v := r.URL.Query().Get("startIndex"); v != "" { + if i, err := strconv.Atoi(v); err == nil && i > 0 { + startIndex = i + } + } + + if v := r.URL.Query().Get("count"); v != "" { + if i, err := strconv.Atoi(v); err == nil && i > 0 { + count = i + if count > SCIMMaxPageSize { + count = SCIMMaxPageSize + } + } + } + + return startIndex, count +} + +func (a *API) parseSCIMBody(r *http.Request, v interface{}) error { + body, err := utilities.GetBodyBytes(r) + if err != nil { + return apierrors.NewInternalServerError("Could not read request body").WithInternalError(err) + } + if err := json.Unmarshal(body, v); err != nil { + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid JSON: %v", err), "invalidSyntax") + } + return nil +} + +func userBelongsToProvider(user *models.User, providerID uuid.UUID) bool { + providerType := "sso:" + providerID.String() + for _, identity := range user.Identities { + if identity.Provider == providerType { + return true + } + } + return false +} + +func (a *API) userToSCIMResponse(user *models.User) *SCIMUserResponse { + baseURL := a.getSCIMBaseURL() + resp := &SCIMUserResponse{ + Schemas: []string{SCIMSchemaUser}, + ID: user.ID.String(), + UserName: user.GetEmail(), + Active: !user.IsBanned(), + Meta: SCIMMeta{ + ResourceType: "User", + Created: &user.CreatedAt, + LastModified: &user.UpdatedAt, + Location: baseURL + "/scim/v2/Users/" + user.ID.String(), + }, + } + + var emailType string + for _, identity := range user.Identities { + if strings.HasPrefix(identity.Provider, "sso:") { + if identity.ProviderID != "" { + resp.ExternalID = identity.ProviderID + } + if identity.IdentityData != nil { + if userName, ok := identity.IdentityData["user_name"].(string); ok && userName != "" { + resp.UserName = userName + } + if et, ok := identity.IdentityData["email_type"].(string); ok { + emailType = et + } + } + break + } + } + + if email := user.GetEmail(); email != "" { + scimEmail := SCIMEmail{Value: email, Primary: true} + if emailType != "" { + scimEmail.Type = emailType + } + resp.Emails = []SCIMEmail{scimEmail} + } + + if user.UserMetaData != nil { + name := &SCIMName{} + hasName := false + if v, ok := user.UserMetaData["given_name"].(string); ok { + name.GivenName = v + hasName = true + } + if v, ok := user.UserMetaData["family_name"].(string); ok { + name.FamilyName = v + hasName = true + } + if v, ok := user.UserMetaData["full_name"].(string); ok { + name.Formatted = v + hasName = true + } + if hasName { + resp.Name = name + } + } + + return resp +} + +func (a *API) groupToSCIMResponse(group *models.SCIMGroup, members []*models.User) *SCIMGroupResponse { + baseURL := a.getSCIMBaseURL() + resp := &SCIMGroupResponse{ + Schemas: []string{SCIMSchemaGroup}, + ID: group.ID.String(), + ExternalID: string(group.ExternalID), + DisplayName: group.DisplayName, + Members: []SCIMGroupMemberRef{}, + Meta: SCIMMeta{ + ResourceType: "Group", + Created: &group.CreatedAt, + LastModified: &group.UpdatedAt, + Location: baseURL + "/scim/v2/Groups/" + group.ID.String(), + }, + } + + if len(members) > 0 { + resp.Members = make([]SCIMGroupMemberRef, len(members)) + for i, m := range members { + resp.Members[i] = SCIMGroupMemberRef{ + Value: m.ID.String(), + Ref: baseURL + "/scim/v2/Users/" + m.ID.String(), + Display: m.GetEmail(), + } + } + } + + return resp +} + +func (a *API) getSCIMBaseURL() string { + return a.config.SiteURL +} + +func sendSCIMJSON(w http.ResponseWriter, status int, obj interface{}) error { + w.Header().Set("Content-Type", "application/scim+json") + w.WriteHeader(status) + return json.NewEncoder(w).Encode(obj) +} From 9183e451879b4cfd1a85811a1480a964e43ea6e8 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 24 Dec 2025 04:42:39 +0000 Subject: [PATCH 017/101] chore: add SCIM errors --- internal/api/apierrors/apierrors.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/internal/api/apierrors/apierrors.go b/internal/api/apierrors/apierrors.go index 4df76cfd4..a6011718b 100644 --- a/internal/api/apierrors/apierrors.go +++ b/internal/api/apierrors/apierrors.go @@ -160,6 +160,14 @@ func NewSCIMConflictError(detail string, scimType string) *SCIMHTTPError { return NewSCIMHTTPError(http.StatusConflict, detail, scimType) } +func NewSCIMForbiddenError(detail string) *SCIMHTTPError { + return NewSCIMHTTPError(http.StatusForbidden, detail, "") +} + +func NewSCIMInternalServerError(detail string) *SCIMHTTPError { + return NewSCIMHTTPError(http.StatusInternalServerError, detail, "") +} + func (e *SCIMHTTPError) Error() string { if e.InternalMessage != "" { return e.InternalMessage From 1cc3ec352c1715ddb5b56d449c7045da9d73e235 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 24 Dec 2025 04:44:32 +0000 Subject: [PATCH 018/101] feat: add SCIM filter support to user and group queries - Add FindUsersByProviderWithFilter for SCIM user listing - Add FindSCIMGroupsBySSOProviderWithFilter for group listing - Make external_id nullable, add case-insensitive displayName index - Validate user belongs to SSO provider before adding to group --- internal/models/scim_group.go | 93 ++++++++++++++++++++++++++++++----- internal/models/user.go | 48 ++++++++++++++++++ 2 files changed, 130 insertions(+), 11 deletions(-) diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index 3602508cb..45fe15f9f 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -10,10 +10,10 @@ import ( ) type SCIMGroup struct { - ID uuid.UUID `db:"id" json:"id"` - SSOProviderID uuid.UUID `db:"sso_provider_id" json:"-"` - ExternalID string `db:"external_id" json:"external_id"` - DisplayName string `db:"display_name" json:"display_name"` + ID uuid.UUID `db:"id" json:"id"` + SSOProviderID uuid.UUID `db:"sso_provider_id" json:"-"` + ExternalID storage.NullString `db:"external_id" json:"external_id,omitempty"` + DisplayName string `db:"display_name" json:"display_name"` CreatedAt time.Time `db:"created_at" json:"created_at"` UpdatedAt time.Time `db:"updated_at" json:"updated_at"` @@ -37,12 +37,16 @@ func (SCIMGroupMember) TableName() string { func NewSCIMGroup(ssoProviderID uuid.UUID, externalID, displayName string) *SCIMGroup { id := uuid.Must(uuid.NewV4()) - return &SCIMGroup{ + group := &SCIMGroup{ ID: id, SSOProviderID: ssoProviderID, - ExternalID: externalID, DisplayName: displayName, } + // Only set ExternalID if non-empty (NULL in DB otherwise) + if externalID != "" { + group.ExternalID = storage.NullString(externalID) + } + return group } func FindSCIMGroupByID(tx *storage.Connection, id uuid.UUID) (*SCIMGroup, error) { @@ -99,6 +103,48 @@ func FindSCIMGroupsBySSOProvider(tx *storage.Connection, ssoProviderID uuid.UUID return groups, nil } +// SCIMFilterClause represents a parsed SCIM filter as SQL WHERE clause +type SCIMFilterClause struct { + Where string + Args []interface{} +} + +// FindSCIMGroupsBySSOProviderWithFilter finds groups with optional SCIM filter. +// The filterClause should be generated by ParseSCIMFilterToSQL. +func FindSCIMGroupsBySSOProviderWithFilter(tx *storage.Connection, ssoProviderID uuid.UUID, filterClause *SCIMFilterClause, startIndex, count int) ([]*SCIMGroup, int, error) { + groups := []*SCIMGroup{} + + offset := startIndex - 1 + if offset < 0 { + offset = 0 + } + + // Build query dynamically based on filter + whereClause := "sso_provider_id = ?" + args := []interface{}{ssoProviderID} + + if filterClause != nil && filterClause.Where != "" && filterClause.Where != "1=1" { + whereClause += " AND (" + filterClause.Where + ")" + args = append(args, filterClause.Args...) + } + + var totalResults int + countQuery := "SELECT COUNT(*) FROM scim_groups WHERE " + whereClause + if err := tx.RawQuery(countQuery, args...).First(&totalResults); err != nil { + return nil, 0, errors.Wrap(err, "error counting SCIM groups") + } + + query := "SELECT * FROM scim_groups WHERE " + whereClause + " ORDER BY created_at ASC LIMIT ? OFFSET ?" + args = append(args, count, offset) + if err := tx.RawQuery(query, args...).All(&groups); err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return []*SCIMGroup{}, totalResults, nil + } + return nil, 0, errors.Wrap(err, "error finding SCIM groups") + } + return groups, totalResults, nil +} + func FindSCIMGroupsForUser(tx *storage.Connection, userID uuid.UUID) ([]*SCIMGroup, error) { groups := []*SCIMGroup{} if err := tx.RawQuery(` @@ -116,12 +162,29 @@ func FindSCIMGroupsForUser(tx *storage.Connection, userID uuid.UUID) ([]*SCIMGro } func (g *SCIMGroup) AddMember(tx *storage.Connection, userID uuid.UUID) error { - member := &SCIMGroupMember{ - GroupID: g.ID, - UserID: userID, - CreatedAt: time.Now(), + user, err := FindUserByID(tx, userID) + if err != nil { + return err + } + + if !userBelongsToSSOProvider(user, g.SSOProviderID) { + return errors.New("user does not belong to this SSO provider") + } + + return tx.RawQuery( + "INSERT INTO scim_group_members (group_id, user_id, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", + g.ID, userID, time.Now(), + ).Exec() +} + +func userBelongsToSSOProvider(user *User, ssoProviderID uuid.UUID) bool { + providerType := "sso:" + ssoProviderID.String() + for _, identity := range user.Identities { + if identity.Provider == providerType { + return true + } } - return tx.Create(member) + return false } func (g *SCIMGroup) RemoveMember(tx *storage.Connection, userID uuid.UUID) error { @@ -154,6 +217,14 @@ func (g *SCIMGroup) SetMembers(tx *storage.Connection, userIDs []uuid.UUID) erro for _, userID := range userIDs { if err := g.AddMember(tx, userID); err != nil { + if IsNotFoundError(err) { + // Skip non-existent users silently per SCIM best practice + continue + } + // Skip users that don't belong to this provider silently + if err.Error() == "user does not belong to this SSO provider" { + continue + } return errors.Wrap(err, "error adding SCIM group member") } } diff --git a/internal/models/user.go b/internal/models/user.go index 9fa2c0bdb..fa5a25887 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -671,6 +671,54 @@ func FindUsersByProvider(tx *storage.Connection, provider string, startIndex, co return users, nil } +// FindUsersByProviderWithFilter finds users by provider with optional SCIM filter. +// The filterClause should be generated by ParseSCIMFilterToSQL with SCIMUserFilterAttrs. +func FindUsersByProviderWithFilter(tx *storage.Connection, provider string, filterClause *SCIMFilterClause, startIndex, count int) ([]*User, int, error) { + users := []*User{} + + offset := startIndex - 1 + if offset < 0 { + offset = 0 + } + + // Base WHERE clause for provider + baseWhere := "i.provider = ? AND u.instance_id = ?" + baseArgs := []interface{}{provider, uuid.Nil} + + // Add filter clause if present + whereClause := baseWhere + args := baseArgs + if filterClause != nil && filterClause.Where != "" && filterClause.Where != "1=1" { + whereClause += " AND (" + filterClause.Where + ")" + args = append(args, filterClause.Args...) + } + + var totalResults int + countQuery := ` + SELECT COUNT(DISTINCT u.id) FROM ` + (&User{}).TableName() + ` u + INNER JOIN identities i ON u.id = i.user_id + WHERE ` + whereClause + if err := tx.RawQuery(countQuery, args...).First(&totalResults); err != nil { + return nil, 0, errors.Wrap(err, "error counting users by provider with filter") + } + + query := ` + SELECT DISTINCT u.* FROM ` + (&User{}).TableName() + ` u + INNER JOIN identities i ON u.id = i.user_id + WHERE ` + whereClause + ` + ORDER BY u.created_at ASC + LIMIT ? OFFSET ? + ` + args = append(args, count, offset) + if err := tx.Eager().RawQuery(query, args...).All(&users); err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return users, totalResults, nil + } + return nil, 0, errors.Wrap(err, "error finding users by provider with filter") + } + return users, totalResults, nil +} + // FindUserWithRefreshToken finds a user from the provided refresh token. If // forUpdate is set to true, then the SELECT statement used by the query has // the form SELECT ... FOR UPDATE SKIP LOCKED. This means that a FOR UPDATE From 4731c5a6fd53c65dcd7b5fec3adcac878fe95361 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 24 Dec 2025 04:45:02 +0000 Subject: [PATCH 019/101] chore: refactor SCIM to use extracted types/helpers. --- internal/api/scim.go | 851 +++++++++++++++++++++++++------------------ 1 file changed, 506 insertions(+), 345 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 28bb989ed..6e2a62e2a 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -2,10 +2,9 @@ package api import ( "context" - "encoding/json" + "fmt" "math" "net/http" - "strconv" "strings" "time" @@ -17,275 +16,34 @@ import ( "github.com/supabase/auth/internal/utilities" ) -const ( - SCIMDefaultPageSize = 100 - SCIMMaxPageSize = 1000 - SCIMSchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User" - SCIMSchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group" - SCIMSchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse" - SCIMSchemaPatchOp = "urn:ietf:params:scim:api:messages:2.0:PatchOp" -) - -var scimDeprovisionedReason = "SCIM_DEPROVISIONED" - -// SCIM request/response types - using camelCase per SCIM v2 spec (RFC 7643) - -type SCIMUserParams struct { - Schemas []string `json:"schemas"` - ExternalID string `json:"externalId"` - UserName string `json:"userName"` - Name *SCIMName `json:"name,omitempty"` - Emails []SCIMEmail `json:"emails,omitempty"` - Active *bool `json:"active,omitempty"` -} - -type SCIMName struct { - Formatted string `json:"formatted,omitempty"` - FamilyName string `json:"familyName,omitempty"` - GivenName string `json:"givenName,omitempty"` -} - -type SCIMEmail struct { - Value string `json:"value"` - Type string `json:"type,omitempty"` - Primary bool `json:"primary,omitempty"` -} - -type SCIMGroupParams struct { - Schemas []string `json:"schemas"` - ExternalID string `json:"externalId"` - DisplayName string `json:"displayName"` - Members []SCIMGroupMemberRef `json:"members,omitempty"` -} - -type SCIMGroupMemberRef struct { - Value string `json:"value"` - Ref string `json:"$ref,omitempty"` - Display string `json:"display,omitempty"` -} - -type SCIMPatchRequest struct { - Schemas []string `json:"schemas"` - Operations []SCIMPatchOperation `json:"Operations"` -} - -type SCIMPatchOperation struct { - Op string `json:"op"` - Path string `json:"path,omitempty"` - Value interface{} `json:"value,omitempty"` -} - -type SCIMMeta struct { - ResourceType string `json:"resourceType"` - Created *time.Time `json:"created,omitempty"` - LastModified *time.Time `json:"lastModified,omitempty"` - Location string `json:"location,omitempty"` -} - -type SCIMUserResponse struct { - Schemas []string `json:"schemas"` - ID string `json:"id"` - ExternalID string `json:"externalId,omitempty"` - UserName string `json:"userName"` - Name *SCIMName `json:"name,omitempty"` - Emails []SCIMEmail `json:"emails,omitempty"` - Active bool `json:"active"` - Meta SCIMMeta `json:"meta"` -} - -type SCIMGroupResponse struct { - Schemas []string `json:"schemas"` - ID string `json:"id"` - ExternalID string `json:"externalId,omitempty"` - DisplayName string `json:"displayName"` - Members []SCIMGroupMemberRef `json:"members,omitempty"` - Meta SCIMMeta `json:"meta"` -} - -type SCIMListResponse struct { - Schemas []string `json:"schemas"` - TotalResults int `json:"totalResults"` - StartIndex int `json:"startIndex"` - ItemsPerPage int `json:"itemsPerPage"` - Resources []interface{} `json:"Resources"` -} - -// Validation methods - -func (p *SCIMUserParams) Validate() error { - if p.UserName == "" { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "userName is required") - } - return nil -} - -func (p *SCIMGroupParams) Validate() error { - if p.DisplayName == "" { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "displayName is required") - } - return nil -} - -// SCIM Authentication Middleware - func (a *API) requireSCIMAuthentication(w http.ResponseWriter, r *http.Request) (context.Context, error) { ctx := r.Context() db := a.db.WithContext(ctx) token, err := a.extractBearerToken(r) if err != nil { - return nil, apierrors.NewHTTPError(http.StatusUnauthorized, apierrors.ErrorCodeSCIMTokenInvalid, "Invalid or missing SCIM bearer token") + return nil, apierrors.NewSCIMUnauthorizedError("Invalid or missing SCIM bearer token") } provider, err := models.FindSSOProviderBySCIMToken(ctx, db, token) if err != nil { if models.IsNotFoundError(err) { - return nil, apierrors.NewHTTPError(http.StatusUnauthorized, apierrors.ErrorCodeSCIMTokenInvalid, "Invalid SCIM bearer token") + return nil, apierrors.NewSCIMUnauthorizedError("Invalid SCIM bearer token") } - return nil, apierrors.NewInternalServerError("Error validating SCIM token").WithInternalError(err) + return nil, apierrors.NewSCIMInternalServerError("Error validating SCIM token").WithInternalError(err) } if !provider.IsSCIMEnabled() { - return nil, apierrors.NewForbiddenError(apierrors.ErrorCodeSCIMDisabled, "SCIM provisioning is not enabled for this provider") + return nil, apierrors.NewSCIMForbiddenError("SCIM provisioning is not enabled for this provider") } if !provider.IsEnabled() { - return nil, apierrors.NewForbiddenError(apierrors.ErrorCodeSSOProviderDisabled, "SSO provider is disabled") + return nil, apierrors.NewSCIMForbiddenError("SSO provider is disabled") } return withSSOProvider(ctx, provider), nil } -// Helper functions - -func (a *API) getSCIMBaseURL() string { - return a.config.SiteURL -} - -func parseSCIMPagination(r *http.Request) (startIndex, count int) { - startIndex = 1 - count = SCIMDefaultPageSize - - if v := r.URL.Query().Get("startIndex"); v != "" { - if i, err := strconv.Atoi(v); err == nil && i > 0 { - startIndex = i - } - } - - if v := r.URL.Query().Get("count"); v != "" { - if i, err := strconv.Atoi(v); err == nil && i > 0 { - count = i - if count > SCIMMaxPageSize { - count = SCIMMaxPageSize - } - } - } - - return startIndex, count -} - -func userBelongsToProvider(user *models.User, providerID uuid.UUID) bool { - providerType := "sso:" + providerID.String() - for _, identity := range user.Identities { - if identity.Provider == providerType { - return true - } - } - return false -} - -func (a *API) userToSCIMResponse(user *models.User) *SCIMUserResponse { - baseURL := a.getSCIMBaseURL() - resp := &SCIMUserResponse{ - Schemas: []string{SCIMSchemaUser}, - ID: user.ID.String(), - UserName: user.GetEmail(), - Active: !user.IsBanned(), - Meta: SCIMMeta{ - ResourceType: "User", - Created: &user.CreatedAt, - LastModified: &user.UpdatedAt, - Location: baseURL + "/scim/v2/Users/" + user.ID.String(), - }, - } - - // Set external ID from identity if available - for _, identity := range user.Identities { - if identity.Provider != "" && identity.ProviderID != "" { - resp.ExternalID = identity.ProviderID - break - } - } - - if email := user.GetEmail(); email != "" { - resp.Emails = []SCIMEmail{{Value: email, Type: "work", Primary: true}} - } - - if user.UserMetaData != nil { - name := &SCIMName{} - hasName := false - if v, ok := user.UserMetaData["given_name"].(string); ok { - name.GivenName = v - hasName = true - } - if v, ok := user.UserMetaData["family_name"].(string); ok { - name.FamilyName = v - hasName = true - } - if v, ok := user.UserMetaData["full_name"].(string); ok { - name.Formatted = v - hasName = true - } - if hasName { - resp.Name = name - } - } - - return resp -} - -func (a *API) groupToSCIMResponse(group *models.SCIMGroup, members []*models.User) *SCIMGroupResponse { - baseURL := a.getSCIMBaseURL() - resp := &SCIMGroupResponse{ - Schemas: []string{SCIMSchemaGroup}, - ID: group.ID.String(), - ExternalID: group.ExternalID, - DisplayName: group.DisplayName, - Meta: SCIMMeta{ - ResourceType: "Group", - Created: &group.CreatedAt, - LastModified: &group.UpdatedAt, - Location: baseURL + "/scim/v2/Groups/" + group.ID.String(), - }, - } - - if members != nil { - resp.Members = make([]SCIMGroupMemberRef, len(members)) - for i, m := range members { - resp.Members[i] = SCIMGroupMemberRef{ - Value: m.ID.String(), - Ref: baseURL + "/scim/v2/Users/" + m.ID.String(), - Display: m.GetEmail(), - } - } - } - - return resp -} - -func (a *API) parseSCIMBody(r *http.Request, v interface{}) error { - body, err := utilities.GetBodyBytes(r) - if err != nil { - return apierrors.NewInternalServerError("Could not read request body").WithInternalError(err) - } - if err := json.Unmarshal(body, v); err != nil { - return apierrors.NewBadRequestError(apierrors.ErrorCodeBadJSON, "Invalid JSON: %v", err) - } - return nil -} - -// User Endpoints - func (a *API) scimListUsers(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) @@ -293,14 +51,15 @@ func (a *API) scimListUsers(w http.ResponseWriter, r *http.Request) error { startIndex, count := parseSCIMPagination(r) - providerType := "sso:" + provider.ID.String() - - totalResults, err := models.CountUsersByProvider(db, providerType) + filterStr := r.URL.Query().Get("filter") + filterClause, err := ParseSCIMFilterToSQL(filterStr, SCIMUserFilterAttrs) if err != nil { - return apierrors.NewInternalServerError("Error counting users").WithInternalError(err) + return err } - users, err := models.FindUsersByProvider(db, providerType, startIndex, count) + providerType := "sso:" + provider.ID.String() + + users, totalResults, err := models.FindUsersByProviderWithFilter(db, providerType, toModelFilterClause(filterClause), startIndex, count) if err != nil { return apierrors.NewInternalServerError("Error fetching users").WithInternalError(err) } @@ -310,7 +69,7 @@ func (a *API) scimListUsers(w http.ResponseWriter, r *http.Request) error { resources[i] = a.userToSCIMResponse(user) } - return sendJSON(w, http.StatusOK, &SCIMListResponse{ + return sendSCIMJSON(w, http.StatusOK, &SCIMListResponse{ Schemas: []string{SCIMSchemaListResponse}, TotalResults: totalResults, StartIndex: startIndex, @@ -326,25 +85,24 @@ func (a *API) scimGetUser(w http.ResponseWriter, r *http.Request) error { userID, err := uuid.FromString(chi.URLParam(r, "user_id")) if err != nil { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid user ID format") + return apierrors.NewSCIMNotFoundError("User not found") } user, err := models.FindUserByID(db, userID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMUserNotFound, "User not found") + return apierrors.NewSCIMNotFoundError("User not found") } return apierrors.NewInternalServerError("Error fetching user").WithInternalError(err) } if !userBelongsToProvider(user, provider.ID) { - return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMUserNotFound, "User not found") + return apierrors.NewSCIMNotFoundError("User not found") } - return sendJSON(w, http.StatusOK, a.userToSCIMResponse(user)) + return sendSCIMJSON(w, http.StatusOK, a.userToSCIMResponse(user)) } -// scimCreateUser handles POST /scim/v2/Users func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) @@ -359,7 +117,27 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { return err } - email, err := a.validateEmail(params.UserName) + var email string + var emailType string + if len(params.Emails) > 0 { + for _, e := range params.Emails { + if bool(e.Primary) { + email = e.Value + emailType = e.Type + break + } + } + if email == "" { + email = params.Emails[0].Value + emailType = params.Emails[0].Type + } + } + + if email == "" { + return apierrors.NewSCIMBadRequestError("At least one email address is required", "invalidValue") + } + + email, err := a.validateEmail(email) if err != nil { return err } @@ -368,7 +146,6 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { var user *models.User terr := db.Transaction(func(tx *storage.Connection) error { - // Check if user exists and was deprovisioned existingUser, err := models.FindUserByEmailAndAudience(tx, email, config.JWT.Aud) if err != nil && !models.IsNotFoundError(err) { return apierrors.NewInternalServerError("Error checking existing user").WithInternalError(err) @@ -376,7 +153,6 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { if existingUser != nil { if existingUser.BannedReason != nil && *existingUser.BannedReason == scimDeprovisionedReason { - // Reactivate deprovisioned user if err := existingUser.Ban(tx, 0, nil); err != nil { return apierrors.NewInternalServerError("Error reactivating user").WithInternalError(err) } @@ -390,10 +166,9 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { user = existingUser return nil } - return apierrors.NewHTTPError(http.StatusConflict, apierrors.ErrorCodeSCIMUserAlreadyExists, "User with this email already exists") + return apierrors.NewSCIMConflictError("User with this email already exists", "uniqueness") } - // Create new user user, err = models.NewUser("", email, "", config.JWT.Aud, nil) if err != nil { return apierrors.NewInternalServerError("Error creating user").WithInternalError(err) @@ -417,14 +192,31 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { } if err := tx.Create(user); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("User with this email already exists", "uniqueness") + } return apierrors.NewInternalServerError("Error saving user").WithInternalError(err) } + identityID := params.ExternalID + if identityID == "" { + identityID = params.UserName + } + if _, err := a.createNewIdentity(tx, user, providerType, map[string]interface{}{ - "sub": params.ExternalID, + "sub": identityID, "external_id": params.ExternalID, "email": email, + "email_type": emailType, + "user_name": params.UserName, }); err != nil { + errToCheck := err + if httpErr, ok := err.(*apierrors.HTTPError); ok && httpErr.InternalError != nil { + errToCheck = httpErr.InternalError + } + if pgErr := utilities.NewPostgresError(errToCheck); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") + } return err } @@ -446,7 +238,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { return terr } - return sendJSON(w, http.StatusCreated, a.userToSCIMResponse(user)) + return sendSCIMJSON(w, http.StatusCreated, a.userToSCIMResponse(user)) } func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { @@ -457,7 +249,7 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { userID, err := uuid.FromString(chi.URLParam(r, "user_id")) if err != nil { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid user ID format") + return apierrors.NewSCIMNotFoundError("User not found") } var params SCIMUserParams @@ -471,13 +263,13 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { user, err = models.FindUserByID(tx, userID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMUserNotFound, "User not found") + return apierrors.NewSCIMNotFoundError("User not found") } return apierrors.NewInternalServerError("Error fetching user").WithInternalError(err) } if !userBelongsToProvider(user, provider.ID) { - return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMUserNotFound, "User not found") + return apierrors.NewSCIMNotFoundError("User not found") } if params.Name != nil { @@ -514,6 +306,22 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewInternalServerError("Error updating user").WithInternalError(err) } + if params.UserName != "" { + providerType := "sso:" + provider.ID.String() + for i := range user.Identities { + if user.Identities[i].Provider == providerType { + if user.Identities[i].IdentityData == nil { + user.Identities[i].IdentityData = make(map[string]interface{}) + } + user.Identities[i].IdentityData["user_name"] = params.UserName + if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { + return apierrors.NewInternalServerError("Error updating identity").WithInternalError(err) + } + break + } + } + } + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserModifiedAction, "", map[string]interface{}{ "provider": "scim", "sso_provider_id": provider.ID, @@ -521,6 +329,10 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewInternalServerError("Error recording audit log entry").WithInternalError(terr) } + if err := tx.Eager().Find(user, user.ID); err != nil { + return apierrors.NewInternalServerError("Error reloading user").WithInternalError(err) + } + return nil }) @@ -528,7 +340,7 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { return terr } - return sendJSON(w, http.StatusOK, a.userToSCIMResponse(user)) + return sendSCIMJSON(w, http.StatusOK, a.userToSCIMResponse(user)) } func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { @@ -539,7 +351,7 @@ func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { userID, err := uuid.FromString(chi.URLParam(r, "user_id")) if err != nil { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid user ID format") + return apierrors.NewSCIMNotFoundError("User not found") } var params SCIMPatchRequest @@ -553,21 +365,25 @@ func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { user, err = models.FindUserByID(tx, userID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMUserNotFound, "User not found") + return apierrors.NewSCIMNotFoundError("User not found") } return apierrors.NewInternalServerError("Error fetching user").WithInternalError(err) } if !userBelongsToProvider(user, provider.ID) { - return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMUserNotFound, "User not found") + return apierrors.NewSCIMNotFoundError("User not found") } for _, op := range params.Operations { - if err := a.applySCIMUserPatch(tx, user, op); err != nil { + if err := a.applySCIMUserPatch(tx, user, op, provider.ID); err != nil { return err } } + if err := tx.Eager().Find(user, user.ID); err != nil { + return apierrors.NewInternalServerError("Error reloading user").WithInternalError(err) + } + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserModifiedAction, "", map[string]interface{}{ "provider": "scim", "sso_provider_id": provider.ID, @@ -582,17 +398,57 @@ func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { return terr } - return sendJSON(w, http.StatusOK, a.userToSCIMResponse(user)) + return sendSCIMJSON(w, http.StatusOK, a.userToSCIMResponse(user)) } -func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op SCIMPatchOperation) error { +func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op SCIMPatchOperation, providerID uuid.UUID) error { + providerType := "sso:" + providerID.String() + switch strings.ToLower(op.Op) { + case "remove": + switch strings.ToLower(op.Path) { + case "externalid": + for i := range user.Identities { + if user.Identities[i].Provider == providerType { + user.Identities[i].ProviderID = "" + if user.Identities[i].IdentityData != nil { + delete(user.Identities[i].IdentityData, "external_id") + } + if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { + return apierrors.NewInternalServerError("Error updating identity").WithInternalError(err) + } + break + } + } + return nil + default: + return nil + } + case "add": + if valueMap, ok := op.Value.(map[string]interface{}); ok { + if externalID, ok := valueMap["externalId"].(string); ok { + for i := range user.Identities { + if user.Identities[i].Provider == providerType { + user.Identities[i].ProviderID = externalID + if user.Identities[i].IdentityData == nil { + user.Identities[i].IdentityData = make(map[string]interface{}) + } + user.Identities[i].IdentityData["external_id"] = externalID + if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { + return apierrors.NewInternalServerError("Error updating identity").WithInternalError(err) + } + break + } + } + } + } + return nil case "replace": switch strings.ToLower(op.Path) { case "active": active, ok := op.Value.(bool) if !ok { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "active must be a boolean") + return apierrors.NewSCIMBadRequestError("active must be a boolean", "invalidValue") } if active { return user.Ban(tx, 0, nil) @@ -601,9 +457,104 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S return err } return models.Logout(tx, user.ID) + case "username": + userName, ok := op.Value.(string) + if !ok { + return apierrors.NewSCIMBadRequestError("userName must be a string", "invalidValue") + } + for i := range user.Identities { + if user.Identities[i].Provider == providerType { + if user.Identities[i].IdentityData == nil { + user.Identities[i].IdentityData = make(map[string]interface{}) + } + user.Identities[i].IdentityData["user_name"] = userName + if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { + return apierrors.NewInternalServerError("Error updating identity").WithInternalError(err) + } + break + } + } + return nil + case "emails[primary eq true].value": + newEmail, ok := op.Value.(string) + if !ok { + return apierrors.NewSCIMBadRequestError("email value must be a string", "invalidValue") + } + validatedEmail, err := a.validateEmail(newEmail) + if err != nil { + return apierrors.NewSCIMBadRequestError("Invalid email address", "invalidValue") + } + user.Email = storage.NullString(validatedEmail) + if err := tx.UpdateOnly(user, "email"); err != nil { + return apierrors.NewInternalServerError("Error updating email").WithInternalError(err) + } + return nil case "": - // Replace entire resource if valueMap, ok := op.Value.(map[string]interface{}); ok { + if userName, ok := valueMap["userName"].(string); ok && userName != "" { + for i := range user.Identities { + if user.Identities[i].Provider == providerType { + if user.Identities[i].IdentityData == nil { + user.Identities[i].IdentityData = make(map[string]interface{}) + } + user.Identities[i].IdentityData["user_name"] = userName + if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { + return apierrors.NewInternalServerError("Error updating identity").WithInternalError(err) + } + break + } + } + } + + if user.UserMetaData == nil { + user.UserMetaData = make(map[string]interface{}) + } + metadataUpdated := false + if v, ok := valueMap["name.formatted"].(string); ok { + user.UserMetaData["full_name"] = v + metadataUpdated = true + } + if v, ok := valueMap["name.familyName"].(string); ok { + user.UserMetaData["family_name"] = v + metadataUpdated = true + } + if v, ok := valueMap["name.givenName"].(string); ok { + user.UserMetaData["given_name"] = v + metadataUpdated = true + } + if metadataUpdated { + if err := tx.UpdateOnly(user, "raw_user_meta_data"); err != nil { + return apierrors.NewInternalServerError("Error updating user metadata").WithInternalError(err) + } + } + + if externalID, ok := valueMap["externalId"].(string); ok { + for i := range user.Identities { + if user.Identities[i].Provider == providerType { + user.Identities[i].ProviderID = externalID + if user.Identities[i].IdentityData == nil { + user.Identities[i].IdentityData = make(map[string]interface{}) + } + user.Identities[i].IdentityData["external_id"] = externalID + if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { + return apierrors.NewInternalServerError("Error updating identity").WithInternalError(err) + } + break + } + } + } + + if emailValue, ok := valueMap["emails[primary eq true].value"].(string); ok { + validatedEmail, err := a.validateEmail(emailValue) + if err != nil { + return apierrors.NewSCIMBadRequestError("Invalid email address", "invalidValue") + } + user.Email = storage.NullString(validatedEmail) + if err := tx.UpdateOnly(user, "email"); err != nil { + return apierrors.NewInternalServerError("Error updating email").WithInternalError(err) + } + } + if active, ok := valueMap["active"].(bool); ok { if active { return user.Ban(tx, 0, nil) @@ -616,12 +567,11 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } } default: - return apierrors.NewBadRequestError(apierrors.ErrorCodeSCIMMutuallyExclusive, "Unsupported patch operation: %s", op.Op) + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported patch operation: %s", op.Op), "invalidSyntax") } return nil } -// scimDeleteUser handles DELETE /scim/v2/Users/{id} func (a *API) scimDeleteUser(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) @@ -630,23 +580,26 @@ func (a *API) scimDeleteUser(w http.ResponseWriter, r *http.Request) error { userID, err := uuid.FromString(chi.URLParam(r, "user_id")) if err != nil { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid user ID format") + return apierrors.NewSCIMNotFoundError("User not found") } terr := db.Transaction(func(tx *storage.Connection) error { user, err := models.FindUserByID(tx, userID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMUserNotFound, "User not found") + return apierrors.NewSCIMNotFoundError("User not found") } return apierrors.NewInternalServerError("Error fetching user").WithInternalError(err) } if !userBelongsToProvider(user, provider.ID) { - return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMUserNotFound, "User not found") + return apierrors.NewSCIMNotFoundError("User not found") + } + + if user.IsBanned() && user.BannedReason != nil && *user.BannedReason == scimDeprovisionedReason { + return apierrors.NewSCIMNotFoundError("User not found") } - // Soft delete: ban with infinity duration if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { return apierrors.NewInternalServerError("Error deprovisioning user").WithInternalError(err) } @@ -665,12 +618,11 @@ func (a *API) scimDeleteUser(w http.ResponseWriter, r *http.Request) error { return terr } + w.Header().Set("Content-Type", "application/scim+json") w.WriteHeader(http.StatusNoContent) return nil } -// Group Endpoints - func (a *API) scimListGroups(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() db := a.db.WithContext(ctx) @@ -678,23 +630,33 @@ func (a *API) scimListGroups(w http.ResponseWriter, r *http.Request) error { startIndex, count := parseSCIMPagination(r) - totalResults, err := models.CountSCIMGroupsBySSOProvider(db, provider.ID) + filterStr := r.URL.Query().Get("filter") + filterClause, err := ParseSCIMFilterToSQL(filterStr, SCIMGroupFilterAttrs) if err != nil { - return apierrors.NewInternalServerError("Error counting groups").WithInternalError(err) + return err } - groups, err := models.FindSCIMGroupsBySSOProvider(db, provider.ID, startIndex, count) + groups, totalResults, err := models.FindSCIMGroupsBySSOProviderWithFilter(db, provider.ID, toModelFilterClause(filterClause), startIndex, count) if err != nil { return apierrors.NewInternalServerError("Error fetching groups").WithInternalError(err) } + excludeMembers := strings.Contains(strings.ToLower(r.URL.Query().Get("excludedAttributes")), "members") + resources := make([]interface{}, len(groups)) for i, group := range groups { - members, _ := group.GetMembers(db) + var members []*models.User + if !excludeMembers { + var err error + members, err = group.GetMembers(db) + if err != nil { + return apierrors.NewInternalServerError("Error fetching group members").WithInternalError(err) + } + } resources[i] = a.groupToSCIMResponse(group, members) } - return sendJSON(w, http.StatusOK, &SCIMListResponse{ + return sendSCIMJSON(w, http.StatusOK, &SCIMListResponse{ Schemas: []string{SCIMSchemaListResponse}, TotalResults: totalResults, StartIndex: startIndex, @@ -710,19 +672,19 @@ func (a *API) scimGetGroup(w http.ResponseWriter, r *http.Request) error { groupID, err := uuid.FromString(chi.URLParam(r, "group_id")) if err != nil { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid group ID format") + return apierrors.NewSCIMNotFoundError("Group not found") } group, err := models.FindSCIMGroupByID(db, groupID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMGroupNotFound, "Group not found") + return apierrors.NewSCIMNotFoundError("Group not found") } return apierrors.NewInternalServerError("Error fetching group").WithInternalError(err) } if group.SSOProviderID != provider.ID { - return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMGroupNotFound, "Group not found") + return apierrors.NewSCIMNotFoundError("Group not found") } members, err := group.GetMembers(db) @@ -730,7 +692,7 @@ func (a *API) scimGetGroup(w http.ResponseWriter, r *http.Request) error { return apierrors.NewInternalServerError("Error fetching group members").WithInternalError(err) } - return sendJSON(w, http.StatusOK, a.groupToSCIMResponse(group, members)) + return sendSCIMJSON(w, http.StatusOK, a.groupToSCIMResponse(group, members)) } func (a *API) scimCreateGroup(w http.ResponseWriter, r *http.Request) error { @@ -751,7 +713,7 @@ func (a *API) scimCreateGroup(w http.ResponseWriter, r *http.Request) error { if params.ExternalID != "" { existing, err := models.FindSCIMGroupByExternalID(tx, provider.ID, params.ExternalID) if err == nil && existing != nil { - return apierrors.NewHTTPError(http.StatusConflict, apierrors.ErrorCodeSCIMGroupAlreadyExists, "Group with this externalId already exists") + return apierrors.NewSCIMConflictError("Group with this externalId already exists", "uniqueness") } if err != nil && !models.IsNotFoundError(err) { return apierrors.NewInternalServerError("Error checking existing group").WithInternalError(err) @@ -768,7 +730,9 @@ func (a *API) scimCreateGroup(w http.ResponseWriter, r *http.Request) error { if err != nil { continue } - _ = group.AddMember(tx, memberID) + if err := group.AddMember(tx, memberID); err != nil { + return apierrors.NewSCIMInternalServerError("Error adding group member").WithInternalError(err) + } } return nil @@ -778,8 +742,11 @@ func (a *API) scimCreateGroup(w http.ResponseWriter, r *http.Request) error { return terr } - members, _ := group.GetMembers(db) - return sendJSON(w, http.StatusCreated, a.groupToSCIMResponse(group, members)) + members, err := group.GetMembers(db) + if err != nil { + return apierrors.NewInternalServerError("Error fetching group members").WithInternalError(err) + } + return sendSCIMJSON(w, http.StatusCreated, a.groupToSCIMResponse(group, members)) } func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { @@ -789,7 +756,7 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { groupID, err := uuid.FromString(chi.URLParam(r, "group_id")) if err != nil { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid group ID format") + return apierrors.NewSCIMNotFoundError("Group not found") } var params SCIMGroupParams @@ -803,18 +770,18 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { group, err = models.FindSCIMGroupByID(tx, groupID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMGroupNotFound, "Group not found") + return apierrors.NewSCIMNotFoundError("Group not found") } return apierrors.NewInternalServerError("Error fetching group").WithInternalError(err) } if group.SSOProviderID != provider.ID { - return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMGroupNotFound, "Group not found") + return apierrors.NewSCIMNotFoundError("Group not found") } group.DisplayName = params.DisplayName if params.ExternalID != "" { - group.ExternalID = params.ExternalID + group.ExternalID = storage.NullString(params.ExternalID) } if err := tx.Update(group); err != nil { @@ -837,8 +804,16 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { return terr } - members, _ := group.GetMembers(db) - return sendJSON(w, http.StatusOK, a.groupToSCIMResponse(group, members)) + group, err = models.FindSCIMGroupByID(db, groupID) + if err != nil { + return apierrors.NewInternalServerError("Error reloading group").WithInternalError(err) + } + + members, err := group.GetMembers(db) + if err != nil { + return apierrors.NewInternalServerError("Error fetching group members").WithInternalError(err) + } + return sendSCIMJSON(w, http.StatusOK, a.groupToSCIMResponse(group, members)) } func (a *API) scimPatchGroup(w http.ResponseWriter, r *http.Request) error { @@ -848,7 +823,7 @@ func (a *API) scimPatchGroup(w http.ResponseWriter, r *http.Request) error { groupID, err := uuid.FromString(chi.URLParam(r, "group_id")) if err != nil { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid group ID format") + return apierrors.NewSCIMNotFoundError("Group not found") } var params SCIMPatchRequest @@ -862,13 +837,13 @@ func (a *API) scimPatchGroup(w http.ResponseWriter, r *http.Request) error { group, err = models.FindSCIMGroupByID(tx, groupID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMGroupNotFound, "Group not found") + return apierrors.NewSCIMNotFoundError("Group not found") } return apierrors.NewInternalServerError("Error fetching group").WithInternalError(err) } if group.SSOProviderID != provider.ID { - return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMGroupNotFound, "Group not found") + return apierrors.NewSCIMNotFoundError("Group not found") } for _, op := range params.Operations { @@ -884,17 +859,33 @@ func (a *API) scimPatchGroup(w http.ResponseWriter, r *http.Request) error { return terr } - members, _ := group.GetMembers(db) - return sendJSON(w, http.StatusOK, a.groupToSCIMResponse(group, members)) + group, err = models.FindSCIMGroupByID(db, groupID) + if err != nil { + return apierrors.NewInternalServerError("Error reloading group").WithInternalError(err) + } + + members, err := group.GetMembers(db) + if err != nil { + return apierrors.NewInternalServerError("Error fetching group members").WithInternalError(err) + } + return sendSCIMJSON(w, http.StatusOK, a.groupToSCIMResponse(group, members)) } func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGroup, op SCIMPatchOperation) error { switch strings.ToLower(op.Op) { case "add": - if strings.ToLower(op.Path) == "members" || op.Path == "" { + switch strings.ToLower(op.Path) { + case "externalid": + externalID, ok := op.Value.(string) + if !ok { + return apierrors.NewSCIMBadRequestError("externalId must be a string", "invalidValue") + } + group.ExternalID = storage.NullString(externalID) + return tx.UpdateOnly(group, "external_id") + case "members", "": members, ok := op.Value.([]interface{}) if !ok { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "members must be an array") + return apierrors.NewSCIMBadRequestError("members must be an array", "invalidValue") } for _, m := range members { memberMap, ok := m.(map[string]interface{}) @@ -909,35 +900,50 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou if err != nil { continue } - _ = group.AddMember(tx, memberID) + if err := group.AddMember(tx, memberID); err != nil { + return apierrors.NewSCIMInternalServerError("Error adding group member").WithInternalError(err) + } } } case "remove": - if strings.HasPrefix(strings.ToLower(op.Path), "members") && strings.Contains(op.Path, "[") { - start := strings.Index(op.Path, "\"") - end := strings.LastIndex(op.Path, "\"") - if start != -1 && end != -1 && start < end { - value := op.Path[start+1 : end] - memberID, err := uuid.FromString(value) - if err != nil { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid member ID in path") + switch strings.ToLower(op.Path) { + case "externalid": + group.ExternalID = storage.NullString("") + return tx.UpdateOnly(group, "external_id") + default: + if strings.HasPrefix(strings.ToLower(op.Path), "members") && strings.Contains(op.Path, "[") { + start := strings.Index(op.Path, "\"") + end := strings.LastIndex(op.Path, "\"") + if start != -1 && end != -1 && start < end { + value := op.Path[start+1 : end] + memberID, err := uuid.FromString(value) + if err != nil { + return apierrors.NewSCIMBadRequestError("Invalid member ID in path", "invalidValue") + } + return group.RemoveMember(tx, memberID) } - return group.RemoveMember(tx, memberID) } } case "replace": switch strings.ToLower(op.Path) { + case "externalid": + externalID, ok := op.Value.(string) + if !ok { + return apierrors.NewSCIMBadRequestError("externalId must be a string", "invalidValue") + } + group.ExternalID = storage.NullString(externalID) + return tx.UpdateOnly(group, "external_id") case "displayname": displayName, ok := op.Value.(string) if !ok { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "displayName must be a string") + return apierrors.NewSCIMBadRequestError("displayName must be a string", "invalidValue") } group.DisplayName = displayName return tx.UpdateOnly(group, "display_name") case "members": members, ok := op.Value.([]interface{}) if !ok { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "members must be an array") + return apierrors.NewSCIMBadRequestError("members must be an array", "invalidValue") } memberIDs := make([]uuid.UUID, 0, len(members)) for _, m := range members { @@ -956,9 +962,24 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou memberIDs = append(memberIDs, memberID) } return group.SetMembers(tx, memberIDs) + case "": + if valueMap, ok := op.Value.(map[string]interface{}); ok { + columnsToUpdate := []string{} + if externalID, ok := valueMap["externalId"].(string); ok { + group.ExternalID = storage.NullString(externalID) + columnsToUpdate = append(columnsToUpdate, "external_id") + } + if displayName, ok := valueMap["displayName"].(string); ok { + group.DisplayName = displayName + columnsToUpdate = append(columnsToUpdate, "display_name") + } + if len(columnsToUpdate) > 0 { + return tx.UpdateOnly(group, columnsToUpdate...) + } + } } default: - return apierrors.NewBadRequestError(apierrors.ErrorCodeSCIMMutuallyExclusive, "Unsupported patch operation: %s", op.Op) + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported patch operation: %s", op.Op), "invalidSyntax") } return nil } @@ -970,20 +991,20 @@ func (a *API) scimDeleteGroup(w http.ResponseWriter, r *http.Request) error { groupID, err := uuid.FromString(chi.URLParam(r, "group_id")) if err != nil { - return apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid group ID format") + return apierrors.NewSCIMNotFoundError("Group not found") } terr := db.Transaction(func(tx *storage.Connection) error { group, err := models.FindSCIMGroupByID(tx, groupID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMGroupNotFound, "Group not found") + return apierrors.NewSCIMNotFoundError("Group not found") } return apierrors.NewInternalServerError("Error fetching group").WithInternalError(err) } if group.SSOProviderID != provider.ID { - return apierrors.NewNotFoundError(apierrors.ErrorCodeSCIMGroupNotFound, "Group not found") + return apierrors.NewSCIMNotFoundError("Group not found") } return tx.Destroy(group) @@ -993,16 +1014,15 @@ func (a *API) scimDeleteGroup(w http.ResponseWriter, r *http.Request) error { return terr } + w.Header().Set("Content-Type", "application/scim+json") w.WriteHeader(http.StatusNoContent) return nil } -// Service Provider Config Endpoints - func (a *API) scimServiceProviderConfig(w http.ResponseWriter, r *http.Request) error { baseURL := a.getSCIMBaseURL() - return sendJSON(w, http.StatusOK, map[string]interface{}{ + return sendSCIMJSON(w, http.StatusOK, map[string]interface{}{ "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"}, "documentationUri": "https://supabase.com/docs/guides/auth/enterprise-sso/scim", "patch": map[string]interface{}{"supported": true}, @@ -1030,8 +1050,8 @@ func (a *API) scimServiceProviderConfig(w http.ResponseWriter, r *http.Request) func (a *API) scimResourceTypes(w http.ResponseWriter, r *http.Request) error { baseURL := a.getSCIMBaseURL() - return sendJSON(w, http.StatusOK, []map[string]interface{}{ - { + resourceTypes := []interface{}{ + map[string]interface{}{ "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, "id": "User", "name": "User", @@ -1040,7 +1060,7 @@ func (a *API) scimResourceTypes(w http.ResponseWriter, r *http.Request) error { "schema": SCIMSchemaUser, "meta": map[string]interface{}{"resourceType": "ResourceType", "location": baseURL + "/scim/v2/ResourceTypes/User"}, }, - { + map[string]interface{}{ "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, "id": "Group", "name": "Group", @@ -1049,32 +1069,173 @@ func (a *API) scimResourceTypes(w http.ResponseWriter, r *http.Request) error { "schema": SCIMSchemaGroup, "meta": map[string]interface{}{"resourceType": "ResourceType", "location": baseURL + "/scim/v2/ResourceTypes/Group"}, }, + } + + return sendSCIMJSON(w, http.StatusOK, SCIMListResponse{ + Schemas: []string{SCIMSchemaListResponse}, + TotalResults: len(resourceTypes), + StartIndex: 1, + ItemsPerPage: len(resourceTypes), + Resources: resourceTypes, }) } func (a *API) scimSchemas(w http.ResponseWriter, r *http.Request) error { - return sendJSON(w, http.StatusOK, []map[string]interface{}{ - { + baseURL := a.getSCIMBaseURL() + schemas := []interface{}{ + map[string]interface{}{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, "id": SCIMSchemaUser, "name": "User", "description": "User Account", "attributes": []map[string]interface{}{ - {"name": "userName", "type": "string", "required": true, "uniqueness": "server"}, - {"name": "name", "type": "complex", "required": false}, - {"name": "emails", "type": "complex", "multiValued": true, "required": false}, - {"name": "active", "type": "boolean", "required": false}, - {"name": "externalId", "type": "string", "required": false}, + {"name": "userName", "type": "string", "multiValued": false, "required": true, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "server"}, + {"name": "name", "type": "complex", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "subAttributes": []map[string]interface{}{ + {"name": "formatted", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "familyName", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "givenName", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + }}, + {"name": "emails", "type": "complex", "multiValued": true, "required": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "subAttributes": []map[string]interface{}{ + {"name": "value", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "type", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "primary", "type": "boolean", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + }}, + {"name": "active", "type": "boolean", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "externalId", "type": "string", "multiValued": false, "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + }, + "meta": map[string]interface{}{ + "resourceType": "Schema", + "location": baseURL + "/scim/v2/Schemas/" + SCIMSchemaUser, }, }, - { + map[string]interface{}{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, "id": SCIMSchemaGroup, "name": "Group", "description": "Group", "attributes": []map[string]interface{}{ - {"name": "displayName", "type": "string", "required": true}, - {"name": "members", "type": "complex", "multiValued": true, "required": false}, - {"name": "externalId", "type": "string", "required": false}, + {"name": "displayName", "type": "string", "multiValued": false, "required": true, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "members", "type": "complex", "multiValued": true, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "subAttributes": []map[string]interface{}{ + {"name": "value", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "immutable", "returned": "default", "uniqueness": "none"}, + {"name": "$ref", "type": "reference", "multiValued": false, "required": false, "caseExact": false, "mutability": "immutable", "returned": "default", "uniqueness": "none", "referenceTypes": []string{"User"}}, + {"name": "display", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readOnly", "returned": "default", "uniqueness": "none"}, + }}, + {"name": "externalId", "type": "string", "multiValued": false, "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + }, + "meta": map[string]interface{}{ + "resourceType": "Schema", + "location": baseURL + "/scim/v2/Schemas/" + SCIMSchemaGroup, }, }, + } + + return sendSCIMJSON(w, http.StatusOK, SCIMListResponse{ + Schemas: []string{SCIMSchemaListResponse}, + TotalResults: len(schemas), + StartIndex: 1, + ItemsPerPage: len(schemas), + Resources: schemas, }) } + +func (a *API) scimResourceTypeByID(w http.ResponseWriter, r *http.Request) error { + resourceTypeID := chi.URLParam(r, "resource_type_id") + baseURL := a.getSCIMBaseURL() + + var resourceType map[string]interface{} + + switch resourceTypeID { + case "User": + resourceType = map[string]interface{}{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, + "id": "User", + "name": "User", + "endpoint": "/Users", + "description": "User Account", + "schema": SCIMSchemaUser, + "meta": map[string]interface{}{"resourceType": "ResourceType", "location": baseURL + "/scim/v2/ResourceTypes/User"}, + } + case "Group": + resourceType = map[string]interface{}{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, + "id": "Group", + "name": "Group", + "endpoint": "/Groups", + "description": "Group", + "schema": SCIMSchemaGroup, + "meta": map[string]interface{}{"resourceType": "ResourceType", "location": baseURL + "/scim/v2/ResourceTypes/Group"}, + } + default: + return sendSCIMError(w, http.StatusNotFound, "Resource type not found", "") + } + + return sendSCIMJSON(w, http.StatusOK, resourceType) +} + +func (a *API) scimSchemaByID(w http.ResponseWriter, r *http.Request) error { + schemaID := chi.URLParam(r, "schema_id") + baseURL := a.getSCIMBaseURL() + + var schema map[string]interface{} + + switch schemaID { + case SCIMSchemaUser: + schema = map[string]interface{}{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, + "id": SCIMSchemaUser, + "name": "User", + "description": "User Account", + "attributes": []map[string]interface{}{ + {"name": "userName", "type": "string", "multiValued": false, "required": true, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "server"}, + {"name": "name", "type": "complex", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "subAttributes": []map[string]interface{}{ + {"name": "formatted", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "familyName", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "givenName", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + }}, + {"name": "emails", "type": "complex", "multiValued": true, "required": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "subAttributes": []map[string]interface{}{ + {"name": "value", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "type", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "primary", "type": "boolean", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + }}, + {"name": "active", "type": "boolean", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "externalId", "type": "string", "multiValued": false, "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + }, + "meta": map[string]interface{}{ + "resourceType": "Schema", + "location": baseURL + "/scim/v2/Schemas/" + SCIMSchemaUser, + }, + } + case SCIMSchemaGroup: + schema = map[string]interface{}{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, + "id": SCIMSchemaGroup, + "name": "Group", + "description": "Group", + "attributes": []map[string]interface{}{ + {"name": "displayName", "type": "string", "multiValued": false, "required": true, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "members", "type": "complex", "multiValued": true, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "subAttributes": []map[string]interface{}{ + {"name": "value", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "immutable", "returned": "default", "uniqueness": "none"}, + {"name": "$ref", "type": "reference", "multiValued": false, "required": false, "caseExact": false, "mutability": "immutable", "returned": "default", "uniqueness": "none", "referenceTypes": []string{"User"}}, + {"name": "display", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readOnly", "returned": "default", "uniqueness": "none"}, + }}, + {"name": "externalId", "type": "string", "multiValued": false, "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + }, + "meta": map[string]interface{}{ + "resourceType": "Schema", + "location": baseURL + "/scim/v2/Schemas/" + SCIMSchemaGroup, + }, + } + default: + return sendSCIMError(w, http.StatusNotFound, "Schema not found", "") + } + + return sendSCIMJSON(w, http.StatusOK, schema) +} + +func sendSCIMError(w http.ResponseWriter, status int, detail string, scimType string) error { + return sendSCIMJSON(w, status, NewSCIMError(status, detail, scimType)) +} + +func (a *API) scimNotFound(w http.ResponseWriter, r *http.Request) error { + return sendSCIMError(w, http.StatusNotFound, "Resource not found", "") +} From 090dbf2a442060451b0f6186d239339d48c7fb01 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 24 Dec 2025 04:45:36 +0000 Subject: [PATCH 020/101] feat: add scim2/filter-parser dependency --- go.mod | 2 ++ go.sum | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/go.mod b/go.mod index 18af9134b..61e87d177 100644 --- a/go.mod +++ b/go.mod @@ -39,6 +39,7 @@ require ( github.com/crate-crypto/go-eth-kzg v1.3.0 // indirect github.com/crate-crypto/go-ipa v0.0.0-20240724233137-53bbb0ceb27a // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect + github.com/di-wu/parser v0.2.2 // indirect github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect github.com/ethereum/c-kzg-4844/v2 v2.1.0 // indirect github.com/ethereum/go-verkle v0.2.2 // indirect @@ -64,6 +65,7 @@ require ( github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/scim2/filter-parser/v2 v2.2.0 // indirect github.com/segmentio/asm v1.2.0 // indirect github.com/speakeasy-api/openapi-overlay v0.9.0 // indirect github.com/supranational/blst v0.3.14 // indirect diff --git a/go.sum b/go.sum index fcd1b1dc4..fe079a481 100644 --- a/go.sum +++ b/go.sum @@ -80,6 +80,8 @@ github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5il github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 h1:rpfIENRNNilwHwZeG5+P150SMrnNEcHYvcCuK6dPZSg= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= +github.com/di-wu/parser v0.2.2 h1:I9oHJ8spBXOeL7Wps0ffkFFFiXJf/pk7NX9lcAMqRMU= +github.com/di-wu/parser v0.2.2/go.mod h1:SLp58pW6WamdmznrVRrw2NTyn4wAvT9rrEFynKX7nYo= github.com/didip/tollbooth/v5 v5.1.1 h1:QpKFg56jsbNuQ6FFj++Z1gn2fbBsvAc1ZPLUaDOYW5k= github.com/didip/tollbooth/v5 v5.1.1/go.mod h1:d9rzwOULswrD3YIrAQmP3bfjxab32Df4IaO6+D25l9g= github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= @@ -430,6 +432,8 @@ github.com/russellhaering/goxmldsig v1.3.0 h1:DllIWUgMy0cRUMfGiASiYEa35nsieyD3ci github.com/russellhaering/goxmldsig v1.3.0/go.mod h1:gM4MDENBQf7M+V824SGfyIUVFWydB7n0KkEubVJl+Tw= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/scim2/filter-parser/v2 v2.2.0 h1:QGadEcsmypxg8gYChRSM2j1edLyE/2j72j+hdmI4BJM= +github.com/scim2/filter-parser/v2 v2.2.0/go.mod h1:jWnkDToqX/Y0ugz0P5VvpVEUKcWcyHHj+X+je9ce5JA= github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35 h1:eajwn6K3weW5cd1ZXLu2sJ4pvwlBiCWY4uDejOr73gM= github.com/sebest/xff v0.0.0-20160910043805-6c115e0ffa35/go.mod h1:wozgYq9WEBQBaIJe4YZ0qTSFAMxmcwBhQH0fO0R34Z0= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= From d890c41cebf2e7335a21dd60659c47e9b66cb534 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 24 Dec 2025 04:45:56 +0000 Subject: [PATCH 021/101] feat: add scim filters with RFC 7644 support --- internal/api/scim_filter.go | 224 ++++++++++++++++++++++++++++++++++++ 1 file changed, 224 insertions(+) create mode 100644 internal/api/scim_filter.go diff --git a/internal/api/scim_filter.go b/internal/api/scim_filter.go new file mode 100644 index 000000000..b220ac926 --- /dev/null +++ b/internal/api/scim_filter.go @@ -0,0 +1,224 @@ +package api + +import ( + "fmt" + "strings" + + filter "github.com/scim2/filter-parser/v2" + "github.com/supabase/auth/internal/api/apierrors" + "github.com/supabase/auth/internal/models" +) + +type SCIMFilterResult struct { + Where string + Args []interface{} +} + +var SCIMUserFilterAttrs = map[string]string{ + "username": "COALESCE(i.identity_data->>'user_name', u.email)", + "externalid": "i.provider_id", + "email": "u.email", + "emails.value": "u.email", +} + +var SCIMGroupFilterAttrs = map[string]string{ + "displayname": "display_name", + "externalid": "external_id", +} + +func ParseSCIMFilterToSQL(filterStr string, allowedAttrs map[string]string) (*SCIMFilterResult, error) { + if filterStr == "" { + return &SCIMFilterResult{Where: "1=1", Args: nil}, nil + } + + expr, err := filter.ParseFilter([]byte(filterStr)) + if err != nil { + return nil, apierrors.NewSCIMBadRequestError( + fmt.Sprintf("Invalid filter syntax: %v", err), "invalidFilter") + } + + return exprToSQL(expr, allowedAttrs) +} + +func exprToSQL(expr filter.Expression, allowedAttrs map[string]string) (*SCIMFilterResult, error) { + switch e := expr.(type) { + case *filter.AttributeExpression: + return attrExprToSQL(*e, allowedAttrs) + case *filter.LogicalExpression: + return logicalExprToSQL(*e, allowedAttrs) + case *filter.NotExpression: + return notExprToSQL(*e, allowedAttrs) + case *filter.ValuePath: + return valuePathToSQL(*e, allowedAttrs) + default: + return nil, apierrors.NewSCIMBadRequestError( + fmt.Sprintf("Unsupported filter expression type: %T", expr), "invalidFilter") + } +} + +func attrExprToSQL(e filter.AttributeExpression, allowedAttrs map[string]string) (*SCIMFilterResult, error) { + attrName := strings.ToLower(e.AttributePath.AttributeName) + if e.AttributePath.SubAttribute != nil { + attrName = attrName + "." + strings.ToLower(*e.AttributePath.SubAttribute) + } + + dbColumn, ok := allowedAttrs[attrName] + if !ok { + return nil, apierrors.NewSCIMBadRequestError( + fmt.Sprintf("Filtering on attribute '%s' is not supported", attrName), "invalidFilter") + } + + switch e.Operator { + case filter.EQ: + return &SCIMFilterResult{ + Where: fmt.Sprintf("LOWER(CAST(%s AS TEXT)) = LOWER(?)", dbColumn), + Args: []interface{}{fmt.Sprintf("%v", e.CompareValue)}, + }, nil + + case filter.NE: + return &SCIMFilterResult{ + Where: fmt.Sprintf("LOWER(CAST(%s AS TEXT)) != LOWER(?)", dbColumn), + Args: []interface{}{fmt.Sprintf("%v", e.CompareValue)}, + }, nil + + case filter.CO: + val, ok := e.CompareValue.(string) + if !ok { + return nil, apierrors.NewSCIMBadRequestError("'co' operator requires a string value", "invalidValue") + } + return &SCIMFilterResult{ + Where: fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE LOWER(?)", dbColumn), + Args: []interface{}{"%" + escapeLikePattern(val) + "%"}, + }, nil + + case filter.SW: + val, ok := e.CompareValue.(string) + if !ok { + return nil, apierrors.NewSCIMBadRequestError("'sw' operator requires a string value", "invalidValue") + } + return &SCIMFilterResult{ + Where: fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE LOWER(?)", dbColumn), + Args: []interface{}{escapeLikePattern(val) + "%"}, + }, nil + + case filter.EW: + val, ok := e.CompareValue.(string) + if !ok { + return nil, apierrors.NewSCIMBadRequestError("'ew' operator requires a string value", "invalidValue") + } + return &SCIMFilterResult{ + Where: fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE LOWER(?)", dbColumn), + Args: []interface{}{"%" + escapeLikePattern(val)}, + }, nil + + case filter.PR: + return &SCIMFilterResult{ + Where: fmt.Sprintf("(%s IS NOT NULL AND CAST(%s AS TEXT) != '')", dbColumn, dbColumn), + Args: nil, + }, nil + + case filter.GT: + return &SCIMFilterResult{ + Where: fmt.Sprintf("%s > ?", dbColumn), + Args: []interface{}{e.CompareValue}, + }, nil + + case filter.GE: + return &SCIMFilterResult{ + Where: fmt.Sprintf("%s >= ?", dbColumn), + Args: []interface{}{e.CompareValue}, + }, nil + + case filter.LT: + return &SCIMFilterResult{ + Where: fmt.Sprintf("%s < ?", dbColumn), + Args: []interface{}{e.CompareValue}, + }, nil + + case filter.LE: + return &SCIMFilterResult{ + Where: fmt.Sprintf("%s <= ?", dbColumn), + Args: []interface{}{e.CompareValue}, + }, nil + + default: + return nil, apierrors.NewSCIMBadRequestError( + fmt.Sprintf("Unsupported operator: %s", e.Operator), "invalidFilter") + } +} + +func logicalExprToSQL(e filter.LogicalExpression, allowedAttrs map[string]string) (*SCIMFilterResult, error) { + left, err := exprToSQL(e.Left, allowedAttrs) + if err != nil { + return nil, err + } + + right, err := exprToSQL(e.Right, allowedAttrs) + if err != nil { + return nil, err + } + + op := "AND" + if e.Operator == filter.OR { + op = "OR" + } + + return &SCIMFilterResult{ + Where: fmt.Sprintf("(%s %s %s)", left.Where, op, right.Where), + Args: append(left.Args, right.Args...), + }, nil +} + +func notExprToSQL(e filter.NotExpression, allowedAttrs map[string]string) (*SCIMFilterResult, error) { + operand, err := exprToSQL(e.Expression, allowedAttrs) + if err != nil { + return nil, err + } + + return &SCIMFilterResult{ + Where: fmt.Sprintf("NOT (%s)", operand.Where), + Args: operand.Args, + }, nil +} + +// valuePathToSQL handles bracket notation (e.g., emails[value eq "x"]). +// Only emails[value ...] is supported since Supabase Auth stores one email per user. +func valuePathToSQL(e filter.ValuePath, allowedAttrs map[string]string) (*SCIMFilterResult, error) { + attrName := strings.ToLower(e.AttributePath.AttributeName) + + switch attrName { + case "emails": + if e.ValueFilter != nil { + if attrExpr, ok := e.ValueFilter.(*filter.AttributeExpression); ok { + if strings.ToLower(attrExpr.AttributePath.AttributeName) == "value" { + modifiedExpr := filter.AttributeExpression{ + AttributePath: filter.AttributePath{AttributeName: "email"}, + Operator: attrExpr.Operator, + CompareValue: attrExpr.CompareValue, + } + return attrExprToSQL(modifiedExpr, allowedAttrs) + } + } + } + } + + return nil, apierrors.NewSCIMBadRequestError( + fmt.Sprintf("Value path filter '%s[...]' is not supported", attrName), "invalidFilter") +} + +func escapeLikePattern(s string) string { + s = strings.ReplaceAll(s, "\\", "\\\\") + s = strings.ReplaceAll(s, "%", "\\%") + s = strings.ReplaceAll(s, "_", "\\_") + return s +} + +func toModelFilterClause(f *SCIMFilterResult) *models.SCIMFilterClause { + if f == nil { + return nil + } + return &models.SCIMFilterClause{ + Where: f.Where, + Args: f.Args, + } +} From 548f909b55bb069161ee7858b81ae5a30ecb762b Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 24 Dec 2025 04:46:19 +0000 Subject: [PATCH 022/101] fix: group schema migration fix --- migrations/20251210100002_add_scim_groups.up.sql | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/migrations/20251210100002_add_scim_groups.up.sql b/migrations/20251210100002_add_scim_groups.up.sql index 61094cdcd..4e9dbdd5b 100644 --- a/migrations/20251210100002_add_scim_groups.up.sql +++ b/migrations/20251210100002_add_scim_groups.up.sql @@ -3,7 +3,7 @@ create table if not exists {{ index .Options "Namespace" }}.scim_groups ( id uuid not null, sso_provider_id uuid not null, - external_id text not null, + external_id text null, display_name text not null, created_at timestamptz null, updated_at timestamptz null, @@ -11,13 +11,18 @@ create table if not exists {{ index .Options "Namespace" }}.scim_groups ( constraint scim_groups_pkey primary key (id), constraint scim_groups_sso_provider_fkey foreign key (sso_provider_id) references {{ index .Options "Namespace" }}.sso_providers (id) on delete cascade, - constraint "external_id not empty" check (char_length(external_id) > 0), + constraint "external_id not empty if set" check (external_id is null or char_length(external_id) > 0), constraint "display_name not empty" check (char_length(display_name) > 0) ); --- Unique index Scoped to SSO provider +-- Unique index scoped to SSO provider (only for non-null external_id) create unique index if not exists scim_groups_sso_provider_external_id_idx - on {{ index .Options "Namespace" }}.scim_groups (sso_provider_id, external_id); + on {{ index .Options "Namespace" }}.scim_groups (sso_provider_id, external_id) + where external_id is not null; + +-- Unique index for displayName per SSO provider (case-insensitive, required by Azure AD) +create unique index if not exists scim_groups_sso_provider_display_name_idx + on {{ index .Options "Namespace" }}.scim_groups (sso_provider_id, lower(display_name)); -- Index for listing groups by SSO provider create index if not exists scim_groups_sso_provider_id_idx From d6ee00809b56795d9790096d139d625a9ff8953a Mon Sep 17 00:00:00 2001 From: bewinxed Date: Wed, 24 Dec 2025 04:58:05 +0000 Subject: [PATCH 023/101] chore: rename scim parser to scim helper after dep was added --- internal/api/scim_parser.go | 193 ------------------------------------ 1 file changed, 193 deletions(-) delete mode 100644 internal/api/scim_parser.go diff --git a/internal/api/scim_parser.go b/internal/api/scim_parser.go deleted file mode 100644 index 1848a1499..000000000 --- a/internal/api/scim_parser.go +++ /dev/null @@ -1,193 +0,0 @@ -package api - -import ( - "encoding/json" - "fmt" - "net/http" - "strconv" - "strings" - - "github.com/gofrs/uuid" - "github.com/supabase/auth/internal/api/apierrors" - "github.com/supabase/auth/internal/models" - "github.com/supabase/auth/internal/utilities" -) - -// parseSCIMPagination extracts startIndex and count from SCIM query parameters. -// startIndex is 1-indexed per SCIM spec, count defaults to SCIMDefaultPageSize. -func parseSCIMPagination(r *http.Request) (startIndex, count int) { - startIndex = 1 - count = SCIMDefaultPageSize - - if v := r.URL.Query().Get("startIndex"); v != "" { - if i, err := strconv.Atoi(v); err == nil && i > 0 { - startIndex = i - } - } - - if v := r.URL.Query().Get("count"); v != "" { - if i, err := strconv.Atoi(v); err == nil && i > 0 { - count = i - if count > SCIMMaxPageSize { - count = SCIMMaxPageSize - } - } - } - - return startIndex, count -} - -// parseSCIMFilter extracts a value from a SCIM filter expression for the given attribute. -// Supports: attributeName eq "value" (case-insensitive attribute name per RFC 7644) -// Returns empty string if no valid filter found. -func parseSCIMFilter(filter, attributeName string) string { - if filter == "" { - return "" - } - filter = strings.TrimSpace(filter) - lower := strings.ToLower(filter) - expectedPrefix := strings.ToLower(attributeName) + " eq " - - if strings.HasPrefix(lower, expectedPrefix) { - rest := filter[len(attributeName)+4:] // " eq " = 4 chars - rest = strings.TrimSpace(rest) - if len(rest) >= 2 && rest[0] == '"' && rest[len(rest)-1] == '"' { - return rest[1 : len(rest)-1] - } - } - return "" -} - -// parseSCIMBody parses the request body as JSON into the provided struct. -func (a *API) parseSCIMBody(r *http.Request, v interface{}) error { - body, err := utilities.GetBodyBytes(r) - if err != nil { - return apierrors.NewInternalServerError("Could not read request body").WithInternalError(err) - } - if err := json.Unmarshal(body, v); err != nil { - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid JSON: %v", err), "invalidSyntax") - } - return nil -} - -// userBelongsToProvider checks if a user has an identity linked to the given SSO provider. -func userBelongsToProvider(user *models.User, providerID uuid.UUID) bool { - providerType := "sso:" + providerID.String() - for _, identity := range user.Identities { - if identity.Provider == providerType { - return true - } - } - return false -} - -// userToSCIMResponse converts a User model to a SCIM User response. -func (a *API) userToSCIMResponse(user *models.User) *SCIMUserResponse { - baseURL := a.getSCIMBaseURL() - resp := &SCIMUserResponse{ - Schemas: []string{SCIMSchemaUser}, - ID: user.ID.String(), - UserName: user.GetEmail(), // Default to email, will be overwritten if userName stored in identity - Active: !user.IsBanned(), - Meta: SCIMMeta{ - ResourceType: "User", - Created: &user.CreatedAt, - LastModified: &user.UpdatedAt, - Location: baseURL + "/scim/v2/Users/" + user.ID.String(), - }, - } - - // Set external ID, userName, and email type from SSO identity if available - var emailType string - for _, identity := range user.Identities { - if strings.HasPrefix(identity.Provider, "sso:") { - if identity.ProviderID != "" { - resp.ExternalID = identity.ProviderID - } - // Get userName and email_type from identity metadata if stored - if identity.IdentityData != nil { - if userName, ok := identity.IdentityData["user_name"].(string); ok && userName != "" { - resp.UserName = userName - } - if et, ok := identity.IdentityData["email_type"].(string); ok { - emailType = et - } - } - break - } - } - - if email := user.GetEmail(); email != "" { - scimEmail := SCIMEmail{Value: email, Primary: true} - // Only include type if it was originally provided (not empty) - if emailType != "" { - scimEmail.Type = emailType - } - resp.Emails = []SCIMEmail{scimEmail} - } - - if user.UserMetaData != nil { - name := &SCIMName{} - hasName := false - if v, ok := user.UserMetaData["given_name"].(string); ok { - name.GivenName = v - hasName = true - } - if v, ok := user.UserMetaData["family_name"].(string); ok { - name.FamilyName = v - hasName = true - } - if v, ok := user.UserMetaData["full_name"].(string); ok { - name.Formatted = v - hasName = true - } - if hasName { - resp.Name = name - } - } - - return resp -} - -// groupToSCIMResponse converts a SCIMGroup model to a SCIM Group response. -func (a *API) groupToSCIMResponse(group *models.SCIMGroup, members []*models.User) *SCIMGroupResponse { - baseURL := a.getSCIMBaseURL() - resp := &SCIMGroupResponse{ - Schemas: []string{SCIMSchemaGroup}, - ID: group.ID.String(), - ExternalID: string(group.ExternalID), - DisplayName: group.DisplayName, - Members: []SCIMGroupMemberRef{}, // Always include members, empty array if none - Meta: SCIMMeta{ - ResourceType: "Group", - Created: &group.CreatedAt, - LastModified: &group.UpdatedAt, - Location: baseURL + "/scim/v2/Groups/" + group.ID.String(), - }, - } - - if len(members) > 0 { - resp.Members = make([]SCIMGroupMemberRef, len(members)) - for i, m := range members { - resp.Members[i] = SCIMGroupMemberRef{ - Value: m.ID.String(), - Ref: baseURL + "/scim/v2/Users/" + m.ID.String(), - Display: m.GetEmail(), - } - } - } - - return resp -} - -// getSCIMBaseURL returns the base URL for SCIM resource locations. -func (a *API) getSCIMBaseURL() string { - return a.config.SiteURL -} - -// sendSCIMJSON sends a JSON response with SCIM content type. -func sendSCIMJSON(w http.ResponseWriter, status int, obj interface{}) error { - w.Header().Set("Content-Type", "application/scim+json") - w.WriteHeader(status) - return json.NewEncoder(w).Encode(obj) -} From 4a9679873a944565c2f94725a7f07500fbd39b85 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Sun, 18 Jan 2026 12:21:16 +0000 Subject: [PATCH 024/101] chore: extract UserNotInSSOProviderError as typed error Replace string-based error comparison with proper typed error pattern following existing codebase conventions in models/errors.go. --- internal/models/errors.go | 7 +++++++ internal/models/scim_group.go | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/internal/models/errors.go b/internal/models/errors.go index 072eb26c9..6e321f991 100644 --- a/internal/models/errors.go +++ b/internal/models/errors.go @@ -117,6 +117,13 @@ func (e SCIMGroupNotFoundError) Error() string { return "SCIM Group not found" } +// UserNotInSSOProviderError represents when a user does not belong to an SSO provider. +type UserNotInSSOProviderError struct{} + +func (e UserNotInSSOProviderError) Error() string { + return "User does not belong to this SSO provider" +} + // FlowStateNotFoundError represents an error when an FlowState can't be // found. type FlowStateNotFoundError struct{} diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index 45fe15f9f..594344899 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -168,7 +168,7 @@ func (g *SCIMGroup) AddMember(tx *storage.Connection, userID uuid.UUID) error { } if !userBelongsToSSOProvider(user, g.SSOProviderID) { - return errors.New("user does not belong to this SSO provider") + return UserNotInSSOProviderError{} } return tx.RawQuery( @@ -222,7 +222,7 @@ func (g *SCIMGroup) SetMembers(tx *storage.Connection, userIDs []uuid.UUID) erro continue } // Skip users that don't belong to this provider silently - if err.Error() == "user does not belong to this SSO provider" { + if _, ok := err.(UserNotInSSOProviderError); ok { continue } return errors.Wrap(err, "error adding SCIM group member") From 3ebe3310218cb76a3bb43ef509e6bd53626c943c Mon Sep 17 00:00:00 2001 From: bewinxed Date: Sun, 18 Jan 2026 12:21:39 +0000 Subject: [PATCH 025/101] chore(scim): remove unused group query functions Remove CountSCIMGroupsBySSOProvider and FindSCIMGroupsBySSOProvider which were superseded by FindSCIMGroupsBySSOProviderWithFilter. --- internal/models/scim_group.go | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index 594344899..7e588ec7d 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -71,38 +71,6 @@ func FindSCIMGroupByExternalID(tx *storage.Connection, ssoProviderID uuid.UUID, return &group, nil } -func CountSCIMGroupsBySSOProvider(tx *storage.Connection, ssoProviderID uuid.UUID) (int, error) { - count, err := tx.Q().Where("sso_provider_id = ?", ssoProviderID).Count(&SCIMGroup{}) - if err != nil { - return 0, errors.Wrap(err, "error counting SCIM groups by SSO provider") - } - return count, nil -} - -// startIndex is 1-indexed per SCIM spec. count is the max number of results to return. -func FindSCIMGroupsBySSOProvider(tx *storage.Connection, ssoProviderID uuid.UUID, startIndex, count int) ([]*SCIMGroup, error) { - groups := []*SCIMGroup{} - - offset := startIndex - 1 - if offset < 0 { - offset = 0 - } - - query := ` - SELECT * FROM scim_groups - WHERE sso_provider_id = ? - ORDER BY created_at ASC - LIMIT ? OFFSET ? - ` - if err := tx.RawQuery(query, ssoProviderID, count, offset).All(&groups); err != nil { - if errors.Cause(err) == sql.ErrNoRows { - return []*SCIMGroup{}, nil - } - return nil, errors.Wrap(err, "error finding SCIM groups by SSO provider") - } - return groups, nil -} - // SCIMFilterClause represents a parsed SCIM filter as SQL WHERE clause type SCIMFilterClause struct { Where string From 5bb5172a002d5a9d9f0cdeae7b322381e7588ad3 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Sun, 18 Jan 2026 12:22:21 +0000 Subject: [PATCH 026/101] chore: consolidate userBelongsToProvider implementations Export UserBelongsToSSOProvider from models package and use it as single source of truth. API layer retains thin wrapper for convenience. --- internal/api/scim_helpers.go | 8 +------- internal/models/scim_group.go | 5 +++-- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go index aa713bcab..0190ba0e9 100644 --- a/internal/api/scim_helpers.go +++ b/internal/api/scim_helpers.go @@ -47,13 +47,7 @@ func (a *API) parseSCIMBody(r *http.Request, v interface{}) error { } func userBelongsToProvider(user *models.User, providerID uuid.UUID) bool { - providerType := "sso:" + providerID.String() - for _, identity := range user.Identities { - if identity.Provider == providerType { - return true - } - } - return false + return models.UserBelongsToSSOProvider(user, providerID) } func (a *API) userToSCIMResponse(user *models.User) *SCIMUserResponse { diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index 7e588ec7d..0105b3d99 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -135,7 +135,7 @@ func (g *SCIMGroup) AddMember(tx *storage.Connection, userID uuid.UUID) error { return err } - if !userBelongsToSSOProvider(user, g.SSOProviderID) { + if !UserBelongsToSSOProvider(user, g.SSOProviderID) { return UserNotInSSOProviderError{} } @@ -145,7 +145,8 @@ func (g *SCIMGroup) AddMember(tx *storage.Connection, userID uuid.UUID) error { ).Exec() } -func userBelongsToSSOProvider(user *User, ssoProviderID uuid.UUID) bool { +// UserBelongsToSSOProvider checks if a user has an identity linked to the specified SSO provider. +func UserBelongsToSSOProvider(user *User, ssoProviderID uuid.UUID) bool { providerType := "sso:" + ssoProviderID.String() for _, identity := range user.Identities { if identity.Provider == providerType { From 7d38d1a06f38a55a064c622277bbb130f176bd50 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Sun, 18 Jan 2026 12:23:42 +0000 Subject: [PATCH 027/101] chore: consolidate filter clause types Remove duplicate SCIMFilterResult type from api package in favor of models.SCIMFilterClause. Remove now-unnecessary toModelFilterClause conversion function. --- internal/api/scim.go | 4 +-- internal/api/scim_filter.go | 53 +++++++++++++------------------------ 2 files changed, 21 insertions(+), 36 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 6e2a62e2a..ae94c116a 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -59,7 +59,7 @@ func (a *API) scimListUsers(w http.ResponseWriter, r *http.Request) error { providerType := "sso:" + provider.ID.String() - users, totalResults, err := models.FindUsersByProviderWithFilter(db, providerType, toModelFilterClause(filterClause), startIndex, count) + users, totalResults, err := models.FindUsersByProviderWithFilter(db, providerType, filterClause, startIndex, count) if err != nil { return apierrors.NewInternalServerError("Error fetching users").WithInternalError(err) } @@ -636,7 +636,7 @@ func (a *API) scimListGroups(w http.ResponseWriter, r *http.Request) error { return err } - groups, totalResults, err := models.FindSCIMGroupsBySSOProviderWithFilter(db, provider.ID, toModelFilterClause(filterClause), startIndex, count) + groups, totalResults, err := models.FindSCIMGroupsBySSOProviderWithFilter(db, provider.ID, filterClause, startIndex, count) if err != nil { return apierrors.NewInternalServerError("Error fetching groups").WithInternalError(err) } diff --git a/internal/api/scim_filter.go b/internal/api/scim_filter.go index b220ac926..cc5077e70 100644 --- a/internal/api/scim_filter.go +++ b/internal/api/scim_filter.go @@ -9,11 +9,6 @@ import ( "github.com/supabase/auth/internal/models" ) -type SCIMFilterResult struct { - Where string - Args []interface{} -} - var SCIMUserFilterAttrs = map[string]string{ "username": "COALESCE(i.identity_data->>'user_name', u.email)", "externalid": "i.provider_id", @@ -26,9 +21,9 @@ var SCIMGroupFilterAttrs = map[string]string{ "externalid": "external_id", } -func ParseSCIMFilterToSQL(filterStr string, allowedAttrs map[string]string) (*SCIMFilterResult, error) { +func ParseSCIMFilterToSQL(filterStr string, allowedAttrs map[string]string) (*models.SCIMFilterClause, error) { if filterStr == "" { - return &SCIMFilterResult{Where: "1=1", Args: nil}, nil + return &models.SCIMFilterClause{Where: "1=1", Args: nil}, nil } expr, err := filter.ParseFilter([]byte(filterStr)) @@ -40,7 +35,7 @@ func ParseSCIMFilterToSQL(filterStr string, allowedAttrs map[string]string) (*SC return exprToSQL(expr, allowedAttrs) } -func exprToSQL(expr filter.Expression, allowedAttrs map[string]string) (*SCIMFilterResult, error) { +func exprToSQL(expr filter.Expression, allowedAttrs map[string]string) (*models.SCIMFilterClause, error) { switch e := expr.(type) { case *filter.AttributeExpression: return attrExprToSQL(*e, allowedAttrs) @@ -56,7 +51,7 @@ func exprToSQL(expr filter.Expression, allowedAttrs map[string]string) (*SCIMFil } } -func attrExprToSQL(e filter.AttributeExpression, allowedAttrs map[string]string) (*SCIMFilterResult, error) { +func attrExprToSQL(e filter.AttributeExpression, allowedAttrs map[string]string) (*models.SCIMFilterClause, error) { attrName := strings.ToLower(e.AttributePath.AttributeName) if e.AttributePath.SubAttribute != nil { attrName = attrName + "." + strings.ToLower(*e.AttributePath.SubAttribute) @@ -70,13 +65,13 @@ func attrExprToSQL(e filter.AttributeExpression, allowedAttrs map[string]string) switch e.Operator { case filter.EQ: - return &SCIMFilterResult{ + return &models.SCIMFilterClause{ Where: fmt.Sprintf("LOWER(CAST(%s AS TEXT)) = LOWER(?)", dbColumn), Args: []interface{}{fmt.Sprintf("%v", e.CompareValue)}, }, nil case filter.NE: - return &SCIMFilterResult{ + return &models.SCIMFilterClause{ Where: fmt.Sprintf("LOWER(CAST(%s AS TEXT)) != LOWER(?)", dbColumn), Args: []interface{}{fmt.Sprintf("%v", e.CompareValue)}, }, nil @@ -86,7 +81,7 @@ func attrExprToSQL(e filter.AttributeExpression, allowedAttrs map[string]string) if !ok { return nil, apierrors.NewSCIMBadRequestError("'co' operator requires a string value", "invalidValue") } - return &SCIMFilterResult{ + return &models.SCIMFilterClause{ Where: fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE LOWER(?)", dbColumn), Args: []interface{}{"%" + escapeLikePattern(val) + "%"}, }, nil @@ -96,7 +91,7 @@ func attrExprToSQL(e filter.AttributeExpression, allowedAttrs map[string]string) if !ok { return nil, apierrors.NewSCIMBadRequestError("'sw' operator requires a string value", "invalidValue") } - return &SCIMFilterResult{ + return &models.SCIMFilterClause{ Where: fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE LOWER(?)", dbColumn), Args: []interface{}{escapeLikePattern(val) + "%"}, }, nil @@ -106,37 +101,37 @@ func attrExprToSQL(e filter.AttributeExpression, allowedAttrs map[string]string) if !ok { return nil, apierrors.NewSCIMBadRequestError("'ew' operator requires a string value", "invalidValue") } - return &SCIMFilterResult{ + return &models.SCIMFilterClause{ Where: fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE LOWER(?)", dbColumn), Args: []interface{}{"%" + escapeLikePattern(val)}, }, nil case filter.PR: - return &SCIMFilterResult{ + return &models.SCIMFilterClause{ Where: fmt.Sprintf("(%s IS NOT NULL AND CAST(%s AS TEXT) != '')", dbColumn, dbColumn), Args: nil, }, nil case filter.GT: - return &SCIMFilterResult{ + return &models.SCIMFilterClause{ Where: fmt.Sprintf("%s > ?", dbColumn), Args: []interface{}{e.CompareValue}, }, nil case filter.GE: - return &SCIMFilterResult{ + return &models.SCIMFilterClause{ Where: fmt.Sprintf("%s >= ?", dbColumn), Args: []interface{}{e.CompareValue}, }, nil case filter.LT: - return &SCIMFilterResult{ + return &models.SCIMFilterClause{ Where: fmt.Sprintf("%s < ?", dbColumn), Args: []interface{}{e.CompareValue}, }, nil case filter.LE: - return &SCIMFilterResult{ + return &models.SCIMFilterClause{ Where: fmt.Sprintf("%s <= ?", dbColumn), Args: []interface{}{e.CompareValue}, }, nil @@ -147,7 +142,7 @@ func attrExprToSQL(e filter.AttributeExpression, allowedAttrs map[string]string) } } -func logicalExprToSQL(e filter.LogicalExpression, allowedAttrs map[string]string) (*SCIMFilterResult, error) { +func logicalExprToSQL(e filter.LogicalExpression, allowedAttrs map[string]string) (*models.SCIMFilterClause, error) { left, err := exprToSQL(e.Left, allowedAttrs) if err != nil { return nil, err @@ -163,19 +158,19 @@ func logicalExprToSQL(e filter.LogicalExpression, allowedAttrs map[string]string op = "OR" } - return &SCIMFilterResult{ + return &models.SCIMFilterClause{ Where: fmt.Sprintf("(%s %s %s)", left.Where, op, right.Where), Args: append(left.Args, right.Args...), }, nil } -func notExprToSQL(e filter.NotExpression, allowedAttrs map[string]string) (*SCIMFilterResult, error) { +func notExprToSQL(e filter.NotExpression, allowedAttrs map[string]string) (*models.SCIMFilterClause, error) { operand, err := exprToSQL(e.Expression, allowedAttrs) if err != nil { return nil, err } - return &SCIMFilterResult{ + return &models.SCIMFilterClause{ Where: fmt.Sprintf("NOT (%s)", operand.Where), Args: operand.Args, }, nil @@ -183,7 +178,7 @@ func notExprToSQL(e filter.NotExpression, allowedAttrs map[string]string) (*SCIM // valuePathToSQL handles bracket notation (e.g., emails[value eq "x"]). // Only emails[value ...] is supported since Supabase Auth stores one email per user. -func valuePathToSQL(e filter.ValuePath, allowedAttrs map[string]string) (*SCIMFilterResult, error) { +func valuePathToSQL(e filter.ValuePath, allowedAttrs map[string]string) (*models.SCIMFilterClause, error) { attrName := strings.ToLower(e.AttributePath.AttributeName) switch attrName { @@ -212,13 +207,3 @@ func escapeLikePattern(s string) string { s = strings.ReplaceAll(s, "_", "\\_") return s } - -func toModelFilterClause(f *SCIMFilterResult) *models.SCIMFilterClause { - if f == nil { - return nil - } - return &models.SCIMFilterClause{ - Where: f.Where, - Args: f.Args, - } -} From cc9109384d8edab84056471183e94b37b7a3483a Mon Sep 17 00:00:00 2001 From: bewinxed Date: Sun, 18 Jan 2026 13:38:35 +0000 Subject: [PATCH 028/101] fix(scim): remove duplicate types and fix error handling consistency - Remove duplicate SCIMErrorResponse and NewSCIMError from scim_types.go (use apierrors.SCIMHTTPError instead) - Remove duplicate SCIMSchemaError constant (already defined in apierrors) - Fix error wrapping in applySCIMUserPatch for Ban/Logout operations - Fix error wrapping in scimDeleteUser for Logout operation - Wrap validateEmail error in SCIM format in scimCreateUser --- internal/api/scim.go | 36 +++++++++++++++++++++++++----------- internal/api/scim_types.go | 18 ------------------ 2 files changed, 25 insertions(+), 29 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index ae94c116a..1c6680bd9 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -139,7 +139,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { email, err := a.validateEmail(email) if err != nil { - return err + return apierrors.NewSCIMBadRequestError("Invalid email address", "invalidValue") } providerType := "sso:" + provider.ID.String() @@ -451,12 +451,18 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S return apierrors.NewSCIMBadRequestError("active must be a boolean", "invalidValue") } if active { - return user.Ban(tx, 0, nil) + if err := user.Ban(tx, 0, nil); err != nil { + return apierrors.NewInternalServerError("Error unbanning user").WithInternalError(err) + } + return nil } if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { - return err + return apierrors.NewInternalServerError("Error banning user").WithInternalError(err) + } + if err := models.Logout(tx, user.ID); err != nil { + return apierrors.NewInternalServerError("Error invalidating sessions").WithInternalError(err) } - return models.Logout(tx, user.ID) + return nil case "username": userName, ok := op.Value.(string) if !ok { @@ -557,12 +563,17 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S if active, ok := valueMap["active"].(bool); ok { if active { - return user.Ban(tx, 0, nil) - } - if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { - return err + if err := user.Ban(tx, 0, nil); err != nil { + return apierrors.NewInternalServerError("Error unbanning user").WithInternalError(err) + } + } else { + if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { + return apierrors.NewInternalServerError("Error banning user").WithInternalError(err) + } + if err := models.Logout(tx, user.ID); err != nil { + return apierrors.NewInternalServerError("Error invalidating sessions").WithInternalError(err) + } } - return models.Logout(tx, user.ID) } } } @@ -611,7 +622,10 @@ func (a *API) scimDeleteUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewInternalServerError("Error recording audit log entry").WithInternalError(terr) } - return models.Logout(tx, user.ID) + if err := models.Logout(tx, user.ID); err != nil { + return apierrors.NewInternalServerError("Error invalidating sessions").WithInternalError(err) + } + return nil }) if terr != nil { @@ -1233,7 +1247,7 @@ func (a *API) scimSchemaByID(w http.ResponseWriter, r *http.Request) error { } func sendSCIMError(w http.ResponseWriter, status int, detail string, scimType string) error { - return sendSCIMJSON(w, status, NewSCIMError(status, detail, scimType)) + return sendSCIMJSON(w, status, apierrors.NewSCIMHTTPError(status, detail, scimType)) } func (a *API) scimNotFound(w http.ResponseWriter, r *http.Request) error { diff --git a/internal/api/scim_types.go b/internal/api/scim_types.go index 3a1fc14c5..95a06de37 100644 --- a/internal/api/scim_types.go +++ b/internal/api/scim_types.go @@ -3,7 +3,6 @@ package api import ( "encoding/json" "fmt" - "strconv" "strings" "time" @@ -17,7 +16,6 @@ const ( SCIMSchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group" SCIMSchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse" SCIMSchemaPatchOp = "urn:ietf:params:scim:api:messages:2.0:PatchOp" - SCIMSchemaError = "urn:ietf:params:scim:api:messages:2.0:Error" ) // Must be var (not const) because it's passed by pointer to user.Ban() @@ -133,19 +131,3 @@ type SCIMListResponse struct { ItemsPerPage int `json:"itemsPerPage"` Resources []interface{} `json:"Resources"` } - -type SCIMErrorResponse struct { - Schemas []string `json:"schemas"` - Status string `json:"status"` - Detail string `json:"detail,omitempty"` - ScimType string `json:"scimType,omitempty"` -} - -func NewSCIMError(status int, detail string, scimType string) *SCIMErrorResponse { - return &SCIMErrorResponse{ - Schemas: []string{SCIMSchemaError}, - Status: strconv.Itoa(status), - Detail: detail, - ScimType: scimType, - } -} From 96ed4663b046022aef06558486584eb571009ede Mon Sep 17 00:00:00 2001 From: bewinxed Date: Mon, 19 Jan 2026 08:03:16 +0000 Subject: [PATCH 029/101] chore: add scim test infrastructure --- internal/api/scim_test.go | 687 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 687 insertions(+) create mode 100644 internal/api/scim_test.go diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go new file mode 100644 index 000000000..31b596331 --- /dev/null +++ b/internal/api/scim_test.go @@ -0,0 +1,687 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + "github.com/supabase/auth/internal/conf" + "github.com/supabase/auth/internal/models" +) + +type SCIMTestSuite struct { + suite.Suite + API *API + Config *conf.GlobalConfiguration + SCIMToken string + SSOProvider *models.SSOProvider +} + +func TestSCIM(t *testing.T) { + api, config, err := setupAPIForTest() + require.NoError(t, err) + + ts := &SCIMTestSuite{ + API: api, + Config: config, + } + defer api.db.Close() + + suite.Run(t, ts) +} + +func (ts *SCIMTestSuite) SetupTest() { + models.TruncateAll(ts.API.db) + ts.SCIMToken = "test-scim-token-12345" + ts.SSOProvider = ts.createSSOProviderWithSCIM() +} + +func (ts *SCIMTestSuite) createSSOProviderWithSCIM() *models.SSOProvider { + provider := &models.SSOProvider{} + require.NoError(ts.T(), ts.API.db.Create(provider)) + require.NoError(ts.T(), provider.SetSCIMToken(context.Background(), ts.SCIMToken)) + require.NoError(ts.T(), ts.API.db.Update(provider)) + require.NoError(ts.T(), ts.API.db.Reload(provider)) + return provider +} + +func (ts *SCIMTestSuite) makeSCIMRequest(method, path string, body interface{}) *http.Request { + var reqBody *bytes.Buffer + if body != nil { + jsonBody, err := json.Marshal(body) + require.NoError(ts.T(), err) + reqBody = bytes.NewBuffer(jsonBody) + } else { + reqBody = bytes.NewBuffer(nil) + } + + req := httptest.NewRequest(method, "http://localhost"+path, reqBody) + req.Header.Set("Authorization", "Bearer "+ts.SCIMToken) + req.Header.Set("Content-Type", "application/scim+json") + return req +} + +func (ts *SCIMTestSuite) createSCIMUser(userName, email string) *SCIMUserResponse { + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaUser}, + "userName": userName, + "emails": []map[string]interface{}{ + {"value": email, "primary": true, "type": "work"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Users", body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusCreated, w.Code, "Failed to create SCIM user: %s", w.Body.String()) + + var result SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + return &result +} + +func (ts *SCIMTestSuite) createSCIMUserWithName(userName, email, givenName, familyName string) *SCIMUserResponse { + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaUser}, + "userName": userName, + "name": map[string]interface{}{ + "givenName": givenName, + "familyName": familyName, + "formatted": givenName + " " + familyName, + }, + "emails": []map[string]interface{}{ + {"value": email, "primary": true, "type": "work"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Users", body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusCreated, w.Code, "Failed to create SCIM user: %s", w.Body.String()) + + var result SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + return &result +} + +func (ts *SCIMTestSuite) createSCIMUserWithExternalID(userName, email, externalID string) *SCIMUserResponse { + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaUser}, + "userName": userName, + "externalId": externalID, + "emails": []map[string]interface{}{ + {"value": email, "primary": true, "type": "work"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Users", body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusCreated, w.Code, "Failed to create SCIM user: %s", w.Body.String()) + + var result SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + return &result +} + +func (ts *SCIMTestSuite) createSCIMGroup(displayName string) *SCIMGroupResponse { + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaGroup}, + "displayName": displayName, + } + + req := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Groups", body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusCreated, w.Code, "Failed to create SCIM group: %s", w.Body.String()) + + var result SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + return &result +} + +func (ts *SCIMTestSuite) createSCIMGroupWithExternalID(displayName, externalID string) *SCIMGroupResponse { + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaGroup}, + "displayName": displayName, + "externalId": externalID, + } + + req := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Groups", body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusCreated, w.Code, "Failed to create SCIM group: %s", w.Body.String()) + + var result SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + return &result +} + +func (ts *SCIMTestSuite) createSCIMGroupWithMembers(displayName string, memberIDs []string) *SCIMGroupResponse { + members := make([]map[string]interface{}, len(memberIDs)) + for i, id := range memberIDs { + members[i] = map[string]interface{}{"value": id} + } + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaGroup}, + "displayName": displayName, + "members": members, + } + + req := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Groups", body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusCreated, w.Code, "Failed to create SCIM group: %s", w.Body.String()) + + var result SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + return &result +} + +func (ts *SCIMTestSuite) assertSCIMError(w *httptest.ResponseRecorder, expectedStatus int) { + require.Equal(ts.T(), expectedStatus, w.Code) + + var errorResp map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errorResp)) + + schemas, ok := errorResp["schemas"].([]interface{}) + require.True(ts.T(), ok, "SCIM error should have schemas field") + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), "urn:ietf:params:scim:api:messages:2.0:Error", schemas[0]) + + _, ok = errorResp["detail"].(string) + require.True(ts.T(), ok, "SCIM error should have detail field") + + // SCIM status is a string per RFC 7644 + status, ok := errorResp["status"].(string) + require.True(ts.T(), ok, "SCIM error should have status field") + require.Equal(ts.T(), fmt.Sprintf("%d", expectedStatus), status) +} + +func (ts *SCIMTestSuite) assertSCIMListResponse(w *httptest.ResponseRecorder, expectedTotal int) *SCIMListResponse { + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaListResponse, result.Schemas[0]) + require.Equal(ts.T(), expectedTotal, result.TotalResults) + require.GreaterOrEqual(ts.T(), result.StartIndex, 1) + + return &result +} +func (ts *SCIMTestSuite) TestSCIMProviderSetup() { + require.NotNil(ts.T(), ts.SSOProvider) + require.True(ts.T(), ts.SSOProvider.IsSCIMEnabled()) +} + +func (ts *SCIMTestSuite) TestSCIMTokenValidation() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) +} + +func (ts *SCIMTestSuite) TestSCIMInvalidToken() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/scim/v2/Users", nil) + req.Header.Set("Authorization", "Bearer invalid-token") + req.Header.Set("Content-Type", "application/scim+json") + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusUnauthorized) +} + +func (ts *SCIMTestSuite) TestSCIMMissingToken() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/scim/v2/Users", nil) + req.Header.Set("Content-Type", "application/scim+json") + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusUnauthorized) +} + +func (ts *SCIMTestSuite) TestSCIMEmptyUserList() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + result := ts.assertSCIMListResponse(w, 0) + require.Len(ts.T(), result.Resources, 0) +} + +func (ts *SCIMTestSuite) TestSCIMEmptyGroupList() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + result := ts.assertSCIMListResponse(w, 0) + require.Len(ts.T(), result.Resources, 0) +} + +func (ts *SCIMTestSuite) TestSCIMCreateUser() { + user := ts.createSCIMUser("testuser", "testuser@example.com") + + require.NotEmpty(ts.T(), user.ID) + require.Equal(ts.T(), "testuser", user.UserName) + require.True(ts.T(), user.Active) + require.Len(ts.T(), user.Emails, 1) + require.Equal(ts.T(), "testuser@example.com", user.Emails[0].Value) +} + +func (ts *SCIMTestSuite) TestSCIMCreateGroup() { + group := ts.createSCIMGroup("Test Group") + + require.NotEmpty(ts.T(), group.ID) + require.Equal(ts.T(), "Test Group", group.DisplayName) +} + +func (ts *SCIMTestSuite) TestSCIMServiceProviderConfig() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/ServiceProviderConfig", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + schemas, ok := result["schemas"].([]interface{}) + require.True(ts.T(), ok) + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig", schemas[0]) + + patch, ok := result["patch"].(map[string]interface{}) + require.True(ts.T(), ok) + require.True(ts.T(), patch["supported"].(bool)) + + filter, ok := result["filter"].(map[string]interface{}) + require.True(ts.T(), ok) + require.True(ts.T(), filter["supported"].(bool)) +} + +func (ts *SCIMTestSuite) TestSCIMResourceTypes() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/ResourceTypes", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Equal(ts.T(), 2, result.TotalResults) + require.Len(ts.T(), result.Resources, 2) +} + +func (ts *SCIMTestSuite) TestSCIMSchemas() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Schemas", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Equal(ts.T(), 2, result.TotalResults) + require.Len(ts.T(), result.Resources, 2) +} + +func (ts *SCIMTestSuite) TestSCIMGetUserNotFound() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users/00000000-0000-0000-0000-000000000000", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMGetGroupNotFound() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups/00000000-0000-0000-0000-000000000000", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMCreateUserWithName() { + user := ts.createSCIMUserWithName("jdoe", "john.doe@example.com", "John", "Doe") + + require.NotEmpty(ts.T(), user.ID) + require.Equal(ts.T(), "jdoe", user.UserName) + require.NotNil(ts.T(), user.Name) + require.Equal(ts.T(), "John", user.Name.GivenName) + require.Equal(ts.T(), "Doe", user.Name.FamilyName) + require.Equal(ts.T(), "John Doe", user.Name.Formatted) +} + +func (ts *SCIMTestSuite) TestSCIMCreateUserWithExternalID() { + user := ts.createSCIMUserWithExternalID("extuser", "ext@example.com", "ext-12345") + + require.NotEmpty(ts.T(), user.ID) + require.Equal(ts.T(), "extuser", user.UserName) + require.Equal(ts.T(), "ext-12345", user.ExternalID) +} + +func (ts *SCIMTestSuite) TestSCIMGetUser() { + created := ts.createSCIMUser("getuser", "getuser@example.com") + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users/"+created.ID, nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Equal(ts.T(), created.ID, result.ID) + require.Equal(ts.T(), "getuser", result.UserName) +} + +func (ts *SCIMTestSuite) TestSCIMGetGroup() { + created := ts.createSCIMGroup("Get Group") + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups/"+created.ID, nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Equal(ts.T(), created.ID, result.ID) + require.Equal(ts.T(), "Get Group", result.DisplayName) +} + +func (ts *SCIMTestSuite) TestSCIMListUsersWithData() { + ts.createSCIMUser("user1", "user1@example.com") + ts.createSCIMUser("user2", "user2@example.com") + ts.createSCIMUser("user3", "user3@example.com") + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + result := ts.assertSCIMListResponse(w, 3) + require.Len(ts.T(), result.Resources, 3) +} + +func (ts *SCIMTestSuite) TestSCIMListGroupsWithData() { + ts.createSCIMGroup("Group 1") + ts.createSCIMGroup("Group 2") + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + result := ts.assertSCIMListResponse(w, 2) + require.Len(ts.T(), result.Resources, 2) +} + +func (ts *SCIMTestSuite) TestSCIMDeleteUser() { + user := ts.createSCIMUser("deleteuser", "deleteuser@example.com") + + require.True(ts.T(), user.Active) + + req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Users/"+user.ID, nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusNoContent, w.Code) + + req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users/"+user.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.False(ts.T(), result.Active, "Deprovisioned user should have active=false") + + req = ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Users/"+user.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMDeleteGroup() { + group := ts.createSCIMGroup("Delete Group") + + req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Groups/"+group.ID, nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusNoContent, w.Code) + + req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups/"+group.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMCreateGroupWithMembers() { + user1 := ts.createSCIMUser("member1", "member1@example.com") + user2 := ts.createSCIMUser("member2", "member2@example.com") + + group := ts.createSCIMGroupWithMembers("Team Group", []string{user1.ID, user2.ID}) + + require.NotEmpty(ts.T(), group.ID) + require.Equal(ts.T(), "Team Group", group.DisplayName) + require.Len(ts.T(), group.Members, 2) +} + +func (ts *SCIMTestSuite) TestSCIMContentTypeHeader() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), "application/scim+json", w.Header().Get("Content-Type")) +} + +func (ts *SCIMTestSuite) TestSCIMCreateUserMissingUserName() { + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaUser}, + "emails": []map[string]interface{}{ + {"value": "test@example.com", "primary": true}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Users", body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusBadRequest) +} + +func (ts *SCIMTestSuite) TestSCIMCreateGroupMissingDisplayName() { + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaGroup}, + } + + req := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Groups", body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusBadRequest) +} + +func (ts *SCIMTestSuite) TestSCIMUserPagination() { + for i := 0; i < 5; i++ { + ts.createSCIMUser(fmt.Sprintf("pageuser%d", i), fmt.Sprintf("pageuser%d@example.com", i)) + } + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?startIndex=1&count=2", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Equal(ts.T(), 5, result.TotalResults) + require.Equal(ts.T(), 2, result.ItemsPerPage) + require.Len(ts.T(), result.Resources, 2) +} + +func (ts *SCIMTestSuite) assertSCIMErrorWithType(w *httptest.ResponseRecorder, expectedStatus int, expectedScimType string) { + require.Equal(ts.T(), expectedStatus, w.Code) + + var errorResp map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errorResp)) + + schemas, ok := errorResp["schemas"].([]interface{}) + require.True(ts.T(), ok, "SCIM error should have schemas field") + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), "urn:ietf:params:scim:api:messages:2.0:Error", schemas[0]) + + _, ok = errorResp["detail"].(string) + require.True(ts.T(), ok, "SCIM error should have detail field") + + status, ok := errorResp["status"].(string) + require.True(ts.T(), ok, "SCIM error should have status field") + require.Equal(ts.T(), fmt.Sprintf("%d", expectedStatus), status) + + if expectedScimType != "" { + scimType, ok := errorResp["scimType"].(string) + require.True(ts.T(), ok, "SCIM error should have scimType field") + require.Equal(ts.T(), expectedScimType, scimType) + } +} + +func (ts *SCIMTestSuite) TestSCIMCreateUserAzure() { + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaUser}, + "userName": "maiya@anderson.com", + "externalId": "543b2f37-3363-4d69-8af7-5fc5dc1fc3f8", + "name": map[string]interface{}{ + "formatted": "Kenya", + "familyName": "Lurline", + "givenName": "Ernestina", + }, + "emails": []map[string]interface{}{ + {"primary": true, "value": "kira_koelpin@thiel.ca"}, + }, + "active": true, + } + + req := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Users", body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusCreated, w.Code) + + var result SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaUser, result.Schemas[0]) + require.NotEmpty(ts.T(), result.ID) + require.Equal(ts.T(), "543b2f37-3363-4d69-8af7-5fc5dc1fc3f8", result.ExternalID) + require.Equal(ts.T(), "maiya@anderson.com", result.UserName) + require.NotNil(ts.T(), result.Name) + require.Equal(ts.T(), "Kenya", result.Name.Formatted) + require.Equal(ts.T(), "Lurline", result.Name.FamilyName) + require.Equal(ts.T(), "Ernestina", result.Name.GivenName) + require.Len(ts.T(), result.Emails, 1) + require.Equal(ts.T(), "kira_koelpin@thiel.ca", result.Emails[0].Value) + require.True(ts.T(), bool(result.Emails[0].Primary)) + require.True(ts.T(), result.Active) + require.Equal(ts.T(), "User", result.Meta.ResourceType) + require.NotNil(ts.T(), result.Meta.Created) + require.NotNil(ts.T(), result.Meta.LastModified) + require.Contains(ts.T(), result.Meta.Location, "/scim/v2/Users/"+result.ID) +} + +func (ts *SCIMTestSuite) TestSCIMCreateUserDuplicateExternalID() { + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaUser}, + "userName": "elian_huel@cole.com", + "externalId": "22a77d53-9a54-4c3e-bac7-f0cc9f2be272", + "name": map[string]interface{}{ + "formatted": "Teresa", + "familyName": "Lilly", + "givenName": "Eino", + }, + "emails": []map[string]interface{}{ + {"primary": true, "value": "arno.lynch@crooks.ca"}, + }, + "active": true, + } + + req := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Users", body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusCreated, w.Code) + + req = ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Users", body) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusConflict, "uniqueness") +} + +func (ts *SCIMTestSuite) TestSCIMDeleteUserReturns204() { + user := ts.createSCIMUserWithExternalID("amalia@moore.us", "cade@gulgowski.us", "c51b4421-0bd6-428c-b92e-aab658faeb46") + + require.True(ts.T(), user.Active) + + req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Users/"+user.ID, nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusNoContent, w.Code) + require.Empty(ts.T(), w.Body.String()) +} + +func (ts *SCIMTestSuite) TestSCIMDeleteNonExistentUser() { + nonExistentID := "f1937c5d-cd6d-4151-93b7-dbfb7fb9b31d" + + req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Users/"+nonExistentID, nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMDeleteUserTwice() { + user := ts.createSCIMUserWithExternalID("trudie@jacobs.uk", "oswaldo@marquardt.com", "2423c4dc-e525-4c51-8fa9-a63bce38136f") + + req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Users/"+user.ID, nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusNoContent, w.Code) + + req = ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Users/"+user.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} From fc330c3faa99abc4976074e54b7a4ae267caa521 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Mon, 19 Jan 2026 08:07:46 +0000 Subject: [PATCH 030/101] chore: add SCIM user filtering tests --- internal/api/scim_test.go | 91 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index 31b596331..8c5ae5e42 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -685,3 +685,94 @@ func (ts *SCIMTestSuite) TestSCIMDeleteUserTwice() { ts.API.handler.ServeHTTP(w, req) ts.assertSCIMError(w, http.StatusNotFound) } + +func (ts *SCIMTestSuite) TestSCIMFilterUserByUserNameExisting() { + created := ts.createSCIMUserWithExternalID("kenny.sporer@gislason.com", "aliyah@grady.name", "34aee196-7651-4817-a6d8-8a70336466cb") + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=userName+eq+%22kenny.sporer%40gislason.com%22", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaListResponse, result.Schemas[0]) + require.Equal(ts.T(), 1, result.TotalResults) + require.Equal(ts.T(), 1, result.StartIndex) + require.Equal(ts.T(), 1, result.ItemsPerPage) + require.Len(ts.T(), result.Resources, 1) + + resource := result.Resources[0].(map[string]interface{}) + require.Equal(ts.T(), created.ID, resource["id"]) + require.Equal(ts.T(), "kenny.sporer@gislason.com", resource["userName"]) + require.Equal(ts.T(), "34aee196-7651-4817-a6d8-8a70336466cb", resource["externalId"]) + require.Equal(ts.T(), true, resource["active"]) + + schemas := resource["schemas"].([]interface{}) + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), SCIMSchemaUser, schemas[0]) + + meta := resource["meta"].(map[string]interface{}) + require.Equal(ts.T(), "User", meta["resourceType"]) + require.NotEmpty(ts.T(), meta["created"]) + require.NotEmpty(ts.T(), meta["lastModified"]) + require.Contains(ts.T(), meta["location"], "/scim/v2/Users/"+created.ID) +} + +func (ts *SCIMTestSuite) TestSCIMFilterUserByUserNameNonExistent() { + ts.createSCIMUser("someuser@example.com", "someuser@example.com") + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=userName+eq+%22nonexistent%40example.com%22", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaListResponse, result.Schemas[0]) + require.Equal(ts.T(), 0, result.TotalResults) + require.Equal(ts.T(), 1, result.StartIndex) + require.Equal(ts.T(), 0, result.ItemsPerPage) + require.Len(ts.T(), result.Resources, 0) +} + +func (ts *SCIMTestSuite) TestSCIMFilterUserByUserNameCaseInsensitive() { + created := ts.createSCIMUserWithExternalID("kenny.sporer@gislason.com", "aliyah@grady.name", "case-test-ext-id") + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=userName+eq+%22KENNY.SPORER%40GISLASON.COM%22", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaListResponse, result.Schemas[0]) + require.Equal(ts.T(), 1, result.TotalResults) + require.Equal(ts.T(), 1, result.StartIndex) + require.Equal(ts.T(), 1, result.ItemsPerPage) + require.Len(ts.T(), result.Resources, 1) + + resource := result.Resources[0].(map[string]interface{}) + require.Equal(ts.T(), created.ID, resource["id"]) + require.Equal(ts.T(), "kenny.sporer@gislason.com", resource["userName"]) + require.Equal(ts.T(), true, resource["active"]) + + schemas := resource["schemas"].([]interface{}) + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), SCIMSchemaUser, schemas[0]) + + meta := resource["meta"].(map[string]interface{}) + require.Equal(ts.T(), "User", meta["resourceType"]) + require.NotEmpty(ts.T(), meta["created"]) + require.NotEmpty(ts.T(), meta["lastModified"]) + require.Contains(ts.T(), meta["location"], "/scim/v2/Users/"+created.ID) +} From 3f04db3ac4599a27951d9092820ecb6e65ba544a Mon Sep 17 00:00:00 2001 From: bewinxed Date: Mon, 19 Jan 2026 08:12:14 +0000 Subject: [PATCH 031/101] chore: add SCIM user PATCH tests --- internal/api/scim_test.go | 308 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 308 insertions(+) diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index 8c5ae5e42..aea683d15 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -776,3 +776,311 @@ func (ts *SCIMTestSuite) TestSCIMFilterUserByUserNameCaseInsensitive() { require.NotEmpty(ts.T(), meta["lastModified"]) require.Contains(ts.T(), meta["location"], "/scim/v2/Users/"+created.ID) } + +func (ts *SCIMTestSuite) TestSCIMPatchUserUpdateUserName() { + user := ts.createSCIMUserWithExternalID("nasir@bins.com", "nedra@konopelski.name", "a275970d-3319-4a8c-a86f-dc8af2627c70") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "value": map[string]interface{}{"userName": "pearline@donnelly.us"}}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaUser, result.Schemas[0]) + require.Equal(ts.T(), user.ID, result.ID) + require.Equal(ts.T(), "a275970d-3319-4a8c-a86f-dc8af2627c70", result.ExternalID) + require.Equal(ts.T(), "pearline@donnelly.us", result.UserName) + require.True(ts.T(), result.Active) + require.Equal(ts.T(), "User", result.Meta.ResourceType) + require.NotNil(ts.T(), result.Meta.Created) + require.NotNil(ts.T(), result.Meta.LastModified) + require.Contains(ts.T(), result.Meta.Location, "/scim/v2/Users/"+result.ID) +} + +func (ts *SCIMTestSuite) TestSCIMPatchUserDisable() { + user := ts.createSCIMUserWithExternalID("giovani@marvinwhite.biz", "maxie_botsford@vonrussel.ca", "c2a92a74-436a-4444-bced-a311a4648d66") + + require.True(ts.T(), user.Active) + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "active", "value": false}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaUser, result.Schemas[0]) + require.Equal(ts.T(), user.ID, result.ID) + require.Equal(ts.T(), "c2a92a74-436a-4444-bced-a311a4648d66", result.ExternalID) + require.Equal(ts.T(), "giovani@marvinwhite.biz", result.UserName) + require.False(ts.T(), result.Active) + require.Equal(ts.T(), "User", result.Meta.ResourceType) + require.NotNil(ts.T(), result.Meta.Created) + require.NotNil(ts.T(), result.Meta.LastModified) + require.Contains(ts.T(), result.Meta.Location, "/scim/v2/Users/"+result.ID) + + req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users/"+user.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var getResult SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&getResult)) + require.False(ts.T(), getResult.Active) +} + +func (ts *SCIMTestSuite) TestSCIMPatchUserReplaceEmailPrimaryEqTrue() { + user := ts.createSCIMUserWithExternalID("pascale_morissette@pollich.co.uk", "nathanael_lubowitz@boganterry.co.uk", "5dd3dba4-0349-473c-b0bd-eef47b227587") + + require.Len(ts.T(), user.Emails, 1) + require.Equal(ts.T(), "nathanael_lubowitz@boganterry.co.uk", user.Emails[0].Value) + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "emails[primary eq true].value", "value": "kaylie_dietrich@ward.co.uk"}, + {"op": "replace", "value": map[string]interface{}{ + "name.formatted": "Delphine", + "name.familyName": "Vita", + "name.givenName": "Joanie", + "active": true, + "externalId": "1be8b986-70fe-40b5-8f63-c33dbbad29d3", + }}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaUser, result.Schemas[0]) + require.Equal(ts.T(), user.ID, result.ID) + require.Equal(ts.T(), "1be8b986-70fe-40b5-8f63-c33dbbad29d3", result.ExternalID) + require.Equal(ts.T(), "pascale_morissette@pollich.co.uk", result.UserName) + require.NotNil(ts.T(), result.Name) + require.Equal(ts.T(), "Delphine", result.Name.Formatted) + require.Equal(ts.T(), "Vita", result.Name.FamilyName) + require.Equal(ts.T(), "Joanie", result.Name.GivenName) + require.True(ts.T(), result.Active) + + require.Len(ts.T(), result.Emails, 1) + require.Equal(ts.T(), "kaylie_dietrich@ward.co.uk", result.Emails[0].Value) + require.True(ts.T(), bool(result.Emails[0].Primary)) + + req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users/"+user.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var getResult SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&getResult)) + require.Len(ts.T(), getResult.Emails, 1) + require.Equal(ts.T(), "kaylie_dietrich@ward.co.uk", getResult.Emails[0].Value, "Email update was not persisted - reproduces Azure SCIM test 21 failure") +} + +func (ts *SCIMTestSuite) TestSCIMPatchUserMultipleOperationsSameAttribute() { + user := ts.createSCIMUserWithExternalID("casandra_dare@keebler.co.uk", "raul_doyle@dach.co.uk", "48a46062-d787-474a-b60c-1a1c3c70e055") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "remove", "path": "externalId"}, + {"op": "add", "value": map[string]interface{}{"externalId": "717d6020-1ca0-4e2b-ab59-158e10422645"}}, + {"op": "replace", "value": map[string]interface{}{"externalId": "5f3db8ed-c327-4a10-bd0f-a0e93028e5d2"}}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaUser, result.Schemas[0]) + require.Equal(ts.T(), user.ID, result.ID) + require.Equal(ts.T(), "5f3db8ed-c327-4a10-bd0f-a0e93028e5d2", result.ExternalID) + require.Equal(ts.T(), "casandra_dare@keebler.co.uk", result.UserName) + require.True(ts.T(), result.Active) + require.Equal(ts.T(), "User", result.Meta.ResourceType) + require.NotNil(ts.T(), result.Meta.Created) + require.NotNil(ts.T(), result.Meta.LastModified) + require.Contains(ts.T(), result.Meta.Location, "/scim/v2/Users/"+result.ID) +} + +func (ts *SCIMTestSuite) TestSCIMPatchUserNotFound() { + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "active", "value": false}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/00000000-0000-0000-0000-000000000000", body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMPatchUserReEnableUser() { + user := ts.createSCIMUserWithExternalID("disabled_user@test.com", "disabled_user@test.com", "disable-reenable-test") + + disableBody := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "active", "value": false}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, disableBody) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var disabledResult SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&disabledResult)) + require.False(ts.T(), disabledResult.Active) + + enableBody := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "active", "value": true}, + }, + } + + req = ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, enableBody) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var enabledResult SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&enabledResult)) + require.True(ts.T(), enabledResult.Active) + + req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users/"+user.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var getResult SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&getResult)) + require.True(ts.T(), getResult.Active) +} + +func (ts *SCIMTestSuite) TestSCIMPatchUserUpdateUserNameWithPath() { + user := ts.createSCIMUserWithExternalID("original_username@test.com", "original_username@test.com", "username-path-test") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "userName", "value": "new_username@test.com"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), "new_username@test.com", result.UserName) + + req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users/"+user.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var getResult SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&getResult)) + require.Equal(ts.T(), "new_username@test.com", getResult.UserName) +} + +func (ts *SCIMTestSuite) TestSCIMPatchUserInvalidActiveType() { + user := ts.createSCIMUser("invalid_active_test@test.com", "invalid_active_test@test.com") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "active", "value": "not_a_boolean"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidValue") +} + +func (ts *SCIMTestSuite) TestSCIMPatchUserInvalidUserNameType() { + user := ts.createSCIMUser("invalid_username_test@test.com", "invalid_username_test@test.com") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "userName", "value": 12345}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidValue") +} + +func (ts *SCIMTestSuite) TestSCIMPatchUserUnsupportedOp() { + user := ts.createSCIMUser("unsupported_op_test@test.com", "unsupported_op_test@test.com") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "copy", "path": "userName", "value": "new@test.com"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidSyntax") +} From 0ea17fb55235da16e6afdca7cf5d3c1e014f41f1 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Mon, 19 Jan 2026 08:17:18 +0000 Subject: [PATCH 032/101] chore: add SCIM group CRUD tests Task: fn-1-j1u.5 --- internal/api/scim_test.go | 109 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index aea683d15..64e2b6801 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -1051,6 +1051,115 @@ func (ts *SCIMTestSuite) TestSCIMPatchUserInvalidActiveType() { ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidValue") } +func (ts *SCIMTestSuite) TestSCIMCreateGroupAzure() { + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaGroup}, + "displayName": "QGKWKSWJWHXE", + "externalId": "7dae2322-0f90-42d2-97a1-b8268d2993d3", + } + + req := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Groups", body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusCreated, w.Code) + + var result SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaGroup, result.Schemas[0]) + require.NotEmpty(ts.T(), result.ID) + require.Equal(ts.T(), "7dae2322-0f90-42d2-97a1-b8268d2993d3", result.ExternalID) + require.Equal(ts.T(), "QGKWKSWJWHXE", result.DisplayName) + require.Equal(ts.T(), "Group", result.Meta.ResourceType) + require.NotNil(ts.T(), result.Meta.Created) + require.NotNil(ts.T(), result.Meta.LastModified) + require.Contains(ts.T(), result.Meta.Location, "/scim/v2/Groups/"+result.ID) +} + +func (ts *SCIMTestSuite) TestSCIMCreateGroupDuplicateExternalID() { + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaGroup}, + "displayName": "SMVGZDBVFFRO", + "externalId": "e164812e-d012-4cc3-85dc-9ceb13765d62", + } + + req := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Groups", body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusCreated, w.Code) + + body["displayName"] = "DIFFERENT_NAME" + req = ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Groups", body) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusConflict, "uniqueness") +} + +func (ts *SCIMTestSuite) TestSCIMDeleteGroupReturns204() { + group := ts.createSCIMGroupWithExternalID("TESTGROUP", "delete-test-ext-id") + + req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Groups/"+group.ID, nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusNoContent, w.Code) + require.Empty(ts.T(), w.Body.String()) +} + +func (ts *SCIMTestSuite) TestSCIMDeleteNonExistentGroup() { + nonExistentID := "a0f1d64e-cf53-45cf-8b4b-ea0d7b9ada90" + + req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Groups/"+nonExistentID, nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMDeleteGroupTwice() { + group := ts.createSCIMGroupWithExternalID("YLKGXWFUUUOH", "69565956-96c5-4951-910d-951bba6d2533") + + req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Groups/"+group.ID, nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusNoContent, w.Code) + + req = ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Groups/"+group.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMGetGroupByIdExcludingMembers() { + group := ts.createSCIMGroupWithExternalID("YWWBHTHEMMLR", "94631638-0b6c-4b97-a369-aba35a454041") + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups/"+group.ID+"?excludedAttributes=members", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaGroup, result.Schemas[0]) + require.Equal(ts.T(), group.ID, result.ID) + require.Equal(ts.T(), "94631638-0b6c-4b97-a369-aba35a454041", result.ExternalID) + require.Equal(ts.T(), "YWWBHTHEMMLR", result.DisplayName) + require.Equal(ts.T(), "Group", result.Meta.ResourceType) + require.NotNil(ts.T(), result.Meta.Created) + require.NotNil(ts.T(), result.Meta.LastModified) + require.Contains(ts.T(), result.Meta.Location, "/scim/v2/Groups/"+result.ID) +} + func (ts *SCIMTestSuite) TestSCIMPatchUserInvalidUserNameType() { user := ts.createSCIMUser("invalid_username_test@test.com", "invalid_username_test@test.com") From 1c69b1c0775776c6fce25d1ec66dee607cd0943c Mon Sep 17 00:00:00 2001 From: bewinxed Date: Mon, 19 Jan 2026 08:21:54 +0000 Subject: [PATCH 033/101] chore: add SCIM group filtering tests --- internal/api/scim_test.go | 128 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index 64e2b6801..f6e85fa73 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -1193,3 +1193,131 @@ func (ts *SCIMTestSuite) TestSCIMPatchUserUnsupportedOp() { ts.API.handler.ServeHTTP(w, req) ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidSyntax") } + +func (ts *SCIMTestSuite) TestSCIMFilterGroupByDisplayNameExisting() { + created := ts.createSCIMGroupWithExternalID("YWWBHTHEMMLR", "94631638-0b6c-4b97-a369-aba35a454041") + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups?filter=displayName+eq+%22YWWBHTHEMMLR%22", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaListResponse, result.Schemas[0]) + require.Equal(ts.T(), 1, result.TotalResults) + require.Equal(ts.T(), 1, result.StartIndex) + require.Equal(ts.T(), 1, result.ItemsPerPage) + require.Len(ts.T(), result.Resources, 1) + + resource := result.Resources[0].(map[string]interface{}) + require.Equal(ts.T(), created.ID, resource["id"]) + require.Equal(ts.T(), "YWWBHTHEMMLR", resource["displayName"]) + require.Equal(ts.T(), "94631638-0b6c-4b97-a369-aba35a454041", resource["externalId"]) + + schemas := resource["schemas"].([]interface{}) + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), SCIMSchemaGroup, schemas[0]) + + meta := resource["meta"].(map[string]interface{}) + require.Equal(ts.T(), "Group", meta["resourceType"]) + require.NotEmpty(ts.T(), meta["created"]) + require.NotEmpty(ts.T(), meta["lastModified"]) + require.Contains(ts.T(), meta["location"], "/scim/v2/Groups/"+created.ID) +} + +func (ts *SCIMTestSuite) TestSCIMFilterGroupByDisplayNameExcludingMembers() { + created := ts.createSCIMGroupWithExternalID("YWWBHTHEMMLR", "94631638-0b6c-4b97-a369-aba35a454041") + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups?excludedAttributes=members&filter=displayName+eq+%22YWWBHTHEMMLR%22", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaListResponse, result.Schemas[0]) + require.Equal(ts.T(), 1, result.TotalResults) + require.Equal(ts.T(), 1, result.StartIndex) + require.Equal(ts.T(), 1, result.ItemsPerPage) + require.Len(ts.T(), result.Resources, 1) + + resource := result.Resources[0].(map[string]interface{}) + require.Equal(ts.T(), created.ID, resource["id"]) + require.Equal(ts.T(), "YWWBHTHEMMLR", resource["displayName"]) + require.Equal(ts.T(), "94631638-0b6c-4b97-a369-aba35a454041", resource["externalId"]) + + _, hasMembers := resource["members"] + require.False(ts.T(), hasMembers, "Response should exclude members attribute") + + schemas := resource["schemas"].([]interface{}) + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), SCIMSchemaGroup, schemas[0]) + + meta := resource["meta"].(map[string]interface{}) + require.Equal(ts.T(), "Group", meta["resourceType"]) + require.NotEmpty(ts.T(), meta["created"]) + require.NotEmpty(ts.T(), meta["lastModified"]) + require.Contains(ts.T(), meta["location"], "/scim/v2/Groups/"+created.ID) +} + +func (ts *SCIMTestSuite) TestSCIMFilterGroupByDisplayNameNonExistent() { + ts.createSCIMGroup("SomeExistingGroup") + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups?filter=displayName+eq+%22nonexistente997dccbd8b7_EOKNVHIYLTCZ%22", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaListResponse, result.Schemas[0]) + require.Equal(ts.T(), 0, result.TotalResults) + require.Equal(ts.T(), 1, result.StartIndex) + require.Equal(ts.T(), 0, result.ItemsPerPage) + require.Len(ts.T(), result.Resources, 0) +} + +func (ts *SCIMTestSuite) TestSCIMFilterGroupByDisplayNameCaseInsensitive() { + created := ts.createSCIMGroupWithExternalID("YWWBHTHEMMLR", "94631638-0b6c-4b97-a369-aba35a454041") + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups?filter=displayName+eq+%22ywwbhthemmlr%22", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaListResponse, result.Schemas[0]) + require.Equal(ts.T(), 1, result.TotalResults) + require.Equal(ts.T(), 1, result.StartIndex) + require.Equal(ts.T(), 1, result.ItemsPerPage) + require.Len(ts.T(), result.Resources, 1) + + resource := result.Resources[0].(map[string]interface{}) + require.Equal(ts.T(), created.ID, resource["id"]) + require.Equal(ts.T(), "YWWBHTHEMMLR", resource["displayName"]) + require.Equal(ts.T(), "94631638-0b6c-4b97-a369-aba35a454041", resource["externalId"]) + + schemas := resource["schemas"].([]interface{}) + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), SCIMSchemaGroup, schemas[0]) + + meta := resource["meta"].(map[string]interface{}) + require.Equal(ts.T(), "Group", meta["resourceType"]) + require.NotEmpty(ts.T(), meta["created"]) + require.NotEmpty(ts.T(), meta["lastModified"]) + require.Contains(ts.T(), meta["location"], "/scim/v2/Groups/"+created.ID) +} From ff5c1dcf4a37fc118c309cd2e0f6478f6d6cdbff Mon Sep 17 00:00:00 2001 From: bewinxed Date: Mon, 19 Jan 2026 08:26:27 +0000 Subject: [PATCH 034/101] chore: add SCIM group membership PATCH tests --- internal/api/scim_test.go | 357 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 357 insertions(+) diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index f6e85fa73..43d26f961 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -1321,3 +1321,360 @@ func (ts *SCIMTestSuite) TestSCIMFilterGroupByDisplayNameCaseInsensitive() { require.NotEmpty(ts.T(), meta["lastModified"]) require.Contains(ts.T(), meta["location"], "/scim/v2/Groups/"+created.ID) } + +func (ts *SCIMTestSuite) TestSCIMPatchGroupReplaceExternalID() { + group := ts.createSCIMGroupWithExternalID("SFSNYLFDSMIG", "643a3bd4-43e1-481a-9ea6-bd82d65bbd04") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "value": map[string]interface{}{"externalId": "3d413e4f-7404-45e9-86b9-478c9b6a894a"}}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaGroup, result.Schemas[0]) + require.Equal(ts.T(), group.ID, result.ID) + require.Equal(ts.T(), "3d413e4f-7404-45e9-86b9-478c9b6a894a", result.ExternalID) + require.Equal(ts.T(), "SFSNYLFDSMIG", result.DisplayName) + require.Equal(ts.T(), "Group", result.Meta.ResourceType) + require.NotNil(ts.T(), result.Meta.Created) + require.NotNil(ts.T(), result.Meta.LastModified) + require.Contains(ts.T(), result.Meta.Location, "/scim/v2/Groups/"+result.ID) + + req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups/"+group.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var getResult SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&getResult)) + require.Equal(ts.T(), "3d413e4f-7404-45e9-86b9-478c9b6a894a", getResult.ExternalID) +} + +func (ts *SCIMTestSuite) TestSCIMPatchGroupUpdateDisplayName() { + group := ts.createSCIMGroupWithExternalID("NUOSLUZYECIZ", "fa01b7f2-ab68-4f97-a211-11f5732d0e15") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "value": map[string]interface{}{"displayName": "YJCESZMOUKCA"}}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaGroup, result.Schemas[0]) + require.Equal(ts.T(), group.ID, result.ID) + require.Equal(ts.T(), "fa01b7f2-ab68-4f97-a211-11f5732d0e15", result.ExternalID) + require.Equal(ts.T(), "YJCESZMOUKCA", result.DisplayName) + require.Equal(ts.T(), "Group", result.Meta.ResourceType) + require.NotNil(ts.T(), result.Meta.Created) + require.NotNil(ts.T(), result.Meta.LastModified) + require.Contains(ts.T(), result.Meta.Location, "/scim/v2/Groups/"+result.ID) + + req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups/"+group.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var getResult SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&getResult)) + require.Equal(ts.T(), "YJCESZMOUKCA", getResult.DisplayName) +} + +func (ts *SCIMTestSuite) TestSCIMPatchGroupAddMember() { + group := ts.createSCIMGroupWithExternalID("BWWXXWZXZGGB", "7a48952d-6dbe-4192-8c63-fd575f132232") + user := ts.createSCIMUserWithExternalID("member_buck@schmidt.uk", "ethel_hilpert@gislasonsmitham.biz", "d00a6559-b7e9-41cd-8a7f-e2d52abfff6b") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "members", "value": []map[string]interface{}{ + {"value": user.ID}, + }}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaGroup, result.Schemas[0]) + require.Equal(ts.T(), group.ID, result.ID) + require.Equal(ts.T(), "7a48952d-6dbe-4192-8c63-fd575f132232", result.ExternalID) + require.Equal(ts.T(), "BWWXXWZXZGGB", result.DisplayName) + require.Len(ts.T(), result.Members, 1) + require.Equal(ts.T(), user.ID, result.Members[0].Value) + require.Contains(ts.T(), result.Members[0].Ref, "/scim/v2/Users/"+user.ID) + require.Equal(ts.T(), "ethel_hilpert@gislasonsmitham.biz", result.Members[0].Display) + require.Equal(ts.T(), "Group", result.Meta.ResourceType) + require.NotNil(ts.T(), result.Meta.Created) + require.NotNil(ts.T(), result.Meta.LastModified) + require.Contains(ts.T(), result.Meta.Location, "/scim/v2/Groups/"+result.ID) + + req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups/"+group.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var getResult SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&getResult)) + require.Len(ts.T(), getResult.Members, 1) + require.Equal(ts.T(), user.ID, getResult.Members[0].Value) +} + +func (ts *SCIMTestSuite) TestSCIMPatchGroupRemoveMember() { + group := ts.createSCIMGroupWithExternalID("FGNIPTSHXSMO", "22b56196-c623-4f6d-bc7a-de32fe17071e") + user1 := ts.createSCIMUserWithExternalID("member_twila@reichel.com", "julien_skiles@glover.us", "052a04f6-8fc6-4819-8252-e191e738055d") + user2 := ts.createSCIMUserWithExternalID("member_alfred.ledner@wisoky.biz", "donavon@rempel.name", "c632bcd1-a637-4d4b-84fc-c9ced4f168c8") + + addMembersBody := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "members", "value": []map[string]interface{}{ + {"value": user1.ID}, + {"value": user2.ID}, + }}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, addMembersBody) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var addResult SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&addResult)) + require.Len(ts.T(), addResult.Members, 2) + + removeMemberBody := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "remove", "path": fmt.Sprintf("members[value eq \"%s\"]", user1.ID)}, + }, + } + + req = ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, removeMemberBody) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaGroup, result.Schemas[0]) + require.Equal(ts.T(), group.ID, result.ID) + require.Equal(ts.T(), "22b56196-c623-4f6d-bc7a-de32fe17071e", result.ExternalID) + require.Equal(ts.T(), "FGNIPTSHXSMO", result.DisplayName) + require.Len(ts.T(), result.Members, 1) + require.Equal(ts.T(), user2.ID, result.Members[0].Value) + require.Contains(ts.T(), result.Members[0].Ref, "/scim/v2/Users/"+user2.ID) + require.Equal(ts.T(), "donavon@rempel.name", result.Members[0].Display) + require.Equal(ts.T(), "Group", result.Meta.ResourceType) + require.NotNil(ts.T(), result.Meta.Created) + require.NotNil(ts.T(), result.Meta.LastModified) + require.Contains(ts.T(), result.Meta.Location, "/scim/v2/Groups/"+result.ID) + + req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups/"+group.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var getResult SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&getResult)) + require.Len(ts.T(), getResult.Members, 1) + require.Equal(ts.T(), user2.ID, getResult.Members[0].Value) +} + +func (ts *SCIMTestSuite) TestSCIMPatchGroupMultipleOperationsAddThenRemoveMember() { + group := ts.createSCIMGroupWithExternalID("BXQHAOAUIVTX", "631e641e-0227-4570-bba7-bfdfce9715d3") + user := ts.createSCIMUserWithExternalID("member_cassie_steuber@larkin.name", "vincent@keeblerhamill.uk", "e27b5ca9-73ca-4ee0-9105-b4115a270637") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "add", "path": "members", "value": []map[string]interface{}{ + {"value": user.ID}, + }}, + {"op": "remove", "path": fmt.Sprintf("members[value eq \"%s\"]", user.ID)}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + + require.Len(ts.T(), result.Schemas, 1) + require.Equal(ts.T(), SCIMSchemaGroup, result.Schemas[0]) + require.Equal(ts.T(), group.ID, result.ID) + require.Equal(ts.T(), "631e641e-0227-4570-bba7-bfdfce9715d3", result.ExternalID) + require.Equal(ts.T(), "BXQHAOAUIVTX", result.DisplayName) + require.Empty(ts.T(), result.Members) + require.Equal(ts.T(), "Group", result.Meta.ResourceType) + require.NotNil(ts.T(), result.Meta.Created) + require.NotNil(ts.T(), result.Meta.LastModified) + require.Contains(ts.T(), result.Meta.Location, "/scim/v2/Groups/"+result.ID) + + req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups/"+group.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var getResult SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&getResult)) + require.Empty(ts.T(), getResult.Members) +} + +func (ts *SCIMTestSuite) TestSCIMPatchGroupNotFound() { + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "value": map[string]interface{}{"displayName": "NewName"}}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/00000000-0000-0000-0000-000000000000", body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMPatchGroupUpdateDisplayNameWithPath() { + group := ts.createSCIMGroupWithExternalID("ORIGINALNAME", "path-test-ext-id") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "displayName", "value": "NEWDISPLAYNAME"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), "NEWDISPLAYNAME", result.DisplayName) + + req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups/"+group.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var getResult SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&getResult)) + require.Equal(ts.T(), "NEWDISPLAYNAME", getResult.DisplayName) +} + +func (ts *SCIMTestSuite) TestSCIMPatchGroupAddMemberWithAddOp() { + group := ts.createSCIMGroup("AddOpTestGroup") + user := ts.createSCIMUser("addop_member@test.com", "addop_member@test.com") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "add", "path": "members", "value": []map[string]interface{}{ + {"value": user.ID}, + }}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Len(ts.T(), result.Members, 1) + require.Equal(ts.T(), user.ID, result.Members[0].Value) + + req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups/"+group.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var getResult SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&getResult)) + require.Len(ts.T(), getResult.Members, 1) + require.Equal(ts.T(), user.ID, getResult.Members[0].Value) +} + +func (ts *SCIMTestSuite) TestSCIMPatchGroupRemoveAllMembers() { + user1 := ts.createSCIMUser("remove_all_member1@test.com", "remove_all_member1@test.com") + user2 := ts.createSCIMUser("remove_all_member2@test.com", "remove_all_member2@test.com") + group := ts.createSCIMGroupWithMembers("RemoveAllMembersGroup", []string{user1.ID, user2.ID}) + + require.Len(ts.T(), group.Members, 2) + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "members", "value": []map[string]interface{}{}}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Empty(ts.T(), result.Members) + + req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups/"+group.ID, nil) + w = httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var getResult SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&getResult)) + require.Empty(ts.T(), getResult.Members) +} From a431f2217ef36f237d7ad145d176a04019e1666e Mon Sep 17 00:00:00 2001 From: bewinxed Date: Mon, 19 Jan 2026 08:35:01 +0000 Subject: [PATCH 035/101] chore: add SCIM authentication and error tests --- internal/api/scim_test.go | 316 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index 43d26f961..cd32b05f2 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -1678,3 +1678,319 @@ func (ts *SCIMTestSuite) TestSCIMPatchGroupRemoveAllMembers() { require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&getResult)) require.Empty(ts.T(), getResult.Members) } + +func (ts *SCIMTestSuite) TestSCIMAuthMissingAuthorizationHeader() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/scim/v2/Users", nil) + req.Header.Set("Content-Type", "application/scim+json") + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusUnauthorized, w.Code) + + var errorResp map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errorResp)) + + schemas, ok := errorResp["schemas"].([]interface{}) + require.True(ts.T(), ok, "SCIM error should have schemas field") + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), "urn:ietf:params:scim:api:messages:2.0:Error", schemas[0]) + + detail, ok := errorResp["detail"].(string) + require.True(ts.T(), ok, "SCIM error should have detail field") + require.NotEmpty(ts.T(), detail) + + status, ok := errorResp["status"].(string) + require.True(ts.T(), ok, "SCIM error should have status field as string per RFC 7644") + require.Equal(ts.T(), "401", status) +} + +func (ts *SCIMTestSuite) TestSCIMAuthInvalidBearerToken() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/scim/v2/Users", nil) + req.Header.Set("Authorization", "Bearer completely-invalid-token-xyz") + req.Header.Set("Content-Type", "application/scim+json") + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusUnauthorized, w.Code) + + var errorResp map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errorResp)) + + schemas, ok := errorResp["schemas"].([]interface{}) + require.True(ts.T(), ok, "SCIM error should have schemas field") + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), "urn:ietf:params:scim:api:messages:2.0:Error", schemas[0]) + + detail, ok := errorResp["detail"].(string) + require.True(ts.T(), ok, "SCIM error should have detail field") + require.NotEmpty(ts.T(), detail) + + status, ok := errorResp["status"].(string) + require.True(ts.T(), ok, "SCIM error should have status field as string per RFC 7644") + require.Equal(ts.T(), "401", status) +} + +func (ts *SCIMTestSuite) TestSCIMAuthMalformedAuthorizationHeader() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/scim/v2/Users", nil) + req.Header.Set("Authorization", "Basic dXNlcjpwYXNz") + req.Header.Set("Content-Type", "application/scim+json") + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusUnauthorized, w.Code) + + var errorResp map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errorResp)) + + schemas, ok := errorResp["schemas"].([]interface{}) + require.True(ts.T(), ok, "SCIM error should have schemas field") + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), "urn:ietf:params:scim:api:messages:2.0:Error", schemas[0]) + + status, ok := errorResp["status"].(string) + require.True(ts.T(), ok, "SCIM error should have status field") + require.Equal(ts.T(), "401", status) +} + +func (ts *SCIMTestSuite) TestSCIMAuthEmptyBearerToken() { + req := httptest.NewRequest(http.MethodGet, "http://localhost/scim/v2/Users", nil) + req.Header.Set("Authorization", "Bearer ") + req.Header.Set("Content-Type", "application/scim+json") + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusUnauthorized, w.Code) + + var errorResp map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errorResp)) + + schemas, ok := errorResp["schemas"].([]interface{}) + require.True(ts.T(), ok, "SCIM error should have schemas field") + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), "urn:ietf:params:scim:api:messages:2.0:Error", schemas[0]) + + status, ok := errorResp["status"].(string) + require.True(ts.T(), ok, "SCIM error should have status field") + require.Equal(ts.T(), "401", status) +} + +func (ts *SCIMTestSuite) TestSCIMErrorInvalidFilterSyntax() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=invalid+++syntax", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidFilter") +} + +func (ts *SCIMTestSuite) TestSCIMErrorInvalidFilterUnclosedQuote() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=userName+eq+%22unclosed", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidFilter") +} + +func (ts *SCIMTestSuite) TestSCIMErrorInvalidFilterUnsupportedAttribute() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=unsupportedAttr+eq+%22value%22", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidFilter") +} + +func (ts *SCIMTestSuite) TestSCIMErrorInvalidFilterGroupUnsupportedAttribute() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups?filter=invalidAttr+eq+%22value%22", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidFilter") +} + +func (ts *SCIMTestSuite) TestSCIMErrorInvalidPatchOperationCopy() { + user := ts.createSCIMUser("patch_copy_op@test.com", "patch_copy_op@test.com") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "copy", "from": "userName", "path": "externalId"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidSyntax") +} + +func (ts *SCIMTestSuite) TestSCIMErrorInvalidPatchOperationMove() { + user := ts.createSCIMUser("patch_move_op@test.com", "patch_move_op@test.com") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "move", "from": "userName", "path": "externalId"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidSyntax") +} + +func (ts *SCIMTestSuite) TestSCIMErrorInvalidPatchMissingOperations() { + user := ts.createSCIMUser("patch_missing_ops@test.com", "patch_missing_ops@test.com") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusOK, w.Code) +} + +func (ts *SCIMTestSuite) TestSCIMErrorInvalidJSON() { + req := httptest.NewRequest(http.MethodPost, "http://localhost/scim/v2/Users", bytes.NewBuffer([]byte("{invalid json"))) + req.Header.Set("Authorization", "Bearer "+ts.SCIMToken) + req.Header.Set("Content-Type", "application/scim+json") + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidSyntax") +} + +func (ts *SCIMTestSuite) TestSCIMErrorResponseFormatUsers() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users/00000000-0000-0000-0000-000000000000", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusNotFound, w.Code) + + var errorResp map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errorResp)) + + schemas, ok := errorResp["schemas"].([]interface{}) + require.True(ts.T(), ok, "SCIM error must have schemas field per RFC 7644") + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), "urn:ietf:params:scim:api:messages:2.0:Error", schemas[0]) + + detail, ok := errorResp["detail"].(string) + require.True(ts.T(), ok, "SCIM error must have detail field per RFC 7644") + require.NotEmpty(ts.T(), detail) + + status, ok := errorResp["status"].(string) + require.True(ts.T(), ok, "SCIM error status must be a string per RFC 7644") + require.Equal(ts.T(), "404", status) +} + +func (ts *SCIMTestSuite) TestSCIMErrorResponseFormatGroups() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups/00000000-0000-0000-0000-000000000000", nil) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusNotFound, w.Code) + + var errorResp map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errorResp)) + + schemas, ok := errorResp["schemas"].([]interface{}) + require.True(ts.T(), ok, "SCIM error must have schemas field per RFC 7644") + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), "urn:ietf:params:scim:api:messages:2.0:Error", schemas[0]) + + detail, ok := errorResp["detail"].(string) + require.True(ts.T(), ok, "SCIM error must have detail field per RFC 7644") + require.NotEmpty(ts.T(), detail) + + status, ok := errorResp["status"].(string) + require.True(ts.T(), ok, "SCIM error status must be a string per RFC 7644") + require.Equal(ts.T(), "404", status) +} + +func (ts *SCIMTestSuite) TestSCIMErrorSchemaValidationMissingRequiredField() { + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaUser}, + "emails": []map[string]interface{}{ + {"value": "test@example.com", "primary": true}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Users", body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusBadRequest, w.Code) + + var errorResp map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errorResp)) + + schemas, ok := errorResp["schemas"].([]interface{}) + require.True(ts.T(), ok, "SCIM error should have schemas field") + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), "urn:ietf:params:scim:api:messages:2.0:Error", schemas[0]) + + detail, ok := errorResp["detail"].(string) + require.True(ts.T(), ok, "SCIM error should have detail field") + require.Contains(ts.T(), detail, "userName") + + status, ok := errorResp["status"].(string) + require.True(ts.T(), ok, "SCIM error should have status field") + require.Equal(ts.T(), "400", status) + + scimType, ok := errorResp["scimType"].(string) + require.True(ts.T(), ok, "SCIM error should have scimType field") + require.Equal(ts.T(), "invalidSyntax", scimType) +} + +func (ts *SCIMTestSuite) TestSCIMErrorGroupSchemaValidationMissingDisplayName() { + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaGroup}, + } + + req := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Groups", body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + + require.Equal(ts.T(), http.StatusBadRequest, w.Code) + + var errorResp map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errorResp)) + + schemas, ok := errorResp["schemas"].([]interface{}) + require.True(ts.T(), ok, "SCIM error should have schemas field") + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), "urn:ietf:params:scim:api:messages:2.0:Error", schemas[0]) + + detail, ok := errorResp["detail"].(string) + require.True(ts.T(), ok, "SCIM error should have detail field") + require.Contains(ts.T(), detail, "displayName") + + status, ok := errorResp["status"].(string) + require.True(ts.T(), ok, "SCIM error should have status field") + require.Equal(ts.T(), "400", status) + + scimType, ok := errorResp["scimType"].(string) + require.True(ts.T(), ok, "SCIM error should have scimType field") + require.Equal(ts.T(), "invalidSyntax", scimType) +} From c007f3f95adec0933772743f0a90cc552fcfd8f1 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Mon, 19 Jan 2026 09:29:19 +0000 Subject: [PATCH 036/101] chore: centralize test fixtures --- internal/api/scim_test.go | 250 +++++++++++++++++++++++--------------- 1 file changed, 152 insertions(+), 98 deletions(-) diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index cd32b05f2..0a0caaace 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -15,6 +15,45 @@ import ( "github.com/supabase/auth/internal/models" ) +type scimTestUser struct { + UserName string + Email string + GivenName string + FamilyName string + Formatted string + ExternalID string +} + +type scimTestGroup struct { + DisplayName string + ExternalID string +} + +var ( + testUser1 = scimTestUser{UserName: "user1@acme.com", Email: "user1@acme.com"} + testUser2 = scimTestUser{UserName: "user2@acme.com", Email: "user2@acme.com", GivenName: "Test", FamilyName: "User", Formatted: "Test User"} + testUser3 = scimTestUser{UserName: "user3@acme.com", Email: "user3@acme.com", ExternalID: "ext-001"} + testUser4 = scimTestUser{UserName: "user4@acme.com", Email: "user4@acme.com"} + testUser5 = scimTestUser{UserName: "user5@acme.com", Email: "user5@acme.com"} + testUser6 = scimTestUser{UserName: "user6@example.com", Email: "user6@example.com"} + testUser7 = scimTestUser{UserName: "user7@example.com", Email: "user7@example.com"} + testUser8 = scimTestUser{UserName: "user8@example.com", Email: "user8@example.com"} + testUser9 = scimTestUser{UserName: "user9@acme.com", Email: "user9@acme.com", GivenName: "Jane", FamilyName: "Doe", Formatted: "Jane Doe", ExternalID: "ext-002"} + testUser10 = scimTestUser{UserName: "user10@acme.com", Email: "user10@acme.com", GivenName: "John", FamilyName: "Smith", Formatted: "John Smith", ExternalID: "ext-003"} + testUser11 = scimTestUser{UserName: "user11@acme.com", Email: "user11@acme.com", ExternalID: "ext-004"} + testUser12 = scimTestUser{UserName: "user12@acme.com", Email: "user12@acme.com", ExternalID: "ext-005"} + testUser13 = scimTestUser{UserName: "user13@example.com", Email: "user13@example.com", ExternalID: "ext-006"} + testUser14 = scimTestUser{UserName: "user14@acme.com", Email: "user14@acme.com", ExternalID: "ext-007"} + testUser15 = scimTestUser{UserName: "user15@acme.com", Email: "user15@acme.com", ExternalID: "ext-008"} + testUser16 = scimTestUser{UserName: "user16@example.com", Email: "user16@example.com", ExternalID: "ext-009"} + + testGroup1 = scimTestGroup{DisplayName: "Engineering", ExternalID: "grp-001"} + testGroup2 = scimTestGroup{DisplayName: "Sales", ExternalID: "grp-002"} + testGroup3 = scimTestGroup{DisplayName: "Marketing", ExternalID: "grp-003"} + testGroup4 = scimTestGroup{DisplayName: "Platform", ExternalID: "grp-004"} + testGroup5 = scimTestGroup{DisplayName: "Support", ExternalID: "grp-005"} +) + type SCIMTestSuite struct { suite.Suite API *API @@ -276,20 +315,20 @@ func (ts *SCIMTestSuite) TestSCIMEmptyGroupList() { } func (ts *SCIMTestSuite) TestSCIMCreateUser() { - user := ts.createSCIMUser("testuser", "testuser@example.com") + user := ts.createSCIMUser(testUser1.UserName, testUser1.Email) require.NotEmpty(ts.T(), user.ID) - require.Equal(ts.T(), "testuser", user.UserName) + require.Equal(ts.T(), testUser1.UserName, user.UserName) require.True(ts.T(), user.Active) require.Len(ts.T(), user.Emails, 1) - require.Equal(ts.T(), "testuser@example.com", user.Emails[0].Value) + require.Equal(ts.T(), testUser1.Email, user.Emails[0].Value) } func (ts *SCIMTestSuite) TestSCIMCreateGroup() { - group := ts.createSCIMGroup("Test Group") + group := ts.createSCIMGroup(testGroup1.DisplayName) require.NotEmpty(ts.T(), group.ID) - require.Equal(ts.T(), "Test Group", group.DisplayName) + require.Equal(ts.T(), testGroup1.DisplayName, group.DisplayName) } func (ts *SCIMTestSuite) TestSCIMServiceProviderConfig() { @@ -361,26 +400,26 @@ func (ts *SCIMTestSuite) TestSCIMGetGroupNotFound() { } func (ts *SCIMTestSuite) TestSCIMCreateUserWithName() { - user := ts.createSCIMUserWithName("jdoe", "john.doe@example.com", "John", "Doe") + user := ts.createSCIMUserWithName(testUser2.UserName, testUser2.Email, testUser2.GivenName, testUser2.FamilyName) require.NotEmpty(ts.T(), user.ID) - require.Equal(ts.T(), "jdoe", user.UserName) + require.Equal(ts.T(), testUser2.UserName, user.UserName) require.NotNil(ts.T(), user.Name) - require.Equal(ts.T(), "John", user.Name.GivenName) - require.Equal(ts.T(), "Doe", user.Name.FamilyName) - require.Equal(ts.T(), "John Doe", user.Name.Formatted) + require.Equal(ts.T(), testUser2.GivenName, user.Name.GivenName) + require.Equal(ts.T(), testUser2.FamilyName, user.Name.FamilyName) + require.Equal(ts.T(), testUser2.Formatted, user.Name.Formatted) } func (ts *SCIMTestSuite) TestSCIMCreateUserWithExternalID() { - user := ts.createSCIMUserWithExternalID("extuser", "ext@example.com", "ext-12345") + user := ts.createSCIMUserWithExternalID(testUser3.UserName, testUser3.Email, testUser3.ExternalID) require.NotEmpty(ts.T(), user.ID) - require.Equal(ts.T(), "extuser", user.UserName) - require.Equal(ts.T(), "ext-12345", user.ExternalID) + require.Equal(ts.T(), testUser3.UserName, user.UserName) + require.Equal(ts.T(), testUser3.ExternalID, user.ExternalID) } func (ts *SCIMTestSuite) TestSCIMGetUser() { - created := ts.createSCIMUser("getuser", "getuser@example.com") + created := ts.createSCIMUser(testUser4.UserName, testUser4.Email) req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users/"+created.ID, nil) w := httptest.NewRecorder() @@ -392,11 +431,11 @@ func (ts *SCIMTestSuite) TestSCIMGetUser() { require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) require.Equal(ts.T(), created.ID, result.ID) - require.Equal(ts.T(), "getuser", result.UserName) + require.Equal(ts.T(), testUser4.UserName, result.UserName) } func (ts *SCIMTestSuite) TestSCIMGetGroup() { - created := ts.createSCIMGroup("Get Group") + created := ts.createSCIMGroup(testGroup2.DisplayName) req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups/"+created.ID, nil) w := httptest.NewRecorder() @@ -408,13 +447,13 @@ func (ts *SCIMTestSuite) TestSCIMGetGroup() { require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) require.Equal(ts.T(), created.ID, result.ID) - require.Equal(ts.T(), "Get Group", result.DisplayName) + require.Equal(ts.T(), testGroup2.DisplayName, result.DisplayName) } func (ts *SCIMTestSuite) TestSCIMListUsersWithData() { - ts.createSCIMUser("user1", "user1@example.com") - ts.createSCIMUser("user2", "user2@example.com") - ts.createSCIMUser("user3", "user3@example.com") + ts.createSCIMUser(testUser1.UserName, testUser1.Email) + ts.createSCIMUser(testUser2.UserName, testUser2.Email) + ts.createSCIMUser(testUser3.UserName, testUser3.Email) req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users", nil) w := httptest.NewRecorder() @@ -426,8 +465,8 @@ func (ts *SCIMTestSuite) TestSCIMListUsersWithData() { } func (ts *SCIMTestSuite) TestSCIMListGroupsWithData() { - ts.createSCIMGroup("Group 1") - ts.createSCIMGroup("Group 2") + ts.createSCIMGroup(testGroup1.DisplayName) + ts.createSCIMGroup(testGroup3.DisplayName) req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups", nil) w := httptest.NewRecorder() @@ -439,7 +478,7 @@ func (ts *SCIMTestSuite) TestSCIMListGroupsWithData() { } func (ts *SCIMTestSuite) TestSCIMDeleteUser() { - user := ts.createSCIMUser("deleteuser", "deleteuser@example.com") + user := ts.createSCIMUser(testUser5.UserName, testUser5.Email) require.True(ts.T(), user.Active) @@ -467,7 +506,7 @@ func (ts *SCIMTestSuite) TestSCIMDeleteUser() { } func (ts *SCIMTestSuite) TestSCIMDeleteGroup() { - group := ts.createSCIMGroup("Delete Group") + group := ts.createSCIMGroup(testGroup4.DisplayName) req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Groups/"+group.ID, nil) w := httptest.NewRecorder() @@ -483,13 +522,13 @@ func (ts *SCIMTestSuite) TestSCIMDeleteGroup() { } func (ts *SCIMTestSuite) TestSCIMCreateGroupWithMembers() { - user1 := ts.createSCIMUser("member1", "member1@example.com") - user2 := ts.createSCIMUser("member2", "member2@example.com") + user1 := ts.createSCIMUser(testUser6.UserName, testUser6.Email) + user2 := ts.createSCIMUser(testUser7.UserName, testUser7.Email) - group := ts.createSCIMGroupWithMembers("Team Group", []string{user1.ID, user2.ID}) + group := ts.createSCIMGroupWithMembers(testGroup5.DisplayName, []string{user1.ID, user2.ID}) require.NotEmpty(ts.T(), group.ID) - require.Equal(ts.T(), "Team Group", group.DisplayName) + require.Equal(ts.T(), testGroup5.DisplayName, group.DisplayName) require.Len(ts.T(), group.Members, 2) } @@ -531,7 +570,7 @@ func (ts *SCIMTestSuite) TestSCIMCreateGroupMissingDisplayName() { func (ts *SCIMTestSuite) TestSCIMUserPagination() { for i := 0; i < 5; i++ { - ts.createSCIMUser(fmt.Sprintf("pageuser%d", i), fmt.Sprintf("pageuser%d@example.com", i)) + ts.createSCIMUser(fmt.Sprintf("pageuser%d@acme.com", i), fmt.Sprintf("pageuser%d@acme.com", i)) } req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?startIndex=1&count=2", nil) @@ -576,15 +615,15 @@ func (ts *SCIMTestSuite) assertSCIMErrorWithType(w *httptest.ResponseRecorder, e func (ts *SCIMTestSuite) TestSCIMCreateUserAzure() { body := map[string]interface{}{ "schemas": []string{SCIMSchemaUser}, - "userName": "maiya@anderson.com", - "externalId": "543b2f37-3363-4d69-8af7-5fc5dc1fc3f8", + "userName": testUser9.UserName, + "externalId": testUser9.ExternalID, "name": map[string]interface{}{ - "formatted": "Kenya", - "familyName": "Lurline", - "givenName": "Ernestina", + "formatted": testUser9.Formatted, + "familyName": testUser9.FamilyName, + "givenName": testUser9.GivenName, }, "emails": []map[string]interface{}{ - {"primary": true, "value": "kira_koelpin@thiel.ca"}, + {"primary": true, "value": testUser9.Email}, }, "active": true, } @@ -602,14 +641,14 @@ func (ts *SCIMTestSuite) TestSCIMCreateUserAzure() { require.Len(ts.T(), result.Schemas, 1) require.Equal(ts.T(), SCIMSchemaUser, result.Schemas[0]) require.NotEmpty(ts.T(), result.ID) - require.Equal(ts.T(), "543b2f37-3363-4d69-8af7-5fc5dc1fc3f8", result.ExternalID) - require.Equal(ts.T(), "maiya@anderson.com", result.UserName) + require.Equal(ts.T(), testUser9.ExternalID, result.ExternalID) + require.Equal(ts.T(), testUser9.UserName, result.UserName) require.NotNil(ts.T(), result.Name) - require.Equal(ts.T(), "Kenya", result.Name.Formatted) - require.Equal(ts.T(), "Lurline", result.Name.FamilyName) - require.Equal(ts.T(), "Ernestina", result.Name.GivenName) + require.Equal(ts.T(), testUser9.Formatted, result.Name.Formatted) + require.Equal(ts.T(), testUser9.FamilyName, result.Name.FamilyName) + require.Equal(ts.T(), testUser9.GivenName, result.Name.GivenName) require.Len(ts.T(), result.Emails, 1) - require.Equal(ts.T(), "kira_koelpin@thiel.ca", result.Emails[0].Value) + require.Equal(ts.T(), testUser9.Email, result.Emails[0].Value) require.True(ts.T(), bool(result.Emails[0].Primary)) require.True(ts.T(), result.Active) require.Equal(ts.T(), "User", result.Meta.ResourceType) @@ -621,15 +660,15 @@ func (ts *SCIMTestSuite) TestSCIMCreateUserAzure() { func (ts *SCIMTestSuite) TestSCIMCreateUserDuplicateExternalID() { body := map[string]interface{}{ "schemas": []string{SCIMSchemaUser}, - "userName": "elian_huel@cole.com", - "externalId": "22a77d53-9a54-4c3e-bac7-f0cc9f2be272", + "userName": testUser10.UserName, + "externalId": testUser10.ExternalID, "name": map[string]interface{}{ - "formatted": "Teresa", - "familyName": "Lilly", - "givenName": "Eino", + "formatted": testUser10.Formatted, + "familyName": testUser10.FamilyName, + "givenName": testUser10.GivenName, }, "emails": []map[string]interface{}{ - {"primary": true, "value": "arno.lynch@crooks.ca"}, + {"primary": true, "value": testUser10.Email}, }, "active": true, } @@ -648,7 +687,7 @@ func (ts *SCIMTestSuite) TestSCIMCreateUserDuplicateExternalID() { } func (ts *SCIMTestSuite) TestSCIMDeleteUserReturns204() { - user := ts.createSCIMUserWithExternalID("amalia@moore.us", "cade@gulgowski.us", "c51b4421-0bd6-428c-b92e-aab658faeb46") + user := ts.createSCIMUserWithExternalID(testUser11.UserName, testUser11.Email, testUser11.ExternalID) require.True(ts.T(), user.Active) @@ -671,7 +710,7 @@ func (ts *SCIMTestSuite) TestSCIMDeleteNonExistentUser() { } func (ts *SCIMTestSuite) TestSCIMDeleteUserTwice() { - user := ts.createSCIMUserWithExternalID("trudie@jacobs.uk", "oswaldo@marquardt.com", "2423c4dc-e525-4c51-8fa9-a63bce38136f") + user := ts.createSCIMUserWithExternalID(testUser12.UserName, testUser12.Email, testUser12.ExternalID) req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Users/"+user.ID, nil) w := httptest.NewRecorder() @@ -687,9 +726,9 @@ func (ts *SCIMTestSuite) TestSCIMDeleteUserTwice() { } func (ts *SCIMTestSuite) TestSCIMFilterUserByUserNameExisting() { - created := ts.createSCIMUserWithExternalID("kenny.sporer@gislason.com", "aliyah@grady.name", "34aee196-7651-4817-a6d8-8a70336466cb") + created := ts.createSCIMUserWithExternalID(testUser13.UserName, testUser13.Email, testUser13.ExternalID) - req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=userName+eq+%22kenny.sporer%40gislason.com%22", nil) + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=userName+eq+%22user13%40example.com%22", nil) w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) @@ -707,8 +746,8 @@ func (ts *SCIMTestSuite) TestSCIMFilterUserByUserNameExisting() { resource := result.Resources[0].(map[string]interface{}) require.Equal(ts.T(), created.ID, resource["id"]) - require.Equal(ts.T(), "kenny.sporer@gislason.com", resource["userName"]) - require.Equal(ts.T(), "34aee196-7651-4817-a6d8-8a70336466cb", resource["externalId"]) + require.Equal(ts.T(), testUser13.UserName, resource["userName"]) + require.Equal(ts.T(), testUser13.ExternalID, resource["externalId"]) require.Equal(ts.T(), true, resource["active"]) schemas := resource["schemas"].([]interface{}) @@ -723,7 +762,7 @@ func (ts *SCIMTestSuite) TestSCIMFilterUserByUserNameExisting() { } func (ts *SCIMTestSuite) TestSCIMFilterUserByUserNameNonExistent() { - ts.createSCIMUser("someuser@example.com", "someuser@example.com") + ts.createSCIMUser(testUser8.UserName, testUser8.Email) req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=userName+eq+%22nonexistent%40example.com%22", nil) w := httptest.NewRecorder() @@ -743,9 +782,9 @@ func (ts *SCIMTestSuite) TestSCIMFilterUserByUserNameNonExistent() { } func (ts *SCIMTestSuite) TestSCIMFilterUserByUserNameCaseInsensitive() { - created := ts.createSCIMUserWithExternalID("kenny.sporer@gislason.com", "aliyah@grady.name", "case-test-ext-id") + created := ts.createSCIMUserWithExternalID(testUser14.UserName, testUser14.Email, testUser14.ExternalID) - req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=userName+eq+%22KENNY.SPORER%40GISLASON.COM%22", nil) + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=userName+eq+%22USER14%40ACME.COM%22", nil) w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) @@ -763,7 +802,7 @@ func (ts *SCIMTestSuite) TestSCIMFilterUserByUserNameCaseInsensitive() { resource := result.Resources[0].(map[string]interface{}) require.Equal(ts.T(), created.ID, resource["id"]) - require.Equal(ts.T(), "kenny.sporer@gislason.com", resource["userName"]) + require.Equal(ts.T(), testUser14.UserName, resource["userName"]) require.Equal(ts.T(), true, resource["active"]) schemas := resource["schemas"].([]interface{}) @@ -778,12 +817,13 @@ func (ts *SCIMTestSuite) TestSCIMFilterUserByUserNameCaseInsensitive() { } func (ts *SCIMTestSuite) TestSCIMPatchUserUpdateUserName() { - user := ts.createSCIMUserWithExternalID("nasir@bins.com", "nedra@konopelski.name", "a275970d-3319-4a8c-a86f-dc8af2627c70") + user := ts.createSCIMUserWithExternalID(testUser15.UserName, testUser15.Email, testUser15.ExternalID) + newUserName := "sam.updated@acme.com" body := map[string]interface{}{ "schemas": []string{SCIMSchemaPatchOp}, "Operations": []map[string]interface{}{ - {"op": "replace", "value": map[string]interface{}{"userName": "pearline@donnelly.us"}}, + {"op": "replace", "value": map[string]interface{}{"userName": newUserName}}, }, } @@ -799,8 +839,8 @@ func (ts *SCIMTestSuite) TestSCIMPatchUserUpdateUserName() { require.Len(ts.T(), result.Schemas, 1) require.Equal(ts.T(), SCIMSchemaUser, result.Schemas[0]) require.Equal(ts.T(), user.ID, result.ID) - require.Equal(ts.T(), "a275970d-3319-4a8c-a86f-dc8af2627c70", result.ExternalID) - require.Equal(ts.T(), "pearline@donnelly.us", result.UserName) + require.Equal(ts.T(), testUser15.ExternalID, result.ExternalID) + require.Equal(ts.T(), newUserName, result.UserName) require.True(ts.T(), result.Active) require.Equal(ts.T(), "User", result.Meta.ResourceType) require.NotNil(ts.T(), result.Meta.Created) @@ -809,7 +849,7 @@ func (ts *SCIMTestSuite) TestSCIMPatchUserUpdateUserName() { } func (ts *SCIMTestSuite) TestSCIMPatchUserDisable() { - user := ts.createSCIMUserWithExternalID("giovani@marvinwhite.biz", "maxie_botsford@vonrussel.ca", "c2a92a74-436a-4444-bced-a311a4648d66") + user := ts.createSCIMUserWithExternalID(testUser16.UserName, testUser16.Email, testUser16.ExternalID) require.True(ts.T(), user.Active) @@ -832,8 +872,8 @@ func (ts *SCIMTestSuite) TestSCIMPatchUserDisable() { require.Len(ts.T(), result.Schemas, 1) require.Equal(ts.T(), SCIMSchemaUser, result.Schemas[0]) require.Equal(ts.T(), user.ID, result.ID) - require.Equal(ts.T(), "c2a92a74-436a-4444-bced-a311a4648d66", result.ExternalID) - require.Equal(ts.T(), "giovani@marvinwhite.biz", result.UserName) + require.Equal(ts.T(), testUser16.ExternalID, result.ExternalID) + require.Equal(ts.T(), testUser16.UserName, result.UserName) require.False(ts.T(), result.Active) require.Equal(ts.T(), "User", result.Meta.ResourceType) require.NotNil(ts.T(), result.Meta.Created) @@ -852,21 +892,24 @@ func (ts *SCIMTestSuite) TestSCIMPatchUserDisable() { } func (ts *SCIMTestSuite) TestSCIMPatchUserReplaceEmailPrimaryEqTrue() { - user := ts.createSCIMUserWithExternalID("pascale_morissette@pollich.co.uk", "nathanael_lubowitz@boganterry.co.uk", "5dd3dba4-0349-473c-b0bd-eef47b227587") + origUserName := "patchemail@acme.com" + origEmail := "patchemail@acme.com" + newEmail := "updated.email@acme.com" + user := ts.createSCIMUserWithExternalID(origUserName, origEmail, "ext-patch-email-001") require.Len(ts.T(), user.Emails, 1) - require.Equal(ts.T(), "nathanael_lubowitz@boganterry.co.uk", user.Emails[0].Value) + require.Equal(ts.T(), origEmail, user.Emails[0].Value) body := map[string]interface{}{ "schemas": []string{SCIMSchemaPatchOp}, "Operations": []map[string]interface{}{ - {"op": "replace", "path": "emails[primary eq true].value", "value": "kaylie_dietrich@ward.co.uk"}, + {"op": "replace", "path": "emails[primary eq true].value", "value": newEmail}, {"op": "replace", "value": map[string]interface{}{ - "name.formatted": "Delphine", - "name.familyName": "Vita", - "name.givenName": "Joanie", + "name.formatted": "Updated Name", + "name.familyName": "Name", + "name.givenName": "Updated", "active": true, - "externalId": "1be8b986-70fe-40b5-8f63-c33dbbad29d3", + "externalId": "ext-patch-email-002", }}, }, } @@ -883,16 +926,16 @@ func (ts *SCIMTestSuite) TestSCIMPatchUserReplaceEmailPrimaryEqTrue() { require.Len(ts.T(), result.Schemas, 1) require.Equal(ts.T(), SCIMSchemaUser, result.Schemas[0]) require.Equal(ts.T(), user.ID, result.ID) - require.Equal(ts.T(), "1be8b986-70fe-40b5-8f63-c33dbbad29d3", result.ExternalID) - require.Equal(ts.T(), "pascale_morissette@pollich.co.uk", result.UserName) + require.Equal(ts.T(), "ext-patch-email-002", result.ExternalID) + require.Equal(ts.T(), origUserName, result.UserName) require.NotNil(ts.T(), result.Name) - require.Equal(ts.T(), "Delphine", result.Name.Formatted) - require.Equal(ts.T(), "Vita", result.Name.FamilyName) - require.Equal(ts.T(), "Joanie", result.Name.GivenName) + require.Equal(ts.T(), "Updated Name", result.Name.Formatted) + require.Equal(ts.T(), "Name", result.Name.FamilyName) + require.Equal(ts.T(), "Updated", result.Name.GivenName) require.True(ts.T(), result.Active) require.Len(ts.T(), result.Emails, 1) - require.Equal(ts.T(), "kaylie_dietrich@ward.co.uk", result.Emails[0].Value) + require.Equal(ts.T(), newEmail, result.Emails[0].Value) require.True(ts.T(), bool(result.Emails[0].Primary)) req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users/"+user.ID, nil) @@ -904,18 +947,19 @@ func (ts *SCIMTestSuite) TestSCIMPatchUserReplaceEmailPrimaryEqTrue() { var getResult SCIMUserResponse require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&getResult)) require.Len(ts.T(), getResult.Emails, 1) - require.Equal(ts.T(), "kaylie_dietrich@ward.co.uk", getResult.Emails[0].Value, "Email update was not persisted - reproduces Azure SCIM test 21 failure") + require.Equal(ts.T(), newEmail, getResult.Emails[0].Value) } func (ts *SCIMTestSuite) TestSCIMPatchUserMultipleOperationsSameAttribute() { - user := ts.createSCIMUserWithExternalID("casandra_dare@keebler.co.uk", "raul_doyle@dach.co.uk", "48a46062-d787-474a-b60c-1a1c3c70e055") + userName := "multiop@acme.com" + user := ts.createSCIMUserWithExternalID(userName, userName, "ext-multi-001") body := map[string]interface{}{ "schemas": []string{SCIMSchemaPatchOp}, "Operations": []map[string]interface{}{ {"op": "remove", "path": "externalId"}, - {"op": "add", "value": map[string]interface{}{"externalId": "717d6020-1ca0-4e2b-ab59-158e10422645"}}, - {"op": "replace", "value": map[string]interface{}{"externalId": "5f3db8ed-c327-4a10-bd0f-a0e93028e5d2"}}, + {"op": "add", "value": map[string]interface{}{"externalId": "ext-multi-002"}}, + {"op": "replace", "value": map[string]interface{}{"externalId": "ext-multi-003"}}, }, } @@ -931,8 +975,8 @@ func (ts *SCIMTestSuite) TestSCIMPatchUserMultipleOperationsSameAttribute() { require.Len(ts.T(), result.Schemas, 1) require.Equal(ts.T(), SCIMSchemaUser, result.Schemas[0]) require.Equal(ts.T(), user.ID, result.ID) - require.Equal(ts.T(), "5f3db8ed-c327-4a10-bd0f-a0e93028e5d2", result.ExternalID) - require.Equal(ts.T(), "casandra_dare@keebler.co.uk", result.UserName) + require.Equal(ts.T(), "ext-multi-003", result.ExternalID) + require.Equal(ts.T(), userName, result.UserName) require.True(ts.T(), result.Active) require.Equal(ts.T(), "User", result.Meta.ResourceType) require.NotNil(ts.T(), result.Meta.Created) @@ -1403,8 +1447,11 @@ func (ts *SCIMTestSuite) TestSCIMPatchGroupUpdateDisplayName() { } func (ts *SCIMTestSuite) TestSCIMPatchGroupAddMember() { - group := ts.createSCIMGroupWithExternalID("BWWXXWZXZGGB", "7a48952d-6dbe-4192-8c63-fd575f132232") - user := ts.createSCIMUserWithExternalID("member_buck@schmidt.uk", "ethel_hilpert@gislasonsmitham.biz", "d00a6559-b7e9-41cd-8a7f-e2d52abfff6b") + groupName := "AddMemberGroup" + groupExtID := "grp-add-001" + memberEmail := "member1@acme.com" + group := ts.createSCIMGroupWithExternalID(groupName, groupExtID) + user := ts.createSCIMUserWithExternalID(memberEmail, memberEmail, "usr-member-001") body := map[string]interface{}{ "schemas": []string{SCIMSchemaPatchOp}, @@ -1427,12 +1474,12 @@ func (ts *SCIMTestSuite) TestSCIMPatchGroupAddMember() { require.Len(ts.T(), result.Schemas, 1) require.Equal(ts.T(), SCIMSchemaGroup, result.Schemas[0]) require.Equal(ts.T(), group.ID, result.ID) - require.Equal(ts.T(), "7a48952d-6dbe-4192-8c63-fd575f132232", result.ExternalID) - require.Equal(ts.T(), "BWWXXWZXZGGB", result.DisplayName) + require.Equal(ts.T(), groupExtID, result.ExternalID) + require.Equal(ts.T(), groupName, result.DisplayName) require.Len(ts.T(), result.Members, 1) require.Equal(ts.T(), user.ID, result.Members[0].Value) require.Contains(ts.T(), result.Members[0].Ref, "/scim/v2/Users/"+user.ID) - require.Equal(ts.T(), "ethel_hilpert@gislasonsmitham.biz", result.Members[0].Display) + require.Equal(ts.T(), memberEmail, result.Members[0].Display) require.Equal(ts.T(), "Group", result.Meta.ResourceType) require.NotNil(ts.T(), result.Meta.Created) require.NotNil(ts.T(), result.Meta.LastModified) @@ -1451,9 +1498,13 @@ func (ts *SCIMTestSuite) TestSCIMPatchGroupAddMember() { } func (ts *SCIMTestSuite) TestSCIMPatchGroupRemoveMember() { - group := ts.createSCIMGroupWithExternalID("FGNIPTSHXSMO", "22b56196-c623-4f6d-bc7a-de32fe17071e") - user1 := ts.createSCIMUserWithExternalID("member_twila@reichel.com", "julien_skiles@glover.us", "052a04f6-8fc6-4819-8252-e191e738055d") - user2 := ts.createSCIMUserWithExternalID("member_alfred.ledner@wisoky.biz", "donavon@rempel.name", "c632bcd1-a637-4d4b-84fc-c9ced4f168c8") + groupName := "RemoveMemberGroup" + groupExtID := "grp-remove-001" + member1Email := "member2@acme.com" + member2Email := "member3@acme.com" + group := ts.createSCIMGroupWithExternalID(groupName, groupExtID) + user1 := ts.createSCIMUserWithExternalID(member1Email, member1Email, "usr-member-002") + user2 := ts.createSCIMUserWithExternalID(member2Email, member2Email, "usr-member-003") addMembersBody := map[string]interface{}{ "schemas": []string{SCIMSchemaPatchOp}, @@ -1494,12 +1545,12 @@ func (ts *SCIMTestSuite) TestSCIMPatchGroupRemoveMember() { require.Len(ts.T(), result.Schemas, 1) require.Equal(ts.T(), SCIMSchemaGroup, result.Schemas[0]) require.Equal(ts.T(), group.ID, result.ID) - require.Equal(ts.T(), "22b56196-c623-4f6d-bc7a-de32fe17071e", result.ExternalID) - require.Equal(ts.T(), "FGNIPTSHXSMO", result.DisplayName) + require.Equal(ts.T(), groupExtID, result.ExternalID) + require.Equal(ts.T(), groupName, result.DisplayName) require.Len(ts.T(), result.Members, 1) require.Equal(ts.T(), user2.ID, result.Members[0].Value) require.Contains(ts.T(), result.Members[0].Ref, "/scim/v2/Users/"+user2.ID) - require.Equal(ts.T(), "donavon@rempel.name", result.Members[0].Display) + require.Equal(ts.T(), member2Email, result.Members[0].Display) require.Equal(ts.T(), "Group", result.Meta.ResourceType) require.NotNil(ts.T(), result.Meta.Created) require.NotNil(ts.T(), result.Meta.LastModified) @@ -1518,8 +1569,11 @@ func (ts *SCIMTestSuite) TestSCIMPatchGroupRemoveMember() { } func (ts *SCIMTestSuite) TestSCIMPatchGroupMultipleOperationsAddThenRemoveMember() { - group := ts.createSCIMGroupWithExternalID("BXQHAOAUIVTX", "631e641e-0227-4570-bba7-bfdfce9715d3") - user := ts.createSCIMUserWithExternalID("member_cassie_steuber@larkin.name", "vincent@keeblerhamill.uk", "e27b5ca9-73ca-4ee0-9105-b4115a270637") + groupName := "MultiOpGroup" + groupExtID := "grp-multiop-001" + memberEmail := "member4@acme.com" + group := ts.createSCIMGroupWithExternalID(groupName, groupExtID) + user := ts.createSCIMUserWithExternalID(memberEmail, memberEmail, "usr-member-004") body := map[string]interface{}{ "schemas": []string{SCIMSchemaPatchOp}, @@ -1543,8 +1597,8 @@ func (ts *SCIMTestSuite) TestSCIMPatchGroupMultipleOperationsAddThenRemoveMember require.Len(ts.T(), result.Schemas, 1) require.Equal(ts.T(), SCIMSchemaGroup, result.Schemas[0]) require.Equal(ts.T(), group.ID, result.ID) - require.Equal(ts.T(), "631e641e-0227-4570-bba7-bfdfce9715d3", result.ExternalID) - require.Equal(ts.T(), "BXQHAOAUIVTX", result.DisplayName) + require.Equal(ts.T(), groupExtID, result.ExternalID) + require.Equal(ts.T(), groupName, result.DisplayName) require.Empty(ts.T(), result.Members) require.Equal(ts.T(), "Group", result.Meta.ResourceType) require.NotNil(ts.T(), result.Meta.Created) From 184e3b4ddcb609536fef98b8122c31458dafd2b2 Mon Sep 17 00:00:00 2001 From: bewinxed Date: Mon, 19 Jan 2026 10:17:50 +0000 Subject: [PATCH 037/101] chore: add nosec for false positive token error code --- internal/api/apierrors/errorcode.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/apierrors/errorcode.go b/internal/api/apierrors/errorcode.go index f6613ad3f..717ac427d 100644 --- a/internal/api/apierrors/errorcode.go +++ b/internal/api/apierrors/errorcode.go @@ -62,7 +62,7 @@ const ( ErrorCodeSSOProviderNotFound ErrorCode = "sso_provider_not_found" ErrorCodeSSOProviderDisabled ErrorCode = "sso_provider_disabled" ErrorCodeSCIMDisabled ErrorCode = "scim_disabled" - ErrorCodeSCIMTokenInvalid ErrorCode = "scim_token_invalid" + ErrorCodeSCIMTokenInvalid ErrorCode = "scim_token_invalid" //#nosec G101 -- Not a secret value. ErrorCodeSCIMUserNotFound ErrorCode = "scim_user_not_found" ErrorCodeSCIMUserAlreadyExists ErrorCode = "scim_user_already_exists" ErrorCodeSCIMGroupNotFound ErrorCode = "scim_group_not_found" From b278e1d242b2aab2c417022d2b08eb8795f676e1 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Thu, 5 Feb 2026 22:12:27 +0300 Subject: [PATCH 038/101] fix: use sendSCIMJSON for SCIM error responses --- internal/api/errors.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/api/errors.go b/internal/api/errors.go index e2b9d6295..17011262e 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -190,8 +190,7 @@ func HandleResponseError(err error, w http.ResponseWriter, r *http.Request) { case *apierrors.SCIMHTTPError: log.WithError(e.Cause()).Info(e.Error()) - w.Header().Set("Content-Type", "application/scim+json") - if jsonErr := sendJSON(w, e.HTTPStatus, e); jsonErr != nil && jsonErr != context.DeadlineExceeded { + if jsonErr := sendSCIMJSON(w, e.HTTPStatus, e); jsonErr != nil && jsonErr != context.DeadlineExceeded { log.WithError(jsonErr).Warn("Failed to send JSON on ResponseWriter") } From 68d75d8e24984b4bd71fc7f310a4ebc5275e074a Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Thu, 5 Feb 2026 22:13:30 +0300 Subject: [PATCH 039/101] fix: use schema-qualified table names in SCIM queries --- internal/models/scim_group.go | 40 ++++++++++++++++++++--------------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index 0105b3d99..72da4b0e5 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -4,11 +4,20 @@ import ( "database/sql" "time" + "github.com/gobuffalo/pop/v6" "github.com/gofrs/uuid" "github.com/pkg/errors" "github.com/supabase/auth/internal/storage" ) +func scimGroupTableName() string { + return (&pop.Model{Value: SCIMGroup{}}).TableName() +} + +func scimGroupMemberTableName() string { + return (&pop.Model{Value: SCIMGroupMember{}}).TableName() +} + type SCIMGroup struct { ID uuid.UUID `db:"id" json:"id"` SSOProviderID uuid.UUID `db:"sso_provider_id" json:"-"` @@ -97,12 +106,12 @@ func FindSCIMGroupsBySSOProviderWithFilter(tx *storage.Connection, ssoProviderID } var totalResults int - countQuery := "SELECT COUNT(*) FROM scim_groups WHERE " + whereClause + countQuery := "SELECT COUNT(*) FROM " + scimGroupTableName() + " WHERE " + whereClause if err := tx.RawQuery(countQuery, args...).First(&totalResults); err != nil { return nil, 0, errors.Wrap(err, "error counting SCIM groups") } - query := "SELECT * FROM scim_groups WHERE " + whereClause + " ORDER BY created_at ASC LIMIT ? OFFSET ?" + query := "SELECT * FROM " + scimGroupTableName() + " WHERE " + whereClause + " ORDER BY created_at ASC LIMIT ? OFFSET ?" args = append(args, count, offset) if err := tx.RawQuery(query, args...).All(&groups); err != nil { if errors.Cause(err) == sql.ErrNoRows { @@ -115,12 +124,10 @@ func FindSCIMGroupsBySSOProviderWithFilter(tx *storage.Connection, ssoProviderID func FindSCIMGroupsForUser(tx *storage.Connection, userID uuid.UUID) ([]*SCIMGroup, error) { groups := []*SCIMGroup{} - if err := tx.RawQuery(` - SELECT g.* FROM scim_groups g - INNER JOIN scim_group_members m ON g.id = m.group_id - WHERE m.user_id = ? - ORDER BY g.display_name ASC - `, userID).All(&groups); err != nil { + if err := tx.RawQuery( + "SELECT g.* FROM "+scimGroupTableName()+" g INNER JOIN "+scimGroupMemberTableName()+" m ON g.id = m.group_id WHERE m.user_id = ? ORDER BY g.display_name ASC", + userID, + ).All(&groups); err != nil { if errors.Cause(err) == sql.ErrNoRows { return []*SCIMGroup{}, nil } @@ -140,7 +147,7 @@ func (g *SCIMGroup) AddMember(tx *storage.Connection, userID uuid.UUID) error { } return tx.RawQuery( - "INSERT INTO scim_group_members (group_id, user_id, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", + "INSERT INTO "+scimGroupMemberTableName()+" (group_id, user_id, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", g.ID, userID, time.Now(), ).Exec() } @@ -158,19 +165,18 @@ func UserBelongsToSSOProvider(user *User, ssoProviderID uuid.UUID) bool { func (g *SCIMGroup) RemoveMember(tx *storage.Connection, userID uuid.UUID) error { return tx.RawQuery( - "DELETE FROM scim_group_members WHERE group_id = ? AND user_id = ?", + "DELETE FROM "+scimGroupMemberTableName()+" WHERE group_id = ? AND user_id = ?", g.ID, userID, ).Exec() } func (g *SCIMGroup) GetMembers(tx *storage.Connection) ([]*User, error) { users := []*User{} - if err := tx.RawQuery(` - SELECT u.* FROM users u - INNER JOIN scim_group_members m ON u.id = m.user_id - WHERE m.group_id = ? - ORDER BY u.email ASC - `, g.ID).All(&users); err != nil { + userTable := (&pop.Model{Value: User{}}).TableName() + if err := tx.RawQuery( + "SELECT u.* FROM "+userTable+" u INNER JOIN "+scimGroupMemberTableName()+" m ON u.id = m.user_id WHERE m.group_id = ? ORDER BY u.email ASC", + g.ID, + ).All(&users); err != nil { if errors.Cause(err) == sql.ErrNoRows { return []*User{}, nil } @@ -180,7 +186,7 @@ func (g *SCIMGroup) GetMembers(tx *storage.Connection) ([]*User, error) { } func (g *SCIMGroup) SetMembers(tx *storage.Connection, userIDs []uuid.UUID) error { - if err := tx.RawQuery("DELETE FROM scim_group_members WHERE group_id = ?", g.ID).Exec(); err != nil { + if err := tx.RawQuery("DELETE FROM "+scimGroupMemberTableName()+" WHERE group_id = ?", g.ID).Exec(); err != nil { return errors.Wrap(err, "error clearing SCIM group members") } From 161eafa60f74ced6b1cbc700860f3d057dfe9145 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Thu, 5 Feb 2026 22:14:42 +0300 Subject: [PATCH 040/101] fix: scimReplaceUser now validates, updates email, and fully replaces metadata --- internal/api/scim.go | 67 +++++++++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 1c6680bd9..4b788d9dc 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -256,6 +256,29 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { if err := a.parseSCIMBody(r, ¶ms); err != nil { return err } + if err := params.Validate(); err != nil { + return err + } + + // Extract primary email from params + var email string + if len(params.Emails) > 0 { + for _, e := range params.Emails { + if bool(e.Primary) { + email = e.Value + break + } + } + if email == "" { + email = params.Emails[0].Value + } + } + if email != "" { + email, err = a.validateEmail(email) + if err != nil { + return apierrors.NewSCIMBadRequestError("Invalid email address", "invalidValue") + } + } var user *models.User terr := db.Transaction(func(tx *storage.Connection) error { @@ -272,20 +295,20 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMNotFoundError("User not found") } + // PUT is a full replacement — replace metadata entirely instead of merging + metadata := make(map[string]interface{}) if params.Name != nil { - if user.UserMetaData == nil { - user.UserMetaData = make(map[string]interface{}) - } if params.Name.GivenName != "" { - user.UserMetaData["given_name"] = params.Name.GivenName + metadata["given_name"] = params.Name.GivenName } if params.Name.FamilyName != "" { - user.UserMetaData["family_name"] = params.Name.FamilyName + metadata["family_name"] = params.Name.FamilyName } if params.Name.Formatted != "" { - user.UserMetaData["full_name"] = params.Name.Formatted + metadata["full_name"] = params.Name.Formatted } } + user.UserMetaData = metadata if params.Active != nil { if *params.Active { @@ -302,23 +325,33 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { } } + // Update email if provided + if email != "" && email != user.GetEmail() { + if err := user.SetEmail(tx, email); err != nil { + return apierrors.NewInternalServerError("Error updating user email").WithInternalError(err) + } + } + if err := tx.UpdateOnly(user, "raw_user_meta_data"); err != nil { return apierrors.NewInternalServerError("Error updating user").WithInternalError(err) } - if params.UserName != "" { - providerType := "sso:" + provider.ID.String() - for i := range user.Identities { - if user.Identities[i].Provider == providerType { - if user.Identities[i].IdentityData == nil { - user.Identities[i].IdentityData = make(map[string]interface{}) - } + providerType := "sso:" + provider.ID.String() + for i := range user.Identities { + if user.Identities[i].Provider == providerType { + if user.Identities[i].IdentityData == nil { + user.Identities[i].IdentityData = make(map[string]interface{}) + } + if params.UserName != "" { user.Identities[i].IdentityData["user_name"] = params.UserName - if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { - return apierrors.NewInternalServerError("Error updating identity").WithInternalError(err) - } - break } + if email != "" { + user.Identities[i].IdentityData["email"] = email + } + if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { + return apierrors.NewInternalServerError("Error updating identity").WithInternalError(err) + } + break } } From 3b43ce4eb208e8b52d824c676ade1f6ca5a14348 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Thu, 5 Feb 2026 22:15:13 +0300 Subject: [PATCH 041/101] fix: handle count=0 in SCIM pagination per RFC 7644 --- internal/api/scim_helpers.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go index 0190ba0e9..46d91b378 100644 --- a/internal/api/scim_helpers.go +++ b/internal/api/scim_helpers.go @@ -24,7 +24,7 @@ func parseSCIMPagination(r *http.Request) (startIndex, count int) { } if v := r.URL.Query().Get("count"); v != "" { - if i, err := strconv.Atoi(v); err == nil && i > 0 { + if i, err := strconv.Atoi(v); err == nil && i >= 0 { count = i if count > SCIMMaxPageSize { count = SCIMMaxPageSize From d64ac5713977d1f3cf40ffd5ceb468c373eb9603 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Thu, 5 Feb 2026 22:15:40 +0300 Subject: [PATCH 042/101] fix: make SCIM user delete idempotent --- internal/api/scim.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 4b788d9dc..797ce8c87 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -640,8 +640,9 @@ func (a *API) scimDeleteUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMNotFoundError("User not found") } + // Already deprovisioned — return success for idempotent delete if user.IsBanned() && user.BannedReason != nil && *user.BannedReason == scimDeprovisionedReason { - return apierrors.NewSCIMNotFoundError("User not found") + return nil } if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { From fca20d310af0343bbc261292a955f760109dd6c7 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Thu, 5 Feb 2026 22:16:33 +0300 Subject: [PATCH 043/101] fix: sanitize JSON errors and use API_EXTERNAL_URL for SCIM base URL --- internal/api/scim_helpers.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go index 46d91b378..042c68f37 100644 --- a/internal/api/scim_helpers.go +++ b/internal/api/scim_helpers.go @@ -2,7 +2,6 @@ package api import ( "encoding/json" - "fmt" "net/http" "strconv" "strings" @@ -41,7 +40,7 @@ func (a *API) parseSCIMBody(r *http.Request, v interface{}) error { return apierrors.NewInternalServerError("Could not read request body").WithInternalError(err) } if err := json.Unmarshal(body, v); err != nil { - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid JSON: %v", err), "invalidSyntax") + return apierrors.NewSCIMBadRequestError("Invalid JSON in request body", "invalidSyntax").WithInternalError(err) } return nil } @@ -145,7 +144,7 @@ func (a *API) groupToSCIMResponse(group *models.SCIMGroup, members []*models.Use } func (a *API) getSCIMBaseURL() string { - return a.config.SiteURL + return strings.TrimRight(a.config.API.ExternalURL, "/") } func sendSCIMJSON(w http.ResponseWriter, status int, obj interface{}) error { From 076683d6f81782e9a4ab93f9f90b66c831115a64 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Thu, 5 Feb 2026 22:17:21 +0300 Subject: [PATCH 044/101] fix: use FlexBool for SCIM user Active field --- internal/api/scim.go | 2 +- internal/api/scim_types.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 797ce8c87..4f78731c8 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -311,7 +311,7 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { user.UserMetaData = metadata if params.Active != nil { - if *params.Active { + if bool(*params.Active) { if err := user.Ban(tx, 0, nil); err != nil { return apierrors.NewInternalServerError("Error unbanning user").WithInternalError(err) } diff --git a/internal/api/scim_types.go b/internal/api/scim_types.go index 95a06de37..57f468a80 100644 --- a/internal/api/scim_types.go +++ b/internal/api/scim_types.go @@ -44,7 +44,7 @@ type SCIMUserParams struct { UserName string `json:"userName"` Name *SCIMName `json:"name,omitempty"` Emails []SCIMEmail `json:"emails,omitempty"` - Active *bool `json:"active,omitempty"` + Active *FlexBool `json:"active,omitempty"` } func (p *SCIMUserParams) Validate() error { From d91aeb5290a421e66230b81e33326f150334089a Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Thu, 5 Feb 2026 22:18:21 +0300 Subject: [PATCH 045/101] fix: add ESCAPE clause to LIKE filters and use SCIM error types consistently --- internal/api/scim.go | 6 +++--- internal/api/scim_filter.go | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 4f78731c8..6f4802050 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -61,7 +61,7 @@ func (a *API) scimListUsers(w http.ResponseWriter, r *http.Request) error { users, totalResults, err := models.FindUsersByProviderWithFilter(db, providerType, filterClause, startIndex, count) if err != nil { - return apierrors.NewInternalServerError("Error fetching users").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error fetching users").WithInternalError(err) } resources := make([]interface{}, len(users)) @@ -686,7 +686,7 @@ func (a *API) scimListGroups(w http.ResponseWriter, r *http.Request) error { groups, totalResults, err := models.FindSCIMGroupsBySSOProviderWithFilter(db, provider.ID, filterClause, startIndex, count) if err != nil { - return apierrors.NewInternalServerError("Error fetching groups").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error fetching groups").WithInternalError(err) } excludeMembers := strings.Contains(strings.ToLower(r.URL.Query().Get("excludedAttributes")), "members") @@ -698,7 +698,7 @@ func (a *API) scimListGroups(w http.ResponseWriter, r *http.Request) error { var err error members, err = group.GetMembers(db) if err != nil { - return apierrors.NewInternalServerError("Error fetching group members").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error fetching group members").WithInternalError(err) } } resources[i] = a.groupToSCIMResponse(group, members) diff --git a/internal/api/scim_filter.go b/internal/api/scim_filter.go index cc5077e70..02560ab5d 100644 --- a/internal/api/scim_filter.go +++ b/internal/api/scim_filter.go @@ -82,7 +82,7 @@ func attrExprToSQL(e filter.AttributeExpression, allowedAttrs map[string]string) return nil, apierrors.NewSCIMBadRequestError("'co' operator requires a string value", "invalidValue") } return &models.SCIMFilterClause{ - Where: fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE LOWER(?)", dbColumn), + Where: fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE LOWER(?) ESCAPE '\\'", dbColumn), Args: []interface{}{"%" + escapeLikePattern(val) + "%"}, }, nil @@ -92,7 +92,7 @@ func attrExprToSQL(e filter.AttributeExpression, allowedAttrs map[string]string) return nil, apierrors.NewSCIMBadRequestError("'sw' operator requires a string value", "invalidValue") } return &models.SCIMFilterClause{ - Where: fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE LOWER(?)", dbColumn), + Where: fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE LOWER(?) ESCAPE '\\'", dbColumn), Args: []interface{}{escapeLikePattern(val) + "%"}, }, nil @@ -102,7 +102,7 @@ func attrExprToSQL(e filter.AttributeExpression, allowedAttrs map[string]string) return nil, apierrors.NewSCIMBadRequestError("'ew' operator requires a string value", "invalidValue") } return &models.SCIMFilterClause{ - Where: fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE LOWER(?)", dbColumn), + Where: fmt.Sprintf("LOWER(CAST(%s AS TEXT)) LIKE LOWER(?) ESCAPE '\\'", dbColumn), Args: []interface{}{"%" + escapeLikePattern(val)}, }, nil From 75480a8207b1e92b2f5e814a249116760fef2a9a Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Thu, 5 Feb 2026 22:18:50 +0300 Subject: [PATCH 046/101] chore: remove unused SCIM error code constants --- internal/api/apierrors/errorcode.go | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/internal/api/apierrors/errorcode.go b/internal/api/apierrors/errorcode.go index 717ac427d..10fdbc167 100644 --- a/internal/api/apierrors/errorcode.go +++ b/internal/api/apierrors/errorcode.go @@ -61,15 +61,7 @@ const ( ErrorCodeUserAlreadyExists ErrorCode = "user_already_exists" ErrorCodeSSOProviderNotFound ErrorCode = "sso_provider_not_found" ErrorCodeSSOProviderDisabled ErrorCode = "sso_provider_disabled" - ErrorCodeSCIMDisabled ErrorCode = "scim_disabled" - ErrorCodeSCIMTokenInvalid ErrorCode = "scim_token_invalid" //#nosec G101 -- Not a secret value. - ErrorCodeSCIMUserNotFound ErrorCode = "scim_user_not_found" - ErrorCodeSCIMUserAlreadyExists ErrorCode = "scim_user_already_exists" - ErrorCodeSCIMGroupNotFound ErrorCode = "scim_group_not_found" - ErrorCodeSCIMGroupAlreadyExists ErrorCode = "scim_group_already_exists" - ErrorCodeSCIMInvalidFilter ErrorCode = "scim_invalid_filter" - ErrorCodeSCIMInvalidSchema ErrorCode = "scim_invalid_schema" - ErrorCodeSCIMMutuallyExclusive ErrorCode = "scim_mutually_exclusive" + ErrorCodeSCIMDisabled ErrorCode = "scim_disabled" ErrorCodeSAMLMetadataFetchFailed ErrorCode = "saml_metadata_fetch_failed" ErrorCodeSAMLIdPAlreadyExists ErrorCode = "saml_idp_already_exists" ErrorCodeSSODomainAlreadyExists ErrorCode = "sso_domain_already_exists" From 6024d8d2f93934115c9565d4d361fb36ab94833d Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Thu, 5 Feb 2026 22:20:04 +0300 Subject: [PATCH 047/101] fix: optimize SCIM token lookup and add partial index --- internal/models/sso.go | 6 +++++- migrations/20251210100000_add_scim_to_sso_providers.up.sql | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/internal/models/sso.go b/internal/models/sso.go index 3aa975e0c..216fcf62b 100644 --- a/internal/models/sso.go +++ b/internal/models/sso.go @@ -303,7 +303,7 @@ func FindAllSSOProviders(tx *storage.Connection) ([]SSOProvider, error) { func FindSSOProviderBySCIMToken(ctx context.Context, tx *storage.Connection, token string) (*SSOProvider, error) { var providers []SSOProvider - if err := tx.Eager().Q().Where("scim_enabled = ? AND scim_bearer_token_hash IS NOT NULL", true).All(&providers); err != nil { + if err := tx.Q().Where("scim_enabled = ? AND scim_bearer_token_hash IS NOT NULL", true).All(&providers); err != nil { if errors.Cause(err) == sql.ErrNoRows { return nil, SSOProviderNotFoundError{} } @@ -312,6 +312,10 @@ func FindSSOProviderBySCIMToken(ctx context.Context, tx *storage.Connection, tok for i := range providers { if providers[i].VerifySCIMToken(ctx, token) { + // Reload with associations now that we found the match + if err := tx.Eager().Find(&providers[i], providers[i].ID); err != nil { + return nil, errors.Wrap(err, "error loading SSO provider") + } return &providers[i], nil } } diff --git a/migrations/20251210100000_add_scim_to_sso_providers.up.sql b/migrations/20251210100000_add_scim_to_sso_providers.up.sql index a9f90195a..a5e98c3fa 100644 --- a/migrations/20251210100000_add_scim_to_sso_providers.up.sql +++ b/migrations/20251210100000_add_scim_to_sso_providers.up.sql @@ -7,4 +7,9 @@ do $$ begin end $$; comment on column {{ index .Options "Namespace" }}.sso_providers.scim_enabled is 'Auth: Whether SCIM provisioning is enabled for this SSO provider'; -comment on column {{ index .Options "Namespace" }}.sso_providers.scim_bearer_token_hash is 'Auth: Bcrypt hash of the SCIM bearer token used by the IdP'; +comment on column {{ index .Options "Namespace" }}.sso_providers.scim_bearer_token_hash is 'Auth: Hash of the SCIM bearer token used by the IdP'; + +-- Partial index for SCIM token lookup (only SCIM-enabled providers with a token) +create index if not exists sso_providers_scim_enabled_idx + on {{ index .Options "Namespace" }}.sso_providers (id) + where scim_enabled = true and scim_bearer_token_hash is not null; From c6afbc883f18150061d63e6a1cc1914c361c5566 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Thu, 5 Feb 2026 22:20:39 +0300 Subject: [PATCH 048/101] fix: batch user loading in SetMembers to avoid N+1 queries --- internal/models/scim_group.go | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index 72da4b0e5..89362940f 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -190,18 +190,22 @@ func (g *SCIMGroup) SetMembers(tx *storage.Connection, userIDs []uuid.UUID) erro return errors.Wrap(err, "error clearing SCIM group members") } - for _, userID := range userIDs { - if err := g.AddMember(tx, userID); err != nil { - if IsNotFoundError(err) { - // Skip non-existent users silently per SCIM best practice - continue - } - // Skip users that don't belong to this provider silently - if _, ok := err.(UserNotInSSOProviderError); ok { - continue - } - return errors.Wrap(err, "error adding SCIM group member") - } + if len(userIDs) == 0 { + return nil + } + + identityTable := (&pop.Model{Value: Identity{}}).TableName() + userTable := (&pop.Model{Value: User{}}).TableName() + + if err := tx.RawQuery( + "INSERT INTO "+scimGroupMemberTableName()+" (group_id, user_id, created_at) "+ + "SELECT ?, u.id, ? FROM "+userTable+" u "+ + "INNER JOIN "+identityTable+" i ON i.user_id = u.id "+ + "WHERE u.id IN (?) AND i.provider = ? "+ + "ON CONFLICT DO NOTHING", + g.ID, time.Now(), userIDs, "sso:"+g.SSOProviderID.String(), + ).Exec(); err != nil { + return errors.Wrap(err, "error setting SCIM group members") } return nil } From 13cd61575c535dd659b61cdfa001e8296ba28271 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Thu, 5 Feb 2026 22:34:15 +0300 Subject: [PATCH 049/101] fix: use API_EXTERNAL_URL for SCIM base URL in admin endpoints --- internal/api/ssoadmin.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/api/ssoadmin.go b/internal/api/ssoadmin.go index a29cef68b..710847d99 100644 --- a/internal/api/ssoadmin.go +++ b/internal/api/ssoadmin.go @@ -475,7 +475,7 @@ func (a *API) adminSSOProviderGetSCIM(w http.ResponseWriter, r *http.Request) er return sendJSON(w, http.StatusOK, map[string]interface{}{ "enabled": provider.IsSCIMEnabled(), "token_set": provider.SCIMBearerTokenHash != nil, - "base_url": a.config.SiteURL + "/scim/v2", + "base_url": a.getSCIMBaseURL() + "/scim/v2", }) } @@ -500,7 +500,7 @@ func (a *API) adminSSOProviderEnableSCIM(w http.ResponseWriter, r *http.Request) return sendJSON(w, http.StatusOK, map[string]interface{}{ "enabled": true, "token": token, - "base_url": a.config.SiteURL + "/scim/v2", + "base_url": a.getSCIMBaseURL() + "/scim/v2", }) } @@ -548,6 +548,6 @@ func (a *API) adminSSOProviderRotateSCIMToken(w http.ResponseWriter, r *http.Req return sendJSON(w, http.StatusOK, map[string]interface{}{ "enabled": true, "token": token, - "base_url": a.config.SiteURL + "/scim/v2", + "base_url": a.getSCIMBaseURL() + "/scim/v2", }) } From 58a14f726745d2871593b11df069f39ccd80a067 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Thu, 5 Feb 2026 22:36:02 +0300 Subject: [PATCH 050/101] chore: add SCIM PUT replace and cross-provider isolation tests --- internal/api/scim_test.go | 110 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index 0a0caaace..faeeeb897 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -2048,3 +2048,113 @@ func (ts *SCIMTestSuite) TestSCIMErrorGroupSchemaValidationMissingDisplayName() require.True(ts.T(), ok, "SCIM error should have scimType field") require.Equal(ts.T(), "invalidSyntax", scimType) } + +func (ts *SCIMTestSuite) TestSCIMReplaceUser() { + user := ts.createSCIMUserWithName(testUser9.UserName, testUser9.Email, testUser9.GivenName, testUser9.FamilyName) + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaUser}, + "userName": "replaced@acme.com", + "name": map[string]interface{}{ + "givenName": "Replaced", + "familyName": "Name", + "formatted": "Replaced Name", + }, + "emails": []map[string]interface{}{ + {"value": "replaced@acme.com", "primary": true, "type": "work"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPut, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code, w.Body.String()) + + var result SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), "replaced@acme.com", result.UserName) + require.NotNil(ts.T(), result.Name) + require.Equal(ts.T(), "Replaced", result.Name.GivenName) + require.Equal(ts.T(), "Name", result.Name.FamilyName) +} + +func (ts *SCIMTestSuite) TestSCIMReplaceUserNotFound() { + req := ts.makeSCIMRequest(http.MethodPut, "/scim/v2/Users/00000000-0000-0000-0000-000000000000", map[string]interface{}{ + "schemas": []string{SCIMSchemaUser}, + "userName": "nobody@acme.com", + "emails": []map[string]interface{}{{"value": "nobody@acme.com", "primary": true}}, + }) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMReplaceGroup() { + group := ts.createSCIMGroupWithExternalID(testGroup1.DisplayName, testGroup1.ExternalID) + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaGroup}, + "displayName": "Replaced Engineering", + "externalId": "replaced-ext-001", + } + + req := ts.makeSCIMRequest(http.MethodPut, "/scim/v2/Groups/"+group.ID, body) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code, w.Body.String()) + + var result SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), "Replaced Engineering", result.DisplayName) + require.Equal(ts.T(), "replaced-ext-001", result.ExternalID) +} + +func (ts *SCIMTestSuite) TestSCIMReplaceGroupNotFound() { + req := ts.makeSCIMRequest(http.MethodPut, "/scim/v2/Groups/00000000-0000-0000-0000-000000000000", map[string]interface{}{ + "schemas": []string{SCIMSchemaGroup}, + "displayName": "Ghost", + }) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMCrossProviderIsolationUsers() { + user := ts.createSCIMUser(testUser1.UserName, testUser1.Email) + + provider2 := &models.SSOProvider{} + require.NoError(ts.T(), ts.API.db.Create(provider2)) + token2 := "other-provider-token" + require.NoError(ts.T(), provider2.SetSCIMToken(context.Background(), token2)) + require.NoError(ts.T(), ts.API.db.Update(provider2)) + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users/"+user.ID, nil) + req.Header.Set("Authorization", "Bearer "+token2) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMCrossProviderIsolationGroups() { + group := ts.createSCIMGroup(testGroup1.DisplayName) + + provider2 := &models.SSOProvider{} + require.NoError(ts.T(), ts.API.db.Create(provider2)) + token2 := "other-provider-token" + require.NoError(ts.T(), provider2.SetSCIMToken(context.Background(), token2)) + require.NoError(ts.T(), ts.API.db.Update(provider2)) + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups/"+group.ID, nil) + req.Header.Set("Authorization", "Bearer "+token2) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMErrorResponseContentType() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users/not-a-uuid", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusNotFound, w.Code) + require.Equal(ts.T(), "application/scim+json", w.Header().Get("Content-Type")) +} From 313c57c6af1acd13fe0508471df61fa2d92c9209 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Thu, 5 Feb 2026 23:17:00 +0300 Subject: [PATCH 051/101] fix: add request size limits and validation for SCIM endpoints --- internal/api/apierrors/apierrors.go | 4 ++++ internal/api/scim.go | 15 +++++++++++++++ internal/api/scim_helpers.go | 4 ++++ internal/api/scim_test.go | 2 +- internal/api/scim_types.go | 16 ++++++++++++++++ 5 files changed, 40 insertions(+), 1 deletion(-) diff --git a/internal/api/apierrors/apierrors.go b/internal/api/apierrors/apierrors.go index a6011718b..110f97a6b 100644 --- a/internal/api/apierrors/apierrors.go +++ b/internal/api/apierrors/apierrors.go @@ -164,6 +164,10 @@ func NewSCIMForbiddenError(detail string) *SCIMHTTPError { return NewSCIMHTTPError(http.StatusForbidden, detail, "") } +func NewSCIMRequestTooLargeError(detail string) *SCIMHTTPError { + return NewSCIMHTTPError(http.StatusRequestEntityTooLarge, detail, "") +} + func NewSCIMInternalServerError(detail string) *SCIMHTTPError { return NewSCIMHTTPError(http.StatusInternalServerError, detail, "") } diff --git a/internal/api/scim.go b/internal/api/scim.go index 6f4802050..53f961b3a 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -391,6 +391,9 @@ func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { if err := a.parseSCIMBody(r, ¶ms); err != nil { return err } + if err := params.Validate(); err != nil { + return err + } var user *models.User terr := db.Transaction(func(tx *storage.Connection) error { @@ -811,6 +814,9 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { if err := a.parseSCIMBody(r, ¶ms); err != nil { return err } + if err := params.Validate(); err != nil { + return err + } var group *models.SCIMGroup terr := db.Transaction(func(tx *storage.Connection) error { @@ -878,6 +884,9 @@ func (a *API) scimPatchGroup(w http.ResponseWriter, r *http.Request) error { if err := a.parseSCIMBody(r, ¶ms); err != nil { return err } + if err := params.Validate(); err != nil { + return err + } var group *models.SCIMGroup terr := db.Transaction(func(tx *storage.Connection) error { @@ -935,6 +944,9 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou if !ok { return apierrors.NewSCIMBadRequestError("members must be an array", "invalidValue") } + if len(members) > SCIMMaxMembers { + return apierrors.NewSCIMRequestTooLargeError(fmt.Sprintf("Maximum %d members per operation", SCIMMaxMembers)) + } for _, m := range members { memberMap, ok := m.(map[string]interface{}) if !ok { @@ -993,6 +1005,9 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou if !ok { return apierrors.NewSCIMBadRequestError("members must be an array", "invalidValue") } + if len(members) > SCIMMaxMembers { + return apierrors.NewSCIMRequestTooLargeError(fmt.Sprintf("Maximum %d members per operation", SCIMMaxMembers)) + } memberIDs := make([]uuid.UUID, 0, len(members)) for _, m := range members { memberMap, ok := m.(map[string]interface{}) diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go index 042c68f37..cf798570f 100644 --- a/internal/api/scim_helpers.go +++ b/internal/api/scim_helpers.go @@ -35,8 +35,12 @@ func parseSCIMPagination(r *http.Request) (startIndex, count int) { } func (a *API) parseSCIMBody(r *http.Request, v interface{}) error { + r.Body = http.MaxBytesReader(nil, r.Body, SCIMMaxBodySize) body, err := utilities.GetBodyBytes(r) if err != nil { + if err.Error() == "http: request body too large" { + return apierrors.NewSCIMRequestTooLargeError("Request body exceeds maximum size of 1MB") + } return apierrors.NewInternalServerError("Could not read request body").WithInternalError(err) } if err := json.Unmarshal(body, v); err != nil { diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index faeeeb897..14950af45 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -1916,7 +1916,7 @@ func (ts *SCIMTestSuite) TestSCIMErrorInvalidPatchMissingOperations() { ts.API.handler.ServeHTTP(w, req) - require.Equal(ts.T(), http.StatusOK, w.Code) + ts.assertSCIMError(w, http.StatusBadRequest) } func (ts *SCIMTestSuite) TestSCIMErrorInvalidJSON() { diff --git a/internal/api/scim_types.go b/internal/api/scim_types.go index 57f468a80..9fde2b587 100644 --- a/internal/api/scim_types.go +++ b/internal/api/scim_types.go @@ -12,6 +12,9 @@ import ( const ( SCIMDefaultPageSize = 100 SCIMMaxPageSize = 1000 + SCIMMaxBodySize = 1 << 20 // 1 MB + SCIMMaxMembers = 1000 + SCIMMaxPatchOperations = 100 SCIMSchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User" SCIMSchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group" SCIMSchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse" @@ -77,6 +80,9 @@ func (p *SCIMGroupParams) Validate() error { if p.DisplayName == "" { return apierrors.NewSCIMBadRequestError("displayName is required", "invalidSyntax") } + if len(p.Members) > SCIMMaxMembers { + return apierrors.NewSCIMRequestTooLargeError(fmt.Sprintf("Maximum %d members per request", SCIMMaxMembers)) + } return nil } @@ -91,6 +97,16 @@ type SCIMPatchRequest struct { Operations []SCIMPatchOperation `json:"Operations"` } +func (p *SCIMPatchRequest) Validate() error { + if len(p.Operations) == 0 { + return apierrors.NewSCIMBadRequestError("At least one operation is required", "invalidSyntax") + } + if len(p.Operations) > SCIMMaxPatchOperations { + return apierrors.NewSCIMRequestTooLargeError(fmt.Sprintf("Maximum %d operations per request", SCIMMaxPatchOperations)) + } + return nil +} + type SCIMPatchOperation struct { Op string `json:"op"` Path string `json:"path,omitempty"` From 4fe20ccd6734b098d6c41c159f07935d091f9c1d Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Thu, 5 Feb 2026 23:26:50 +0300 Subject: [PATCH 052/101] fix: use SHA-256 instead of bcrypt for SCIM token lookup --- internal/api/scim.go | 2 +- internal/api/scim_test.go | 11 +++-- internal/api/ssoadmin.go | 8 +--- internal/models/sso.go | 44 +++++++------------ ...210100000_add_scim_to_sso_providers.up.sql | 10 ++--- 5 files changed, 30 insertions(+), 45 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 53f961b3a..ec245acfa 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -25,7 +25,7 @@ func (a *API) requireSCIMAuthentication(w http.ResponseWriter, r *http.Request) return nil, apierrors.NewSCIMUnauthorizedError("Invalid or missing SCIM bearer token") } - provider, err := models.FindSSOProviderBySCIMToken(ctx, db, token) + provider, err := models.FindSSOProviderBySCIMToken(db, token) if err != nil { if models.IsNotFoundError(err) { return nil, apierrors.NewSCIMUnauthorizedError("Invalid SCIM bearer token") diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index 14950af45..d91afba40 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -2,7 +2,6 @@ package api import ( "bytes" - "context" "encoding/json" "fmt" "net/http" @@ -84,7 +83,7 @@ func (ts *SCIMTestSuite) SetupTest() { func (ts *SCIMTestSuite) createSSOProviderWithSCIM() *models.SSOProvider { provider := &models.SSOProvider{} require.NoError(ts.T(), ts.API.db.Create(provider)) - require.NoError(ts.T(), provider.SetSCIMToken(context.Background(), ts.SCIMToken)) + provider.SetSCIMToken(ts.SCIMToken) require.NoError(ts.T(), ts.API.db.Update(provider)) require.NoError(ts.T(), ts.API.db.Reload(provider)) return provider @@ -502,7 +501,7 @@ func (ts *SCIMTestSuite) TestSCIMDeleteUser() { w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) - ts.assertSCIMError(w, http.StatusNotFound) + require.Equal(ts.T(), http.StatusNoContent, w.Code) } func (ts *SCIMTestSuite) TestSCIMDeleteGroup() { @@ -722,7 +721,7 @@ func (ts *SCIMTestSuite) TestSCIMDeleteUserTwice() { w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) - ts.assertSCIMError(w, http.StatusNotFound) + require.Equal(ts.T(), http.StatusNoContent, w.Code) } func (ts *SCIMTestSuite) TestSCIMFilterUserByUserNameExisting() { @@ -2125,7 +2124,7 @@ func (ts *SCIMTestSuite) TestSCIMCrossProviderIsolationUsers() { provider2 := &models.SSOProvider{} require.NoError(ts.T(), ts.API.db.Create(provider2)) token2 := "other-provider-token" - require.NoError(ts.T(), provider2.SetSCIMToken(context.Background(), token2)) + provider2.SetSCIMToken(token2) require.NoError(ts.T(), ts.API.db.Update(provider2)) req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users/"+user.ID, nil) @@ -2141,7 +2140,7 @@ func (ts *SCIMTestSuite) TestSCIMCrossProviderIsolationGroups() { provider2 := &models.SSOProvider{} require.NoError(ts.T(), ts.API.db.Create(provider2)) token2 := "other-provider-token" - require.NoError(ts.T(), provider2.SetSCIMToken(context.Background(), token2)) + provider2.SetSCIMToken(token2) require.NoError(ts.T(), ts.API.db.Update(provider2)) req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups/"+group.ID, nil) diff --git a/internal/api/ssoadmin.go b/internal/api/ssoadmin.go index 710847d99..3af02790a 100644 --- a/internal/api/ssoadmin.go +++ b/internal/api/ssoadmin.go @@ -489,9 +489,7 @@ func (a *API) adminSSOProviderEnableSCIM(w http.ResponseWriter, r *http.Request) token := generateSCIMToken() if err := db.Transaction(func(tx *storage.Connection) error { - if err := provider.SetSCIMToken(ctx, token); err != nil { - return apierrors.NewInternalServerError("Error generating SCIM token").WithInternalError(err) - } + provider.SetSCIMToken(token) return tx.UpdateOnly(provider, "scim_enabled", "scim_bearer_token_hash") }); err != nil { return err @@ -537,9 +535,7 @@ func (a *API) adminSSOProviderRotateSCIMToken(w http.ResponseWriter, r *http.Req token := generateSCIMToken() if err := db.Transaction(func(tx *storage.Connection) error { - if err := provider.SetSCIMToken(ctx, token); err != nil { - return apierrors.NewInternalServerError("Error generating SCIM token").WithInternalError(err) - } + provider.SetSCIMToken(token) return tx.UpdateOnly(provider, "scim_bearer_token_hash") }); err != nil { return err diff --git a/internal/models/sso.go b/internal/models/sso.go index 216fcf62b..a57c5fe7a 100644 --- a/internal/models/sso.go +++ b/internal/models/sso.go @@ -1,10 +1,12 @@ package models import ( - "context" + "crypto/sha256" + "crypto/subtle" "database/sql" "database/sql/driver" "encoding/json" + "fmt" "net/url" "reflect" "strings" @@ -14,7 +16,6 @@ import ( "github.com/crewjam/saml/samlsp" "github.com/gofrs/uuid" "github.com/pkg/errors" - "github.com/supabase/auth/internal/crypto" "github.com/supabase/auth/internal/storage" ) @@ -48,23 +49,22 @@ func (p SSOProvider) IsSCIMEnabled() bool { return p.SCIMEnabled != nil && *p.SCIMEnabled } -func (p *SSOProvider) SetSCIMToken(ctx context.Context, token string) error { - hash, err := crypto.GenerateFromPassword(ctx, token) - if err != nil { - return err - } +func scimTokenHash(token string) string { + return fmt.Sprintf("%x", sha256.Sum256([]byte(token))) +} + +func (p *SSOProvider) SetSCIMToken(token string) { + hash := scimTokenHash(token) p.SCIMBearerTokenHash = &hash enabled := true p.SCIMEnabled = &enabled - return nil } -func (p *SSOProvider) VerifySCIMToken(ctx context.Context, token string) bool { +func (p *SSOProvider) VerifySCIMToken(token string) bool { if p.SCIMBearerTokenHash == nil { return false } - err := crypto.CompareHashAndPassword(ctx, *p.SCIMBearerTokenHash, token) - return err == nil + return subtle.ConstantTimeCompare([]byte(*p.SCIMBearerTokenHash), []byte(scimTokenHash(token))) == 1 } func (p *SSOProvider) ClearSCIMToken() { @@ -300,27 +300,17 @@ func FindAllSSOProviders(tx *storage.Connection) ([]SSOProvider, error) { return providers, nil } -func FindSSOProviderBySCIMToken(ctx context.Context, tx *storage.Connection, token string) (*SSOProvider, error) { - var providers []SSOProvider +func FindSSOProviderBySCIMToken(tx *storage.Connection, token string) (*SSOProvider, error) { + hash := scimTokenHash(token) - if err := tx.Q().Where("scim_enabled = ? AND scim_bearer_token_hash IS NOT NULL", true).All(&providers); err != nil { + var provider SSOProvider + if err := tx.Eager().Q().Where("scim_enabled = ? AND scim_bearer_token_hash = ?", true, hash).First(&provider); err != nil { if errors.Cause(err) == sql.ErrNoRows { return nil, SSOProviderNotFoundError{} } - return nil, errors.Wrap(err, "error finding SCIM-enabled SSO providers") - } - - for i := range providers { - if providers[i].VerifySCIMToken(ctx, token) { - // Reload with associations now that we found the match - if err := tx.Eager().Find(&providers[i], providers[i].ID); err != nil { - return nil, errors.Wrap(err, "error loading SSO provider") - } - return &providers[i], nil - } + return nil, errors.Wrap(err, "error finding SSO provider by SCIM token") } - - return nil, SSOProviderNotFoundError{} + return &provider, nil } const ( diff --git a/migrations/20251210100000_add_scim_to_sso_providers.up.sql b/migrations/20251210100000_add_scim_to_sso_providers.up.sql index a5e98c3fa..2c487525e 100644 --- a/migrations/20251210100000_add_scim_to_sso_providers.up.sql +++ b/migrations/20251210100000_add_scim_to_sso_providers.up.sql @@ -7,9 +7,9 @@ do $$ begin end $$; comment on column {{ index .Options "Namespace" }}.sso_providers.scim_enabled is 'Auth: Whether SCIM provisioning is enabled for this SSO provider'; -comment on column {{ index .Options "Namespace" }}.sso_providers.scim_bearer_token_hash is 'Auth: Hash of the SCIM bearer token used by the IdP'; +comment on column {{ index .Options "Namespace" }}.sso_providers.scim_bearer_token_hash is 'Auth: SHA-256 hash of the SCIM bearer token used by the IdP'; --- Partial index for SCIM token lookup (only SCIM-enabled providers with a token) -create index if not exists sso_providers_scim_enabled_idx - on {{ index .Options "Namespace" }}.sso_providers (id) - where scim_enabled = true and scim_bearer_token_hash is not null; +-- Index for direct SCIM token hash lookup +create unique index if not exists sso_providers_scim_token_hash_idx + on {{ index .Options "Namespace" }}.sso_providers (scim_bearer_token_hash) + where scim_bearer_token_hash is not null; From 7241f9d5651e9850ab35ff5f793c8fd49d16c5da Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 09:22:56 +0300 Subject: [PATCH 053/101] fix: harden SCIM cross-provider isolation, error handling, and batch queries --- internal/api/scim.go | 237 ++++++++++++++++++++++------------ internal/api/scim_helpers.go | 10 +- internal/models/scim_group.go | 57 +++++++- 3 files changed, 214 insertions(+), 90 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index ec245acfa..baffd8f6f 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -66,7 +66,7 @@ func (a *API) scimListUsers(w http.ResponseWriter, r *http.Request) error { resources := make([]interface{}, len(users)) for i, user := range users { - resources[i] = a.userToSCIMResponse(user) + resources[i] = a.userToSCIMResponse(user, providerType) } return sendSCIMJSON(w, http.StatusOK, &SCIMListResponse{ @@ -93,14 +93,14 @@ func (a *API) scimGetUser(w http.ResponseWriter, r *http.Request) error { if models.IsNotFoundError(err) { return apierrors.NewSCIMNotFoundError("User not found") } - return apierrors.NewInternalServerError("Error fetching user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error fetching user").WithInternalError(err) } if !userBelongsToProvider(user, provider.ID) { return apierrors.NewSCIMNotFoundError("User not found") } - return sendSCIMJSON(w, http.StatusOK, a.userToSCIMResponse(user)) + return sendSCIMJSON(w, http.StatusOK, a.userToSCIMResponse(user, "sso:"+provider.ID.String())) } func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { @@ -121,7 +121,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { var emailType string if len(params.Emails) > 0 { for _, e := range params.Emails { - if bool(e.Primary) { + if e.Primary { email = e.Value emailType = e.Type break @@ -148,20 +148,49 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { terr := db.Transaction(func(tx *storage.Connection) error { existingUser, err := models.FindUserByEmailAndAudience(tx, email, config.JWT.Aud) if err != nil && !models.IsNotFoundError(err) { - return apierrors.NewInternalServerError("Error checking existing user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error checking existing user").WithInternalError(err) } if existingUser != nil { if existingUser.BannedReason != nil && *existingUser.BannedReason == scimDeprovisionedReason { + if !userBelongsToProvider(existingUser, provider.ID) { + return apierrors.NewSCIMConflictError("User with this email already exists", "uniqueness") + } if err := existingUser.Ban(tx, 0, nil); err != nil { - return apierrors.NewInternalServerError("Error reactivating user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error reactivating user").WithInternalError(err) + } + + if params.Name != nil { + metadata := make(map[string]interface{}) + if params.Name.GivenName != "" { + metadata["given_name"] = params.Name.GivenName + } + if params.Name.FamilyName != "" { + metadata["family_name"] = params.Name.FamilyName + } + if params.Name.Formatted != "" { + metadata["full_name"] = params.Name.Formatted + } + if len(metadata) > 0 { + existingUser.UserMetaData = metadata + if err := tx.UpdateOnly(existingUser, "raw_user_meta_data"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating user metadata").WithInternalError(err) + } + } + } + + if email != existingUser.GetEmail() { + if err := existingUser.SetEmail(tx, email); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating user email").WithInternalError(err) + } } + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, existingUser, models.UserModifiedAction, "", map[string]interface{}{ "provider": "scim", "sso_provider_id": provider.ID, "action": "reactivated", }); terr != nil { - return apierrors.NewInternalServerError("Error recording audit log entry").WithInternalError(terr) + return apierrors.NewSCIMInternalServerError("Error recording audit log entry").WithInternalError(terr) } user = existingUser return nil @@ -171,7 +200,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { user, err = models.NewUser("", email, "", config.JWT.Aud, nil) if err != nil { - return apierrors.NewInternalServerError("Error creating user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error creating user").WithInternalError(err) } user.IsSSOUser = true @@ -195,7 +224,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { return apierrors.NewSCIMConflictError("User with this email already exists", "uniqueness") } - return apierrors.NewInternalServerError("Error saving user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error saving user").WithInternalError(err) } identityID := params.ExternalID @@ -224,11 +253,11 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { "provider": "scim", "sso_provider_id": provider.ID, }); terr != nil { - return apierrors.NewInternalServerError("Error recording audit log entry").WithInternalError(terr) + return apierrors.NewSCIMInternalServerError("Error recording audit log entry").WithInternalError(terr) } if err := tx.Eager().Find(user, user.ID); err != nil { - return apierrors.NewInternalServerError("Error reloading user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error reloading user").WithInternalError(err) } return nil @@ -238,7 +267,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { return terr } - return sendSCIMJSON(w, http.StatusCreated, a.userToSCIMResponse(user)) + return sendSCIMJSON(w, http.StatusCreated, a.userToSCIMResponse(user, providerType)) } func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { @@ -264,7 +293,7 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { var email string if len(params.Emails) > 0 { for _, e := range params.Emails { - if bool(e.Primary) { + if e.Primary { email = e.Value break } @@ -288,7 +317,7 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { if models.IsNotFoundError(err) { return apierrors.NewSCIMNotFoundError("User not found") } - return apierrors.NewInternalServerError("Error fetching user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error fetching user").WithInternalError(err) } if !userBelongsToProvider(user, provider.ID) { @@ -311,16 +340,16 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { user.UserMetaData = metadata if params.Active != nil { - if bool(*params.Active) { + if *params.Active { if err := user.Ban(tx, 0, nil); err != nil { - return apierrors.NewInternalServerError("Error unbanning user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error unbanning user").WithInternalError(err) } } else { if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { - return apierrors.NewInternalServerError("Error banning user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error banning user").WithInternalError(err) } if err := models.Logout(tx, user.ID); err != nil { - return apierrors.NewInternalServerError("Error invalidating sessions").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error invalidating sessions").WithInternalError(err) } } } @@ -328,12 +357,12 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { // Update email if provided if email != "" && email != user.GetEmail() { if err := user.SetEmail(tx, email); err != nil { - return apierrors.NewInternalServerError("Error updating user email").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error updating user email").WithInternalError(err) } } if err := tx.UpdateOnly(user, "raw_user_meta_data"); err != nil { - return apierrors.NewInternalServerError("Error updating user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error updating user").WithInternalError(err) } providerType := "sso:" + provider.ID.String() @@ -348,8 +377,15 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { if email != "" { user.Identities[i].IdentityData["email"] = email } - if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { - return apierrors.NewInternalServerError("Error updating identity").WithInternalError(err) + if params.ExternalID != "" { + user.Identities[i].ProviderID = params.ExternalID + user.Identities[i].IdentityData["external_id"] = params.ExternalID + } else { + user.Identities[i].ProviderID = "" + delete(user.Identities[i].IdentityData, "external_id") + } + if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } break } @@ -359,11 +395,11 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { "provider": "scim", "sso_provider_id": provider.ID, }); terr != nil { - return apierrors.NewInternalServerError("Error recording audit log entry").WithInternalError(terr) + return apierrors.NewSCIMInternalServerError("Error recording audit log entry").WithInternalError(terr) } if err := tx.Eager().Find(user, user.ID); err != nil { - return apierrors.NewInternalServerError("Error reloading user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error reloading user").WithInternalError(err) } return nil @@ -373,7 +409,7 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { return terr } - return sendSCIMJSON(w, http.StatusOK, a.userToSCIMResponse(user)) + return sendSCIMJSON(w, http.StatusOK, a.userToSCIMResponse(user, "sso:"+provider.ID.String())) } func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { @@ -403,7 +439,7 @@ func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { if models.IsNotFoundError(err) { return apierrors.NewSCIMNotFoundError("User not found") } - return apierrors.NewInternalServerError("Error fetching user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error fetching user").WithInternalError(err) } if !userBelongsToProvider(user, provider.ID) { @@ -417,14 +453,14 @@ func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { } if err := tx.Eager().Find(user, user.ID); err != nil { - return apierrors.NewInternalServerError("Error reloading user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error reloading user").WithInternalError(err) } if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserModifiedAction, "", map[string]interface{}{ "provider": "scim", "sso_provider_id": provider.ID, }); terr != nil { - return apierrors.NewInternalServerError("Error recording audit log entry").WithInternalError(terr) + return apierrors.NewSCIMInternalServerError("Error recording audit log entry").WithInternalError(terr) } return nil @@ -434,7 +470,7 @@ func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { return terr } - return sendSCIMJSON(w, http.StatusOK, a.userToSCIMResponse(user)) + return sendSCIMJSON(w, http.StatusOK, a.userToSCIMResponse(user, "sso:"+provider.ID.String())) } func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op SCIMPatchOperation, providerID uuid.UUID) error { @@ -451,7 +487,7 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S delete(user.Identities[i].IdentityData, "external_id") } if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { - return apierrors.NewInternalServerError("Error updating identity").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } break } @@ -471,7 +507,7 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } user.Identities[i].IdentityData["external_id"] = externalID if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { - return apierrors.NewInternalServerError("Error updating identity").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } break } @@ -488,15 +524,15 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } if active { if err := user.Ban(tx, 0, nil); err != nil { - return apierrors.NewInternalServerError("Error unbanning user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error unbanning user").WithInternalError(err) } return nil } if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { - return apierrors.NewInternalServerError("Error banning user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error banning user").WithInternalError(err) } if err := models.Logout(tx, user.ID); err != nil { - return apierrors.NewInternalServerError("Error invalidating sessions").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error invalidating sessions").WithInternalError(err) } return nil case "username": @@ -511,7 +547,7 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } user.Identities[i].IdentityData["user_name"] = userName if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { - return apierrors.NewInternalServerError("Error updating identity").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } break } @@ -528,7 +564,7 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } user.Email = storage.NullString(validatedEmail) if err := tx.UpdateOnly(user, "email"); err != nil { - return apierrors.NewInternalServerError("Error updating email").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error updating email").WithInternalError(err) } return nil case "": @@ -541,7 +577,7 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } user.Identities[i].IdentityData["user_name"] = userName if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { - return apierrors.NewInternalServerError("Error updating identity").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } break } @@ -566,7 +602,7 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } if metadataUpdated { if err := tx.UpdateOnly(user, "raw_user_meta_data"); err != nil { - return apierrors.NewInternalServerError("Error updating user metadata").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error updating user metadata").WithInternalError(err) } } @@ -579,7 +615,7 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } user.Identities[i].IdentityData["external_id"] = externalID if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { - return apierrors.NewInternalServerError("Error updating identity").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } break } @@ -593,21 +629,21 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } user.Email = storage.NullString(validatedEmail) if err := tx.UpdateOnly(user, "email"); err != nil { - return apierrors.NewInternalServerError("Error updating email").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error updating email").WithInternalError(err) } } if active, ok := valueMap["active"].(bool); ok { if active { if err := user.Ban(tx, 0, nil); err != nil { - return apierrors.NewInternalServerError("Error unbanning user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error unbanning user").WithInternalError(err) } } else { if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { - return apierrors.NewInternalServerError("Error banning user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error banning user").WithInternalError(err) } if err := models.Logout(tx, user.ID); err != nil { - return apierrors.NewInternalServerError("Error invalidating sessions").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error invalidating sessions").WithInternalError(err) } } } @@ -636,7 +672,7 @@ func (a *API) scimDeleteUser(w http.ResponseWriter, r *http.Request) error { if models.IsNotFoundError(err) { return apierrors.NewSCIMNotFoundError("User not found") } - return apierrors.NewInternalServerError("Error fetching user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error fetching user").WithInternalError(err) } if !userBelongsToProvider(user, provider.ID) { @@ -645,22 +681,29 @@ func (a *API) scimDeleteUser(w http.ResponseWriter, r *http.Request) error { // Already deprovisioned — return success for idempotent delete if user.IsBanned() && user.BannedReason != nil && *user.BannedReason == scimDeprovisionedReason { + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserDeletedAction, "", map[string]interface{}{ + "provider": "scim", + "sso_provider_id": provider.ID, + "action": "idempotent_delete", + }); terr != nil { + return apierrors.NewSCIMInternalServerError("Error recording audit log entry").WithInternalError(terr) + } return nil } if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { - return apierrors.NewInternalServerError("Error deprovisioning user").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error deprovisioning user").WithInternalError(err) } if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserDeletedAction, "", map[string]interface{}{ "provider": "scim", "sso_provider_id": provider.ID, }); terr != nil { - return apierrors.NewInternalServerError("Error recording audit log entry").WithInternalError(terr) + return apierrors.NewSCIMInternalServerError("Error recording audit log entry").WithInternalError(terr) } if err := models.Logout(tx, user.ID); err != nil { - return apierrors.NewInternalServerError("Error invalidating sessions").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error invalidating sessions").WithInternalError(err) } return nil }) @@ -694,15 +737,24 @@ func (a *API) scimListGroups(w http.ResponseWriter, r *http.Request) error { excludeMembers := strings.Contains(strings.ToLower(r.URL.Query().Get("excludedAttributes")), "members") + var membersByGroup map[uuid.UUID][]*models.User + if !excludeMembers && len(groups) > 0 { + groupIDs := make([]uuid.UUID, len(groups)) + for i, g := range groups { + groupIDs[i] = g.ID + } + var err error + membersByGroup, err = models.GetMembersForGroups(db, groupIDs) + if err != nil { + return apierrors.NewSCIMInternalServerError("Error fetching group members").WithInternalError(err) + } + } + resources := make([]interface{}, len(groups)) for i, group := range groups { var members []*models.User if !excludeMembers { - var err error - members, err = group.GetMembers(db) - if err != nil { - return apierrors.NewSCIMInternalServerError("Error fetching group members").WithInternalError(err) - } + members = membersByGroup[group.ID] } resources[i] = a.groupToSCIMResponse(group, members) } @@ -731,16 +783,21 @@ func (a *API) scimGetGroup(w http.ResponseWriter, r *http.Request) error { if models.IsNotFoundError(err) { return apierrors.NewSCIMNotFoundError("Group not found") } - return apierrors.NewInternalServerError("Error fetching group").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error fetching group").WithInternalError(err) } if group.SSOProviderID != provider.ID { return apierrors.NewSCIMNotFoundError("Group not found") } - members, err := group.GetMembers(db) - if err != nil { - return apierrors.NewInternalServerError("Error fetching group members").WithInternalError(err) + excludeMembers := strings.Contains(strings.ToLower(r.URL.Query().Get("excludedAttributes")), "members") + + var members []*models.User + if !excludeMembers { + members, err = group.GetMembers(db) + if err != nil { + return apierrors.NewSCIMInternalServerError("Error fetching group members").WithInternalError(err) + } } return sendSCIMJSON(w, http.StatusOK, a.groupToSCIMResponse(group, members)) @@ -767,21 +824,27 @@ func (a *API) scimCreateGroup(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMConflictError("Group with this externalId already exists", "uniqueness") } if err != nil && !models.IsNotFoundError(err) { - return apierrors.NewInternalServerError("Error checking existing group").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error checking existing group").WithInternalError(err) } } group = models.NewSCIMGroup(provider.ID, params.ExternalID, params.DisplayName) if err := tx.Create(group); err != nil { - return apierrors.NewInternalServerError("Error creating group").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error creating group").WithInternalError(err) } for _, member := range params.Members { memberID, err := uuid.FromString(member.Value) if err != nil { - continue + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", member.Value), "invalidValue") } if err := group.AddMember(tx, memberID); err != nil { + if models.IsNotFoundError(err) { + return apierrors.NewSCIMNotFoundError(fmt.Sprintf("User %s not found", member.Value)) + } + if _, ok := err.(models.UserNotInSSOProviderError); ok { + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("User %s does not belong to this SSO provider", member.Value), "invalidValue") + } return apierrors.NewSCIMInternalServerError("Error adding group member").WithInternalError(err) } } @@ -795,7 +858,7 @@ func (a *API) scimCreateGroup(w http.ResponseWriter, r *http.Request) error { members, err := group.GetMembers(db) if err != nil { - return apierrors.NewInternalServerError("Error fetching group members").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error fetching group members").WithInternalError(err) } return sendSCIMJSON(w, http.StatusCreated, a.groupToSCIMResponse(group, members)) } @@ -826,7 +889,7 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { if models.IsNotFoundError(err) { return apierrors.NewSCIMNotFoundError("Group not found") } - return apierrors.NewInternalServerError("Error fetching group").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error fetching group").WithInternalError(err) } if group.SSOProviderID != provider.ID { @@ -836,17 +899,19 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { group.DisplayName = params.DisplayName if params.ExternalID != "" { group.ExternalID = storage.NullString(params.ExternalID) + } else { + group.ExternalID = storage.NullString("") } if err := tx.Update(group); err != nil { - return apierrors.NewInternalServerError("Error updating group").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error updating group").WithInternalError(err) } memberIDs := make([]uuid.UUID, 0, len(params.Members)) for _, member := range params.Members { memberID, err := uuid.FromString(member.Value) if err != nil { - continue + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", member.Value), "invalidValue") } memberIDs = append(memberIDs, memberID) } @@ -860,12 +925,12 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { group, err = models.FindSCIMGroupByID(db, groupID) if err != nil { - return apierrors.NewInternalServerError("Error reloading group").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error reloading group").WithInternalError(err) } members, err := group.GetMembers(db) if err != nil { - return apierrors.NewInternalServerError("Error fetching group members").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error fetching group members").WithInternalError(err) } return sendSCIMJSON(w, http.StatusOK, a.groupToSCIMResponse(group, members)) } @@ -896,7 +961,7 @@ func (a *API) scimPatchGroup(w http.ResponseWriter, r *http.Request) error { if models.IsNotFoundError(err) { return apierrors.NewSCIMNotFoundError("Group not found") } - return apierrors.NewInternalServerError("Error fetching group").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error fetching group").WithInternalError(err) } if group.SSOProviderID != provider.ID { @@ -918,12 +983,12 @@ func (a *API) scimPatchGroup(w http.ResponseWriter, r *http.Request) error { group, err = models.FindSCIMGroupByID(db, groupID) if err != nil { - return apierrors.NewInternalServerError("Error reloading group").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error reloading group").WithInternalError(err) } members, err := group.GetMembers(db) if err != nil { - return apierrors.NewInternalServerError("Error fetching group members").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error fetching group members").WithInternalError(err) } return sendSCIMJSON(w, http.StatusOK, a.groupToSCIMResponse(group, members)) } @@ -950,17 +1015,23 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou for _, m := range members { memberMap, ok := m.(map[string]interface{}) if !ok { - continue + return apierrors.NewSCIMBadRequestError("Invalid member format", "invalidValue") } value, ok := memberMap["value"].(string) if !ok { - continue + return apierrors.NewSCIMBadRequestError("Member value must be a string", "invalidValue") } memberID, err := uuid.FromString(value) if err != nil { - continue + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", value), "invalidValue") } if err := group.AddMember(tx, memberID); err != nil { + if models.IsNotFoundError(err) { + return apierrors.NewSCIMNotFoundError(fmt.Sprintf("User %s not found", value)) + } + if _, ok := err.(models.UserNotInSSOProviderError); ok { + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("User %s does not belong to this SSO provider", value), "invalidValue") + } return apierrors.NewSCIMInternalServerError("Error adding group member").WithInternalError(err) } } @@ -974,15 +1045,17 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou if strings.HasPrefix(strings.ToLower(op.Path), "members") && strings.Contains(op.Path, "[") { start := strings.Index(op.Path, "\"") end := strings.LastIndex(op.Path, "\"") - if start != -1 && end != -1 && start < end { - value := op.Path[start+1 : end] - memberID, err := uuid.FromString(value) - if err != nil { - return apierrors.NewSCIMBadRequestError("Invalid member ID in path", "invalidValue") - } - return group.RemoveMember(tx, memberID) + if start == -1 || end == -1 || start >= end { + return apierrors.NewSCIMBadRequestError("Invalid member filter path syntax", "invalidPath") + } + value := op.Path[start+1 : end] + memberID, err := uuid.FromString(value) + if err != nil { + return apierrors.NewSCIMBadRequestError("Invalid member ID in path", "invalidValue") } + return group.RemoveMember(tx, memberID) } + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported remove path: %s", op.Path), "invalidPath") } case "replace": switch strings.ToLower(op.Path) { @@ -1012,15 +1085,15 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou for _, m := range members { memberMap, ok := m.(map[string]interface{}) if !ok { - continue + return apierrors.NewSCIMBadRequestError("Invalid member format", "invalidValue") } value, ok := memberMap["value"].(string) if !ok { - continue + return apierrors.NewSCIMBadRequestError("Member value must be a string", "invalidValue") } memberID, err := uuid.FromString(value) if err != nil { - continue + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", value), "invalidValue") } memberIDs = append(memberIDs, memberID) } @@ -1063,7 +1136,7 @@ func (a *API) scimDeleteGroup(w http.ResponseWriter, r *http.Request) error { if models.IsNotFoundError(err) { return apierrors.NewSCIMNotFoundError("Group not found") } - return apierrors.NewInternalServerError("Error fetching group").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error fetching group").WithInternalError(err) } if group.SSOProviderID != provider.ID { diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go index cf798570f..500404249 100644 --- a/internal/api/scim_helpers.go +++ b/internal/api/scim_helpers.go @@ -2,6 +2,7 @@ package api import ( "encoding/json" + "errors" "net/http" "strconv" "strings" @@ -38,10 +39,11 @@ func (a *API) parseSCIMBody(r *http.Request, v interface{}) error { r.Body = http.MaxBytesReader(nil, r.Body, SCIMMaxBodySize) body, err := utilities.GetBodyBytes(r) if err != nil { - if err.Error() == "http: request body too large" { + var maxBytesErr *http.MaxBytesError + if errors.As(err, &maxBytesErr) { return apierrors.NewSCIMRequestTooLargeError("Request body exceeds maximum size of 1MB") } - return apierrors.NewInternalServerError("Could not read request body").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Could not read request body").WithInternalError(err) } if err := json.Unmarshal(body, v); err != nil { return apierrors.NewSCIMBadRequestError("Invalid JSON in request body", "invalidSyntax").WithInternalError(err) @@ -53,7 +55,7 @@ func userBelongsToProvider(user *models.User, providerID uuid.UUID) bool { return models.UserBelongsToSSOProvider(user, providerID) } -func (a *API) userToSCIMResponse(user *models.User) *SCIMUserResponse { +func (a *API) userToSCIMResponse(user *models.User, providerType string) *SCIMUserResponse { baseURL := a.getSCIMBaseURL() resp := &SCIMUserResponse{ Schemas: []string{SCIMSchemaUser}, @@ -70,7 +72,7 @@ func (a *API) userToSCIMResponse(user *models.User) *SCIMUserResponse { var emailType string for _, identity := range user.Identities { - if strings.HasPrefix(identity.Provider, "sso:") { + if identity.Provider == providerType { if identity.ProviderID != "" { resp.ExternalID = identity.ProviderID } diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index 89362940f..1e13f0054 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -2,6 +2,7 @@ package models import ( "database/sql" + "strings" "time" "github.com/gobuffalo/pop/v6" @@ -23,8 +24,8 @@ type SCIMGroup struct { SSOProviderID uuid.UUID `db:"sso_provider_id" json:"-"` ExternalID storage.NullString `db:"external_id" json:"external_id,omitempty"` DisplayName string `db:"display_name" json:"display_name"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` SSOProvider *SSOProvider `belongs_to:"sso_providers" json:"-"` Members []User `many_to_many:"scim_group_members" json:"members,omitempty"` @@ -197,16 +198,64 @@ func (g *SCIMGroup) SetMembers(tx *storage.Connection, userIDs []uuid.UUID) erro identityTable := (&pop.Model{Value: Identity{}}).TableName() userTable := (&pop.Model{Value: User{}}).TableName() + placeholders := make([]string, len(userIDs)) + args := []interface{}{g.ID, time.Now()} + for i, id := range userIDs { + placeholders[i] = "?" + args = append(args, id) + } + args = append(args, "sso:"+g.SSOProviderID.String()) + if err := tx.RawQuery( "INSERT INTO "+scimGroupMemberTableName()+" (group_id, user_id, created_at) "+ "SELECT ?, u.id, ? FROM "+userTable+" u "+ "INNER JOIN "+identityTable+" i ON i.user_id = u.id "+ - "WHERE u.id IN (?) AND i.provider = ? "+ + "WHERE u.id IN ("+strings.Join(placeholders, ",")+") AND i.provider = ? "+ "ON CONFLICT DO NOTHING", - g.ID, time.Now(), userIDs, "sso:"+g.SSOProviderID.String(), + args..., ).Exec(); err != nil { return errors.Wrap(err, "error setting SCIM group members") } return nil } +func GetMembersForGroups(tx *storage.Connection, groupIDs []uuid.UUID) (map[uuid.UUID][]*User, error) { + result := make(map[uuid.UUID][]*User) + if len(groupIDs) == 0 { + return result, nil + } + + userTable := (&pop.Model{Value: User{}}).TableName() + + type memberRow struct { + GroupID uuid.UUID `db:"group_id"` + User + } + + placeholders := make([]string, len(groupIDs)) + args := make([]interface{}, len(groupIDs)) + for i, id := range groupIDs { + placeholders[i] = "?" + args[i] = id + } + + rows := []memberRow{} + if err := tx.RawQuery( + "SELECT m.group_id, u.* FROM "+userTable+" u "+ + "INNER JOIN "+scimGroupMemberTableName()+" m ON u.id = m.user_id "+ + "WHERE m.group_id IN ("+strings.Join(placeholders, ",")+") "+ + "ORDER BY u.email ASC", + args..., + ).All(&rows); err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return result, nil + } + return nil, errors.Wrap(err, "error batch loading SCIM group members") + } + + for i := range rows { + user := rows[i].User + result[rows[i].GroupID] = append(result[rows[i].GroupID], &user) + } + return result, nil +} From c0482dd0891820c6034fda9afd96db35e740c6a7 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 09:51:32 +0300 Subject: [PATCH 054/101] refactor: route all SCIM PATCH paths through filter.ParsePath --- internal/api/scim.go | 421 +++++++++++++++++++++-------------- internal/api/scim_helpers.go | 5 - 2 files changed, 253 insertions(+), 173 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index baffd8f6f..418289b44 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -10,6 +10,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/gofrs/uuid" + filter "github.com/scim2/filter-parser/v2" "github.com/supabase/auth/internal/api/apierrors" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/storage" @@ -96,7 +97,7 @@ func (a *API) scimGetUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMInternalServerError("Error fetching user").WithInternalError(err) } - if !userBelongsToProvider(user, provider.ID) { + if !models.UserBelongsToSSOProvider(user, provider.ID) { return apierrors.NewSCIMNotFoundError("User not found") } @@ -153,7 +154,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { if existingUser != nil { if existingUser.BannedReason != nil && *existingUser.BannedReason == scimDeprovisionedReason { - if !userBelongsToProvider(existingUser, provider.ID) { + if !models.UserBelongsToSSOProvider(existingUser, provider.ID) { return apierrors.NewSCIMConflictError("User with this email already exists", "uniqueness") } if err := existingUser.Ban(tx, 0, nil); err != nil { @@ -320,7 +321,7 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMInternalServerError("Error fetching user").WithInternalError(err) } - if !userBelongsToProvider(user, provider.ID) { + if !models.UserBelongsToSSOProvider(user, provider.ID) { return apierrors.NewSCIMNotFoundError("User not found") } @@ -442,7 +443,7 @@ func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMInternalServerError("Error fetching user").WithInternalError(err) } - if !userBelongsToProvider(user, provider.ID) { + if !models.UserBelongsToSSOProvider(user, provider.ID) { return apierrors.NewSCIMNotFoundError("User not found") } @@ -476,10 +477,22 @@ func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op SCIMPatchOperation, providerID uuid.UUID) error { providerType := "sso:" + providerID.String() + var path *filter.Path + if op.Path != "" { + p, err := filter.ParsePath([]byte(op.Path)) + if err != nil { + return apierrors.NewSCIMBadRequestError( + fmt.Sprintf("Invalid path: %v", err), "invalidPath") + } + path = &p + } + switch strings.ToLower(op.Op) { case "remove": - switch strings.ToLower(op.Path) { - case "externalid": + if path == nil { + return nil + } + if strings.ToLower(path.AttributePath.AttributeName) == "externalid" { for i := range user.Identities { if user.Identities[i].Provider == providerType { user.Identities[i].ProviderID = "" @@ -492,84 +505,122 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S break } } - return nil - default: - return nil } + return nil + case "add": - if valueMap, ok := op.Value.(map[string]interface{}); ok { - if externalID, ok := valueMap["externalId"].(string); ok { + valueMap, ok := op.Value.(map[string]interface{}) + if !ok { + return nil + } + for key, val := range valueMap { + if key == "" { + continue + } + keyPath, err := filter.ParsePath([]byte(key)) + if err != nil { + continue + } + if strings.ToLower(keyPath.AttributePath.AttributeName) == "externalid" { + if externalID, ok := val.(string); ok { + for i := range user.Identities { + if user.Identities[i].Provider == providerType { + user.Identities[i].ProviderID = externalID + if user.Identities[i].IdentityData == nil { + user.Identities[i].IdentityData = make(map[string]interface{}) + } + user.Identities[i].IdentityData["external_id"] = externalID + if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) + } + break + } + } + } + } + } + return nil + + case "replace": + if path != nil { + attrName := strings.ToLower(path.AttributePath.AttributeName) + switch { + case attrName == "active": + active, ok := op.Value.(bool) + if !ok { + return apierrors.NewSCIMBadRequestError("active must be a boolean", "invalidValue") + } + if active { + if err := user.Ban(tx, 0, nil); err != nil { + return apierrors.NewSCIMInternalServerError("Error unbanning user").WithInternalError(err) + } + return nil + } + if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { + return apierrors.NewSCIMInternalServerError("Error banning user").WithInternalError(err) + } + if err := models.Logout(tx, user.ID); err != nil { + return apierrors.NewSCIMInternalServerError("Error invalidating sessions").WithInternalError(err) + } + return nil + case attrName == "username": + userName, ok := op.Value.(string) + if !ok { + return apierrors.NewSCIMBadRequestError("userName must be a string", "invalidValue") + } for i := range user.Identities { if user.Identities[i].Provider == providerType { - user.Identities[i].ProviderID = externalID if user.Identities[i].IdentityData == nil { user.Identities[i].IdentityData = make(map[string]interface{}) } - user.Identities[i].IdentityData["external_id"] = externalID - if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { + user.Identities[i].IdentityData["user_name"] = userName + if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } break } } - } - } - return nil - case "replace": - switch strings.ToLower(op.Path) { - case "active": - active, ok := op.Value.(bool) - if !ok { - return apierrors.NewSCIMBadRequestError("active must be a boolean", "invalidValue") - } - if active { - if err := user.Ban(tx, 0, nil); err != nil { - return apierrors.NewSCIMInternalServerError("Error unbanning user").WithInternalError(err) + return nil + case attrName == "emails" && path.ValueExpression != nil && strings.ToLower(path.SubAttributeName()) == "value": + newEmail, ok := op.Value.(string) + if !ok { + return apierrors.NewSCIMBadRequestError("email value must be a string", "invalidValue") + } + validatedEmail, err := a.validateEmail(newEmail) + if err != nil { + return apierrors.NewSCIMBadRequestError("Invalid email address", "invalidValue") + } + user.Email = storage.NullString(validatedEmail) + if err := tx.UpdateOnly(user, "email"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating email").WithInternalError(err) } return nil } - if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { - return apierrors.NewSCIMInternalServerError("Error banning user").WithInternalError(err) - } - if err := models.Logout(tx, user.ID); err != nil { - return apierrors.NewSCIMInternalServerError("Error invalidating sessions").WithInternalError(err) - } return nil - case "username": - userName, ok := op.Value.(string) - if !ok { - return apierrors.NewSCIMBadRequestError("userName must be a string", "invalidValue") - } - for i := range user.Identities { - if user.Identities[i].Provider == providerType { - if user.Identities[i].IdentityData == nil { - user.Identities[i].IdentityData = make(map[string]interface{}) - } - user.Identities[i].IdentityData["user_name"] = userName - if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { - return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) - } - break - } - } + } + + valueMap, ok := op.Value.(map[string]interface{}) + if !ok { return nil - case "emails[primary eq true].value": - newEmail, ok := op.Value.(string) - if !ok { - return apierrors.NewSCIMBadRequestError("email value must be a string", "invalidValue") + } + if user.UserMetaData == nil { + user.UserMetaData = make(map[string]interface{}) + } + metadataUpdated := false + for key, val := range valueMap { + if key == "" { + continue } - validatedEmail, err := a.validateEmail(newEmail) + keyPath, err := filter.ParsePath([]byte(key)) if err != nil { - return apierrors.NewSCIMBadRequestError("Invalid email address", "invalidValue") - } - user.Email = storage.NullString(validatedEmail) - if err := tx.UpdateOnly(user, "email"); err != nil { - return apierrors.NewSCIMInternalServerError("Error updating email").WithInternalError(err) + continue } - return nil - case "": - if valueMap, ok := op.Value.(map[string]interface{}); ok { - if userName, ok := valueMap["userName"].(string); ok && userName != "" { + attrName := strings.ToLower(keyPath.AttributePath.AttributeName) + subAttr := strings.ToLower(keyPath.AttributePath.SubAttributeName()) + + switch { + case attrName == "username": + if userName, ok := val.(string); ok && userName != "" { for i := range user.Identities { if user.Identities[i].Provider == providerType { if user.Identities[i].IdentityData == nil { @@ -583,30 +634,23 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } } } - - if user.UserMetaData == nil { - user.UserMetaData = make(map[string]interface{}) - } - metadataUpdated := false - if v, ok := valueMap["name.formatted"].(string); ok { + case attrName == "name" && subAttr == "formatted": + if v, ok := val.(string); ok { user.UserMetaData["full_name"] = v metadataUpdated = true } - if v, ok := valueMap["name.familyName"].(string); ok { + case attrName == "name" && subAttr == "familyname": + if v, ok := val.(string); ok { user.UserMetaData["family_name"] = v metadataUpdated = true } - if v, ok := valueMap["name.givenName"].(string); ok { + case attrName == "name" && subAttr == "givenname": + if v, ok := val.(string); ok { user.UserMetaData["given_name"] = v metadataUpdated = true } - if metadataUpdated { - if err := tx.UpdateOnly(user, "raw_user_meta_data"); err != nil { - return apierrors.NewSCIMInternalServerError("Error updating user metadata").WithInternalError(err) - } - } - - if externalID, ok := valueMap["externalId"].(string); ok { + case attrName == "externalid": + if externalID, ok := val.(string); ok { for i := range user.Identities { if user.Identities[i].Provider == providerType { user.Identities[i].ProviderID = externalID @@ -621,8 +665,8 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } } } - - if emailValue, ok := valueMap["emails[primary eq true].value"].(string); ok { + case attrName == "emails" && keyPath.ValueExpression != nil && strings.ToLower(keyPath.SubAttributeName()) == "value": + if emailValue, ok := val.(string); ok { validatedEmail, err := a.validateEmail(emailValue) if err != nil { return apierrors.NewSCIMBadRequestError("Invalid email address", "invalidValue") @@ -632,8 +676,8 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S return apierrors.NewSCIMInternalServerError("Error updating email").WithInternalError(err) } } - - if active, ok := valueMap["active"].(bool); ok { + case attrName == "active": + if active, ok := val.(bool); ok { if active { if err := user.Ban(tx, 0, nil); err != nil { return apierrors.NewSCIMInternalServerError("Error unbanning user").WithInternalError(err) @@ -649,6 +693,12 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } } } + if metadataUpdated { + if err := tx.UpdateOnly(user, "raw_user_meta_data"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating user metadata").WithInternalError(err) + } + } + default: return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported patch operation: %s", op.Op), "invalidSyntax") } @@ -675,7 +725,7 @@ func (a *API) scimDeleteUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMInternalServerError("Error fetching user").WithInternalError(err) } - if !userBelongsToProvider(user, provider.ID) { + if !models.UserBelongsToSSOProvider(user, provider.ID) { return apierrors.NewSCIMNotFoundError("User not found") } @@ -994,126 +1044,161 @@ func (a *API) scimPatchGroup(w http.ResponseWriter, r *http.Request) error { } func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGroup, op SCIMPatchOperation) error { + var path *filter.Path + if op.Path != "" { + p, err := filter.ParsePath([]byte(op.Path)) + if err != nil { + return apierrors.NewSCIMBadRequestError( + fmt.Sprintf("Invalid path: %v", err), "invalidPath") + } + path = &p + } + switch strings.ToLower(op.Op) { case "add": - switch strings.ToLower(op.Path) { - case "externalid": + if path != nil && strings.ToLower(path.AttributePath.AttributeName) == "externalid" { externalID, ok := op.Value.(string) if !ok { return apierrors.NewSCIMBadRequestError("externalId must be a string", "invalidValue") } group.ExternalID = storage.NullString(externalID) return tx.UpdateOnly(group, "external_id") - case "members", "": - members, ok := op.Value.([]interface{}) + } + members, ok := op.Value.([]interface{}) + if !ok { + return apierrors.NewSCIMBadRequestError("members must be an array", "invalidValue") + } + if len(members) > SCIMMaxMembers { + return apierrors.NewSCIMRequestTooLargeError(fmt.Sprintf("Maximum %d members per operation", SCIMMaxMembers)) + } + for _, m := range members { + memberMap, ok := m.(map[string]interface{}) if !ok { - return apierrors.NewSCIMBadRequestError("members must be an array", "invalidValue") + return apierrors.NewSCIMBadRequestError("Invalid member format", "invalidValue") } - if len(members) > SCIMMaxMembers { - return apierrors.NewSCIMRequestTooLargeError(fmt.Sprintf("Maximum %d members per operation", SCIMMaxMembers)) + value, ok := memberMap["value"].(string) + if !ok { + return apierrors.NewSCIMBadRequestError("Member value must be a string", "invalidValue") } - for _, m := range members { - memberMap, ok := m.(map[string]interface{}) - if !ok { - return apierrors.NewSCIMBadRequestError("Invalid member format", "invalidValue") - } - value, ok := memberMap["value"].(string) - if !ok { - return apierrors.NewSCIMBadRequestError("Member value must be a string", "invalidValue") - } - memberID, err := uuid.FromString(value) - if err != nil { - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", value), "invalidValue") + memberID, err := uuid.FromString(value) + if err != nil { + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", value), "invalidValue") + } + if err := group.AddMember(tx, memberID); err != nil { + if models.IsNotFoundError(err) { + return apierrors.NewSCIMNotFoundError(fmt.Sprintf("User %s not found", value)) } - if err := group.AddMember(tx, memberID); err != nil { - if models.IsNotFoundError(err) { - return apierrors.NewSCIMNotFoundError(fmt.Sprintf("User %s not found", value)) - } - if _, ok := err.(models.UserNotInSSOProviderError); ok { - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("User %s does not belong to this SSO provider", value), "invalidValue") - } - return apierrors.NewSCIMInternalServerError("Error adding group member").WithInternalError(err) + if _, ok := err.(models.UserNotInSSOProviderError); ok { + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("User %s does not belong to this SSO provider", value), "invalidValue") } + return apierrors.NewSCIMInternalServerError("Error adding group member").WithInternalError(err) } } + case "remove": - switch strings.ToLower(op.Path) { - case "externalid": - group.ExternalID = storage.NullString("") - return tx.UpdateOnly(group, "external_id") - default: - if strings.HasPrefix(strings.ToLower(op.Path), "members") && strings.Contains(op.Path, "[") { - start := strings.Index(op.Path, "\"") - end := strings.LastIndex(op.Path, "\"") - if start == -1 || end == -1 || start >= end { - return apierrors.NewSCIMBadRequestError("Invalid member filter path syntax", "invalidPath") - } - value := op.Path[start+1 : end] - memberID, err := uuid.FromString(value) - if err != nil { - return apierrors.NewSCIMBadRequestError("Invalid member ID in path", "invalidValue") - } - return group.RemoveMember(tx, memberID) - } - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported remove path: %s", op.Path), "invalidPath") + if path == nil { + return apierrors.NewSCIMBadRequestError("remove operation requires a path", "noTarget") } - case "replace": - switch strings.ToLower(op.Path) { - case "externalid": - externalID, ok := op.Value.(string) - if !ok { - return apierrors.NewSCIMBadRequestError("externalId must be a string", "invalidValue") - } - group.ExternalID = storage.NullString(externalID) + attrName := strings.ToLower(path.AttributePath.AttributeName) + switch { + case attrName == "externalid": + group.ExternalID = storage.NullString("") return tx.UpdateOnly(group, "external_id") - case "displayname": - displayName, ok := op.Value.(string) - if !ok { - return apierrors.NewSCIMBadRequestError("displayName must be a string", "invalidValue") + case attrName == "members" && path.ValueExpression != nil: + attrExpr, ok := path.ValueExpression.(*filter.AttributeExpression) + if !ok || attrExpr.Operator != filter.EQ || strings.ToLower(attrExpr.AttributePath.AttributeName) != "value" { + return apierrors.NewSCIMBadRequestError("Unsupported member filter", "invalidFilter") } - group.DisplayName = displayName - return tx.UpdateOnly(group, "display_name") - case "members": - members, ok := op.Value.([]interface{}) + memberIDStr, ok := attrExpr.CompareValue.(string) if !ok { - return apierrors.NewSCIMBadRequestError("members must be an array", "invalidValue") + return apierrors.NewSCIMBadRequestError("Member filter value must be a string", "invalidValue") } - if len(members) > SCIMMaxMembers { - return apierrors.NewSCIMRequestTooLargeError(fmt.Sprintf("Maximum %d members per operation", SCIMMaxMembers)) + memberID, err := uuid.FromString(memberIDStr) + if err != nil { + return apierrors.NewSCIMBadRequestError("Invalid member ID in path", "invalidValue") } - memberIDs := make([]uuid.UUID, 0, len(members)) - for _, m := range members { - memberMap, ok := m.(map[string]interface{}) + return group.RemoveMember(tx, memberID) + default: + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported remove path: %s", op.Path), "invalidPath") + } + + case "replace": + if path != nil { + attrName := strings.ToLower(path.AttributePath.AttributeName) + switch attrName { + case "externalid": + externalID, ok := op.Value.(string) if !ok { - return apierrors.NewSCIMBadRequestError("Invalid member format", "invalidValue") + return apierrors.NewSCIMBadRequestError("externalId must be a string", "invalidValue") } - value, ok := memberMap["value"].(string) + group.ExternalID = storage.NullString(externalID) + return tx.UpdateOnly(group, "external_id") + case "displayname": + displayName, ok := op.Value.(string) if !ok { - return apierrors.NewSCIMBadRequestError("Member value must be a string", "invalidValue") + return apierrors.NewSCIMBadRequestError("displayName must be a string", "invalidValue") } - memberID, err := uuid.FromString(value) - if err != nil { - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", value), "invalidValue") + group.DisplayName = displayName + return tx.UpdateOnly(group, "display_name") + case "members": + members, ok := op.Value.([]interface{}) + if !ok { + return apierrors.NewSCIMBadRequestError("members must be an array", "invalidValue") + } + if len(members) > SCIMMaxMembers { + return apierrors.NewSCIMRequestTooLargeError(fmt.Sprintf("Maximum %d members per operation", SCIMMaxMembers)) + } + memberIDs := make([]uuid.UUID, 0, len(members)) + for _, m := range members { + memberMap, ok := m.(map[string]interface{}) + if !ok { + return apierrors.NewSCIMBadRequestError("Invalid member format", "invalidValue") + } + value, ok := memberMap["value"].(string) + if !ok { + return apierrors.NewSCIMBadRequestError("Member value must be a string", "invalidValue") + } + memberID, err := uuid.FromString(value) + if err != nil { + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", value), "invalidValue") + } + memberIDs = append(memberIDs, memberID) } - memberIDs = append(memberIDs, memberID) + return group.SetMembers(tx, memberIDs) } - return group.SetMembers(tx, memberIDs) - case "": - if valueMap, ok := op.Value.(map[string]interface{}); ok { - columnsToUpdate := []string{} - if externalID, ok := valueMap["externalId"].(string); ok { + return nil + } + + valueMap, ok := op.Value.(map[string]interface{}) + if !ok { + return nil + } + columnsToUpdate := []string{} + for key, val := range valueMap { + if key == "" { + continue + } + keyPath, err := filter.ParsePath([]byte(key)) + if err != nil { + continue + } + switch strings.ToLower(keyPath.AttributePath.AttributeName) { + case "externalid": + if externalID, ok := val.(string); ok { group.ExternalID = storage.NullString(externalID) columnsToUpdate = append(columnsToUpdate, "external_id") } - if displayName, ok := valueMap["displayName"].(string); ok { + case "displayname": + if displayName, ok := val.(string); ok { group.DisplayName = displayName columnsToUpdate = append(columnsToUpdate, "display_name") } - if len(columnsToUpdate) > 0 { - return tx.UpdateOnly(group, columnsToUpdate...) - } } } + if len(columnsToUpdate) > 0 { + return tx.UpdateOnly(group, columnsToUpdate...) + } + default: return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported patch operation: %s", op.Op), "invalidSyntax") } diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go index 500404249..110ad0998 100644 --- a/internal/api/scim_helpers.go +++ b/internal/api/scim_helpers.go @@ -7,7 +7,6 @@ import ( "strconv" "strings" - "github.com/gofrs/uuid" "github.com/supabase/auth/internal/api/apierrors" "github.com/supabase/auth/internal/models" "github.com/supabase/auth/internal/utilities" @@ -51,10 +50,6 @@ func (a *API) parseSCIMBody(r *http.Request, v interface{}) error { return nil } -func userBelongsToProvider(user *models.User, providerID uuid.UUID) bool { - return models.UserBelongsToSSOProvider(user, providerID) -} - func (a *API) userToSCIMResponse(user *models.User, providerType string) *SCIMUserResponse { baseURL := a.getSCIMBaseURL() resp := &SCIMUserResponse{ From 51282de1724ef9414e52aa3199f1ad36a37b85c2 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 10:56:32 +0300 Subject: [PATCH 055/101] fix: pass ResponseWriter to MaxBytesReader instead of nil --- internal/api/scim.go | 12 ++++++------ internal/api/scim_helpers.go | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 418289b44..3a4ac791d 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -111,7 +111,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { config := a.config var params SCIMUserParams - if err := a.parseSCIMBody(r, ¶ms); err != nil { + if err := a.parseSCIMBody(w, r, ¶ms); err != nil { return err } if err := params.Validate(); err != nil { @@ -283,7 +283,7 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { } var params SCIMUserParams - if err := a.parseSCIMBody(r, ¶ms); err != nil { + if err := a.parseSCIMBody(w, r, ¶ms); err != nil { return err } if err := params.Validate(); err != nil { @@ -425,7 +425,7 @@ func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { } var params SCIMPatchRequest - if err := a.parseSCIMBody(r, ¶ms); err != nil { + if err := a.parseSCIMBody(w, r, ¶ms); err != nil { return err } if err := params.Validate(); err != nil { @@ -859,7 +859,7 @@ func (a *API) scimCreateGroup(w http.ResponseWriter, r *http.Request) error { provider := getSSOProvider(ctx) var params SCIMGroupParams - if err := a.parseSCIMBody(r, ¶ms); err != nil { + if err := a.parseSCIMBody(w, r, ¶ms); err != nil { return err } if err := params.Validate(); err != nil { @@ -924,7 +924,7 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { } var params SCIMGroupParams - if err := a.parseSCIMBody(r, ¶ms); err != nil { + if err := a.parseSCIMBody(w, r, ¶ms); err != nil { return err } if err := params.Validate(); err != nil { @@ -996,7 +996,7 @@ func (a *API) scimPatchGroup(w http.ResponseWriter, r *http.Request) error { } var params SCIMPatchRequest - if err := a.parseSCIMBody(r, ¶ms); err != nil { + if err := a.parseSCIMBody(w, r, ¶ms); err != nil { return err } if err := params.Validate(); err != nil { diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go index 110ad0998..ec0fb7eaa 100644 --- a/internal/api/scim_helpers.go +++ b/internal/api/scim_helpers.go @@ -34,8 +34,8 @@ func parseSCIMPagination(r *http.Request) (startIndex, count int) { return startIndex, count } -func (a *API) parseSCIMBody(r *http.Request, v interface{}) error { - r.Body = http.MaxBytesReader(nil, r.Body, SCIMMaxBodySize) +func (a *API) parseSCIMBody(w http.ResponseWriter, r *http.Request, v interface{}) error { + r.Body = http.MaxBytesReader(w, r.Body, SCIMMaxBodySize) body, err := utilities.GetBodyBytes(r) if err != nil { var maxBytesErr *http.MaxBytesError From fc66e833268dd19cb4eb4ba9b57e83b1babca9ea Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 11:10:54 +0300 Subject: [PATCH 056/101] fix: wrap all raw DB/model errors in SCIMHTTPError types --- internal/api/scim.go | 44 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 3a4ac791d..88fdec8fc 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -966,7 +966,10 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { memberIDs = append(memberIDs, memberID) } - return group.SetMembers(tx, memberIDs) + if err := group.SetMembers(tx, memberIDs); err != nil { + return apierrors.NewSCIMInternalServerError("Error setting group members").WithInternalError(err) + } + return nil }) if terr != nil { @@ -1062,7 +1065,10 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou return apierrors.NewSCIMBadRequestError("externalId must be a string", "invalidValue") } group.ExternalID = storage.NullString(externalID) - return tx.UpdateOnly(group, "external_id") + if err := tx.UpdateOnly(group, "external_id"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating group external ID").WithInternalError(err) + } + return nil } members, ok := op.Value.([]interface{}) if !ok { @@ -1103,7 +1109,10 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou switch { case attrName == "externalid": group.ExternalID = storage.NullString("") - return tx.UpdateOnly(group, "external_id") + if err := tx.UpdateOnly(group, "external_id"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating group external ID").WithInternalError(err) + } + return nil case attrName == "members" && path.ValueExpression != nil: attrExpr, ok := path.ValueExpression.(*filter.AttributeExpression) if !ok || attrExpr.Operator != filter.EQ || strings.ToLower(attrExpr.AttributePath.AttributeName) != "value" { @@ -1117,7 +1126,10 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou if err != nil { return apierrors.NewSCIMBadRequestError("Invalid member ID in path", "invalidValue") } - return group.RemoveMember(tx, memberID) + if err := group.RemoveMember(tx, memberID); err != nil { + return apierrors.NewSCIMInternalServerError("Error removing group member").WithInternalError(err) + } + return nil default: return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported remove path: %s", op.Path), "invalidPath") } @@ -1132,14 +1144,20 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou return apierrors.NewSCIMBadRequestError("externalId must be a string", "invalidValue") } group.ExternalID = storage.NullString(externalID) - return tx.UpdateOnly(group, "external_id") + if err := tx.UpdateOnly(group, "external_id"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating group external ID").WithInternalError(err) + } + return nil case "displayname": displayName, ok := op.Value.(string) if !ok { return apierrors.NewSCIMBadRequestError("displayName must be a string", "invalidValue") } group.DisplayName = displayName - return tx.UpdateOnly(group, "display_name") + if err := tx.UpdateOnly(group, "display_name"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating group display name").WithInternalError(err) + } + return nil case "members": members, ok := op.Value.([]interface{}) if !ok { @@ -1164,7 +1182,10 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou } memberIDs = append(memberIDs, memberID) } - return group.SetMembers(tx, memberIDs) + if err := group.SetMembers(tx, memberIDs); err != nil { + return apierrors.NewSCIMInternalServerError("Error setting group members").WithInternalError(err) + } + return nil } return nil } @@ -1196,7 +1217,9 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou } } if len(columnsToUpdate) > 0 { - return tx.UpdateOnly(group, columnsToUpdate...) + if err := tx.UpdateOnly(group, columnsToUpdate...); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating group").WithInternalError(err) + } } default: @@ -1228,7 +1251,10 @@ func (a *API) scimDeleteGroup(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMNotFoundError("Group not found") } - return tx.Destroy(group) + if err := tx.Destroy(group); err != nil { + return apierrors.NewSCIMInternalServerError("Error deleting group").WithInternalError(err) + } + return nil }) if terr != nil { From 75204ed9a4a1cf165e090c2686614d2bcf4f998b Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 11:18:40 +0300 Subject: [PATCH 057/101] fix: map uniqueness violations to 409 in group patch and replace --- internal/api/scim.go | 30 ++++++++++++++++++++ internal/api/scim_test.go | 58 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) diff --git a/internal/api/scim.go b/internal/api/scim.go index 88fdec8fc..35d00da2a 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -954,6 +954,9 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { } if err := tx.Update(group); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("Group with this displayName already exists", "uniqueness") + } return apierrors.NewSCIMInternalServerError("Error updating group").WithInternalError(err) } @@ -967,6 +970,12 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { } if err := group.SetMembers(tx, memberIDs); err != nil { + if models.IsNotFoundError(err) { + return apierrors.NewSCIMNotFoundError("One or more member IDs not found") + } + if _, ok := err.(models.UserNotInSSOProviderError); ok { + return apierrors.NewSCIMBadRequestError("One or more members do not belong to this SSO provider", "invalidValue") + } return apierrors.NewSCIMInternalServerError("Error setting group members").WithInternalError(err) } return nil @@ -1066,6 +1075,9 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou } group.ExternalID = storage.NullString(externalID) if err := tx.UpdateOnly(group, "external_id"); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("Group with this externalId already exists", "uniqueness") + } return apierrors.NewSCIMInternalServerError("Error updating group external ID").WithInternalError(err) } return nil @@ -1110,6 +1122,9 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou case attrName == "externalid": group.ExternalID = storage.NullString("") if err := tx.UpdateOnly(group, "external_id"); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("Group with this externalId already exists", "uniqueness") + } return apierrors.NewSCIMInternalServerError("Error updating group external ID").WithInternalError(err) } return nil @@ -1145,6 +1160,9 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou } group.ExternalID = storage.NullString(externalID) if err := tx.UpdateOnly(group, "external_id"); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("Group with this externalId already exists", "uniqueness") + } return apierrors.NewSCIMInternalServerError("Error updating group external ID").WithInternalError(err) } return nil @@ -1155,6 +1173,9 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou } group.DisplayName = displayName if err := tx.UpdateOnly(group, "display_name"); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("Group with this displayName already exists", "uniqueness") + } return apierrors.NewSCIMInternalServerError("Error updating group display name").WithInternalError(err) } return nil @@ -1183,6 +1204,12 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou memberIDs = append(memberIDs, memberID) } if err := group.SetMembers(tx, memberIDs); err != nil { + if models.IsNotFoundError(err) { + return apierrors.NewSCIMNotFoundError("One or more member IDs not found") + } + if _, ok := err.(models.UserNotInSSOProviderError); ok { + return apierrors.NewSCIMBadRequestError("One or more members do not belong to this SSO provider", "invalidValue") + } return apierrors.NewSCIMInternalServerError("Error setting group members").WithInternalError(err) } return nil @@ -1218,6 +1245,9 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou } if len(columnsToUpdate) > 0 { if err := tx.UpdateOnly(group, columnsToUpdate...); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("Group already exists with this value", "uniqueness") + } return apierrors.NewSCIMInternalServerError("Error updating group").WithInternalError(err) } } diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index d91afba40..cd5bc2374 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -1732,6 +1732,64 @@ func (ts *SCIMTestSuite) TestSCIMPatchGroupRemoveAllMembers() { require.Empty(ts.T(), getResult.Members) } +func (ts *SCIMTestSuite) TestSCIMPatchGroupDisplayNameConflict() { + // Create two groups with distinct names + _ = ts.createSCIMGroupWithExternalID("FirstGroup", "conflict-ext-1") + secondGroup := ts.createSCIMGroupWithExternalID("SecondGroup", "conflict-ext-2") + + // Try to rename SecondGroup to FirstGroup via PATCH replace with path + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "displayName", "value": "FirstGroup"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+secondGroup.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusConflict, "uniqueness") +} + +func (ts *SCIMTestSuite) TestSCIMPatchGroupDisplayNameConflictValueMap() { + // Create two groups with distinct names + _ = ts.createSCIMGroupWithExternalID("ValueMapFirst", "vm-ext-1") + secondGroup := ts.createSCIMGroupWithExternalID("ValueMapSecond", "vm-ext-2") + + // Try to rename SecondGroup via PATCH replace without path (value map) + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "value": map[string]interface{}{"displayName": "ValueMapFirst"}}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+secondGroup.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusConflict, "uniqueness") +} + +func (ts *SCIMTestSuite) TestSCIMReplaceGroupDisplayNameConflict() { + // Create two groups with distinct names + _ = ts.createSCIMGroupWithExternalID("ReplaceFirst", "replace-ext-1") + secondGroup := ts.createSCIMGroupWithExternalID("ReplaceSecond", "replace-ext-2") + + // Try to replace SecondGroup with FirstGroup's displayName via PUT + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaGroup}, + "displayName": "ReplaceFirst", + } + + req := ts.makeSCIMRequest(http.MethodPut, "/scim/v2/Groups/"+secondGroup.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusConflict, "uniqueness") +} + func (ts *SCIMTestSuite) TestSCIMAuthMissingAuthorizationHeader() { req := httptest.NewRequest(http.MethodGet, "http://localhost/scim/v2/Users", nil) req.Header.Set("Content-Type", "application/scim+json") From c10e61e9286680334c69ed75f15ac360787c0bef Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 11:22:33 +0300 Subject: [PATCH 058/101] fix: validate all member IDs in SetMembers before replacing --- internal/models/scim_group.go | 56 +++++++++++++++++++++++++++++------ 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index 1e13f0054..107fef793 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -187,32 +187,70 @@ func (g *SCIMGroup) GetMembers(tx *storage.Connection) ([]*User, error) { } func (g *SCIMGroup) SetMembers(tx *storage.Connection, userIDs []uuid.UUID) error { - if err := tx.RawQuery("DELETE FROM "+scimGroupMemberTableName()+" WHERE group_id = ?", g.ID).Exec(); err != nil { - return errors.Wrap(err, "error clearing SCIM group members") - } - if len(userIDs) == 0 { + if err := tx.RawQuery("DELETE FROM "+scimGroupMemberTableName()+" WHERE group_id = ?", g.ID).Exec(); err != nil { + return errors.Wrap(err, "error clearing SCIM group members") + } return nil } identityTable := (&pop.Model{Value: Identity{}}).TableName() userTable := (&pop.Model{Value: User{}}).TableName() + providerType := "sso:" + g.SSOProviderID.String() placeholders := make([]string, len(userIDs)) - args := []interface{}{g.ID, time.Now()} + queryArgs := make([]interface{}, len(userIDs)) for i, id := range userIDs { placeholders[i] = "?" - args = append(args, id) + queryArgs[i] = id } - args = append(args, "sso:"+g.SSOProviderID.String()) + inClause := strings.Join(placeholders, ",") + + var validIDs []uuid.UUID + validationArgs := make([]interface{}, 0, len(userIDs)+1) + validationArgs = append(validationArgs, queryArgs...) + validationArgs = append(validationArgs, providerType) + if err := tx.RawQuery( + "SELECT DISTINCT u.id FROM "+userTable+" u "+ + "INNER JOIN "+identityTable+" i ON i.user_id = u.id "+ + "WHERE u.id IN ("+inClause+") AND i.provider = ?", + validationArgs..., + ).All(&validIDs); err != nil { + return errors.Wrap(err, "error validating SCIM group member IDs") + } + + if len(validIDs) != len(userIDs) { + validSet := make(map[uuid.UUID]struct{}, len(validIDs)) + for _, id := range validIDs { + validSet[id] = struct{}{} + } + for _, id := range userIDs { + if _, ok := validSet[id]; !ok { + if _, err := FindUserByID(tx, id); err != nil { + return UserNotFoundError{} + } + return UserNotInSSOProviderError{} + } + } + } + + if err := tx.RawQuery("DELETE FROM "+scimGroupMemberTableName()+" WHERE group_id = ?", g.ID).Exec(); err != nil { + return errors.Wrap(err, "error clearing SCIM group members") + } + + now := time.Now() + insertArgs := make([]interface{}, 0, 2+len(userIDs)+1) + insertArgs = append(insertArgs, g.ID, now) + insertArgs = append(insertArgs, queryArgs...) + insertArgs = append(insertArgs, providerType) if err := tx.RawQuery( "INSERT INTO "+scimGroupMemberTableName()+" (group_id, user_id, created_at) "+ "SELECT ?, u.id, ? FROM "+userTable+" u "+ "INNER JOIN "+identityTable+" i ON i.user_id = u.id "+ - "WHERE u.id IN ("+strings.Join(placeholders, ",")+") AND i.provider = ? "+ + "WHERE u.id IN ("+inClause+") AND i.provider = ? "+ "ON CONFLICT DO NOTHING", - args..., + insertArgs..., ).Exec(); err != nil { return errors.Wrap(err, "error setting SCIM group members") } From ce05abb9cc24d5e1b1297fbd3949210a71d18e87 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 11:24:28 +0300 Subject: [PATCH 059/101] fix: preserve non-not-found errors in SetMembers validation and lock rows --- internal/models/scim_group.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index 107fef793..fda258971 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -210,10 +210,13 @@ func (g *SCIMGroup) SetMembers(tx *storage.Connection, userIDs []uuid.UUID) erro validationArgs := make([]interface{}, 0, len(userIDs)+1) validationArgs = append(validationArgs, queryArgs...) validationArgs = append(validationArgs, providerType) + // Use FOR SHARE to lock user rows during validation, preventing concurrent + // deletion or modification between validation and the subsequent membership write. if err := tx.RawQuery( "SELECT DISTINCT u.id FROM "+userTable+" u "+ "INNER JOIN "+identityTable+" i ON i.user_id = u.id "+ - "WHERE u.id IN ("+inClause+") AND i.provider = ?", + "WHERE u.id IN ("+inClause+") AND i.provider = ? "+ + "FOR SHARE OF u", validationArgs..., ).All(&validIDs); err != nil { return errors.Wrap(err, "error validating SCIM group member IDs") @@ -227,7 +230,10 @@ func (g *SCIMGroup) SetMembers(tx *storage.Connection, userIDs []uuid.UUID) erro for _, id := range userIDs { if _, ok := validSet[id]; !ok { if _, err := FindUserByID(tx, id); err != nil { - return UserNotFoundError{} + if IsNotFoundError(err) { + return UserNotFoundError{} + } + return errors.Wrap(err, "error looking up user for SCIM group membership") } return UserNotInSSOProviderError{} } From f2035abe4cdda557e06b0f373974a42c864d4d33 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 11:26:24 +0300 Subject: [PATCH 060/101] fix: remove DISTINCT from FOR SHARE query and de-duplicate in Go --- internal/models/scim_group.go | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index fda258971..afb2515c5 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -206,27 +206,31 @@ func (g *SCIMGroup) SetMembers(tx *storage.Connection, userIDs []uuid.UUID) erro } inClause := strings.Join(placeholders, ",") - var validIDs []uuid.UUID + var rawValidIDs []uuid.UUID validationArgs := make([]interface{}, 0, len(userIDs)+1) validationArgs = append(validationArgs, queryArgs...) validationArgs = append(validationArgs, providerType) - // Use FOR SHARE to lock user rows during validation, preventing concurrent + // Use FOR SHARE OF u to lock user rows during validation, preventing concurrent // deletion or modification between validation and the subsequent membership write. + // DISTINCT is omitted because PostgreSQL disallows row-locking with DISTINCT; + // de-duplication is done in Go below. if err := tx.RawQuery( - "SELECT DISTINCT u.id FROM "+userTable+" u "+ + "SELECT u.id FROM "+userTable+" u "+ "INNER JOIN "+identityTable+" i ON i.user_id = u.id "+ "WHERE u.id IN ("+inClause+") AND i.provider = ? "+ "FOR SHARE OF u", validationArgs..., - ).All(&validIDs); err != nil { + ).All(&rawValidIDs); err != nil { return errors.Wrap(err, "error validating SCIM group member IDs") } - if len(validIDs) != len(userIDs) { - validSet := make(map[uuid.UUID]struct{}, len(validIDs)) - for _, id := range validIDs { - validSet[id] = struct{}{} - } + // De-duplicate IDs in Go since we cannot use DISTINCT with FOR SHARE. + validSet := make(map[uuid.UUID]struct{}, len(rawValidIDs)) + for _, id := range rawValidIDs { + validSet[id] = struct{}{} + } + + if len(validSet) != len(userIDs) { for _, id := range userIDs { if _, ok := validSet[id]; !ok { if _, err := FindUserByID(tx, id); err != nil { From 1f4e920767c44022f3d4bc58ded0acb8986d47fc Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 11:32:52 +0300 Subject: [PATCH 061/101] fix: enable SCIM user reactivation for SSO users --- internal/api/scim.go | 2 +- internal/api/scim_test.go | 45 +++++++++++++++++++++++++++++++++++++++ internal/models/user.go | 7 ++++++ 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 35d00da2a..d1bccc5a3 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -147,7 +147,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { var user *models.User terr := db.Transaction(func(tx *storage.Connection) error { - existingUser, err := models.FindUserByEmailAndAudience(tx, email, config.JWT.Aud) + existingUser, err := models.FindUserByEmailAndAudienceIncludingSSO(tx, email, config.JWT.Aud) if err != nil && !models.IsNotFoundError(err) { return apierrors.NewSCIMInternalServerError("Error checking existing user").WithInternalError(err) } diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index cd5bc2374..4fc42cb16 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -45,6 +45,7 @@ var ( testUser14 = scimTestUser{UserName: "user14@acme.com", Email: "user14@acme.com", ExternalID: "ext-007"} testUser15 = scimTestUser{UserName: "user15@acme.com", Email: "user15@acme.com", ExternalID: "ext-008"} testUser16 = scimTestUser{UserName: "user16@example.com", Email: "user16@example.com", ExternalID: "ext-009"} + testUser17 = scimTestUser{UserName: "user17@acme.com", Email: "user17@acme.com", GivenName: "Reactivated", FamilyName: "User", Formatted: "Reactivated User", ExternalID: "ext-010"} testGroup1 = scimTestGroup{DisplayName: "Engineering", ExternalID: "grp-001"} testGroup2 = scimTestGroup{DisplayName: "Sales", ExternalID: "grp-002"} @@ -724,6 +725,50 @@ func (ts *SCIMTestSuite) TestSCIMDeleteUserTwice() { require.Equal(ts.T(), http.StatusNoContent, w.Code) } +func (ts *SCIMTestSuite) TestSCIMReactivateDeprovisionedUser() { + user := ts.createSCIMUserWithName(testUser17.UserName, testUser17.Email, testUser17.GivenName, testUser17.FamilyName) + require.True(ts.T(), user.Active) + + req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Users/"+user.ID, nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusNoContent, w.Code) + + req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users/"+user.ID, nil) + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var deprovisioned SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&deprovisioned)) + require.False(ts.T(), deprovisioned.Active) + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaUser}, + "userName": testUser17.UserName, + "name": map[string]interface{}{ + "givenName": "Updated", + "familyName": "Name", + "formatted": "Updated Name", + }, + "emails": []map[string]interface{}{ + {"value": testUser17.Email, "primary": true, "type": "work"}, + }, + } + + req = ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Users", body) + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusCreated, w.Code, "Reactivating a deprovisioned SSO user should succeed: %s", w.Body.String()) + + var reactivated SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&reactivated)) + require.True(ts.T(), reactivated.Active) + require.Equal(ts.T(), user.ID, reactivated.ID, "Reactivated user should have the same ID") + require.Equal(ts.T(), "Updated", reactivated.Name.GivenName) + require.Equal(ts.T(), "Name", reactivated.Name.FamilyName) +} + func (ts *SCIMTestSuite) TestSCIMFilterUserByUserNameExisting() { created := ts.createSCIMUserWithExternalID(testUser13.UserName, testUser13.Email, testUser13.ExternalID) diff --git a/internal/models/user.go b/internal/models/user.go index fa5a25887..b4dc88353 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -622,6 +622,13 @@ func FindUserByEmailAndAudience(tx *storage.Connection, email, aud string) (*Use return findUser(tx, "instance_id = ? and LOWER(email) = ? and aud = ? and is_sso_user = false", uuid.Nil, strings.ToLower(email), aud) } +// FindUserByEmailAndAudienceIncludingSSO finds a user with the matching email +// and audience regardless of whether they are an SSO user. This is used by SCIM +// provisioning to detect previously deprovisioned SSO users for reactivation. +func FindUserByEmailAndAudienceIncludingSSO(tx *storage.Connection, email, aud string) (*User, error) { + return findUser(tx, "instance_id = ? and LOWER(email) = ? and aud = ?", uuid.Nil, strings.ToLower(email), aud) +} + // FindUserByPhoneAndAudience finds a user with the matching email and audience. func FindUserByPhoneAndAudience(tx *storage.Connection, phone, aud string) (*User, error) { return findUser(tx, "instance_id = ? and phone = ? and aud = ? and is_sso_user = false", uuid.Nil, phone, aud) From e855455830cef469c8cf3049dca28bf2bbc2af4f Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 11:36:09 +0300 Subject: [PATCH 062/101] fix: scope SCIM reactivation lookup by provider to prevent cross-provider conflicts --- internal/api/scim.go | 31 ++++++++++++++++++------------- internal/api/scim_test.go | 31 +++++++++++++++++++++++++++++++ internal/models/user.go | 23 ++++++++++++++++++----- 3 files changed, 67 insertions(+), 18 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index d1bccc5a3..11f9304ca 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -147,17 +147,22 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { var user *models.User terr := db.Transaction(func(tx *storage.Connection) error { - existingUser, err := models.FindUserByEmailAndAudienceIncludingSSO(tx, email, config.JWT.Aud) + nonSSOUser, err := models.FindUserByEmailAndAudience(tx, email, config.JWT.Aud) if err != nil && !models.IsNotFoundError(err) { return apierrors.NewSCIMInternalServerError("Error checking existing user").WithInternalError(err) } + if nonSSOUser != nil { + return apierrors.NewSCIMConflictError("User with this email already exists", "uniqueness") + } - if existingUser != nil { - if existingUser.BannedReason != nil && *existingUser.BannedReason == scimDeprovisionedReason { - if !models.UserBelongsToSSOProvider(existingUser, provider.ID) { - return apierrors.NewSCIMConflictError("User with this email already exists", "uniqueness") - } - if err := existingUser.Ban(tx, 0, nil); err != nil { + ssoUser, err := models.FindSSOUserByEmailAndProvider(tx, email, config.JWT.Aud, providerType) + if err != nil && !models.IsNotFoundError(err) { + return apierrors.NewSCIMInternalServerError("Error checking existing SSO user").WithInternalError(err) + } + + if ssoUser != nil { + if ssoUser.BannedReason != nil && *ssoUser.BannedReason == scimDeprovisionedReason { + if err := ssoUser.Ban(tx, 0, nil); err != nil { return apierrors.NewSCIMInternalServerError("Error reactivating user").WithInternalError(err) } @@ -173,27 +178,27 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { metadata["full_name"] = params.Name.Formatted } if len(metadata) > 0 { - existingUser.UserMetaData = metadata - if err := tx.UpdateOnly(existingUser, "raw_user_meta_data"); err != nil { + ssoUser.UserMetaData = metadata + if err := tx.UpdateOnly(ssoUser, "raw_user_meta_data"); err != nil { return apierrors.NewSCIMInternalServerError("Error updating user metadata").WithInternalError(err) } } } - if email != existingUser.GetEmail() { - if err := existingUser.SetEmail(tx, email); err != nil { + if email != ssoUser.GetEmail() { + if err := ssoUser.SetEmail(tx, email); err != nil { return apierrors.NewSCIMInternalServerError("Error updating user email").WithInternalError(err) } } - if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, existingUser, models.UserModifiedAction, "", map[string]interface{}{ + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, ssoUser, models.UserModifiedAction, "", map[string]interface{}{ "provider": "scim", "sso_provider_id": provider.ID, "action": "reactivated", }); terr != nil { return apierrors.NewSCIMInternalServerError("Error recording audit log entry").WithInternalError(terr) } - user = existingUser + user = ssoUser return nil } return apierrors.NewSCIMConflictError("User with this email already exists", "uniqueness") diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index 4fc42cb16..e2101163d 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -46,6 +46,7 @@ var ( testUser15 = scimTestUser{UserName: "user15@acme.com", Email: "user15@acme.com", ExternalID: "ext-008"} testUser16 = scimTestUser{UserName: "user16@example.com", Email: "user16@example.com", ExternalID: "ext-009"} testUser17 = scimTestUser{UserName: "user17@acme.com", Email: "user17@acme.com", GivenName: "Reactivated", FamilyName: "User", Formatted: "Reactivated User", ExternalID: "ext-010"} + testUser18 = scimTestUser{UserName: "crossemail@acme.com", Email: "crossemail@acme.com", ExternalID: "ext-011"} testGroup1 = scimTestGroup{DisplayName: "Engineering", ExternalID: "grp-001"} testGroup2 = scimTestGroup{DisplayName: "Sales", ExternalID: "grp-002"} @@ -769,6 +770,36 @@ func (ts *SCIMTestSuite) TestSCIMReactivateDeprovisionedUser() { require.Equal(ts.T(), "Name", reactivated.Name.FamilyName) } +func (ts *SCIMTestSuite) TestSCIMCreateUserCrossProviderSameEmail() { + ts.createSCIMUserWithExternalID(testUser18.UserName, testUser18.Email, testUser18.ExternalID) + + provider2 := &models.SSOProvider{} + require.NoError(ts.T(), ts.API.db.Create(provider2)) + token2 := "other-provider-token-cross" + provider2.SetSCIMToken(token2) + require.NoError(ts.T(), ts.API.db.Update(provider2)) + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaUser}, + "userName": testUser18.UserName, + "externalId": "other-provider-ext", + "emails": []map[string]interface{}{ + {"value": testUser18.Email, "primary": true, "type": "work"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Users", body) + req.Header.Set("Authorization", "Bearer "+token2) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusCreated, w.Code, "Cross-provider create with same email should succeed: %s", w.Body.String()) + + var result SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.True(ts.T(), result.Active) + require.Equal(ts.T(), testUser18.UserName, result.UserName) +} + func (ts *SCIMTestSuite) TestSCIMFilterUserByUserNameExisting() { created := ts.createSCIMUserWithExternalID(testUser13.UserName, testUser13.Email, testUser13.ExternalID) diff --git a/internal/models/user.go b/internal/models/user.go index b4dc88353..2f1cc8530 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -622,11 +622,24 @@ func FindUserByEmailAndAudience(tx *storage.Connection, email, aud string) (*Use return findUser(tx, "instance_id = ? and LOWER(email) = ? and aud = ? and is_sso_user = false", uuid.Nil, strings.ToLower(email), aud) } -// FindUserByEmailAndAudienceIncludingSSO finds a user with the matching email -// and audience regardless of whether they are an SSO user. This is used by SCIM -// provisioning to detect previously deprovisioned SSO users for reactivation. -func FindUserByEmailAndAudienceIncludingSSO(tx *storage.Connection, email, aud string) (*User, error) { - return findUser(tx, "instance_id = ? and LOWER(email) = ? and aud = ?", uuid.Nil, strings.ToLower(email), aud) +// FindSSOUserByEmailAndProvider finds an SSO user with the matching email, +// audience, and identity provider. This is used by SCIM provisioning to detect +// previously deprovisioned SSO users for reactivation without crossing provider +// boundaries. +func FindSSOUserByEmailAndProvider(tx *storage.Connection, email, aud, provider string) (*User, error) { + user := &User{} + query := ` + SELECT DISTINCT u.* FROM ` + user.TableName() + ` u + INNER JOIN identities i ON u.id = i.user_id + WHERE u.instance_id = ? AND LOWER(u.email) = ? AND u.aud = ? AND u.is_sso_user = true AND i.provider = ? + ` + if err := tx.Eager().RawQuery(query, uuid.Nil, strings.ToLower(email), aud, provider).First(user); err != nil { + if errors.Cause(err) == sql.ErrNoRows { + return nil, UserNotFoundError{} + } + return nil, errors.Wrap(err, "error finding SSO user by email and provider") + } + return user, nil } // FindUserByPhoneAndAudience finds a user with the matching email and audience. From e8b22b933a196fd4d3465887de1fe2f3755267bd Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 11:38:10 +0300 Subject: [PATCH 063/101] fix: make SCIM reactivation deterministic by querying all matching SSO users --- internal/api/scim.go | 79 ++++++++++++++++++++++------------------- internal/models/user.go | 16 +++++---- 2 files changed, 52 insertions(+), 43 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 11f9304ca..7797e88ea 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -155,53 +155,60 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMConflictError("User with this email already exists", "uniqueness") } - ssoUser, err := models.FindSSOUserByEmailAndProvider(tx, email, config.JWT.Aud, providerType) - if err != nil && !models.IsNotFoundError(err) { + ssoUsers, err := models.FindSSOUsersByEmailAndProvider(tx, email, config.JWT.Aud, providerType) + if err != nil { return apierrors.NewSCIMInternalServerError("Error checking existing SSO user").WithInternalError(err) } - if ssoUser != nil { - if ssoUser.BannedReason != nil && *ssoUser.BannedReason == scimDeprovisionedReason { - if err := ssoUser.Ban(tx, 0, nil); err != nil { - return apierrors.NewSCIMInternalServerError("Error reactivating user").WithInternalError(err) + if len(ssoUsers) > 0 { + var deprovisioned *models.User + for _, u := range ssoUsers { + if u.BannedReason == nil || *u.BannedReason != scimDeprovisionedReason { + return apierrors.NewSCIMConflictError("User with this email already exists", "uniqueness") } - - if params.Name != nil { - metadata := make(map[string]interface{}) - if params.Name.GivenName != "" { - metadata["given_name"] = params.Name.GivenName - } - if params.Name.FamilyName != "" { - metadata["family_name"] = params.Name.FamilyName - } - if params.Name.Formatted != "" { - metadata["full_name"] = params.Name.Formatted - } - if len(metadata) > 0 { - ssoUser.UserMetaData = metadata - if err := tx.UpdateOnly(ssoUser, "raw_user_meta_data"); err != nil { - return apierrors.NewSCIMInternalServerError("Error updating user metadata").WithInternalError(err) - } - } + if deprovisioned == nil { + deprovisioned = u } + } - if email != ssoUser.GetEmail() { - if err := ssoUser.SetEmail(tx, email); err != nil { - return apierrors.NewSCIMInternalServerError("Error updating user email").WithInternalError(err) + if err := deprovisioned.Ban(tx, 0, nil); err != nil { + return apierrors.NewSCIMInternalServerError("Error reactivating user").WithInternalError(err) + } + + if params.Name != nil { + metadata := make(map[string]interface{}) + if params.Name.GivenName != "" { + metadata["given_name"] = params.Name.GivenName + } + if params.Name.FamilyName != "" { + metadata["family_name"] = params.Name.FamilyName + } + if params.Name.Formatted != "" { + metadata["full_name"] = params.Name.Formatted + } + if len(metadata) > 0 { + deprovisioned.UserMetaData = metadata + if err := tx.UpdateOnly(deprovisioned, "raw_user_meta_data"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating user metadata").WithInternalError(err) } } + } - if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, ssoUser, models.UserModifiedAction, "", map[string]interface{}{ - "provider": "scim", - "sso_provider_id": provider.ID, - "action": "reactivated", - }); terr != nil { - return apierrors.NewSCIMInternalServerError("Error recording audit log entry").WithInternalError(terr) + if email != deprovisioned.GetEmail() { + if err := deprovisioned.SetEmail(tx, email); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating user email").WithInternalError(err) } - user = ssoUser - return nil } - return apierrors.NewSCIMConflictError("User with this email already exists", "uniqueness") + + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, deprovisioned, models.UserModifiedAction, "", map[string]interface{}{ + "provider": "scim", + "sso_provider_id": provider.ID, + "action": "reactivated", + }); terr != nil { + return apierrors.NewSCIMInternalServerError("Error recording audit log entry").WithInternalError(terr) + } + user = deprovisioned + return nil } user, err = models.NewUser("", email, "", config.JWT.Aud, nil) diff --git a/internal/models/user.go b/internal/models/user.go index 2f1cc8530..3250f88a8 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -622,24 +622,26 @@ func FindUserByEmailAndAudience(tx *storage.Connection, email, aud string) (*Use return findUser(tx, "instance_id = ? and LOWER(email) = ? and aud = ? and is_sso_user = false", uuid.Nil, strings.ToLower(email), aud) } -// FindSSOUserByEmailAndProvider finds an SSO user with the matching email, +// FindSSOUsersByEmailAndProvider finds all SSO users with the matching email, // audience, and identity provider. This is used by SCIM provisioning to detect // previously deprovisioned SSO users for reactivation without crossing provider -// boundaries. -func FindSSOUserByEmailAndProvider(tx *storage.Connection, email, aud, provider string) (*User, error) { +// boundaries. Results are ordered with active users first. +func FindSSOUsersByEmailAndProvider(tx *storage.Connection, email, aud, provider string) ([]*User, error) { + users := []*User{} user := &User{} query := ` SELECT DISTINCT u.* FROM ` + user.TableName() + ` u INNER JOIN identities i ON u.id = i.user_id WHERE u.instance_id = ? AND LOWER(u.email) = ? AND u.aud = ? AND u.is_sso_user = true AND i.provider = ? + ORDER BY u.banned_until ASC NULLS FIRST, u.created_at ASC ` - if err := tx.Eager().RawQuery(query, uuid.Nil, strings.ToLower(email), aud, provider).First(user); err != nil { + if err := tx.Eager().RawQuery(query, uuid.Nil, strings.ToLower(email), aud, provider).All(&users); err != nil { if errors.Cause(err) == sql.ErrNoRows { - return nil, UserNotFoundError{} + return nil, nil } - return nil, errors.Wrap(err, "error finding SSO user by email and provider") + return nil, errors.Wrap(err, "error finding SSO users by email and provider") } - return user, nil + return users, nil } // FindUserByPhoneAndAudience finds a user with the matching email and audience. From 09a8d177da787d262584588609e9d549210c080a Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 11:43:20 +0300 Subject: [PATCH 064/101] fix: return 400 for unsupported SCIM PATCH paths and value types --- internal/api/scim.go | 56 ++++++++++------ internal/api/scim_test.go | 136 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 22 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 7797e88ea..f436e0182 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -502,9 +502,10 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S switch strings.ToLower(op.Op) { case "remove": if path == nil { - return nil + return apierrors.NewSCIMBadRequestError("remove operation requires a path", "noTarget") } - if strings.ToLower(path.AttributePath.AttributeName) == "externalid" { + attrName := strings.ToLower(path.AttributePath.AttributeName) + if attrName == "externalid" { for i := range user.Identities { if user.Identities[i].Provider == providerType { user.Identities[i].ProviderID = "" @@ -517,13 +518,14 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S break } } + return nil } - return nil + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported remove path: %s", op.Path), "invalidPath") case "add": valueMap, ok := op.Value.(map[string]interface{}) if !ok { - return nil + return apierrors.NewSCIMBadRequestError("add operation value must be an object", "invalidValue") } for key, val := range valueMap { if key == "" { @@ -531,7 +533,7 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } keyPath, err := filter.ParsePath([]byte(key)) if err != nil { - continue + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid attribute path: %s", key), "invalidPath") } if strings.ToLower(keyPath.AttributePath.AttributeName) == "externalid" { if externalID, ok := val.(string); ok { @@ -607,13 +609,14 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S return apierrors.NewSCIMInternalServerError("Error updating email").WithInternalError(err) } return nil + default: + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported replace path: %s", op.Path), "invalidPath") } - return nil } valueMap, ok := op.Value.(map[string]interface{}) if !ok { - return nil + return apierrors.NewSCIMBadRequestError("replace operation value must be an object when path is not specified", "invalidValue") } if user.UserMetaData == nil { user.UserMetaData = make(map[string]interface{}) @@ -625,7 +628,7 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } keyPath, err := filter.ParsePath([]byte(key)) if err != nil { - continue + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid attribute path: %s", key), "invalidPath") } attrName := strings.ToLower(keyPath.AttributePath.AttributeName) subAttr := strings.ToLower(keyPath.AttributePath.SubAttributeName()) @@ -1080,19 +1083,27 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou switch strings.ToLower(op.Op) { case "add": - if path != nil && strings.ToLower(path.AttributePath.AttributeName) == "externalid" { - externalID, ok := op.Value.(string) - if !ok { - return apierrors.NewSCIMBadRequestError("externalId must be a string", "invalidValue") - } - group.ExternalID = storage.NullString(externalID) - if err := tx.UpdateOnly(group, "external_id"); err != nil { - if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("Group with this externalId already exists", "uniqueness") + if path != nil { + attrName := strings.ToLower(path.AttributePath.AttributeName) + switch attrName { + case "externalid": + externalID, ok := op.Value.(string) + if !ok { + return apierrors.NewSCIMBadRequestError("externalId must be a string", "invalidValue") } - return apierrors.NewSCIMInternalServerError("Error updating group external ID").WithInternalError(err) + group.ExternalID = storage.NullString(externalID) + if err := tx.UpdateOnly(group, "external_id"); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("Group with this externalId already exists", "uniqueness") + } + return apierrors.NewSCIMInternalServerError("Error updating group external ID").WithInternalError(err) + } + return nil + case "members": + // fall through to member handling below + default: + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported add path: %s", op.Path), "invalidPath") } - return nil } members, ok := op.Value.([]interface{}) if !ok { @@ -1225,13 +1236,14 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou return apierrors.NewSCIMInternalServerError("Error setting group members").WithInternalError(err) } return nil + default: + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported replace path: %s", op.Path), "invalidPath") } - return nil } valueMap, ok := op.Value.(map[string]interface{}) if !ok { - return nil + return apierrors.NewSCIMBadRequestError("replace operation value must be an object when path is not specified", "invalidValue") } columnsToUpdate := []string{} for key, val := range valueMap { @@ -1240,7 +1252,7 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou } keyPath, err := filter.ParsePath([]byte(key)) if err != nil { - continue + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid attribute path: %s", key), "invalidPath") } switch strings.ToLower(keyPath.AttributePath.AttributeName) { case "externalid": diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index e2101163d..a18e2a561 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -1313,6 +1313,142 @@ func (ts *SCIMTestSuite) TestSCIMPatchUserUnsupportedOp() { ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidSyntax") } +func (ts *SCIMTestSuite) TestSCIMPatchUserUnsupportedReplacePath() { + user := ts.createSCIMUser("unsup_replace_path@test.com", "unsup_replace_path@test.com") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "displayName", "value": "Foo"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidPath") +} + +func (ts *SCIMTestSuite) TestSCIMPatchUserUnsupportedRemovePath() { + user := ts.createSCIMUser("unsup_remove_path@test.com", "unsup_remove_path@test.com") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "remove", "path": "displayName"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidPath") +} + +func (ts *SCIMTestSuite) TestSCIMPatchUserRemoveWithoutPath() { + user := ts.createSCIMUser("remove_no_path@test.com", "remove_no_path@test.com") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "remove"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "noTarget") +} + +func (ts *SCIMTestSuite) TestSCIMPatchUserAddInvalidValueType() { + user := ts.createSCIMUser("add_invalid_val@test.com", "add_invalid_val@test.com") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "add", "value": "not_an_object"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidValue") +} + +func (ts *SCIMTestSuite) TestSCIMPatchUserReplaceInvalidValueType() { + user := ts.createSCIMUser("replace_invalid_val@test.com", "replace_invalid_val@test.com") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "value": "not_an_object"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidValue") +} + +func (ts *SCIMTestSuite) TestSCIMPatchGroupUnsupportedReplacePath() { + group := ts.createSCIMGroupWithExternalID("UnsupReplPath", "unsup-repl-path-ext") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "schemas", "value": "Foo"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidPath") +} + +func (ts *SCIMTestSuite) TestSCIMPatchGroupReplaceInvalidValueType() { + group := ts.createSCIMGroupWithExternalID("ReplInvalidVal", "repl-invalid-val-ext") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "value": "not_an_object"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidValue") +} + +func (ts *SCIMTestSuite) TestSCIMPatchGroupUnsupportedAddPath() { + group := ts.createSCIMGroupWithExternalID("UnsupAddPath", "unsup-add-path-ext") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "add", "path": "schemas", "value": "Foo"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidPath") +} + func (ts *SCIMTestSuite) TestSCIMFilterGroupByDisplayNameExisting() { created := ts.createSCIMGroupWithExternalID("YWWBHTHEMMLR", "94631638-0b6c-4b97-a369-aba35a454041") From 332b8e436c4eb913d2426344971de28d641d3142 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 11:45:49 +0300 Subject: [PATCH 065/101] fix: log SCIM 5xx errors at Error level and 429 at Warn level --- internal/api/errors.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/api/errors.go b/internal/api/errors.go index 17011262e..4ae6668dd 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -189,7 +189,14 @@ func HandleResponseError(err error, w http.ResponseWriter, r *http.Request) { } case *apierrors.SCIMHTTPError: - log.WithError(e.Cause()).Info(e.Error()) + switch { + case e.HTTPStatus >= http.StatusInternalServerError: + log.WithError(e.Cause()).Error(e.Error()) + case e.HTTPStatus == http.StatusTooManyRequests: + log.WithError(e.Cause()).Warn(e.Error()) + default: + log.WithError(e.Cause()).Info(e.Error()) + } if jsonErr := sendSCIMJSON(w, e.HTTPStatus, e); jsonErr != nil && jsonErr != context.DeadlineExceeded { log.WithError(jsonErr).Warn("Failed to send JSON on ResponseWriter") } From 650abd952636b704a33370a69c0ebd6c1737e91b Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 11:59:44 +0300 Subject: [PATCH 066/101] fix: reject ambiguous reactivation when multiple deprovisioned users exist --- internal/api/scim.go | 26 +++++++++++++++----------- internal/api/scim_test.go | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index f436e0182..b9723bece 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -161,17 +161,21 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { } if len(ssoUsers) > 0 { - var deprovisioned *models.User + var deprovisioned []*models.User for _, u := range ssoUsers { if u.BannedReason == nil || *u.BannedReason != scimDeprovisionedReason { return apierrors.NewSCIMConflictError("User with this email already exists", "uniqueness") } - if deprovisioned == nil { - deprovisioned = u - } + deprovisioned = append(deprovisioned, u) + } + + if len(deprovisioned) > 1 { + return apierrors.NewSCIMConflictError("Multiple deprovisioned users exist for this email", "uniqueness") } - if err := deprovisioned.Ban(tx, 0, nil); err != nil { + candidate := deprovisioned[0] + + if err := candidate.Ban(tx, 0, nil); err != nil { return apierrors.NewSCIMInternalServerError("Error reactivating user").WithInternalError(err) } @@ -187,27 +191,27 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { metadata["full_name"] = params.Name.Formatted } if len(metadata) > 0 { - deprovisioned.UserMetaData = metadata - if err := tx.UpdateOnly(deprovisioned, "raw_user_meta_data"); err != nil { + candidate.UserMetaData = metadata + if err := tx.UpdateOnly(candidate, "raw_user_meta_data"); err != nil { return apierrors.NewSCIMInternalServerError("Error updating user metadata").WithInternalError(err) } } } - if email != deprovisioned.GetEmail() { - if err := deprovisioned.SetEmail(tx, email); err != nil { + if email != candidate.GetEmail() { + if err := candidate.SetEmail(tx, email); err != nil { return apierrors.NewSCIMInternalServerError("Error updating user email").WithInternalError(err) } } - if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, deprovisioned, models.UserModifiedAction, "", map[string]interface{}{ + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, candidate, models.UserModifiedAction, "", map[string]interface{}{ "provider": "scim", "sso_provider_id": provider.ID, "action": "reactivated", }); terr != nil { return apierrors.NewSCIMInternalServerError("Error recording audit log entry").WithInternalError(terr) } - user = deprovisioned + user = candidate return nil } diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index a18e2a561..c9e5b6849 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -4,9 +4,11 @@ import ( "bytes" "encoding/json" "fmt" + "math" "net/http" "net/http/httptest" "testing" + "time" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -47,6 +49,7 @@ var ( testUser16 = scimTestUser{UserName: "user16@example.com", Email: "user16@example.com", ExternalID: "ext-009"} testUser17 = scimTestUser{UserName: "user17@acme.com", Email: "user17@acme.com", GivenName: "Reactivated", FamilyName: "User", Formatted: "Reactivated User", ExternalID: "ext-010"} testUser18 = scimTestUser{UserName: "crossemail@acme.com", Email: "crossemail@acme.com", ExternalID: "ext-011"} + testUser19 = scimTestUser{UserName: "ambiguous@acme.com", Email: "ambiguous@acme.com", ExternalID: "ext-012"} testGroup1 = scimTestGroup{DisplayName: "Engineering", ExternalID: "grp-001"} testGroup2 = scimTestGroup{DisplayName: "Sales", ExternalID: "grp-002"} @@ -770,6 +773,42 @@ func (ts *SCIMTestSuite) TestSCIMReactivateDeprovisionedUser() { require.Equal(ts.T(), "Name", reactivated.Name.FamilyName) } +func (ts *SCIMTestSuite) TestSCIMReactivateAmbiguousDeprovisioned() { + user1 := ts.createSCIMUserWithExternalID(testUser19.UserName, testUser19.Email, testUser19.ExternalID) + + req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Users/"+user1.ID, nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusNoContent, w.Code) + + user2, err := models.NewUser("", testUser19.Email, "", ts.Config.JWT.Aud, nil) + require.NoError(ts.T(), err) + user2.IsSSOUser = true + reason := "SCIM_DEPROVISIONED" + user2.BannedReason = &reason + bannedUntil := time.Now().Add(time.Duration(math.MaxInt64)) + user2.BannedUntil = &bannedUntil + require.NoError(ts.T(), ts.API.db.Create(user2)) + + providerType := "sso:" + ts.SSOProvider.ID.String() + identity, err := models.NewIdentity(user2, providerType, map[string]interface{}{"sub": user2.ID.String()}) + require.NoError(ts.T(), err) + require.NoError(ts.T(), ts.API.db.Create(identity)) + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaUser}, + "userName": testUser19.UserName, + "emails": []map[string]interface{}{ + {"value": testUser19.Email, "primary": true, "type": "work"}, + }, + } + + req = ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Users", body) + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusConflict, w.Code, "Ambiguous deprovisioned users should return 409: %s", w.Body.String()) +} + func (ts *SCIMTestSuite) TestSCIMCreateUserCrossProviderSameEmail() { ts.createSCIMUserWithExternalID(testUser18.UserName, testUser18.Email, testUser18.ExternalID) From 504bad41df08ff7a33b0f3f2f9dc143c4bce21fc Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 12:01:38 +0300 Subject: [PATCH 067/101] fix: support SCIM PATCH add with explicit path for user attributes --- internal/api/scim.go | 26 +++++++++++++++++++++++++- internal/api/scim_test.go | 20 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index b9723bece..84ab43d56 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -527,9 +527,33 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported remove path: %s", op.Path), "invalidPath") case "add": + if path != nil { + attrName := strings.ToLower(path.AttributePath.AttributeName) + if attrName == "externalid" { + if externalID, ok := op.Value.(string); ok { + for i := range user.Identities { + if user.Identities[i].Provider == providerType { + user.Identities[i].ProviderID = externalID + if user.Identities[i].IdentityData == nil { + user.Identities[i].IdentityData = make(map[string]interface{}) + } + user.Identities[i].IdentityData["external_id"] = externalID + if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) + } + break + } + } + return nil + } + return apierrors.NewSCIMBadRequestError("externalId must be a string", "invalidValue") + } + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported add path: %s", op.Path), "invalidPath") + } + valueMap, ok := op.Value.(map[string]interface{}) if !ok { - return apierrors.NewSCIMBadRequestError("add operation value must be an object", "invalidValue") + return apierrors.NewSCIMBadRequestError("add operation without path requires an object value", "invalidValue") } for key, val := range valueMap { if key == "" { diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index c9e5b6849..a1a16602a 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -1403,6 +1403,26 @@ func (ts *SCIMTestSuite) TestSCIMPatchUserRemoveWithoutPath() { ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "noTarget") } +func (ts *SCIMTestSuite) TestSCIMPatchUserAddExternalIDWithPath() { + user := ts.createSCIMUser("add_ext_path@test.com", "add_ext_path@test.com") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "add", "path": "externalId", "value": "new-ext-via-path"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code, "add with path should succeed: %s", w.Body.String()) + + var result SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), "new-ext-via-path", result.ExternalID) +} + func (ts *SCIMTestSuite) TestSCIMPatchUserAddInvalidValueType() { user := ts.createSCIMUser("add_invalid_val@test.com", "add_invalid_val@test.com") From 630f38eba0a5d5af021b854a956a67c2543f3bbc Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 14:08:14 +0300 Subject: [PATCH 068/101] fix: cap startIndex, use SetEmail in PATCH, map externalId uniqueness to 409, validate filter comparison types --- internal/api/scim.go | 30 ++++++++++++++++++++++++++++-- internal/api/scim_filter.go | 29 +++++++++-------------------- internal/api/scim_helpers.go | 3 +++ internal/api/scim_types.go | 1 + 4 files changed, 41 insertions(+), 22 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 84ab43d56..8c0dc876e 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -402,6 +402,9 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { delete(user.Identities[i].IdentityData, "external_id") } if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") + } return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } break @@ -539,6 +542,9 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } user.Identities[i].IdentityData["external_id"] = externalID if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") + } return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } break @@ -573,6 +579,9 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } user.Identities[i].IdentityData["external_id"] = externalID if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") + } return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } break @@ -632,10 +641,24 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S if err != nil { return apierrors.NewSCIMBadRequestError("Invalid email address", "invalidValue") } - user.Email = storage.NullString(validatedEmail) - if err := tx.UpdateOnly(user, "email"); err != nil { + if err := user.SetEmail(tx, validatedEmail); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("Email already in use", "uniqueness") + } return apierrors.NewSCIMInternalServerError("Error updating email").WithInternalError(err) } + for i := range user.Identities { + if user.Identities[i].Provider == providerType { + if user.Identities[i].IdentityData == nil { + user.Identities[i].IdentityData = make(map[string]interface{}) + } + user.Identities[i].IdentityData["email"] = validatedEmail + if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) + } + break + } + } return nil default: return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported replace path: %s", op.Path), "invalidPath") @@ -702,6 +725,9 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } user.Identities[i].IdentityData["external_id"] = externalID if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") + } return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } break diff --git a/internal/api/scim_filter.go b/internal/api/scim_filter.go index 02560ab5d..6256ae31f 100644 --- a/internal/api/scim_filter.go +++ b/internal/api/scim_filter.go @@ -112,27 +112,16 @@ func attrExprToSQL(e filter.AttributeExpression, allowedAttrs map[string]string) Args: nil, }, nil - case filter.GT: - return &models.SCIMFilterClause{ - Where: fmt.Sprintf("%s > ?", dbColumn), - Args: []interface{}{e.CompareValue}, - }, nil - - case filter.GE: - return &models.SCIMFilterClause{ - Where: fmt.Sprintf("%s >= ?", dbColumn), - Args: []interface{}{e.CompareValue}, - }, nil - - case filter.LT: - return &models.SCIMFilterClause{ - Where: fmt.Sprintf("%s < ?", dbColumn), - Args: []interface{}{e.CompareValue}, - }, nil - - case filter.LE: + case filter.GT, filter.GE, filter.LT, filter.LE: + if _, ok := e.CompareValue.(string); !ok { + return nil, apierrors.NewSCIMBadRequestError( + fmt.Sprintf("'%s' operator requires a string value", e.Operator), "invalidValue") + } + ops := map[filter.CompareOperator]string{ + filter.GT: ">", filter.GE: ">=", filter.LT: "<", filter.LE: "<=", + } return &models.SCIMFilterClause{ - Where: fmt.Sprintf("%s <= ?", dbColumn), + Where: fmt.Sprintf("%s %s ?", dbColumn, ops[e.Operator]), Args: []interface{}{e.CompareValue}, }, nil diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go index ec0fb7eaa..dcfdde36e 100644 --- a/internal/api/scim_helpers.go +++ b/internal/api/scim_helpers.go @@ -19,6 +19,9 @@ func parseSCIMPagination(r *http.Request) (startIndex, count int) { if v := r.URL.Query().Get("startIndex"); v != "" { if i, err := strconv.Atoi(v); err == nil && i > 0 { startIndex = i + if startIndex > SCIMMaxStartIndex { + startIndex = SCIMMaxStartIndex + } } } diff --git a/internal/api/scim_types.go b/internal/api/scim_types.go index 9fde2b587..318988343 100644 --- a/internal/api/scim_types.go +++ b/internal/api/scim_types.go @@ -15,6 +15,7 @@ const ( SCIMMaxBodySize = 1 << 20 // 1 MB SCIMMaxMembers = 1000 SCIMMaxPatchOperations = 100 + SCIMMaxStartIndex = 100000 SCIMSchemaUser = "urn:ietf:params:scim:schemas:core:2.0:User" SCIMSchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group" SCIMSchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse" From e310393dca509e206bcd51bd0182227db7321f37 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 14:13:53 +0300 Subject: [PATCH 069/101] fix: use SetEmail consistently in SCIM PATCH/PUT and map email uniqueness violations to 409 --- internal/api/scim.go | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 8c0dc876e..4d0b18528 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -371,9 +371,11 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { } } - // Update email if provided if email != "" && email != user.GetEmail() { if err := user.SetEmail(tx, email); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("Email already in use", "uniqueness") + } return apierrors.NewSCIMInternalServerError("Error updating user email").WithInternalError(err) } } @@ -740,10 +742,24 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S if err != nil { return apierrors.NewSCIMBadRequestError("Invalid email address", "invalidValue") } - user.Email = storage.NullString(validatedEmail) - if err := tx.UpdateOnly(user, "email"); err != nil { + if err := user.SetEmail(tx, validatedEmail); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("Email already in use", "uniqueness") + } return apierrors.NewSCIMInternalServerError("Error updating email").WithInternalError(err) } + for i := range user.Identities { + if user.Identities[i].Provider == providerType { + if user.Identities[i].IdentityData == nil { + user.Identities[i].IdentityData = make(map[string]interface{}) + } + user.Identities[i].IdentityData["email"] = validatedEmail + if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) + } + break + } + } } case attrName == "active": if active, ok := val.(bool); ok { From a9259b56b2972cdc1ef75f0785b55db5758e57ce Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 14:17:45 +0300 Subject: [PATCH 070/101] fix: enforce provider-scoped email uniqueness in SCIM PUT and PATCH paths --- internal/api/scim.go | 13 +++++++++++-- internal/api/scim_helpers.go | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 4d0b18528..465d2ef2e 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -371,7 +371,12 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { } } + providerType := "sso:" + provider.ID.String() + if email != "" && email != user.GetEmail() { + if err := checkSCIMEmailUniqueness(tx, email, config.JWT.Aud, providerType, user.ID); err != nil { + return err + } if err := user.SetEmail(tx, email); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { return apierrors.NewSCIMConflictError("Email already in use", "uniqueness") @@ -383,8 +388,6 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { if err := tx.UpdateOnly(user, "raw_user_meta_data"); err != nil { return apierrors.NewSCIMInternalServerError("Error updating user").WithInternalError(err) } - - providerType := "sso:" + provider.ID.String() for i := range user.Identities { if user.Identities[i].Provider == providerType { if user.Identities[i].IdentityData == nil { @@ -643,6 +646,9 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S if err != nil { return apierrors.NewSCIMBadRequestError("Invalid email address", "invalidValue") } + if err := checkSCIMEmailUniqueness(tx, validatedEmail, a.config.JWT.Aud, providerType, user.ID); err != nil { + return err + } if err := user.SetEmail(tx, validatedEmail); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { return apierrors.NewSCIMConflictError("Email already in use", "uniqueness") @@ -742,6 +748,9 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S if err != nil { return apierrors.NewSCIMBadRequestError("Invalid email address", "invalidValue") } + if err := checkSCIMEmailUniqueness(tx, validatedEmail, a.config.JWT.Aud, providerType, user.ID); err != nil { + return err + } if err := user.SetEmail(tx, validatedEmail); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { return apierrors.NewSCIMConflictError("Email already in use", "uniqueness") diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go index dcfdde36e..28319f7e8 100644 --- a/internal/api/scim_helpers.go +++ b/internal/api/scim_helpers.go @@ -7,8 +7,10 @@ import ( "strconv" "strings" + "github.com/gofrs/uuid" "github.com/supabase/auth/internal/api/apierrors" "github.com/supabase/auth/internal/models" + "github.com/supabase/auth/internal/storage" "github.com/supabase/auth/internal/utilities" ) @@ -156,3 +158,19 @@ func sendSCIMJSON(w http.ResponseWriter, status int, obj interface{}) error { w.WriteHeader(status) return json.NewEncoder(w).Encode(obj) } + +func checkSCIMEmailUniqueness(tx *storage.Connection, email, aud, providerType string, excludeUserID uuid.UUID) error { + ssoUsers, err := models.FindSSOUsersByEmailAndProvider(tx, email, aud, providerType) + if err != nil { + return apierrors.NewSCIMInternalServerError("Error checking email uniqueness").WithInternalError(err) + } + for _, u := range ssoUsers { + if u.ID == excludeUserID { + continue + } + if u.BannedReason == nil || *u.BannedReason != scimDeprovisionedReason { + return apierrors.NewSCIMConflictError("Email already in use by another user", "uniqueness") + } + } + return nil +} From 0484837bcd0665925e9890592afd9a2390de50dc Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 14:22:09 +0300 Subject: [PATCH 071/101] test: add SCIM email uniqueness regression tests for PUT and PATCH --- internal/api/scim_test.go | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index a1a16602a..8ee4dda12 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -2479,6 +2479,39 @@ func (ts *SCIMTestSuite) TestSCIMCrossProviderIsolationGroups() { ts.assertSCIMError(w, http.StatusNotFound) } +func (ts *SCIMTestSuite) TestSCIMPutEmailUniqueness() { + userA := ts.createSCIMUser("uniqueA@acme.com", "uniqueA@acme.com") + ts.createSCIMUser("uniqueB@acme.com", "uniqueB@acme.com") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaUser}, + "userName": "uniqueB@acme.com", + "emails": []map[string]interface{}{{"value": "uniqueB@acme.com", "primary": true}}, + } + + req := ts.makeSCIMRequest(http.MethodPut, "/scim/v2/Users/"+userA.ID, body) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusConflict, "uniqueness") +} + +func (ts *SCIMTestSuite) TestSCIMPatchEmailUniqueness() { + userA := ts.createSCIMUser("patchA@acme.com", "patchA@acme.com") + ts.createSCIMUser("patchB@acme.com", "patchB@acme.com") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "emails[value eq \"patchA@acme.com\"].value", "value": "patchB@acme.com"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+userA.ID, body) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusConflict, "uniqueness") +} + func (ts *SCIMTestSuite) TestSCIMErrorResponseContentType() { req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users/not-a-uuid", nil) w := httptest.NewRecorder() From 1bab6ed879636420da61237acf90807dbac23f1b Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 14:52:06 +0300 Subject: [PATCH 072/101] fix: fix group member pointer aliasing, group create race, and FlexBool validation --- internal/api/scim.go | 3 +++ internal/api/scim_types.go | 9 ++++++++- internal/models/scim_group.go | 3 +-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 465d2ef2e..e8894fe09 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -974,6 +974,9 @@ func (a *API) scimCreateGroup(w http.ResponseWriter, r *http.Request) error { group = models.NewSCIMGroup(provider.ID, params.ExternalID, params.DisplayName) if err := tx.Create(group); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("Group already exists", "uniqueness") + } return apierrors.NewSCIMInternalServerError("Error creating group").WithInternalError(err) } diff --git a/internal/api/scim_types.go b/internal/api/scim_types.go index 318988343..4d71e9d69 100644 --- a/internal/api/scim_types.go +++ b/internal/api/scim_types.go @@ -36,7 +36,14 @@ func (fb *FlexBool) UnmarshalJSON(data []byte) error { } var s string if err := json.Unmarshal(data, &s); err == nil { - *fb = FlexBool(strings.ToLower(s) == "true") + switch strings.ToLower(s) { + case "true": + *fb = FlexBool(true) + case "false": + *fb = FlexBool(false) + default: + return fmt.Errorf("cannot unmarshal %q into FlexBool: must be true or false", s) + } return nil } return fmt.Errorf("cannot unmarshal %s into FlexBool", string(data)) diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index afb2515c5..1f82f0a44 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -302,8 +302,7 @@ func GetMembersForGroups(tx *storage.Connection, groupIDs []uuid.UUID) (map[uuid } for i := range rows { - user := rows[i].User - result[rows[i].GroupID] = append(result[rows[i].GroupID], &user) + result[rows[i].GroupID] = append(result[rows[i].GroupID], &rows[i].User) } return result, nil } From b7a934854696d2a60c23a762744797d36bfb4372 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 15:12:15 +0300 Subject: [PATCH 073/101] fix: preserve non-SCIM metadata in PUT and pass IP to audit logs --- internal/api/scim.go | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index e8894fe09..0cdd57b0c 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -204,7 +204,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { } } - if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, candidate, models.UserModifiedAction, "", map[string]interface{}{ + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, candidate, models.UserModifiedAction, utilities.GetIPAddress(r), map[string]interface{}{ "provider": "scim", "sso_provider_id": provider.ID, "action": "reactivated", @@ -266,7 +266,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { return err } - if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserSignedUpAction, "", map[string]interface{}{ + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserSignedUpAction, utilities.GetIPAddress(r), map[string]interface{}{ "provider": "scim", "sso_provider_id": provider.ID, }); terr != nil { @@ -341,8 +341,13 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMNotFoundError("User not found") } - // PUT is a full replacement — replace metadata entirely instead of merging - metadata := make(map[string]interface{}) + metadata := user.UserMetaData + if metadata == nil { + metadata = make(map[string]interface{}) + } + delete(metadata, "given_name") + delete(metadata, "family_name") + delete(metadata, "full_name") if params.Name != nil { if params.Name.GivenName != "" { metadata["given_name"] = params.Name.GivenName @@ -416,7 +421,7 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { } } - if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserModifiedAction, "", map[string]interface{}{ + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserModifiedAction, utilities.GetIPAddress(r), map[string]interface{}{ "provider": "scim", "sso_provider_id": provider.ID, }); terr != nil { @@ -481,7 +486,7 @@ func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMInternalServerError("Error reloading user").WithInternalError(err) } - if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserModifiedAction, "", map[string]interface{}{ + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserModifiedAction, utilities.GetIPAddress(r), map[string]interface{}{ "provider": "scim", "sso_provider_id": provider.ID, }); terr != nil { @@ -825,7 +830,7 @@ func (a *API) scimDeleteUser(w http.ResponseWriter, r *http.Request) error { // Already deprovisioned — return success for idempotent delete if user.IsBanned() && user.BannedReason != nil && *user.BannedReason == scimDeprovisionedReason { - if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserDeletedAction, "", map[string]interface{}{ + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserDeletedAction, utilities.GetIPAddress(r), map[string]interface{}{ "provider": "scim", "sso_provider_id": provider.ID, "action": "idempotent_delete", @@ -839,7 +844,7 @@ func (a *API) scimDeleteUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMInternalServerError("Error deprovisioning user").WithInternalError(err) } - if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserDeletedAction, "", map[string]interface{}{ + if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserDeletedAction, utilities.GetIPAddress(r), map[string]interface{}{ "provider": "scim", "sso_provider_id": provider.ID, }); terr != nil { From 050e64b6748c9e4f522dac1f6d528af51c5aed58 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 15:35:18 +0300 Subject: [PATCH 074/101] fix: stop clearing provider_id on externalId removal and batch group member adds --- internal/api/scim.go | 48 ++++++++++++++----------- internal/models/scim_group.go | 68 +++++++++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 21 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 0cdd57b0c..9452420e4 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -404,14 +404,15 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { if email != "" { user.Identities[i].IdentityData["email"] = email } + updateCols := []string{"identity_data"} if params.ExternalID != "" { user.Identities[i].ProviderID = params.ExternalID user.Identities[i].IdentityData["external_id"] = params.ExternalID + updateCols = append(updateCols, "provider_id") } else { - user.Identities[i].ProviderID = "" delete(user.Identities[i].IdentityData, "external_id") } - if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { + if err := tx.UpdateOnly(&user.Identities[i], updateCols...); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") } @@ -525,11 +526,10 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S if attrName == "externalid" { for i := range user.Identities { if user.Identities[i].Provider == providerType { - user.Identities[i].ProviderID = "" if user.Identities[i].IdentityData != nil { delete(user.Identities[i].IdentityData, "external_id") } - if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { + if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } break @@ -985,19 +985,23 @@ func (a *API) scimCreateGroup(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMInternalServerError("Error creating group").WithInternalError(err) } - for _, member := range params.Members { - memberID, err := uuid.FromString(member.Value) - if err != nil { - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", member.Value), "invalidValue") + if len(params.Members) > 0 { + memberIDs := make([]uuid.UUID, 0, len(params.Members)) + for _, member := range params.Members { + memberID, err := uuid.FromString(member.Value) + if err != nil { + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", member.Value), "invalidValue") + } + memberIDs = append(memberIDs, memberID) } - if err := group.AddMember(tx, memberID); err != nil { - if models.IsNotFoundError(err) { - return apierrors.NewSCIMNotFoundError(fmt.Sprintf("User %s not found", member.Value)) + if err := group.AddMembers(tx, memberIDs); err != nil { + if _, ok := err.(models.UserNotFoundError); ok { + return apierrors.NewSCIMNotFoundError("One or more members not found") } if _, ok := err.(models.UserNotInSSOProviderError); ok { - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("User %s does not belong to this SSO provider", member.Value), "invalidValue") + return apierrors.NewSCIMBadRequestError("One or more members do not belong to this SSO provider", "invalidValue") } - return apierrors.NewSCIMInternalServerError("Error adding group member").WithInternalError(err) + return apierrors.NewSCIMInternalServerError("Error adding group members").WithInternalError(err) } } @@ -1199,6 +1203,7 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou if len(members) > SCIMMaxMembers { return apierrors.NewSCIMRequestTooLargeError(fmt.Sprintf("Maximum %d members per operation", SCIMMaxMembers)) } + memberIDs := make([]uuid.UUID, 0, len(members)) for _, m := range members { memberMap, ok := m.(map[string]interface{}) if !ok { @@ -1212,15 +1217,16 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou if err != nil { return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", value), "invalidValue") } - if err := group.AddMember(tx, memberID); err != nil { - if models.IsNotFoundError(err) { - return apierrors.NewSCIMNotFoundError(fmt.Sprintf("User %s not found", value)) - } - if _, ok := err.(models.UserNotInSSOProviderError); ok { - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("User %s does not belong to this SSO provider", value), "invalidValue") - } - return apierrors.NewSCIMInternalServerError("Error adding group member").WithInternalError(err) + memberIDs = append(memberIDs, memberID) + } + if err := group.AddMembers(tx, memberIDs); err != nil { + if _, ok := err.(models.UserNotFoundError); ok { + return apierrors.NewSCIMNotFoundError("One or more members not found") + } + if _, ok := err.(models.UserNotInSSOProviderError); ok { + return apierrors.NewSCIMBadRequestError("One or more members do not belong to this SSO provider", "invalidValue") } + return apierrors.NewSCIMInternalServerError("Error adding group members").WithInternalError(err) } case "remove": diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index 1f82f0a44..54319baf5 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -164,6 +164,74 @@ func UserBelongsToSSOProvider(user *User, ssoProviderID uuid.UUID) bool { return false } +func (g *SCIMGroup) AddMembers(tx *storage.Connection, userIDs []uuid.UUID) error { + if len(userIDs) == 0 { + return nil + } + + identityTable := (&pop.Model{Value: Identity{}}).TableName() + userTable := (&pop.Model{Value: User{}}).TableName() + providerType := "sso:" + g.SSOProviderID.String() + + placeholders := make([]string, len(userIDs)) + queryArgs := make([]interface{}, len(userIDs)) + for i, id := range userIDs { + placeholders[i] = "?" + queryArgs[i] = id + } + inClause := strings.Join(placeholders, ",") + + var rawValidIDs []uuid.UUID + validationArgs := make([]interface{}, 0, len(userIDs)+1) + validationArgs = append(validationArgs, queryArgs...) + validationArgs = append(validationArgs, providerType) + if err := tx.RawQuery( + "SELECT u.id FROM "+userTable+" u "+ + "INNER JOIN "+identityTable+" i ON i.user_id = u.id "+ + "WHERE u.id IN ("+inClause+") AND i.provider = ?", + validationArgs..., + ).All(&rawValidIDs); err != nil { + return errors.Wrap(err, "error validating SCIM group member IDs") + } + + validSet := make(map[uuid.UUID]struct{}, len(rawValidIDs)) + for _, id := range rawValidIDs { + validSet[id] = struct{}{} + } + + if len(validSet) != len(userIDs) { + for _, id := range userIDs { + if _, ok := validSet[id]; !ok { + if _, err := FindUserByID(tx, id); err != nil { + if IsNotFoundError(err) { + return UserNotFoundError{} + } + return errors.Wrap(err, "error looking up user for SCIM group membership") + } + return UserNotInSSOProviderError{} + } + } + } + + now := time.Now() + insertArgs := make([]interface{}, 0, 2+len(userIDs)+1) + insertArgs = append(insertArgs, g.ID, now) + insertArgs = append(insertArgs, queryArgs...) + insertArgs = append(insertArgs, providerType) + + if err := tx.RawQuery( + "INSERT INTO "+scimGroupMemberTableName()+" (group_id, user_id, created_at) "+ + "SELECT ?, u.id, ? FROM "+userTable+" u "+ + "INNER JOIN "+identityTable+" i ON i.user_id = u.id "+ + "WHERE u.id IN ("+inClause+") AND i.provider = ? "+ + "ON CONFLICT DO NOTHING", + insertArgs..., + ).Exec(); err != nil { + return errors.Wrap(err, "error adding SCIM group members") + } + return nil +} + func (g *SCIMGroup) RemoveMember(tx *storage.Connection, userID uuid.UUID) error { return tx.RawQuery( "DELETE FROM "+scimGroupMemberTableName()+" WHERE group_id = ? AND user_id = ?", From 5bfb196afb0ec1d1176be913e2317ff8a27f77a0 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 16:02:41 +0300 Subject: [PATCH 075/101] fix: normalize active parsing, derive externalId from identity data, cap filter length, exclude members from group list by default --- internal/api/scim.go | 37 +++++++++++++++++++----------------- internal/api/scim_filter.go | 6 ++++++ internal/api/scim_helpers.go | 21 +++++++++++++++++--- 3 files changed, 44 insertions(+), 20 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 9452420e4..066ace860 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -607,9 +607,9 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S attrName := strings.ToLower(path.AttributePath.AttributeName) switch { case attrName == "active": - active, ok := op.Value.(bool) - if !ok { - return apierrors.NewSCIMBadRequestError("active must be a boolean", "invalidValue") + active, err := parseSCIMActiveBool(op.Value) + if err != nil { + return err } if active { if err := user.Ban(tx, 0, nil); err != nil { @@ -776,18 +776,20 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S } } case attrName == "active": - if active, ok := val.(bool); ok { - if active { - if err := user.Ban(tx, 0, nil); err != nil { - return apierrors.NewSCIMInternalServerError("Error unbanning user").WithInternalError(err) - } - } else { - if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { - return apierrors.NewSCIMInternalServerError("Error banning user").WithInternalError(err) - } - if err := models.Logout(tx, user.ID); err != nil { - return apierrors.NewSCIMInternalServerError("Error invalidating sessions").WithInternalError(err) - } + active, err := parseSCIMActiveBool(val) + if err != nil { + return err + } + if active { + if err := user.Ban(tx, 0, nil); err != nil { + return apierrors.NewSCIMInternalServerError("Error unbanning user").WithInternalError(err) + } + } else { + if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { + return apierrors.NewSCIMInternalServerError("Error banning user").WithInternalError(err) + } + if err := models.Logout(tx, user.ID); err != nil { + return apierrors.NewSCIMInternalServerError("Error invalidating sessions").WithInternalError(err) } } } @@ -884,10 +886,11 @@ func (a *API) scimListGroups(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMInternalServerError("Error fetching groups").WithInternalError(err) } + includeMembers := strings.Contains(strings.ToLower(r.URL.Query().Get("attributes")), "members") excludeMembers := strings.Contains(strings.ToLower(r.URL.Query().Get("excludedAttributes")), "members") var membersByGroup map[uuid.UUID][]*models.User - if !excludeMembers && len(groups) > 0 { + if includeMembers && !excludeMembers && len(groups) > 0 { groupIDs := make([]uuid.UUID, len(groups)) for i, g := range groups { groupIDs[i] = g.ID @@ -902,7 +905,7 @@ func (a *API) scimListGroups(w http.ResponseWriter, r *http.Request) error { resources := make([]interface{}, len(groups)) for i, group := range groups { var members []*models.User - if !excludeMembers { + if includeMembers && !excludeMembers { members = membersByGroup[group.ID] } resources[i] = a.groupToSCIMResponse(group, members) diff --git a/internal/api/scim_filter.go b/internal/api/scim_filter.go index 6256ae31f..4c3fdd394 100644 --- a/internal/api/scim_filter.go +++ b/internal/api/scim_filter.go @@ -21,11 +21,17 @@ var SCIMGroupFilterAttrs = map[string]string{ "externalid": "external_id", } +const SCIMMaxFilterLength = 1024 + func ParseSCIMFilterToSQL(filterStr string, allowedAttrs map[string]string) (*models.SCIMFilterClause, error) { if filterStr == "" { return &models.SCIMFilterClause{Where: "1=1", Args: nil}, nil } + if len(filterStr) > SCIMMaxFilterLength { + return nil, apierrors.NewSCIMBadRequestError("Filter exceeds maximum length", "invalidFilter") + } + expr, err := filter.ParseFilter([]byte(filterStr)) if err != nil { return nil, apierrors.NewSCIMBadRequestError( diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go index 28319f7e8..302fc8f3d 100644 --- a/internal/api/scim_helpers.go +++ b/internal/api/scim_helpers.go @@ -73,10 +73,10 @@ func (a *API) userToSCIMResponse(user *models.User, providerType string) *SCIMUs var emailType string for _, identity := range user.Identities { if identity.Provider == providerType { - if identity.ProviderID != "" { - resp.ExternalID = identity.ProviderID - } if identity.IdentityData != nil { + if extID, ok := identity.IdentityData["external_id"].(string); ok && extID != "" { + resp.ExternalID = extID + } if userName, ok := identity.IdentityData["user_name"].(string); ok && userName != "" { resp.UserName = userName } @@ -159,6 +159,21 @@ func sendSCIMJSON(w http.ResponseWriter, status int, obj interface{}) error { return json.NewEncoder(w).Encode(obj) } +func parseSCIMActiveBool(val interface{}) (bool, error) { + switch v := val.(type) { + case bool: + return v, nil + case string: + switch strings.ToLower(v) { + case "true": + return true, nil + case "false": + return false, nil + } + } + return false, apierrors.NewSCIMBadRequestError("active must be a boolean or \"true\"/\"false\"", "invalidValue") +} + func checkSCIMEmailUniqueness(tx *storage.Connection, email, aud, providerType string, excludeUserID uuid.UUID) error { ssoUsers, err := models.FindSSOUsersByEmailAndProvider(tx, email, aud, providerType) if err != nil { From f123bd396126b8ea42d98e5909a200187962d7e4 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 16:17:37 +0300 Subject: [PATCH 076/101] fix: use identity_data for externalId filter and add row locking to AddMembers --- internal/api/scim_filter.go | 2 +- internal/models/scim_group.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/api/scim_filter.go b/internal/api/scim_filter.go index 4c3fdd394..6548f6df3 100644 --- a/internal/api/scim_filter.go +++ b/internal/api/scim_filter.go @@ -11,7 +11,7 @@ import ( var SCIMUserFilterAttrs = map[string]string{ "username": "COALESCE(i.identity_data->>'user_name', u.email)", - "externalid": "i.provider_id", + "externalid": "i.identity_data->>'external_id'", "email": "u.email", "emails.value": "u.email", } diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index 54319baf5..728a7aacd 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -188,7 +188,8 @@ func (g *SCIMGroup) AddMembers(tx *storage.Connection, userIDs []uuid.UUID) erro if err := tx.RawQuery( "SELECT u.id FROM "+userTable+" u "+ "INNER JOIN "+identityTable+" i ON i.user_id = u.id "+ - "WHERE u.id IN ("+inClause+") AND i.provider = ?", + "WHERE u.id IN ("+inClause+") AND i.provider = ? "+ + "FOR SHARE OF u", validationArgs..., ).All(&rawValidIDs); err != nil { return errors.Wrap(err, "error validating SCIM group member IDs") From ed379b2a5c2153cf5d67213518a10a586d2408a9 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 16:32:46 +0300 Subject: [PATCH 077/101] fix: lock identity rows alongside user rows in group membership validation --- internal/models/scim_group.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index 728a7aacd..b308de17c 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -189,7 +189,7 @@ func (g *SCIMGroup) AddMembers(tx *storage.Connection, userIDs []uuid.UUID) erro "SELECT u.id FROM "+userTable+" u "+ "INNER JOIN "+identityTable+" i ON i.user_id = u.id "+ "WHERE u.id IN ("+inClause+") AND i.provider = ? "+ - "FOR SHARE OF u", + "FOR SHARE OF u, i", validationArgs..., ).All(&rawValidIDs); err != nil { return errors.Wrap(err, "error validating SCIM group member IDs") @@ -279,15 +279,15 @@ func (g *SCIMGroup) SetMembers(tx *storage.Connection, userIDs []uuid.UUID) erro validationArgs := make([]interface{}, 0, len(userIDs)+1) validationArgs = append(validationArgs, queryArgs...) validationArgs = append(validationArgs, providerType) - // Use FOR SHARE OF u to lock user rows during validation, preventing concurrent - // deletion or modification between validation and the subsequent membership write. + // Lock both user and identity rows during validation to prevent concurrent + // deletion or identity changes between validation and the membership write. // DISTINCT is omitted because PostgreSQL disallows row-locking with DISTINCT; // de-duplication is done in Go below. if err := tx.RawQuery( "SELECT u.id FROM "+userTable+" u "+ "INNER JOIN "+identityTable+" i ON i.user_id = u.id "+ "WHERE u.id IN ("+inClause+") AND i.provider = ? "+ - "FOR SHARE OF u", + "FOR SHARE OF u, i", validationArgs..., ).All(&rawValidIDs); err != nil { return errors.Wrap(err, "error validating SCIM group member IDs") From 97c5d34d5783792b807ab426ace325ec4b2eb58a Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 16:53:09 +0300 Subject: [PATCH 078/101] fix: honor active attribute on SCIM user create --- internal/api/scim.go | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 066ace860..5e142a4be 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -175,8 +175,10 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { candidate := deprovisioned[0] - if err := candidate.Ban(tx, 0, nil); err != nil { - return apierrors.NewSCIMInternalServerError("Error reactivating user").WithInternalError(err) + if params.Active == nil || bool(*params.Active) { + if err := candidate.Ban(tx, 0, nil); err != nil { + return apierrors.NewSCIMInternalServerError("Error reactivating user").WithInternalError(err) + } } if params.Name != nil { @@ -277,6 +279,15 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMInternalServerError("Error reloading user").WithInternalError(err) } + if params.Active != nil && !bool(*params.Active) { + if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { + return apierrors.NewSCIMInternalServerError("Error banning user").WithInternalError(err) + } + if err := models.Logout(tx, user.ID); err != nil { + return apierrors.NewSCIMInternalServerError("Error invalidating sessions").WithInternalError(err) + } + } + return nil }) From 8c2b44f647bbd393a88b10f55180e58f034e917b Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 17:16:58 +0300 Subject: [PATCH 079/101] fix: check non-SSO email collisions, sync sub on externalId change, generic group error --- internal/api/scim.go | 6 +++++- internal/api/scim_helpers.go | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 5e142a4be..bbed405ee 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -419,6 +419,7 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { if params.ExternalID != "" { user.Identities[i].ProviderID = params.ExternalID user.Identities[i].IdentityData["external_id"] = params.ExternalID + user.Identities[i].IdentityData["sub"] = params.ExternalID updateCols = append(updateCols, "provider_id") } else { delete(user.Identities[i].IdentityData, "external_id") @@ -562,6 +563,7 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S user.Identities[i].IdentityData = make(map[string]interface{}) } user.Identities[i].IdentityData["external_id"] = externalID + user.Identities[i].IdentityData["sub"] = externalID if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") @@ -599,6 +601,7 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S user.Identities[i].IdentityData = make(map[string]interface{}) } user.Identities[i].IdentityData["external_id"] = externalID + user.Identities[i].IdentityData["sub"] = externalID if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") @@ -748,6 +751,7 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S user.Identities[i].IdentityData = make(map[string]interface{}) } user.Identities[i].IdentityData["external_id"] = externalID + user.Identities[i].IdentityData["sub"] = externalID if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") @@ -1075,7 +1079,7 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { if err := tx.Update(group); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("Group with this displayName already exists", "uniqueness") + return apierrors.NewSCIMConflictError("Group already exists", "uniqueness") } return apierrors.NewSCIMInternalServerError("Error updating group").WithInternalError(err) } diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go index 302fc8f3d..6b0c82d1e 100644 --- a/internal/api/scim_helpers.go +++ b/internal/api/scim_helpers.go @@ -175,6 +175,14 @@ func parseSCIMActiveBool(val interface{}) (bool, error) { } func checkSCIMEmailUniqueness(tx *storage.Connection, email, aud, providerType string, excludeUserID uuid.UUID) error { + nonSSOUser, err := models.FindUserByEmailAndAudience(tx, email, aud) + if err != nil && !models.IsNotFoundError(err) { + return apierrors.NewSCIMInternalServerError("Error checking email uniqueness").WithInternalError(err) + } + if nonSSOUser != nil && nonSSOUser.ID != excludeUserID && !nonSSOUser.IsSSOUser { + return apierrors.NewSCIMConflictError("Email already in use by another user", "uniqueness") + } + ssoUsers, err := models.FindSSOUsersByEmailAndProvider(tx, email, aud, providerType) if err != nil { return apierrors.NewSCIMInternalServerError("Error checking email uniqueness").WithInternalError(err) From 270db7f327d7413dc599566c80153714fa033475 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Fri, 6 Feb 2026 17:35:08 +0300 Subject: [PATCH 080/101] fix: default members in group list, avoid eager loading, make timestamps NOT NULL --- internal/api/scim.go | 7 ++++--- internal/models/sso.go | 2 +- internal/models/user.go | 2 +- migrations/20251210100002_add_scim_groups.up.sql | 6 +++--- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index bbed405ee..d80e0deef 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -901,11 +901,12 @@ func (a *API) scimListGroups(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMInternalServerError("Error fetching groups").WithInternalError(err) } - includeMembers := strings.Contains(strings.ToLower(r.URL.Query().Get("attributes")), "members") + attrs := strings.ToLower(r.URL.Query().Get("attributes")) excludeMembers := strings.Contains(strings.ToLower(r.URL.Query().Get("excludedAttributes")), "members") + includeMembers := !excludeMembers && (attrs == "" || strings.Contains(attrs, "members")) var membersByGroup map[uuid.UUID][]*models.User - if includeMembers && !excludeMembers && len(groups) > 0 { + if includeMembers && len(groups) > 0 { groupIDs := make([]uuid.UUID, len(groups)) for i, g := range groups { groupIDs[i] = g.ID @@ -920,7 +921,7 @@ func (a *API) scimListGroups(w http.ResponseWriter, r *http.Request) error { resources := make([]interface{}, len(groups)) for i, group := range groups { var members []*models.User - if includeMembers && !excludeMembers { + if includeMembers { members = membersByGroup[group.ID] } resources[i] = a.groupToSCIMResponse(group, members) diff --git a/internal/models/sso.go b/internal/models/sso.go index a57c5fe7a..a55fd8629 100644 --- a/internal/models/sso.go +++ b/internal/models/sso.go @@ -304,7 +304,7 @@ func FindSSOProviderBySCIMToken(tx *storage.Connection, token string) (*SSOProvi hash := scimTokenHash(token) var provider SSOProvider - if err := tx.Eager().Q().Where("scim_enabled = ? AND scim_bearer_token_hash = ?", true, hash).First(&provider); err != nil { + if err := tx.Q().Where("scim_enabled = ? AND scim_bearer_token_hash = ?", true, hash).First(&provider); err != nil { if errors.Cause(err) == sql.ErrNoRows { return nil, SSOProviderNotFoundError{} } diff --git a/internal/models/user.go b/internal/models/user.go index 3250f88a8..45d9a4c44 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -732,7 +732,7 @@ func FindUsersByProviderWithFilter(tx *storage.Connection, provider string, filt LIMIT ? OFFSET ? ` args = append(args, count, offset) - if err := tx.Eager().RawQuery(query, args...).All(&users); err != nil { + if err := tx.Eager("Identities").RawQuery(query, args...).All(&users); err != nil { if errors.Cause(err) == sql.ErrNoRows { return users, totalResults, nil } diff --git a/migrations/20251210100002_add_scim_groups.up.sql b/migrations/20251210100002_add_scim_groups.up.sql index 4e9dbdd5b..76ba947f4 100644 --- a/migrations/20251210100002_add_scim_groups.up.sql +++ b/migrations/20251210100002_add_scim_groups.up.sql @@ -5,8 +5,8 @@ create table if not exists {{ index .Options "Namespace" }}.scim_groups ( sso_provider_id uuid not null, external_id text null, display_name text not null, - created_at timestamptz null, - updated_at timestamptz null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), constraint scim_groups_pkey primary key (id), constraint scim_groups_sso_provider_fkey foreign key (sso_provider_id) @@ -35,7 +35,7 @@ comment on column {{ index .Options "Namespace" }}.scim_groups.display_name is ' create table if not exists {{ index .Options "Namespace" }}.scim_group_members ( group_id uuid not null, user_id uuid not null, - created_at timestamptz null, + created_at timestamptz not null default now(), constraint scim_group_members_pkey primary key (group_id, user_id), constraint scim_group_members_group_fkey foreign key (group_id) From 407d495ce84ccfe7da0f436e5599a25e4748b793 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 03:23:55 +0300 Subject: [PATCH 081/101] chore: consolidate SCIM migrations into single file --- ...210100000_add_scim_to_sso_providers.up.sql | 59 ++++++++++++++++++- ...10100001_add_banned_reason_to_users.up.sql | 6 -- .../20251210100002_add_scim_groups.up.sql | 51 ---------------- 3 files changed, 58 insertions(+), 58 deletions(-) delete mode 100644 migrations/20251210100001_add_banned_reason_to_users.up.sql delete mode 100644 migrations/20251210100002_add_scim_groups.up.sql diff --git a/migrations/20251210100000_add_scim_to_sso_providers.up.sql b/migrations/20251210100000_add_scim_to_sso_providers.up.sql index 2c487525e..4fce688d7 100644 --- a/migrations/20251210100000_add_scim_to_sso_providers.up.sql +++ b/migrations/20251210100000_add_scim_to_sso_providers.up.sql @@ -1,5 +1,6 @@ --- Add SCIM provisioning support to SSO providers +-- Add SCIM provisioning support +-- Add SCIM columns to SSO providers do $$ begin alter table only {{ index .Options "Namespace" }}.sso_providers add column if not exists scim_enabled boolean null default false, @@ -13,3 +14,59 @@ comment on column {{ index .Options "Namespace" }}.sso_providers.scim_bearer_tok create unique index if not exists sso_providers_scim_token_hash_idx on {{ index .Options "Namespace" }}.sso_providers (scim_bearer_token_hash) where scim_bearer_token_hash is not null; + +-- Add banned_reason to users for SCIM deprovisioning +do $$ begin + alter table only {{ index .Options "Namespace" }}.users + add column if not exists banned_reason text null; +end $$; + +comment on column {{ index .Options "Namespace" }}.users.banned_reason is 'Auth: Reason for user ban (e.g., SCIM_DEPROVISIONED)'; + +-- SCIM Groups +create table if not exists {{ index .Options "Namespace" }}.scim_groups ( + id uuid not null, + sso_provider_id uuid not null, + external_id text null, + display_name text not null, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + + constraint scim_groups_pkey primary key (id), + constraint scim_groups_sso_provider_fkey foreign key (sso_provider_id) + references {{ index .Options "Namespace" }}.sso_providers (id) on delete cascade, + constraint "external_id not empty if set" check (external_id is null or char_length(external_id) > 0), + constraint "display_name not empty" check (char_length(display_name) > 0) +); + +create unique index if not exists scim_groups_sso_provider_external_id_idx + on {{ index .Options "Namespace" }}.scim_groups (sso_provider_id, external_id) + where external_id is not null; + +create unique index if not exists scim_groups_sso_provider_display_name_idx + on {{ index .Options "Namespace" }}.scim_groups (sso_provider_id, lower(display_name)); + +create index if not exists scim_groups_sso_provider_id_idx + on {{ index .Options "Namespace" }}.scim_groups (sso_provider_id); + +comment on table {{ index .Options "Namespace" }}.scim_groups is 'Auth: Manages SCIM groups provisioned by SSO identity providers.'; +comment on column {{ index .Options "Namespace" }}.scim_groups.external_id is 'Auth: The group ID from the external identity provider.'; +comment on column {{ index .Options "Namespace" }}.scim_groups.display_name is 'Auth: Human-readable name of the group.'; + +-- SCIM Group Members +create table if not exists {{ index .Options "Namespace" }}.scim_group_members ( + group_id uuid not null, + user_id uuid not null, + created_at timestamptz not null default now(), + + constraint scim_group_members_pkey primary key (group_id, user_id), + constraint scim_group_members_group_fkey foreign key (group_id) + references {{ index .Options "Namespace" }}.scim_groups (id) on delete cascade, + constraint scim_group_members_user_fkey foreign key (user_id) + references {{ index .Options "Namespace" }}.users (id) on delete cascade +); + +create index if not exists scim_group_members_user_id_idx + on {{ index .Options "Namespace" }}.scim_group_members (user_id); + +comment on table {{ index .Options "Namespace" }}.scim_group_members is 'Auth: Junction table for SCIM group membership.'; diff --git a/migrations/20251210100001_add_banned_reason_to_users.up.sql b/migrations/20251210100001_add_banned_reason_to_users.up.sql deleted file mode 100644 index fbf6d8779..000000000 --- a/migrations/20251210100001_add_banned_reason_to_users.up.sql +++ /dev/null @@ -1,6 +0,0 @@ -do $$ begin - alter table only {{ index .Options "Namespace" }}.users - add column if not exists banned_reason text null; -end $$; - -comment on column {{ index .Options "Namespace" }}.users.banned_reason is 'Auth: Reason for user ban (e.g., SCIM_DEPROVISIONED)'; diff --git a/migrations/20251210100002_add_scim_groups.up.sql b/migrations/20251210100002_add_scim_groups.up.sql deleted file mode 100644 index 76ba947f4..000000000 --- a/migrations/20251210100002_add_scim_groups.up.sql +++ /dev/null @@ -1,51 +0,0 @@ --- Add SCIM Groups support for SSO identity providers - -create table if not exists {{ index .Options "Namespace" }}.scim_groups ( - id uuid not null, - sso_provider_id uuid not null, - external_id text null, - display_name text not null, - created_at timestamptz not null default now(), - updated_at timestamptz not null default now(), - - constraint scim_groups_pkey primary key (id), - constraint scim_groups_sso_provider_fkey foreign key (sso_provider_id) - references {{ index .Options "Namespace" }}.sso_providers (id) on delete cascade, - constraint "external_id not empty if set" check (external_id is null or char_length(external_id) > 0), - constraint "display_name not empty" check (char_length(display_name) > 0) -); - --- Unique index scoped to SSO provider (only for non-null external_id) -create unique index if not exists scim_groups_sso_provider_external_id_idx - on {{ index .Options "Namespace" }}.scim_groups (sso_provider_id, external_id) - where external_id is not null; - --- Unique index for displayName per SSO provider (case-insensitive, required by Azure AD) -create unique index if not exists scim_groups_sso_provider_display_name_idx - on {{ index .Options "Namespace" }}.scim_groups (sso_provider_id, lower(display_name)); - --- Index for listing groups by SSO provider -create index if not exists scim_groups_sso_provider_id_idx - on {{ index .Options "Namespace" }}.scim_groups (sso_provider_id); - -comment on table {{ index .Options "Namespace" }}.scim_groups is 'Auth: Manages SCIM groups provisioned by SSO identity providers.'; -comment on column {{ index .Options "Namespace" }}.scim_groups.external_id is 'Auth: The group ID from the external identity provider.'; -comment on column {{ index .Options "Namespace" }}.scim_groups.display_name is 'Auth: Human-readable name of the group.'; - -create table if not exists {{ index .Options "Namespace" }}.scim_group_members ( - group_id uuid not null, - user_id uuid not null, - created_at timestamptz not null default now(), - - constraint scim_group_members_pkey primary key (group_id, user_id), - constraint scim_group_members_group_fkey foreign key (group_id) - references {{ index .Options "Namespace" }}.scim_groups (id) on delete cascade, - constraint scim_group_members_user_fkey foreign key (user_id) - references {{ index .Options "Namespace" }}.users (id) on delete cascade -); - --- Index for groups that the user belong to -create index if not exists scim_group_members_user_id_idx - on {{ index .Options "Namespace" }}.scim_group_members (user_id); - -comment on table {{ index .Options "Namespace" }}.scim_group_members is 'Auth: Junction table for SCIM group membership.'; From f22a0b7065c2c1a032025dd41342ea2b65b34240 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 03:34:33 +0300 Subject: [PATCH 082/101] fix: use correct audit action when reprovisioning inactive user --- internal/api/scim.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index d80e0deef..2ff0bb129 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -206,10 +206,14 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { } } + auditAction := "reactivated" + if params.Active != nil && !bool(*params.Active) { + auditAction = "reprovisioned_inactive" + } if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, candidate, models.UserModifiedAction, utilities.GetIPAddress(r), map[string]interface{}{ "provider": "scim", "sso_provider_id": provider.ID, - "action": "reactivated", + "action": auditAction, }); terr != nil { return apierrors.NewSCIMInternalServerError("Error recording audit log entry").WithInternalError(terr) } From f38e22621196ac70e810fc31386bb446b739ba7b Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 04:13:08 +0300 Subject: [PATCH 083/101] fix: update identity data and merge metadata on user reactivation --- internal/api/scim.go | 41 +++++++++++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 2ff0bb129..95ac068f5 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -182,7 +182,10 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { } if params.Name != nil { - metadata := make(map[string]interface{}) + metadata := candidate.UserMetaData + if metadata == nil { + metadata = make(map[string]interface{}) + } if params.Name.GivenName != "" { metadata["given_name"] = params.Name.GivenName } @@ -192,11 +195,9 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { if params.Name.Formatted != "" { metadata["full_name"] = params.Name.Formatted } - if len(metadata) > 0 { - candidate.UserMetaData = metadata - if err := tx.UpdateOnly(candidate, "raw_user_meta_data"); err != nil { - return apierrors.NewSCIMInternalServerError("Error updating user metadata").WithInternalError(err) - } + candidate.UserMetaData = metadata + if err := tx.UpdateOnly(candidate, "raw_user_meta_data"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating user metadata").WithInternalError(err) } } @@ -206,6 +207,34 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { } } + identityID := params.ExternalID + if identityID == "" { + identityID = params.UserName + } + for i := range candidate.Identities { + if candidate.Identities[i].Provider == providerType { + if candidate.Identities[i].IdentityData == nil { + candidate.Identities[i].IdentityData = make(map[string]interface{}) + } + candidate.Identities[i].IdentityData["user_name"] = params.UserName + candidate.Identities[i].IdentityData["email"] = email + if params.ExternalID != "" { + candidate.Identities[i].ProviderID = params.ExternalID + candidate.Identities[i].IdentityData["external_id"] = params.ExternalID + candidate.Identities[i].IdentityData["sub"] = params.ExternalID + } else if identityID != "" { + candidate.Identities[i].IdentityData["sub"] = identityID + } + if err := tx.UpdateOnly(&candidate.Identities[i], "provider_id", "identity_data"); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") + } + return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) + } + break + } + } + auditAction := "reactivated" if params.Active != nil && !bool(*params.Active) { auditAction = "reprovisioned_inactive" From 0873bf892328aa3db1616b4478df4cfcaac1b6e0 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 04:13:41 +0300 Subject: [PATCH 084/101] fix: deduplicate member IDs before validation in AddMembers/SetMembers --- internal/models/scim_group.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index b308de17c..b775ff36f 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -169,6 +169,8 @@ func (g *SCIMGroup) AddMembers(tx *storage.Connection, userIDs []uuid.UUID) erro return nil } + userIDs = deduplicateUUIDs(userIDs) + identityTable := (&pop.Model{Value: Identity{}}).TableName() userTable := (&pop.Model{Value: User{}}).TableName() providerType := "sso:" + g.SSOProviderID.String() @@ -263,6 +265,8 @@ func (g *SCIMGroup) SetMembers(tx *storage.Connection, userIDs []uuid.UUID) erro return nil } + userIDs = deduplicateUUIDs(userIDs) + identityTable := (&pop.Model{Value: Identity{}}).TableName() userTable := (&pop.Model{Value: User{}}).TableName() providerType := "sso:" + g.SSOProviderID.String() @@ -375,3 +379,15 @@ func GetMembersForGroups(tx *storage.Connection, groupIDs []uuid.UUID) (map[uuid } return result, nil } + +func deduplicateUUIDs(ids []uuid.UUID) []uuid.UUID { + seen := make(map[uuid.UUID]struct{}, len(ids)) + out := make([]uuid.UUID, 0, len(ids)) + for _, id := range ids { + if _, ok := seen[id]; !ok { + seen[id] = struct{}{} + out = append(out, id) + } + } + return out +} From a6e6ce5cc47c640aedd4ca00b8bcb42684298663 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 04:17:19 +0300 Subject: [PATCH 085/101] refactor: extract identity update helpers to reduce SCIM patch complexity --- internal/api/scim.go | 433 ++++++++++++++--------------------- internal/api/scim_helpers.go | 36 +++ 2 files changed, 213 insertions(+), 256 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 95ac068f5..5ca7319e0 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -564,292 +564,213 @@ func (a *API) applySCIMUserPatch(tx *storage.Connection, user *models.User, op S switch strings.ToLower(op.Op) { case "remove": - if path == nil { - return apierrors.NewSCIMBadRequestError("remove operation requires a path", "noTarget") + return a.applySCIMUserRemove(tx, user, op, path, providerType) + case "add": + return a.applySCIMUserAdd(tx, user, op, path, providerType) + case "replace": + return a.applySCIMUserReplace(tx, user, op, path, providerType) + default: + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported patch operation: %s", op.Op), "invalidSyntax") + } +} + +func (a *API) applySCIMUserRemove(tx *storage.Connection, user *models.User, op SCIMPatchOperation, path *filter.Path, providerType string) error { + if path == nil { + return apierrors.NewSCIMBadRequestError("remove operation requires a path", "noTarget") + } + attrName := strings.ToLower(path.AttributePath.AttributeName) + if attrName == "externalid" { + if identity := findSSOIdentity(user, providerType); identity != nil { + if identity.IdentityData != nil { + delete(identity.IdentityData, "external_id") + } + if err := tx.UpdateOnly(identity, "identity_data"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) + } } + return nil + } + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported remove path: %s", op.Path), "invalidPath") +} + +func (a *API) applySCIMUserAdd(tx *storage.Connection, user *models.User, op SCIMPatchOperation, path *filter.Path, providerType string) error { + if path != nil { attrName := strings.ToLower(path.AttributePath.AttributeName) if attrName == "externalid" { - for i := range user.Identities { - if user.Identities[i].Provider == providerType { - if user.Identities[i].IdentityData != nil { - delete(user.Identities[i].IdentityData, "external_id") - } - if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { - return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) - } - break - } + externalID, ok := op.Value.(string) + if !ok { + return apierrors.NewSCIMBadRequestError("externalId must be a string", "invalidValue") + } + if identity := findSSOIdentity(user, providerType); identity != nil { + return setSCIMExternalID(tx, identity, externalID) } return nil } - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported remove path: %s", op.Path), "invalidPath") + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported add path: %s", op.Path), "invalidPath") + } - case "add": - if path != nil { - attrName := strings.ToLower(path.AttributePath.AttributeName) - if attrName == "externalid" { - if externalID, ok := op.Value.(string); ok { - for i := range user.Identities { - if user.Identities[i].Provider == providerType { - user.Identities[i].ProviderID = externalID - if user.Identities[i].IdentityData == nil { - user.Identities[i].IdentityData = make(map[string]interface{}) - } - user.Identities[i].IdentityData["external_id"] = externalID - user.Identities[i].IdentityData["sub"] = externalID - if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { - if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") - } - return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) - } - break - } + valueMap, ok := op.Value.(map[string]interface{}) + if !ok { + return apierrors.NewSCIMBadRequestError("add operation without path requires an object value", "invalidValue") + } + for key, val := range valueMap { + if key == "" { + continue + } + keyPath, err := filter.ParsePath([]byte(key)) + if err != nil { + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid attribute path: %s", key), "invalidPath") + } + if strings.ToLower(keyPath.AttributePath.AttributeName) == "externalid" { + if externalID, ok := val.(string); ok { + if identity := findSSOIdentity(user, providerType); identity != nil { + if err := setSCIMExternalID(tx, identity, externalID); err != nil { + return err } - return nil } - return apierrors.NewSCIMBadRequestError("externalId must be a string", "invalidValue") } - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported add path: %s", op.Path), "invalidPath") } + } + return nil +} - valueMap, ok := op.Value.(map[string]interface{}) - if !ok { - return apierrors.NewSCIMBadRequestError("add operation without path requires an object value", "invalidValue") +func (a *API) applySCIMUserReplace(tx *storage.Connection, user *models.User, op SCIMPatchOperation, path *filter.Path, providerType string) error { + if path != nil { + return a.applySCIMUserReplaceWithPath(tx, user, op, path, providerType) + } + + valueMap, ok := op.Value.(map[string]interface{}) + if !ok { + return apierrors.NewSCIMBadRequestError("replace operation value must be an object when path is not specified", "invalidValue") + } + if user.UserMetaData == nil { + user.UserMetaData = make(map[string]interface{}) + } + metadataUpdated := false + for key, val := range valueMap { + if key == "" { + continue } - for key, val := range valueMap { - if key == "" { - continue - } - keyPath, err := filter.ParsePath([]byte(key)) - if err != nil { - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid attribute path: %s", key), "invalidPath") - } - if strings.ToLower(keyPath.AttributePath.AttributeName) == "externalid" { - if externalID, ok := val.(string); ok { - for i := range user.Identities { - if user.Identities[i].Provider == providerType { - user.Identities[i].ProviderID = externalID - if user.Identities[i].IdentityData == nil { - user.Identities[i].IdentityData = make(map[string]interface{}) - } - user.Identities[i].IdentityData["external_id"] = externalID - user.Identities[i].IdentityData["sub"] = externalID - if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { - if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") - } - return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) - } - break - } - } - } - } + keyPath, err := filter.ParsePath([]byte(key)) + if err != nil { + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid attribute path: %s", key), "invalidPath") } - return nil + attrName := strings.ToLower(keyPath.AttributePath.AttributeName) + subAttr := strings.ToLower(keyPath.AttributePath.SubAttributeName()) - case "replace": - if path != nil { - attrName := strings.ToLower(path.AttributePath.AttributeName) - switch { - case attrName == "active": - active, err := parseSCIMActiveBool(op.Value) - if err != nil { - return err - } - if active { - if err := user.Ban(tx, 0, nil); err != nil { - return apierrors.NewSCIMInternalServerError("Error unbanning user").WithInternalError(err) - } - return nil - } - if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { - return apierrors.NewSCIMInternalServerError("Error banning user").WithInternalError(err) - } - if err := models.Logout(tx, user.ID); err != nil { - return apierrors.NewSCIMInternalServerError("Error invalidating sessions").WithInternalError(err) - } - return nil - case attrName == "username": - userName, ok := op.Value.(string) - if !ok { - return apierrors.NewSCIMBadRequestError("userName must be a string", "invalidValue") - } - for i := range user.Identities { - if user.Identities[i].Provider == providerType { - if user.Identities[i].IdentityData == nil { - user.Identities[i].IdentityData = make(map[string]interface{}) - } - user.Identities[i].IdentityData["user_name"] = userName - if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { - return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) - } - break - } - } - return nil - case attrName == "emails" && path.ValueExpression != nil && strings.ToLower(path.SubAttributeName()) == "value": - newEmail, ok := op.Value.(string) - if !ok { - return apierrors.NewSCIMBadRequestError("email value must be a string", "invalidValue") - } - validatedEmail, err := a.validateEmail(newEmail) - if err != nil { - return apierrors.NewSCIMBadRequestError("Invalid email address", "invalidValue") - } - if err := checkSCIMEmailUniqueness(tx, validatedEmail, a.config.JWT.Aud, providerType, user.ID); err != nil { - return err - } - if err := user.SetEmail(tx, validatedEmail); err != nil { - if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("Email already in use", "uniqueness") - } - return apierrors.NewSCIMInternalServerError("Error updating email").WithInternalError(err) - } - for i := range user.Identities { - if user.Identities[i].Provider == providerType { - if user.Identities[i].IdentityData == nil { - user.Identities[i].IdentityData = make(map[string]interface{}) - } - user.Identities[i].IdentityData["email"] = validatedEmail - if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { - return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) - } - break + switch { + case attrName == "username": + if userName, ok := val.(string); ok && userName != "" { + if identity := findSSOIdentity(user, providerType); identity != nil { + if err := setSCIMIdentityField(tx, identity, "user_name", userName); err != nil { + return err } } - return nil - default: - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported replace path: %s", op.Path), "invalidPath") } - } - - valueMap, ok := op.Value.(map[string]interface{}) - if !ok { - return apierrors.NewSCIMBadRequestError("replace operation value must be an object when path is not specified", "invalidValue") - } - if user.UserMetaData == nil { - user.UserMetaData = make(map[string]interface{}) - } - metadataUpdated := false - for key, val := range valueMap { - if key == "" { - continue + case attrName == "name" && subAttr == "formatted": + if v, ok := val.(string); ok { + user.UserMetaData["full_name"] = v + metadataUpdated = true } - keyPath, err := filter.ParsePath([]byte(key)) - if err != nil { - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid attribute path: %s", key), "invalidPath") + case attrName == "name" && subAttr == "familyname": + if v, ok := val.(string); ok { + user.UserMetaData["family_name"] = v + metadataUpdated = true } - attrName := strings.ToLower(keyPath.AttributePath.AttributeName) - subAttr := strings.ToLower(keyPath.AttributePath.SubAttributeName()) - - switch { - case attrName == "username": - if userName, ok := val.(string); ok && userName != "" { - for i := range user.Identities { - if user.Identities[i].Provider == providerType { - if user.Identities[i].IdentityData == nil { - user.Identities[i].IdentityData = make(map[string]interface{}) - } - user.Identities[i].IdentityData["user_name"] = userName - if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { - return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) - } - break - } - } - } - case attrName == "name" && subAttr == "formatted": - if v, ok := val.(string); ok { - user.UserMetaData["full_name"] = v - metadataUpdated = true - } - case attrName == "name" && subAttr == "familyname": - if v, ok := val.(string); ok { - user.UserMetaData["family_name"] = v - metadataUpdated = true - } - case attrName == "name" && subAttr == "givenname": - if v, ok := val.(string); ok { - user.UserMetaData["given_name"] = v - metadataUpdated = true - } - case attrName == "externalid": - if externalID, ok := val.(string); ok { - for i := range user.Identities { - if user.Identities[i].Provider == providerType { - user.Identities[i].ProviderID = externalID - if user.Identities[i].IdentityData == nil { - user.Identities[i].IdentityData = make(map[string]interface{}) - } - user.Identities[i].IdentityData["external_id"] = externalID - user.Identities[i].IdentityData["sub"] = externalID - if err := tx.UpdateOnly(&user.Identities[i], "provider_id", "identity_data"); err != nil { - if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") - } - return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) - } - break - } - } - } - case attrName == "emails" && keyPath.ValueExpression != nil && strings.ToLower(keyPath.SubAttributeName()) == "value": - if emailValue, ok := val.(string); ok { - validatedEmail, err := a.validateEmail(emailValue) - if err != nil { - return apierrors.NewSCIMBadRequestError("Invalid email address", "invalidValue") - } - if err := checkSCIMEmailUniqueness(tx, validatedEmail, a.config.JWT.Aud, providerType, user.ID); err != nil { + case attrName == "name" && subAttr == "givenname": + if v, ok := val.(string); ok { + user.UserMetaData["given_name"] = v + metadataUpdated = true + } + case attrName == "externalid": + if externalID, ok := val.(string); ok { + if identity := findSSOIdentity(user, providerType); identity != nil { + if err := setSCIMExternalID(tx, identity, externalID); err != nil { return err } - if err := user.SetEmail(tx, validatedEmail); err != nil { - if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("Email already in use", "uniqueness") - } - return apierrors.NewSCIMInternalServerError("Error updating email").WithInternalError(err) - } - for i := range user.Identities { - if user.Identities[i].Provider == providerType { - if user.Identities[i].IdentityData == nil { - user.Identities[i].IdentityData = make(map[string]interface{}) - } - user.Identities[i].IdentityData["email"] = validatedEmail - if err := tx.UpdateOnly(&user.Identities[i], "identity_data"); err != nil { - return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) - } - break - } - } } - case attrName == "active": - active, err := parseSCIMActiveBool(val) - if err != nil { + } + case attrName == "emails" && keyPath.ValueExpression != nil && strings.ToLower(keyPath.SubAttributeName()) == "value": + if emailValue, ok := val.(string); ok { + if err := a.applySCIMEmailUpdate(tx, user, emailValue, providerType); err != nil { return err } - if active { - if err := user.Ban(tx, 0, nil); err != nil { - return apierrors.NewSCIMInternalServerError("Error unbanning user").WithInternalError(err) - } - } else { - if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { - return apierrors.NewSCIMInternalServerError("Error banning user").WithInternalError(err) - } - if err := models.Logout(tx, user.ID); err != nil { - return apierrors.NewSCIMInternalServerError("Error invalidating sessions").WithInternalError(err) - } - } } - } - if metadataUpdated { - if err := tx.UpdateOnly(user, "raw_user_meta_data"); err != nil { - return apierrors.NewSCIMInternalServerError("Error updating user metadata").WithInternalError(err) + case attrName == "active": + if err := a.applySCIMActiveUpdate(tx, user, val); err != nil { + return err } } + } + if metadataUpdated { + if err := tx.UpdateOnly(user, "raw_user_meta_data"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating user metadata").WithInternalError(err) + } + } + return nil +} +func (a *API) applySCIMUserReplaceWithPath(tx *storage.Connection, user *models.User, op SCIMPatchOperation, path *filter.Path, providerType string) error { + attrName := strings.ToLower(path.AttributePath.AttributeName) + switch { + case attrName == "active": + return a.applySCIMActiveUpdate(tx, user, op.Value) + case attrName == "username": + userName, ok := op.Value.(string) + if !ok { + return apierrors.NewSCIMBadRequestError("userName must be a string", "invalidValue") + } + if identity := findSSOIdentity(user, providerType); identity != nil { + return setSCIMIdentityField(tx, identity, "user_name", userName) + } + return nil + case attrName == "emails" && path.ValueExpression != nil && strings.ToLower(path.SubAttributeName()) == "value": + newEmail, ok := op.Value.(string) + if !ok { + return apierrors.NewSCIMBadRequestError("email value must be a string", "invalidValue") + } + return a.applySCIMEmailUpdate(tx, user, newEmail, providerType) default: - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported patch operation: %s", op.Op), "invalidSyntax") + return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Unsupported replace path: %s", op.Path), "invalidPath") + } +} + +func (a *API) applySCIMEmailUpdate(tx *storage.Connection, user *models.User, newEmail, providerType string) error { + validatedEmail, err := a.validateEmail(newEmail) + if err != nil { + return apierrors.NewSCIMBadRequestError("Invalid email address", "invalidValue") + } + if err := checkSCIMEmailUniqueness(tx, validatedEmail, a.config.JWT.Aud, providerType, user.ID); err != nil { + return err + } + if err := user.SetEmail(tx, validatedEmail); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("Email already in use", "uniqueness") + } + return apierrors.NewSCIMInternalServerError("Error updating email").WithInternalError(err) + } + if identity := findSSOIdentity(user, providerType); identity != nil { + return setSCIMIdentityField(tx, identity, "email", validatedEmail) + } + return nil +} + +func (a *API) applySCIMActiveUpdate(tx *storage.Connection, user *models.User, val interface{}) error { + active, err := parseSCIMActiveBool(val) + if err != nil { + return err + } + if active { + if err := user.Ban(tx, 0, nil); err != nil { + return apierrors.NewSCIMInternalServerError("Error unbanning user").WithInternalError(err) + } + return nil + } + if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { + return apierrors.NewSCIMInternalServerError("Error banning user").WithInternalError(err) + } + if err := models.Logout(tx, user.ID); err != nil { + return apierrors.NewSCIMInternalServerError("Error invalidating sessions").WithInternalError(err) } return nil } diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go index 6b0c82d1e..a9b365a9c 100644 --- a/internal/api/scim_helpers.go +++ b/internal/api/scim_helpers.go @@ -174,6 +174,42 @@ func parseSCIMActiveBool(val interface{}) (bool, error) { return false, apierrors.NewSCIMBadRequestError("active must be a boolean or \"true\"/\"false\"", "invalidValue") } +func findSSOIdentity(user *models.User, providerType string) *models.Identity { + for i := range user.Identities { + if user.Identities[i].Provider == providerType { + return &user.Identities[i] + } + } + return nil +} + +func setSCIMExternalID(tx *storage.Connection, identity *models.Identity, externalID string) error { + identity.ProviderID = externalID + if identity.IdentityData == nil { + identity.IdentityData = make(map[string]interface{}) + } + identity.IdentityData["external_id"] = externalID + identity.IdentityData["sub"] = externalID + if err := tx.UpdateOnly(identity, "provider_id", "identity_data"); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") + } + return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) + } + return nil +} + +func setSCIMIdentityField(tx *storage.Connection, identity *models.Identity, key, value string) error { + if identity.IdentityData == nil { + identity.IdentityData = make(map[string]interface{}) + } + identity.IdentityData[key] = value + if err := tx.UpdateOnly(identity, "identity_data"); err != nil { + return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) + } + return nil +} + func checkSCIMEmailUniqueness(tx *storage.Connection, email, aud, providerType string, excludeUserID uuid.UUID) error { nonSSOUser, err := models.FindUserByEmailAndAudience(tx, email, aud) if err != nil && !models.IsNotFoundError(err) { From 0474ed1e551447827bae334a096cee1e768a509d Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 04:19:59 +0300 Subject: [PATCH 086/101] feat: gate SCIM routes behind GOTRUE_SCIM_ENABLED config flag --- hack/test.env | 1 + internal/api/api.go | 2 ++ internal/conf/configuration.go | 5 +++++ 3 files changed, 8 insertions(+) diff --git a/hack/test.env b/hack/test.env index dc4769eaa..09c38a590 100644 --- a/hack/test.env +++ b/hack/test.env @@ -129,6 +129,7 @@ GOTRUE_SECURITY_CAPTCHA_PROVIDER="hcaptcha" GOTRUE_SECURITY_CAPTCHA_SECRET="0x0000000000000000000000000000000000000000" GOTRUE_SECURITY_CAPTCHA_TIMEOUT="10s" GOTRUE_SAML_ENABLED="true" +GOTRUE_SCIM_ENABLED="true" GOTRUE_SAML_PRIVATE_KEY="MIIEowIBAAKCAQEAszrVveMQcSsa0Y+zN1ZFb19cRS0jn4UgIHTprW2tVBmO2PABzjY3XFCfx6vPirMAPWBYpsKmXrvm1tr0A6DZYmA8YmJd937VUQ67fa6DMyppBYTjNgGEkEhmKuszvF3MARsIKCGtZqUrmS7UG4404wYxVppnr2EYm3RGtHlkYsXu20MBqSDXP47bQP+PkJqC3BuNGk3xt5UHl2FSFpTHelkI6lBynw16B+lUT1F96SERNDaMqi/TRsZdGe5mB/29ngC/QBMpEbRBLNRir5iUevKS7Pn4aph9Qjaxx/97siktK210FJT23KjHpgcUfjoQ6BgPBTLtEeQdRyDuc/CgfwIDAQABAoIBAGYDWOEpupQPSsZ4mjMnAYJwrp4ZISuMpEqVAORbhspVeb70bLKonT4IDcmiexCg7cQBcLQKGpPVM4CbQ0RFazXZPMVq470ZDeWDEyhoCfk3bGtdxc1Zc9CDxNMs6FeQs6r1beEZug6weG5J/yRn/qYxQife3qEuDMl+lzfl2EN3HYVOSnBmdt50dxRuX26iW3nqqbMRqYn9OHuJ1LvRRfYeyVKqgC5vgt/6Tf7DAJwGe0dD7q08byHV8DBZ0pnMVU0bYpf1GTgMibgjnLjK//EVWafFHtN+RXcjzGmyJrk3+7ZyPUpzpDjO21kpzUQLrpEkkBRnmg6bwHnSrBr8avECgYEA3pq1PTCAOuLQoIm1CWR9/dhkbJQiKTJevlWV8slXQLR50P0WvI2RdFuSxlWmA4xZej8s4e7iD3MYye6SBsQHygOVGc4efvvEZV8/XTlDdyj7iLVGhnEmu2r7AFKzy8cOvXx0QcLg+zNd7vxZv/8D3Qj9Jje2LjLHKM5n/dZ3RzUCgYEAzh5Lo2anc4WN8faLGt7rPkGQF+7/18ImQE11joHWa3LzAEy7FbeOGpE/vhOv5umq5M/KlWFIRahMEQv4RusieHWI19ZLIP+JwQFxWxS+cPp3xOiGcquSAZnlyVSxZ//dlVgaZq2o2MfrxECcovRlaknl2csyf+HjFFwKlNxHm2MCgYAr//R3BdEy0oZeVRndo2lr9YvUEmu2LOihQpWDCd0fQw0ZDA2kc28eysL2RROte95r1XTvq6IvX5a0w11FzRWlDpQ4J4/LlcQ6LVt+98SoFwew+/PWuyLmxLycUbyMOOpm9eSc4wJJZNvaUzMCSkvfMtmm5jgyZYMMQ9A2Ul/9SQKBgB9mfh9mhBwVPIqgBJETZMMXOdxrjI5SBYHGSyJqpT+5Q0vIZLfqPrvNZOiQFzwWXPJ+tV4Mc/YorW3rZOdo6tdvEGnRO6DLTTEaByrY/io3/gcBZXoSqSuVRmxleqFdWWRnB56c1hwwWLqNHU+1671FhL6pNghFYVK4suP6qu4BAoGBAMk+VipXcIlD67mfGrET/xDqiWWBZtgTzTMjTpODhDY1GZck1eb4CQMP5j5V3gFJ4cSgWDJvnWg8rcz0unz/q4aeMGl1rah5WNDWj1QKWMS6vJhMHM/rqN1WHWR0ZnV83svYgtg0zDnQKlLujqW4JmGXLMU7ur6a+e6lpa1fvLsP" GOTRUE_MAX_VERIFIED_FACTORS=10 GOTRUE_SMS_TEST_OTP_VALID_UNTIL="" diff --git a/internal/api/api.go b/internal/api/api.go index d4c157107..448ffb0a0 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -202,6 +202,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne }) // SCIM v2 API endpoints + if api.config.SCIM.Enabled { r.Route("/scim/v2", func(r *router) { r.Use(api.requireSCIMAuthentication) @@ -239,6 +240,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne }) }) }) + } r.Route("/", func(r *router) { diff --git a/internal/conf/configuration.go b/internal/conf/configuration.go index d5df496ef..518a45301 100644 --- a/internal/conf/configuration.go +++ b/internal/conf/configuration.go @@ -351,6 +351,7 @@ type GlobalConfiguration struct { Sessions SessionsConfiguration `json:"sessions"` MFA MFAConfiguration `json:"MFA"` SAML SAMLConfiguration `json:"saml"` + SCIM SCIMConfiguration `json:"scim"` CORS CORSConfiguration `json:"cors"` IndexWorker IndexWorkerConfiguration `json:"index_worker" split_words:"true"` @@ -358,6 +359,10 @@ type GlobalConfiguration struct { Reloading ReloadingConfiguration `json:"reloading"` } +type SCIMConfiguration struct { + Enabled bool `json:"enabled" default:"false"` +} + type CORSConfiguration struct { AllowedHeaders []string `json:"allowed_headers" split_words:"true"` } From 6b96df9bd95524f63f4ca24572fd36941aada4a0 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 04:20:49 +0300 Subject: [PATCH 087/101] fix: validate schemas field in SCIM request bodies per RFC 7644 --- internal/api/scim_types.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/internal/api/scim_types.go b/internal/api/scim_types.go index 4d71e9d69..16d4c82ee 100644 --- a/internal/api/scim_types.go +++ b/internal/api/scim_types.go @@ -59,6 +59,9 @@ type SCIMUserParams struct { } func (p *SCIMUserParams) Validate() error { + if err := requireSCIMSchema(p.Schemas, SCIMSchemaUser); err != nil { + return err + } if p.UserName == "" { return apierrors.NewSCIMBadRequestError("userName is required", "invalidSyntax") } @@ -85,6 +88,9 @@ type SCIMGroupParams struct { } func (p *SCIMGroupParams) Validate() error { + if err := requireSCIMSchema(p.Schemas, SCIMSchemaGroup); err != nil { + return err + } if p.DisplayName == "" { return apierrors.NewSCIMBadRequestError("displayName is required", "invalidSyntax") } @@ -106,6 +112,9 @@ type SCIMPatchRequest struct { } func (p *SCIMPatchRequest) Validate() error { + if err := requireSCIMSchema(p.Schemas, SCIMSchemaPatchOp); err != nil { + return err + } if len(p.Operations) == 0 { return apierrors.NewSCIMBadRequestError("At least one operation is required", "invalidSyntax") } @@ -155,3 +164,15 @@ type SCIMListResponse struct { ItemsPerPage int `json:"itemsPerPage"` Resources []interface{} `json:"Resources"` } + +func requireSCIMSchema(schemas []string, required string) error { + for _, s := range schemas { + if s == required { + return nil + } + } + return apierrors.NewSCIMBadRequestError( + fmt.Sprintf("schemas must include %s", required), + "invalidValue", + ) +} From c28221ff845cc33604f6849ee932d4f88a088162 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 04:22:27 +0300 Subject: [PATCH 088/101] fix: use NULLIF in COALESCE to skip empty userName in filter queries --- internal/api/scim_filter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/api/scim_filter.go b/internal/api/scim_filter.go index 6548f6df3..3a9bc15eb 100644 --- a/internal/api/scim_filter.go +++ b/internal/api/scim_filter.go @@ -10,7 +10,7 @@ import ( ) var SCIMUserFilterAttrs = map[string]string{ - "username": "COALESCE(i.identity_data->>'user_name', u.email)", + "username": "COALESCE(NULLIF(i.identity_data->>'user_name', ''), u.email)", "externalid": "i.identity_data->>'external_id'", "email": "u.email", "emails.value": "u.email", From 1459b26722f50ff86cf8203b9bfe3395be472ade Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 04:22:30 +0300 Subject: [PATCH 089/101] fix: add safety LIMIT to GetMembers query --- internal/models/scim_group.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index b775ff36f..72f5415a2 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -246,7 +246,7 @@ func (g *SCIMGroup) GetMembers(tx *storage.Connection) ([]*User, error) { users := []*User{} userTable := (&pop.Model{Value: User{}}).TableName() if err := tx.RawQuery( - "SELECT u.* FROM "+userTable+" u INNER JOIN "+scimGroupMemberTableName()+" m ON u.id = m.user_id WHERE m.group_id = ? ORDER BY u.email ASC", + "SELECT u.* FROM "+userTable+" u INNER JOIN "+scimGroupMemberTableName()+" m ON u.id = m.user_id WHERE m.group_id = ? ORDER BY u.email ASC LIMIT 10000", g.ID, ).All(&users); err != nil { if errors.Cause(err) == sql.ErrNoRows { From 767dabba37d62e6013f6444ead29f5d6831f4650 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 04:22:34 +0300 Subject: [PATCH 090/101] refactor: deduplicate schema and resource type definitions --- internal/api/scim.go | 191 ++++++++++++++----------------------------- 1 file changed, 61 insertions(+), 130 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 5ca7319e0..9886f8ea2 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -1414,29 +1414,65 @@ func (a *API) scimServiceProviderConfig(w http.ResponseWriter, r *http.Request) }) } -func (a *API) scimResourceTypes(w http.ResponseWriter, r *http.Request) error { +func (a *API) buildSCIMResourceType(id, name, endpoint, description, schema string) map[string]interface{} { baseURL := a.getSCIMBaseURL() + return map[string]interface{}{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, + "id": id, + "name": name, + "endpoint": endpoint, + "description": description, + "schema": schema, + "meta": map[string]interface{}{"resourceType": "ResourceType", "location": baseURL + "/scim/v2/ResourceTypes/" + id}, + } +} - resourceTypes := []interface{}{ - map[string]interface{}{ - "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, - "id": "User", - "name": "User", - "endpoint": "/Users", - "description": "User Account", - "schema": SCIMSchemaUser, - "meta": map[string]interface{}{"resourceType": "ResourceType", "location": baseURL + "/scim/v2/ResourceTypes/User"}, - }, - map[string]interface{}{ - "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, - "id": "Group", - "name": "Group", - "endpoint": "/Groups", - "description": "Group", - "schema": SCIMSchemaGroup, - "meta": map[string]interface{}{"resourceType": "ResourceType", "location": baseURL + "/scim/v2/ResourceTypes/Group"}, +func (a *API) buildSCIMSchema(id, name, description string, attributes []map[string]interface{}) map[string]interface{} { + baseURL := a.getSCIMBaseURL() + return map[string]interface{}{ + "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, + "id": id, + "name": name, + "description": description, + "attributes": attributes, + "meta": map[string]interface{}{ + "resourceType": "Schema", + "location": baseURL + "/scim/v2/Schemas/" + id, }, } +} + +var scimUserSchemaAttributes = []map[string]interface{}{ + {"name": "userName", "type": "string", "multiValued": false, "required": true, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "server"}, + {"name": "name", "type": "complex", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "subAttributes": []map[string]interface{}{ + {"name": "formatted", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "familyName", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "givenName", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + }}, + {"name": "emails", "type": "complex", "multiValued": true, "required": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "subAttributes": []map[string]interface{}{ + {"name": "value", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "type", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "primary", "type": "boolean", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + }}, + {"name": "active", "type": "boolean", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "externalId", "type": "string", "multiValued": false, "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, +} + +var scimGroupSchemaAttributes = []map[string]interface{}{ + {"name": "displayName", "type": "string", "multiValued": false, "required": true, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, + {"name": "members", "type": "complex", "multiValued": true, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "subAttributes": []map[string]interface{}{ + {"name": "value", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "immutable", "returned": "default", "uniqueness": "none"}, + {"name": "$ref", "type": "reference", "multiValued": false, "required": false, "caseExact": false, "mutability": "immutable", "returned": "default", "uniqueness": "none", "referenceTypes": []string{"User"}}, + {"name": "display", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readOnly", "returned": "default", "uniqueness": "none"}, + }}, + {"name": "externalId", "type": "string", "multiValued": false, "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, +} + +func (a *API) scimResourceTypes(w http.ResponseWriter, r *http.Request) error { + resourceTypes := []interface{}{ + a.buildSCIMResourceType("User", "User", "/Users", "User Account", SCIMSchemaUser), + a.buildSCIMResourceType("Group", "Group", "/Groups", "Group", SCIMSchemaGroup), + } return sendSCIMJSON(w, http.StatusOK, SCIMListResponse{ Schemas: []string{SCIMSchemaListResponse}, @@ -1448,52 +1484,9 @@ func (a *API) scimResourceTypes(w http.ResponseWriter, r *http.Request) error { } func (a *API) scimSchemas(w http.ResponseWriter, r *http.Request) error { - baseURL := a.getSCIMBaseURL() schemas := []interface{}{ - map[string]interface{}{ - "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, - "id": SCIMSchemaUser, - "name": "User", - "description": "User Account", - "attributes": []map[string]interface{}{ - {"name": "userName", "type": "string", "multiValued": false, "required": true, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "server"}, - {"name": "name", "type": "complex", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "subAttributes": []map[string]interface{}{ - {"name": "formatted", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - {"name": "familyName", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - {"name": "givenName", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - }}, - {"name": "emails", "type": "complex", "multiValued": true, "required": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "subAttributes": []map[string]interface{}{ - {"name": "value", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - {"name": "type", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - {"name": "primary", "type": "boolean", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - }}, - {"name": "active", "type": "boolean", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - {"name": "externalId", "type": "string", "multiValued": false, "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - }, - "meta": map[string]interface{}{ - "resourceType": "Schema", - "location": baseURL + "/scim/v2/Schemas/" + SCIMSchemaUser, - }, - }, - map[string]interface{}{ - "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, - "id": SCIMSchemaGroup, - "name": "Group", - "description": "Group", - "attributes": []map[string]interface{}{ - {"name": "displayName", "type": "string", "multiValued": false, "required": true, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - {"name": "members", "type": "complex", "multiValued": true, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "subAttributes": []map[string]interface{}{ - {"name": "value", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "immutable", "returned": "default", "uniqueness": "none"}, - {"name": "$ref", "type": "reference", "multiValued": false, "required": false, "caseExact": false, "mutability": "immutable", "returned": "default", "uniqueness": "none", "referenceTypes": []string{"User"}}, - {"name": "display", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readOnly", "returned": "default", "uniqueness": "none"}, - }}, - {"name": "externalId", "type": "string", "multiValued": false, "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - }, - "meta": map[string]interface{}{ - "resourceType": "Schema", - "location": baseURL + "/scim/v2/Schemas/" + SCIMSchemaGroup, - }, - }, + a.buildSCIMSchema(SCIMSchemaUser, "User", "User Account", scimUserSchemaAttributes), + a.buildSCIMSchema(SCIMSchemaGroup, "Group", "Group", scimGroupSchemaAttributes), } return sendSCIMJSON(w, http.StatusOK, SCIMListResponse{ @@ -1507,31 +1500,13 @@ func (a *API) scimSchemas(w http.ResponseWriter, r *http.Request) error { func (a *API) scimResourceTypeByID(w http.ResponseWriter, r *http.Request) error { resourceTypeID := chi.URLParam(r, "resource_type_id") - baseURL := a.getSCIMBaseURL() var resourceType map[string]interface{} - switch resourceTypeID { case "User": - resourceType = map[string]interface{}{ - "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, - "id": "User", - "name": "User", - "endpoint": "/Users", - "description": "User Account", - "schema": SCIMSchemaUser, - "meta": map[string]interface{}{"resourceType": "ResourceType", "location": baseURL + "/scim/v2/ResourceTypes/User"}, - } + resourceType = a.buildSCIMResourceType("User", "User", "/Users", "User Account", SCIMSchemaUser) case "Group": - resourceType = map[string]interface{}{ - "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:ResourceType"}, - "id": "Group", - "name": "Group", - "endpoint": "/Groups", - "description": "Group", - "schema": SCIMSchemaGroup, - "meta": map[string]interface{}{"resourceType": "ResourceType", "location": baseURL + "/scim/v2/ResourceTypes/Group"}, - } + resourceType = a.buildSCIMResourceType("Group", "Group", "/Groups", "Group", SCIMSchemaGroup) default: return sendSCIMError(w, http.StatusNotFound, "Resource type not found", "") } @@ -1541,57 +1516,13 @@ func (a *API) scimResourceTypeByID(w http.ResponseWriter, r *http.Request) error func (a *API) scimSchemaByID(w http.ResponseWriter, r *http.Request) error { schemaID := chi.URLParam(r, "schema_id") - baseURL := a.getSCIMBaseURL() var schema map[string]interface{} - switch schemaID { case SCIMSchemaUser: - schema = map[string]interface{}{ - "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, - "id": SCIMSchemaUser, - "name": "User", - "description": "User Account", - "attributes": []map[string]interface{}{ - {"name": "userName", "type": "string", "multiValued": false, "required": true, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "server"}, - {"name": "name", "type": "complex", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "subAttributes": []map[string]interface{}{ - {"name": "formatted", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - {"name": "familyName", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - {"name": "givenName", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - }}, - {"name": "emails", "type": "complex", "multiValued": true, "required": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "subAttributes": []map[string]interface{}{ - {"name": "value", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - {"name": "type", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - {"name": "primary", "type": "boolean", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - }}, - {"name": "active", "type": "boolean", "multiValued": false, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - {"name": "externalId", "type": "string", "multiValued": false, "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - }, - "meta": map[string]interface{}{ - "resourceType": "Schema", - "location": baseURL + "/scim/v2/Schemas/" + SCIMSchemaUser, - }, - } + schema = a.buildSCIMSchema(SCIMSchemaUser, "User", "User Account", scimUserSchemaAttributes) case SCIMSchemaGroup: - schema = map[string]interface{}{ - "schemas": []string{"urn:ietf:params:scim:schemas:core:2.0:Schema"}, - "id": SCIMSchemaGroup, - "name": "Group", - "description": "Group", - "attributes": []map[string]interface{}{ - {"name": "displayName", "type": "string", "multiValued": false, "required": true, "caseExact": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - {"name": "members", "type": "complex", "multiValued": true, "required": false, "mutability": "readWrite", "returned": "default", "uniqueness": "none", "subAttributes": []map[string]interface{}{ - {"name": "value", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "immutable", "returned": "default", "uniqueness": "none"}, - {"name": "$ref", "type": "reference", "multiValued": false, "required": false, "caseExact": false, "mutability": "immutable", "returned": "default", "uniqueness": "none", "referenceTypes": []string{"User"}}, - {"name": "display", "type": "string", "multiValued": false, "required": false, "caseExact": false, "mutability": "readOnly", "returned": "default", "uniqueness": "none"}, - }}, - {"name": "externalId", "type": "string", "multiValued": false, "required": false, "caseExact": true, "mutability": "readWrite", "returned": "default", "uniqueness": "none"}, - }, - "meta": map[string]interface{}{ - "resourceType": "Schema", - "location": baseURL + "/scim/v2/Schemas/" + SCIMSchemaGroup, - }, - } + schema = a.buildSCIMSchema(SCIMSchemaGroup, "Group", "Group", scimGroupSchemaAttributes) default: return sendSCIMError(w, http.StatusNotFound, "Schema not found", "") } From 10ca92cc131821f2cff1c072355c106a61d85dad Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 04:29:22 +0300 Subject: [PATCH 091/101] style: fix indentation of SCIM route registration block --- internal/api/api.go | 66 ++++++++++++++++++++++----------------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 448ffb0a0..7cf4954cb 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -203,43 +203,43 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne // SCIM v2 API endpoints if api.config.SCIM.Enabled { - r.Route("/scim/v2", func(r *router) { - r.Use(api.requireSCIMAuthentication) - - // SCIM-specific NotFound handler for proper error format - r.NotFound(api.scimNotFound) - - // Service Provider Configuration - r.Get("/ServiceProviderConfig", api.scimServiceProviderConfig) - r.Get("/ResourceTypes", api.scimResourceTypes) - r.Get("/ResourceTypes/{resource_type_id}", api.scimResourceTypeByID) - r.Get("/Schemas", api.scimSchemas) - r.Get("/Schemas/{schema_id}", api.scimSchemaByID) - - // User endpoints - r.Route("/Users", func(r *router) { - r.Get("/", api.scimListUsers) - r.Post("/", api.scimCreateUser) - r.Route("/{user_id}", func(r *router) { - r.Get("/", api.scimGetUser) - r.Put("/", api.scimReplaceUser) - r.Patch("/", api.scimPatchUser) - r.Delete("/", api.scimDeleteUser) + r.Route("/scim/v2", func(r *router) { + r.Use(api.requireSCIMAuthentication) + + // SCIM-specific NotFound handler for proper error format + r.NotFound(api.scimNotFound) + + // Service Provider Configuration + r.Get("/ServiceProviderConfig", api.scimServiceProviderConfig) + r.Get("/ResourceTypes", api.scimResourceTypes) + r.Get("/ResourceTypes/{resource_type_id}", api.scimResourceTypeByID) + r.Get("/Schemas", api.scimSchemas) + r.Get("/Schemas/{schema_id}", api.scimSchemaByID) + + // User endpoints + r.Route("/Users", func(r *router) { + r.Get("/", api.scimListUsers) + r.Post("/", api.scimCreateUser) + r.Route("/{user_id}", func(r *router) { + r.Get("/", api.scimGetUser) + r.Put("/", api.scimReplaceUser) + r.Patch("/", api.scimPatchUser) + r.Delete("/", api.scimDeleteUser) + }) }) - }) - // Group endpoints - r.Route("/Groups", func(r *router) { - r.Get("/", api.scimListGroups) - r.Post("/", api.scimCreateGroup) - r.Route("/{group_id}", func(r *router) { - r.Get("/", api.scimGetGroup) - r.Put("/", api.scimReplaceGroup) - r.Patch("/", api.scimPatchGroup) - r.Delete("/", api.scimDeleteGroup) + // Group endpoints + r.Route("/Groups", func(r *router) { + r.Get("/", api.scimListGroups) + r.Post("/", api.scimCreateGroup) + r.Route("/{group_id}", func(r *router) { + r.Get("/", api.scimGetGroup) + r.Put("/", api.scimReplaceGroup) + r.Patch("/", api.scimPatchGroup) + r.Delete("/", api.scimDeleteGroup) + }) }) }) - }) } r.Route("/", func(r *router) { From 6965e3cd63b56c57b9b29d5a0f45b2cafef31ebf Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 04:29:27 +0300 Subject: [PATCH 092/101] fix: reset ProviderID and sub when externalId is omitted in PUT and reactivation --- internal/api/scim.go | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 9886f8ea2..80b3dd1f7 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -222,8 +222,9 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { candidate.Identities[i].ProviderID = params.ExternalID candidate.Identities[i].IdentityData["external_id"] = params.ExternalID candidate.Identities[i].IdentityData["sub"] = params.ExternalID - } else if identityID != "" { - candidate.Identities[i].IdentityData["sub"] = identityID + } else { + candidate.Identities[i].ProviderID = params.UserName + candidate.Identities[i].IdentityData["sub"] = params.UserName } if err := tx.UpdateOnly(&candidate.Identities[i], "provider_id", "identity_data"); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { @@ -442,20 +443,19 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { if user.Identities[i].IdentityData == nil { user.Identities[i].IdentityData = make(map[string]interface{}) } - if params.UserName != "" { - user.Identities[i].IdentityData["user_name"] = params.UserName - } + user.Identities[i].IdentityData["user_name"] = params.UserName if email != "" { user.Identities[i].IdentityData["email"] = email } - updateCols := []string{"identity_data"} + updateCols := []string{"identity_data", "provider_id"} if params.ExternalID != "" { user.Identities[i].ProviderID = params.ExternalID user.Identities[i].IdentityData["external_id"] = params.ExternalID user.Identities[i].IdentityData["sub"] = params.ExternalID - updateCols = append(updateCols, "provider_id") } else { delete(user.Identities[i].IdentityData, "external_id") + user.Identities[i].ProviderID = params.UserName + user.Identities[i].IdentityData["sub"] = params.UserName } if err := tx.UpdateOnly(&user.Identities[i], updateCols...); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { From a53392e05e9fc1f4c5992c6db8f51f5b24be83e9 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 04:29:30 +0300 Subject: [PATCH 093/101] fix: check cross-provider email collisions to return 409 instead of 500 --- internal/api/scim_helpers.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go index a9b365a9c..2978d4f55 100644 --- a/internal/api/scim_helpers.go +++ b/internal/api/scim_helpers.go @@ -211,12 +211,17 @@ func setSCIMIdentityField(tx *storage.Connection, identity *models.Identity, key } func checkSCIMEmailUniqueness(tx *storage.Connection, email, aud, providerType string, excludeUserID uuid.UUID) error { - nonSSOUser, err := models.FindUserByEmailAndAudience(tx, email, aud) + existingUser, err := models.FindUserByEmailAndAudience(tx, email, aud) if err != nil && !models.IsNotFoundError(err) { return apierrors.NewSCIMInternalServerError("Error checking email uniqueness").WithInternalError(err) } - if nonSSOUser != nil && nonSSOUser.ID != excludeUserID && !nonSSOUser.IsSSOUser { - return apierrors.NewSCIMConflictError("Email already in use by another user", "uniqueness") + if existingUser != nil && existingUser.ID != excludeUserID { + if !existingUser.IsSSOUser { + return apierrors.NewSCIMConflictError("Email already in use by another user", "uniqueness") + } + if existingUser.BannedReason == nil || *existingUser.BannedReason != scimDeprovisionedReason { + return apierrors.NewSCIMConflictError("Email already in use by another user", "uniqueness") + } } ssoUsers, err := models.FindSSOUsersByEmailAndProvider(tx, email, aud, providerType) From 1db2a531d609c53be8f85c7f8b69bff44df88e78 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 04:32:27 +0300 Subject: [PATCH 094/101] fix: reset ProviderID and sub when removing externalId via PATCH --- internal/api/scim.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 80b3dd1f7..7d5ec86c9 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -584,7 +584,13 @@ func (a *API) applySCIMUserRemove(tx *storage.Connection, user *models.User, op if identity.IdentityData != nil { delete(identity.IdentityData, "external_id") } - if err := tx.UpdateOnly(identity, "identity_data"); err != nil { + fallbackID := user.GetEmail() + if userName, ok := identity.IdentityData["user_name"].(string); ok && userName != "" { + fallbackID = userName + } + identity.ProviderID = fallbackID + identity.IdentityData["sub"] = fallbackID + if err := tx.UpdateOnly(identity, "provider_id", "identity_data"); err != nil { return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } } From 941b0e6e4130f49fef50cc120d11a47f53832135 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 05:03:36 +0300 Subject: [PATCH 095/101] refactor: extract shared helpers and remove duplication across SCIM files --- internal/api/scim.go | 161 ++++++---------------------------- internal/api/scim_filter.go | 19 ++-- internal/api/scim_helpers.go | 71 +++++++++++++++ internal/models/scim_group.go | 45 +++------- 4 files changed, 119 insertions(+), 177 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 7d5ec86c9..8c19aedad 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -118,21 +118,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { return err } - var email string - var emailType string - if len(params.Emails) > 0 { - for _, e := range params.Emails { - if e.Primary { - email = e.Value - emailType = e.Type - break - } - } - if email == "" { - email = params.Emails[0].Value - emailType = params.Emails[0].Type - } - } + email, emailType := extractPrimarySCIMEmail(params.Emails) if email == "" { return apierrors.NewSCIMBadRequestError("At least one email address is required", "invalidValue") @@ -186,15 +172,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { if metadata == nil { metadata = make(map[string]interface{}) } - if params.Name.GivenName != "" { - metadata["given_name"] = params.Name.GivenName - } - if params.Name.FamilyName != "" { - metadata["family_name"] = params.Name.FamilyName - } - if params.Name.Formatted != "" { - metadata["full_name"] = params.Name.Formatted - } + applySCIMNameToMetadata(metadata, params.Name) candidate.UserMetaData = metadata if err := tx.UpdateOnly(candidate, "raw_user_meta_data"); err != nil { return apierrors.NewSCIMInternalServerError("Error updating user metadata").WithInternalError(err) @@ -259,15 +237,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { if params.Name != nil { metadata := make(map[string]interface{}) - if params.Name.GivenName != "" { - metadata["given_name"] = params.Name.GivenName - } - if params.Name.FamilyName != "" { - metadata["family_name"] = params.Name.FamilyName - } - if params.Name.Formatted != "" { - metadata["full_name"] = params.Name.Formatted - } + applySCIMNameToMetadata(metadata, params.Name) if len(metadata) > 0 { user.UserMetaData = metadata } @@ -351,19 +321,7 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { return err } - // Extract primary email from params - var email string - if len(params.Emails) > 0 { - for _, e := range params.Emails { - if e.Primary { - email = e.Value - break - } - } - if email == "" { - email = params.Emails[0].Value - } - } + email, _ := extractPrimarySCIMEmail(params.Emails) if email != "" { email, err = a.validateEmail(email) if err != nil { @@ -393,17 +351,7 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { delete(metadata, "given_name") delete(metadata, "family_name") delete(metadata, "full_name") - if params.Name != nil { - if params.Name.GivenName != "" { - metadata["given_name"] = params.Name.GivenName - } - if params.Name.FamilyName != "" { - metadata["family_name"] = params.Name.FamilyName - } - if params.Name.Formatted != "" { - metadata["full_name"] = params.Name.Formatted - } - } + applySCIMNameToMetadata(metadata, params.Name) user.UserMetaData = metadata if params.Active != nil { @@ -965,13 +913,9 @@ func (a *API) scimCreateGroup(w http.ResponseWriter, r *http.Request) error { } if len(params.Members) > 0 { - memberIDs := make([]uuid.UUID, 0, len(params.Members)) - for _, member := range params.Members { - memberID, err := uuid.FromString(member.Value) - if err != nil { - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", member.Value), "invalidValue") - } - memberIDs = append(memberIDs, memberID) + memberIDs, err := parseSCIMGroupMemberRefs(params.Members) + if err != nil { + return err } if err := group.AddMembers(tx, memberIDs); err != nil { if _, ok := err.(models.UserNotFoundError); ok { @@ -1032,11 +976,7 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { } group.DisplayName = params.DisplayName - if params.ExternalID != "" { - group.ExternalID = storage.NullString(params.ExternalID) - } else { - group.ExternalID = storage.NullString("") - } + group.ExternalID = storage.NullString(params.ExternalID) if err := tx.Update(group); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { @@ -1045,13 +985,9 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMInternalServerError("Error updating group").WithInternalError(err) } - memberIDs := make([]uuid.UUID, 0, len(params.Members)) - for _, member := range params.Members { - memberID, err := uuid.FromString(member.Value) - if err != nil { - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", member.Value), "invalidValue") - } - memberIDs = append(memberIDs, memberID) + memberIDs, err := parseSCIMGroupMemberRefs(params.Members) + if err != nil { + return err } if err := group.SetMembers(tx, memberIDs); err != nil { @@ -1161,14 +1097,7 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou if !ok { return apierrors.NewSCIMBadRequestError("externalId must be a string", "invalidValue") } - group.ExternalID = storage.NullString(externalID) - if err := tx.UpdateOnly(group, "external_id"); err != nil { - if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("Group with this externalId already exists", "uniqueness") - } - return apierrors.NewSCIMInternalServerError("Error updating group external ID").WithInternalError(err) - } - return nil + return updateGroupExternalID(tx, group, externalID) case "members": // fall through to member handling below default: @@ -1182,21 +1111,9 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou if len(members) > SCIMMaxMembers { return apierrors.NewSCIMRequestTooLargeError(fmt.Sprintf("Maximum %d members per operation", SCIMMaxMembers)) } - memberIDs := make([]uuid.UUID, 0, len(members)) - for _, m := range members { - memberMap, ok := m.(map[string]interface{}) - if !ok { - return apierrors.NewSCIMBadRequestError("Invalid member format", "invalidValue") - } - value, ok := memberMap["value"].(string) - if !ok { - return apierrors.NewSCIMBadRequestError("Member value must be a string", "invalidValue") - } - memberID, err := uuid.FromString(value) - if err != nil { - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", value), "invalidValue") - } - memberIDs = append(memberIDs, memberID) + memberIDs, err := parseSCIMGroupMemberIDsRaw(members) + if err != nil { + return err } if err := group.AddMembers(tx, memberIDs); err != nil { if _, ok := err.(models.UserNotFoundError); ok { @@ -1215,14 +1132,7 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou attrName := strings.ToLower(path.AttributePath.AttributeName) switch { case attrName == "externalid": - group.ExternalID = storage.NullString("") - if err := tx.UpdateOnly(group, "external_id"); err != nil { - if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("Group with this externalId already exists", "uniqueness") - } - return apierrors.NewSCIMInternalServerError("Error updating group external ID").WithInternalError(err) - } - return nil + return updateGroupExternalID(tx, group, "") case attrName == "members" && path.ValueExpression != nil: attrExpr, ok := path.ValueExpression.(*filter.AttributeExpression) if !ok || attrExpr.Operator != filter.EQ || strings.ToLower(attrExpr.AttributePath.AttributeName) != "value" { @@ -1253,14 +1163,7 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou if !ok { return apierrors.NewSCIMBadRequestError("externalId must be a string", "invalidValue") } - group.ExternalID = storage.NullString(externalID) - if err := tx.UpdateOnly(group, "external_id"); err != nil { - if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("Group with this externalId already exists", "uniqueness") - } - return apierrors.NewSCIMInternalServerError("Error updating group external ID").WithInternalError(err) - } - return nil + return updateGroupExternalID(tx, group, externalID) case "displayname": displayName, ok := op.Value.(string) if !ok { @@ -1282,21 +1185,9 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou if len(members) > SCIMMaxMembers { return apierrors.NewSCIMRequestTooLargeError(fmt.Sprintf("Maximum %d members per operation", SCIMMaxMembers)) } - memberIDs := make([]uuid.UUID, 0, len(members)) - for _, m := range members { - memberMap, ok := m.(map[string]interface{}) - if !ok { - return apierrors.NewSCIMBadRequestError("Invalid member format", "invalidValue") - } - value, ok := memberMap["value"].(string) - if !ok { - return apierrors.NewSCIMBadRequestError("Member value must be a string", "invalidValue") - } - memberID, err := uuid.FromString(value) - if err != nil { - return apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", value), "invalidValue") - } - memberIDs = append(memberIDs, memberID) + memberIDs, err := parseSCIMGroupMemberIDsRaw(members) + if err != nil { + return err } if err := group.SetMembers(tx, memberIDs); err != nil { if models.IsNotFoundError(err) { @@ -1514,7 +1405,7 @@ func (a *API) scimResourceTypeByID(w http.ResponseWriter, r *http.Request) error case "Group": resourceType = a.buildSCIMResourceType("Group", "Group", "/Groups", "Group", SCIMSchemaGroup) default: - return sendSCIMError(w, http.StatusNotFound, "Resource type not found", "") + return sendSCIMError(w, http.StatusNotFound, "Resource type not found") } return sendSCIMJSON(w, http.StatusOK, resourceType) @@ -1530,16 +1421,16 @@ func (a *API) scimSchemaByID(w http.ResponseWriter, r *http.Request) error { case SCIMSchemaGroup: schema = a.buildSCIMSchema(SCIMSchemaGroup, "Group", "Group", scimGroupSchemaAttributes) default: - return sendSCIMError(w, http.StatusNotFound, "Schema not found", "") + return sendSCIMError(w, http.StatusNotFound, "Schema not found") } return sendSCIMJSON(w, http.StatusOK, schema) } -func sendSCIMError(w http.ResponseWriter, status int, detail string, scimType string) error { - return sendSCIMJSON(w, status, apierrors.NewSCIMHTTPError(status, detail, scimType)) +func sendSCIMError(w http.ResponseWriter, status int, detail string) error { + return sendSCIMJSON(w, status, apierrors.NewSCIMHTTPError(status, detail, "")) } func (a *API) scimNotFound(w http.ResponseWriter, r *http.Request) error { - return sendSCIMError(w, http.StatusNotFound, "Resource not found", "") + return sendSCIMError(w, http.StatusNotFound, "Resource not found") } diff --git a/internal/api/scim_filter.go b/internal/api/scim_filter.go index 3a9bc15eb..b30d5ebb4 100644 --- a/internal/api/scim_filter.go +++ b/internal/api/scim_filter.go @@ -176,18 +176,15 @@ func notExprToSQL(e filter.NotExpression, allowedAttrs map[string]string) (*mode func valuePathToSQL(e filter.ValuePath, allowedAttrs map[string]string) (*models.SCIMFilterClause, error) { attrName := strings.ToLower(e.AttributePath.AttributeName) - switch attrName { - case "emails": - if e.ValueFilter != nil { - if attrExpr, ok := e.ValueFilter.(*filter.AttributeExpression); ok { - if strings.ToLower(attrExpr.AttributePath.AttributeName) == "value" { - modifiedExpr := filter.AttributeExpression{ - AttributePath: filter.AttributePath{AttributeName: "email"}, - Operator: attrExpr.Operator, - CompareValue: attrExpr.CompareValue, - } - return attrExprToSQL(modifiedExpr, allowedAttrs) + if attrName == "emails" && e.ValueFilter != nil { + if attrExpr, ok := e.ValueFilter.(*filter.AttributeExpression); ok { + if strings.ToLower(attrExpr.AttributePath.AttributeName) == "value" { + modifiedExpr := filter.AttributeExpression{ + AttributePath: filter.AttributePath{AttributeName: "email"}, + Operator: attrExpr.Operator, + CompareValue: attrExpr.CompareValue, } + return attrExprToSQL(modifiedExpr, allowedAttrs) } } } diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go index 2978d4f55..2fdf80d18 100644 --- a/internal/api/scim_helpers.go +++ b/internal/api/scim_helpers.go @@ -3,6 +3,7 @@ package api import ( "encoding/json" "errors" + "fmt" "net/http" "strconv" "strings" @@ -210,6 +211,76 @@ func setSCIMIdentityField(tx *storage.Connection, identity *models.Identity, key return nil } +func extractPrimarySCIMEmail(emails []SCIMEmail) (email, emailType string) { + if len(emails) == 0 { + return "", "" + } + for _, e := range emails { + if e.Primary { + return e.Value, e.Type + } + } + return emails[0].Value, emails[0].Type +} + +func applySCIMNameToMetadata(metadata map[string]interface{}, name *SCIMName) { + if name == nil { + return + } + if name.GivenName != "" { + metadata["given_name"] = name.GivenName + } + if name.FamilyName != "" { + metadata["family_name"] = name.FamilyName + } + if name.Formatted != "" { + metadata["full_name"] = name.Formatted + } +} + +func parseSCIMGroupMemberRefs(members []SCIMGroupMemberRef) ([]uuid.UUID, error) { + ids := make([]uuid.UUID, 0, len(members)) + for _, member := range members { + id, err := uuid.FromString(member.Value) + if err != nil { + return nil, apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", member.Value), "invalidValue") + } + ids = append(ids, id) + } + return ids, nil +} + +func parseSCIMGroupMemberIDsRaw(members []interface{}) ([]uuid.UUID, error) { + ids := make([]uuid.UUID, 0, len(members)) + for _, m := range members { + memberMap, ok := m.(map[string]interface{}) + if !ok { + return nil, apierrors.NewSCIMBadRequestError("Invalid member format", "invalidValue") + } + value, ok := memberMap["value"].(string) + if !ok { + return nil, apierrors.NewSCIMBadRequestError("Member value must be a string", "invalidValue") + } + id, err := uuid.FromString(value) + if err != nil { + return nil, apierrors.NewSCIMBadRequestError(fmt.Sprintf("Invalid member ID: %s", value), "invalidValue") + } + ids = append(ids, id) + } + return ids, nil +} + +func updateGroupExternalID(tx *storage.Connection, group *models.SCIMGroup, externalID string) error { + group.ExternalID = storage.NullString(externalID) + if err := tx.UpdateOnly(group, "external_id"); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("Group with this externalId already exists", "uniqueness") + } + return apierrors.NewSCIMInternalServerError("Error updating group external ID").WithInternalError(err) + } + return nil +} + func checkSCIMEmailUniqueness(tx *storage.Connection, email, aud, providerType string, excludeUserID uuid.UUID) error { existingUser, err := models.FindUserByEmailAndAudience(tx, email, aud) if err != nil && !models.IsNotFoundError(err) { diff --git a/internal/models/scim_group.go b/internal/models/scim_group.go index 72f5415a2..926b24189 100644 --- a/internal/models/scim_group.go +++ b/internal/models/scim_group.go @@ -11,13 +11,10 @@ import ( "github.com/supabase/auth/internal/storage" ) -func scimGroupTableName() string { - return (&pop.Model{Value: SCIMGroup{}}).TableName() -} - -func scimGroupMemberTableName() string { - return (&pop.Model{Value: SCIMGroupMember{}}).TableName() -} +var ( + scimGroupTable = (&pop.Model{Value: SCIMGroup{}}).TableName() + scimGroupMemberTable = (&pop.Model{Value: SCIMGroupMember{}}).TableName() +) type SCIMGroup struct { ID uuid.UUID `db:"id" json:"id"` @@ -107,12 +104,12 @@ func FindSCIMGroupsBySSOProviderWithFilter(tx *storage.Connection, ssoProviderID } var totalResults int - countQuery := "SELECT COUNT(*) FROM " + scimGroupTableName() + " WHERE " + whereClause + countQuery := "SELECT COUNT(*) FROM " + scimGroupTable + " WHERE " + whereClause if err := tx.RawQuery(countQuery, args...).First(&totalResults); err != nil { return nil, 0, errors.Wrap(err, "error counting SCIM groups") } - query := "SELECT * FROM " + scimGroupTableName() + " WHERE " + whereClause + " ORDER BY created_at ASC LIMIT ? OFFSET ?" + query := "SELECT * FROM " + scimGroupTable + " WHERE " + whereClause + " ORDER BY created_at ASC LIMIT ? OFFSET ?" args = append(args, count, offset) if err := tx.RawQuery(query, args...).All(&groups); err != nil { if errors.Cause(err) == sql.ErrNoRows { @@ -123,20 +120,6 @@ func FindSCIMGroupsBySSOProviderWithFilter(tx *storage.Connection, ssoProviderID return groups, totalResults, nil } -func FindSCIMGroupsForUser(tx *storage.Connection, userID uuid.UUID) ([]*SCIMGroup, error) { - groups := []*SCIMGroup{} - if err := tx.RawQuery( - "SELECT g.* FROM "+scimGroupTableName()+" g INNER JOIN "+scimGroupMemberTableName()+" m ON g.id = m.group_id WHERE m.user_id = ? ORDER BY g.display_name ASC", - userID, - ).All(&groups); err != nil { - if errors.Cause(err) == sql.ErrNoRows { - return []*SCIMGroup{}, nil - } - return nil, errors.Wrap(err, "error finding SCIM groups for user") - } - return groups, nil -} - func (g *SCIMGroup) AddMember(tx *storage.Connection, userID uuid.UUID) error { user, err := FindUserByID(tx, userID) if err != nil { @@ -148,7 +131,7 @@ func (g *SCIMGroup) AddMember(tx *storage.Connection, userID uuid.UUID) error { } return tx.RawQuery( - "INSERT INTO "+scimGroupMemberTableName()+" (group_id, user_id, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", + "INSERT INTO "+scimGroupMemberTable+" (group_id, user_id, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING", g.ID, userID, time.Now(), ).Exec() } @@ -223,7 +206,7 @@ func (g *SCIMGroup) AddMembers(tx *storage.Connection, userIDs []uuid.UUID) erro insertArgs = append(insertArgs, providerType) if err := tx.RawQuery( - "INSERT INTO "+scimGroupMemberTableName()+" (group_id, user_id, created_at) "+ + "INSERT INTO "+scimGroupMemberTable+" (group_id, user_id, created_at) "+ "SELECT ?, u.id, ? FROM "+userTable+" u "+ "INNER JOIN "+identityTable+" i ON i.user_id = u.id "+ "WHERE u.id IN ("+inClause+") AND i.provider = ? "+ @@ -237,7 +220,7 @@ func (g *SCIMGroup) AddMembers(tx *storage.Connection, userIDs []uuid.UUID) erro func (g *SCIMGroup) RemoveMember(tx *storage.Connection, userID uuid.UUID) error { return tx.RawQuery( - "DELETE FROM "+scimGroupMemberTableName()+" WHERE group_id = ? AND user_id = ?", + "DELETE FROM "+scimGroupMemberTable+" WHERE group_id = ? AND user_id = ?", g.ID, userID, ).Exec() } @@ -246,7 +229,7 @@ func (g *SCIMGroup) GetMembers(tx *storage.Connection) ([]*User, error) { users := []*User{} userTable := (&pop.Model{Value: User{}}).TableName() if err := tx.RawQuery( - "SELECT u.* FROM "+userTable+" u INNER JOIN "+scimGroupMemberTableName()+" m ON u.id = m.user_id WHERE m.group_id = ? ORDER BY u.email ASC LIMIT 10000", + "SELECT u.* FROM "+userTable+" u INNER JOIN "+scimGroupMemberTable+" m ON u.id = m.user_id WHERE m.group_id = ? ORDER BY u.email ASC LIMIT 10000", g.ID, ).All(&users); err != nil { if errors.Cause(err) == sql.ErrNoRows { @@ -259,7 +242,7 @@ func (g *SCIMGroup) GetMembers(tx *storage.Connection) ([]*User, error) { func (g *SCIMGroup) SetMembers(tx *storage.Connection, userIDs []uuid.UUID) error { if len(userIDs) == 0 { - if err := tx.RawQuery("DELETE FROM "+scimGroupMemberTableName()+" WHERE group_id = ?", g.ID).Exec(); err != nil { + if err := tx.RawQuery("DELETE FROM "+scimGroupMemberTable+" WHERE group_id = ?", g.ID).Exec(); err != nil { return errors.Wrap(err, "error clearing SCIM group members") } return nil @@ -317,7 +300,7 @@ func (g *SCIMGroup) SetMembers(tx *storage.Connection, userIDs []uuid.UUID) erro } } - if err := tx.RawQuery("DELETE FROM "+scimGroupMemberTableName()+" WHERE group_id = ?", g.ID).Exec(); err != nil { + if err := tx.RawQuery("DELETE FROM "+scimGroupMemberTable+" WHERE group_id = ?", g.ID).Exec(); err != nil { return errors.Wrap(err, "error clearing SCIM group members") } @@ -328,7 +311,7 @@ func (g *SCIMGroup) SetMembers(tx *storage.Connection, userIDs []uuid.UUID) erro insertArgs = append(insertArgs, providerType) if err := tx.RawQuery( - "INSERT INTO "+scimGroupMemberTableName()+" (group_id, user_id, created_at) "+ + "INSERT INTO "+scimGroupMemberTable+" (group_id, user_id, created_at) "+ "SELECT ?, u.id, ? FROM "+userTable+" u "+ "INNER JOIN "+identityTable+" i ON i.user_id = u.id "+ "WHERE u.id IN ("+inClause+") AND i.provider = ? "+ @@ -363,7 +346,7 @@ func GetMembersForGroups(tx *storage.Connection, groupIDs []uuid.UUID) (map[uuid rows := []memberRow{} if err := tx.RawQuery( "SELECT m.group_id, u.* FROM "+userTable+" u "+ - "INNER JOIN "+scimGroupMemberTableName()+" m ON u.id = m.user_id "+ + "INNER JOIN "+scimGroupMemberTable+" m ON u.id = m.user_id "+ "WHERE m.group_id IN ("+strings.Join(placeholders, ",")+") "+ "ORDER BY u.email ASC", args..., From 17797dca1e50ecab2cf3fe4c7e80aba417ebc5c9 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 15:23:39 +0300 Subject: [PATCH 096/101] fix: remove IsSuperAdmin field re-added against upstream removal --- internal/models/user.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/internal/models/user.go b/internal/models/user.go index 45d9a4c44..1b9d25bc4 100644 --- a/internal/models/user.go +++ b/internal/models/user.go @@ -23,11 +23,10 @@ import ( type User struct { ID uuid.UUID `json:"id" db:"id"` - Aud string `json:"aud" db:"aud"` - Role string `json:"role" db:"role"` - Email storage.NullString `json:"email" db:"email"` - IsSSOUser bool `json:"-" db:"is_sso_user"` - IsSuperAdmin bool `json:"-" db:"is_super_admin"` + Aud string `json:"aud" db:"aud"` + Role string `json:"role" db:"role"` + Email storage.NullString `json:"email" db:"email"` + IsSSOUser bool `json:"-" db:"is_sso_user"` EncryptedPassword *string `json:"-" db:"encrypted_password"` EmailConfirmedAt *time.Time `json:"email_confirmed_at,omitempty" db:"email_confirmed_at"` From 60a56f723864708f07f3c19c1942a7021a147f5e Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 17:45:51 +0300 Subject: [PATCH 097/101] cleanup: remove dead code, fix PR description, add SCIM test coverage --- internal/api/scim_test.go | 625 ++++++++++++++++++++++++++++++++++++++ internal/models/sso.go | 8 - 2 files changed, 625 insertions(+), 8 deletions(-) diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index 8ee4dda12..46e5ce2db 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -7,9 +7,11 @@ import ( "math" "net/http" "net/http/httptest" + "strings" "testing" "time" + jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" "github.com/supabase/auth/internal/conf" @@ -2519,3 +2521,626 @@ func (ts *SCIMTestSuite) TestSCIMErrorResponseContentType() { require.Equal(ts.T(), http.StatusNotFound, w.Code) require.Equal(ts.T(), "application/scim+json", w.Header().Get("Content-Type")) } + +func (ts *SCIMTestSuite) adminToken() string { + claims := &AccessTokenClaims{ + Role: "supabase_admin", + } + token, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString([]byte(ts.Config.JWT.Secret)) + require.NoError(ts.T(), err) + return token +} + +func (ts *SCIMTestSuite) makeAdminRequest(method, path string, body interface{}) *http.Request { + var reqBody *bytes.Buffer + if body != nil { + jsonBody, err := json.Marshal(body) + require.NoError(ts.T(), err) + reqBody = bytes.NewBuffer(jsonBody) + } else { + reqBody = bytes.NewBuffer(nil) + } + req := httptest.NewRequest(method, "http://localhost"+path, reqBody) + req.Header.Set("Authorization", "Bearer "+ts.adminToken()) + req.Header.Set("Content-Type", "application/json") + return req +} + +func (ts *SCIMTestSuite) TestSCIMAdminGetConfig() { + req := ts.makeAdminRequest(http.MethodGet, "/admin/sso/providers/"+ts.SSOProvider.ID.String()+"/scim", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), true, result["enabled"]) + require.Equal(ts.T(), true, result["token_set"]) + require.NotEmpty(ts.T(), result["base_url"]) +} + +func (ts *SCIMTestSuite) TestSCIMAdminEnableSCIM() { + provider := &models.SSOProvider{} + require.NoError(ts.T(), ts.API.db.Create(provider)) + + req := ts.makeAdminRequest(http.MethodPost, "/admin/sso/providers/"+provider.ID.String()+"/scim", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), true, result["enabled"]) + require.NotEmpty(ts.T(), result["token"]) + require.NotEmpty(ts.T(), result["base_url"]) +} + +func (ts *SCIMTestSuite) TestSCIMAdminDisableSCIM() { + req := ts.makeAdminRequest(http.MethodDelete, "/admin/sso/providers/"+ts.SSOProvider.ID.String()+"/scim", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), false, result["enabled"]) + + scimReq := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users", nil) + scimW := httptest.NewRecorder() + ts.API.handler.ServeHTTP(scimW, scimReq) + require.Equal(ts.T(), http.StatusUnauthorized, scimW.Code) +} + +func (ts *SCIMTestSuite) TestSCIMAdminRotateToken() { + scimReq := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users", nil) + scimW := httptest.NewRecorder() + ts.API.handler.ServeHTTP(scimW, scimReq) + require.Equal(ts.T(), http.StatusOK, scimW.Code) + + req := ts.makeAdminRequest(http.MethodPost, "/admin/sso/providers/"+ts.SSOProvider.ID.String()+"/scim/rotate", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), true, result["enabled"]) + newToken, ok := result["token"].(string) + require.True(ts.T(), ok) + require.NotEmpty(ts.T(), newToken) + + scimReq = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users", nil) + scimW = httptest.NewRecorder() + ts.API.handler.ServeHTTP(scimW, scimReq) + require.Equal(ts.T(), http.StatusUnauthorized, scimW.Code) + + scimReq2 := httptest.NewRequest(http.MethodGet, "http://localhost/scim/v2/Users", nil) + scimReq2.Header.Set("Authorization", "Bearer "+newToken) + scimReq2.Header.Set("Content-Type", "application/scim+json") + scimW2 := httptest.NewRecorder() + ts.API.handler.ServeHTTP(scimW2, scimReq2) + require.Equal(ts.T(), http.StatusOK, scimW2.Code) +} + +func (ts *SCIMTestSuite) TestSCIMAdminRotateTokenWhenDisabled() { + provider := &models.SSOProvider{} + require.NoError(ts.T(), ts.API.db.Create(provider)) + + req := ts.makeAdminRequest(http.MethodPost, "/admin/sso/providers/"+provider.ID.String()+"/scim/rotate", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusBadRequest, w.Code) + + var result map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), "scim_disabled", result["error_code"]) +} + +func (ts *SCIMTestSuite) TestSCIMDisabledSCIMProvider() { + provider := &models.SSOProvider{} + require.NoError(ts.T(), ts.API.db.Create(provider)) + token := "disabled-scim-provider-token" + provider.SetSCIMToken(token) + provider.ClearSCIMToken() + require.NoError(ts.T(), ts.API.db.Update(provider)) + + req := httptest.NewRequest(http.MethodGet, "http://localhost/scim/v2/Users", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/scim+json") + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusUnauthorized, w.Code) +} + +func (ts *SCIMTestSuite) TestSCIMDisabledSSOProvider() { + provider := &models.SSOProvider{} + require.NoError(ts.T(), ts.API.db.Create(provider)) + token := "disabled-sso-provider-token" + provider.SetSCIMToken(token) + disabled := true + provider.Disabled = &disabled + require.NoError(ts.T(), ts.API.db.Update(provider)) + + req := httptest.NewRequest(http.MethodGet, "http://localhost/scim/v2/Users", nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/scim+json") + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusForbidden, w.Code) + + var errorResp map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errorResp)) + detail, ok := errorResp["detail"].(string) + require.True(ts.T(), ok) + require.Contains(ts.T(), detail, "SSO provider is disabled") +} + +func (ts *SCIMTestSuite) createFilterTestUsers() { + ts.createSCIMUserWithExternalID("user1@acme.com", "user1@acme.com", "ext-f-001") + ts.createSCIMUserWithExternalID("user2@acme.com", "user2@acme.com", "ext-f-002") + ts.createSCIMUserWithExternalID("user3@other.com", "user3@other.com", "ext-f-003") + ts.createSCIMUser("user4@acme.com", "user4@acme.com") + ts.createSCIMUser("user5@other.com", "user5@other.com") +} + +func (ts *SCIMTestSuite) TestSCIMFilterNE() { + ts.createFilterTestUsers() + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=userName+ne+%22user1%40acme.com%22", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), 4, result.TotalResults) + for _, r := range result.Resources { + resource := r.(map[string]interface{}) + require.NotEqual(ts.T(), "user1@acme.com", resource["userName"]) + } +} + +func (ts *SCIMTestSuite) TestSCIMFilterCO() { + ts.createFilterTestUsers() + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=userName+co+%22acme%22", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), 3, result.TotalResults) + for _, r := range result.Resources { + resource := r.(map[string]interface{}) + require.Contains(ts.T(), resource["userName"], "acme") + } +} + +func (ts *SCIMTestSuite) TestSCIMFilterSW() { + ts.createFilterTestUsers() + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=userName+sw+%22user1%22", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), 1, result.TotalResults) + resource := result.Resources[0].(map[string]interface{}) + require.Equal(ts.T(), "user1@acme.com", resource["userName"]) +} + +func (ts *SCIMTestSuite) TestSCIMFilterEW() { + ts.createFilterTestUsers() + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=userName+ew+%22acme.com%22", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), 3, result.TotalResults) + for _, r := range result.Resources { + resource := r.(map[string]interface{}) + userName := resource["userName"].(string) + require.True(ts.T(), strings.HasSuffix(userName, "acme.com")) + } +} + +func (ts *SCIMTestSuite) TestSCIMFilterPR() { + ts.createFilterTestUsers() + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=externalId+pr", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), 3, result.TotalResults) + for _, r := range result.Resources { + resource := r.(map[string]interface{}) + require.NotEmpty(ts.T(), resource["externalId"]) + } +} + +func (ts *SCIMTestSuite) TestSCIMFilterAnd() { + ts.createFilterTestUsers() + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=userName+sw+%22user%22+and+userName+ew+%22acme.com%22", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), 3, result.TotalResults) +} + +func (ts *SCIMTestSuite) TestSCIMFilterOr() { + ts.createFilterTestUsers() + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=userName+eq+%22user1%40acme.com%22+or+userName+eq+%22user2%40acme.com%22", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), 2, result.TotalResults) +} + +func (ts *SCIMTestSuite) TestSCIMFilterNot() { + ts.createFilterTestUsers() + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=not+userName+eq+%22user1%40acme.com%22", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), 4, result.TotalResults) + for _, r := range result.Resources { + resource := r.(map[string]interface{}) + require.NotEqual(ts.T(), "user1@acme.com", resource["userName"]) + } +} + +func (ts *SCIMTestSuite) TestSCIMFilterEmailsValuePath() { + ts.createFilterTestUsers() + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter=emails%5Bvalue+eq+%22user1%40acme.com%22%5D", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), 1, result.TotalResults) +} + +func (ts *SCIMTestSuite) TestSCIMGroupFilterCO() { + ts.createSCIMGroupWithExternalID("Engineering Team", "grp-fc-001") + ts.createSCIMGroupWithExternalID("Sales Team", "grp-fc-002") + ts.createSCIMGroupWithExternalID("Eng Ops", "grp-fc-003") + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups?filter=displayName+co+%22Eng%22", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), 2, result.TotalResults) +} + +func (ts *SCIMTestSuite) TestSCIMBodyExceedsMaxSize() { + // Create a body larger than 1MB + largeBody := strings.Repeat("x", SCIMMaxBodySize+1) + req := httptest.NewRequest(http.MethodPost, "http://localhost/scim/v2/Users", bytes.NewBufferString(largeBody)) + req.Header.Set("Authorization", "Bearer "+ts.SCIMToken) + req.Header.Set("Content-Type", "application/scim+json") + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + // Should get an error (400 or 413) + require.True(ts.T(), w.Code >= 400, "Expected error status for oversized body, got %d", w.Code) +} + +func (ts *SCIMTestSuite) TestSCIMFilterExceedsMaxLength() { + longFilter := "userName eq \"" + strings.Repeat("a", SCIMMaxFilterLength+1) + "\"" + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?filter="+longFilter, nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidFilter") +} + +func (ts *SCIMTestSuite) TestSCIMResourceTypeByID() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/ResourceTypes/User", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), "User", result["id"]) + require.Equal(ts.T(), "User", result["name"]) +} + +func (ts *SCIMTestSuite) TestSCIMResourceTypeByIDGroup() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/ResourceTypes/Group", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), "Group", result["id"]) + require.Equal(ts.T(), "Group", result["name"]) +} + +func (ts *SCIMTestSuite) TestSCIMResourceTypeByIDNotFound() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/ResourceTypes/Invalid", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMSchemaByID() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Schemas/"+SCIMSchemaUser, nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), SCIMSchemaUser, result["id"]) + require.Equal(ts.T(), "User", result["name"]) +} + +func (ts *SCIMTestSuite) TestSCIMSchemaByIDNotFound() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Schemas/invalid", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMNotFoundRoute() { + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/nonexistent", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusNotFound, w.Code) + + var errorResp map[string]interface{} + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errorResp)) + schemas, ok := errorResp["schemas"].([]interface{}) + require.True(ts.T(), ok) + require.Len(ts.T(), schemas, 1) + require.Equal(ts.T(), "urn:ietf:params:scim:api:messages:2.0:Error", schemas[0]) +} + +func (ts *SCIMTestSuite) TestSCIMPaginationCountZero() { + for i := 0; i < 3; i++ { + ts.createSCIMUser(fmt.Sprintf("pagezero%d@acme.com", i), fmt.Sprintf("pagezero%d@acme.com", i)) + } + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?count=0", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), 3, result.TotalResults) + require.Empty(ts.T(), result.Resources) +} + +func (ts *SCIMTestSuite) TestSCIMPaginationStartIndexExceedsTotal() { + for i := 0; i < 5; i++ { + ts.createSCIMUser(fmt.Sprintf("pageexceed%d@acme.com", i), fmt.Sprintf("pageexceed%d@acme.com", i)) + } + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users?startIndex=999", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), 5, result.TotalResults) + require.Empty(ts.T(), result.Resources) +} + +func (ts *SCIMTestSuite) TestSCIMGroupPagination() { + for i := 0; i < 5; i++ { + ts.createSCIMGroupWithExternalID(fmt.Sprintf("PagGroup%d", i), fmt.Sprintf("pag-grp-%d", i)) + } + + req := ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Groups?startIndex=1&count=2", nil) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMListResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), 5, result.TotalResults) + require.Len(ts.T(), result.Resources, 2) +} + +func (ts *SCIMTestSuite) setupCrossProviderIsolation() (string, *SCIMUserResponse, *SCIMGroupResponse) { + user := ts.createSCIMUser("cross_iso@acme.com", "cross_iso@acme.com") + group := ts.createSCIMGroup("CrossIsoGroup") + + provider2 := &models.SSOProvider{} + require.NoError(ts.T(), ts.API.db.Create(provider2)) + token2 := "cross-provider-iso-token" + provider2.SetSCIMToken(token2) + require.NoError(ts.T(), ts.API.db.Update(provider2)) + + return token2, user, group +} + +func (ts *SCIMTestSuite) TestSCIMCrossProviderPatchUser() { + token2, user, _ := ts.setupCrossProviderIsolation() + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "value": map[string]interface{}{"userName": "hacked@evil.com"}}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + req.Header.Set("Authorization", "Bearer "+token2) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMCrossProviderPutUser() { + token2, user, _ := ts.setupCrossProviderIsolation() + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaUser}, + "userName": "hacked@evil.com", + "emails": []map[string]interface{}{{"value": "hacked@evil.com", "primary": true}}, + } + + req := ts.makeSCIMRequest(http.MethodPut, "/scim/v2/Users/"+user.ID, body) + req.Header.Set("Authorization", "Bearer "+token2) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMCrossProviderDeleteUser() { + token2, user, _ := ts.setupCrossProviderIsolation() + + req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Users/"+user.ID, nil) + req.Header.Set("Authorization", "Bearer "+token2) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMCrossProviderPatchGroup() { + token2, _, group := ts.setupCrossProviderIsolation() + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "value": map[string]interface{}{"displayName": "HackedGroup"}}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, body) + req.Header.Set("Authorization", "Bearer "+token2) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMCrossProviderDeleteGroup() { + token2, _, group := ts.setupCrossProviderIsolation() + + req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Groups/"+group.ID, nil) + req.Header.Set("Authorization", "Bearer "+token2) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusNotFound) +} + +func (ts *SCIMTestSuite) TestSCIMPatchGroupReplaceExternalIDWithPath() { + group := ts.createSCIMGroupWithExternalID("ExtIDPathGroup", "orig-ext-id") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "externalId", "value": "new-ext-id"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, body) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMGroupResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), "new-ext-id", result.ExternalID) +} + +func (ts *SCIMTestSuite) TestSCIMPatchGroupAddMemberWrongProvider() { + group := ts.createSCIMGroup("WrongProviderGroup") + + provider2 := &models.SSOProvider{} + require.NoError(ts.T(), ts.API.db.Create(provider2)) + token2 := "wrong-provider-member-token" + provider2.SetSCIMToken(token2) + require.NoError(ts.T(), ts.API.db.Update(provider2)) + + userBody := map[string]interface{}{ + "schemas": []string{SCIMSchemaUser}, + "userName": "otherprovider@test.com", + "emails": []map[string]interface{}{{"value": "otherprovider@test.com", "primary": true}}, + } + userReq := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Users", userBody) + userReq.Header.Set("Authorization", "Bearer "+token2) + userW := httptest.NewRecorder() + ts.API.handler.ServeHTTP(userW, userReq) + require.Equal(ts.T(), http.StatusCreated, userW.Code) + + var otherUser SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(userW.Body).Decode(&otherUser)) + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "add", "path": "members", "value": []map[string]interface{}{ + {"value": otherUser.ID}, + }}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, body) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.True(ts.T(), w.Code >= 400, "Adding cross-provider member should fail, got %d: %s", w.Code, w.Body.String()) +} + +func (ts *SCIMTestSuite) TestSCIMPatchGroupAddNonExistentMember() { + group := ts.createSCIMGroup("NonExistentMemberGroup") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "add", "path": "members", "value": []map[string]interface{}{ + {"value": "00000000-0000-0000-0000-000000000000"}, + }}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, body) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.True(ts.T(), w.Code >= 400, "Adding non-existent member should fail, got %d: %s", w.Code, w.Body.String()) +} + +func (ts *SCIMTestSuite) TestSCIMPatchGroupRemoveWithoutPath() { + group := ts.createSCIMGroup("RemoveNoPathGroup") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "remove"}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Groups/"+group.ID, body) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "noTarget") +} diff --git a/internal/models/sso.go b/internal/models/sso.go index a55fd8629..746f85cb9 100644 --- a/internal/models/sso.go +++ b/internal/models/sso.go @@ -2,7 +2,6 @@ package models import ( "crypto/sha256" - "crypto/subtle" "database/sql" "database/sql/driver" "encoding/json" @@ -60,13 +59,6 @@ func (p *SSOProvider) SetSCIMToken(token string) { p.SCIMEnabled = &enabled } -func (p *SSOProvider) VerifySCIMToken(token string) bool { - if p.SCIMBearerTokenHash == nil { - return false - } - return subtle.ConstantTimeCompare([]byte(*p.SCIMBearerTokenHash), []byte(scimTokenHash(token))) == 1 -} - func (p *SSOProvider) ClearSCIMToken() { p.SCIMBearerTokenHash = nil enabled := false From 85aab099197e43bc5d79a795a6b08928d2198cce Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 18:28:25 +0300 Subject: [PATCH 098/101] fix: update SCIM delete user response to return 404 for deprovisioned users --- internal/api/scim.go | 10 +--------- internal/api/scim_test.go | 4 ++-- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 8c19aedad..99cefb2e9 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -753,16 +753,8 @@ func (a *API) scimDeleteUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMNotFoundError("User not found") } - // Already deprovisioned — return success for idempotent delete if user.IsBanned() && user.BannedReason != nil && *user.BannedReason == scimDeprovisionedReason { - if terr := models.NewAuditLogEntry(config.AuditLog, r, tx, user, models.UserDeletedAction, utilities.GetIPAddress(r), map[string]interface{}{ - "provider": "scim", - "sso_provider_id": provider.ID, - "action": "idempotent_delete", - }); terr != nil { - return apierrors.NewSCIMInternalServerError("Error recording audit log entry").WithInternalError(terr) - } - return nil + return apierrors.NewSCIMNotFoundError("User not found") } if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index 46e5ce2db..8044aebb6 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -508,7 +508,7 @@ func (ts *SCIMTestSuite) TestSCIMDeleteUser() { w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) - require.Equal(ts.T(), http.StatusNoContent, w.Code) + require.Equal(ts.T(), http.StatusNotFound, w.Code) } func (ts *SCIMTestSuite) TestSCIMDeleteGroup() { @@ -728,7 +728,7 @@ func (ts *SCIMTestSuite) TestSCIMDeleteUserTwice() { w = httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) - require.Equal(ts.T(), http.StatusNoContent, w.Code) + require.Equal(ts.T(), http.StatusNotFound, w.Code) } func (ts *SCIMTestSuite) TestSCIMReactivateDeprovisionedUser() { From a9cb049b2c691be504b9089fdc9d5999dcb18dde Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 18:48:54 +0300 Subject: [PATCH 099/101] fix: remove unused identityID assignment in scimCreateUser function --- internal/api/scim.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 99cefb2e9..44e455bc4 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -185,10 +185,6 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { } } - identityID := params.ExternalID - if identityID == "" { - identityID = params.UserName - } for i := range candidate.Identities { if candidate.Identities[i].Provider == providerType { if candidate.Identities[i].IdentityData == nil { From e021d97559e57eab95b779b1d6ce01bc35a132e8 Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 19:15:56 +0300 Subject: [PATCH 100/101] final cleanup --- internal/api/api.go | 1 + internal/api/router.go | 4 + internal/api/scim.go | 8 +- internal/api/scim_helpers.go | 26 +++++ internal/api/scim_test.go | 187 ++++++++++++++--------------------- 5 files changed, 110 insertions(+), 116 deletions(-) diff --git a/internal/api/api.go b/internal/api/api.go index 7cf4954cb..a54077e0e 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -208,6 +208,7 @@ func NewAPIWithVersion(globalConfig *conf.GlobalConfiguration, db *storage.Conne // SCIM-specific NotFound handler for proper error format r.NotFound(api.scimNotFound) + r.MethodNotAllowed(api.scimMethodNotAllowed) // Service Provider Configuration r.Get("/ServiceProviderConfig", api.scimServiceProviderConfig) diff --git a/internal/api/router.go b/internal/api/router.go index 25fbae3d1..d7c9a0b3b 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -58,6 +58,10 @@ func (r *router) NotFound(fn apiHandler) { r.chi.NotFound(handler(fn)) } +func (r *router) MethodNotAllowed(fn apiHandler) { + r.chi.MethodNotAllowed(handler(fn)) +} + func (r *router) ServeHTTP(w http.ResponseWriter, req *http.Request) { r.chi.ServeHTTP(w, req) } diff --git a/internal/api/scim.go b/internal/api/scim.go index 44e455bc4..5f9a993b9 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -612,7 +612,7 @@ func (a *API) applySCIMUserReplace(tx *storage.Connection, user *models.User, op case attrName == "username": if userName, ok := val.(string); ok && userName != "" { if identity := findSSOIdentity(user, providerType); identity != nil { - if err := setSCIMIdentityField(tx, identity, "user_name", userName); err != nil { + if err := setSCIMUserName(tx, identity, userName); err != nil { return err } } @@ -671,7 +671,7 @@ func (a *API) applySCIMUserReplaceWithPath(tx *storage.Connection, user *models. return apierrors.NewSCIMBadRequestError("userName must be a string", "invalidValue") } if identity := findSSOIdentity(user, providerType); identity != nil { - return setSCIMIdentityField(tx, identity, "user_name", userName) + return setSCIMUserName(tx, identity, userName) } return nil case attrName == "emails" && path.ValueExpression != nil && strings.ToLower(path.SubAttributeName()) == "value": @@ -1422,3 +1422,7 @@ func sendSCIMError(w http.ResponseWriter, status int, detail string) error { func (a *API) scimNotFound(w http.ResponseWriter, r *http.Request) error { return sendSCIMError(w, http.StatusNotFound, "Resource not found") } + +func (a *API) scimMethodNotAllowed(w http.ResponseWriter, r *http.Request) error { + return sendSCIMError(w, http.StatusMethodNotAllowed, "Method not allowed") +} diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go index 2fdf80d18..f7e0948ef 100644 --- a/internal/api/scim_helpers.go +++ b/internal/api/scim_helpers.go @@ -185,6 +185,10 @@ func findSSOIdentity(user *models.User, providerType string) *models.Identity { } func setSCIMExternalID(tx *storage.Connection, identity *models.Identity, externalID string) error { + if strings.TrimSpace(externalID) == "" { + return apierrors.NewSCIMBadRequestError("externalId must not be empty", "invalidValue") + } + identity.ProviderID = externalID if identity.IdentityData == nil { identity.IdentityData = make(map[string]interface{}) @@ -200,6 +204,28 @@ func setSCIMExternalID(tx *storage.Connection, identity *models.Identity, extern return nil } +func setSCIMUserName(tx *storage.Connection, identity *models.Identity, userName string) error { + if identity.IdentityData == nil { + identity.IdentityData = make(map[string]interface{}) + } + identity.IdentityData["user_name"] = userName + + updateCols := []string{"identity_data"} + if externalID, ok := identity.IdentityData["external_id"].(string); !ok || externalID == "" { + identity.ProviderID = userName + identity.IdentityData["sub"] = userName + updateCols = append(updateCols, "provider_id") + } + + if err := tx.UpdateOnly(identity, updateCols...); err != nil { + if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { + return apierrors.NewSCIMConflictError("User with this userName already exists", "uniqueness") + } + return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) + } + return nil +} + func setSCIMIdentityField(tx *storage.Connection, identity *models.Identity, key, value string) error { if identity.IdentityData == nil { identity.IdentityData = make(map[string]interface{}) diff --git a/internal/api/scim_test.go b/internal/api/scim_test.go index 8044aebb6..578597080 100644 --- a/internal/api/scim_test.go +++ b/internal/api/scim_test.go @@ -43,8 +43,6 @@ var ( testUser8 = scimTestUser{UserName: "user8@example.com", Email: "user8@example.com"} testUser9 = scimTestUser{UserName: "user9@acme.com", Email: "user9@acme.com", GivenName: "Jane", FamilyName: "Doe", Formatted: "Jane Doe", ExternalID: "ext-002"} testUser10 = scimTestUser{UserName: "user10@acme.com", Email: "user10@acme.com", GivenName: "John", FamilyName: "Smith", Formatted: "John Smith", ExternalID: "ext-003"} - testUser11 = scimTestUser{UserName: "user11@acme.com", Email: "user11@acme.com", ExternalID: "ext-004"} - testUser12 = scimTestUser{UserName: "user12@acme.com", Email: "user12@acme.com", ExternalID: "ext-005"} testUser13 = scimTestUser{UserName: "user13@example.com", Email: "user13@example.com", ExternalID: "ext-006"} testUser14 = scimTestUser{UserName: "user14@acme.com", Email: "user14@acme.com", ExternalID: "ext-007"} testUser15 = scimTestUser{UserName: "user15@acme.com", Email: "user15@acme.com", ExternalID: "ext-008"} @@ -405,6 +403,17 @@ func (ts *SCIMTestSuite) TestSCIMGetGroupNotFound() { ts.assertSCIMError(w, http.StatusNotFound) } +func (ts *SCIMTestSuite) TestSCIMMethodNotAllowedReturnsSCIMError() { + user := ts.createSCIMUser("method_not_allowed@test.com", "method_not_allowed@test.com") + + req := ts.makeSCIMRequest(http.MethodPost, "/scim/v2/Users/"+user.ID, map[string]interface{}{}) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMError(w, http.StatusMethodNotAllowed) + require.Equal(ts.T(), "application/scim+json", w.Header().Get("Content-Type")) +} + func (ts *SCIMTestSuite) TestSCIMCreateUserWithName() { user := ts.createSCIMUserWithName(testUser2.UserName, testUser2.Email, testUser2.GivenName, testUser2.FamilyName) @@ -692,19 +701,6 @@ func (ts *SCIMTestSuite) TestSCIMCreateUserDuplicateExternalID() { ts.assertSCIMErrorWithType(w, http.StatusConflict, "uniqueness") } -func (ts *SCIMTestSuite) TestSCIMDeleteUserReturns204() { - user := ts.createSCIMUserWithExternalID(testUser11.UserName, testUser11.Email, testUser11.ExternalID) - - require.True(ts.T(), user.Active) - - req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Users/"+user.ID, nil) - w := httptest.NewRecorder() - - ts.API.handler.ServeHTTP(w, req) - require.Equal(ts.T(), http.StatusNoContent, w.Code) - require.Empty(ts.T(), w.Body.String()) -} - func (ts *SCIMTestSuite) TestSCIMDeleteNonExistentUser() { nonExistentID := "f1937c5d-cd6d-4151-93b7-dbfb7fb9b31d" @@ -715,22 +711,6 @@ func (ts *SCIMTestSuite) TestSCIMDeleteNonExistentUser() { ts.assertSCIMError(w, http.StatusNotFound) } -func (ts *SCIMTestSuite) TestSCIMDeleteUserTwice() { - user := ts.createSCIMUserWithExternalID(testUser12.UserName, testUser12.Email, testUser12.ExternalID) - - req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Users/"+user.ID, nil) - w := httptest.NewRecorder() - - ts.API.handler.ServeHTTP(w, req) - require.Equal(ts.T(), http.StatusNoContent, w.Code) - - req = ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Users/"+user.ID, nil) - w = httptest.NewRecorder() - - ts.API.handler.ServeHTTP(w, req) - require.Equal(ts.T(), http.StatusNotFound, w.Code) -} - func (ts *SCIMTestSuite) TestSCIMReactivateDeprovisionedUser() { user := ts.createSCIMUserWithName(testUser17.UserName, testUser17.Email, testUser17.GivenName, testUser17.FamilyName) require.True(ts.T(), user.Active) @@ -1194,6 +1174,39 @@ func (ts *SCIMTestSuite) TestSCIMPatchUserUpdateUserNameWithPath() { require.Equal(ts.T(), "new_username@test.com", getResult.UserName) } +func (ts *SCIMTestSuite) TestSCIMPatchUserUpdateUserNameWithPathSyncsSubjectWhenExternalIDMissing() { + oldUserName := "subject_sync_original@test.com" + newUserName := "subject_sync_new@test.com" + user := ts.createSCIMUser(oldUserName, oldUserName) + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "userName", "value": newUserName}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), newUserName, result.UserName) + + providerType := "sso:" + ts.SSOProvider.ID.String() + identity, err := models.FindIdentityByIdAndProvider(ts.API.db, newUserName, providerType) + require.NoError(ts.T(), err) + require.Equal(ts.T(), newUserName, identity.ProviderID) + require.Equal(ts.T(), newUserName, identity.IdentityData["user_name"]) + require.Equal(ts.T(), newUserName, identity.IdentityData["sub"]) + + _, err = models.FindIdentityByIdAndProvider(ts.API.db, oldUserName, providerType) + require.Error(ts.T(), err) +} + func (ts *SCIMTestSuite) TestSCIMPatchUserInvalidActiveType() { user := ts.createSCIMUser("invalid_active_test@test.com", "invalid_active_test@test.com") @@ -1260,17 +1273,6 @@ func (ts *SCIMTestSuite) TestSCIMCreateGroupDuplicateExternalID() { ts.assertSCIMErrorWithType(w, http.StatusConflict, "uniqueness") } -func (ts *SCIMTestSuite) TestSCIMDeleteGroupReturns204() { - group := ts.createSCIMGroupWithExternalID("TESTGROUP", "delete-test-ext-id") - - req := ts.makeSCIMRequest(http.MethodDelete, "/scim/v2/Groups/"+group.ID, nil) - w := httptest.NewRecorder() - - ts.API.handler.ServeHTTP(w, req) - require.Equal(ts.T(), http.StatusNoContent, w.Code) - require.Empty(ts.T(), w.Body.String()) -} - func (ts *SCIMTestSuite) TestSCIMDeleteNonExistentGroup() { nonExistentID := "a0f1d64e-cf53-45cf-8b4b-ea0d7b9ada90" @@ -1425,6 +1427,31 @@ func (ts *SCIMTestSuite) TestSCIMPatchUserAddExternalIDWithPath() { require.Equal(ts.T(), "new-ext-via-path", result.ExternalID) } +func (ts *SCIMTestSuite) TestSCIMPatchUserRejectsEmptyExternalID() { + user := ts.createSCIMUserWithExternalID("empty_external_id_patch@test.com", "empty_external_id_patch@test.com", "ext-original-id") + + body := map[string]interface{}{ + "schemas": []string{SCIMSchemaPatchOp}, + "Operations": []map[string]interface{}{ + {"op": "replace", "path": "externalId", "value": ""}, + }, + } + + req := ts.makeSCIMRequest(http.MethodPatch, "/scim/v2/Users/"+user.ID, body) + w := httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + ts.assertSCIMErrorWithType(w, http.StatusBadRequest, "invalidValue") + + req = ts.makeSCIMRequest(http.MethodGet, "/scim/v2/Users/"+user.ID, nil) + w = httptest.NewRecorder() + ts.API.handler.ServeHTTP(w, req) + require.Equal(ts.T(), http.StatusOK, w.Code) + + var result SCIMUserResponse + require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&result)) + require.Equal(ts.T(), "ext-original-id", result.ExternalID) +} + func (ts *SCIMTestSuite) TestSCIMPatchUserAddInvalidValueType() { user := ts.createSCIMUser("add_invalid_val@test.com", "add_invalid_val@test.com") @@ -2006,11 +2033,9 @@ func (ts *SCIMTestSuite) TestSCIMPatchGroupRemoveAllMembers() { } func (ts *SCIMTestSuite) TestSCIMPatchGroupDisplayNameConflict() { - // Create two groups with distinct names _ = ts.createSCIMGroupWithExternalID("FirstGroup", "conflict-ext-1") secondGroup := ts.createSCIMGroupWithExternalID("SecondGroup", "conflict-ext-2") - // Try to rename SecondGroup to FirstGroup via PATCH replace with path body := map[string]interface{}{ "schemas": []string{SCIMSchemaPatchOp}, "Operations": []map[string]interface{}{ @@ -2026,11 +2051,9 @@ func (ts *SCIMTestSuite) TestSCIMPatchGroupDisplayNameConflict() { } func (ts *SCIMTestSuite) TestSCIMPatchGroupDisplayNameConflictValueMap() { - // Create two groups with distinct names _ = ts.createSCIMGroupWithExternalID("ValueMapFirst", "vm-ext-1") secondGroup := ts.createSCIMGroupWithExternalID("ValueMapSecond", "vm-ext-2") - // Try to rename SecondGroup via PATCH replace without path (value map) body := map[string]interface{}{ "schemas": []string{SCIMSchemaPatchOp}, "Operations": []map[string]interface{}{ @@ -2046,11 +2069,9 @@ func (ts *SCIMTestSuite) TestSCIMPatchGroupDisplayNameConflictValueMap() { } func (ts *SCIMTestSuite) TestSCIMReplaceGroupDisplayNameConflict() { - // Create two groups with distinct names _ = ts.createSCIMGroupWithExternalID("ReplaceFirst", "replace-ext-1") secondGroup := ts.createSCIMGroupWithExternalID("ReplaceSecond", "replace-ext-2") - // Try to replace SecondGroup with FirstGroup's displayName via PUT body := map[string]interface{}{ "schemas": []string{SCIMSchemaGroup}, "displayName": "ReplaceFirst", @@ -2069,24 +2090,7 @@ func (ts *SCIMTestSuite) TestSCIMAuthMissingAuthorizationHeader() { w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) - - require.Equal(ts.T(), http.StatusUnauthorized, w.Code) - - var errorResp map[string]interface{} - require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errorResp)) - - schemas, ok := errorResp["schemas"].([]interface{}) - require.True(ts.T(), ok, "SCIM error should have schemas field") - require.Len(ts.T(), schemas, 1) - require.Equal(ts.T(), "urn:ietf:params:scim:api:messages:2.0:Error", schemas[0]) - - detail, ok := errorResp["detail"].(string) - require.True(ts.T(), ok, "SCIM error should have detail field") - require.NotEmpty(ts.T(), detail) - - status, ok := errorResp["status"].(string) - require.True(ts.T(), ok, "SCIM error should have status field as string per RFC 7644") - require.Equal(ts.T(), "401", status) + ts.assertSCIMError(w, http.StatusUnauthorized) } func (ts *SCIMTestSuite) TestSCIMAuthInvalidBearerToken() { @@ -2096,24 +2100,7 @@ func (ts *SCIMTestSuite) TestSCIMAuthInvalidBearerToken() { w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) - - require.Equal(ts.T(), http.StatusUnauthorized, w.Code) - - var errorResp map[string]interface{} - require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errorResp)) - - schemas, ok := errorResp["schemas"].([]interface{}) - require.True(ts.T(), ok, "SCIM error should have schemas field") - require.Len(ts.T(), schemas, 1) - require.Equal(ts.T(), "urn:ietf:params:scim:api:messages:2.0:Error", schemas[0]) - - detail, ok := errorResp["detail"].(string) - require.True(ts.T(), ok, "SCIM error should have detail field") - require.NotEmpty(ts.T(), detail) - - status, ok := errorResp["status"].(string) - require.True(ts.T(), ok, "SCIM error should have status field as string per RFC 7644") - require.Equal(ts.T(), "401", status) + ts.assertSCIMError(w, http.StatusUnauthorized) } func (ts *SCIMTestSuite) TestSCIMAuthMalformedAuthorizationHeader() { @@ -2123,20 +2110,7 @@ func (ts *SCIMTestSuite) TestSCIMAuthMalformedAuthorizationHeader() { w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) - - require.Equal(ts.T(), http.StatusUnauthorized, w.Code) - - var errorResp map[string]interface{} - require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errorResp)) - - schemas, ok := errorResp["schemas"].([]interface{}) - require.True(ts.T(), ok, "SCIM error should have schemas field") - require.Len(ts.T(), schemas, 1) - require.Equal(ts.T(), "urn:ietf:params:scim:api:messages:2.0:Error", schemas[0]) - - status, ok := errorResp["status"].(string) - require.True(ts.T(), ok, "SCIM error should have status field") - require.Equal(ts.T(), "401", status) + ts.assertSCIMError(w, http.StatusUnauthorized) } func (ts *SCIMTestSuite) TestSCIMAuthEmptyBearerToken() { @@ -2146,20 +2120,7 @@ func (ts *SCIMTestSuite) TestSCIMAuthEmptyBearerToken() { w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) - - require.Equal(ts.T(), http.StatusUnauthorized, w.Code) - - var errorResp map[string]interface{} - require.NoError(ts.T(), json.NewDecoder(w.Body).Decode(&errorResp)) - - schemas, ok := errorResp["schemas"].([]interface{}) - require.True(ts.T(), ok, "SCIM error should have schemas field") - require.Len(ts.T(), schemas, 1) - require.Equal(ts.T(), "urn:ietf:params:scim:api:messages:2.0:Error", schemas[0]) - - status, ok := errorResp["status"].(string) - require.True(ts.T(), ok, "SCIM error should have status field") - require.Equal(ts.T(), "401", status) + ts.assertSCIMError(w, http.StatusUnauthorized) } func (ts *SCIMTestSuite) TestSCIMErrorInvalidFilterSyntax() { @@ -2839,14 +2800,12 @@ func (ts *SCIMTestSuite) TestSCIMGroupFilterCO() { } func (ts *SCIMTestSuite) TestSCIMBodyExceedsMaxSize() { - // Create a body larger than 1MB largeBody := strings.Repeat("x", SCIMMaxBodySize+1) req := httptest.NewRequest(http.MethodPost, "http://localhost/scim/v2/Users", bytes.NewBufferString(largeBody)) req.Header.Set("Authorization", "Bearer "+ts.SCIMToken) req.Header.Set("Content-Type", "application/scim+json") w := httptest.NewRecorder() ts.API.handler.ServeHTTP(w, req) - // Should get an error (400 or 413) require.True(ts.T(), w.Code >= 400, "Expected error status for oversized body, got %d", w.Code) } From dff166dd0fa1f8db852823273f8af80955d4667a Mon Sep 17 00:00:00 2001 From: Omar Al Matar Date: Sat, 7 Feb 2026 19:23:06 +0300 Subject: [PATCH 101/101] refactor: extract SCIM error messages to constants and normalize wording --- internal/api/scim.go | 94 ++++++++++++++++++------------------ internal/api/scim_helpers.go | 12 ++--- internal/api/scim_types.go | 11 +++++ 3 files changed, 64 insertions(+), 53 deletions(-) diff --git a/internal/api/scim.go b/internal/api/scim.go index 5f9a993b9..bbcd96e3f 100644 --- a/internal/api/scim.go +++ b/internal/api/scim.go @@ -86,19 +86,19 @@ func (a *API) scimGetUser(w http.ResponseWriter, r *http.Request) error { userID, err := uuid.FromString(chi.URLParam(r, "user_id")) if err != nil { - return apierrors.NewSCIMNotFoundError("User not found") + return apierrors.NewSCIMNotFoundError(scimErrUserNotFound) } user, err := models.FindUserByID(db, userID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewSCIMNotFoundError("User not found") + return apierrors.NewSCIMNotFoundError(scimErrUserNotFound) } return apierrors.NewSCIMInternalServerError("Error fetching user").WithInternalError(err) } if !models.UserBelongsToSSOProvider(user, provider.ID) { - return apierrors.NewSCIMNotFoundError("User not found") + return apierrors.NewSCIMNotFoundError(scimErrUserNotFound) } return sendSCIMJSON(w, http.StatusOK, a.userToSCIMResponse(user, "sso:"+provider.ID.String())) @@ -138,7 +138,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { return apierrors.NewSCIMInternalServerError("Error checking existing user").WithInternalError(err) } if nonSSOUser != nil { - return apierrors.NewSCIMConflictError("User with this email already exists", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrEmailConflict, "uniqueness") } ssoUsers, err := models.FindSSOUsersByEmailAndProvider(tx, email, config.JWT.Aud, providerType) @@ -150,13 +150,13 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { var deprovisioned []*models.User for _, u := range ssoUsers { if u.BannedReason == nil || *u.BannedReason != scimDeprovisionedReason { - return apierrors.NewSCIMConflictError("User with this email already exists", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrEmailConflict, "uniqueness") } deprovisioned = append(deprovisioned, u) } if len(deprovisioned) > 1 { - return apierrors.NewSCIMConflictError("Multiple deprovisioned users exist for this email", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrAmbiguousDeprovisioned, "uniqueness") } candidate := deprovisioned[0] @@ -202,7 +202,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { } if err := tx.UpdateOnly(&candidate.Identities[i], "provider_id", "identity_data"); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrExternalIDConflict, "uniqueness") } return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } @@ -241,7 +241,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { if err := tx.Create(user); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("User with this email already exists", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrEmailConflict, "uniqueness") } return apierrors.NewSCIMInternalServerError("Error saving user").WithInternalError(err) } @@ -263,7 +263,7 @@ func (a *API) scimCreateUser(w http.ResponseWriter, r *http.Request) error { errToCheck = httpErr.InternalError } if pgErr := utilities.NewPostgresError(errToCheck); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrExternalIDConflict, "uniqueness") } return err } @@ -306,7 +306,7 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { userID, err := uuid.FromString(chi.URLParam(r, "user_id")) if err != nil { - return apierrors.NewSCIMNotFoundError("User not found") + return apierrors.NewSCIMNotFoundError(scimErrUserNotFound) } var params SCIMUserParams @@ -331,13 +331,13 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { user, err = models.FindUserByID(tx, userID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewSCIMNotFoundError("User not found") + return apierrors.NewSCIMNotFoundError(scimErrUserNotFound) } return apierrors.NewSCIMInternalServerError("Error fetching user").WithInternalError(err) } if !models.UserBelongsToSSOProvider(user, provider.ID) { - return apierrors.NewSCIMNotFoundError("User not found") + return apierrors.NewSCIMNotFoundError(scimErrUserNotFound) } metadata := user.UserMetaData @@ -373,7 +373,7 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { } if err := user.SetEmail(tx, email); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("Email already in use", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrEmailConflict, "uniqueness") } return apierrors.NewSCIMInternalServerError("Error updating user email").WithInternalError(err) } @@ -403,7 +403,7 @@ func (a *API) scimReplaceUser(w http.ResponseWriter, r *http.Request) error { } if err := tx.UpdateOnly(&user.Identities[i], updateCols...); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrExternalIDConflict, "uniqueness") } return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } @@ -440,7 +440,7 @@ func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { userID, err := uuid.FromString(chi.URLParam(r, "user_id")) if err != nil { - return apierrors.NewSCIMNotFoundError("User not found") + return apierrors.NewSCIMNotFoundError(scimErrUserNotFound) } var params SCIMPatchRequest @@ -457,13 +457,13 @@ func (a *API) scimPatchUser(w http.ResponseWriter, r *http.Request) error { user, err = models.FindUserByID(tx, userID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewSCIMNotFoundError("User not found") + return apierrors.NewSCIMNotFoundError(scimErrUserNotFound) } return apierrors.NewSCIMInternalServerError("Error fetching user").WithInternalError(err) } if !models.UserBelongsToSSOProvider(user, provider.ID) { - return apierrors.NewSCIMNotFoundError("User not found") + return apierrors.NewSCIMNotFoundError(scimErrUserNotFound) } for _, op := range params.Operations { @@ -695,7 +695,7 @@ func (a *API) applySCIMEmailUpdate(tx *storage.Connection, user *models.User, ne } if err := user.SetEmail(tx, validatedEmail); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("Email already in use", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrEmailConflict, "uniqueness") } return apierrors.NewSCIMInternalServerError("Error updating email").WithInternalError(err) } @@ -733,24 +733,24 @@ func (a *API) scimDeleteUser(w http.ResponseWriter, r *http.Request) error { userID, err := uuid.FromString(chi.URLParam(r, "user_id")) if err != nil { - return apierrors.NewSCIMNotFoundError("User not found") + return apierrors.NewSCIMNotFoundError(scimErrUserNotFound) } terr := db.Transaction(func(tx *storage.Connection) error { user, err := models.FindUserByID(tx, userID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewSCIMNotFoundError("User not found") + return apierrors.NewSCIMNotFoundError(scimErrUserNotFound) } return apierrors.NewSCIMInternalServerError("Error fetching user").WithInternalError(err) } if !models.UserBelongsToSSOProvider(user, provider.ID) { - return apierrors.NewSCIMNotFoundError("User not found") + return apierrors.NewSCIMNotFoundError(scimErrUserNotFound) } if user.IsBanned() && user.BannedReason != nil && *user.BannedReason == scimDeprovisionedReason { - return apierrors.NewSCIMNotFoundError("User not found") + return apierrors.NewSCIMNotFoundError(scimErrUserNotFound) } if err := user.Ban(tx, time.Duration(math.MaxInt64), &scimDeprovisionedReason); err != nil { @@ -839,19 +839,19 @@ func (a *API) scimGetGroup(w http.ResponseWriter, r *http.Request) error { groupID, err := uuid.FromString(chi.URLParam(r, "group_id")) if err != nil { - return apierrors.NewSCIMNotFoundError("Group not found") + return apierrors.NewSCIMNotFoundError(scimErrGroupNotFound) } group, err := models.FindSCIMGroupByID(db, groupID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewSCIMNotFoundError("Group not found") + return apierrors.NewSCIMNotFoundError(scimErrGroupNotFound) } return apierrors.NewSCIMInternalServerError("Error fetching group").WithInternalError(err) } if group.SSOProviderID != provider.ID { - return apierrors.NewSCIMNotFoundError("Group not found") + return apierrors.NewSCIMNotFoundError(scimErrGroupNotFound) } excludeMembers := strings.Contains(strings.ToLower(r.URL.Query().Get("excludedAttributes")), "members") @@ -885,7 +885,7 @@ func (a *API) scimCreateGroup(w http.ResponseWriter, r *http.Request) error { if params.ExternalID != "" { existing, err := models.FindSCIMGroupByExternalID(tx, provider.ID, params.ExternalID) if err == nil && existing != nil { - return apierrors.NewSCIMConflictError("Group with this externalId already exists", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrGroupExternalIDConflict, "uniqueness") } if err != nil && !models.IsNotFoundError(err) { return apierrors.NewSCIMInternalServerError("Error checking existing group").WithInternalError(err) @@ -895,7 +895,7 @@ func (a *API) scimCreateGroup(w http.ResponseWriter, r *http.Request) error { group = models.NewSCIMGroup(provider.ID, params.ExternalID, params.DisplayName) if err := tx.Create(group); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("Group already exists", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrGroupDisplayNameConflict, "uniqueness") } return apierrors.NewSCIMInternalServerError("Error creating group").WithInternalError(err) } @@ -907,10 +907,10 @@ func (a *API) scimCreateGroup(w http.ResponseWriter, r *http.Request) error { } if err := group.AddMembers(tx, memberIDs); err != nil { if _, ok := err.(models.UserNotFoundError); ok { - return apierrors.NewSCIMNotFoundError("One or more members not found") + return apierrors.NewSCIMNotFoundError(scimErrMembersNotFound) } if _, ok := err.(models.UserNotInSSOProviderError); ok { - return apierrors.NewSCIMBadRequestError("One or more members do not belong to this SSO provider", "invalidValue") + return apierrors.NewSCIMBadRequestError(scimErrMembersWrongProvider, "invalidValue") } return apierrors.NewSCIMInternalServerError("Error adding group members").WithInternalError(err) } @@ -937,7 +937,7 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { groupID, err := uuid.FromString(chi.URLParam(r, "group_id")) if err != nil { - return apierrors.NewSCIMNotFoundError("Group not found") + return apierrors.NewSCIMNotFoundError(scimErrGroupNotFound) } var params SCIMGroupParams @@ -954,13 +954,13 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { group, err = models.FindSCIMGroupByID(tx, groupID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewSCIMNotFoundError("Group not found") + return apierrors.NewSCIMNotFoundError(scimErrGroupNotFound) } return apierrors.NewSCIMInternalServerError("Error fetching group").WithInternalError(err) } if group.SSOProviderID != provider.ID { - return apierrors.NewSCIMNotFoundError("Group not found") + return apierrors.NewSCIMNotFoundError(scimErrGroupNotFound) } group.DisplayName = params.DisplayName @@ -968,7 +968,7 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { if err := tx.Update(group); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("Group already exists", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrGroupDisplayNameConflict, "uniqueness") } return apierrors.NewSCIMInternalServerError("Error updating group").WithInternalError(err) } @@ -980,10 +980,10 @@ func (a *API) scimReplaceGroup(w http.ResponseWriter, r *http.Request) error { if err := group.SetMembers(tx, memberIDs); err != nil { if models.IsNotFoundError(err) { - return apierrors.NewSCIMNotFoundError("One or more member IDs not found") + return apierrors.NewSCIMNotFoundError(scimErrMembersNotFound) } if _, ok := err.(models.UserNotInSSOProviderError); ok { - return apierrors.NewSCIMBadRequestError("One or more members do not belong to this SSO provider", "invalidValue") + return apierrors.NewSCIMBadRequestError(scimErrMembersWrongProvider, "invalidValue") } return apierrors.NewSCIMInternalServerError("Error setting group members").WithInternalError(err) } @@ -1013,7 +1013,7 @@ func (a *API) scimPatchGroup(w http.ResponseWriter, r *http.Request) error { groupID, err := uuid.FromString(chi.URLParam(r, "group_id")) if err != nil { - return apierrors.NewSCIMNotFoundError("Group not found") + return apierrors.NewSCIMNotFoundError(scimErrGroupNotFound) } var params SCIMPatchRequest @@ -1030,13 +1030,13 @@ func (a *API) scimPatchGroup(w http.ResponseWriter, r *http.Request) error { group, err = models.FindSCIMGroupByID(tx, groupID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewSCIMNotFoundError("Group not found") + return apierrors.NewSCIMNotFoundError(scimErrGroupNotFound) } return apierrors.NewSCIMInternalServerError("Error fetching group").WithInternalError(err) } if group.SSOProviderID != provider.ID { - return apierrors.NewSCIMNotFoundError("Group not found") + return apierrors.NewSCIMNotFoundError(scimErrGroupNotFound) } for _, op := range params.Operations { @@ -1105,10 +1105,10 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou } if err := group.AddMembers(tx, memberIDs); err != nil { if _, ok := err.(models.UserNotFoundError); ok { - return apierrors.NewSCIMNotFoundError("One or more members not found") + return apierrors.NewSCIMNotFoundError(scimErrMembersNotFound) } if _, ok := err.(models.UserNotInSSOProviderError); ok { - return apierrors.NewSCIMBadRequestError("One or more members do not belong to this SSO provider", "invalidValue") + return apierrors.NewSCIMBadRequestError(scimErrMembersWrongProvider, "invalidValue") } return apierrors.NewSCIMInternalServerError("Error adding group members").WithInternalError(err) } @@ -1160,7 +1160,7 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou group.DisplayName = displayName if err := tx.UpdateOnly(group, "display_name"); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("Group with this displayName already exists", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrGroupDisplayNameConflict, "uniqueness") } return apierrors.NewSCIMInternalServerError("Error updating group display name").WithInternalError(err) } @@ -1179,10 +1179,10 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou } if err := group.SetMembers(tx, memberIDs); err != nil { if models.IsNotFoundError(err) { - return apierrors.NewSCIMNotFoundError("One or more member IDs not found") + return apierrors.NewSCIMNotFoundError(scimErrMembersNotFound) } if _, ok := err.(models.UserNotInSSOProviderError); ok { - return apierrors.NewSCIMBadRequestError("One or more members do not belong to this SSO provider", "invalidValue") + return apierrors.NewSCIMBadRequestError(scimErrMembersWrongProvider, "invalidValue") } return apierrors.NewSCIMInternalServerError("Error setting group members").WithInternalError(err) } @@ -1221,7 +1221,7 @@ func (a *API) applySCIMGroupPatch(tx *storage.Connection, group *models.SCIMGrou if len(columnsToUpdate) > 0 { if err := tx.UpdateOnly(group, columnsToUpdate...); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("Group already exists with this value", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrGroupDisplayNameConflict, "uniqueness") } return apierrors.NewSCIMInternalServerError("Error updating group").WithInternalError(err) } @@ -1240,20 +1240,20 @@ func (a *API) scimDeleteGroup(w http.ResponseWriter, r *http.Request) error { groupID, err := uuid.FromString(chi.URLParam(r, "group_id")) if err != nil { - return apierrors.NewSCIMNotFoundError("Group not found") + return apierrors.NewSCIMNotFoundError(scimErrGroupNotFound) } terr := db.Transaction(func(tx *storage.Connection) error { group, err := models.FindSCIMGroupByID(tx, groupID) if err != nil { if models.IsNotFoundError(err) { - return apierrors.NewSCIMNotFoundError("Group not found") + return apierrors.NewSCIMNotFoundError(scimErrGroupNotFound) } return apierrors.NewSCIMInternalServerError("Error fetching group").WithInternalError(err) } if group.SSOProviderID != provider.ID { - return apierrors.NewSCIMNotFoundError("Group not found") + return apierrors.NewSCIMNotFoundError(scimErrGroupNotFound) } if err := tx.Destroy(group); err != nil { diff --git a/internal/api/scim_helpers.go b/internal/api/scim_helpers.go index f7e0948ef..40bd15c16 100644 --- a/internal/api/scim_helpers.go +++ b/internal/api/scim_helpers.go @@ -197,7 +197,7 @@ func setSCIMExternalID(tx *storage.Connection, identity *models.Identity, extern identity.IdentityData["sub"] = externalID if err := tx.UpdateOnly(identity, "provider_id", "identity_data"); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("User with this externalId already exists", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrExternalIDConflict, "uniqueness") } return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } @@ -219,7 +219,7 @@ func setSCIMUserName(tx *storage.Connection, identity *models.Identity, userName if err := tx.UpdateOnly(identity, updateCols...); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("User with this userName already exists", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrUserNameConflict, "uniqueness") } return apierrors.NewSCIMInternalServerError("Error updating identity").WithInternalError(err) } @@ -300,7 +300,7 @@ func updateGroupExternalID(tx *storage.Connection, group *models.SCIMGroup, exte group.ExternalID = storage.NullString(externalID) if err := tx.UpdateOnly(group, "external_id"); err != nil { if pgErr := utilities.NewPostgresError(err); pgErr != nil && pgErr.IsUniqueConstraintViolated() { - return apierrors.NewSCIMConflictError("Group with this externalId already exists", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrGroupExternalIDConflict, "uniqueness") } return apierrors.NewSCIMInternalServerError("Error updating group external ID").WithInternalError(err) } @@ -314,10 +314,10 @@ func checkSCIMEmailUniqueness(tx *storage.Connection, email, aud, providerType s } if existingUser != nil && existingUser.ID != excludeUserID { if !existingUser.IsSSOUser { - return apierrors.NewSCIMConflictError("Email already in use by another user", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrEmailConflict, "uniqueness") } if existingUser.BannedReason == nil || *existingUser.BannedReason != scimDeprovisionedReason { - return apierrors.NewSCIMConflictError("Email already in use by another user", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrEmailConflict, "uniqueness") } } @@ -330,7 +330,7 @@ func checkSCIMEmailUniqueness(tx *storage.Connection, email, aud, providerType s continue } if u.BannedReason == nil || *u.BannedReason != scimDeprovisionedReason { - return apierrors.NewSCIMConflictError("Email already in use by another user", "uniqueness") + return apierrors.NewSCIMConflictError(scimErrEmailConflict, "uniqueness") } } return nil diff --git a/internal/api/scim_types.go b/internal/api/scim_types.go index 16d4c82ee..dd4ba3d12 100644 --- a/internal/api/scim_types.go +++ b/internal/api/scim_types.go @@ -20,6 +20,17 @@ const ( SCIMSchemaGroup = "urn:ietf:params:scim:schemas:core:2.0:Group" SCIMSchemaListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse" SCIMSchemaPatchOp = "urn:ietf:params:scim:api:messages:2.0:PatchOp" + + scimErrUserNotFound = "User not found" + scimErrGroupNotFound = "Group not found" + scimErrEmailConflict = "Email already in use by another user" + scimErrExternalIDConflict = "User with this externalId already exists" + scimErrUserNameConflict = "User with this userName already exists" + scimErrGroupExternalIDConflict = "Group with this externalId already exists" + scimErrGroupDisplayNameConflict = "Group with this displayName already exists" + scimErrMembersNotFound = "One or more members not found" + scimErrMembersWrongProvider = "One or more members do not belong to this SSO provider" + scimErrAmbiguousDeprovisioned = "Multiple deprovisioned users exist for this email" ) // Must be var (not const) because it's passed by pointer to user.Ban()