Guard customer-owned profile and membership transactions
verify / verify (push) Successful in 4m25s

Signed-off-by: Cole Speelman <crspeelman@gmail.com>
This commit is contained in:
2026-09-05 00:33:24 -04:00
parent f142ac23a9
commit d476179148
12 changed files with 369 additions and 10 deletions
+13
View File
@@ -2,6 +2,19 @@
# Changelog # 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 ## v0.1.0-preview.23 — 2026-09-05
- Add atomic direct organization role sets with optimistic binding IDs, current - Add atomic direct organization role sets with optimistic binding IDs, current
+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.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. > 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.23 go get gamertan.com/web@v0.1.0-preview.24
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.23 go get gamertan.com/web/requestmeta@v0.1.0-preview.24
``` ```
The version belongs to the `gamertan.com/web` module. See the The version belongs to the `gamertan.com/web` module. See the
+65 -2
View File
@@ -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 { 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) { if !validOrganization(value) || expectedRevision < 1 || value.Revision != expectedRevision+1 || !validOrganizationAudit(audit, value.ID) {
return errors.New("authsqlite: invalid organization update") return errors.New("authsqlite: invalid organization update")
} }
@@ -416,6 +427,11 @@ func (store *Store) UpdateOrganization(ctx context.Context, value organizations.
return err return err
} }
defer tx.Rollback() 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) 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 { if err != nil {
return err 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 { 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) { if !validMembershipStatusChange(input, ownerRole, audit) {
return organizations.ErrMembershipNotFound return organizations.ErrMembershipNotFound
} }
@@ -543,7 +567,12 @@ func (store *Store) ChangeMembershipStatus(ctx context.Context, input organizati
return err return err
} }
defer tx.Rollback() 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 return err
} }
current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID) 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 { 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) { if !validMembershipRemoval(input, ownerRole, audit) {
return organizations.ErrMembershipNotFound return organizations.ErrMembershipNotFound
} }
@@ -628,7 +665,12 @@ func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organiz
return err return err
} }
defer tx.Rollback() 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 return err
} }
current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID) current, err := membershipStatus(ctx, tx, input.OrganizationID, input.UserID)
@@ -691,6 +733,27 @@ func lockActiveMembershipActor(ctx context.Context, tx *sql.Tx, organizationID,
return nil 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) { func membershipStatus(ctx context.Context, tx *sql.Tx, organizationID, userID string) (string, error) {
var status string 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 { if err := tx.QueryRowContext(ctx, `SELECT status FROM gwf_organization_memberships WHERE organization_id=? AND user_id=?`, organizationID, userID).Scan(&status); err != nil {
+175
View File
@@ -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)
})
}
}
+8
View File
@@ -8,6 +8,14 @@ application concern belongs in the shared module.
## Gamertan accounts and commerce ## 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 customer may need both purchasing and billing access. Replacing one role at
a time would create partial permission states and misleading audit history. a time would create partial permission states and misleading audit history.
The role-set extension commits all direct roles together with optimistic The role-set extension commits all direct roles together with optimistic
+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.23 go get gamertan.com/web/requestmeta@v0.1.0-preview.24
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.23 go get gamertan.com/web/requestmeta@v0.1.0-preview.24
``` ```
Only imported packages are compiled and linked. The packages nevertheless Only imported packages are compiled and linked. The packages nevertheless
+11
View File
@@ -75,6 +75,17 @@ owns atomic public signup, including personal organization and credentials.
## Membership and access lifecycle ## 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 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;
+41 -3
View File
@@ -414,6 +414,14 @@ type UpdateOrganization struct {
} }
func (service *Service) UpdateOrganization(ctx context.Context, input UpdateOrganization) (Organization, error) { 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) 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) { 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") return Organization{}, errors.New("organizations: invalid organization update")
@@ -422,12 +430,20 @@ func (service *Service) UpdateOrganization(ctx context.Context, input UpdateOrga
if err != nil { if err != nil {
return Organization{}, err 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() 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") audit, err := service.auditWithRequest(input.ActorUserID, value.ID, "organization.update", "organization", value.ID, input.RequestID, "Organization details updated")
if err != nil { if err != nil {
return Organization{}, err 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 Organization{}, err
} }
return value, nil return value, nil
@@ -531,6 +547,10 @@ type MembershipStatusChange struct {
} }
func (service *Service) ChangeMembershipStatus(ctx context.Context, input MembershipStatusChange) error { 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) || if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) ||
(input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") || (input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") ||
(input.Status != "active" && input.Status != "suspended") || input.Status == input.ExpectedStatus || (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 == "" { if service.ownerRole == "" {
return errors.New("organizations: owner role is required for membership lifecycle changes") 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) repository, ok := service.repository.(OptimisticMembershipRepository)
if !ok { if !requireOwner && !ok {
return ErrMembershipLifecycleUnsupported return ErrMembershipLifecycleUnsupported
} }
audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership."+input.Status, "membership", input.UserID, input.RequestID, "Organization membership set to "+input.Status) 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 { if err != nil {
return err return err
} }
if requireOwner {
return ownedRepository.ChangeOwnedMembershipStatus(ctx, input, service.ownerRole, audit)
}
return repository.ChangeMembershipStatus(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 { 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) || if !idPattern.MatchString(input.OrganizationID) || !idPattern.MatchString(input.UserID) || !idPattern.MatchString(input.ActorUserID) ||
(input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") || !boundedOptional(input.RequestID, 128) { (input.ExpectedStatus != "active" && input.ExpectedStatus != "suspended") || !boundedOptional(input.RequestID, 128) {
return errors.New("organizations: invalid membership removal") return errors.New("organizations: invalid membership removal")
@@ -579,14 +610,21 @@ func (service *Service) RemoveMembershipIfCurrent(ctx context.Context, input Mem
if service.ownerRole == "" { if service.ownerRole == "" {
return errors.New("organizations: owner role is required for membership lifecycle changes") 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) repository, ok := service.repository.(OptimisticMembershipRepository)
if !ok { if !requireOwner && !ok {
return ErrMembershipLifecycleUnsupported return ErrMembershipLifecycleUnsupported
} }
audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership.remove", "membership", input.UserID, input.RequestID, "Organization membership removed") audit, err := service.auditWithRequest(input.ActorUserID, input.OrganizationID, "membership.remove", "membership", input.UserID, input.RequestID, "Organization membership removed")
if err != nil { if err != nil {
return err return err
} }
if requireOwner {
return ownedRepository.RemoveOwnedMembershipIfCurrent(ctx, input, service.ownerRole, audit)
}
return repository.RemoveMembershipIfCurrent(ctx, input, service.ownerRole, audit) return repository.RemoveMembershipIfCurrent(ctx, input, service.ownerRole, audit)
} }
+31
View File
@@ -25,6 +25,37 @@ type OwnedOrganizationRepository interface {
CreateOwnedOrganization(context.Context, OwnedOrganization) error 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 // CreateOwnedOrganization grants the configured OwnerRole to the initial owner
// inside the creation transaction. Applications authorize creation and choose // inside the creation transaction. Applications authorize creation and choose
// OwnerRole when constructing the service, never from a submitted role name. // OwnerRole when constructing the service, never from a submitted role name.
+19
View File
@@ -92,3 +92,22 @@ func TestOwnedOrganizationDoesNotReturnUncommittedIdentity(t *testing.T) {
t.Fatalf("organization=%+v calls=%d err=%v", organization, repository.calls, err) 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)
}
}
+1
View File
@@ -50,6 +50,7 @@ authsqlite/bootstrap_test.go
authsqlite/organizations.go authsqlite/organizations.go
authsqlite/owned_organization.go authsqlite/owned_organization.go
authsqlite/owned_organization_test.go authsqlite/owned_organization_test.go
authsqlite/owned_management_test.go
authsqlite/role_sets_test.go authsqlite/role_sets_test.go
authsqlite/passkey.go authsqlite/passkey.go
authsqlite/passkey_test.go authsqlite/passkey_test.go