Refactor invitation system

Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Sacha Al Himdani
2025-10-10 01:11:09 +02:00
parent 0f96b8518f
commit 165eb267f2
39 changed files with 2730 additions and 1194 deletions

View File

@@ -20,7 +20,8 @@ import (
_ "embed"
"errors"
"fmt"
"html/template"
"net/url"
"text/template"
"time"
"github.com/getprobo/probo/pkg/coredata"
@@ -41,6 +42,14 @@ type (
invitationTokenValidity time.Duration
}
TenantAuthzService struct {
pg *pg.Client
hostname string
tokenSecret string
invitationTokenValidity time.Duration
scope coredata.Scoper
}
Role string
)
@@ -78,6 +87,18 @@ func NewService(
}, nil
}
func (s *Service) WithTenant(tenantID gid.TenantID) *TenantAuthzService {
return &TenantAuthzService{
pg: s.pg,
hostname: s.hostname,
tokenSecret: s.tokenSecret,
invitationTokenValidity: s.invitationTokenValidity,
scope: coredata.NewScope(tenantID),
}
}
// This method is on Service (not TenantAuthzService) because it operates across tenants
// and doesn't require tenant-scoped access.
func (s *Service) GetAllUserOrganizations(
ctx context.Context,
userID gid.GID,
@@ -98,6 +119,8 @@ func (s *Service) GetAllUserOrganizations(
return organizations, err
}
// This method is on Service (not TenantAuthzService) because it operates across tenants
// and doesn't require tenant-scoped access.
func (s *Service) GetUserOrganizations(
ctx context.Context,
userID gid.GID,
@@ -115,7 +138,253 @@ func (s *Service) GetUserOrganizations(
return organizations, err
}
func (s *Service) GetAllOrganizationInvitations(
// This method is on Service (not TenantAuthzService) because the user accepting
// the invitation doesn't have tenant access yet.
func (s *Service) AcceptInvitation(
ctx context.Context,
token string,
userID gid.GID,
) error {
payload, err := statelesstoken.ValidateToken[coredata.InvitationData](
s.tokenSecret,
TokenTypeOrganizationInvitation,
token,
)
if err != nil {
return fmt.Errorf("invalid invitation token: %w", err)
}
invitationData := payload.Data
scope := coredata.NewScope(invitationData.InvitationID.TenantID())
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
invitation := &coredata.Invitation{}
if err := invitation.LoadByID(ctx, tx, scope, invitationData.InvitationID); err != nil {
var errInvitationNotFound *coredata.ErrInvitationNotFound
if errors.As(err, &errInvitationNotFound) {
return fmt.Errorf("invitation was deleted or no longer exists")
}
return fmt.Errorf("cannot load invitation: %w", err)
}
if invitation.AcceptedAt != nil {
return fmt.Errorf("invitation already accepted")
}
if time.Now().After(invitation.ExpiresAt) {
return fmt.Errorf("invitation expired")
}
now := time.Now()
membershipID := gid.New(scope.GetTenantID(), coredata.MembershipEntityType)
membership := &coredata.Membership{
ID: membershipID,
UserID: userID,
OrganizationID: invitation.OrganizationID,
Role: invitation.Role,
CreatedAt: now,
UpdatedAt: now,
}
if err := membership.Create(ctx, tx, scope); err != nil {
return fmt.Errorf("failed to add user to organization: %w", err)
}
invitation.AcceptedAt = &now
if err := invitation.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("failed to mark invitation as accepted: %w", err)
}
return nil
},
)
}
// This method is on Service (not TenantAuthzService) because the user accepting
// the invitation doesn't have tenant access yet.
func (s *Service) AcceptInvitationByID(
ctx context.Context,
invitationID gid.GID,
userID gid.GID,
) (*coredata.Invitation, error) {
var acceptedInvitation *coredata.Invitation
scope := coredata.NewScope(invitationID.TenantID())
err := s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
invitation := &coredata.Invitation{}
if err := invitation.LoadByID(ctx, tx, scope, invitationID); err != nil {
var errInvitationNotFound *coredata.ErrInvitationNotFound
if errors.As(err, &errInvitationNotFound) {
return fmt.Errorf("invitation was deleted or no longer exists")
}
return fmt.Errorf("cannot load invitation: %w", err)
}
if invitation.AcceptedAt != nil {
return fmt.Errorf("invitation already accepted")
}
if time.Now().After(invitation.ExpiresAt) {
return fmt.Errorf("invitation expired")
}
user := &coredata.User{}
if err := user.LoadByID(ctx, tx, userID); err != nil {
return fmt.Errorf("cannot load user: %w", err)
}
if invitation.Email != user.EmailAddress {
return fmt.Errorf("invitation email does not match user email")
}
now := time.Now()
membershipID := gid.New(scope.GetTenantID(), coredata.MembershipEntityType)
membership := &coredata.Membership{
ID: membershipID,
UserID: userID,
OrganizationID: invitation.OrganizationID,
Role: invitation.Role,
CreatedAt: now,
UpdatedAt: now,
}
if err := membership.Create(ctx, tx, scope); err != nil {
return fmt.Errorf("failed to add user to organization: %w", err)
}
invitation.AcceptedAt = &now
if err := invitation.Update(ctx, tx, scope); err != nil {
return fmt.Errorf("failed to mark invitation as accepted: %w", err)
}
acceptedInvitation = invitation
return nil
},
)
if err != nil {
return nil, err
}
return acceptedInvitation, nil
}
// This method is on Service (not TenantAuthzService) because the user viewing
// their invitations doesn't have tenant access yet, and it operates across multiple tenants.
func (s *Service) GetUserInvitations(
ctx context.Context,
email string,
cursor *page.Cursor[coredata.InvitationOrderField],
filter *coredata.InvitationFilter,
) (*page.Page[*coredata.Invitation, coredata.InvitationOrderField], error) {
var invitations coredata.Invitations
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := invitations.LoadByEmail(ctx, conn, email, cursor, filter); err != nil {
return fmt.Errorf("failed to load invitations: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(invitations, cursor), nil
}
// This method is on Service (not TenantAuthzService) because the user viewing
// their invitations doesn't have tenant access yet, and it operates across multiple tenants.
func (s *Service) CountUserInvitations(
ctx context.Context,
email string,
filter *coredata.InvitationFilter,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var invitations coredata.Invitations
var err error
count, err = invitations.CountByEmail(ctx, conn, email, filter)
return err
},
)
return count, err
}
// This method is on Service (not TenantAuthzService) because the user viewing
// the invitation organization doesn't have tenant access yet.
func (s *Service) GetOrganizationByInvitationID(
ctx context.Context,
invitationID gid.GID,
) (*coredata.Organization, error) {
scope := coredata.NewScope(invitationID.TenantID())
var organization coredata.Organization
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var invitation coredata.Invitation
if err := invitation.LoadByID(ctx, conn, scope, invitationID); err != nil {
return fmt.Errorf("failed to load invitation: %w", err)
}
if err := organization.LoadByID(ctx, conn, scope, invitation.OrganizationID); err != nil {
return fmt.Errorf("failed to load organization: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return &organization, nil
}
// This method is on Service (not TenantAuthzService) because the user added to the organization
// doesn't have tenant access yet
func (s *Service) AddUserToOrganization(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
role string,
) error {
now := time.Now()
tenantID := orgID.TenantID()
membershipID := gid.New(tenantID, coredata.MembershipEntityType)
scope := coredata.NewScope(tenantID)
membership := &coredata.Membership{
ID: membershipID,
UserID: userID,
OrganizationID: orgID,
Role: role,
CreatedAt: now,
UpdatedAt: now,
}
return s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.Create(ctx, conn, scope); err != nil {
return fmt.Errorf("failed to add user to organization: %w", err)
}
return nil
},
)
}
func (s *TenantAuthzService) GetInvitationsByOrganizationID(
ctx context.Context,
orgID gid.GID,
cursor *page.Cursor[coredata.InvitationOrderField],
@@ -125,7 +394,7 @@ func (s *Service) GetAllOrganizationInvitations(
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := invitations.LoadByOrganizationID(ctx, conn, orgID, cursor); err != nil {
if err := invitations.LoadByOrganizationID(ctx, conn, s.scope, orgID, cursor); err != nil {
return fmt.Errorf("failed to load organization invitations: %w", err)
}
@@ -139,7 +408,7 @@ func (s *Service) GetAllOrganizationInvitations(
return page.NewPage(invitations, cursor), nil
}
func (s *Service) CountOrganizationInvitations(
func (s *TenantAuthzService) CountOrganizationInvitations(
ctx context.Context,
orgID gid.GID,
) (int, error) {
@@ -149,7 +418,7 @@ func (s *Service) CountOrganizationInvitations(
func(conn pg.Conn) error {
var invitations coredata.Invitations
var err error
count, err = invitations.CountByOrganizationID(ctx, conn, orgID)
count, err = invitations.CountByOrganizationID(ctx, conn, s.scope, orgID)
return err
},
)
@@ -160,7 +429,27 @@ func (s *Service) CountOrganizationInvitations(
return count, nil
}
func (s *Service) DeleteInvitation(
func (s *TenantAuthzService) GetInvitationByID(
ctx context.Context,
invitationID gid.GID,
) (*coredata.Invitation, error) {
invitation := &coredata.Invitation{}
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := invitation.LoadByID(ctx, conn, s.scope, invitationID); err != nil {
return fmt.Errorf("failed to load invitation: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return invitation, nil
}
func (s *TenantAuthzService) DeleteInvitation(
ctx context.Context,
invitationID gid.GID,
) error {
@@ -168,11 +457,11 @@ func (s *Service) DeleteInvitation(
ctx,
func(conn pg.Conn) error {
invitation := &coredata.Invitation{}
if err := invitation.LoadByID(ctx, conn, invitationID); err != nil {
if err := invitation.LoadByID(ctx, conn, s.scope, invitationID); err != nil {
return fmt.Errorf("failed to load invitation: %w", err)
}
if err := invitation.Delete(ctx, conn); err != nil {
if err := invitation.Delete(ctx, conn, s.scope); err != nil {
return fmt.Errorf("failed to delete invitation: %w", err)
}
@@ -181,7 +470,7 @@ func (s *Service) DeleteInvitation(
)
}
func (s *Service) GetAllOrganizationMemberships(
func (s *TenantAuthzService) GetMembershipsByOrganizationID(
ctx context.Context,
orgID gid.GID,
cursor *page.Cursor[coredata.MembershipOrderField],
@@ -191,7 +480,7 @@ func (s *Service) GetAllOrganizationMemberships(
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := memberships.LoadByOrganizationID(ctx, conn, orgID, cursor); err != nil {
if err := memberships.LoadByOrganizationID(ctx, conn, s.scope, orgID, cursor); err != nil {
return fmt.Errorf("failed to load organization memberships: %w", err)
}
@@ -205,7 +494,7 @@ func (s *Service) GetAllOrganizationMemberships(
return page.NewPage(memberships, cursor), nil
}
func (s *Service) CountOrganizationMemberships(
func (s *TenantAuthzService) CountOrganizationMemberships(
ctx context.Context,
orgID gid.GID,
) (int, error) {
@@ -215,7 +504,7 @@ func (s *Service) CountOrganizationMemberships(
func(conn pg.Conn) error {
var memberships coredata.Memberships
var err error
count, err = memberships.CountByOrganizationID(ctx, conn, orgID)
count, err = memberships.CountByOrganizationID(ctx, conn, s.scope, orgID)
return err
},
)
@@ -226,7 +515,28 @@ func (s *Service) CountOrganizationMemberships(
return count, nil
}
func (s *Service) CanUserAccessOrganization(
func (s *TenantAuthzService) CountOrganizationUsers(
ctx context.Context,
orgID gid.GID,
) (int, error) {
var count int
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
var users coredata.Users
var err error
count, err = users.CountByOrganizationID(ctx, conn, s.scope, orgID)
return err
},
)
if err != nil {
return 0, fmt.Errorf("failed to count users: %w", err)
}
return count, nil
}
func (s *TenantAuthzService) CanUserAccessOrganization(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
@@ -238,7 +548,7 @@ func (s *Service) CanUserAccessOrganization(
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.LoadByUserAndOrg(ctx, conn, userID, orgID); err != nil {
if err := membership.LoadByUserAndOrg(ctx, conn, s.scope, userID, orgID); err != nil {
if _, ok := err.(coredata.ErrMembershipNotFound); ok {
return nil // Not an error, just no access
}
@@ -256,7 +566,7 @@ func (s *Service) CanUserAccessOrganization(
return haveAccess, nil
}
func (s *Service) GetUserRoleInOrganization(
func (s *TenantAuthzService) GetUserRoleInOrganization(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
@@ -266,7 +576,7 @@ func (s *Service) GetUserRoleInOrganization(
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.LoadByUserAndOrg(ctx, conn, userID, orgID); err != nil {
if err := membership.LoadByUserAndOrg(ctx, conn, s.scope, userID, orgID); err != nil {
return fmt.Errorf("failed to get user role: %w", err)
}
return nil
@@ -280,7 +590,7 @@ func (s *Service) GetUserRoleInOrganization(
return membership.Role, nil
}
func (s *Service) RemoveMemberFromOrganization(
func (s *TenantAuthzService) RemoveMemberFromOrganization(
ctx context.Context,
orgID gid.GID,
memberID gid.GID,
@@ -290,7 +600,7 @@ func (s *Service) RemoveMemberFromOrganization(
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
if err := membership.LoadByID(ctx, tx, memberID); err != nil {
if err := membership.LoadByID(ctx, tx, s.scope, memberID); err != nil {
return fmt.Errorf("failed to load membership: %w", err)
}
@@ -298,7 +608,7 @@ func (s *Service) RemoveMemberFromOrganization(
return fmt.Errorf("membership does not belong to organization")
}
if err := membership.Delete(ctx, tx); err != nil {
if err := membership.Delete(ctx, tx, s.scope); err != nil {
return fmt.Errorf("failed to delete membership: %w", err)
}
@@ -307,32 +617,7 @@ func (s *Service) RemoveMemberFromOrganization(
)
}
func (s *Service) AddUserToOrganization(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
role string,
) error {
membership := &coredata.Membership{
UserID: userID,
OrganizationID: orgID,
Role: role,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
return s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := membership.Create(ctx, conn); err != nil {
return fmt.Errorf("failed to add user to organization: %w", err)
}
return nil
},
)
}
func (s *Service) UpdateUserRole(
func (s *TenantAuthzService) UpdateUserRole(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
@@ -342,14 +627,14 @@ func (s *Service) UpdateUserRole(
ctx,
func(tx pg.Conn) error {
membership := &coredata.Membership{}
if err := membership.LoadByUserAndOrg(ctx, tx, userID, orgID); err != nil {
if err := membership.LoadByUserAndOrg(ctx, tx, s.scope, userID, orgID); err != nil {
return fmt.Errorf("failed to find membership: %w", err)
}
membership.Role = newRole
membership.UpdatedAt = time.Now()
if err := membership.Update(ctx, tx); err != nil {
if err := membership.Update(ctx, tx, s.scope); err != nil {
return fmt.Errorf("failed to update user role: %w", err)
}
@@ -358,7 +643,7 @@ func (s *Service) UpdateUserRole(
)
}
func (s *Service) InviteUserToOrganization(
func (s *TenantAuthzService) InviteUserToOrganization(
ctx context.Context,
organizationID gid.GID,
emailAddress string,
@@ -380,12 +665,11 @@ func (s *Service) InviteUserToOrganization(
}
organization := &coredata.Organization{}
scope := coredata.NewScope(organizationID.TenantID())
if err := organization.LoadByID(ctx, tx, scope, organizationID); err != nil {
if err := organization.LoadByID(ctx, tx, s.scope, organizationID); err != nil {
return fmt.Errorf("failed to load organization: %w", err)
}
invitationID := gid.New(organizationID.TenantID(), coredata.InvitationEntityType)
invitationID := gid.New(s.scope.GetTenantID(), coredata.InvitationEntityType)
now := time.Now()
invitation = &coredata.Invitation{
ID: invitationID,
@@ -397,19 +681,20 @@ func (s *Service) InviteUserToOrganization(
CreatedAt: now,
}
body := bytes.NewBuffer(nil)
var err error
if userExists {
membership := &coredata.Membership{
UserID: user.ID,
OrganizationID: organizationID,
Role: role,
CreatedAt: now,
UpdatedAt: now,
err = invitationEmailBodyTemplate.Execute(
body,
map[string]string{
"FullName": user.FullName,
"OrganizationName": organization.Name,
"InvitationURL": fmt.Sprintf("https://%s/", s.hostname),
},
)
if err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
if err := membership.Create(ctx, tx); err != nil {
return fmt.Errorf("failed to add user to organization: %w", err)
}
invitation.AcceptedAt = &now
} else {
invitationData := coredata.InvitationData{
InvitationID: invitationID,
@@ -429,32 +714,31 @@ func (s *Service) InviteUserToOrganization(
return fmt.Errorf("failed to generate invitation token: %w", err)
}
body := bytes.NewBuffer(nil)
err = invitationEmailBodyTemplate.Execute(
body,
map[string]string{
"FullName": fullName,
"OrganizationName": organization.Name,
"InvitationURL": fmt.Sprintf("https://%s/auth/confirm-invitation?token=%s", s.hostname, invitationToken),
"InvitationURL": fmt.Sprintf("https://%s/auth/signup-from-invitation?token=%s&fullName=%s", s.hostname, invitationToken, url.QueryEscape(fullName)),
},
)
if err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
email := coredata.NewEmail(
fullName,
emailAddress,
invitationEmailSubject,
body.String(),
)
if err := email.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
}
if err := invitation.Create(ctx, tx); err != nil {
email := coredata.NewEmail(
fullName,
emailAddress,
invitationEmailSubject,
body.String(),
)
if err := email.Insert(ctx, tx); err != nil {
return fmt.Errorf("cannot insert email: %w", err)
}
if err := invitation.Create(ctx, tx, s.scope); err != nil {
return fmt.Errorf("cannot create invitation: %w", err)
}
@@ -468,66 +752,8 @@ func (s *Service) InviteUserToOrganization(
return invitation, nil
}
func (s *Service) AcceptInvitation(
ctx context.Context,
token string,
userID gid.GID,
) error {
payload, err := statelesstoken.ValidateToken[coredata.InvitationData](
s.tokenSecret,
TokenTypeOrganizationInvitation,
token,
)
if err != nil {
return fmt.Errorf("invalid invitation token: %w", err)
}
invitationData := payload.Data
return s.pg.WithTx(
ctx,
func(tx pg.Conn) error {
invitation := &coredata.Invitation{}
if err := invitation.LoadByID(ctx, tx, invitationData.InvitationID); err != nil {
var errInvitationNotFound *coredata.ErrInvitationNotFound
if errors.As(err, &errInvitationNotFound) {
return fmt.Errorf("invitation was deleted or no longer exists")
}
return fmt.Errorf("cannot load invitation: %w", err)
}
if invitation.AcceptedAt != nil {
return fmt.Errorf("invitation already accepted")
}
if time.Now().After(invitation.ExpiresAt) {
return fmt.Errorf("invitation expired")
}
membership := &coredata.Membership{
UserID: userID,
OrganizationID: invitation.OrganizationID,
Role: invitation.Role,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := membership.Create(ctx, tx); err != nil {
return fmt.Errorf("failed to add user to organization: %w", err)
}
now := time.Now()
invitation.AcceptedAt = &now
if err := invitation.Update(ctx, tx); err != nil {
return fmt.Errorf("failed to mark invitation as accepted: %w", err)
}
return nil
},
)
}
// This is a placeholder for future permission system
func (s *Service) HasPermission(
func (s *TenantAuthzService) HasPermission(
ctx context.Context,
userID gid.GID,
orgID gid.GID,
@@ -538,22 +764,3 @@ func (s *Service) HasPermission(
// In the future, this will check specific permissions based on role
return s.CanUserAccessOrganization(ctx, userID, orgID)
}
func (s *Service) ListUserInvitations(
ctx context.Context,
email string,
) ([]*coredata.Invitation, error) {
var invitations coredata.Invitations
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := invitations.LoadByEmail(ctx, conn, email); err != nil {
return fmt.Errorf("failed to load invitations: %w", err)
}
return nil
},
)
return invitations, err
}