From ed0cc8ceff9c49b200a1f9454fb3615dc1d1dd42 Mon Sep 17 00:00:00 2001 From: Cole Speelman Date: Fri, 4 Sep 2026 23:19:05 -0400 Subject: [PATCH] Add atomic owned organization creation --- CHANGELOG.md | 12 ++ README.md | 6 +- authsqlite/owned_organization.go | 72 +++++++ authsqlite/owned_organization_test.go | 264 ++++++++++++++++++++++++++ docs/DOGFOOD.md | 10 + docs/GETTING_STARTED.md | 2 +- docs/MODULES.md | 2 +- docs/ORGANIZATIONS.md | 36 ++++ organizations/organizations.go | 36 ++-- organizations/owned.go | 70 +++++++ organizations/owned_test.go | 94 +++++++++ 11 files changed, 586 insertions(+), 18 deletions(-) create mode 100644 authsqlite/owned_organization.go create mode 100644 authsqlite/owned_organization_test.go create mode 100644 organizations/owned.go create mode 100644 organizations/owned_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 900742e..ec48e99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ # Changelog +## v0.1.0-preview.22 — 2026-09-04 + +- Add `organizations.CreateOwnedOrganization` for atomic creation of an existing + user's organization, initial membership, direct configured owner role, and + correlated organization/access audits. +- Require an active, fully registered owner and a pre-seeded role inside the + SQLite transaction. Missing storage support fails without a non-atomic fallback. +- Preserve the older membership-only creation API and schema version 9. Customer + and merchant permissions remain application-owned, with no commerce dependency. +- Exercise failure at every write stage, concurrent duplicate creation, scoped + access, restart recovery, last-owner protection, and mismatched authority/audits. + ## v0.1.0-preview.21 — 2026-09-04 - Derive the registered credential algorithm from the verified COSE public key diff --git a/README.md b/README.md index a820dfb..62a0b37 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and deployment. Adopt one boundary at a time; Go compiles and links only the packages you import. -> **Public preview:** `v0.1.0-preview.21`. APIs may change before a stable +> **Public preview:** `v0.1.0-preview.22`. APIs may change before a stable > release. Linux is the maintained release platform. ## Why Web Foundations? @@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy. Pin the preview in an application module: ```bash -go get gamertan.com/web@v0.1.0-preview.21 +go get gamertan.com/web@v0.1.0-preview.22 go mod verify ``` An application may name the first package it intends to adopt: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.21 +go get gamertan.com/web/requestmeta@v0.1.0-preview.22 ``` The version belongs to the `gamertan.com/web` module. See the diff --git a/authsqlite/owned_organization.go b/authsqlite/owned_organization.go new file mode 100644 index 0000000..9e7babf --- /dev/null +++ b/authsqlite/owned_organization.go @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authsqlite + +import ( + "context" + "errors" + + "gamertan.com/web/organizations" +) + +// CreateOwnedOrganization atomically creates a new organization and its first +// direct owner. It never grants authority in an existing organization. +func (store *Store) CreateOwnedOrganization(ctx context.Context, setup organizations.OwnedOrganization) error { + organization, membership, binding := setup.Organization, setup.Membership, setup.OwnerBinding + audit, accessAudit := setup.OrganizationAudit, setup.AccessAudit + if !validOrganization(organization) || organization.Status != "active" || organization.Revision != 1 || + membership.OrganizationID != organization.ID || !opaqueID(membership.UserID) || membership.Status != "active" || !membership.JoinedAt.Equal(organization.CreatedAt) || + !validOwnerBinding(binding, organization.ID, membership.UserID) || !binding.GrantedAt.Equal(organization.CreatedAt) || + !validOrganizationAudit(audit, organization.ID) || audit.ActorUserID != membership.UserID || audit.Action != "organization.create" || audit.ResourceType != "organization" || audit.ResourceID != organization.ID || + !validAccessAudit(accessAudit) || accessAudit.OrganizationID != organization.ID || accessAudit.ActorUserID != membership.UserID || accessAudit.Action != "access.binding.grant" || accessAudit.ResourceType != "binding" || accessAudit.ResourceID != binding.ID || accessAudit.RequestID != audit.RequestID { + return errors.New("authsqlite: invalid owned organization") + } + tx, err := store.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + var personalOwner any + if organization.Personal { + personalOwner = membership.UserID + } + // The first statement acquires the writer lock and validates active, completed + // identity inside the transaction; account suspension cannot race the grant. + result, err := tx.ExecContext(ctx, `INSERT INTO gwf_organizations(id,slug,name,personal,personal_owner_user_id,created_at,status,revision,updated_at) + SELECT ?,?,?,?,?,?,?,?,? FROM gwf_users WHERE id=? AND status='active' AND registration_pending=0`, + organization.ID, organization.Slug, organization.Name, organization.Personal, personalOwner, + organization.CreatedAt.Unix(), organization.Status, organization.Revision, organization.UpdatedAt.Unix(), membership.UserID) + if err != nil { + return err + } + if changed, err := result.RowsAffected(); err != nil || changed != 1 { + if err != nil { + return err + } + return organizations.ErrOwnerAuthority + } + if _, err = tx.ExecContext(ctx, `INSERT INTO gwf_organization_memberships(organization_id,user_id,status,joined_at) VALUES(?,?,?,?)`, organization.ID, membership.UserID, membership.Status, membership.JoinedAt.Unix()); err != nil { + return err + } + result, err = tx.ExecContext(ctx, `INSERT INTO gwf_access_bindings(id,organization_id,subject_kind,subject_id,role_name,project_id,environment_id,service_id,granted_by_user_id,granted_at) + SELECT ?,?,'user',?,?,NULL,NULL,NULL,?,? FROM gwf_access_roles WHERE name=?`, + binding.ID, organization.ID, membership.UserID, binding.Role, membership.UserID, binding.GrantedAt.Unix(), binding.Role) + if err != nil { + return err + } + if changed, err := result.RowsAffected(); err != nil || changed != 1 { + if err != nil { + return err + } + return errors.New("authsqlite: initial owner role has not been seeded") + } + if err = appendOrganizationAudit(ctx, tx, audit); err != nil { + return err + } + if err = appendAccessAudit(ctx, tx, accessAudit); err != nil { + return err + } + return tx.Commit() +} + +var _ organizations.OwnedOrganizationRepository = (*Store)(nil) diff --git a/authsqlite/owned_organization_test.go b/authsqlite/owned_organization_test.go new file mode 100644 index 0000000..dfd7767 --- /dev/null +++ b/authsqlite/owned_organization_test.go @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authsqlite + +import ( + "context" + "errors" + "path/filepath" + "sync" + "testing" + "time" + + "gamertan.com/web/access" + "gamertan.com/web/organizations" +) + +func ownedOrganizationFixture(t *testing.T) (*Store, *organizations.Service, access.Policy, organizations.CreateOrganization) { + t.Helper() + store, err := Open(filepath.Join(t.TempDir(), "owned.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + now := time.Unix(2000, 0).UTC() + if _, err = store.db.Exec(`INSERT INTO gwf_users(id,username,username_normalized,email,email_normalized,display_name,status,registration_pending,created_at,updated_at) + VALUES('customer-12345','customer','customer','customer@example.test','customer@example.test','Customer','active',0,2000,2000)`); err != nil { + t.Fatal(err) + } + policy := access.Policy{ + Roles: map[string]string{"customer.owner": "Customer owner", "home.owner": "Merchant owner"}, + Permissions: map[string]string{"customer.purchase": "Purchase", "merchant.manage": "Manage merchant"}, + Grants: map[string][]string{"customer.owner": {"customer.purchase"}, "home.owner": {"merchant.manage"}}, + } + accessService, err := access.New(store, policy, access.Options{}) + if err != nil { + t.Fatal(err) + } + if err = accessService.Seed(t.Context()); err != nil { + t.Fatal(err) + } + service, err := organizations.New(store, organizations.Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + return store, service, policy, organizations.CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345", RequestID: "request-creation"} +} + +func countOwnedRows(t *testing.T, store *Store, want int) { + t.Helper() + for _, table := range []string{"gwf_organizations", "gwf_organization_memberships", "gwf_access_bindings", "gwf_access_audit_events"} { + var count int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM ` + table).Scan(&count); err != nil { + t.Fatal(err) + } + expected := want + if table == "gwf_access_audit_events" { + expected *= 2 + } + if count != expected { + t.Errorf("%s count=%d want=%d", table, count, expected) + } + } +} + +func TestOwnedOrganizationCommitsScopedOwnerAndAudits(t *testing.T) { + store, service, policy, input := ownedOrganizationFixture(t) + organization, err := service.CreateOwnedOrganization(t.Context(), input) + if err != nil { + t.Fatal(err) + } + countOwnedRows(t, store, 1) + accessService, err := access.New(store, policy, access.Options{}) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + scope string + permission string + want bool + }{ + {organization.ID, "customer.purchase", true}, + {organization.ID, "merchant.manage", false}, + {"other-org-12345", "customer.purchase", false}, + } { + decision, err := accessService.Authorize(t.Context(), input.OwnerUserID, access.Scope{OrganizationID: test.scope}, test.permission) + if err != nil || decision.Allowed != test.want { + t.Fatalf("scope=%s permission=%s decision=%+v err=%v", test.scope, test.permission, decision, err) + } + } + for _, action := range []string{"organization.create", "access.binding.grant"} { + var actor, request string + if err = store.db.QueryRow(`SELECT actor_user_id,request_id FROM gwf_access_audit_events WHERE organization_id=? AND action=?`, organization.ID, action).Scan(&actor, &request); err != nil { + t.Fatal(err) + } + if actor != input.OwnerUserID || request != input.RequestID { + t.Fatalf("audit actor=%q request=%q", actor, request) + } + } + if _, err = service.CreateOwnedOrganization(t.Context(), input); err == nil { + t.Fatal("duplicate slug accepted") + } + countOwnedRows(t, store, 1) +} + +func TestOwnedOrganizationRollsBackEveryWriteFailure(t *testing.T) { + for _, stage := range []struct{ table, when string }{ + {"gwf_organizations", ""}, {"gwf_organization_memberships", ""}, {"gwf_access_bindings", ""}, + {"gwf_access_audit_events", " WHEN NEW.action='organization.create'"}, + {"gwf_access_audit_events", " WHEN NEW.action='access.binding.grant'"}, + } { + t.Run(stage.table+stage.when, func(t *testing.T) { + store, service, _, input := ownedOrganizationFixture(t) + if _, err := store.db.Exec(`CREATE TRIGGER reject_creation BEFORE INSERT ON ` + stage.table + stage.when + ` BEGIN SELECT RAISE(ABORT,'injected write failure'); END`); err != nil { + t.Fatal(err) + } + if organization, err := service.CreateOwnedOrganization(t.Context(), input); err == nil || organization.ID != "" { + t.Fatalf("organization=%+v err=%v", organization, err) + } + countOwnedRows(t, store, 0) + }) + } +} + +func TestOwnedOrganizationRejectsMissingRoleAndUnavailableOwner(t *testing.T) { + for _, change := range []string{ + `DELETE FROM gwf_access_role_permissions WHERE role_name='customer.owner'; DELETE FROM gwf_access_roles WHERE name='customer.owner'`, + `UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`, + `UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`, + `DELETE FROM gwf_users WHERE id='customer-12345'`, + } { + t.Run(change, func(t *testing.T) { + store, service, _, input := ownedOrganizationFixture(t) + if _, err := store.db.Exec(change); err != nil { + t.Fatal(err) + } + if organization, err := service.CreateOwnedOrganization(t.Context(), input); err == nil || organization.ID != "" { + t.Fatalf("organization=%+v err=%v", organization, err) + } + countOwnedRows(t, store, 0) + }) + } +} + +func TestConcurrentOwnedOrganizationCreationHasOneCompleteWinner(t *testing.T) { + store, service, _, input := ownedOrganizationFixture(t) + var workers sync.WaitGroup + results := make(chan error, 8) + for range 8 { + workers.Go(func() { _, err := service.CreateOwnedOrganization(t.Context(), input); results <- err }) + } + workers.Wait() + close(results) + winners := 0 + for err := range results { + if err == nil { + winners++ + } + } + if winners != 1 { + t.Fatalf("successful creations=%d", winners) + } + countOwnedRows(t, store, 1) +} + +func TestLegacyOrganizationCreationRemainsMembershipOnly(t *testing.T) { + store, service, _, input := ownedOrganizationFixture(t) + if _, err := service.CreateOrganization(t.Context(), input); err != nil { + t.Fatal(err) + } + var bindings int + if err := store.db.QueryRow(`SELECT COUNT(*) FROM gwf_access_bindings`).Scan(&bindings); err != nil { + t.Fatal(err) + } + if bindings != 0 { + t.Fatal("legacy creation unexpectedly granted authority") + } +} + +func TestOwnedOrganizationSurvivesReopenAndProtectsLastOwner(t *testing.T) { + store, service, _, input := ownedOrganizationFixture(t) + organization, err := service.CreateOwnedOrganization(t.Context(), input) + if err != nil { + t.Fatal(err) + } + var sequence int + var name, path string + if err = store.db.QueryRow(`PRAGMA database_list`).Scan(&sequence, &name, &path); err != nil { + t.Fatal(err) + } + if err = store.Close(); err != nil { + t.Fatal(err) + } + reopened, err := OpenWithOptions(path, OpenOptions{Migrate: false}) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + if err = reopened.RequireCurrentSchema(t.Context()); err != nil { + t.Fatal(err) + } + countOwnedRows(t, reopened, 1) + service, err = organizations.New(reopened, organizations.Options{OwnerRole: "customer.owner"}) + if err != nil { + t.Fatal(err) + } + err = service.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{ + OrganizationID: organization.ID, UserID: input.OwnerUserID, ActorUserID: input.OwnerUserID, ExpectedStatus: "active", + }) + if !errors.Is(err, organizations.ErrLastOwner) { + t.Fatalf("last owner removal: %v", err) + } + err = service.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{ + OrganizationID: organization.ID, UserID: input.OwnerUserID, ActorUserID: input.OwnerUserID, ExpectedStatus: "active", Status: "suspended", + }) + if !errors.Is(err, organizations.ErrLastOwner) { + t.Fatalf("last owner suspension: %v", err) + } + countOwnedRows(t, reopened, 1) +} + +type capturedOwnedStore struct { + *Store + setup organizations.OwnedOrganization +} + +func (store *capturedOwnedStore) CreateOwnedOrganization(_ context.Context, setup organizations.OwnedOrganization) error { + store.setup = setup + return nil +} + +func TestOwnedOrganizationRejectsMismatchedAuthorityAndAudits(t *testing.T) { + for _, test := range []struct { + name string + change func(*organizations.OwnedOrganization) + }{ + {"foreign member", func(s *organizations.OwnedOrganization) { s.Membership.OrganizationID = "other-org-12345" }}, + {"foreign owner", func(s *organizations.OwnedOrganization) { s.OwnerBinding.SubjectID = "other-user-12345" }}, + {"foreign scope", func(s *organizations.OwnedOrganization) { s.OwnerBinding.Scope.OrganizationID = "other-org-12345" }}, + {"narrow scope", func(s *organizations.OwnedOrganization) { s.OwnerBinding.Scope.ProjectID = "project-12345" }}, + {"team owner", func(s *organizations.OwnedOrganization) { s.OwnerBinding.SubjectKind = access.Team }}, + {"wrong audit actor", func(s *organizations.OwnedOrganization) { s.AccessAudit.ActorUserID = "other-user-12345" }}, + {"wrong audit binding", func(s *organizations.OwnedOrganization) { s.AccessAudit.ResourceID = "other-binding-12345" }}, + {"wrong creation resource", func(s *organizations.OwnedOrganization) { s.OrganizationAudit.ResourceID = "other-org-12345" }}, + {"wrong request", func(s *organizations.OwnedOrganization) { s.AccessAudit.RequestID = "other-request" }}, + {"archived organization", func(s *organizations.OwnedOrganization) { s.Organization.Status = "archived" }}, + } { + t.Run(test.name, func(t *testing.T) { + store, _, _, input := ownedOrganizationFixture(t) + capture := &capturedOwnedStore{Store: store} + service, err := organizations.New(capture, organizations.Options{OwnerRole: "customer.owner"}) + if err != nil { + t.Fatal(err) + } + if _, err = service.CreateOwnedOrganization(t.Context(), input); err != nil { + t.Fatal(err) + } + test.change(&capture.setup) + if err = store.CreateOwnedOrganization(t.Context(), capture.setup); err == nil { + t.Fatal("invalid creation accepted") + } + countOwnedRows(t, store, 0) + }) + } +} diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md index bb29f57..1263640 100644 --- a/docs/DOGFOOD.md +++ b/docs/DOGFOOD.md @@ -8,6 +8,16 @@ application concern belongs in the shared module. ## Gamertan accounts and commerce +- Shared business purchasing exposed the difference between an initial member + and an initial RBAC owner. The historical organization creation method commits + membership but no access binding. The new `CreateOwnedOrganization` extension + grants the application-configured role and writes both audits atomically for + an existing active, fully registered user. It rejects missing roles and + unsupported adapters instead of leaving an ownerless organization behind. + SQLite tests inject failure at every write stage, including the second audit, + and race duplicate creates. Customer/merchant vocabulary remains application + policy; there is no new database schema or commerce dependency in Foundations. + - The account email remains required and unique. Gamertan uses normalized email as the canonical login identifier and keeps username as a stable public identity. Until a mail package exists, the application must not describe an diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index f78680a..62963fb 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its module checksum: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.21 +go get gamertan.com/web/requestmeta@v0.1.0-preview.22 go mod verify ``` diff --git a/docs/MODULES.md b/docs/MODULES.md index 09e8a60..377fdee 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta" and request the containing module at an exact version: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.21 +go get gamertan.com/web/requestmeta@v0.1.0-preview.22 ``` Only imported packages are compiled and linked. The packages nevertheless diff --git a/docs/ORGANIZATIONS.md b/docs/ORGANIZATIONS.md index bb78b44..b7c4e06 100644 --- a/docs/ORGANIZATIONS.md +++ b/docs/ORGANIZATIONS.md @@ -19,6 +19,42 @@ ordinary invitations but cannot create or cancel owner access. Applications own invitation pages, email or out-of-band delivery, active-source checks before archival, and account recovery. +## Creating an organization with an owner + +For an existing authenticated user creating a business, use +`CreateOwnedOrganization` with `OwnerRole` configured when constructing the +service. Seed that role first. This commits the organization, active membership, +direct organization-wide owner binding, and both creation/access audit events in +one transaction. `CreateOrganization.RequestID` correlates those audit events. +The owner must be an active user whose registration has completed. + +The application authorizes creation and chooses the role; do not accept an owner +role name from a browser or API payload. A customer-owner role can intentionally +have different permissions from an installation's merchant-owner role. Creating +a customer organization grants no authority in any other organization. + +```go +customers, err := organizations.New(store, organizations.Options{ + OwnerRole: "customer.owner", // Application-defined, already seeded. +}) +if err != nil { + return err +} +business, err := customers.CreateOwnedOrganization(ctx, organizations.CreateOrganization{ + Slug: "example-business", Name: "Example Business", OwnerUserID: principal.User.ID, + RequestID: requestID, +}) +``` + +Repositories implement `OwnedOrganizationRepository` to support this operation. +There is no create-then-grant fallback: unsupported adapters return +`ErrOwnedCreationUnsupported`. The older `CreateOrganization` and +`CreatePersonalOrganization` retain their membership-only behavior; configuring +`OwnerRole` does not silently change them. The separate `account` package still +owns atomic public signup, including personal organization and credentials. + +## Membership and access lifecycle + Organizations and teams use optimistic revisions and reversible `active`/`archived` states. Archived objects keep their history but contribute no effective authority. Memberships may be suspended, reactivated, or removed; diff --git a/organizations/organizations.go b/organizations/organizations.go index 14a943b..dc0c6d4 100644 --- a/organizations/organizations.go +++ b/organizations/organizations.go @@ -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 { diff --git a/organizations/owned.go b/organizations/owned.go new file mode 100644 index 0000000..3f02570 --- /dev/null +++ b/organizations/owned.go @@ -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 +} diff --git a/organizations/owned_test.go b/organizations/owned_test.go new file mode 100644 index 0000000..7136620 --- /dev/null +++ b/organizations/owned_test.go @@ -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) + } +}