This commit is contained in:
@@ -19,15 +19,16 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvitationNotFound = errors.New("organizations: invitation not found")
|
||||
ErrMembershipNotFound = errors.New("organizations: membership not found")
|
||||
ErrOrganizationNotFound = errors.New("organizations: organization not found")
|
||||
ErrTeamNotFound = errors.New("organizations: team not found")
|
||||
ErrRevisionConflict = errors.New("organizations: revision conflict")
|
||||
ErrPersonalOrganization = errors.New("organizations: personal organization lifecycle is fixed")
|
||||
ErrLastOwner = errors.New("organizations: the last active direct owner must be preserved")
|
||||
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
|
||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
||||
ErrInvitationNotFound = errors.New("organizations: invitation not found")
|
||||
ErrMembershipNotFound = errors.New("organizations: membership not found")
|
||||
ErrMembershipLifecycleUnsupported = errors.New("organizations: optimistic membership lifecycle is unsupported")
|
||||
ErrOrganizationNotFound = errors.New("organizations: organization not found")
|
||||
ErrTeamNotFound = errors.New("organizations: team not found")
|
||||
ErrRevisionConflict = errors.New("organizations: revision conflict")
|
||||
ErrPersonalOrganization = errors.New("organizations: personal organization lifecycle is fixed")
|
||||
ErrLastOwner = errors.New("organizations: the last active direct owner must be preserved")
|
||||
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
|
||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
||||
)
|
||||
|
||||
type Organization struct {
|
||||
@@ -112,6 +113,16 @@ type Repository interface {
|
||||
TeamsForUser(context.Context, string, string) ([]Team, error)
|
||||
}
|
||||
|
||||
// OptimisticMembershipRepository is implemented by repositories that can
|
||||
// bind a membership lifecycle mutation to the exact state authorized by the
|
||||
// caller. Services deliberately do not fall back to the older lifecycle
|
||||
// methods: a stale fresh-authentication ceremony must fail instead of acting
|
||||
// on a membership that changed while the ceremony was in progress.
|
||||
type OptimisticMembershipRepository interface {
|
||||
ChangeMembershipStatus(context.Context, MembershipStatusChange, string, AuditEvent) error
|
||||
RemoveMembershipIfCurrent(context.Context, MembershipRemoval, string, AuditEvent) error
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
Random io.Reader
|
||||
Now func() time.Time
|
||||
@@ -464,6 +475,34 @@ func (service *Service) SetMembershipStatus(ctx context.Context, organizationID,
|
||||
return service.repository.SetMembershipStatus(ctx, organizationID, userID, status, service.ownerRole, audit)
|
||||
}
|
||||
|
||||
// MembershipStatusChange describes an exact active-to-suspended or
|
||||
// suspended-to-active transition. ExpectedStatus is part of the authorized
|
||||
// operation and is checked again inside the repository transaction.
|
||||
type MembershipStatusChange struct {
|
||||
OrganizationID, UserID, ExpectedStatus, Status, ActorUserID, RequestID string
|
||||
}
|
||||
|
||||
func (service *Service) ChangeMembershipStatus(ctx context.Context, input MembershipStatusChange) error {
|
||||
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) ||
|
||||
(input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") ||
|
||||
(input.Status != "active" && input.Status != "suspended") || input.Status == input.ExpectedStatus ||
|
||||
!boundedOptional(input.RequestID, 128) {
|
||||
return errors.New("organizations: invalid membership status change")
|
||||
}
|
||||
if service.ownerRole == "" {
|
||||
return errors.New("organizations: owner role is required for membership lifecycle changes")
|
||||
}
|
||||
repository, ok := service.repository.(OptimisticMembershipRepository)
|
||||
if !ok {
|
||||
return ErrMembershipLifecycleUnsupported
|
||||
}
|
||||
audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership."+input.Status, "membership", input.UserID, input.RequestID, "Organization membership set to "+input.Status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.ChangeMembershipStatus(ctx, input, service.ownerRole, audit)
|
||||
}
|
||||
|
||||
func (service *Service) RemoveMembership(ctx context.Context, organizationID, userID, actorUserID, requestID string) error {
|
||||
if !idPattern.MatchString(organizationID) || !idPattern.MatchString(userID) || !idPattern.MatchString(actorUserID) || !boundedOptional(requestID, 128) {
|
||||
return errors.New("organizations: invalid membership removal")
|
||||
@@ -478,6 +517,31 @@ func (service *Service) RemoveMembership(ctx context.Context, organizationID, us
|
||||
return service.repository.RemoveMembership(ctx, organizationID, userID, service.ownerRole, audit)
|
||||
}
|
||||
|
||||
// MembershipRemoval binds removal to the exact membership state observed by
|
||||
// the caller before fresh authentication began.
|
||||
type MembershipRemoval struct {
|
||||
OrganizationID, UserID, ExpectedStatus, ActorUserID, RequestID string
|
||||
}
|
||||
|
||||
func (service *Service) RemoveMembershipIfCurrent(ctx context.Context, input MembershipRemoval) error {
|
||||
if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) ||
|
||||
(input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") || !boundedOptional(input.RequestID, 128) {
|
||||
return errors.New("organizations: invalid membership removal")
|
||||
}
|
||||
if service.ownerRole == "" {
|
||||
return errors.New("organizations: owner role is required for membership lifecycle changes")
|
||||
}
|
||||
repository, ok := service.repository.(OptimisticMembershipRepository)
|
||||
if !ok {
|
||||
return ErrMembershipLifecycleUnsupported
|
||||
}
|
||||
audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership.remove", "membership", input.UserID, input.RequestID, "Organization membership removed")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repository.RemoveMembershipIfCurrent(ctx, input, service.ownerRole, audit)
|
||||
}
|
||||
|
||||
func (service *Service) Invitations(ctx context.Context, organizationID string, limit int) ([]Invitation, error) {
|
||||
if !idPattern.MatchString(organizationID) || limit < 1 || limit > 1000 {
|
||||
return nil, errors.New("organizations: invalid invitation query")
|
||||
|
||||
@@ -47,6 +47,19 @@ func TestInvitationFailsClosed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptimisticMembershipLifecycleFailsClosedWithoutRepositorySupport(t *testing.T) {
|
||||
service, err := New(&repositoryStub{}, Options{OwnerRole: "organization.owner"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = service.ChangeMembershipStatus(t.Context(), MembershipStatusChange{OrganizationID: "organization-1234", UserID: "user-12345678", ExpectedStatus: "active", Status: "suspended", ActorUserID: "user-87654321", RequestID: "request-suspend"}); !errors.Is(err, ErrMembershipLifecycleUnsupported) {
|
||||
t.Fatalf("status change err=%v", err)
|
||||
}
|
||||
if err = service.RemoveMembershipIfCurrent(t.Context(), MembershipRemoval{OrganizationID: "organization-1234", UserID: "user-12345678", ExpectedStatus: "active", ActorUserID: "user-87654321", RequestID: "request-remove"}); !errors.Is(err, ErrMembershipLifecycleUnsupported) {
|
||||
t.Fatalf("removal err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type repositoryStub struct {
|
||||
organization Organization
|
||||
invitation Invitation
|
||||
|
||||
Reference in New Issue
Block a user