From 3fe1547a5b9879850b95029e309785cc726632e9 Mon Sep 17 00:00:00 2001 From: Cole Speelman Date: Fri, 4 Sep 2026 00:30:29 -0400 Subject: [PATCH] Protect owner invitation authority --- CHANGELOG.md | 12 +++++ README.md | 6 +-- authsqlite/organizations.go | 39 +++++++++++++-- authsqlite/store_test.go | 74 +++++++++++++++++++++++++++++ docs/DOGFOOD.md | 6 +++ docs/GETTING_STARTED.md | 2 +- docs/MODULES.md | 2 +- docs/ORGANIZATIONS.md | 4 ++ organizations/organizations.go | 10 ++-- organizations/organizations_test.go | 4 +- 10 files changed, 143 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 42ddfd9..4a2005c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 30d2cf8..f8f22c2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/authsqlite/organizations.go b/authsqlite/organizations.go index 1c2e294..887701a 100644 --- a/authsqlite/organizations.go +++ b/authsqlite/organizations.go @@ -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 diff --git a/authsqlite/store_test.go b/authsqlite/store_test.go index 1e06d48..bb1d402 100644 --- a/authsqlite/store_test.go +++ b/authsqlite/store_test.go @@ -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 { diff --git a/docs/DOGFOOD.md b/docs/DOGFOOD.md index 47c2d97..ddd7fa7 100644 --- a/docs/DOGFOOD.md +++ b/docs/DOGFOOD.md @@ -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. diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md index 076d369..1c9acc3 100644 --- a/docs/GETTING_STARTED.md +++ b/docs/GETTING_STARTED.md @@ -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 ``` diff --git a/docs/MODULES.md b/docs/MODULES.md index c5d5e6e..ed507fe 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -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 diff --git a/docs/ORGANIZATIONS.md b/docs/ORGANIZATIONS.md index 4c94ba4..bb78b44 100644 --- a/docs/ORGANIZATIONS.md +++ b/docs/ORGANIZATIONS.md @@ -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. diff --git a/organizations/organizations.go b/organizations/organizations.go index a5f39a1..14a943b 100644 --- a/organizations/organizations.go +++ b/organizations/organizations.go @@ -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 } diff --git a/organizations/organizations_test.go b/organizations/organizations_test.go index d24ac64..af472ba 100644 --- a/organizations/organizations_test.go +++ b/organizations/organizations_test.go @@ -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 {