Accept/Expire invitations on SAML & SCIM operations

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-01-08 20:01:41 +01:00
committed by Bryan Frimin
parent cfcbda4519
commit b961069e1d
5 changed files with 168 additions and 102 deletions

View File

@@ -164,6 +164,72 @@ WHERE
return nil return nil
} }
func (i *Invitations) AcceptByEmailAndOrganization(
ctx context.Context,
conn pg.Conn,
scope Scoper,
email mail.Addr,
organizationID gid.GID,
filter *InvitationFilter,
) error {
q := `
UPDATE iam_invitations SET accepted_at = NOW()
WHERE
email = @email
AND organization_id = @organization_id
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{
"email": email,
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot accept invitations: %w", err)
}
return nil
}
func (i *Invitations) ExpireByEmailAndOrganization(
ctx context.Context,
conn pg.Conn,
scope Scoper,
email mail.Addr,
organizationID gid.GID,
filter *InvitationFilter,
) error {
q := `
UPDATE iam_invitations SET expires_at = NOW()
WHERE
email = @email
AND organization_id = @organization_id
AND %s
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{
"email": email,
"organization_id": organizationID,
}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
if _, err := conn.Exec(ctx, q, args); err != nil {
return fmt.Errorf("cannot expire invitations: %w", err)
}
return nil
}
// AuthorizationAttributes loads the minimal authorization attributes for policy condition evaluation. // AuthorizationAttributes loads the minimal authorization attributes for policy condition evaluation.
// It is intentionally lightweight and does not populate the Invitation struct. // It is intentionally lightweight and does not populate the Invitation struct.
func (i *Invitation) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) { func (i *Invitation) AuthorizationAttributes(ctx context.Context, conn pg.Conn) (map[string]string, error) {
@@ -230,7 +296,7 @@ DELETE FROM
iam_invitations iam_invitations
WHERE WHERE
%s %s
AND id = @invitation_id AND id = @invitation_id
` `
query = fmt.Sprintf(query, scope.SQLFragment()) query = fmt.Sprintf(query, scope.SQLFragment())
@@ -369,11 +435,11 @@ func (i *Invitations) CountByOrganizationID(
) (int, error) { ) (int, error) {
q := ` q := `
SELECT SELECT
COUNT(*) COUNT(*)
FROM FROM
iam_invitations iam_invitations
WHERE WHERE
organization_id = @organization_id AND %s AND %s organization_id = @organization_id AND %s AND %s
` `
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment()) q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
@@ -405,12 +471,12 @@ func (i *Invitations) CountByEmail(
) (int, error) { ) (int, error) {
q := ` q := `
SELECT SELECT
COUNT(*) COUNT(*)
FROM FROM
iam_invitations iam_invitations
WHERE WHERE
email = @email email = @email
AND %s AND %s
` `
q = fmt.Sprintf(q, filter.SQLFragment()) q = fmt.Sprintf(q, filter.SQLFragment())

View File

@@ -339,78 +339,6 @@ LEFT JOIN
return nil 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.state,
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,
mbr.state,
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 { func (m *Membership) Update(ctx context.Context, conn pg.Conn, scope Scoper) error {
query := ` query := `
UPDATE UPDATE

View File

@@ -210,8 +210,7 @@ func (s *AccountService) AcceptInvitation(
func(tx pg.Conn) error { func(tx pg.Conn) error {
identity := coredata.Identity{} identity := coredata.Identity{}
err := identity.LoadByID(ctx, tx, identityID) if err := identity.LoadByID(ctx, tx, identityID); err != nil {
if err != nil {
if err == coredata.ErrResourceNotFound { if err == coredata.ErrResourceNotFound {
return NewIdentityNotFoundError(identityID) return NewIdentityNotFoundError(identityID)
} }
@@ -219,8 +218,7 @@ func (s *AccountService) AcceptInvitation(
return fmt.Errorf("cannot load identity: %w", err) return fmt.Errorf("cannot load identity: %w", err)
} }
err = invitation.LoadByID(ctx, tx, coredata.NewNoScope(), invitationID) if err := invitation.LoadByID(ctx, tx, coredata.NewNoScope(), invitationID); err != nil {
if err != nil {
if err == coredata.ErrResourceNotFound { if err == coredata.ErrResourceNotFound {
return NewInvitationNotFoundError(invitationID) return NewInvitationNotFoundError(invitationID)
} }
@@ -244,8 +242,13 @@ func (s *AccountService) AcceptInvitation(
scope := coredata.NewScope(invitation.OrganizationID.TenantID()) scope := coredata.NewScope(invitation.OrganizationID.TenantID())
existingMembership := &coredata.Membership{} existingMembership := &coredata.Membership{}
err = existingMembership.LoadByIdentityAndOrg(ctx, tx, scope, identityID, invitation.OrganizationID) if err := existingMembership.LoadByIdentityAndOrg(
if err != nil && err != coredata.ErrResourceNotFound { ctx,
tx,
scope,
identityID,
invitation.OrganizationID,
); err != nil && err != coredata.ErrResourceNotFound {
return fmt.Errorf("cannot load existing membership: %w", err) return fmt.Errorf("cannot load existing membership: %w", err)
} }
@@ -254,8 +257,7 @@ func (s *AccountService) AcceptInvitation(
existingMembership.Role = invitation.Role existingMembership.Role = invitation.Role
existingMembership.UpdatedAt = now existingMembership.UpdatedAt = now
err = existingMembership.Update(ctx, tx, scope) if err := existingMembership.Update(ctx, tx, scope); err != nil {
if err != nil {
return fmt.Errorf("cannot reactivate membership: %w", err) return fmt.Errorf("cannot reactivate membership: %w", err)
} }
@@ -272,8 +274,7 @@ func (s *AccountService) AcceptInvitation(
UpdatedAt: now, UpdatedAt: now,
} }
err = membership.Insert(ctx, tx, scope) if err := membership.Insert(ctx, tx, scope); err != nil {
if err != nil {
return fmt.Errorf("cannot create membership: %w", err) return fmt.Errorf("cannot create membership: %w", err)
} }
@@ -285,15 +286,13 @@ func (s *AccountService) AcceptInvitation(
UpdatedAt: now, UpdatedAt: now,
} }
err = profile.Insert(ctx, tx) if err := profile.Insert(ctx, tx); err != nil {
if err != nil {
return fmt.Errorf("cannot insert profile: %w", err) return fmt.Errorf("cannot insert profile: %w", err)
} }
} }
invitation.AcceptedAt = &now invitation.AcceptedAt = &now
err = invitation.Update(ctx, tx, scope) if err := invitation.Update(ctx, tx, scope); err != nil {
if err != nil {
if err == coredata.ErrResourceNotFound { if err == coredata.ErrResourceNotFound {
return NewInvitationNotFoundError(invitationID) return NewInvitationNotFoundError(invitationID)
} }
@@ -301,6 +300,20 @@ func (s *AccountService) AcceptInvitation(
return fmt.Errorf("cannot update invitation: %w", err) return fmt.Errorf("cannot update invitation: %w", err)
} }
// Accept other pending invitations for email in organization
invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
if err := invitations.AcceptByEmailAndOrganization(
ctx,
tx,
coredata.NewScopeFromObjectID(invitation.OrganizationID),
invitation.Email,
invitation.OrganizationID,
onlyPending,
); err != nil {
return fmt.Errorf("cannot accept pending invitations by email: %w", err)
}
return nil return nil
}, },
) )

View File

@@ -325,6 +325,20 @@ func (s *Service) HandleAssertion(
if err != nil { if err != nil {
return fmt.Errorf("cannot insert membership profile: %w", err) return fmt.Errorf("cannot insert membership profile: %w", err)
} }
// Accept all pending invitations for email in organization
invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
if err := invitations.AcceptByEmailAndOrganization(
ctx,
tx,
coredata.NewScopeFromObjectID(config.OrganizationID),
email,
config.OrganizationID,
onlyPending,
); err != nil {
return fmt.Errorf("cannot accept pending invitations by email: %w", err)
}
} }
if membership.Source != coredata.MembershipSourceSCIM { if membership.Source != coredata.MembershipSourceSCIM {

View File

@@ -172,6 +172,22 @@ func (s *Service) CreateUser(
if err != nil { if err != nil {
return fmt.Errorf("cannot insert membership profile: %w", err) return fmt.Errorf("cannot insert membership profile: %w", err)
} }
// Accept all pending invitations for email in organization
invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
err := invitations.AcceptByEmailAndOrganization(
ctx,
tx,
coredata.NewScopeFromObjectID(config.OrganizationID),
emailAddr,
config.OrganizationID,
onlyPending,
)
if err != nil {
return fmt.Errorf("cannot accept pending invitations by email")
}
} else if err != nil { } else if err != nil {
return fmt.Errorf("cannot load membership: %w", err) return fmt.Errorf("cannot load membership: %w", err)
} else { } else {
@@ -335,8 +351,7 @@ func (s *Service) updateUser(
ctx, ctx,
func(tx pg.Conn) error { func(tx pg.Conn) error {
membership = &coredata.Membership{} membership = &coredata.Membership{}
err := membership.LoadByID(ctx, tx, scope, membershipID) if err := membership.LoadByID(ctx, tx, scope, membershipID); err != nil {
if err != nil {
if err == coredata.ErrResourceNotFound { if err == coredata.ErrResourceNotFound {
return scimerrors.ScimErrorResourceNotFound(membershipID.String()) return scimerrors.ScimErrorResourceNotFound(membershipID.String())
} }
@@ -350,13 +365,46 @@ func (s *Service) updateUser(
needsUpdate := false needsUpdate := false
if active != nil { if active != nil {
identity := &coredata.Identity{}
if err := identity.LoadByID(ctx, tx, membership.IdentityID); err != nil {
return fmt.Errorf("cannot load identity: %w", err)
}
if *active && membership.State == coredata.MembershipStateInactive { if *active && membership.State == coredata.MembershipStateInactive {
membership.State = coredata.MembershipStateActive membership.State = coredata.MembershipStateActive
membership.Role = coredata.MembershipRoleEmployee membership.Role = coredata.MembershipRoleEmployee
needsUpdate = true needsUpdate = true
// Accept all pending invitations for email in organization
invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
if err := invitations.AcceptByEmailAndOrganization(
ctx,
tx,
coredata.NewScopeFromObjectID(config.OrganizationID),
identity.EmailAddress,
config.OrganizationID,
onlyPending,
); err != nil {
return fmt.Errorf("cannot accept pending invitations by email: %w", err)
}
} else if !*active && membership.State == coredata.MembershipStateActive { } else if !*active && membership.State == coredata.MembershipStateActive {
membership.State = coredata.MembershipStateInactive membership.State = coredata.MembershipStateInactive
needsUpdate = true needsUpdate = true
// Expire all pending invitations for email in organization
invitations := &coredata.Invitations{}
onlyPending := coredata.NewInvitationFilter([]coredata.InvitationStatus{coredata.InvitationStatusPending})
if err := invitations.ExpireByEmailAndOrganization(
ctx,
tx,
coredata.NewScopeFromObjectID(config.OrganizationID),
identity.EmailAddress,
config.OrganizationID,
onlyPending,
); err != nil {
return fmt.Errorf("cannot expire pending invitations: %w", err)
}
} }
} }
@@ -367,21 +415,18 @@ func (s *Service) updateUser(
if needsUpdate { if needsUpdate {
membership.UpdatedAt = now membership.UpdatedAt = now
err = membership.Update(ctx, tx, scope) if err := membership.Update(ctx, tx, scope); err != nil {
if err != nil {
return fmt.Errorf("cannot update membership: %w", err) return fmt.Errorf("cannot update membership: %w", err)
} }
} }
profile := &coredata.MembershipProfile{} profile := &coredata.MembershipProfile{}
err = profile.LoadByMembershipID(ctx, tx, scope, membershipID) if err := profile.LoadByMembershipID(ctx, tx, scope, membershipID); err == nil {
if err == nil {
if fullName != "" { if fullName != "" {
profile.FullName = fullName profile.FullName = fullName
profile.UpdatedAt = now profile.UpdatedAt = now
err = profile.Update(ctx, tx, scope) if err := profile.Update(ctx, tx, scope); err != nil {
if err != nil {
return fmt.Errorf("cannot update membership profile: %w", err) return fmt.Errorf("cannot update membership profile: %w", err)
} }
} }