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 # 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 ## v0.1.0-preview.18 — 2026-09-04
- Add owner-assisted account recovery for a documented human-review path when - 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 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.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. > 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.18 go get gamertan.com/web@v0.1.0-preview.19
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.18 go get gamertan.com/web/requestmeta@v0.1.0-preview.19
``` ```
The version belongs to the `gamertan.com/web` module. See the The version belongs to the `gamertan.com/web` module. See the
+4 -2
View File
@@ -19,6 +19,7 @@ import (
var ( var (
ErrLastOwner = errors.New("access: the last active direct owner must be preserved") 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") ErrRoleChangeConflict = errors.New("access: role binding changed")
ErrRoleUnchanged = errors.New("access: role is unchanged") ErrRoleUnchanged = errors.New("access: role is unchanged")
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`) idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
@@ -218,8 +219,9 @@ type OrganizationUserRoleChange struct {
// ReplaceOrganizationUserRole atomically replaces every current direct, // ReplaceOrganizationUserRole atomically replaces every current direct,
// organization-wide role for one active member with exactly one role. The // organization-wide role for one active member with exactly one role. The
// expected binding IDs make concurrent administration fail closed. When an // expected binding IDs make concurrent administration fail closed. When an
// owner role is configured, the repository also protects the final active // owner role is configured, the repository also requires a current active
// direct owner in the same transaction. // 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) { func (service *Service) ReplaceOrganizationUserRole(ctx context.Context, input OrganizationUserRoleChange) (Binding, error) {
if service.ownerRole == "" { if service.ownerRole == "" {
return Binding{}, errors.New("access: owner role is required for role replacement") 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 { if len(currentRoles) == 1 && currentRoles[0] == replacement.Role {
return access.ErrRoleUnchanged 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) { if replacement.Role != ownerRole && slices.Contains(currentRoles, ownerRole) {
var otherOwners int var otherOwners int
if err = tx.QueryRowContext(ctx, `SELECT COUNT(DISTINCT b.subject_id) 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 return err
} }
defer tx.Rollback() 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 status != "active" {
if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil { if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil {
return err return err
@@ -472,6 +478,9 @@ func (store *Store) ChangeMembershipStatus(ctx context.Context, input organizati
if current != input.ExpectedStatus { if current != input.ExpectedStatus {
return organizations.ErrRevisionConflict return organizations.ErrRevisionConflict
} }
if err = requireOwnerAuthorityForOwnerTarget(ctx, tx, input.OrganizationID, input.ActorUserID, input.UserID, ownerRole); err != nil {
return err
}
if input.Status == "suspended" { if input.Status == "suspended" {
if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil { if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil {
return err return err
@@ -504,6 +513,12 @@ func (store *Store) RemoveMembership(ctx context.Context, organizationID, userID
return err return err
} }
defer tx.Rollback() 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 { if err = protectLastOwner(ctx, tx, organizationID, userID, ownerRole); err != nil {
return err return err
} }
@@ -545,6 +560,9 @@ func (store *Store) RemoveMembershipIfCurrent(ctx context.Context, input organiz
if current != input.ExpectedStatus { if current != input.ExpectedStatus {
return organizations.ErrRevisionConflict 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 { if err = protectLastOwner(ctx, tx, input.OrganizationID, input.UserID, ownerRole); err != nil {
return err return err
} }
@@ -597,6 +615,32 @@ func membershipStatus(ctx context.Context, tx *sql.Tx, organizationID, userID st
return status, nil 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 { func protectLastOwner(ctx context.Context, tx *sql.Tx, organizationID, userID, ownerRole string) error {
var targetIsOwner int 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 { 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 { if err != nil || len(direct) != 2 {
t.Fatalf("direct=%+v err=%v", direct, err) 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 { type replacementResult struct {
binding access.Binding 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 and writes identity plus organization audits. Grant completion installs the
replacement password, passkey, and recovery-code set atomically and never replacement password, passkey, and recovery-code set atomically and never
issues a session. 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: module checksum:
```bash ```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 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.18 go get gamertan.com/web/requestmeta@v0.1.0-preview.19
``` ```
Only imported packages are compiled and linked. The packages nevertheless Only imported packages are compiled and linked. The packages nevertheless
+1
View File
@@ -27,6 +27,7 @@ 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")
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}$`)
) )