This commit is contained in:
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
# Changelog
|
# 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
|
## v0.1.0-preview.21 — 2026-09-04
|
||||||
|
|
||||||
- Derive the registered credential algorithm from the verified COSE public key
|
- Derive the registered credential algorithm from the verified COSE public key
|
||||||
|
|||||||
@@ -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
|
deployment. Adopt one boundary at a time; Go compiles and links only the
|
||||||
packages you import.
|
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.
|
> release. Linux is the maintained release platform.
|
||||||
|
|
||||||
## Why Web Foundations?
|
## Why Web Foundations?
|
||||||
@@ -57,14 +57,14 @@ owns—and, just as importantly, what remains application policy.
|
|||||||
Pin the preview in an application module:
|
Pin the preview in an application module:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web@v0.1.0-preview.21
|
go get gamertan.com/web@v0.1.0-preview.22
|
||||||
go mod verify
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
An application may name the first package it intends to adopt:
|
An application may name the first package it intends to adopt:
|
||||||
|
|
||||||
```bash
|
```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
|
The version belongs to the `gamertan.com/web` module. See the
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,16 @@ application concern belongs in the shared module.
|
|||||||
|
|
||||||
## Gamertan accounts and commerce
|
## 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
|
- The account email remains required and unique. Gamertan uses normalized
|
||||||
email as the canonical login identifier and keeps username as a stable public
|
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
|
identity. Until a mail package exists, the application must not describe an
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its
|
|||||||
module checksum:
|
module checksum:
|
||||||
|
|
||||||
```bash
|
```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
|
go mod verify
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta"
|
|||||||
and request the containing module at an exact version:
|
and request the containing module at an exact version:
|
||||||
|
|
||||||
```bash
|
```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
|
Only imported packages are compiled and linked. The packages nevertheless
|
||||||
|
|||||||
@@ -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
|
Applications own invitation pages, email or out-of-band delivery, active-source
|
||||||
checks before archival, and account recovery.
|
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
|
Organizations and teams use optimistic revisions and reversible
|
||||||
`active`/`archived` states. Archived objects keep their history but contribute
|
`active`/`archived` states. Archived objects keep their history but contribute
|
||||||
no effective authority. Memberships may be suspended, reactivated, or removed;
|
no effective authority. Memberships may be suspended, reactivated, or removed;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ var (
|
|||||||
ErrPersonalOrganization = errors.New("organizations: personal organization lifecycle is fixed")
|
ErrPersonalOrganization = errors.New("organizations: personal organization lifecycle is fixed")
|
||||||
ErrLastOwner = errors.New("organizations: the last active direct owner must be preserved")
|
ErrLastOwner = errors.New("organizations: the last active direct owner must be preserved")
|
||||||
ErrOwnerAuthority = errors.New("organizations: a current direct owner must manage owner access")
|
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}$`)
|
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
|
||||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
||||||
)
|
)
|
||||||
@@ -156,22 +157,11 @@ func New(repository Repository, options Options) (*Service, error) {
|
|||||||
type CreateOrganization struct {
|
type CreateOrganization struct {
|
||||||
Slug, Name, OwnerUserID string
|
Slug, Name, OwnerUserID string
|
||||||
Personal bool
|
Personal bool
|
||||||
|
RequestID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (service *Service) CreateOrganization(ctx context.Context, input CreateOrganization) (Organization, error) {
|
func (service *Service) CreateOrganization(ctx context.Context, input CreateOrganization) (Organization, error) {
|
||||||
input.Slug = strings.ToLower(strings.TrimSpace(input.Slug))
|
organization, owner, audit, err := service.prepareOrganization(input)
|
||||||
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")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Organization{}, err
|
return Organization{}, err
|
||||||
}
|
}
|
||||||
@@ -181,6 +171,26 @@ func (service *Service) CreateOrganization(ctx context.Context, input CreateOrga
|
|||||||
return organization, nil
|
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) {
|
func (service *Service) CreatePersonalOrganization(ctx context.Context, userID, displayName string) (Organization, error) {
|
||||||
value := make([]byte, 6)
|
value := make([]byte, 6)
|
||||||
if _, err := io.ReadFull(service.random, value); err != nil {
|
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