From d476179148dcd2b9e848428fa20febd67d16fe01 Mon Sep 17 00:00:00 2001 From: Cole Speelman Date: Sat, 5 Sep 2026 00:33:24 -0400 Subject: [PATCH] Guard customer-owned profile and membership transactions Signed-off-by: Cole Speelman --- CHANGELOG.md | 13 +++ README.md | 6 +- authsqlite/organizations.go | 67 ++++++++++- authsqlite/owned_management_test.go | 175 ++++++++++++++++++++++++++++ docs/DOGFOOD.md | 8 ++ docs/GETTING_STARTED.md | 2 +- docs/MODULES.md | 2 +- docs/ORGANIZATIONS.md | 11 ++ organizations/organizations.go | 44 ++++++- organizations/owned.go | 31 +++++ organizations/owned_test.go | 19 +++ scripts/public-snapshot.allow | 1 + 12 files changed, 369 insertions(+), 10 deletions(-) create mode 100644 authsqlite/owned_management_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f6ead7..180bff8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ # Changelog +## v0.1.0-preview.24 — 2026-09-05 + +- Add explicit owner-managed profile and optimistic membership operations. + Current direct ownership is checked inside the SQLite write transaction even + when the target is an ordinary member. Active account/membership, non-personal + organization, last-owner, optimistic state, and atomic audit requirements remain + intact. Existing delegated-administrator APIs retain their behavior. +- Require `OwnerManagedRepository` support without a preflight-only fallback. + No schema migration is added; schema 10 remains current. +- Test revoked, narrowed, suspended, removed and incomplete actor authority, + archived/personal organizations, stale and concurrent submissions, and rollback + of profile, membership, team, role and invitation effects after audit failure. + ## v0.1.0-preview.23 — 2026-09-05 - Add atomic direct organization role sets with optimistic binding IDs, current diff --git a/README.md b/README.md index fae83fa..3922e37 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ router, handlers, HTML, authorization decisions, cache behavior, and deployment. Adopt one boundary at a time; Go compiles and links only the packages you import. -> **Public preview:** `v0.1.0-preview.23`. APIs may change before a stable +> **Public preview:** `v0.1.0-preview.24`. 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.23 +go get gamertan.com/web@v0.1.0-preview.24 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.23 +go get gamertan.com/web/requestmeta@v0.1.0-preview.24 ``` The version belongs to the `gamertan.com/web` module. See the diff --git a/authsqlite/organizations.go b/authsqlite/organizations.go index c6cee8b..86ea294 100644 --- a/authsqlite/organizations.go +++ b/authsqlite/organizations.go @@ -408,6 +408,17 @@ func (store *Store) OrganizationByID(ctx context.Context, organizationID string) } func (store *Store) UpdateOrganization(ctx context.Context, value organizations.Organization, expectedRevision int64, audit organizations.AuditEvent) error { + return store.updateOrganization(ctx, value, expectedRevision, "", audit) +} + +func (store *Store) UpdateOwnedOrganization(ctx context.Context, value organizations.Organization, expectedRevision int64, ownerRole string, audit organizations.AuditEvent) error { + if !safeName(ownerRole) || value.Personal || value.Status != "active" || audit.Action != "organization.update" || audit.ResourceType != "organization" || audit.ResourceID != value.ID { + return errors.New("authsqlite: invalid owner-managed organization update") + } + return store.updateOrganization(ctx, value, expectedRevision, ownerRole, audit) +} + +func (store *Store) updateOrganization(ctx context.Context, value organizations.Organization, expectedRevision int64, ownerRole string, audit organizations.AuditEvent) error { if !validOrganization(value) || expectedRevision < 1 || value.Revision != expectedRevision+1 || !validOrganizationAudit(audit, value.ID) { return errors.New("authsqlite: invalid organization update") } @@ -416,6 +427,11 @@ func (store *Store) UpdateOrganization(ctx context.Context, value organizations. return err } defer tx.Rollback() + if ownerRole != "" { + if err = lockOrganizationOwner(ctx, tx, value.ID, audit.ActorUserID, ownerRole); err != nil { + return err + } + } result, err := tx.ExecContext(ctx, `UPDATE gwf_organizations SET slug=?,name=?,status=?,revision=?,updated_at=? WHERE id=? AND revision=?`, value.Slug, value.Name, value.Status, value.Revision, value.UpdatedAt.Unix(), value.ID, expectedRevision) if err != nil { return err @@ -535,6 +551,14 @@ func (store *Store) SetMembershipStatus(ctx context.Context, organizationID, use } func (store *Store) ChangeMembershipStatus(ctx context.Context, input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent) error { + return store.changeMembershipStatus(ctx, input, ownerRole, audit, false) +} + +func (store *Store) ChangeOwnedMembershipStatus(ctx context.Context, input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent) error { + return store.changeMembershipStatus(ctx, input, ownerRole, audit, true) +} + +func (store *Store) changeMembershipStatus(ctx context.Context, input organizations.MembershipStatusChange, ownerRole string, audit organizations.AuditEvent, requireOwner bool) error { if !validMembershipStatusChange(input, ownerRole, audit) { return organizations.ErrMembershipNotFound } @@ -543,7 +567,12 @@ func (store *Store) ChangeMembershipStatus(ctx context.Context, input organizati return err } defer tx.Rollback() - if err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID); err != nil { + if requireOwner { + err = lockOrganizationOwner(ctx, tx, input.OrganizationID, input.ActorUserID, ownerRole) + } else { + err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID) + } + if err != nil { return err } current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID) @@ -620,6 +649,14 @@ func (store *Store) RemoveMembership(ctx context.Context, organizationID, userID } func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent) error { + return store.removeMembershipIfCurrent(ctx, input, ownerRole, audit, false) +} + +func (store *Store) RemoveOwnedMembershipIfCurrent(ctx context.Context, input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent) error { + return store.removeMembershipIfCurrent(ctx, input, ownerRole, audit, true) +} + +func (store *Store) removeMembershipIfCurrent(ctx context.Context, input organizations.MembershipRemoval, ownerRole string, audit organizations.AuditEvent, requireOwner bool) error { if !validMembershipRemoval(input, ownerRole, audit) { return organizations.ErrMembershipNotFound } @@ -628,7 +665,12 @@ func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organiz return err } defer tx.Rollback() - if err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID); err != nil { + if requireOwner { + err = lockOrganizationOwner(ctx, tx, input.OrganizationID, input.ActorUserID, ownerRole) + } else { + err = lockActiveMembershipActor(ctx, tx, input.OrganizationID, input.ActorUserID) + } + if err != nil { return err } current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID) @@ -691,6 +733,27 @@ func lockActiveMembershipActor(ctx context.Context, tx *sql.Tx, organizationID, return nil } +func lockOrganizationOwner(ctx context.Context, tx *sql.Tx, organizationID, actorUserID, ownerRole string) error { + if err := lockActiveMembershipActor(ctx, tx, organizationID, actorUserID); err != nil { + return err + } + var personal bool + if err := tx.QueryRowContext(ctx, `SELECT personal FROM gwf_organizations WHERE id=?`, organizationID).Scan(&personal); err != nil { + return err + } + if personal { + return organizations.ErrPersonalOrganization + } + owner, err := hasDirectOwnerRole(ctx, tx, organizationID, actorUserID, ownerRole) + if err != nil { + return err + } + if !owner { + return organizations.ErrOwnerAuthority + } + return nil +} + func membershipStatus(ctx context.Context, tx *sql.Tx, organizationID, userID string) (string, error) { var status string if err := tx.QueryRowContext(ctx, `SELECT status FROM gwf_organization_memberships WHERE organization_id=? AND user_id=?`, organizationID, userID).Scan(&status); err != nil { diff --git a/authsqlite/owned_management_test.go b/authsqlite/owned_management_test.go new file mode 100644 index 0000000..3839ca8 --- /dev/null +++ b/authsqlite/owned_management_test.go @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MPL-2.0 + +package authsqlite + +import ( + "context" + "errors" + "testing" + + "gamertan.com/web/organizations" +) + +type ownedOperation struct { + name, action string + apply func(context.Context, roleSetFixture, string, string) error +} + +func ownedOperations() []ownedOperation { + return []ownedOperation{ + {"profile", "organization.update", func(ctx context.Context, f roleSetFixture, actor, _ string) error { + _, err := f.organizations.UpdateOwnedOrganization(ctx, organizations.UpdateOrganization{ID: f.org.ID, Slug: "updated-business", Name: "Updated business", ActorUserID: actor, ExpectedRevision: 1, RequestID: "request-profile"}) + return err + }}, + {"suspend", "membership.suspended", func(ctx context.Context, f roleSetFixture, actor, target string) error { + return f.organizations.ChangeOwnedMembershipStatus(ctx, organizations.MembershipStatusChange{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, ExpectedStatus: "active", Status: "suspended", RequestID: "request-status"}) + }}, + {"remove", "membership.remove", func(ctx context.Context, f roleSetFixture, actor, target string) error { + return f.organizations.RemoveOwnedMembershipIfCurrent(ctx, organizations.MembershipRemoval{OrganizationID: f.org.ID, UserID: target, ActorUserID: actor, ExpectedStatus: "active", RequestID: "request-remove"}) + }}, + } +} + +func TestOwnedManagementRechecksActorInWriteTransaction(t *testing.T) { + for _, operation := range ownedOperations() { + for _, change := range []struct{ name, sql string }{ + {"role revoked", `UPDATE gwf_access_bindings SET revoked_at=2100 WHERE subject_id='customer-12345'`}, + {"role narrowed", `UPDATE gwf_access_bindings SET project_id=(SELECT id FROM gwf_projects LIMIT 1) WHERE subject_id='customer-12345'`}, + {"actor suspended", `UPDATE gwf_organization_memberships SET status='suspended' WHERE user_id='customer-12345'`}, + {"actor removed", `DELETE FROM gwf_organization_memberships WHERE user_id='customer-12345'`}, + {"account disabled", `UPDATE gwf_users SET status='disabled' WHERE id='customer-12345'`}, + {"registration incomplete", `UPDATE gwf_users SET registration_pending=1 WHERE id='customer-12345'`}, + {"organization archived", `UPDATE gwf_organizations SET status='archived'`}, + {"personal organization", `UPDATE gwf_organizations SET personal=1,personal_owner_user_id='customer-12345'`}, + } { + t.Run(operation.name+"/"+change.name, func(t *testing.T) { + f := newRoleSetFixture(t) + f.addMember(t) + if change.name == "role narrowed" { + if _, err := f.organizations.CreateProject(t.Context(), organizations.CreateProject{OrganizationID: f.org.ID, Slug: "project", Name: "Project"}); err != nil { + t.Fatal(err) + } + } + // Model a change committed after the caller displayed/authorized the + // operation. The repository must not rely on that earlier decision. + if _, err := f.store.db.Exec(change.sql); err != nil { + t.Fatal(err) + } + if err := operation.apply(t.Context(), f, roleOwner, roleMember); err == nil { + t.Fatal("stale owner authority accepted") + } + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 0) + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='active'`, roleMember, 1) + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organizations WHERE id=? AND revision=1`, f.org.ID, 1) + }) + } + } +} + +func TestOwnedManagementDoesNotInheritDelegatedAdministratorSemantics(t *testing.T) { + for _, operation := range ownedOperations() { + t.Run(operation.name, func(t *testing.T) { + f := newRoleSetFixture(t) + f.addMember(t) + if err := operation.apply(t.Context(), f, roleMember, roleMember); !errors.Is(err, organizations.ErrOwnerAuthority) { + t.Fatalf("non-owner management: %v", err) + } + if err := operation.apply(t.Context(), f, roleOwner, roleMember); err != nil { + t.Fatal(err) + } + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 1) + if err := operation.apply(t.Context(), f, roleOwner, roleMember); err == nil { + t.Fatal("replayed mutation accepted") + } + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 1) + }) + } + // Legacy callers still authorize non-owner administrative operations in + // their application policy; the new explicit methods do not alter that API. + f := newRoleSetFixture(t) + f.addMember(t) + err := f.organizations.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleMember, ExpectedStatus: "active", Status: "suspended"}) + if err != nil { + t.Fatalf("delegated legacy operation changed: %v", err) + } +} + +func TestOwnedMembershipPreservesLastOwnerAndRestoresSuspendedMember(t *testing.T) { + f := newRoleSetFixture(t) + f.addMember(t) + for _, operation := range ownedOperations()[1:] { + if err := operation.apply(t.Context(), f, roleOwner, roleOwner); !errors.Is(err, organizations.ErrLastOwner) { + t.Fatalf("%s last owner: %v", operation.name, err) + } + } + if err := ownedOperations()[1].apply(t.Context(), f, roleOwner, roleMember); err != nil { + t.Fatal(err) + } + input := organizations.MembershipStatusChange{OrganizationID: f.org.ID, UserID: roleMember, ActorUserID: roleOwner, ExpectedStatus: "suspended", Status: "active"} + if err := f.organizations.ChangeOwnedMembershipStatus(t.Context(), input); err != nil { + t.Fatal(err) + } + if err := f.organizations.ChangeOwnedMembershipStatus(t.Context(), input); !errors.Is(err, organizations.ErrRevisionConflict) { + t.Fatalf("stale reactivation: %v", err) + } + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='active'`, roleMember, 1) +} + +func TestOwnedManagementRollsBackWithAuditFailure(t *testing.T) { + for _, operation := range ownedOperations() { + t.Run(operation.name, func(t *testing.T) { + f := newRoleSetFixture(t) + f.addMember(t) + _, pending := f.invite(t, "buyer") + team, err := f.organizations.CreateTeam(t.Context(), organizations.CreateTeam{OrganizationID: f.org.ID, Slug: "team", Name: "Team", ActorUserID: roleOwner}) + if err != nil { + t.Fatal(err) + } + if err = f.organizations.AddTeamMember(t.Context(), team.ID, roleMember, roleOwner); err != nil { + t.Fatal(err) + } + if _, err = f.store.db.Exec(`CREATE TRIGGER reject_owned_audit BEFORE INSERT ON gwf_access_audit_events WHEN NEW.action='` + operation.action + `' BEGIN SELECT RAISE(ABORT,'injected audit failure'); END`); err != nil { + t.Fatal(err) + } + if err = operation.apply(t.Context(), f, roleOwner, roleMember); err == nil { + t.Fatal("audit failure accepted") + } + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_memberships WHERE user_id=? AND status='active'`, roleMember, 1) + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_team_members WHERE user_id=?`, roleMember, 1) + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE subject_id=? AND revoked_at IS NULL`, roleMember, 1) + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organizations WHERE id=? AND revision=1`, f.org.ID, 1) + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_organization_invitations WHERE id=? AND revoked_at IS NULL`, pending.ID, 1) + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 0) + }) + } +} + +func TestConcurrentOwnedManagementHasOneWinner(t *testing.T) { + for _, operation := range ownedOperations() { + t.Run(operation.name, func(t *testing.T) { + f := newRoleSetFixture(t) + f.addMember(t) + start, results := make(chan struct{}), make(chan error, 2) + for range 2 { + go func() { <-start; results <- operation.apply(t.Context(), f, roleOwner, roleMember) }() + } + close(start) + success, stale := 0, 0 + for range 2 { + err := <-results + switch { + case err == nil: + success++ + case errors.Is(err, organizations.ErrRevisionConflict), errors.Is(err, organizations.ErrMembershipNotFound): + stale++ + default: + t.Fatalf("concurrent mutation: %v", err) + } + } + if success != 1 || stale != 1 { + t.Fatalf("success=%d stale=%d", success, stale) + } + assertCount(t, f.store, `SELECT COUNT(*) FROM gwf_access_audit_events WHERE action=?`, operation.action, 1) + }) + } +} diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md index e73ec0b..72b127a 100644 --- a/docs/DOGFOOD.md +++ b/docs/DOGFOOD.md @@ -8,6 +8,14 @@ application concern belongs in the shared module. ## Gamertan accounts and commerce +- Customer profile and membership editing requires current ownership for every + write, not just changes involving another owner. The existing generic methods + intentionally permit application-authorized delegated administrators, so an + application preflight alone would leave a demotion race. Explicit owner-managed + methods now share their transactional cores while rechecking current direct + ownership before any write. Tests cover stale authority and optimistic state, + last-owner protection, concurrent winners, and audit-failure rollback. No extra + passkey ceremony or database migration is needed for this invariant. - A customer may need both purchasing and billing access. Replacing one role at a time would create partial permission states and misleading audit history. The role-set extension commits all direct roles together with optimistic diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 4eae2f1..8babbb5 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -26,7 +26,7 @@ The packages are ordinary Go imports. Pin the current preview and verify its module checksum: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.23 +go get gamertan.com/web/requestmeta@v0.1.0-preview.24 go mod verify ``` diff --git a/docs/MODULES.md b/docs/MODULES.md index 3bfc778..e2bd6ea 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -18,7 +18,7 @@ import "gamertan.com/web/requestmeta" and request the containing module at an exact version: ```bash -go get gamertan.com/web/requestmeta@v0.1.0-preview.23 +go get gamertan.com/web/requestmeta@v0.1.0-preview.24 ``` Only imported packages are compiled and linked. The packages nevertheless diff --git a/docs/ORGANIZATIONS.md b/docs/ORGANIZATIONS.md index bea6c3b..e84bc1a 100644 --- a/docs/ORGANIZATIONS.md +++ b/docs/ORGANIZATIONS.md @@ -75,6 +75,17 @@ owns atomic public signup, including personal organization and credentials. ## Membership and access lifecycle +For customer-owned businesses where only owners manage profiles and members, +use `UpdateOwnedOrganization`, `ChangeOwnedMembershipStatus`, and +`RemoveOwnedMembershipIfCurrent`. They recheck the service's configured direct +owner after acquiring the write lock, including when the target is a non-owner. +The actor must remain active and fully registered, their membership must remain +active, and the organization must be active and non-personal. Profile changes +only update name and slug; archiving and personal-account lifecycle are separate. +Custom adapters must implement `OwnerManagedRepository`, with no fallback to an +application preflight followed by an unguarded write. These additions retain +schema 10 and do not change the existing delegated-administration methods below. + Organizations and teams use optimistic revisions and reversible `active`/`archived` states. Archived objects keep their history but contribute no effective authority. Memberships may be suspended, reactivated, or removed; diff --git a/organizations/organizations.go b/organizations/organizations.go index cc40dfa..bc08ec4 100644 --- a/organizations/organizations.go +++ b/organizations/organizations.go @@ -414,6 +414,14 @@ type UpdateOrganization struct { } func (service *Service) UpdateOrganization(ctx context.Context, input UpdateOrganization) (Organization, error) { + return service.updateOrganization(ctx, input, false) +} + +func (service *Service) updateOrganization(ctx context.Context, input UpdateOrganization, requireOwner bool) (Organization, error) { + ownedRepository, ownedSupported := service.repository.(OwnerManagedRepository) + if requireOwner && (!ownedSupported || service.ownerRole == "") { + return Organization{}, ErrOwnedManagementUnsupported + } input.Slug, input.Name = strings.ToLower(strings.TrimSpace(input.Slug)), strings.TrimSpace(input.Name) if !idPattern.MatchString(input.ID) || !idPattern.MatchString(input.ActorUserID) || !slugPattern.MatchString(input.Slug) || !bounded(input.Name, 128) || input.ExpectedRevision < 1 || !boundedOptional(input.RequestID, 128) { return Organization{}, errors.New("organizations: invalid organization update") @@ -422,12 +430,20 @@ func (service *Service) UpdateOrganization(ctx context.Context, input UpdateOrga if err != nil { return Organization{}, err } + if requireOwner && value.Personal { + return Organization{}, ErrPersonalOrganization + } value.Slug, value.Name, value.Revision, value.UpdatedAt = input.Slug, input.Name, input.ExpectedRevision+1, service.now().UTC() audit, err := service.auditWithRequest(input.ActorUserID, value.ID, "organization.update", "organization", value.ID, input.RequestID, "Organization details updated") if err != nil { return Organization{}, err } - if err = service.repository.UpdateOrganization(ctx, value, input.ExpectedRevision, audit); err != nil { + if requireOwner { + err = ownedRepository.UpdateOwnedOrganization(ctx, value, input.ExpectedRevision, service.ownerRole, audit) + } else { + err = service.repository.UpdateOrganization(ctx, value, input.ExpectedRevision, audit) + } + if err != nil { return Organization{}, err } return value, nil @@ -531,6 +547,10 @@ type MembershipStatusChange struct { } func (service *Service) ChangeMembershipStatus(ctx context.Context, input MembershipStatusChange) error { + return service.changeMembershipStatus(ctx, input, false) +} + +func (service *Service) changeMembershipStatus(ctx context.Context, input MembershipStatusChange, requireOwner bool) error { if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) || (input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") || (input.Status != "active" && input.Status != "suspended") || input.Status == input.ExpectedStatus || @@ -540,14 +560,21 @@ func (service *Service) ChangeMembershipStatus(ctx context.Context, input Member if service.ownerRole == "" { return errors.New("organizations: owner role is required for membership lifecycle changes") } + ownedRepository, ownedSupported := service.repository.(OwnerManagedRepository) + if requireOwner && !ownedSupported { + return ErrOwnedManagementUnsupported + } repository, ok := service.repository.(OptimisticMembershipRepository) - if !ok { + if !requireOwner && !ok { return ErrMembershipLifecycleUnsupported } audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership."+input.Status, "membership", input.UserID, input.RequestID, "Organization membership set to "+input.Status) if err != nil { return err } + if requireOwner { + return ownedRepository.ChangeOwnedMembershipStatus(ctx, input, service.ownerRole, audit) + } return repository.ChangeMembershipStatus(ctx, input, service.ownerRole, audit) } @@ -572,6 +599,10 @@ type MembershipRemoval struct { } func (service *Service) RemoveMembershipIfCurrent(ctx context.Context, input MembershipRemoval) error { + return service.removeMembershipIfCurrent(ctx, input, false) +} + +func (service *Service) removeMembershipIfCurrent(ctx context.Context, input MembershipRemoval, requireOwner bool) error { if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) || (input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") || !boundedOptional(input.RequestID, 128) { return errors.New("organizations: invalid membership removal") @@ -579,14 +610,21 @@ func (service *Service) RemoveMembershipIfCurrent(ctx context.Context, input Mem if service.ownerRole == "" { return errors.New("organizations: owner role is required for membership lifecycle changes") } + ownedRepository, ownedSupported := service.repository.(OwnerManagedRepository) + if requireOwner && !ownedSupported { + return ErrOwnedManagementUnsupported + } repository, ok := service.repository.(OptimisticMembershipRepository) - if !ok { + if !requireOwner && !ok { return ErrMembershipLifecycleUnsupported } audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership.remove", "membership", input.UserID, input.RequestID, "Organization membership removed") if err != nil { return err } + if requireOwner { + return ownedRepository.RemoveOwnedMembershipIfCurrent(ctx, input, service.ownerRole, audit) + } return repository.RemoveMembershipIfCurrent(ctx, input, service.ownerRole, audit) } diff --git a/organizations/owned.go b/organizations/owned.go index 3f02570..67db040 100644 --- a/organizations/owned.go +++ b/organizations/owned.go @@ -25,6 +25,37 @@ type OwnedOrganizationRepository interface { CreateOwnedOrganization(context.Context, OwnedOrganization) error } +var ErrOwnedManagementUnsupported = errors.New("organizations: atomic owner-managed updates are unsupported") + +// OwnerManagedRepository rechecks the configured direct owner in the same +// transaction as profile and membership writes. These explicit operations do +// not change legacy methods used by applications with delegated administrators. +// Implementations must also preserve optimistic state, last-owner protection, +// and audit atomicity. There is no preflight-only fallback. +type OwnerManagedRepository interface { + UpdateOwnedOrganization(context.Context, Organization, int64, string, AuditEvent) error + ChangeOwnedMembershipStatus(context.Context, MembershipStatusChange, string, AuditEvent) error + RemoveOwnedMembershipIfCurrent(context.Context, MembershipRemoval, string, AuditEvent) error +} + +// UpdateOwnedOrganization changes a non-personal organization's name and slug. +// OwnerRole comes from trusted service configuration, not submitted form data. +func (service *Service) UpdateOwnedOrganization(ctx context.Context, input UpdateOrganization) (Organization, error) { + return service.updateOrganization(ctx, input, true) +} + +// ChangeOwnedMembershipStatus requires a current owner even when the target +// member is not an owner. It preserves the last active owner. +func (service *Service) ChangeOwnedMembershipStatus(ctx context.Context, input MembershipStatusChange) error { + return service.changeMembershipStatus(ctx, input, true) +} + +// RemoveOwnedMembershipIfCurrent removes only the displayed membership state, +// with current owner authority checked in the write transaction. +func (service *Service) RemoveOwnedMembershipIfCurrent(ctx context.Context, input MembershipRemoval) error { + return service.removeMembershipIfCurrent(ctx, input, true) +} + // 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. diff --git a/organizations/owned_test.go b/organizations/owned_test.go index 7136620..b14be12 100644 --- a/organizations/owned_test.go +++ b/organizations/owned_test.go @@ -92,3 +92,22 @@ func TestOwnedOrganizationDoesNotReturnUncommittedIdentity(t *testing.T) { t.Fatalf("organization=%+v calls=%d err=%v", organization, repository.calls, err) } } + +func TestOwnedManagementHasNoPreflightOnlyFallback(t *testing.T) { + service, err := New(&repositoryStub{}, Options{OwnerRole: "customer.owner"}) + if err != nil { + t.Fatal(err) + } + _, err = service.UpdateOwnedOrganization(t.Context(), UpdateOrganization{ID: "organization-12345", Slug: "business", Name: "Business", ActorUserID: "customer-12345", ExpectedRevision: 1}) + if !errors.Is(err, ErrOwnedManagementUnsupported) { + t.Fatalf("profile fallback: %v", err) + } + err = service.ChangeOwnedMembershipStatus(t.Context(), MembershipStatusChange{OrganizationID: "organization-12345", UserID: "member-12345", ActorUserID: "customer-12345", ExpectedStatus: "active", Status: "suspended"}) + if !errors.Is(err, ErrOwnedManagementUnsupported) { + t.Fatalf("status fallback: %v", err) + } + err = service.RemoveOwnedMembershipIfCurrent(t.Context(), MembershipRemoval{OrganizationID: "organization-12345", UserID: "member-12345", ActorUserID: "customer-12345", ExpectedStatus: "active"}) + if !errors.Is(err, ErrOwnedManagementUnsupported) { + t.Fatalf("removal fallback: %v", err) + } +} diff --git a/scripts/public-snapshot.allow b/scripts/public-snapshot.allow index 2f29de5..5384e6d 100644 --- a/scripts/public-snapshot.allow +++ b/scripts/public-snapshot.allow @@ -50,6 +50,7 @@ authsqlite/bootstrap_test.go authsqlite/organizations.go authsqlite/owned_organization.go authsqlite/owned_organization_test.go +authsqlite/owned_management_test.go authsqlite/role_sets_test.go authsqlite/passkey.go authsqlite/passkey_test.go