This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
- 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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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