Guard customer-owned profile and membership transactions
verify / verify (push) Successful in 4m25s

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
2026-09-05 00:33:24 -04:00
parent f142ac23a9
commit d476179148
12 changed files with 369 additions and 10 deletions
+41 -3
View File
@@ -414,6 +414,14 @@ type UpdateOrganization struct {
}
func (service *Service) UpdateOrganization(ctx context.Context, input UpdateOrganization) (Organization, error) {
return service.updateOrganization(ctx, input, false)
}
func (service *Service) updateOrganization(ctx context.Context, input UpdateOrganization, requireOwner bool) (Organization, error) {
ownedRepository, ownedSupported := service.repository.(OwnerManagedRepository)
if requireOwner && (!ownedSupported || service.ownerRole == "") {
return Organization{}, ErrOwnedManagementUnsupported
}
input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name)
if !idPattern.MatchString(input.ID) || !idPattern.MatchString(input.ActorUserID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) || input.ExpectedRevision < 1 || !boundedOptional(input.RequestID, 128) {
return Organization{}, errors.New("organizations: invalid organization update")
@@ -422,12 +430,20 @@ func (service *Service) UpdateOrganization(ctx context.Context, input UpdateOrga
if err != nil {
return Organization{}, err
}
if requireOwner && value.Personal {
return Organization{}, ErrPersonalOrganization
}
value.Slug, value.Name, value.Revision, value.UpdatedAt = input.Slug, input.Name, input.ExpectedRevision+1, service.now().UTC()
audit, err := service.auditWithRequest(input.ActorUserID, value.ID, "organization.update", "organization", value.ID, input.RequestID, "Organization details updated")
if err != nil {
return Organization{}, err
}
if err = service.repository.UpdateOrganization(ctx, value, input.ExpectedRevision, audit); err != nil {
if requireOwner {
err = ownedRepository.UpdateOwnedOrganization(ctx, value, input.ExpectedRevision, service.ownerRole, audit)
} else {
err = service.repository.UpdateOrganization(ctx, value, input.ExpectedRevision, audit)
}
if err != nil {
return Organization{}, err
}
return value, nil
@@ -531,6 +547,10 @@ type MembershipStatusChange struct {
}
func (service *Service) ChangeMembershipStatus(ctx context.Context, input MembershipStatusChange) error {
return service.changeMembershipStatus(ctx, input, false)
}
func (service *Service) changeMembershipStatus(ctx context.Context, input MembershipStatusChange, requireOwner bool) 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 ||
@@ -540,14 +560,21 @@ func (service *Service) ChangeMembershipStatus(ctx context.Context, input Member
if service.ownerRole == "" {
return errors.New("organizations: owner role is required for membership lifecycle changes")
}
ownedRepository, ownedSupported := service.repository.(OwnerManagedRepository)
if requireOwner && !ownedSupported {
return ErrOwnedManagementUnsupported
}
repository, ok := service.repository.(OptimisticMembershipRepository)
if !ok {
if !requireOwner && !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
}
if requireOwner {
return ownedRepository.ChangeOwnedMembershipStatus(ctx, input, service.ownerRole, audit)
}
return repository.ChangeMembershipStatus(ctx, input, service.ownerRole, audit)
}
@@ -572,6 +599,10 @@ type MembershipRemoval struct {
}
func (service *Service) RemoveMembershipIfCurrent(ctx context.Context, input MembershipRemoval) error {
return service.removeMembershipIfCurrent(ctx, input, false)
}
func (service *Service) removeMembershipIfCurrent(ctx context.Context, input MembershipRemoval, requireOwner bool) 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")
@@ -579,14 +610,21 @@ func (service *Service) RemoveMembershipIfCurrent(ctx context.Context, input Mem
if service.ownerRole == "" {
return errors.New("organizations: owner role is required for membership lifecycle changes")
}
ownedRepository, ownedSupported := service.repository.(OwnerManagedRepository)
if requireOwner && !ownedSupported {
return ErrOwnedManagementUnsupported
}
repository, ok := service.repository.(OptimisticMembershipRepository)
if !ok {
if !requireOwner && !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
}
if requireOwner {
return ownedRepository.RemoveOwnedMembershipIfCurrent(ctx, input, service.ownerRole, audit)
}
return repository.RemoveMembershipIfCurrent(ctx, input, service.ownerRole, audit)
}
+31
View File
@@ -25,6 +25,37 @@ type OwnedOrganizationRepository interface {
CreateOwnedOrganization(context.Context, OwnedOrganization) error
}
var ErrOwnedManagementUnsupported = errors.New("organizations: atomic owner-managed updates are unsupported")
// OwnerManagedRepository rechecks the configured direct owner in the same
// transaction as profile and membership writes. These explicit operations do
// not change legacy methods used by applications with delegated administrators.
// Implementations must also preserve optimistic state, last-owner protection,
// and audit atomicity. There is no preflight-only fallback.
type OwnerManagedRepository interface {
UpdateOwnedOrganization(context.Context, Organization, int64, string, AuditEvent) error
ChangeOwnedMembershipStatus(context.Context, MembershipStatusChange, string, AuditEvent) error
RemoveOwnedMembershipIfCurrent(context.Context, MembershipRemoval, string, AuditEvent) error
}
// UpdateOwnedOrganization changes a non-personal organization's name and slug.
// OwnerRole comes from trusted service configuration, not submitted form data.
func (service *Service) UpdateOwnedOrganization(ctx context.Context, input UpdateOrganization) (Organization, error) {
return service.updateOrganization(ctx, input, true)
}
// ChangeOwnedMembershipStatus requires a current owner even when the target
// member is not an owner. It preserves the last active owner.
func (service *Service) ChangeOwnedMembershipStatus(ctx context.Context, input MembershipStatusChange) error {
return service.changeMembershipStatus(ctx, input, true)
}
// RemoveOwnedMembershipIfCurrent removes only the displayed membership state,
// with current owner authority checked in the write transaction.
func (service *Service) RemoveOwnedMembershipIfCurrent(ctx context.Context, input MembershipRemoval) error {
return service.removeMembershipIfCurrent(ctx, input, true)
}
// CreateOwnedOrganization grants the configured OwnerRole to the initial owner
// inside the creation transaction. Applications authorize creation and choose
// OwnerRole when constructing the service, never from a submitted role name.
+19
View File
@@ -92,3 +92,22 @@ func TestOwnedOrganizationDoesNotReturnUncommittedIdentity(t *testing.T) {
t.Fatalf("organization=%+v calls=%d err=%v", organization, repository.calls, err)
}
}
func TestOwnedManagementHasNoPreflightOnlyFallback(t *testing.T) {
service, err := New(&repositoryStub{}, Options{OwnerRole: "customer.owner"})
if err != nil {
t.Fatal(err)
}
_, err = service.UpdateOwnedOrganization(t.Context(), UpdateOrganization{ID: "organization-12345", Slug: "business", Name: "Business", ActorUserID: "customer-12345", ExpectedRevision: 1})
if !errors.Is(err, ErrOwnedManagementUnsupported) {
t.Fatalf("profile fallback: %v", err)
}
err = service.ChangeOwnedMembershipStatus(t.Context(), MembershipStatusChange{OrganizationID: "organization-12345", UserID: "member-12345", ActorUserID: "customer-12345", ExpectedStatus: "active", Status: "suspended"})
if !errors.Is(err, ErrOwnedManagementUnsupported) {
t.Fatalf("status fallback: %v", err)
}
err = service.RemoveOwnedMembershipIfCurrent(t.Context(), MembershipRemoval{OrganizationID: "organization-12345", UserID: "member-12345", ActorUserID: "customer-12345", ExpectedStatus: "active"})
if !errors.Is(err, ErrOwnedManagementUnsupported) {
t.Fatalf("removal fallback: %v", err)
}
}