Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1710e08b8 | ||
|
|
3fe1547a5b |
@@ -2,6 +2,29 @@
|
|||||||
|
|
||||||
# Changelog
|
# 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
|
## v0.1.0-preview.19 — 2026-09-04
|
||||||
|
|
||||||
- Require a current active direct owner for every direct-role transition to or
|
- Require a current active direct owner for every direct-role transition to or
|
||||||
|
|||||||
@@ -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.19`. 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.
|
> 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.19
|
go get gamertan.com/web@v0.1.0-preview.21
|
||||||
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.19
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.21
|
||||||
```
|
```
|
||||||
|
|
||||||
The version belongs to the `gamertan.com/web` module. See the
|
The version belongs to the `gamertan.com/web` module. See the
|
||||||
|
|||||||
@@ -123,8 +123,8 @@ func (store *Store) CreateApplicationService(ctx context.Context, application or
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) CreateInvitation(ctx context.Context, invitation organizations.Invitation, audit organizations.AuditEvent) error {
|
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) || !validInvitationTeamIDs(invitation.TeamIDs) || invitation.CreatedAt.IsZero() || !invitation.ExpiresAt.After(invitation.CreatedAt) || !invitation.UsedAt.IsZero() || !invitation.RevokedAt.IsZero() || !validOrganizationAudit(audit, invitation.OrganizationID) {
|
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")
|
return errors.New("authsqlite: invalid invitation")
|
||||||
}
|
}
|
||||||
teamIDs, err := json.Marshal(invitation.TeamIDs)
|
teamIDs, err := json.Marshal(invitation.TeamIDs)
|
||||||
@@ -136,6 +136,18 @@ func (store *Store) CreateInvitation(ctx context.Context, invitation organizatio
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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 {
|
if err = validateInvitationTeams(ctx, tx, invitation.OrganizationID, invitation.TeamIDs); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -743,8 +755,8 @@ func validateInvitationTeams(ctx context.Context, tx *sql.Tx, organizationID str
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invitationID string, revokedAt time.Time, audit organizations.AuditEvent) error {
|
func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invitationID, ownerRole string, revokedAt time.Time, audit organizations.AuditEvent) error {
|
||||||
if !opaqueID(organizationID) || !opaqueID(invitationID) || revokedAt.IsZero() || !validOrganizationAudit(audit, organizationID) {
|
if !opaqueID(organizationID) || !opaqueID(invitationID) || ownerRole != "" && !safeName(ownerRole) || revokedAt.IsZero() || !validOrganizationAudit(audit, organizationID) {
|
||||||
return organizations.ErrInvitationNotFound
|
return organizations.ErrInvitationNotFound
|
||||||
}
|
}
|
||||||
tx, err := store.db.BeginTx(ctx, nil)
|
tx, err := store.db.BeginTx(ctx, nil)
|
||||||
@@ -752,6 +764,25 @@ func (store *Store) RevokeInvitation(ctx context.Context, organizationID, invita
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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)
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -653,6 +653,80 @@ func TestOrganizationRoleAdministrationIsAtomicAndProtectsOwners(t *testing.T) {
|
|||||||
assertCount(t, store, `SELECT COUNT(*) FROM gwf_access_bindings WHERE id=?`, replacement.ID, 0)
|
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) {
|
func TestOptimisticMembershipLifecycleIsSerializedAndAtomic(t *testing.T) {
|
||||||
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
|
store, err := Open(filepath.Join(t.TempDir(), "accounts.db"))
|
||||||
if err != nil {
|
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 {
|
if err != nil {
|
||||||
return Credential{}, fmt.Errorf("authwebauthn: verify registration: %w", err)
|
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
|
return Credential{}, ErrUnsupportedCredential
|
||||||
}
|
}
|
||||||
encoded, err := json.Marshal(verified)
|
encoded, err := json.Marshal(verified)
|
||||||
@@ -407,6 +407,26 @@ func (service *Service) finishRegistrationCeremony(ctx context.Context, ceremony
|
|||||||
return record, nil
|
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) {
|
func (service *Service) BeginLogin(ctx context.Context) (BeginResult, error) {
|
||||||
challenge, err := service.randomBytes(32)
|
challenge, err := service.randomBytes(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -79,3 +79,17 @@ application concern belongs in the shared module.
|
|||||||
that invariant into the same SQLite transactions as direct-role and
|
that invariant into the same SQLite transactions as direct-role and
|
||||||
membership changes, while leaving the application's role vocabulary and UI
|
membership changes, while leaving the application's role vocabulary and UI
|
||||||
policy application-owned.
|
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:
|
module checksum:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get gamertan.com/web/requestmeta@v0.1.0-preview.19
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.21
|
||||||
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.19
|
go get gamertan.com/web/requestmeta@v0.1.0-preview.21
|
||||||
```
|
```
|
||||||
|
|
||||||
Only imported packages are compiled and linked. The packages nevertheless
|
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
|
up to sixteen reviewed team memberships. Acceptance verifies that the
|
||||||
authenticated user's normalized email matches and applies the membership,
|
authenticated user's normalized email matches and applies the membership,
|
||||||
role, teams, consumption marker, and audit event in one transaction.
|
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
|
Applications own invitation pages, email or out-of-band delivery, active-source
|
||||||
checks before archival, and account recovery.
|
checks before archival, and account recovery.
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ authorization decisions, session cookie, HTML, and local recovery command.
|
|||||||
- Request no attestation conveyance.
|
- Request no attestation conveyance.
|
||||||
- Permit ES256 only until another algorithm has explicit interoperability and
|
- Permit ES256 only until another algorithm has explicit interoperability and
|
||||||
security evidence.
|
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,
|
- Store random challenges and verifier session data only behind opaque,
|
||||||
single-use ceremony tokens.
|
single-use ceremony tokens.
|
||||||
- Treat clone warnings as audit signals rather than automatic lockout for
|
- Treat clone warnings as audit signals rather than automatic lockout for
|
||||||
|
|||||||
@@ -27,7 +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")
|
ErrOwnerAuthority = errors.New("organizations: a current direct owner must manage owner access")
|
||||||
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}$`)
|
||||||
)
|
)
|
||||||
@@ -104,10 +104,10 @@ type Repository interface {
|
|||||||
CreateProject(context.Context, Project) error
|
CreateProject(context.Context, Project) error
|
||||||
CreateEnvironment(context.Context, Environment) error
|
CreateEnvironment(context.Context, Environment) error
|
||||||
CreateApplicationService(context.Context, ApplicationService) 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)
|
InvitationByDigest(context.Context, [32]byte, time.Time) (Invitation, error)
|
||||||
Invitations(context.Context, string, int) ([]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
|
AcceptInvitation(context.Context, [32]byte, string, time.Time, AuditEvent) error
|
||||||
OrganizationMemberships(context.Context, string, int) ([]Membership, error)
|
OrganizationMemberships(context.Context, string, int) ([]Membership, error)
|
||||||
MembershipsForUser(context.Context, string) ([]Membership, error)
|
MembershipsForUser(context.Context, string) ([]Membership, error)
|
||||||
@@ -314,7 +314,7 @@ func (service *Service) InviteWithAccess(ctx context.Context, input InviteWithAc
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", Invitation{}, err
|
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 "", Invitation{}, err
|
||||||
}
|
}
|
||||||
return raw, invitation, nil
|
return raw, invitation, nil
|
||||||
@@ -559,7 +559,7 @@ func (service *Service) RevokeInvitation(ctx context.Context, organizationID, in
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 }
|
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 {
|
func (*repositoryStub) CreateApplicationService(context.Context, ApplicationService) error {
|
||||||
return nil
|
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
|
repository.invitation = invitation
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -113,7 +113,7 @@ func (repository *repositoryStub) InvitationByDigest(context.Context, [32]byte,
|
|||||||
func (*repositoryStub) Invitations(context.Context, string, int) ([]Invitation, error) {
|
func (*repositoryStub) Invitations(context.Context, string, int) ([]Invitation, error) {
|
||||||
return nil, nil
|
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
|
return nil
|
||||||
}
|
}
|
||||||
func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time, _ AuditEvent) error {
|
func (repository *repositoryStub) AcceptInvitation(_ context.Context, _ [32]byte, userID string, _ time.Time, _ AuditEvent) error {
|
||||||
|
|||||||
Reference in New Issue
Block a user