Protect owner administration authority
verify / verify (push) Successful in 3m42s

This commit is contained in:
2026-09-04 00:20:28 -04:00
parent 6f0b597943
commit d54d6a4ad1
10 changed files with 100 additions and 7 deletions
+13
View File
@@ -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
+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
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
+4 -2
View File
@@ -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")
+9
View File
@@ -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)
+44
View File
@@ -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 {
+18
View File
@@ -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
+6
View File
@@ -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.
+1 -1
View File
@@ -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
```
+1 -1
View File
@@ -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
+1
View File
@@ -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}$`)
)