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