Protect owner invitation authority
verify / verify (push) Successful in 3m40s

This commit is contained in:
2026-09-04 00:30:29 -04:00
parent d54d6a4ad1
commit 3fe1547a5b
10 changed files with 143 additions and 16 deletions
+12
View File
@@ -2,6 +2,18 @@
# Changelog
## v0.1.0-preview.20 — 2026-09-04
- Extend the direct-owner transaction boundary to invitations. Creating or
revoking an invitation that grants the configured owner role now requires
the actor to remain an active direct owner after the SQLite write lock is
acquired.
- Preserve application-owned permission policy for ordinary invitations while
preventing a broad access-management role, stale ceremony, or alternate
repository call from creating or cancelling owner access.
- Pass the configured owner role explicitly through invitation repository
mutations so non-SQLite adapters cannot silently omit the invariant.
## v0.1.0-preview.19 — 2026-09-04
- Require a current active direct owner for every direct-role transition to or
+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.19`. APIs may change before a stable
> **Public preview:** `v0.1.0-preview.20`. 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.19
go get gamertan.com/web@v0.1.0-preview.20
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.19
go get gamertan.com/web/requestmeta@v0.1.0-preview.20
```
The version belongs to the `gamertan.com/web` module. See the
+35 -4
View File
@@ -123,8 +123,8 @@ func (store *Store) CreateApplicationService(ctx context.Context, application or
return nil
}
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, audit organizations.AuditEvent) error {
if !opaqueID(invitation.ID) || zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.DirectRole != "" && !safeName(invitation.DirectRole) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) {
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, ownerRole string, audit organizations.AuditEvent) error {
if !opaqueID(invitation.ID) || zeroDigest(invitation.Digest) || !opaqueID(invitation.OrganizationID) || !text(invitation.Email, 320, false) || !opaqueID(invitation.InvitedByUserID) || invitation.DirectRole != "" && !safeName(invitation.DirectRole) || ownerRole != "" && !safeName(ownerRole) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) {
return errors.New("authsqlite: invalid invitation")
}
teamIDs, err := json.Marshal(invitation.TeamIDs)
@@ -136,6 +136,18 @@ func (store *Store) CreateInvitation(ctx context.Context, invitation organizatio
return err
}
defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID); err != nil {
return err
}
if ownerRole != "" && invitation.DirectRole == ownerRole {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, invitation.OrganizationID, invitation.InvitedByUserID, ownerRole)
if ownerErr != nil {
return ownerErr
}
if !actorIsOwner {
return organizations.ErrOwnerAuthority
}
}
if err = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil {
return err
}
@@ -743,8 +755,8 @@ func validateInvitationTeams(ctx context.Context, tx *sql.Tx, organizationID str
return nil
}
func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invitationID string, revokedAt time.Time, audit organizations.AuditEvent) error {
if !opaqueID(organizationID) || !opaqueID(invitationID) || revokedAt.IsZero() || !validOrganizationAudit(audit, organizationID) {
func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invitationID, ownerRole string, revokedAt time.Time, audit organizations.AuditEvent) error {
if !opaqueID(organizationID) || !opaqueID(invitationID) || ownerRole != "" && !safeName(ownerRole) || revokedAt.IsZero() || !validOrganizationAudit(audit, organizationID) {
return organizations.ErrInvitationNotFound
}
tx, err := store.db.BeginTx(ctx, nil)
@@ -752,6 +764,25 @@ func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invita
return err
}
defer tx.Rollback()
if err = lockActiveMembershipActor(ctx, tx, organizationID, audit.ActorUserID); err != nil {
return err
}
var directRole string
if err = tx.QueryRowContext(ctx, `SELECT direct_role FROM gwf_organization_invitations WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, organizationID, invitationID).Scan(&directRole); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return organizations.ErrInvitationNotFound
}
return err
}
if ownerRole != "" && directRole == ownerRole {
actorIsOwner, ownerErr := hasDirectOwnerRole(ctx, tx, organizationID, audit.ActorUserID, ownerRole)
if ownerErr != nil {
return ownerErr
}
if !actorIsOwner {
return organizations.ErrOwnerAuthority
}
}
result, err := tx.ExecContext(ctx, `UPDATE gwf_organization_invitations SET revoked_at=? WHERE organization_id=? AND id=? AND used_at IS NULL AND revoked_at IS NULL`, revokedAt.Unix(), organizationID, invitationID)
if err != nil {
return err
+74
View File
@@ -653,6 +653,80 @@ func TestOrganizationRoleAdministrationIsAtomicAndProtectsOwners(t *testing.T) {
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=?`, replacement.ID, 0)
}
func TestOwnerInvitationsRequireDirectOwnerAuthority(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Date(2026, 9, 4, 12, 0, 0, 0, time.UTC)
authService, err := auth.New(store, auth.Options{Now: func() time.Time { return now }})
if err != nil {
t.Fatal(err)
}
owner, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "invitation.owner", Email: "invitation-owner@example.test", DisplayName: "Invitation Owner", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
manager, err := authService.CreateUser(t.Context(), auth.CreateUser{Username: "invitation.manager", Email: "invitation-manager@example.test", DisplayName: "Invitation Manager", Password: "correct horse battery staple"})
if err != nil {
t.Fatal(err)
}
organizationService, err := organizations.New(store, organizations.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
organization, err := organizationService.CreateOrganization(t.Context(), organizations.CreateOrganization{Slug: "invitation-authority", Name: "Invitation Authority", OwnerUserID: owner.ID})
if err != nil {
t.Fatal(err)
}
raw, _, err := organizationService.Invite(t.Context(), organization.ID, manager.Email, owner.ID, time.Hour)
if err != nil {
t.Fatal(err)
}
if err = organizationService.AcceptInvitation(t.Context(), raw, manager.ID); err != nil {
t.Fatal(err)
}
policy := access.Policy{
Roles: map[string]string{"owner": "Owner", "site-admin": "Site administrator", "viewer": "Viewer"},
Permissions: map[string]string{"site.access.manage": "Manage site access"},
Grants: map[string][]string{"owner": {"site.access.manage"}, "site-admin": {"site.access.manage"}, "viewer": {}},
}
accessService, err := access.New(store, policy, access.Options{Now: func() time.Time { return now }, OwnerRole: "owner"})
if err != nil {
t.Fatal(err)
}
if err = accessService.Seed(t.Context()); err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: owner.ID, Role: "owner", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
if _, err = accessService.Grant(t.Context(), access.Grant{SubjectKind: access.User, SubjectID: manager.ID, Role: "site-admin", Scope: access.Scope{OrganizationID: organization.ID}, GrantedBy: owner.ID}); err != nil {
t.Fatal(err)
}
if _, _, err = organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "blocked-owner@example.test", InvitedByUserID: manager.ID, DirectRole: "owner", Lifetime: time.Hour}); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner owner invitation err=%v", err)
}
_, viewerInvitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "viewer@example.test", InvitedByUserID: manager.ID, DirectRole: "viewer", Lifetime: time.Hour})
if err != nil {
t.Fatalf("non-owner ordinary invitation err=%v", err)
}
_, ownerInvitation, err := organizationService.InviteWithAccess(t.Context(), organizations.InviteWithAccess{OrganizationID: organization.ID, Email: "new-owner@example.test", InvitedByUserID: owner.ID, DirectRole: "owner", Lifetime: time.Hour})
if err != nil {
t.Fatalf("owner invitation err=%v", err)
}
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, ownerInvitation.ID, manager.ID, "request-manager-owner-revoke"); !errors.Is(err, organizations.ErrOwnerAuthority) {
t.Fatalf("non-owner owner invitation revocation err=%v", err)
}
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, viewerInvitation.ID, manager.ID, "request-manager-viewer-revoke"); err != nil {
t.Fatalf("ordinary invitation revocation err=%v", err)
}
if err = organizationService.RevokeInvitation(t.Context(), organization.ID, ownerInvitation.ID, owner.ID, "request-owner-owner-revoke"); err != nil {
t.Fatalf("owner invitation revocation err=%v", err)
}
}
func TestOptimisticMembershipLifecycleIsSerializedAndAtomic(t *testing.T) {
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
if err != nil {
+6
View File
@@ -79,3 +79,9 @@ application concern belongs in the shared module.
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.
- Gamertan's invitation work found the same authority boundary before a route
was exposed: Site Admin must be able to invite ordinary staff without being
able to grant or cancel Owner access. Preview 20 passes the configured owner
role into invitation mutations and rechecks a current active direct Owner
after acquiring the SQLite write lock. The application still owns fresh
authentication, recipient delivery, and the one-time secret presentation.
+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.19
go get gamertan.com/web/requestmeta@v0.1.0-preview.20
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.19
go get gamertan.com/web/requestmeta@v0.1.0-preview.20
```
Only imported packages are compiled and linked. The packages nevertheless
+4
View File
@@ -12,6 +12,10 @@ expiring, single-use invitations. An invitation may carry one direct role and
up to sixteen reviewed team memberships. Acceptance verifies that the
authenticated user's normalized email matches and applies the membership,
role, teams, consumption marker, and audit event in one transaction.
When `OwnerRole` is configured, creating or revoking an invitation carrying
that role additionally requires a current active direct owner inside the same
SQLite transaction. A broad access-management permission may administer
ordinary invitations but cannot create or cancel owner access.
Applications own invitation pages, email or out-of-band delivery, active-source
checks before archival, and account recovery.
+5 -5
View File
@@ -27,7 +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")
ErrOwnerAuthority = errors.New("organizations: a current direct owner must manage owner access")
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
)
@@ -104,10 +104,10 @@ type Repository interface {
CreateProject(context.Context, Project) error
CreateEnvironment(context.Context, Environment) error
CreateApplicationService(context.Context, ApplicationService) error
CreateInvitation(context.Context, Invitation, AuditEvent) error
CreateInvitation(context.Context, Invitation, string, AuditEvent) error
InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error)
Invitations(context.Context, string, int) ([]Invitation, error)
RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error
RevokeInvitation(context.Context, string, string, string, time.Time, AuditEvent) error
AcceptInvitation(context.Context, [32]byte, string, time.Time, AuditEvent) error
OrganizationMemberships(context.Context, string, int) ([]Membership, error)
MembershipsForUser(context.Context, string) ([]Membership, error)
@@ -314,7 +314,7 @@ func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAc
if err != nil {
return "", Invitation{}, err
}
if err = service.repository.CreateInvitation(ctx, invitation, audit); err != nil {
if err = service.repository.CreateInvitation(ctx, invitation, service.ownerRole, audit); err != nil {
return "", Invitation{}, err
}
return raw, invitation, nil
@@ -559,7 +559,7 @@ func (service *Service) RevokeInvitation(ctx context.Context, organizationID, in
if err != nil {
return err
}
return service.repository.RevokeInvitation(ctx, organizationID, invitationID, now, audit)
return service.repository.RevokeInvitation(ctx, organizationID, invitationID, service.ownerRole, now, audit)
}
func (service *Service) Repository() Repository { return service.repository }
+2 -2
View File
@@ -100,7 +100,7 @@ func (*repositoryStub) CreateEnvironment(context.Context, Environment) error { r
func (*repositoryStub) CreateApplicationService(context.Context, ApplicationService) error {
return nil
}
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation, _ AuditEvent) error {
func (repository *repositoryStub) CreateInvitation(_ context.Context, invitation Invitation, _ string, _ AuditEvent) error {
repository.invitation = invitation
return nil
}
@@ -113,7 +113,7 @@ func (repository *repositoryStub) InvitationByDigest(context.Context, [32]byte,
func (*repositoryStub) Invitations(context.Context, string, int) ([]Invitation, error) {
return nil, nil
}
func (*repositoryStub) RevokeInvitation(context.Context, string, string, time.Time, AuditEvent) error {
func (*repositoryStub) RevokeInvitation(context.Context, string, string, string, time.Time, AuditEvent) error {
return nil
}
func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time, _ AuditEvent) error {