This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user