Support atomic organization role sets and owner-managed invitations
verify / verify (push) Successful in 4m17s

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
2026-09-05 00:10:08 -04:00
parent ed0cc8ceff
commit c0986168bc
19 changed files with 1052 additions and 69 deletions
+51 -14
View File
@@ -14,6 +14,7 @@ import (
"fmt"
"io"
"regexp"
"slices"
"strings"
"time"
)
@@ -77,11 +78,16 @@ type ApplicationService struct {
}
type Invitation struct {
ID string
Digest [32]byte
OrganizationID string
Email, InvitedByUserID string
DirectRole string
ID string
Digest [32]byte
OrganizationID string
Email, InvitedByUserID string
// DirectRole is the legacy single-role form. Use exactly one form.
DirectRole string
DirectRoles []string
// RequiredOwnerRole records the grantor authority to recheck at acceptance.
// Services set it from their trusted configuration, never a request payload.
RequiredOwnerRole string
TeamIDs []string
CreatedAt, ExpiresAt, UsedAt, RevokedAt time.Time
}
@@ -129,13 +135,17 @@ type Options struct {
Random io.Reader
Now func() time.Time
OwnerRole string
// OwnerManagedInvitations requires a current direct owner to create, revoke,
// and remain the grantor of an invitation until it is accepted.
OwnerManagedInvitations bool
}
type Service struct {
repository Repository
random io.Reader
now func() time.Time
ownerRole string
repository Repository
random io.Reader
now func() time.Time
ownerRole string
ownerManagedInvitations bool
}
func New(repository Repository, options Options) (*Service, error) {
@@ -148,10 +158,10 @@ func New(repository Repository, options Options) (*Service, error) {
if options.Now == nil {
options.Now = time.Now
}
if options.OwnerRole != "" && !safeNamePattern.MatchString(options.OwnerRole) {
if options.OwnerRole != "" && !safeNamePattern.MatchString(options.OwnerRole) || options.OwnerManagedInvitations && options.OwnerRole == "" {
return nil, errors.New("organizations: owner role is invalid")
}
return &Service{repository: repository, random: options.Random, now: options.Now, ownerRole: options.OwnerRole}, nil
return &Service{repository: repository, random: options.Random, now: options.Now, ownerRole: options.OwnerRole, ownerManagedInvitations: options.OwnerManagedInvitations}, nil
}
type CreateOrganization struct {
@@ -299,6 +309,8 @@ func (service *Service) Invite(ctx context.Context, organizationID, email, invit
type InviteWithAccess struct {
OrganizationID, Email, InvitedByUserID, DirectRole string
DirectRoles []string
RequestID string
TeamIDs []string
Lifetime time.Duration
}
@@ -307,9 +319,17 @@ func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAc
organizationID, email, invitedBy, lifetime := input.OrganizationID, input.Email, input.InvitedByUserID, input.Lifetime
email = strings.ToLower(strings.TrimSpace(email))
input.DirectRole = strings.TrimSpace(input.DirectRole)
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitedBy) || !bounded(email, 320) || !strings.Contains(email, "@") || lifetime < 5*time.Minute || lifetime > 30*24*time.Hour || input.DirectRole != "" && !safeNamePattern.MatchString(input.DirectRole) || !validIDs(input.TeamIDs, 16) {
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(invitedBy) || !bounded(email, 320) || !strings.Contains(email, "@") || lifetime < 5*time.Minute || lifetime > 30*24*time.Hour || input.DirectRole != "" && !safeNamePattern.MatchString(input.DirectRole) || !validIDs(input.TeamIDs, 16) || !boundedOptional(input.RequestID, 128) {
return "", Invitation{}, errors.New("organizations: invalid invitation")
}
roles, err := (Invitation{DirectRole: input.DirectRole, DirectRoles: input.DirectRoles}).RoleNames()
if err != nil {
return "", Invitation{}, err
}
roleRepository, roleSupport := service.repository.(RoleInvitationRepository)
if (len(input.DirectRoles) > 0 || service.ownerManagedInvitations || service.ownerRole != "" && slices.Contains(roles, service.ownerRole)) && !roleSupport {
return "", Invitation{}, ErrRoleInvitationUnsupported
}
id, err := token(service.random, 18)
if err != nil {
return "", Invitation{}, err
@@ -320,11 +340,22 @@ func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAc
}
now := service.now().UTC()
invitation := Invitation{ID: id, Digest: sha256.Sum256([]byte(raw)), OrganizationID: organizationID, Email: email, InvitedByUserID: invitedBy, DirectRole: input.DirectRole, TeamIDs: append([]string(nil), input.TeamIDs...), CreatedAt: now, ExpiresAt: now.Add(lifetime)}
audit, err := service.audit(invitedBy, organizationID, "invitation.create", "invitation", id, "Organization invitation created")
if len(input.DirectRoles) > 0 {
invitation.DirectRoles = roles
}
if service.ownerManagedInvitations || service.ownerRole != "" && slices.Contains(roles, service.ownerRole) {
invitation.RequiredOwnerRole = service.ownerRole
}
audit, err := service.auditWithRequest(invitedBy, organizationID, "invitation.create", "invitation", id, input.RequestID, "Organization invitation created")
if err != nil {
return "", Invitation{}, err
}
if err = service.repository.CreateInvitation(ctx, invitation, service.ownerRole, audit); err != nil {
if roleSupport {
err = roleRepository.CreateInvitationWithRoles(ctx, invitation, service.ownerRole, audit)
} else {
err = service.repository.CreateInvitation(ctx, invitation, service.ownerRole, audit)
}
if err != nil {
return "", Invitation{}, err
}
return raw, invitation, nil
@@ -344,6 +375,12 @@ func (service *Service) AcceptInvitation(ctx context.Context, rawToken, userID s
if err != nil {
return err
}
if repository, ok := service.repository.(RoleInvitationRepository); ok {
return repository.AcceptInvitationWithRoles(ctx, digest, userID, service.ownerRole, now, audit)
}
if len(invitation.DirectRoles) > 0 || invitation.RequiredOwnerRole != "" || service.ownerManagedInvitations {
return ErrRoleInvitationUnsupported
}
return service.repository.AcceptInvitation(ctx, digest, userID, now, audit)
}