Move source and state from membership to profile

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-02-13 21:21:11 +04:00
parent ec3d8fad40
commit 56ec0ab3c3
33 changed files with 1066 additions and 1220 deletions

View File

@@ -24,8 +24,8 @@ import (
"go.probo.inc/probo/pkg/mail"
)
func ParseUserFilter(expr scimfilter.Expression) (*coredata.MembershipFilter, error) {
filter := coredata.NewMembershipFilter()
func ParseUserFilter(expr scimfilter.Expression) (*coredata.MembershipProfileFilter, error) {
filter := coredata.NewMembershipProfileFilter(nil)
if expr == nil {
return filter, nil

View File

@@ -19,6 +19,7 @@ import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net"
"strings"
@@ -138,103 +139,120 @@ func (s *Service) CreateUser(
}
now := time.Now()
membershipState := coredata.MembershipStateActive
profileState := coredata.ProfileStateActive
if !active {
membershipState = coredata.MembershipStateInactive
profileState = coredata.ProfileStateInactive
}
var membership *coredata.Membership
var profile *coredata.MembershipProfile
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 := identity.LoadByEmail(ctx, tx, emailAddr); err != nil {
if errors.Is(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,
}
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 {
return fmt.Errorf("cannot load identity: %w", err)
}
}
err = identity.Insert(ctx, tx)
if err != nil {
return fmt.Errorf("cannot insert identity: %w", err)
// Check if profile exists
profile = &coredata.MembershipProfile{}
if err := profile.LoadByIdentityIDAndOrganizationID(
ctx,
tx,
coredata.NewScopeFromObjectID(config.OrganizationID),
identity.ID,
config.OrganizationID,
); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
profile = &coredata.MembershipProfile{
ID: gid.New(config.OrganizationID.TenantID(), coredata.MembershipProfileEntityType),
IdentityID: identity.ID,
OrganizationID: config.OrganizationID,
Source: coredata.ProfileSourceSCIM,
State: profileState,
FullName: fullName,
CreatedAt: now,
UpdatedAt: now,
}
err = profile.Insert(ctx, tx)
if err != nil {
return fmt.Errorf("cannot insert profile: %w", err)
}
} else {
return fmt.Errorf("cannot load profile: %w", err)
}
} else {
profile.Source = coredata.ProfileSourceSCIM
profile.State = profileState
profile.UpdatedAt = now
if err := profile.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update profile: %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 := membership.LoadByIdentityAndOrg(
ctx,
tx,
scope,
identity.ID,
config.OrganizationID,
); err != nil {
if errors.Is(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.MembershipRoleEmployee,
CreatedAt: now,
UpdatedAt: now,
}
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.MembershipRoleEmployee,
Source: coredata.MembershipSourceSCIM,
State: membershipState,
CreatedAt: now,
UpdatedAt: now,
}
err = membership.Insert(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot insert membership: %w", err)
}
err = membership.Insert(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot insert membership: %w", err)
}
// Expire all pending invitations for email in organization
invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
err := invitations.ExpireByEmailAndOrganization(
ctx,
tx,
coredata.NewScopeFromObjectID(config.OrganizationID),
emailAddr,
config.OrganizationID,
onlyPending,
)
// Create membership profile
membershipProfile := &coredata.MembershipProfile{
ID: gid.New(membership.ID.TenantID(), coredata.MembershipProfileEntityType),
IdentityID: identity.ID,
OrganizationID: config.OrganizationID,
FullName: fullName,
CreatedAt: now,
UpdatedAt: now,
}
err = membershipProfile.Insert(ctx, tx)
if err != nil {
return fmt.Errorf("cannot insert membership profile: %w", err)
}
// Expire all pending invitations for email in organization
invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
err := invitations.ExpireByEmailAndOrganization(
ctx,
tx,
coredata.NewScopeFromObjectID(config.OrganizationID),
emailAddr,
config.OrganizationID,
onlyPending,
)
if err != nil {
return fmt.Errorf("cannot expire pending invitations by email")
}
} else if err != nil {
return fmt.Errorf("cannot load membership: %w", err)
} else {
// Update existing membership - follow what SCIM tells us
membership.Source = coredata.MembershipSourceSCIM
membership.State = membershipState
membership.UpdatedAt = now
err = membership.Update(ctx, tx, scope)
if err != nil {
return fmt.Errorf("cannot update membership: %w", err)
if err != nil {
return fmt.Errorf("cannot expire pending invitations by email")
}
} else {
return fmt.Errorf("cannot load membership: %w", err)
}
}
@@ -245,32 +263,45 @@ func (s *Service) CreateUser(
return scim.Resource{}, err
}
return membershipToResource(membership), nil
return userToResource(profile), nil
}
func (s *Service) GetUser(
ctx context.Context,
config *coredata.SCIMConfiguration,
membershipID gid.GID,
profileID gid.GID,
) (scim.Resource, error) {
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
var membership *coredata.Membership
var (
profile *coredata.MembershipProfile
membership *coredata.Membership
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
membership = &coredata.Membership{}
err := membership.LoadByID(ctx, conn, scope, membershipID)
if err != nil {
profile = &coredata.MembershipProfile{}
if err := profile.LoadByID(ctx, conn, scope, profileID); err != nil {
if err == coredata.ErrResourceNotFound {
return scimerrors.ScimErrorResourceNotFound(membershipID.String())
return scimerrors.ScimErrorResourceNotFound(profileID.String())
}
return fmt.Errorf("cannot load membership: %w", err)
}
if membership.OrganizationID != config.OrganizationID {
return scimerrors.ScimErrorResourceNotFound(membershipID.String())
if profile.OrganizationID != config.OrganizationID {
return scimerrors.ScimErrorResourceNotFound(profileID.String())
}
membership = &coredata.Membership{}
if err := membership.LoadByIdentityAndOrg(
ctx,
conn,
scope,
profile.IdentityID,
profile.OrganizationID,
); err != nil {
return fmt.Errorf("cannot load membership: %w", err)
}
return nil
@@ -281,7 +312,7 @@ func (s *Service) GetUser(
return scim.Resource{}, err
}
return membershipToResource(membership), nil
return userToResource(profile), nil
}
func (s *Service) ListUsers(
@@ -301,31 +332,31 @@ func (s *Service) ListUsers(
// when they don't exist in the identity provider.
// 2. When a manual user exists in the identity provider but not in the
// SCIM list, CreateUser is called which enrolls them into SCIM management.
filter.WithSource(coredata.MembershipSourceSCIM)
filter.WithSource(coredata.ProfileSourceSCIM)
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
var memberships coredata.Memberships
var profiles coredata.MembershipProfiles
var totalCount int
err = s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var err error
totalCount, err = memberships.CountByOrganizationID(ctx, conn, scope, config.OrganizationID, filter)
totalCount, err = profiles.CountByOrganizationID(ctx, conn, scope, config.OrganizationID, filter)
if err != nil {
return fmt.Errorf("cannot count memberships: %w", err)
return fmt.Errorf("cannot count profiles: %w", err)
}
orderBy := page.OrderBy[coredata.MembershipOrderField]{
Field: coredata.MembershipOrderFieldCreatedAt,
orderBy := page.OrderBy[coredata.MembershipProfileOrderField]{
Field: coredata.MembershipProfileOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
cursor := page.NewCursor(count, nil, page.Head, orderBy)
err = memberships.LoadByOrganizationID(ctx, conn, scope, config.OrganizationID, cursor, filter)
err = profiles.LoadByOrganizationID(ctx, conn, scope, config.OrganizationID, cursor, filter)
if err != nil {
return fmt.Errorf("cannot load memberships: %w", err)
return fmt.Errorf("cannot load profiles: %w", err)
}
return nil
@@ -336,9 +367,9 @@ func (s *Service) ListUsers(
return nil, 0, err
}
resources := make([]scim.Resource, 0, len(memberships))
for _, m := range memberships {
resources = append(resources, membershipToResource(m))
resources := make([]scim.Resource, 0, len(profiles))
for _, p := range profiles {
resources = append(resources, userToResource(p))
}
return resources, totalCount, nil
@@ -347,61 +378,95 @@ func (s *Service) ListUsers(
func (s *Service) ReplaceUser(
ctx context.Context,
config *coredata.SCIMConfiguration,
membershipID gid.GID,
profileID gid.GID,
attributes scim.ResourceAttributes,
) (scim.Resource, error) {
fullName, active := ParseUserFromReplaceAttributes(attributes)
membership, err := s.updateUser(ctx, config, membershipID, fullName, active)
profile, err := s.updateUser(ctx, config, profileID, fullName, active)
if err != nil {
return scim.Resource{}, err
}
return membershipToResource(membership), nil
return userToResource(profile), nil
}
func (s *Service) PatchUser(
ctx context.Context,
config *coredata.SCIMConfiguration,
membershipID gid.GID,
profileID gid.GID,
operations []scim.PatchOperation,
) (scim.Resource, error) {
fullName, active := ParseUserFromPatchOperations(operations)
membership, err := s.updateUser(ctx, config, membershipID, fullName, active)
profile, err := s.updateUser(ctx, config, profileID, fullName, active)
if err != nil {
return scim.Resource{}, err
}
return membershipToResource(membership), nil
return userToResource(profile), nil
}
func (s *Service) updateUser(
ctx context.Context,
config *coredata.SCIMConfiguration,
membershipID gid.GID,
profileID gid.GID,
fullName string,
active *bool,
) (*coredata.Membership, error) {
) (*coredata.MembershipProfile, error) {
scope := coredata.NewScopeFromObjectID(config.OrganizationID)
now := time.Now()
var membership *coredata.Membership
var (
membership *coredata.Membership
profile *coredata.MembershipProfile
)
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
membership = &coredata.Membership{}
if err := membership.LoadByID(ctx, tx, scope, membershipID); err != nil {
if err == coredata.ErrResourceNotFound {
return scimerrors.ScimErrorResourceNotFound(membershipID.String())
profile = &coredata.MembershipProfile{}
if err := profile.LoadByID(ctx, tx, scope, profileID); err != nil {
if errors.Is(err, coredata.ErrResourceNotFound) {
return scimerrors.ScimErrorResourceNotFound(profileID.String())
}
return fmt.Errorf("cannot load profile: %w", err)
}
if profile.OrganizationID != config.OrganizationID {
return scimerrors.ScimErrorResourceNotFound(profileID.String())
}
membership = &coredata.Membership{}
if err := membership.LoadByIdentityAndOrg(ctx, tx, scope, profile.IdentityID, profile.OrganizationID); err != nil {
return fmt.Errorf("cannot load membership: %w", err)
}
if membership.OrganizationID != config.OrganizationID {
return scimerrors.ScimErrorResourceNotFound(membershipID.String())
shouldReactivate := active != nil && *active && profile.State == coredata.ProfileStateInactive
shouldDeactivate := active != nil && !*active && profile.State == coredata.ProfileStateActive
if fullName != "" {
profile.FullName = fullName
profile.UpdatedAt = now
}
needsUpdate := false
if shouldReactivate {
profile.State = coredata.ProfileStateActive
profile.UpdatedAt = now
} else if shouldDeactivate {
profile.State = coredata.ProfileStateInactive
profile.UpdatedAt = now
}
if profile.Source != coredata.ProfileSourceSCIM {
profile.Source = coredata.ProfileSourceSCIM
profile.UpdatedAt = now
}
if err := profile.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update membership profile: %w", err)
}
needsUpdate := shouldReactivate || shouldDeactivate
if active != nil {
identity := &coredata.Identity{}
@@ -409,11 +474,8 @@ func (s *Service) updateUser(
return fmt.Errorf("cannot load identity: %w", err)
}
if *active && membership.State == coredata.MembershipStateInactive {
membership.State = coredata.MembershipStateActive
if shouldReactivate {
membership.Role = coredata.MembershipRoleEmployee
needsUpdate = true
// Expire all pending invitations for email in organization
invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
@@ -427,10 +489,7 @@ func (s *Service) updateUser(
); err != nil {
return fmt.Errorf("cannot expire pending invitations by email: %w", err)
}
} else if !*active && membership.State == coredata.MembershipStateActive {
membership.State = coredata.MembershipStateInactive
needsUpdate = true
} else if shouldDeactivate {
// Expire all pending invitations for email in organization
invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
@@ -447,11 +506,6 @@ func (s *Service) updateUser(
}
}
if membership.Source != coredata.MembershipSourceSCIM {
membership.Source = coredata.MembershipSourceSCIM
needsUpdate = true
}
if needsUpdate {
membership.UpdatedAt = now
if err := membership.Update(ctx, tx, scope); err != nil {
@@ -459,18 +513,6 @@ func (s *Service) updateUser(
}
}
profile := &coredata.MembershipProfile{}
if err := profile.LoadByIdentityIDAndOrganizationID(ctx, tx, scope, membership.IdentityID, membership.OrganizationID); err == nil {
if fullName != "" {
profile.FullName = fullName
profile.UpdatedAt = now
if err := profile.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("cannot update membership profile: %w", err)
}
}
}
return nil
},
)
@@ -479,7 +521,7 @@ func (s *Service) updateUser(
return nil, err
}
return membership, nil
return profile, nil
}
func (s *Service) DeleteUser(
@@ -538,19 +580,14 @@ func (s *Service) LogEvent(
config *coredata.SCIMConfiguration,
method string,
path string,
membershipID *gid.GID,
userName string,
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)
event := s.createEvent(config, method, path, userName, ipAddress, statusCode, errorMessage)
err := s.pg.WithConn(
ctx,
@@ -572,7 +609,7 @@ func (s *Service) createEvent(
config *coredata.SCIMConfiguration,
method string,
path string,
membershipID gid.GID,
userName string,
ipAddress net.IP,
statusCode int,
errorMessage *string,
@@ -586,13 +623,10 @@ func (s *Service) createEvent(
StatusCode: statusCode,
ErrorMessage: errorMessage,
IPAddress: ipAddress,
UserName: userName,
CreatedAt: time.Now(),
}
if membershipID != gid.Nil {
event.MembershipID = &membershipID
}
return event
}
@@ -730,28 +764,28 @@ func ParseUserFromPatchOperations(operations []scim.PatchOperation) (fullName st
return fullName, active
}
func membershipToResource(m *coredata.Membership) scim.Resource {
func userToResource(p *coredata.MembershipProfile) scim.Resource {
return scim.Resource{
ID: m.ID.String(),
ExternalID: optional.NewString(m.ID.String()),
ID: p.ID.String(),
ExternalID: optional.NewString(p.ID.String()),
Attributes: scim.ResourceAttributes{
"userName": m.EmailAddress.String(),
"displayName": m.FullName,
"active": m.State == coredata.MembershipStateActive,
"userName": p.EmailAddress.String(),
"displayName": p.FullName,
"active": p.State == coredata.ProfileStateActive,
"name": map[string]any{
"formatted": m.FullName,
"formatted": p.FullName,
},
"emails": []map[string]any{
{
"value": m.EmailAddress.String(),
"value": p.EmailAddress.String(),
"type": "work",
"primary": true,
},
},
},
Meta: scim.Meta{
Created: &m.CreatedAt,
LastModified: &m.UpdatedAt,
Created: &p.CreatedAt,
LastModified: &p.UpdatedAt,
},
}
}