Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1710e08b8 | ||
|
|
3fe1547a5b | ||
|
|
d54d6a4ad1 |
@@ -2,6 +2,42 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
## v0.1.0-preview.21 — 2026-09-04
|
||||
|
||||
- Derive the registered credential algorithm from the verified COSE public key
|
||||
embedded in authenticator data instead of the optional browser
|
||||
`publicKeyAlgorithm` convenience member.
|
||||
- Preserve the ES256-only policy while accepting standards-compliant response
|
||||
serializers that omit redundant response conveniences, including the
|
||||
Bitwarden/Vaultwarden passkey flow exercised through Gamertan.
|
||||
- Add regression coverage for an ES256 credential whose convenience algorithm
|
||||
is absent, plus malformed and non-ES256 credential rejection.
|
||||
|
||||
## 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
|
||||
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.21`. 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.21
|
||||
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.21
|
||||
```
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -430,6 +442,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 +490,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 +525,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 +572,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 +627,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 {
|
||||
@@ -699,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)
|
||||
@@ -708,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
|
||||
|
||||
@@ -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
|
||||
@@ -635,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 {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// SPDX-License-Identifier: MPL-2.0
|
||||
|
||||
package authwebauthn
|
||||
|
||||
import (
|
||||
"crypto/ecdh"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncbor"
|
||||
"gamertan.com/web/internal/webauthnvendored/protocol/webauthncose"
|
||||
wa "gamertan.com/web/internal/webauthnvendored/webauthn"
|
||||
)
|
||||
|
||||
func TestEnforceCredentialAlgorithmUsesVerifiedCOSEKey(t *testing.T) {
|
||||
privateKey, err := ecdh.P256().GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
publicKey := privateKey.PublicKey().Bytes()
|
||||
encoded, err := webauthncbor.Marshal(map[int64]any{
|
||||
1: int64(webauthncose.EllipticKey),
|
||||
3: int64(webauthncose.AlgES256),
|
||||
-1: int64(webauthncose.P256),
|
||||
-2: publicKey[1:33],
|
||||
-3: publicKey[33:65],
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
credential := &wa.Credential{
|
||||
PublicKey: encoded,
|
||||
// This value is absent when a standards-compliant client serializes the
|
||||
// mandatory attestation object without optional response conveniences.
|
||||
Attestation: wa.CredentialAttestation{PublicKeyAlgorithm: 0},
|
||||
}
|
||||
if err = enforceCredentialAlgorithm(credential); err != nil {
|
||||
t.Fatalf("verified ES256 COSE key rejected when convenience value was absent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforceCredentialAlgorithmRejectsOtherOrInvalidKeys(t *testing.T) {
|
||||
rsaKey, err := webauthncbor.Marshal(map[int64]any{
|
||||
1: int64(webauthncose.RSAKey),
|
||||
3: int64(webauthncose.AlgRS256),
|
||||
-1: []byte{0xff},
|
||||
-2: []byte{0x01, 0x00, 0x01},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, credential := range map[string]*wa.Credential{
|
||||
"nil": nil,
|
||||
"malformed": {PublicKey: []byte("not-cose")},
|
||||
"rsa": {PublicKey: rsaKey},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := enforceCredentialAlgorithm(credential); !errors.Is(err, ErrUnsupportedCredential) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+21
-1
@@ -375,7 +375,7 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
|
||||
if err != nil {
|
||||
return Credential{}, fmt.Errorf("authwebauthn: verify registration: %w", err)
|
||||
}
|
||||
if verified.Attestation.PublicKeyAlgorithm != int64(webauthncose.AlgES256) {
|
||||
if err = enforceCredentialAlgorithm(verified); err != nil {
|
||||
return Credential{}, ErrUnsupportedCredential
|
||||
}
|
||||
encoded, err := json.Marshal(verified)
|
||||
@@ -407,6 +407,26 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
|
||||
return record, nil
|
||||
}
|
||||
|
||||
// enforceCredentialAlgorithm derives the algorithm from the verified COSE key
|
||||
// carried inside authenticator data. AuthenticatorAttestationResponse's
|
||||
// publicKeyAlgorithm member is an optional browser convenience value: clients
|
||||
// that serialize the mandatory attestation object directly may omit it, and it
|
||||
// is not the cryptographically authoritative representation.
|
||||
func enforceCredentialAlgorithm(credential *wa.Credential) error {
|
||||
if credential == nil {
|
||||
return ErrUnsupportedCredential
|
||||
}
|
||||
parsed, err := webauthncose.ParsePublicKey(credential.PublicKey)
|
||||
if err != nil {
|
||||
return ErrUnsupportedCredential
|
||||
}
|
||||
key, ok := parsed.(webauthncose.EC2PublicKeyData)
|
||||
if !ok || key.Algorithm != int64(webauthncose.AlgES256) {
|
||||
return ErrUnsupportedCredential
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *Service) BeginLogin(ctx context.Context) (BeginResult, error) {
|
||||
challenge, err := service.randomBytes(32)
|
||||
if err != nil {
|
||||
|
||||
@@ -73,3 +73,23 @@ 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.
|
||||
- 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.
|
||||
- A real Bitwarden/Vaultwarden owner enrollment reached successful WebAuthn
|
||||
verification but was rejected by a redundant algorithm check because the
|
||||
application's direct response serializer omitted the optional browser
|
||||
`publicKeyAlgorithm` convenience member. Preview 21 keeps ES256-only policy
|
||||
enforcement but derives it from the verified COSE key embedded in
|
||||
authenticator data. This makes the server independent of serializer-specific
|
||||
convenience fields without weakening origin, challenge, user-verification,
|
||||
or algorithm validation.
|
||||
|
||||
@@ -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.21
|
||||
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.21
|
||||
```
|
||||
|
||||
Only imported packages are compiled and linked. The packages nevertheless
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -19,6 +19,10 @@ authorization decisions, session cookie, HTML, and local recovery command.
|
||||
- Request no attestation conveyance.
|
||||
- Permit ES256 only until another algorithm has explicit interoperability and
|
||||
security evidence.
|
||||
- Enforce that policy from the verified COSE public key embedded in
|
||||
authenticator data. Do not rely on the optional browser
|
||||
`publicKeyAlgorithm` convenience member: direct standards-compliant response
|
||||
serializers may omit it even when the attested credential is ES256.
|
||||
- Store random challenges and verifier session data only behind opaque,
|
||||
single-use ceremony tokens.
|
||||
- Treat clone warnings as audit signals rather than automatic lockout for
|
||||
|
||||
@@ -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 access")
|
||||
slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{1,62}$`)
|
||||
idPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{8,128}$`)
|
||||
)
|
||||
@@ -103,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)
|
||||
@@ -313,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
|
||||
@@ -558,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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user