Add atomic owned organization creation
verify / verify (push) Successful in 3m48s

This commit is contained in:
2026-09-04 23:19:05 -04:00
parent b1710e08b8
commit ed0cc8ceff
11 changed files with 586 additions and 18 deletions
+23 -13
View File
@@ -28,6 +28,7 @@ var (
ErrPersonalOrganization = errors.New("organizations: personal organization lifecycle is fixed")
ErrLastOwner = errors.New("organizations: the last active direct owner must be preserved")
ErrOwnerAuthority = errors.New("organizations: a current direct owner must manage owner access")
ErrOwnedCreationUnsupported = errors.New("organizations: atomic owned organization creation is unsupported")
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
)
@@ -156,22 +157,11 @@ func New(repository Repository, options Options) (*Service, error) {
type CreateOrganization struct {
Slug, Name, OwnerUserID string
Personal bool
RequestID string
}
func (service *Service) CreateOrganization(ctx context.Context, input CreateOrganization) (Organization, error) {
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug))
input.Name = strings.TrimSpace(input.Name)
if !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) || !idPattern.MatchString(input.OwnerUserID) {
return Organization{}, errors.New("organizations: invalid organization")
}
id, err := token(service.random, 18)
if err != nil {
return Organization{}, err
}
now := service.now().UTC()
organization := Organization{ID: id, Slug: input.Slug, Name: input.Name, Status: "active", Personal: input.Personal, Revision: 1, CreatedAt: now, UpdatedAt: now}
owner := Membership{OrganizationID: id, UserID: input.OwnerUserID, Status: "active", JoinedAt: now}
audit, err := service.audit(input.OwnerUserID, id, "organization.create", "organization", id, "Organization created")
organization, owner, audit, err := service.prepareOrganization(input)
if err != nil {
return Organization{}, err
}
@@ -181,6 +171,26 @@ func (service *Service) CreateOrganization(ctx context.Context, input CreateOrga
return organization, nil
}
func (service *Service) prepareOrganization(input CreateOrganization) (Organization, Membership, AuditEvent, error) {
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug))
input.Name = strings.TrimSpace(input.Name)
if !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) || !idPattern.MatchString(input.OwnerUserID) || !boundedOptional(input.RequestID, 128) {
return Organization{}, Membership{}, AuditEvent{}, errors.New("organizations: invalid organization")
}
id, err := token(service.random, 18)
if err != nil {
return Organization{}, Membership{}, AuditEvent{}, err
}
now := service.now().UTC()
organization := Organization{ID: id, Slug: input.Slug, Name: input.Name, Status: "active", Personal: input.Personal, Revision: 1, CreatedAt: now, UpdatedAt: now}
owner := Membership{OrganizationID: id, UserID: input.OwnerUserID, Status: "active", JoinedAt: now}
audit, err := service.auditWithRequest(input.OwnerUserID, id, "organization.create", "organization", id, input.RequestID, "Organization created")
if err != nil {
return Organization{}, Membership{}, AuditEvent{}, err
}
return organization, owner, audit, nil
}
func (service *Service) CreatePersonalOrganization(ctx context.Context, userID, displayName string) (Organization, error) {
value := make([]byte, 6)
if _, err := io.ReadFull(service.random, value); err != nil {
+70
View File
@@ -0,0 +1,70 @@
// SPDX-License-Identifier: MPL-2.0
package organizations
import (
"context"
"errors"
"gamertan.com/web/access"
)
// OwnedOrganization is one atomic creation command. Implementations must commit
// the organization, membership, direct owner binding, and both audits together.
type OwnedOrganization struct {
Organization Organization
Membership Membership
OwnerBinding access.Binding
OrganizationAudit AuditEvent
AccessAudit access.AuditEvent
}
// OwnedOrganizationRepository extends Repository without changing the legacy
// membership-only CreateOrganization contract. There is no non-atomic fallback.
type OwnedOrganizationRepository interface {
CreateOwnedOrganization(context.Context, OwnedOrganization) error
}
// 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.
// The role must already be seeded in the repository.
func (service *Service) CreateOwnedOrganization(ctx context.Context, input CreateOrganization) (Organization, error) {
if service.ownerRole == "" {
return Organization{}, errors.New("organizations: owned creation requires a configured owner role")
}
repository, ok := service.repository.(OwnedOrganizationRepository)
if !ok {
return Organization{}, ErrOwnedCreationUnsupported
}
organization, membership, audit, err := service.prepareOrganization(input)
if err != nil {
return Organization{}, err
}
bindingID, err := token(service.random, 18)
if err != nil {
return Organization{}, err
}
accessAuditID, err := token(service.random, 18)
if err != nil {
return Organization{}, err
}
binding := access.Binding{
ID: bindingID, SubjectKind: access.User, SubjectID: input.OwnerUserID,
Role: service.ownerRole, Scope: access.Scope{OrganizationID: organization.ID},
GrantedBy: input.OwnerUserID, GrantedAt: organization.CreatedAt,
}
accessAudit := access.AuditEvent{
ID: accessAuditID, OrganizationID: organization.ID, ActorUserID: input.OwnerUserID,
Action: "access.binding.grant", ResourceType: "binding", ResourceID: bindingID,
RequestID: input.RequestID, Summary: "Initial organization owner granted",
CreatedAt: organization.CreatedAt,
}
if err = repository.CreateOwnedOrganization(ctx, OwnedOrganization{
Organization: organization, Membership: membership, OwnerBinding: binding,
OrganizationAudit: audit, AccessAudit: accessAudit,
}); err != nil {
return Organization{}, err
}
return organization, nil
}
+94
View File
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: MPL-2.0
package organizations
import (
"context"
"errors"
"strings"
"testing"
"time"
"gamertan.com/web/access"
)
type ownedRepositoryStub struct {
repositoryStub
setup OwnedOrganization
calls int
err error
}
func (repository *ownedRepositoryStub) CreateOwnedOrganization(_ context.Context, setup OwnedOrganization) error {
repository.calls++
repository.setup = setup
return repository.err
}
func TestOwnedOrganizationUsesConfiguredRoleAndAtomicRepository(t *testing.T) {
now := time.Unix(1000, 0).UTC()
repository := &ownedRepositoryStub{}
service, err := New(repository, Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
input := CreateOrganization{Slug: " CLIENT-BUSINESS ", Name: " Client Business ", OwnerUserID: "customer-12345", RequestID: "request-creation"}
organization, err := service.CreateOwnedOrganization(t.Context(), input)
if err != nil {
t.Fatal(err)
}
setup := repository.setup
if repository.calls != 1 || repository.organization.ID != "" || setup.Organization != organization || organization.Slug != "client-business" || organization.Name != "Client Business" {
t.Fatalf("unexpected creation: %+v", setup)
}
if setup.Membership.UserID != input.OwnerUserID || setup.OwnerBinding.SubjectKind != access.User || setup.OwnerBinding.SubjectID != input.OwnerUserID || setup.OwnerBinding.Role != "customer.owner" || setup.OwnerBinding.Scope != (access.Scope{OrganizationID: organization.ID}) || setup.OwnerBinding.GrantedBy != input.OwnerUserID {
t.Fatalf("unexpected owner: %+v", setup)
}
if setup.OrganizationAudit.RequestID != input.RequestID || setup.AccessAudit.RequestID != input.RequestID || setup.AccessAudit.ResourceID != setup.OwnerBinding.ID || !setup.OwnerBinding.GrantedAt.Equal(now) {
t.Fatalf("unexpected audits: %+v", setup)
}
}
func TestOwnedOrganizationFailsWithoutAtomicSupport(t *testing.T) {
repository := &repositoryStub{}
service, _ := New(repository, Options{OwnerRole: "customer.owner"})
organization, err := service.CreateOwnedOrganization(t.Context(), CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345"})
if !errors.Is(err, ErrOwnedCreationUnsupported) || organization.ID != "" || repository.organization.ID != "" {
t.Fatalf("non-atomic fallback: organization=%+v err=%v", organization, err)
}
}
func TestOwnedOrganizationRejectsInvalidSetupBeforeStorage(t *testing.T) {
for _, test := range []struct {
name string
role string
request string
random string
}{
{name: "missing role", random: strings.Repeat("a", 200)},
{name: "bad request ID", role: "customer.owner", request: "request\nsecret", random: strings.Repeat("a", 200)},
{name: "random failure", role: "customer.owner", random: strings.Repeat("a", 40)},
} {
t.Run(test.name, func(t *testing.T) {
repository := &ownedRepositoryStub{}
service, err := New(repository, Options{OwnerRole: test.role, Random: strings.NewReader(test.random)})
if err != nil {
t.Fatal(err)
}
organization, err := service.CreateOwnedOrganization(t.Context(), CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345", RequestID: test.request})
if err == nil || organization.ID != "" || repository.calls != 0 {
t.Fatalf("organization=%+v calls=%d err=%v", organization, repository.calls, err)
}
})
}
}
func TestOwnedOrganizationDoesNotReturnUncommittedIdentity(t *testing.T) {
want := errors.New("durability failure")
repository := &ownedRepositoryStub{err: want}
service, _ := New(repository, Options{OwnerRole: "customer.owner"})
organization, err := service.CreateOwnedOrganization(t.Context(), CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345"})
if !errors.Is(err, want) || organization.ID != "" || repository.calls != 1 {
t.Fatalf("organization=%+v calls=%d err=%v", organization, repository.calls, err)
}
}