Support atomic organization role sets and owner-managed invitations
verify / verify (push) Successful in 4m17s
verify / verify (push) Successful in 4m17s
Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package organizations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrRoleInvitationUnsupported = errors.New("organizations: atomic role-set invitations are unsupported")
|
||||
|
||||
// RoleInvitationRepository must preserve the entire role set and its required
|
||||
// owner authority, then commit acceptance, membership, grants and audit together.
|
||||
type RoleInvitationRepository interface {
|
||||
CreateInvitationWithRoles(context.Context, Invitation, string, AuditEvent) error
|
||||
AcceptInvitationWithRoles(context.Context, [32]byte, string, string, time.Time, AuditEvent) error
|
||||
}
|
||||
|
||||
// RoleNames returns a validated copy of the invitation's direct roles. The older
|
||||
// DirectRole remains supported; supplying both forms is an error, not a union.
|
||||
func (invitation Invitation) RoleNames() ([]string, error) {
|
||||
if invitation.DirectRole != "" && len(invitation.DirectRoles) > 0 || len(invitation.DirectRoles) > 16 {
|
||||
return nil, errors.New("organizations: invalid invitation roles")
|
||||
}
|
||||
roles := append([]string(nil), invitation.DirectRoles...)
|
||||
if invitation.DirectRole != "" {
|
||||
roles = append(roles, invitation.DirectRole)
|
||||
}
|
||||
slices.Sort(roles)
|
||||
for i, role := range roles {
|
||||
if !safeNamePattern.MatchString(role) || i > 0 && role == roles[i-1] {
|
||||
return nil, errors.New("organizations: invalid invitation role")
|
||||
}
|
||||
}
|
||||
return roles, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package organizations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type roleInvitationRepositoryStub struct {
|
||||
repositoryStub
|
||||
audit AuditEvent
|
||||
ownerRole string
|
||||
created int
|
||||
}
|
||||
|
||||
func (r *roleInvitationRepositoryStub) CreateInvitationWithRoles(_ context.Context, invitation Invitation, ownerRole string, audit AuditEvent) error {
|
||||
r.created++
|
||||
r.invitation, r.audit, r.ownerRole = invitation, audit, ownerRole
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *roleInvitationRepositoryStub) AcceptInvitationWithRoles(_ context.Context, _ [32]byte, userID, ownerRole string, _ time.Time, audit AuditEvent) error {
|
||||
r.acceptedUser, r.ownerRole, r.audit = userID, ownerRole, audit
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestInvitationRoleNamesAreBoundedAndUnambiguous(t *testing.T) {
|
||||
input := []string{"buyer", "billing"}
|
||||
roles, err := (Invitation{DirectRoles: input}).RoleNames()
|
||||
if err != nil || !slices.Equal(roles, []string{"billing", "buyer"}) || !slices.Equal(input, []string{"buyer", "billing"}) {
|
||||
t.Fatalf("roles=%v input=%v err=%v", roles, input, err)
|
||||
}
|
||||
for _, invitation := range []Invitation{
|
||||
{DirectRole: "owner", DirectRoles: []string{"buyer"}},
|
||||
{DirectRoles: []string{"buyer", "buyer"}},
|
||||
{DirectRoles: []string{"not a role"}},
|
||||
{DirectRoles: make([]string, 17)},
|
||||
} {
|
||||
if _, err := invitation.RoleNames(); err == nil {
|
||||
t.Fatalf("invalid roles accepted=%+v", invitation)
|
||||
}
|
||||
}
|
||||
if roles, err = (Invitation{DirectRole: "buyer"}).RoleNames(); err != nil || !slices.Equal(roles, []string{"buyer"}) {
|
||||
t.Fatalf("legacy=%v err=%v", roles, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleInvitationServicePreservesOwnerRequirementAndRequest(t *testing.T) {
|
||||
r := &roleInvitationRepositoryStub{}
|
||||
service, err := New(r, Options{OwnerRole: "owner", OwnerManagedInvitations: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := InviteWithAccess{OrganizationID: "organization-123", InvitedByUserID: "owner-12345678", Email: " Member@example.test ", DirectRoles: []string{"buyer", "billing"}, Lifetime: time.Hour, RequestID: "request-invite"}
|
||||
raw, invitation, err := service.InviteWithAccess(t.Context(), input)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if invitation.Digest != sha256.Sum256([]byte(raw)) || invitation.RequiredOwnerRole != "owner" || r.ownerRole != "owner" || r.audit.RequestID != input.RequestID || !slices.Equal(invitation.DirectRoles, []string{"billing", "buyer"}) || invitation.Email != "member@example.test" {
|
||||
t.Fatalf("invitation or audit mismatch: %+v %+v", invitation, r.audit)
|
||||
}
|
||||
if !slices.Equal(input.DirectRoles, []string{"buyer", "billing"}) {
|
||||
t.Fatal("caller roles mutated")
|
||||
}
|
||||
if err = service.AcceptInvitation(t.Context(), raw, "member-12345678"); err != nil || r.ownerRole != "owner" || r.acceptedUser != "member-12345678" {
|
||||
t.Fatalf("accept=%v owner=%q user=%q", err, r.ownerRole, r.acceptedUser)
|
||||
}
|
||||
input.RequestID = strings.Repeat("x", 129)
|
||||
if _, _, err = service.InviteWithAccess(t.Context(), input); err == nil || r.created != 1 {
|
||||
t.Fatal("oversized request accepted")
|
||||
}
|
||||
if _, err = New(r, Options{OwnerManagedInvitations: true}); err == nil {
|
||||
t.Fatal("owner-managed service without owner accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleInvitationHasNoPartialLegacyFallback(t *testing.T) {
|
||||
for _, input := range []InviteWithAccess{
|
||||
{DirectRoles: []string{"buyer", "billing"}}, {DirectRole: "owner"}, {},
|
||||
} {
|
||||
r := &repositoryStub{}
|
||||
service, err := New(r, Options{OwnerRole: "owner", OwnerManagedInvitations: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input.OrganizationID, input.InvitedByUserID, input.Email, input.Lifetime = "organization-123", "owner-12345678", "member@example.test", time.Hour
|
||||
if _, _, err = service.InviteWithAccess(t.Context(), input); !errors.Is(err, ErrRoleInvitationUnsupported) || r.invitation.ID != "" {
|
||||
t.Fatalf("legacy fallback=%v", err)
|
||||
}
|
||||
r.invitation = Invitation{OrganizationID: input.OrganizationID, ID: "invitation-1234", RequiredOwnerRole: "owner"}
|
||||
if err = service.AcceptInvitation(t.Context(), strings.Repeat("a", 43), "member-12345678"); !errors.Is(err, ErrRoleInvitationUnsupported) || r.acceptedUser != "" {
|
||||
t.Fatalf("legacy acceptance fallback=%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user