diff --git a/go.mod b/go.mod index c499aea5f..7a08db231 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d github.com/chromedp/chromedp v0.14.2 github.com/crewjam/saml v0.5.1 + github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 github.com/go-chi/chi/v5 v5.2.4 github.com/go-chi/cors v1.2.2 github.com/jackc/pgx/v5 v5.8.0 @@ -57,6 +58,8 @@ require ( github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/di-wu/parser v0.2.2 // indirect + github.com/di-wu/xsd-datetime v1.0.0 // indirect github.com/fatih/color v1.18.0 // indirect github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -95,6 +98,7 @@ require ( github.com/richardlehane/mscfb v1.0.4 // indirect github.com/richardlehane/msoleps v1.0.4 // indirect github.com/russellhaering/goxmldsig v1.5.0 // indirect + github.com/scim2/filter-parser/v2 v2.2.0 // indirect github.com/sosodev/duration v1.3.1 // indirect github.com/spf13/cobra v1.10.1 // indirect github.com/spf13/pflag v1.0.9 // indirect diff --git a/go.sum b/go.sum index 68fc39cc9..d79dfab3a 100644 --- a/go.sum +++ b/go.sum @@ -64,6 +64,12 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo= github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA= +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/di-wu/xsd-datetime v1.0.0 h1:vZoGNkbzpBNoc+JyfVLEbutNDNydYV8XwHeV7eUJoxI= +github.com/di-wu/xsd-datetime v1.0.0/go.mod h1:i3iEhrP3WchwseOBeIdW/zxeoleXTOzx1WyDXgdmOww= +github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8 h1:0+BTyxIYgiVAry/P5s8R4dYuLkhB9Nhso8ogFWNr4IQ= +github.com/elimity-com/scim v0.0.0-20240320110924-172bf2aee9c8/go.mod h1:JkjcmqbLW+khwt2fmBPJFBhx2zGZ8XobRZ+O0VhlwWo= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/go-chi/chi/v5 v5.2.4 h1:WtFKPHwlywe8Srng8j2BhOD9312j9cGUxG1SP4V2cR4= @@ -184,6 +190,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russellhaering/goxmldsig v1.5.0 h1:AU2UkkYIUOTyZRbe08XMThaOCelArgvNfYapcmSjBNw= github.com/russellhaering/goxmldsig v1.5.0/go.mod h1:x98CjQNFJcWfMxeOrMnMKg70lvDP6tE0nTaeUnjXDmk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +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/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= diff --git a/pkg/coredata/membership.go b/pkg/coredata/membership.go index 2096bec63..6f5050656 100644 --- a/pkg/coredata/membership.go +++ b/pkg/coredata/membership.go @@ -387,6 +387,76 @@ LEFT JOIN return nil } +func (m *Membership) LoadByEmailAndOrganization( + ctx context.Context, + conn pg.Conn, + scope Scoper, + email mail.Addr, + organizationID gid.GID, +) error { + q := ` +WITH mbr AS ( + SELECT + am.id, + am.identity_id, + am.organization_id, + am.role, + am.source, + am.created_at, + am.updated_at + FROM + iam_memberships am + JOIN + identities i ON am.identity_id = i.id + WHERE + i.email_address = @email + AND am.organization_id = @organization_id + AND %s +) +SELECT + mbr.id, + mbr.identity_id, + mbr.organization_id, + mbr.role, + mbr.source, + COALESCE(mp.full_name, i.full_name, '') as full_name, + i.email_address, + mbr.created_at, + mbr.updated_at +FROM + mbr +JOIN + identities i ON mbr.identity_id = i.id +LEFT JOIN + iam_membership_profiles mp ON mp.membership_id = mbr.id +` + + q = fmt.Sprintf(q, scope.SQLFragment()) + + args := pgx.StrictNamedArgs{ + "email": email, + "organization_id": organizationID, + } + maps.Copy(args, scope.SQLArguments()) + + rows, err := conn.Query(ctx, q, args) + if err != nil { + return fmt.Errorf("cannot query membership by email: %w", err) + } + + membership, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Membership]) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return ErrResourceNotFound + } + + return fmt.Errorf("cannot collect membership: %w", err) + } + + *m = membership + return nil +} + func (m *Membership) Update(ctx context.Context, conn pg.Conn, scope Scoper) error { query := ` UPDATE diff --git a/pkg/iam/scim/errors.go b/pkg/iam/scim/errors.go new file mode 100644 index 000000000..d3763fcb8 --- /dev/null +++ b/pkg/iam/scim/errors.go @@ -0,0 +1,91 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package scim + +import ( + "fmt" + + "go.probo.inc/probo/pkg/gid" +) + +type ErrSCIMConfigurationNotFound struct { + ID gid.GID +} + +func (e *ErrSCIMConfigurationNotFound) Error() string { + return fmt.Sprintf("SCIM configuration %s not found", e.ID) +} + +func NewSCIMConfigurationNotFoundError(id gid.GID) *ErrSCIMConfigurationNotFound { + return &ErrSCIMConfigurationNotFound{ID: id} +} + +type ErrSCIMConfigurationAlreadyExists struct { + OrganizationID gid.GID +} + +func (e *ErrSCIMConfigurationAlreadyExists) Error() string { + return fmt.Sprintf("SCIM configuration already exists for organization %s", e.OrganizationID) +} + +func NewSCIMConfigurationAlreadyExistsError(organizationID gid.GID) *ErrSCIMConfigurationAlreadyExists { + return &ErrSCIMConfigurationAlreadyExists{OrganizationID: organizationID} +} + +type ErrSCIMUserNotFound struct { + ID gid.GID +} + +func (e *ErrSCIMUserNotFound) Error() string { + return fmt.Sprintf("SCIM user %s not found", e.ID) +} + +func NewSCIMUserNotFoundError(id gid.GID) *ErrSCIMUserNotFound { + return &ErrSCIMUserNotFound{ID: id} +} + +type ErrSCIMInvalidToken struct{} + +func (e *ErrSCIMInvalidToken) Error() string { + return "invalid SCIM bearer token" +} + +func NewSCIMInvalidTokenError() *ErrSCIMInvalidToken { + return &ErrSCIMInvalidToken{} +} + +type ErrSCIMInvalidRequest struct { + Detail string +} + +func (e *ErrSCIMInvalidRequest) Error() string { + return fmt.Sprintf("invalid SCIM request: %s", e.Detail) +} + +func NewSCIMInvalidRequestError(detail string) *ErrSCIMInvalidRequest { + return &ErrSCIMInvalidRequest{Detail: detail} +} + +type ErrSCIMUserAlreadyExists struct { + Email string +} + +func (e *ErrSCIMUserAlreadyExists) Error() string { + return fmt.Sprintf("user with email %s already exists in this organization", e.Email) +} + +func NewSCIMUserAlreadyExistsError(email string) *ErrSCIMUserAlreadyExists { + return &ErrSCIMUserAlreadyExists{Email: email} +} diff --git a/pkg/iam/scim/schema.go b/pkg/iam/scim/schema.go new file mode 100644 index 000000000..7aedf03a2 --- /dev/null +++ b/pkg/iam/scim/schema.go @@ -0,0 +1,72 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package scim + +import ( + "github.com/elimity-com/scim/optional" + "github.com/elimity-com/scim/schema" +) + +// UserSchema returns the SCIM User schema definition +func UserSchema() schema.Schema { + return schema.Schema{ + ID: schema.UserSchema, + Name: optional.NewString("User"), + Description: optional.NewString("User Account"), + Attributes: []schema.CoreAttribute{ + schema.SimpleCoreAttribute(schema.SimpleStringParams(schema.StringParams{ + Name: "userName", + Required: true, + Uniqueness: schema.AttributeUniquenessServer(), + })), + schema.SimpleCoreAttribute(schema.SimpleStringParams(schema.StringParams{ + Name: "displayName", + })), + schema.ComplexCoreAttribute(schema.ComplexParams{ + Name: "name", + SubAttributes: []schema.SimpleParams{ + schema.SimpleStringParams(schema.StringParams{ + Name: "formatted", + }), + schema.SimpleStringParams(schema.StringParams{ + Name: "familyName", + }), + schema.SimpleStringParams(schema.StringParams{ + Name: "givenName", + }), + }, + }), + schema.SimpleCoreAttribute(schema.SimpleBooleanParams(schema.BooleanParams{ + Name: "active", + })), + schema.ComplexCoreAttribute(schema.ComplexParams{ + Name: "emails", + MultiValued: true, + SubAttributes: []schema.SimpleParams{ + schema.SimpleStringParams(schema.StringParams{ + Name: "value", + }), + schema.SimpleStringParams(schema.StringParams{ + Name: "type", + }), + schema.SimpleBooleanParams(schema.BooleanParams{ + Name: "primary", + }), + }, + }), + }, + } +} + diff --git a/pkg/iam/scim/service.go b/pkg/iam/scim/service.go new file mode 100644 index 000000000..4fec14548 --- /dev/null +++ b/pkg/iam/scim/service.go @@ -0,0 +1,739 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package scim + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "net" + "strings" + "time" + + "github.com/elimity-com/scim" + "github.com/elimity-com/scim/optional" + "go.gearno.de/kit/log" + "go.gearno.de/kit/pg" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/mail" + "go.probo.inc/probo/pkg/page" +) + +type ( + Service struct { + pg *pg.Client + logger *log.Logger + } +) + +func NewService( + pg *pg.Client, + logger *log.Logger, +) *Service { + return &Service{ + pg: pg, + logger: logger, + } +} + +// HashToken creates a FIPS 140 compliant SHA-256 hash of the token +func HashToken(token string) []byte { + hash := sha256.Sum256([]byte(token)) + return hash[:] +} + +// GenerateToken creates a cryptographically secure random token +func GenerateToken() (string, error) { + bytes := make([]byte, 32) + if _, err := rand.Read(bytes); err != nil { + return "", fmt.Errorf("cannot generate random token: %w", err) + } + return hex.EncodeToString(bytes), nil +} + +// ValidateToken validates a bearer token and returns the SCIM configuration +func (s *Service) ValidateToken(ctx context.Context, token string) (*coredata.SCIMConfiguration, error) { + hashedToken := HashToken(token) + config := &coredata.SCIMConfiguration{} + + err := s.pg.WithConn(ctx, func(conn pg.Conn) error { + err := config.LoadByHashedToken(ctx, conn, hashedToken) + if err != nil { + if err == coredata.ErrResourceNotFound { + return NewSCIMInvalidTokenError() + } + return fmt.Errorf("cannot load SCIM configuration: %w", err) + } + return nil + }) + + if err != nil { + return nil, err + } + + return config, nil +} + +// CreateUser creates a new user via SCIM provisioning +func (s *Service) CreateUser( + ctx context.Context, + config *coredata.SCIMConfiguration, + attributes scim.ResourceAttributes, + ipAddress net.IP, +) (*coredata.Membership, error) { + user := ParseUserFromAttributes(attributes) + email := user.GetPrimaryEmail() + if email == "" { + return nil, NewSCIMInvalidRequestError("userName or email is required") + } + + emailAddr, err := mail.ParseAddr(email) + if err != nil { + return nil, NewSCIMInvalidRequestError("invalid email format") + } + + fullName := user.GetFullName() + now := time.Now() + + var membership *coredata.Membership + + scope := coredata.NewScopeFromObjectID(config.OrganizationID) + + err = s.pg.WithTx(ctx, func(tx pg.Conn) error { + // Check if identity exists + identity := &coredata.Identity{} + err := identity.LoadByEmail(ctx, tx, emailAddr) + + if err == coredata.ErrResourceNotFound { + // Create new identity + identity = &coredata.Identity{ + ID: gid.New(gid.NilTenant, coredata.IdentityEntityType), + EmailAddress: emailAddr, + FullName: fullName, + HashedPassword: nil, + EmailAddressVerified: false, + CreatedAt: now, + UpdatedAt: now, + } + + err = identity.Insert(ctx, tx) + if err != nil { + return fmt.Errorf("cannot insert identity: %w", err) + } + } else if err != nil { + return fmt.Errorf("cannot load identity: %w", err) + } + + // Check if membership exists + membership = &coredata.Membership{} + err = membership.LoadByIdentityAndOrg(ctx, tx, scope, identity.ID, config.OrganizationID) + + if err == coredata.ErrResourceNotFound { + // Create new membership + membership = &coredata.Membership{ + ID: gid.New(config.OrganizationID.TenantID(), coredata.MembershipEntityType), + IdentityID: identity.ID, + OrganizationID: config.OrganizationID, + Role: coredata.MembershipRoleViewer, + Source: coredata.MembershipSourceSCIM, + CreatedAt: now, + UpdatedAt: now, + } + + err = membership.Insert(ctx, tx, scope) + if err != nil { + return fmt.Errorf("cannot insert membership: %w", err) + } + + // Create membership profile + membershipProfile := &coredata.MembershipProfile{ + ID: gid.New(membership.ID.TenantID(), coredata.MembershipProfileEntityType), + MembershipID: membership.ID, + FullName: fullName, + CreatedAt: now, + UpdatedAt: now, + } + + err = membershipProfile.Insert(ctx, tx) + if err != nil { + return fmt.Errorf("cannot insert membership profile: %w", err) + } + } else if err != nil { + return fmt.Errorf("cannot load membership: %w", err) + } else { + // Update existing membership source to SCIM + membership.Source = coredata.MembershipSourceSCIM + membership.UpdatedAt = now + + err = membership.Update(ctx, tx, scope) + if err != nil { + return fmt.Errorf("cannot update membership: %w", err) + } + } + + // Log SCIM event + event := s.createEvent(config, "POST", "/Users", membership.ID, ipAddress, 201, nil) + err = event.Insert(ctx, tx, scope) + if err != nil { + s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err)) + } + + return nil + }) + + if err != nil { + return nil, err + } + + return membership, nil +} + +// GetUser gets a user by membership ID +func (s *Service) GetUser( + ctx context.Context, + config *coredata.SCIMConfiguration, + membershipID gid.GID, + ipAddress net.IP, +) (*coredata.Membership, *coredata.Identity, *coredata.MembershipProfile, error) { + scope := coredata.NewScopeFromObjectID(config.OrganizationID) + + var membership *coredata.Membership + var identity *coredata.Identity + var profile *coredata.MembershipProfile + + err := s.pg.WithConn( + ctx, + func(conn pg.Conn) error { + membership = &coredata.Membership{} + err := membership.LoadByID(ctx, conn, scope, membershipID) + if err != nil { + if err == coredata.ErrResourceNotFound { + return NewSCIMUserNotFoundError(membershipID) + } + return fmt.Errorf("cannot load membership: %w", err) + } + + // Verify membership belongs to this organization + if membership.OrganizationID != config.OrganizationID { + return NewSCIMUserNotFoundError(membershipID) + } + + identity = &coredata.Identity{} + err = identity.LoadByID(ctx, conn, membership.IdentityID) + if err != nil { + return fmt.Errorf("cannot load identity: %w", err) + } + + profile = &coredata.MembershipProfile{} + err = profile.LoadByMembershipID(ctx, conn, scope, membershipID) + if err != nil && err != coredata.ErrResourceNotFound { + return fmt.Errorf("cannot load membership profile: %w", err) + } + + return nil + }, + ) + + if err != nil { + return nil, nil, nil, err + } + + return membership, identity, profile, nil +} + +// ListUsers lists all users in an organization, with optional filter support +func (s *Service) ListUsers( + ctx context.Context, + config *coredata.SCIMConfiguration, + filter *UserFilter, + startIndex int, + count int, + ipAddress net.IP, +) ([]*coredata.Membership, int, error) { + scope := coredata.NewScopeFromObjectID(config.OrganizationID) + + var memberships coredata.Memberships + var totalCount int + + err := s.pg.WithConn(ctx, func(conn pg.Conn) error { + // If we have a userName filter, query by email directly + if filter != nil && filter.UserName != nil { + emailAddr, err := mail.ParseAddr(*filter.UserName) + if err != nil { + // Invalid email format - return empty result + totalCount = 0 + return nil + } + + membership := &coredata.Membership{} + err = membership.LoadByEmailAndOrganization(ctx, conn, scope, emailAddr, config.OrganizationID) + if err == coredata.ErrResourceNotFound { + totalCount = 0 + return nil + } + if err != nil { + return fmt.Errorf("cannot load membership by email: %w", err) + } + + memberships = append(memberships, membership) + totalCount = 1 + return nil + } + + // No filter - return all memberships with pagination + var err error + totalCount, err = memberships.CountByOrganizationID(ctx, conn, scope, config.OrganizationID) + if err != nil { + return fmt.Errorf("cannot count memberships: %w", err) + } + + orderBy := page.OrderBy[coredata.MembershipOrderField]{ + Field: coredata.MembershipOrderFieldCreatedAt, + Direction: page.OrderDirectionDesc, + } + cursor := page.NewCursor(count, nil, page.Head, orderBy) + + err = memberships.LoadByOrganizationID(ctx, conn, scope, config.OrganizationID, cursor) + if err != nil { + return fmt.Errorf("cannot load memberships: %w", err) + } + + return nil + }) + + if err != nil { + return nil, 0, err + } + + return memberships, totalCount, nil +} + +// ReplaceUser replaces a user via SCIM PUT +// Returns the membership, a boolean indicating if user was deactivated, and an error +func (s *Service) ReplaceUser( + ctx context.Context, + config *coredata.SCIMConfiguration, + membershipID gid.GID, + attributes scim.ResourceAttributes, + ipAddress net.IP, +) (*coredata.Membership, bool, error) { + user := ParseUserFromReplaceAttributes(attributes) + return s.updateUser(ctx, config, membershipID, user, "PUT", ipAddress) +} + +// PatchUser patches a user via SCIM PATCH +// Returns the membership, a boolean indicating if user was deactivated, and an error +func (s *Service) PatchUser( + ctx context.Context, + config *coredata.SCIMConfiguration, + membershipID gid.GID, + operations []scim.PatchOperation, + ipAddress net.IP, +) (*coredata.Membership, bool, error) { + user := ParseUserFromPatchOperations(operations) + return s.updateUser(ctx, config, membershipID, user, "PATCH", ipAddress) +} + +func (s *Service) updateUser( + ctx context.Context, + config *coredata.SCIMConfiguration, + membershipID gid.GID, + user *User, + method string, + ipAddress net.IP, +) (*coredata.Membership, bool, error) { + scope := coredata.NewScopeFromObjectID(config.OrganizationID) + now := time.Now() + + var membership *coredata.Membership + var deactivated bool + + err := s.pg.WithTx(ctx, func(tx pg.Conn) error { + membership = &coredata.Membership{} + err := membership.LoadByID(ctx, tx, scope, membershipID) + if err != nil { + if err == coredata.ErrResourceNotFound { + return NewSCIMUserNotFoundError(membershipID) + } + return fmt.Errorf("cannot load membership: %w", err) + } + + // Verify membership belongs to this organization + if membership.OrganizationID != config.OrganizationID { + return NewSCIMUserNotFoundError(membershipID) + } + + // Handle deactivation - Okta sends PATCH with active=false to deprovision users + if user.Active != nil && !*user.Active { + err = membership.Delete(ctx, tx, scope, membershipID) + if err != nil { + return fmt.Errorf("cannot delete membership: %w", err) + } + + deactivated = true + + // Log SCIM event for deactivation + event := s.createEvent(config, method, fmt.Sprintf("/Users/%s", membershipID), membershipID, ipAddress, 200, nil) + err = event.Insert(ctx, tx, scope) + if err != nil { + s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err)) + } + + return nil + } + + // Update membership source to SCIM if not already + if membership.Source != coredata.MembershipSourceSCIM { + membership.Source = coredata.MembershipSourceSCIM + membership.UpdatedAt = now + + err = membership.Update(ctx, tx, scope) + if err != nil { + return fmt.Errorf("cannot update membership: %w", err) + } + } + + // Update membership profile + profile := &coredata.MembershipProfile{} + err = profile.LoadByMembershipID(ctx, tx, scope, membershipID) + if err == nil { + fullName := user.GetFullName() + if fullName != "" { + profile.FullName = fullName + profile.UpdatedAt = now + + err = profile.Update(ctx, tx, scope) + if err != nil { + return fmt.Errorf("cannot update membership profile: %w", err) + } + } + } + + // Log SCIM event + event := s.createEvent(config, method, fmt.Sprintf("/Users/%s", membershipID), membership.ID, ipAddress, 200, nil) + err = event.Insert(ctx, tx, scope) + if err != nil { + s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err)) + } + + return nil + }) + + if err != nil { + return nil, false, err + } + + return membership, deactivated, nil +} + +// DeleteUser removes a user's membership from the organization +func (s *Service) DeleteUser( + ctx context.Context, + config *coredata.SCIMConfiguration, + membershipID gid.GID, + ipAddress net.IP, +) error { + scope := coredata.NewScopeFromObjectID(config.OrganizationID) + + return s.pg.WithTx(ctx, func(tx pg.Conn) error { + membership := &coredata.Membership{} + err := membership.LoadByID(ctx, tx, scope, membershipID) + if err != nil { + if err == coredata.ErrResourceNotFound { + return NewSCIMUserNotFoundError(membershipID) + } + return fmt.Errorf("cannot load membership: %w", err) + } + + // Verify membership belongs to this organization + if membership.OrganizationID != config.OrganizationID { + return NewSCIMUserNotFoundError(membershipID) + } + + err = membership.Delete(ctx, tx, scope, membershipID) + if err != nil { + return fmt.Errorf("cannot delete membership: %w", err) + } + + // Log SCIM event + event := s.createEvent(config, "DELETE", fmt.Sprintf("/Users/%s", membershipID), membershipID, ipAddress, 204, nil) + err = event.Insert(ctx, tx, scope) + if err != nil { + s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err)) + } + + return nil + }) +} + +// LogEvent logs a SCIM event +func (s *Service) LogEvent( + ctx context.Context, + config *coredata.SCIMConfiguration, + method string, + path string, + membershipID *gid.GID, + ipAddress net.IP, + statusCode int, + errorMessage *string, +) { + scope := coredata.NewScopeFromObjectID(config.OrganizationID) + + var mID gid.GID + if membershipID != nil { + mID = *membershipID + } + + event := s.createEvent(config, method, path, mID, ipAddress, statusCode, errorMessage) + + err := s.pg.WithConn(ctx, func(conn pg.Conn) error { + return event.Insert(ctx, conn, scope) + }) + + if err != nil { + s.logger.ErrorCtx(ctx, "cannot log SCIM event", log.Error(err)) + } +} + +func (s *Service) createEvent( + config *coredata.SCIMConfiguration, + method string, + path string, + membershipID gid.GID, + ipAddress net.IP, + statusCode int, + errorMessage *string, +) *coredata.SCIMEvent { + event := &coredata.SCIMEvent{ + ID: gid.New(config.OrganizationID.TenantID(), coredata.SCIMEventEntityType), + OrganizationID: config.OrganizationID, + SCIMConfigurationID: config.ID, + Method: method, + Path: path, + StatusCode: statusCode, + ErrorMessage: errorMessage, + IPAddress: ipAddress, + CreatedAt: time.Now(), + } + + if membershipID != gid.Nil { + event.MembershipID = &membershipID + } + + return event +} + +// ParseUserFromAttributes extracts a User from SCIM resource attributes +func ParseUserFromAttributes(attributes scim.ResourceAttributes) *User { + userName, _ := attributes["userName"].(string) + displayName, _ := attributes["displayName"].(string) + + var givenName, familyName string + if name, ok := attributes["name"].(map[string]interface{}); ok { + givenName, _ = name["givenName"].(string) + familyName, _ = name["familyName"].(string) + } + + // Get email from emails array or use userName + email := userName + if emails, ok := attributes["emails"].([]interface{}); ok && len(emails) > 0 { + for _, e := range emails { + if emailMap, ok := e.(map[string]interface{}); ok { + if primary, _ := emailMap["primary"].(bool); primary { + if value, ok := emailMap["value"].(string); ok { + email = value + break + } + } + } + } + // If no primary email found, use the first one + if email == userName { + if emailMap, ok := emails[0].(map[string]interface{}); ok { + if value, ok := emailMap["value"].(string); ok { + email = value + } + } + } + } + + // Build full name + fullName := displayName + if fullName == "" { + fullName = strings.TrimSpace(givenName + " " + familyName) + } + if fullName == "" { + fullName = userName + } + + user := &User{ + UserName: userName, + DisplayName: displayName, + Name: &Name{ + GivenName: givenName, + FamilyName: familyName, + Formatted: fullName, + }, + Emails: []Email{ + { + Value: email, + Type: "work", + Primary: true, + }, + }, + } + + return user +} + +// ParseUserFromReplaceAttributes extracts a User from SCIM replace attributes +func ParseUserFromReplaceAttributes(attributes scim.ResourceAttributes) *User { + displayName, _ := attributes["displayName"].(string) + + var givenName, familyName string + if name, ok := attributes["name"].(map[string]interface{}); ok { + givenName, _ = name["givenName"].(string) + familyName, _ = name["familyName"].(string) + } + + fullName := displayName + if fullName == "" { + fullName = strings.TrimSpace(givenName + " " + familyName) + } + + active := true + if a, ok := attributes["active"].(bool); ok { + active = a + } + + return &User{ + DisplayName: fullName, + Active: &active, + Name: &Name{ + GivenName: givenName, + FamilyName: familyName, + Formatted: fullName, + }, + } +} + +// ParseUserFromPatchOperations extracts a User from SCIM patch operations +func ParseUserFromPatchOperations(operations []scim.PatchOperation) *User { + user := &User{} + for _, op := range operations { + if strings.EqualFold(op.Op, "replace") || strings.EqualFold(op.Op, "add") { + path := "" + if op.Path != nil { + path = op.Path.String() + } + switch strings.ToLower(path) { + case "active": + if active, ok := op.Value.(bool); ok { + user.Active = &active + } + case "displayname": + if name, ok := op.Value.(string); ok { + user.DisplayName = name + } + case "name.givenname": + if user.Name == nil { + user.Name = &Name{} + } + if name, ok := op.Value.(string); ok { + user.Name.GivenName = name + } + case "name.familyname": + if user.Name == nil { + user.Name = &Name{} + } + if name, ok := op.Value.(string); ok { + user.Name.FamilyName = name + } + } + } + } + return user +} + +// MembershipToResource converts a Membership to a SCIM resource +func MembershipToResource(m *coredata.Membership) scim.Resource { + return MembershipToResourceWithActive(m, true) +} + +// MembershipToResourceWithActive converts a Membership to a SCIM resource with a custom active state +func MembershipToResourceWithActive(m *coredata.Membership, active bool) scim.Resource { + created := m.CreatedAt + modified := m.UpdatedAt + return scim.Resource{ + ID: m.ID.String(), + ExternalID: optional.NewString(m.ID.String()), + Attributes: scim.ResourceAttributes{ + "userName": m.EmailAddress.String(), + "displayName": m.FullName, + "active": active, + "name": map[string]interface{}{ + "formatted": m.FullName, + }, + "emails": []map[string]interface{}{ + { + "value": m.EmailAddress.String(), + "type": "work", + "primary": true, + }, + }, + }, + Meta: scim.Meta{ + Created: &created, + LastModified: &modified, + }, + } +} + +// MembershipToResourceFull converts a Membership with full identity and profile to a SCIM resource +func MembershipToResourceFull(m *coredata.Membership, identity *coredata.Identity, profile *coredata.MembershipProfile) scim.Resource { + fullName := identity.FullName + if profile != nil && profile.FullName != "" { + fullName = profile.FullName + } + + created := m.CreatedAt + modified := m.UpdatedAt + return scim.Resource{ + ID: m.ID.String(), + ExternalID: optional.NewString(m.ID.String()), + Attributes: scim.ResourceAttributes{ + "userName": identity.EmailAddress.String(), + "displayName": fullName, + "active": true, + "name": map[string]interface{}{ + "formatted": fullName, + }, + "emails": []map[string]interface{}{ + { + "value": identity.EmailAddress.String(), + "type": "work", + "primary": true, + }, + }, + }, + Meta: scim.Meta{ + Created: &created, + LastModified: &modified, + }, + } +} diff --git a/pkg/iam/scim/types.go b/pkg/iam/scim/types.go new file mode 100644 index 000000000..a8d4bcbee --- /dev/null +++ b/pkg/iam/scim/types.go @@ -0,0 +1,333 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package scim + +import ( + "fmt" + "strings" + + scimfilter "github.com/scim2/filter-parser/v2" +) + +// UserFilter represents filter criteria for listing SCIM users +type UserFilter struct { + // UserName filters by userName (email) with exact match + UserName *string +} + +// ErrUnsupportedFilter is returned when a SCIM filter uses unsupported operators or attributes +type ErrUnsupportedFilter struct { + Reason string +} + +func (e *ErrUnsupportedFilter) Error() string { + return fmt.Sprintf("unsupported filter: %s", e.Reason) +} + +// ParseUserFilter converts a SCIM filter AST expression to a UserFilter. +// Returns (nil, nil) if no filter is provided. +// Returns an error if the filter uses unsupported operators or attributes. +func ParseUserFilter(expr scimfilter.Expression) (*UserFilter, error) { + if expr == nil { + return nil, nil + } + + filter := &UserFilter{} + + switch e := expr.(type) { + case *scimfilter.AttributeExpression: + if err := parseAttributeExpression(e, filter); err != nil { + return nil, err + } + case *scimfilter.LogicalExpression: + if e.Operator != scimfilter.AND { + return nil, &ErrUnsupportedFilter{Reason: fmt.Sprintf("logical operator '%s' is not supported, only 'and' is supported", e.Operator)} + } + if err := parseLogicalExpression(e, filter); err != nil { + return nil, err + } + case *scimfilter.NotExpression: + return nil, &ErrUnsupportedFilter{Reason: "NOT expressions are not supported"} + case *scimfilter.ValuePath: + return nil, &ErrUnsupportedFilter{Reason: "value path expressions are not supported"} + default: + return nil, &ErrUnsupportedFilter{Reason: "unknown filter expression type"} + } + + return filter, nil +} + +func parseAttributeExpression(e *scimfilter.AttributeExpression, filter *UserFilter) error { + // Only support "eq" operator + if e.Operator != scimfilter.EQ { + return &ErrUnsupportedFilter{Reason: fmt.Sprintf("operator '%s' is not supported, only 'eq' is supported", e.Operator)} + } + + // Get the attribute name (lowercase for comparison) + attrName := strings.ToLower(e.AttributePath.AttributeName) + + // Extract the string value + value, ok := e.CompareValue.(string) + if !ok { + return &ErrUnsupportedFilter{Reason: "filter value must be a string"} + } + + switch attrName { + case "username": + filter.UserName = &value + default: + return &ErrUnsupportedFilter{Reason: fmt.Sprintf("attribute '%s' is not supported for filtering, only 'userName' is supported", e.AttributePath.AttributeName)} + } + + return nil +} + +func parseLogicalExpression(e *scimfilter.LogicalExpression, filter *UserFilter) error { + // Process left expression + if left, ok := e.Left.(*scimfilter.AttributeExpression); ok { + if err := parseAttributeExpression(left, filter); err != nil { + return err + } + } else { + return &ErrUnsupportedFilter{Reason: "nested logical expressions are not supported"} + } + + // Process right expression + if right, ok := e.Right.(*scimfilter.AttributeExpression); ok { + if err := parseAttributeExpression(right, filter); err != nil { + return err + } + } else { + return &ErrUnsupportedFilter{Reason: "nested logical expressions are not supported"} + } + + return nil +} + +// SCIM 2.0 User Resource +// https://datatracker.ietf.org/doc/html/rfc7643#section-4.1 +type User struct { + Schemas []string `json:"schemas"` + ID string `json:"id,omitempty"` + ExternalID string `json:"externalId,omitempty"` + UserName string `json:"userName"` + Name *Name `json:"name,omitempty"` + DisplayName string `json:"displayName,omitempty"` + Emails []Email `json:"emails,omitempty"` + Active *bool `json:"active,omitempty"` + Meta *Meta `json:"meta,omitempty"` +} + +type Name struct { + Formatted string `json:"formatted,omitempty"` + FamilyName string `json:"familyName,omitempty"` + GivenName string `json:"givenName,omitempty"` + MiddleName string `json:"middleName,omitempty"` + HonorificPrefix string `json:"honorificPrefix,omitempty"` + HonorificSuffix string `json:"honorificSuffix,omitempty"` +} + +type Email struct { + Value string `json:"value"` + Type string `json:"type,omitempty"` + Primary bool `json:"primary,omitempty"` +} + +type Meta struct { + ResourceType string `json:"resourceType,omitempty"` + Created string `json:"created,omitempty"` + LastModified string `json:"lastModified,omitempty"` + Location string `json:"location,omitempty"` + Version string `json:"version,omitempty"` +} + +// SCIM 2.0 List Response +// https://datatracker.ietf.org/doc/html/rfc7644#section-3.4.2 +type ListResponse struct { + Schemas []string `json:"schemas"` + TotalResults int `json:"totalResults"` + StartIndex int `json:"startIndex,omitempty"` + ItemsPerPage int `json:"itemsPerPage,omitempty"` + Resources []User `json:"Resources"` +} + +// SCIM 2.0 Error Response +// https://datatracker.ietf.org/doc/html/rfc7644#section-3.12 +type ErrorResponse struct { + Schemas []string `json:"schemas"` + Detail string `json:"detail,omitempty"` + Status string `json:"status"` + ScimType string `json:"scimType,omitempty"` +} + +// SCIM 2.0 Patch Operation +// https://datatracker.ietf.org/doc/html/rfc7644#section-3.5.2 +type PatchOp struct { + Schemas []string `json:"schemas"` + Operations []Operation `json:"Operations"` +} + +type Operation struct { + Op string `json:"op"` + Path string `json:"path,omitempty"` + Value interface{} `json:"value,omitempty"` +} + +// SCIM 2.0 Service Provider Config +// https://datatracker.ietf.org/doc/html/rfc7643#section-5 +type ServiceProviderConfig struct { + Schemas []string `json:"schemas"` + DocumentationUri string `json:"documentationUri,omitempty"` + Patch Supported `json:"patch"` + Bulk BulkSupported `json:"bulk"` + Filter FilterSupported `json:"filter"` + ChangePassword Supported `json:"changePassword"` + Sort Supported `json:"sort"` + Etag Supported `json:"etag"` + AuthenticationSchemes []AuthScheme `json:"authenticationSchemes"` + Meta *Meta `json:"meta,omitempty"` +} + +type Supported struct { + Supported bool `json:"supported"` +} + +type BulkSupported struct { + Supported bool `json:"supported"` + MaxOperations int `json:"maxOperations"` + MaxPayloadSize int `json:"maxPayloadSize"` +} + +type FilterSupported struct { + Supported bool `json:"supported"` + MaxResults int `json:"maxResults"` +} + +type AuthScheme struct { + Type string `json:"type"` + Name string `json:"name"` + Description string `json:"description"` + SpecUri string `json:"specUri,omitempty"` + DocumentationUri string `json:"documentationUri,omitempty"` + Primary bool `json:"primary,omitempty"` +} + +// SCIM 2.0 Schemas response +type SchemasResponse struct { + Schemas []string `json:"schemas"` + TotalResults int `json:"totalResults"` + Resources []Schema `json:"Resources"` +} + +type Schema struct { + ID string `json:"id"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Attributes []SchemaAttribute `json:"attributes,omitempty"` + Meta *Meta `json:"meta,omitempty"` +} + +type SchemaAttribute struct { + Name string `json:"name"` + Type string `json:"type"` + MultiValued bool `json:"multiValued"` + Description string `json:"description,omitempty"` + Required bool `json:"required"` + CaseExact bool `json:"caseExact,omitempty"` + Mutability string `json:"mutability,omitempty"` + Returned string `json:"returned,omitempty"` + Uniqueness string `json:"uniqueness,omitempty"` + SubAttributes []SchemaAttribute `json:"subAttributes,omitempty"` +} + +// SCIM Schema URIs +const ( + SchemaURIUser = "urn:ietf:params:scim:schemas:core:2.0:User" + SchemaURIListResponse = "urn:ietf:params:scim:api:messages:2.0:ListResponse" + SchemaURIError = "urn:ietf:params:scim:api:messages:2.0:Error" + SchemaURIPatchOp = "urn:ietf:params:scim:api:messages:2.0:PatchOp" + SchemaURIServiceProviderConfig = "urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig" + SchemaURISchema = "urn:ietf:params:scim:schemas:core:2.0:Schema" +) + +func NewUser() *User { + return &User{ + Schemas: []string{SchemaURIUser}, + } +} + +func NewListResponse(users []User, totalResults int) *ListResponse { + return &ListResponse{ + Schemas: []string{SchemaURIListResponse}, + TotalResults: totalResults, + StartIndex: 1, + ItemsPerPage: len(users), + Resources: users, + } +} + +func NewErrorResponse(status int, detail string, scimType string) *ErrorResponse { + return &ErrorResponse{ + Schemas: []string{SchemaURIError}, + Detail: detail, + Status: fmt.Sprintf("%d", status), + ScimType: scimType, + } +} + +func (u *User) GetPrimaryEmail() string { + for _, email := range u.Emails { + if email.Primary { + return email.Value + } + } + if len(u.Emails) > 0 { + return u.Emails[0].Value + } + return u.UserName +} + +func (u *User) GetFullName() string { + if u.DisplayName != "" { + return u.DisplayName + } + if u.Name != nil { + if u.Name.Formatted != "" { + return u.Name.Formatted + } + parts := []string{} + if u.Name.GivenName != "" { + parts = append(parts, u.Name.GivenName) + } + if u.Name.FamilyName != "" { + parts = append(parts, u.Name.FamilyName) + } + if len(parts) > 0 { + return join(parts, " ") + } + } + return u.UserName +} + +func join(parts []string, sep string) string { + if len(parts) == 0 { + return "" + } + result := parts[0] + for i := 1; i < len(parts); i++ { + result += sep + parts[i] + } + return result +} diff --git a/pkg/iam/service.go b/pkg/iam/service.go index 78cbdbede..8ae4e389d 100644 --- a/pkg/iam/service.go +++ b/pkg/iam/service.go @@ -16,6 +16,7 @@ import ( "go.probo.inc/probo/pkg/filemanager" "go.probo.inc/probo/pkg/gid" "go.probo.inc/probo/pkg/iam/saml" + "go.probo.inc/probo/pkg/iam/scim" "golang.org/x/sync/errgroup" ) @@ -41,6 +42,7 @@ type ( SessionService *SessionService AuthService *AuthService SAMLService *saml.Service + SCIMService *scim.Service APIKeyService *APIKeyService Authorizer *Authorizer @@ -119,6 +121,8 @@ func NewService( } svc.SAMLService = samlService + svc.SCIMService = scim.NewService(svc.pg, cfg.Logger.Named("scim")) + svc.samlDomainVerifier = NewSAMLDomainVerifier( pgClient, cfg.Logger, diff --git a/pkg/server/api/api.go b/pkg/server/api/api.go index 0a9818525..89ce7c830 100644 --- a/pkg/server/api/api.go +++ b/pkg/server/api/api.go @@ -179,11 +179,11 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { router.Use(cors.Handler(corsOpts)) - router.Mount("/console/v1", s.consoleHandler) - router.Mount("/connect/v1", s.connectHandler) - router.Mount("/trust/v1", s.compliancePageHandler) - router.Mount("/mcp/v1", s.mcpHandler) - router.Mount("/slack/v1", s.slackHandler) + router.Mount("/console/v1", http.StripPrefix("/console/v1", s.consoleHandler)) + router.Mount("/connect/v1", http.StripPrefix("/connect/v1", s.connectHandler)) + router.Mount("/trust/v1", http.StripPrefix("/trust/v1", s.compliancePageHandler)) + router.Mount("/mcp/v1", http.StripPrefix("/mcp/v1", s.mcpHandler)) + router.Mount("/slack/v1", http.StripPrefix("/slack/v1", s.slackHandler)) router.ServeHTTP(w, r) } diff --git a/pkg/server/api/connect/v1/resolver.go b/pkg/server/api/connect/v1/resolver.go index d0690e62c..30559fcd0 100644 --- a/pkg/server/api/connect/v1/resolver.go +++ b/pkg/server/api/connect/v1/resolver.go @@ -19,6 +19,7 @@ package connect_v1 import ( "context" "errors" + "net/http" "time" "github.com/99designs/gqlgen/graphql" @@ -63,6 +64,7 @@ func NewMux(logger *log.Logger, svc *iam.Service, cookieConfig securecookie.Conf apiKeyMiddleware := NewAPIKeyMiddleware(svc, tokenSecret) graphqlHandler := NewGraphQLHandler(svc, logger, baseURL, cookieConfig) samlHandler := NewSAMLHandler(svc, cookieConfig, baseURL, logger) + scimHandler := NewSCIMHandler(svc, logger.Named("scim")) router := r.With(sessionMiddleware, apiKeyMiddleware) @@ -71,6 +73,10 @@ func NewMux(logger *log.Logger, svc *iam.Service, cookieConfig securecookie.Conf router.Post("/saml/2.0/consume", samlHandler.ConsumeHandler) router.Get("/saml/2.0/{samlConfigID}", samlHandler.LoginHandler) + // SCIM 2.0 endpoints - these use their own bearer token authentication + scimServer := NewSCIMServer(scimHandler) + r.Mount("/scim/2.0", http.StripPrefix("/scim/2.0", scimHandler.BearerTokenMiddleware(scimServer))) + return r } diff --git a/pkg/server/api/connect/v1/scim_handler.go b/pkg/server/api/connect/v1/scim_handler.go new file mode 100644 index 000000000..75910881d --- /dev/null +++ b/pkg/server/api/connect/v1/scim_handler.go @@ -0,0 +1,301 @@ +// Copyright (c) 2025 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package connect_v1 + +import ( + "context" + "errors" + "net" + "net/http" + + "github.com/elimity-com/scim" + scimerrors "github.com/elimity-com/scim/errors" + "github.com/elimity-com/scim/optional" + "go.gearno.de/kit/httpserver" + "go.gearno.de/kit/log" + "go.probo.inc/probo/pkg/bearertoken" + "go.probo.inc/probo/pkg/coredata" + "go.probo.inc/probo/pkg/gid" + "go.probo.inc/probo/pkg/iam" + scimservice "go.probo.inc/probo/pkg/iam/scim" +) + +type SCIMHandler struct { + iam *iam.Service + logger *log.Logger +} + +func NewSCIMHandler(iam *iam.Service, logger *log.Logger) *SCIMHandler { + return &SCIMHandler{iam: iam, logger: logger} +} + +// Context key for SCIM configuration +type scimCtxKey struct{ name string } + +var scimConfigCtxKey = &scimCtxKey{name: "scim_config"} + +func scimConfigFromContext(ctx context.Context) *coredata.SCIMConfiguration { + config, _ := ctx.Value(scimConfigCtxKey).(*coredata.SCIMConfiguration) + return config +} + +// NewSCIMServer creates a new SCIM server using elimity-com/scim +func NewSCIMServer(h *SCIMHandler) http.Handler { + resourceTypes := []scim.ResourceType{ + { + ID: optional.NewString("User"), + Name: "User", + Endpoint: "/Users", + Description: optional.NewString("User Account"), + Schema: scimservice.UserSchema(), + Handler: &scimResourceHandler{handler: h}, + }, + } + + serverConfig := scim.ServiceProviderConfig{ + SupportFiltering: true, + SupportPatch: true, + AuthenticationSchemes: []scim.AuthenticationScheme{ + { + Type: scim.AuthenticationTypeOauthBearerToken, + Name: "OAuth Bearer Token", + Description: "Authentication using OAuth Bearer Token", + }, + }, + } + + server, err := scim.NewServer( + &scim.ServerArgs{ + ServiceProviderConfig: &serverConfig, + ResourceTypes: resourceTypes, + }, + ) + if err != nil { + panic(err) + } + + return server +} + +// BearerTokenMiddleware validates the bearer token and sets the SCIM configuration in context +func (h *SCIMHandler) BearerTokenMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader == "" { + httpserver.RenderError(w, http.StatusUnauthorized, errors.New("authorization header required")) + return + } + + token, err := bearertoken.Parse(authHeader) + if err != nil { + httpserver.RenderError(w, http.StatusUnauthorized, errors.New("invalid authorization header")) + return + } + + config, err := h.iam.SCIMService.ValidateToken(r.Context(), token) + if err != nil { + var invalidToken *scimservice.ErrSCIMInvalidToken + if errors.As(err, &invalidToken) { + httpserver.RenderError(w, http.StatusUnauthorized, errors.New("invalid token")) + return + } + + h.logger.ErrorCtx(r.Context(), "SCIM token validation error", log.Error(err)) + httpserver.RenderError(w, http.StatusInternalServerError, errors.New("internal server error")) + return + } + + ctx := context.WithValue(r.Context(), scimConfigCtxKey, config) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// scimResourceHandler implements the elimity-com/scim ResourceHandler interface +type scimResourceHandler struct { + handler *SCIMHandler +} + +func (h *scimResourceHandler) Create(r *http.Request, attributes scim.ResourceAttributes) (scim.Resource, error) { + ctx := r.Context() + config := scimConfigFromContext(ctx) + + membership, err := h.handler.iam.SCIMService.CreateUser(ctx, config, attributes, getIPAddress(r)) + if err != nil { + var invalidReq *scimservice.ErrSCIMInvalidRequest + var alreadyExists *scimservice.ErrSCIMUserAlreadyExists + + if errors.As(err, &invalidReq) { + return scim.Resource{}, scimerrors.ScimErrorBadRequest(invalidReq.Detail) + } + + if errors.As(err, &alreadyExists) { + return scim.Resource{}, scimerrors.ScimErrorUniqueness + } + + h.handler.logger.ErrorCtx(ctx, "cannot create user", log.Error(err)) + return scim.Resource{}, scimerrors.ScimErrorInternal + } + + return scimservice.MembershipToResource(membership), nil +} + +func (h *scimResourceHandler) Get(r *http.Request, id string) (scim.Resource, error) { + ctx := r.Context() + config := scimConfigFromContext(ctx) + + membershipID, err := gid.ParseGID(id) + if err != nil { + return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id) + } + + membership, identity, profile, err := h.handler.iam.SCIMService.GetUser(ctx, config, membershipID, getIPAddress(r)) + if err != nil { + var notFound *scimservice.ErrSCIMUserNotFound + if errors.As(err, ¬Found) { + return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id) + } + + h.handler.logger.ErrorCtx(ctx, "cannot get user", log.Error(err)) + return scim.Resource{}, scimerrors.ScimErrorInternal + } + + return scimservice.MembershipToResourceFull(membership, identity, profile), nil +} + +func (h *scimResourceHandler) GetAll(r *http.Request, params scim.ListRequestParams) (scim.Page, error) { + ctx := r.Context() + config := scimConfigFromContext(ctx) + + if err := params.FilterValidator.Validate(); err != nil { + return scim.Page{}, scimerrors.ScimErrorBadRequest(err.Error()) + } + + // Parse SCIM filter AST into our filter type + filter, err := scimservice.ParseUserFilter(params.FilterValidator.GetFilter()) + if err != nil { + var unsupportedFilter *scimservice.ErrUnsupportedFilter + if errors.As(err, &unsupportedFilter) { + return scim.Page{}, scimerrors.ScimErrorBadRequest(err.Error()) + } + h.handler.logger.ErrorCtx(ctx, "cannot parse filter", log.Error(err)) + return scim.Page{}, scimerrors.ScimErrorInternal + } + + memberships, totalCount, err := h.handler.iam.SCIMService.ListUsers(ctx, config, filter, params.StartIndex, params.Count, getIPAddress(r)) + if err != nil { + h.handler.logger.ErrorCtx(ctx, "cannot list users", log.Error(err)) + return scim.Page{}, scimerrors.ScimErrorInternal + } + + resources := make([]scim.Resource, 0, len(memberships)) + for _, m := range memberships { + resources = append(resources, scimservice.MembershipToResource(m)) + } + + return scim.Page{ + TotalResults: totalCount, + Resources: resources, + }, nil +} + +func (h *scimResourceHandler) Replace(r *http.Request, id string, attributes scim.ResourceAttributes) (scim.Resource, error) { + ctx := r.Context() + config := scimConfigFromContext(ctx) + + membershipID, err := gid.ParseGID(id) + if err != nil { + return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id) + } + + membership, deactivated, err := h.handler.iam.SCIMService.ReplaceUser(ctx, config, membershipID, attributes, getIPAddress(r)) + if err != nil { + var notFound *scimservice.ErrSCIMUserNotFound + if errors.As(err, ¬Found) { + return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id) + } + + h.handler.logger.ErrorCtx(ctx, "cannot update user", log.Error(err)) + return scim.Resource{}, scimerrors.ScimErrorInternal + } + + if deactivated { + return scimservice.MembershipToResourceWithActive(membership, false), nil + } + + return scimservice.MembershipToResource(membership), nil +} + +func (h *scimResourceHandler) Patch(r *http.Request, id string, operations []scim.PatchOperation) (scim.Resource, error) { + ctx := r.Context() + config := scimConfigFromContext(ctx) + + membershipID, err := gid.ParseGID(id) + if err != nil { + return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id) + } + + membership, deactivated, err := h.handler.iam.SCIMService.PatchUser(ctx, config, membershipID, operations, getIPAddress(r)) + if err != nil { + var notFound *scimservice.ErrSCIMUserNotFound + if errors.As(err, ¬Found) { + return scim.Resource{}, scimerrors.ScimErrorResourceNotFound(id) + } + + h.handler.logger.ErrorCtx(ctx, "cannot patch user", log.Error(err)) + return scim.Resource{}, scimerrors.ScimErrorInternal + } + + if deactivated { + return scimservice.MembershipToResourceWithActive(membership, false), nil + } + + return scimservice.MembershipToResource(membership), nil +} + +func (h *scimResourceHandler) Delete(r *http.Request, id string) error { + ctx := r.Context() + config := scimConfigFromContext(ctx) + + membershipID, err := gid.ParseGID(id) + if err != nil { + return scimerrors.ScimErrorResourceNotFound(id) + } + + err = h.handler.iam.SCIMService.DeleteUser(ctx, config, membershipID, getIPAddress(r)) + if err != nil { + var notFound *scimservice.ErrSCIMUserNotFound + if errors.As(err, ¬Found) { + return scimerrors.ScimErrorResourceNotFound(id) + } + + h.handler.logger.ErrorCtx(ctx, "cannot delete user", log.Error(err)) + return scimerrors.ScimErrorInternal + } + + return nil +} + +func getIPAddress(r *http.Request) net.IP { + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + + if ip := net.ParseIP(host); ip != nil { + return ip + } + + return net.IPv4(127, 0, 0, 1) +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 82dea29a4..7abb1a7e4 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -112,7 +112,7 @@ func NewServer(cfg Config) (*Server, error) { } func (s *Server) setupRoutes() { - s.router.Mount("/api", s.apiServer) + s.router.Mount("/api", http.StripPrefix("/api", s.apiServer)) s.router.Route("/trust/{slugOrId}", func(r chi.Router) { r.Use(s.loadTrustCenterBySlugOrID)