Compare commits

...
Author SHA1 Message Date
gamertan ed0cc8ceff Add atomic owned organization creation
verify / verify (push) Successful in 3m48s
2026-09-04 23:19:05 -04:00
gamertan b1710e08b8 Verify passkey algorithms from COSE keys
verify / verify (push) Successful in 3m38s
2026-09-04 12:19:15 -04:00
gamertan 3fe1547a5b Protect owner invitation authority
verify / verify (push) Successful in 3m40s
2026-09-04 00:30:29 -04:00
17 changed files with 833 additions and 30 deletions
+35
View File
@@ -2,6 +2,41 @@
# 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
- Derive the registered credential algorithm from the verified COSE public key
embedded in authenticator data instead of the optional browser
`publicKeyAlgorithm` convenience member.
- Preserve the ES256-only policy while accepting standards-compliant response
serializers that omit redundant response conveniences, including the
Bitwarden/Vaultwarden passkey flow exercised through Gamertan.
- Add regression coverage for an ES256 credential whose convenience algorithm
is absent, plus malformed and non-ES256 credential rejection.
## v0.1.0-preview.20 — 2026-09-04
- Extend the direct-owner transaction boundary to invitations. Creating or
revoking an invitation that grants the configured owner role now requires
the actor to remain an active direct owner after the SQLite write lock is
acquired.
- Preserve application-owned permission policy for ordinary invitations while
preventing a broad access-management role, stale ceremony, or alternate
repository call from creating or cancelling owner access.
- Pass the configured owner role explicitly through invitation repository
mutations so non-SQLite adapters cannot silently omit the invariant.
## v0.1.0-preview.19 — 2026-09-04 ## v0.1.0-preview.19 — 2026-09-04
- Require a current active direct owner for every direct-role transition to or - Require a current active direct owner for every direct-role transition to or
+3 -3
View File
@@ -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.19`. 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.19 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.19 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
+35 -4
View File
@@ -123,8 +123,8 @@ func (store *Store) CreateApplicationService(ctx context.Context, application or
return nil return nil
} }
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, audit organizations.AuditEvent) error { func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error {
if !opaqueID(invitation.ID) || zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.DirectRole != "" && !safeName(invitation.DirectRole) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) { if !opaqueID(invitation.ID) || zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.DirectRole != "" && !safeName(invitation.DirectRole) || ownerRole != "" && !safeName(ownerRole) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) {
return errors.New("authsqlite: invalid invitation") return errors.New("authsqlite: invalid invitation")
} }
teamIDs, err := json.Marshal(invitation.TeamIDs) teamIDs, err := json.Marshal(invitation.TeamIDs)
@@ -136,6 +136,18 @@ func (store *Store) CreateInvitation(ctx context.Context, invitation organizatio
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID); err != nil {
return err
}
if ownerRole != "" && invitation.DirectRole == ownerRole {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, ownerRole)
if ownerErr != nil {
return ownerErr
}
if !actorIsOwner {
return organizations.ErrOwnerAuthority
}
}
if err = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil { if err = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil {
return err return err
} }
@@ -743,8 +755,8 @@ func validateInvitationTeams(ctx context.Context, tx *sql.Tx, organizationID str
return nil return nil
} }
func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invitationID string, revokedAt time.Time, audit organizations.AuditEvent) error { func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invitationID, ownerRole string, revokedAt time.Time, audit organizations.AuditEvent) error {
if !opaqueID(organizationID) || !opaqueID(invitationID) || revokedAt.IsZero() || !validOrganizationAudit(audit, organizationID) { if !opaqueID(organizationID) || !opaqueID(invitationID) || ownerRole != "" && !safeName(ownerRole) || revokedAt.IsZero() || !validOrganizationAudit(audit, organizationID) {
return organizations.ErrInvitationNotFound return organizations.ErrInvitationNotFound
} }
tx, err := store.db.BeginTx(ctx, nil) tx, err := store.db.BeginTx(ctx, nil)
@@ -752,6 +764,25 @@ func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invita
return err return err
} }
defer tx.Rollback() defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
return err
}
var directRole string
if err = tx.QueryRowContext(ctx, `SELECT direct_role FROM gwf_organization_invitations WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, organizationID, invitationID).Scan(&directRole); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return organizations.ErrInvitationNotFound
}
return err
}
if ownerRole != "" && directRole == ownerRole {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, audit.ActorUserID, ownerRole)
if ownerErr != nil {
return ownerErr
}
if !actorIsOwner {
return organizations.ErrOwnerAuthority
}
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=? WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, revokedAt.Unix(), organizationID, invitationID) result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=? WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, revokedAt.Unix(), organizationID, invitationID)
if err != nil { if err != nil {
return err return err
+72
View File
@@ -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)
+264
View File
@@ -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)
})
}
}
+74
View File
@@ -653,6 +653,80 @@ func TestOrganizationRoleAdministrationIsAtomicAndProtectsOwners(t *testing.T) {
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=?`, replacement.ID, 0) assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=?`, replacement.ID, 0)
} }
func TestOwnerInvitationsRequireDirectOwnerAuthority(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "invitation.owner", Email: "invitation-owner@example.test", DisplayName: "Invitation Owner", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
manager, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "invitation.manager", Email: "invitation-manager@example.test", DisplayName: "Invitation Manager", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "invitation-authority", Name: "Invitation Authority", OwnerUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
raw, _, err := organizationService.Invite(t.Context(), organization.ID, manager.Email, owner.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
if err = organizationService.AcceptInvitation(t.Context(), raw, manager.ID); err != nil {
t.Fatal(err)
}
policy := access.Policy{
Roles: map[string]string{"owner": "Owner", "site-admin": "Site administrator", "viewer": "Viewer"},
Permissions: map[string]string{"site.access.manage": "Manage site access"},
Grants: map[string][]string{"owner": {"site.access.manage"}, "site-admin": {"site.access.manage"}, "viewer": {}},
}
accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: manager.ID, Role: "site-admin", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
if _, _, err = organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "blocked-owner@example.test", InvitedByUserID: manager.ID, DirectRole: "owner", Lifetime: time.Hour}); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner owner invitation err=%v", err)
}
_, viewerInvitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "viewer@example.test", InvitedByUserID: manager.ID, DirectRole: "viewer", Lifetime: time.Hour})
if err != nil {
t.Fatalf("non-owner ordinary invitation err=%v", err)
}
_, ownerInvitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "new-owner@example.test", InvitedByUserID: owner.ID, DirectRole: "owner", Lifetime: time.Hour})
if err != nil {
t.Fatalf("owner invitation err=%v", err)
}
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, ownerInvitation.ID, manager.ID, "request-manager-owner-revoke"); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner owner invitation revocation err=%v", err)
}
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, viewerInvitation.ID, manager.ID, "request-manager-viewer-revoke"); err != nil {
t.Fatalf("ordinary invitation revocation err=%v", err)
}
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, ownerInvitation.ID, owner.ID, "request-owner-owner-revoke"); err != nil {
t.Fatalf("owner invitation revocation err=%v", err)
}
}
func TestOptimisticMembershipLifecycleIsSerializedAndAtomic(t *testing.T) { func TestOptimisticMembershipLifecycleIsSerializedAndAtomic(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db")) store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil { if err != nil {
+65
View File
@@ -0,0 +1,65 @@
// SPDX-License-Identifier: MPL-2.0
package authwebauthn
import (
"crypto/ecdh"
"crypto/rand"
"errors"
"testing"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncbor"
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
)
func TestEnforceCredentialAlgorithmUsesVerifiedCOSEKey(t *testing.T) {
privateKey, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
publicKey := privateKey.PublicKey().Bytes()
encoded, err := webauthncbor.Marshal(map[int64]any{
1: int64(webauthncose.EllipticKey),
3: int64(webauthncose.AlgES256),
-1: int64(webauthncose.P256),
-2: publicKey[1:33],
-3: publicKey[33:65],
})
if err != nil {
t.Fatal(err)
}
credential := &wa.Credential{
PublicKey: encoded,
// This value is absent when a standards-compliant client serializes the
// mandatory attestation object without optional response conveniences.
Attestation: wa.CredentialAttestation{PublicKeyAlgorithm: 0},
}
if err = enforceCredentialAlgorithm(credential); err != nil {
t.Fatalf("verified ES256 COSE key rejected when convenience value was absent: %v", err)
}
}
func TestEnforceCredentialAlgorithmRejectsOtherOrInvalidKeys(t *testing.T) {
rsaKey, err := webauthncbor.Marshal(map[int64]any{
1: int64(webauthncose.RSAKey),
3: int64(webauthncose.AlgRS256),
-1: []byte{0xff},
-2: []byte{0x01, 0x00, 0x01},
})
if err != nil {
t.Fatal(err)
}
for name, credential := range map[string]*wa.Credential{
"nil": nil,
"malformed": {PublicKey: []byte("not-cose")},
"rsa": {PublicKey: rsaKey},
} {
t.Run(name, func(t *testing.T) {
if err := enforceCredentialAlgorithm(credential); !errors.Is(err, ErrUnsupportedCredential) {
t.Fatalf("error=%v", err)
}
})
}
}
+21 -1
View File
@@ -375,7 +375,7 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
if err != nil { if err != nil {
return Credential{}, fmt.Errorf("authwebauthn: verify registration: %w", err) return Credential{}, fmt.Errorf("authwebauthn: verify registration: %w", err)
} }
if verified.Attestation.PublicKeyAlgorithm != int64(webauthncose.AlgES256) { if err = enforceCredentialAlgorithm(verified); err != nil {
return Credential{}, ErrUnsupportedCredential return Credential{}, ErrUnsupportedCredential
} }
encoded, err := json.Marshal(verified) encoded, err := json.Marshal(verified)
@@ -407,6 +407,26 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
return record, nil return record, nil
} }
// enforceCredentialAlgorithm derives the algorithm from the verified COSE key
// carried inside authenticator data. AuthenticatorAttestationResponse's
// publicKeyAlgorithm member is an optional browser convenience value: clients
// that serialize the mandatory attestation object directly may omit it, and it
// is not the cryptographically authoritative representation.
func enforceCredentialAlgorithm(credential *wa.Credential) error {
if credential == nil {
return ErrUnsupportedCredential
}
parsed, err := webauthncose.ParsePublicKey(credential.PublicKey)
if err != nil {
return ErrUnsupportedCredential
}
key, ok := parsed.(webauthncose.EC2PublicKeyData)
if !ok || key.Algorithm != int64(webauthncose.AlgES256) {
return ErrUnsupportedCredential
}
return nil
}
func (service *Service) BeginLogin(ctx context.Context) (BeginResult, error) { func (service *Service) BeginLogin(ctx context.Context) (BeginResult, error) {
challenge, err := service.randomBytes(32) challenge, err := service.randomBytes(32)
if err != nil { if err != nil {
+24
View File
@@ -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
@@ -79,3 +89,17 @@ application concern belongs in the shared module.
that invariant into the same SQLite transactions as direct-role and that invariant into the same SQLite transactions as direct-role and
membership changes, while leaving the application's role vocabulary and UI membership changes, while leaving the application's role vocabulary and UI
policy application-owned. policy application-owned.
- Gamertan's invitation work found the same authority boundary before a route
was exposed: Site Admin must be able to invite ordinary staff without being
able to grant or cancel Owner access. Preview 20 passes the configured owner
role into invitation mutations and rechecks a current active direct Owner
after acquiring the SQLite write lock. The application still owns fresh
authentication, recipient delivery, and the one-time secret presentation.
- A real Bitwarden/Vaultwarden owner enrollment reached successful WebAuthn
verification but was rejected by a redundant algorithm check because the
application's direct response serializer omitted the optional browser
`publicKeyAlgorithm` convenience member. Preview 21 keeps ES256-only policy
enforcement but derives it from the verified COSE key embedded in
authenticator data. This makes the server independent of serializer-specific
convenience fields without weakening origin, challenge, user-verification,
or algorithm validation.
+1 -1
View File
@@ -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.19 go get gamertan.com/web/requestmeta@v0.1.0-preview.22
go mod verify go mod verify
``` ```
+1 -1
View File
@@ -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.19 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
+40
View File
@@ -12,9 +12,49 @@ expiring, single-use invitations. An invitation may carry one direct role and
up to sixteen reviewed team memberships. Acceptance verifies that the up to sixteen reviewed team memberships. Acceptance verifies that the
authenticated user's normalized email matches and applies the membership, authenticated user's normalized email matches and applies the membership,
role, teams, consumption marker, and audit event in one transaction. role, teams, consumption marker, and audit event in one transaction.
When `OwnerRole` is configured, creating or revoking an invitation carrying
that role additionally requires a current active direct owner inside the same
SQLite transaction. A broad access-management permission may administer
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;
+4
View File
@@ -19,6 +19,10 @@ authorization decisions, session cookie, HTML, and local recovery command.
- Request no attestation conveyance. - Request no attestation conveyance.
- Permit ES256 only until another algorithm has explicit interoperability and - Permit ES256 only until another algorithm has explicit interoperability and
security evidence. security evidence.
- Enforce that policy from the verified COSE public key embedded in
authenticator data. Do not rely on the optional browser
`publicKeyAlgorithm` convenience member: direct standards-compliant response
serializers may omit it even when the attested credential is ES256.
- Store random challenges and verifier session data only behind opaque, - Store random challenges and verifier session data only behind opaque,
single-use ceremony tokens. single-use ceremony tokens.
- Treat clone warnings as audit signals rather than automatic lockout for - Treat clone warnings as audit signals rather than automatic lockout for
+28 -18
View File
@@ -27,7 +27,8 @@ var (
ErrRevisionConflict = errors.New("organizations: revision conflict") ErrRevisionConflict = errors.New("organizations: revision conflict")
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 memberships") 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}$`)
) )
@@ -104,10 +105,10 @@ type Repository interface {
CreateProject(context.Context, Project) error CreateProject(context.Context, Project) error
CreateEnvironment(context.Context, Environment) error CreateEnvironment(context.Context, Environment) error
CreateApplicationService(context.Context, ApplicationService) error CreateApplicationService(context.Context, ApplicationService) error
CreateInvitation(context.Context, Invitation, AuditEvent) error CreateInvitation(context.Context, Invitation, string, AuditEvent) error
InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error) InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error)
Invitations(context.Context, string, int) ([]Invitation, error) Invitations(context.Context, string, int) ([]Invitation, error)
RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error RevokeInvitation(context.Context, string, string, string, time.Time, AuditEvent) error
AcceptInvitation(context.Context, [32]byte, string, time.Time, AuditEvent) error AcceptInvitation(context.Context, [32]byte, string, time.Time, AuditEvent) error
OrganizationMemberships(context.Context, string, int) ([]Membership, error) OrganizationMemberships(context.Context, string, int) ([]Membership, error)
MembershipsForUser(context.Context, string) ([]Membership, error) MembershipsForUser(context.Context, string) ([]Membership, error)
@@ -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 {
@@ -314,7 +324,7 @@ func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAc
if err != nil { if err != nil {
return "", Invitation{}, err return "", Invitation{}, err
} }
if err = service.repository.CreateInvitation(ctx, invitation, audit); err != nil { if err = service.repository.CreateInvitation(ctx, invitation, service.ownerRole, audit); err != nil {
return "", Invitation{}, err return "", Invitation{}, err
} }
return raw, invitation, nil return raw, invitation, nil
@@ -559,7 +569,7 @@ func (service *Service) RevokeInvitation(ctx context.Context, organizationID, in
if err != nil { if err != nil {
return err return err
} }
return service.repository.RevokeInvitation(ctx, organizationID, invitationID, now, audit) return service.repository.RevokeInvitation(ctx, organizationID, invitationID, service.ownerRole, now, audit)
} }
func (service *Service) Repository() Repository { return service.repository } func (service *Service) Repository() Repository { return service.repository }
+2 -2
View File
@@ -100,7 +100,7 @@ func (*repositoryStub) CreateEnvironment(context.Context, Environment) error { r
func (*repositoryStub) CreateApplicationService(context.Context, ApplicationService) error { func (*repositoryStub) CreateApplicationService(context.Context, ApplicationService) error {
return nil return nil
} }
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation, _ AuditEvent) error { func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation, _ string, _ AuditEvent) error {
repository.invitation = invitation repository.invitation = invitation
return nil return nil
} }
@@ -113,7 +113,7 @@ func (repository *repositoryStub) InvitationByDigest(context.Context, [32]byte,
func (*repositoryStub) Invitations(context.Context, string, int) ([]Invitation, error) { func (*repositoryStub) Invitations(context.Context, string, int) ([]Invitation, error) {
return nil, nil return nil, nil
} }
func (*repositoryStub) RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error { func (*repositoryStub) RevokeInvitation(context.Context, string, string, string, time.Time, AuditEvent) error {
return nil return nil
} }
func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time, _ AuditEvent) error { func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time, _ AuditEvent) error {
+70
View File
@@ -0,0 +1,70 @@
// SPDX-License-Identifier: MPL-2.0
package organizations
import (
"context"
"errors"
"gamertan.com/web/access"
)
// OwnedOrganization is one atomic creation command. Implementations must commit
// the organization, membership, direct owner binding, and both audits together.
type OwnedOrganization struct {
Organization Organization
Membership Membership
OwnerBinding access.Binding
OrganizationAudit AuditEvent
AccessAudit access.AuditEvent
}
// OwnedOrganizationRepository extends Repository without changing the legacy
// membership-only CreateOrganization contract. There is no non-atomic fallback.
type OwnedOrganizationRepository interface {
CreateOwnedOrganization(context.Context, OwnedOrganization) error
}
// CreateOwnedOrganization grants the configured OwnerRole to the initial owner
// inside the creation transaction. Applications authorize creation and choose
// OwnerRole when constructing the service, never from a submitted role name.
// The role must already be seeded in the repository.
func (service *Service) CreateOwnedOrganization(ctx context.Context, input CreateOrganization) (Organization, error) {
if service.ownerRole == "" {
return Organization{}, errors.New("organizations: owned creation requires a configured owner role")
}
repository, ok := service.repository.(OwnedOrganizationRepository)
if !ok {
return Organization{}, ErrOwnedCreationUnsupported
}
organization, membership, audit, err := service.prepareOrganization(input)
if err != nil {
return Organization{}, err
}
bindingID, err := token(service.random, 18)
if err != nil {
return Organization{}, err
}
accessAuditID, err := token(service.random, 18)
if err != nil {
return Organization{}, err
}
binding := access.Binding{
ID: bindingID, SubjectKind: access.User, SubjectID: input.OwnerUserID,
Role: service.ownerRole, Scope: access.Scope{OrganizationID: organization.ID},
GrantedBy: input.OwnerUserID, GrantedAt: organization.CreatedAt,
}
accessAudit := access.AuditEvent{
ID: accessAuditID, OrganizationID: organization.ID, ActorUserID: input.OwnerUserID,
Action: "access.binding.grant", ResourceType: "binding", ResourceID: bindingID,
RequestID: input.RequestID, Summary: "Initial organization owner granted",
CreatedAt: organization.CreatedAt,
}
if err = repository.CreateOwnedOrganization(ctx, OwnedOrganization{
Organization: organization, Membership: membership, OwnerBinding: binding,
OrganizationAudit: audit, AccessAudit: accessAudit,
}); err != nil {
return Organization{}, err
}
return organization, nil
}
+94
View File
@@ -0,0 +1,94 @@
// SPDX-License-Identifier: MPL-2.0
package organizations
import (
"context"
"errors"
"strings"
"testing"
"time"
"gamertan.com/web/access"
)
type ownedRepositoryStub struct {
repositoryStub
setup OwnedOrganization
calls int
err error
}
func (repository *ownedRepositoryStub) CreateOwnedOrganization(_ context.Context, setup OwnedOrganization) error {
repository.calls++
repository.setup = setup
return repository.err
}
func TestOwnedOrganizationUsesConfiguredRoleAndAtomicRepository(t *testing.T) {
now := time.Unix(1000, 0).UTC()
repository := &ownedRepositoryStub{}
service, err := New(repository, Options{OwnerRole: "customer.owner", Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
input := CreateOrganization{Slug: " CLIENT-BUSINESS ", Name: " Client Business ", OwnerUserID: "customer-12345", RequestID: "request-creation"}
organization, err := service.CreateOwnedOrganization(t.Context(), input)
if err != nil {
t.Fatal(err)
}
setup := repository.setup
if repository.calls != 1 || repository.organization.ID != "" || setup.Organization != organization || organization.Slug != "client-business" || organization.Name != "Client Business" {
t.Fatalf("unexpected creation: %+v", setup)
}
if setup.Membership.UserID != input.OwnerUserID || setup.OwnerBinding.SubjectKind != access.User || setup.OwnerBinding.SubjectID != input.OwnerUserID || setup.OwnerBinding.Role != "customer.owner" || setup.OwnerBinding.Scope != (access.Scope{OrganizationID: organization.ID}) || setup.OwnerBinding.GrantedBy != input.OwnerUserID {
t.Fatalf("unexpected owner: %+v", setup)
}
if setup.OrganizationAudit.RequestID != input.RequestID || setup.AccessAudit.RequestID != input.RequestID || setup.AccessAudit.ResourceID != setup.OwnerBinding.ID || !setup.OwnerBinding.GrantedAt.Equal(now) {
t.Fatalf("unexpected audits: %+v", setup)
}
}
func TestOwnedOrganizationFailsWithoutAtomicSupport(t *testing.T) {
repository := &repositoryStub{}
service, _ := New(repository, Options{OwnerRole: "customer.owner"})
organization, err := service.CreateOwnedOrganization(t.Context(), CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345"})
if !errors.Is(err, ErrOwnedCreationUnsupported) || organization.ID != "" || repository.organization.ID != "" {
t.Fatalf("non-atomic fallback: organization=%+v err=%v", organization, err)
}
}
func TestOwnedOrganizationRejectsInvalidSetupBeforeStorage(t *testing.T) {
for _, test := range []struct {
name string
role string
request string
random string
}{
{name: "missing role", random: strings.Repeat("a", 200)},
{name: "bad request ID", role: "customer.owner", request: "request\nsecret", random: strings.Repeat("a", 200)},
{name: "random failure", role: "customer.owner", random: strings.Repeat("a", 40)},
} {
t.Run(test.name, func(t *testing.T) {
repository := &ownedRepositoryStub{}
service, err := New(repository, Options{OwnerRole: test.role, Random: strings.NewReader(test.random)})
if err != nil {
t.Fatal(err)
}
organization, err := service.CreateOwnedOrganization(t.Context(), CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345", RequestID: test.request})
if err == nil || organization.ID != "" || repository.calls != 0 {
t.Fatalf("organization=%+v calls=%d err=%v", organization, repository.calls, err)
}
})
}
}
func TestOwnedOrganizationDoesNotReturnUncommittedIdentity(t *testing.T) {
want := errors.New("durability failure")
repository := &ownedRepositoryStub{err: want}
service, _ := New(repository, Options{OwnerRole: "customer.owner"})
organization, err := service.CreateOwnedOrganization(t.Context(), CreateOrganization{Slug: "client-business", Name: "Client Business", OwnerUserID: "customer-12345"})
if !errors.Is(err, want) || organization.ID != "" || repository.calls != 1 {
t.Fatalf("organization=%+v calls=%d err=%v", organization, repository.calls, err)
}
}