diff --git a/CHANGELOG.md b/CHANGELOG.md index 90f4c9d..42ddfd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ # Changelog +## v0.1.0-preview.19 — 2026-09-04 + +- Require a current active direct owner for every direct-role transition to or + from the configured owner role. The SQLite adapter rechecks that authority + after acquiring its write lock, preventing a role manager from promoting + itself or changing an owner through a stale application authorization. +- Apply the same transactional owner-authority boundary to membership + suspension, reactivation, and removal, including the legacy lifecycle + methods. Non-owner administrators may still manage non-owner members while + last-owner protection remains a separate invariant. +- Expose stable owner-authority errors so applications can distinguish an + authorization drift conflict from malformed input or storage failure. + ## v0.1.0-preview.18 — 2026-09-04 - Add owner-assisted account recovery for a documented human-review path when diff --git a/README.md b/README.md index 7e6b480..30d2cf8 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.18`. APIs may change before a stable +> **Public preview:** `v0.1.0-preview.19`. 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.18 +go get gamertan.com/web@v0.1.0-preview.19 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.18 +go get gamertan.com/web/requestmeta@v0.1.0-preview.19 ``` The version belongs to the `gamertan.com/web` module. See the diff --git a/access/access.go b/access/access.go index 950c2e2..7dafe9c 100644 --- a/access/access.go +++ b/access/access.go @@ -19,6 +19,7 @@ import ( var ( ErrLastOwner = errors.New("access: the last active direct owner must be preserved") + ErrOwnerAuthority = errors.New("access: a current direct owner must approve owner role changes") ErrRoleChangeConflict = errors.New("access: role binding changed") ErrRoleUnchanged = errors.New("access: role is unchanged") idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`) @@ -218,8 +219,9 @@ type OrganizationUserRoleChange struct { // ReplaceOrganizationUserRole atomically replaces every current direct, // organization-wide role for one active member with exactly one role. The // expected binding IDs make concurrent administration fail closed. When an -// owner role is configured, the repository also protects the final active -// direct owner in the same transaction. +// owner role is configured, the repository also requires a current active +// direct owner for any change to or from that role and protects the final +// active direct owner in the same transaction. func (service *Service) ReplaceOrganizationUserRole(ctx context.Context, input OrganizationUserRoleChange) (Binding, error) { if service.ownerRole == "" { return Binding{}, errors.New("access: owner role is required for role replacement") diff --git a/authsqlite/access.go b/authsqlite/access.go index 67f2dc0..40874b2 100644 --- a/authsqlite/access.go +++ b/authsqlite/access.go @@ -234,6 +234,15 @@ func (store *Store) ReplaceOrganizationUserRole(ctx context.Context, expected [] if len(currentRoles) == 1 && currentRoles[0] == replacement.Role { return access.ErrRoleUnchanged } + if replacement.Role == ownerRole || slices.Contains(currentRoles, ownerRole) { + actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, replacement.Scope.OrganizationID, replacement.GrantedBy, ownerRole) + if ownerErr != nil { + return ownerErr + } + if !actorIsOwner { + return access.ErrOwnerAuthority + } + } if replacement.Role != ownerRole && slices.Contains(currentRoles, ownerRole) { var otherOwners int if err = tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id) diff --git a/authsqlite/organizations.go b/authsqlite/organizations.go index a12d233..1c2e294 100644 --- a/authsqlite/organizations.go +++ b/authsqlite/organizations.go @@ -430,6 +430,12 @@ func (store *Store) SetMembershipStatus(ctx context.Context, organizationID, use return err } defer tx.Rollback() + if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil { + return err + } + if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, organizationID, audit.ActorUserID, userID, ownerRole); err != nil { + return err + } if status != "active" { if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil { return err @@ -472,6 +478,9 @@ func (store *Store) ChangeMembershipStatus(ctx context.Context, input organizati if current != input.ExpectedStatus { return organizations.ErrRevisionConflict } + if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, input.OrganizationID, input.ActorUserID, input.UserID, ownerRole); err != nil { + return err + } if input.Status == "suspended" { if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil { return err @@ -504,6 +513,12 @@ func (store *Store) RemoveMembership(ctx context.Context, organizationID, userID return err } defer tx.Rollback() + if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil { + return err + } + if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, organizationID, audit.ActorUserID, userID, ownerRole); err != nil { + return err + } if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil { return err } @@ -545,6 +560,9 @@ func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organiz if current != input.ExpectedStatus { return organizations.ErrRevisionConflict } + if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, input.OrganizationID, input.ActorUserID, input.UserID, ownerRole); err != nil { + return err + } if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil { return err } @@ -597,6 +615,32 @@ func membershipStatus(ctx context.Context, tx *sql.Tx, organizationID, userID st return status, nil } +func requireOwnerAuthorityForOwnerTarget(ctx context.Context, tx *sql.Tx, organizationID, actorUserID, targetUserID, ownerRole string) error { + targetIsOwner, err := hasDirectOwnerRole(ctx, tx, organizationID, targetUserID, ownerRole) + if err != nil || !targetIsOwner { + return err + } + actorIsOwner, err := hasDirectOwnerRole(ctx, tx, organizationID, actorUserID, ownerRole) + if err != nil { + return err + } + if !actorIsOwner { + return organizations.ErrOwnerAuthority + } + return nil +} + +func hasDirectOwnerRole(ctx context.Context, tx *sql.Tx, organizationID, userID, ownerRole string) (bool, error) { + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_bindings + WHERE organization_id=? AND subject_kind='user' AND subject_id=? AND role_name=? + AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL + AND revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&count); err != nil { + return false, err + } + return count > 0, nil +} + func protectLastOwner(ctx context.Context, tx *sql.Tx, organizationID, userID, ownerRole string) error { var targetIsOwner int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM gwf_access_bindings WHERE organization_id=? AND subject_kind='user' AND subject_id=? AND role_name=? AND project_id IS NULL AND environment_id IS NULL AND service_id IS NULL AND revoked_at IS NULL`, organizationID, userID, ownerRole).Scan(&targetIsOwner); err != nil { diff --git a/authsqlite/store_test.go b/authsqlite/store_test.go index c545965..1e06d48 100644 --- a/authsqlite/store_test.go +++ b/authsqlite/store_test.go @@ -548,6 +548,24 @@ func TestOrganizationRoleAdministrationIsAtomicAndProtectsOwners(t *testing.T) { if err != nil || len(direct) != 2 { t.Fatalf("direct=%+v err=%v", direct, err) } + if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: member.ID, Role: "owner", ActorUserID: member.ID, RequestID: "request-self-promote", ExpectedBindingIDs: []string{memberBinding.ID}}); !errors.Is(err, access.ErrOwnerAuthority) { + t.Fatalf("non-owner self-promotion err=%v", err) + } + if _, err = accessService.ReplaceOrganizationUserRole(t.Context(), access.OrganizationUserRoleChange{OrganizationID: organization.ID, UserID: owner.ID, Role: "viewer", ActorUserID: member.ID, RequestID: "request-demote-owner", ExpectedBindingIDs: []string{ownerBinding.ID}}); !errors.Is(err, access.ErrOwnerAuthority) { + t.Fatalf("non-owner owner-demotion err=%v", err) + } + if err = organizationService.ChangeMembershipStatus(t.Context(), organizations.MembershipStatusChange{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", Status: "suspended", ActorUserID: member.ID, RequestID: "request-suspend-owner-without-authority"}); !errors.Is(err, organizations.ErrOwnerAuthority) { + t.Fatalf("non-owner owner-suspension err=%v", err) + } + if err = organizationService.RemoveMembershipIfCurrent(t.Context(), organizations.MembershipRemoval{OrganizationID: organization.ID, UserID: owner.ID, ExpectedStatus: "active", ActorUserID: member.ID, RequestID: "request-remove-owner-without-authority"}); !errors.Is(err, organizations.ErrOwnerAuthority) { + t.Fatalf("non-owner owner-removal err=%v", err) + } + if err = organizationService.SetMembershipStatus(t.Context(), organization.ID, owner.ID, "suspended", member.ID, "request-legacy-suspend-owner-without-authority"); !errors.Is(err, organizations.ErrOwnerAuthority) { + t.Fatalf("legacy non-owner owner-suspension err=%v", err) + } + if err = organizationService.RemoveMembership(t.Context(), organization.ID, owner.ID, member.ID, "request-legacy-remove-owner-without-authority"); !errors.Is(err, organizations.ErrOwnerAuthority) { + t.Fatalf("legacy non-owner owner-removal err=%v", err) + } type replacementResult struct { binding access.Binding diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md index 565607e..47c2d97 100644 --- a/docs/DOGFOOD.md +++ b/docs/DOGFOOD.md @@ -73,3 +73,9 @@ application concern belongs in the shared module. and writes identity plus organization audits. Grant completion installs the replacement password, passkey, and recovery-code set atomically and never issues a session. +- Gamertan's distinction between Site Admin and Owner exposed a second + composition boundary: permission to manage ordinary staff must not imply + permission to create, demote, suspend, or remove an Owner. Preview 19 moves + that invariant into the same SQLite transactions as direct-role and + membership changes, while leaving the application's role vocabulary and UI + policy application-owned. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 4dd1cfe..076d369 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.18 +go get gamertan.com/web/requestmeta@v0.1.0-preview.19 go mod verify ``` diff --git a/docs/MODULES.md b/docs/MODULES.md index ac77b55..c5d5e6e 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.18 +go get gamertan.com/web/requestmeta@v0.1.0-preview.19 ``` Only imported packages are compiled and linked. The packages nevertheless diff --git a/organizations/organizations.go b/organizations/organizations.go index 91cf96d..a5f39a1 100644 --- a/organizations/organizations.go +++ b/organizations/organizations.go @@ -27,6 +27,7 @@ var ( ErrRevisionConflict = errors.New("organizations: revision conflict") ErrPersonalOrganization = errors.New("organizations: personal organization lifecycle is fixed") ErrLastOwner = errors.New("organizations: the last active direct owner must be preserved") + ErrOwnerAuthority = errors.New("organizations: a current direct owner must manage owner memberships") slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`) idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`) )