This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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}$`)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user