rastrillo / idear Public

idear: canonicalise Subject on both sides, and prove the store can fail

Authorize wrote a lowercased Subject; rastrillo/auth mints the session
as sessions.Session{Subject: id.Address} — the address the visitor
TYPED. keymaildev/signin's SplitAddress lowercases only the domain and
deliberately preserves the local part's case (signin.go:57-74), and
flow.go carries the raw string through to Identity. So a first arrival
who typed "Alice@Corp.Test" — what an iOS keyboard produces by default —
claimed the instance, then signed in successfully forever and 404'd on
every guarded route forever, /members included, unable to invite anyone.
The claim was spent and the instance needed database surgery.

normalizeSubject now folds every Subject WRITE and the one Subject READ
(BySubject). Both sides, because a canonical form applied to one side of
a comparison is worse than none. Lowercasing rather than storing the raw
typed string, because raw storage still lets one human hold two rows past
a unique index that cannot see they are the same person — and signin
itself compares addresses with strings.EqualFold, so case-insensitive is
the framework's own notion of address equality. It is a no-op on
password's decimal subjects.

Authorize returns a bool with no error channel, so "a storage failure can
never come back true" is a property only a test can hold. Two faults
prove it: dropping idear_members, and a gorm callback that fails only
COUNT — needed because with the table gone BySubject fails first and the
IsEmpty branch is structurally unreachable. That branch failing open
would hand a stranger Owner of a populated instance on a hiccup.

Also: admission normalises the submitted address without help from its
caller; Authorize runs under a timeout above db's busy_timeout rather
than a bare Background; a nil Create fails as storage, never as a policy
refusal; and CarryToken reads the body only, never the query string,
where a live invitation token would ride into access logs and Referer
headers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev ff6fafa6f5a9cce6b3a128a1bfa898b90896734d parent 656234a
4 files changed, +465 −14
  • admit.go +16 −3
  • admit_test.go +368 −3
  • middleware_test.go +29 −0
  • roster.go +52 −8
diff --git a/admit.go b/admit.go
index 1683ce7..0aefb1b 100644
--- a/admit.go
+++ b/admit.go
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"strconv"
+ "time"
"github.com/carlosframework/rastrillo/password"
)
@@ -28,6 +29,14 @@ import (
// deliberately does not log refusals; that is idear's job.
const refusedCopy = "Sign-up here is by invitation."
+// authorizeTimeout bounds one keymail admission. It sits above
+// rastrillo/db's busy_timeout(5000) on purpose: a request that waits
+// out a normal lock contention must still be allowed to succeed, and
+// only a writer that is genuinely stuck should be abandoned. Expiring
+// it is refused-and-logged like any other storage failure, because
+// Authorize has no way to say "try again".
+const authorizeTimeout = 10 * time.Second
+
// refused builds the one refusal this package ever returns.
// password.Signup renders the refusal's OWN message (errors.As, not
// Error()), so nothing wrapped around it can reach the page.
@@ -284,9 +293,13 @@ func (rs *Roster) Admitting(create func(ctx context.Context, email, hash string)
// stops them there is Require on every route, the landing page
// included.
func (rs *Roster) Authorize(address string) bool {
- // auth hands over an address and nothing else — there is no
- // request context to inherit, so this one carries no deadline.
- ctx := context.Background()
+ // auth hands over an address and nothing else: there is no request
+ // context to inherit, so there is nothing to cancel this when the
+ // visitor gives up or the server shuts down. A background context
+ // with no deadline would park auth's sign-in handler on a stuck
+ // writer indefinitely, so the deadline is invented here.
+ ctx, cancel := context.WithTimeout(context.Background(), authorizeTimeout)
+ defer cancel()
addr := normalizeEmail(address)
if addr == "" {
diff --git a/admit_test.go b/admit_test.go
index 0124023..4e0d6fc 100644
--- a/admit_test.go
+++ b/admit_test.go
@@ -15,6 +15,7 @@ import (
"github.com/carlosframework/rastrillo/password"
"github.com/carlosframework/rastrillo/sessions"
+ "gorm.io/gorm"
"amadan.net/rastrillo/idear"
"amadan.net/rastrillo/idear/internal/ideartest"
@@ -73,8 +74,29 @@ func (u *users) created() []string {
//
// The email is lowercased and trimmed first, exactly as
// password.Signup does before calling Create, so these tests exercise
-// the string admission will really see.
+// the string admission will really see in a correctly-wired app.
func attempt(t *testing.T, rs *idear.Roster, u *users, form url.Values, carry bool) (int64, error) {
+ t.Helper()
+ return runSignup(t, rs, u, form, carry, func(typed string) string {
+ return strings.ToLower(strings.TrimSpace(typed))
+ })
+}
+
+// attemptAsTyped is attempt with password's own normalisation REMOVED,
+// handing admission the address exactly as it came off the form.
+//
+// It exists because admission must not depend on its caller for that.
+// password/handlers.go's normalizeEmail happens to do the same folding
+// today, so nothing is broken — but Admitting's contract says it
+// normalises the submitted address, and a contract nothing exercises
+// is a comment. The next Create wrapper, or a password release that
+// stops folding, would find out in production.
+func attemptAsTyped(t *testing.T, rs *idear.Roster, u *users, form url.Values, carry bool) (int64, error) {
+ t.Helper()
+ return runSignup(t, rs, u, form, carry, func(typed string) string { return typed })
+}
+
+func runSignup(t *testing.T, rs *idear.Roster, u *users, form url.Values, carry bool, prepare func(string) string) (int64, error) {
t.Helper()
admitted := rs.Admitting(u.create)
@@ -86,8 +108,7 @@ func attempt(t *testing.T, rs *idear.Roster, u *users, form url.Values, carry bo
if perr := r.ParseForm(); perr != nil {
t.Fatalf("parsing the posted form: %v", perr)
}
- email := strings.ToLower(strings.TrimSpace(r.FormValue("email")))
- id, err = admitted(r.Context(), email, "a-password-hash")
+ id, err = admitted(r.Context(), prepare(r.FormValue("email")), "a-password-hash")
})
var h http.Handler = inner
@@ -701,3 +722,347 @@ func TestAdmittingOpenSignUpStillHonoursAMatchingToken(t *testing.T) {
t.Error("the invitation was left pending by a signup that redeemed it")
}
}
+
+// ------------------------------------------ the keymail round trip
+
+// TestAuthorizeRoundTripsTheSubjectAuthMints is the keymail twin of
+// TestAdmittingSubjectMatchesThePasswordSession, and it pins the
+// failure that broke this task's first round.
+//
+// rastrillo/auth mints the session as
+// sessions.Session{Subject: id.Address} (auth/handlers.go's admit),
+// and Identity.Address is the address THE VISITOR TYPED:
+// keymaildev/signin's SplitAddress lowercases only the domain and
+// deliberately keeps the local part's case, and flow.go stores the raw
+// typed string. An iOS keyboard capitalises the first letter of an
+// email field by default, so "Alice@Corp.Test" is an ordinary thing to
+// receive, not an edge case.
+//
+// If Authorize writes a Subject that the session auth then mints
+// cannot resolve, the first arrival claims the instance, signs in
+// successfully forever, and 404s on every guarded route forever —
+// /members included, so they can never invite anyone. The claim is
+// spent, everyone else is refused, and the instance is dead.
+//
+// TestAuthorizeNormalisesTheAddress does NOT cover this: it only asks
+// whether an unnormalised address finds an ALREADY-SEEDED lowercase
+// row. This asks whether the row Authorize ITSELF writes can be found
+// by the subject auth itself would mint.
+func TestAuthorizeRoundTripsTheSubjectAuthMints(t *testing.T) {
+ const typed = "Alice@Corp.Test" // exactly what auth passes on
+
+ h := guardedHarness(t)
+ if !h.Roster.Authorize(typed) {
+ t.Fatal("Authorize refused the first arrival into an empty roster")
+ }
+
+ // The session auth mints carries the typed address verbatim.
+ var s spy
+ w := as(typed, h.Roster.Require(s.handler()))
+ if w.Code != http.StatusOK {
+ t.Fatalf("Require answered %d for the very session auth would mint after this Authorize; the instance is bricked", w.Code)
+ }
+ if s.member == nil || s.member.Role != idear.RoleOwner {
+ t.Fatalf("From = %+v, want the owner", s.member)
+ }
+
+ // And the same human typing it differently later is the SAME row,
+ // not a second one — which is why both sides are folded rather
+ // than the raw string being stored.
+ var s2 spy
+ if got := as("alice@corp.test", h.Roster.Require(s2.handler())); got.Code != http.StatusOK {
+ t.Errorf("the same address in lower case answered %d, want 200", got.Code)
+ }
+ if s2.member == nil || s.member.ID != s2.member.ID {
+ t.Error("two spellings of one address resolved to different member rows")
+ }
+ if n := h.CountMembers(); n != 1 {
+ t.Errorf("roster has %d rows for one person, want 1", n)
+ }
+}
+
+// TestAuthorizeRoundTripsAnInvitedSubject is the same round trip on
+// the invitation path, where the Member is written by
+// acceptByAddress rather than by Claim.
+func TestAuthorizeRoundTripsAnInvitedSubject(t *testing.T) {
+ h := guardedHarness(t)
+ invited(t, h, "Bob@Corp.Test", idear.RoleAdmin)
+
+ const typed = "Bob@Corp.Test"
+ if !h.Roster.Authorize(typed) {
+ t.Fatal("Authorize refused an invited address")
+ }
+
+ var s spy
+ w := as(typed, h.Roster.Require(s.handler()))
+ if w.Code != http.StatusOK {
+ t.Fatalf("Require answered %d for the session auth would mint; the invited member is locked out", w.Code)
+ }
+ if s.member == nil || s.member.Role != idear.RoleAdmin {
+ t.Fatalf("From = %+v, want an admin", s.member)
+ }
+}
+
+// ---------------------------------------------------- fault injection
+
+// breakRoster drops the members table out from under the roster. It is
+// the bluntest possible storage failure and the only one that needs no
+// hook into gorm: every query idear makes about membership now errors.
+func breakRoster(t *testing.T, h *ideartest.Harness) {
+ t.Helper()
+ if err := h.DB.G.Exec("DROP TABLE idear_members").Error; err != nil {
+ t.Fatalf("dropping idear_members: %v", err)
+ }
+}
+
+// TestAuthorizeRefusesWhenTheStoreIsBroken: Authorize returns a bool
+// with no error channel, so "a database failure can never come back
+// true" is a property only a test can hold. Every early return in it
+// is a `return false`, and this is what stops one of them becoming a
+// `return true` in a later refactor.
+func TestAuthorizeRefusesWhenTheStoreIsBroken(t *testing.T) {
+ h := guardedHarness(t)
+ member := h.Owner()
+ breakRoster(t, h)
+
+ for _, addr := range []string{member.Email, "stranger@corp.test", ""} {
+ if h.Roster.Authorize(addr) {
+ t.Errorf("Authorize(%q) returned true against a broken store", addr)
+ }
+ }
+}
+
+// TestAdmittingRefusesWhenTheStoreIsBroken: admission's FIRST store
+// call is IsEmpty, so a broken roster is what proves that branch
+// refuses. It must also not reach the app's Create — a signup that
+// creates a user and then cannot write a member is the orphan this
+// design goes out of its way to avoid manufacturing.
+func TestAdmittingRefusesWhenTheStoreIsBroken(t *testing.T) {
+ h := guardedHarness(t)
+ h.Owner()
+ breakRoster(t, h)
+
+ u := newUsers()
+ id, err := attempt(t, h.Roster, u, signupForm("anyone@corp.test", ""), true)
+ if err == nil {
+ t.Fatalf("admission returned id %d and no error against a broken store", id)
+ }
+ if id != 0 {
+ t.Errorf("admission returned id %d, want 0", id)
+ }
+ if errors.Is(err, password.ErrRefused) {
+ t.Error("a storage failure was reported as a policy refusal; it must stay an error so password logs it")
+ }
+ if calls := u.created(); len(calls) != 0 {
+ t.Errorf("the app's Create ran %v before the roster was known to be writable; that manufactures an orphan", calls)
+ }
+}
+
+// ------------------------------------- admission's own normalisation
+
+// TestAdmittingNormalisesTheSubmittedAddress hands admission the
+// address EXACTLY as it came off the form, with password's own
+// folding removed.
+//
+// Nothing is broken today — password/handlers.go normalises identically
+// before calling Create — but Admitting's doc comment claims it
+// normalises both sides of the email match, and until now every test
+// pre-folded the address in the harness, so the claim was never
+// exercised. Admission must not depend on its caller for the property
+// its own security rule rests on.
+func TestAdmittingNormalisesTheSubmittedAddress(t *testing.T) {
+ h := guardedHarness(t)
+ inv, token := invited(t, h, "admin@corp.test", idear.RoleAdmin)
+
+ u := newUsers()
+ id, err := attemptAsTyped(t, h.Roster, u, signupForm(" Admin@Corp.Test ", token), true)
+ if err != nil {
+ t.Fatalf("an invited signup was refused because of how it was typed: %v", err)
+ }
+
+ m := memberFor(t, h, "admin@corp.test")
+ if m == nil {
+ t.Fatal("no member row at the normalised address; the row was written under the typed spelling")
+ }
+ if m.Role != idear.RoleAdmin {
+ t.Errorf("role = %s, want %s", m.Role, idear.RoleAdmin)
+ }
+ if want := strconv.FormatInt(id, 10); m.Subject != want {
+ t.Errorf("Subject = %q, want %q", m.Subject, want)
+ }
+ if got := h.Invitation(inv.ID); got.AcceptedAt == nil {
+ t.Error("the invitation was not consumed")
+ }
+}
+
+// TestAdmittingRefusesAnUnnormalisedUninvitedAddress is the other half:
+// folding the address must not accidentally admit anyone. A typed
+// address that matches no invitation is refused however it is spelled.
+func TestAdmittingRefusesAnUnnormalisedUninvitedAddress(t *testing.T) {
+ h := guardedHarness(t)
+ invited(t, h, "admin@corp.test", idear.RoleAdmin)
+
+ u := newUsers()
+ id, err := attemptAsTyped(t, h.Roster, u, signupForm(" Admin@Corp.Test ", ""), true)
+ mustRefuse(t, id, err)
+ if got := memberFor(t, h, "admin@corp.test"); got != nil {
+ t.Fatalf("a token-less signup for an invited address admitted %+v", got)
+ }
+}
+
+// -------------------------------------------------------- the wiring
+
+// TestAdmittingRefusesWithNoCreateFunction: Admitting(nil) is a wiring
+// bug, and it must fail closed as a STORAGE error rather than as a
+// policy refusal — a visitor must never be told they are not invited
+// because the app forgot to pass its own Create.
+func TestAdmittingRefusesWithNoCreateFunction(t *testing.T) {
+ h := guardedHarness(t)
+ h.Owner()
+
+ id, err := h.Roster.Admitting(nil)(context.Background(), "anyone@corp.test", "hash")
+ if err == nil {
+ t.Fatalf("Admitting(nil) returned id %d and no error", id)
+ }
+ if id != 0 {
+ t.Errorf("Admitting(nil) returned id %d, want 0", id)
+ }
+ if errors.Is(err, password.ErrRefused) {
+ t.Error("a nil Create was reported to the visitor as a policy refusal")
+ }
+ if n := h.CountMembers(); n != 1 {
+ t.Errorf("roster has %d rows, want 1: nothing may be written", n)
+ }
+}
+
+// TestCarryTokenIgnoresTheQueryString: the invitation token is a live
+// credential. Read from the query string it would ride in the URL, and
+// from there into access logs, browser history, and the Referer header
+// of every asset the signup page loads — leaking the credential to
+// third parties who were never sent it.
+//
+// r.PostFormValue reads the body only; r.FormValue would accept both,
+// and the two are one keystroke apart.
+func TestCarryTokenIgnoresTheQueryString(t *testing.T) {
+ h := guardedHarness(t)
+ _, token := invited(t, h, "admin@corp.test", idear.RoleAdmin)
+
+ u := newUsers()
+ admitted := h.Roster.Admitting(u.create)
+
+ var (
+ id int64
+ err error
+ )
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ id, err = admitted(r.Context(), "admin@corp.test", "hash")
+ })
+ // The token is in the URL and NOWHERE else.
+ form := url.Values{"email": {"admin@corp.test"}, "password": {"a-long-enough-password"}}
+ req := httptest.NewRequest(http.MethodPost, "/signup?invite="+url.QueryEscape(token),
+ strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ h.Roster.CarryToken(inner).ServeHTTP(httptest.NewRecorder(), req)
+
+ mustRefuse(t, id, err)
+ if got := memberFor(t, h, "admin@corp.test"); got != nil {
+ t.Fatalf("a token from the query string admitted %+v", got)
+ }
+}
+
+// breakCounting fails every COUNT this roster issues, and nothing
+// else.
+//
+// It exists because DROP TABLE is too blunt to reach one branch: with
+// the table gone, Authorize's BySubject fails FIRST and returns before
+// the roster is ever counted, so the IsEmpty failure path is
+// unreachable by that fault. This one discriminates on the
+// destination gorm is scanning into — Count's is *int64, and no other
+// query idear makes has that shape — so a lookup still cleanly MISSES
+// while the count fails. That is the exact state the branch needs.
+func breakCounting(t *testing.T, h *ideartest.Harness) {
+ t.Helper()
+ err := h.DB.G.Callback().Query().Before("gorm:query").
+ Register("ideartest:break_counting", func(tx *gorm.DB) {
+ if _, counting := tx.Statement.Dest.(*int64); counting {
+ tx.AddError(errors.New("ideartest: injected count failure"))
+ }
+ })
+ if err != nil {
+ t.Fatalf("registering the count fault: %v", err)
+ }
+}
+
+// TestAuthorizeRefusesWhenCountingFails covers the branch DROP TABLE
+// cannot reach: the address resolves to no member (a clean miss), and
+// then the roster cannot be counted.
+//
+// Getting this wrong is the worst answer in the package. "Is the
+// roster empty" failing OPEN means an arbitrary stranger is handed
+// Owner of a populated instance the moment the database hiccups.
+func TestAuthorizeRefusesWhenCountingFails(t *testing.T) {
+ h := guardedHarness(t)
+ h.Owner()
+ breakCounting(t, h)
+
+ if h.Roster.Authorize("stranger@corp.test") {
+ t.Fatal("Authorize returned true when it could not tell whether the roster was empty")
+ }
+ // Asserted through Members and Owners, not the harness's
+ // CountMembers: the injected fault breaks every COUNT in this
+ // process, the test's own included.
+ rows, err := h.Roster.Members(h.Ctx())
+ if err != nil {
+ t.Fatalf("listing members: %v", err)
+ }
+ if len(rows) != 1 {
+ t.Errorf("roster has %d rows, want 1: nothing may have been written", len(rows))
+ }
+ if owner := h.TheOwner(); owner.Email == "stranger@corp.test" {
+ t.Fatal("a failed count handed ownership to a stranger")
+ }
+}
+
+// TestSubjectIsCanonicalWhicheverSpellingWritesIt pins the WRITE half
+// of the Subject pair, which Authorize alone cannot pin because it
+// lowercases the address before it ever reaches the store.
+//
+// The caller that will hand the store a raw typed Subject is Task 5's
+// reconciliation route: POST /invitations/{token} writes a Member from
+// the LIVE session Subject, which under keymail is the address as the
+// visitor typed it. Without canonicalisation on the write, that route
+// recreates the same lockout on a different day — a member row nothing
+// can look up, or one human holding two rows past a unique index that
+// cannot see they are the same person.
+func TestSubjectIsCanonicalWhicheverSpellingWritesIt(t *testing.T) {
+ t.Run("Claim", func(t *testing.T) {
+ h := guardedHarness(t)
+ m, err := h.Roster.Claim(h.Ctx(), " Alice@Corp.Test ", "Alice@Corp.Test", "Alice")
+ if err != nil {
+ t.Fatalf("Claim: %v", err)
+ }
+ if m.Subject != "alice@corp.test" {
+ t.Errorf("stored Subject = %q, want the canonical form", m.Subject)
+ }
+ for _, spelling := range []string{"Alice@Corp.Test", "alice@corp.test", " ALICE@CORP.TEST "} {
+ if _, err := h.Roster.BySubject(h.Ctx(), spelling); err != nil {
+ t.Errorf("BySubject(%q) = %v, want the row Claim just wrote", spelling, err)
+ }
+ }
+ })
+
+ t.Run("Accept", func(t *testing.T) {
+ h := guardedHarness(t)
+ _, token := invited(t, h, "bob@corp.test", idear.RoleAdmin)
+ m, err := h.Roster.Accept(h.Ctx(), token, "Bob@Corp.Test", "Bob")
+ if err != nil {
+ t.Fatalf("Accept: %v", err)
+ }
+ if m.Subject != "bob@corp.test" {
+ t.Errorf("stored Subject = %q, want the canonical form", m.Subject)
+ }
+ if _, err := h.Roster.BySubject(h.Ctx(), "Bob@Corp.Test"); err != nil {
+ t.Errorf("BySubject(typed) = %v, want the row Accept just wrote", err)
+ }
+ })
+}
diff --git a/middleware_test.go b/middleware_test.go
index e4784e2..9403fbc 100644
--- a/middleware_test.go
+++ b/middleware_test.go
@@ -252,3 +252,32 @@ func TestRequireHonoursSubjectNotOk(t *testing.T) {
t.Error("Require called next for a subject its own resolver refused")
}
}
+
+// TestRequireAnswersNotFoundWhenTheStoreIsBroken: a storage failure is
+// NOT a membership answer, and it is deliberately rendered as one.
+//
+// A 500 here would hand a prober a signal that varies with the
+// database rather than with membership, and it would look different
+// from every other refusal this middleware makes. Fail closed, render
+// the app's own 404, and put the distinction in the log — the same
+// posture Authorize takes for the same reason.
+func TestRequireAnswersNotFoundWhenTheStoreIsBroken(t *testing.T) {
+ h := guardedHarness(t)
+ owner := h.Owner()
+ if err := h.DB.G.Exec("DROP TABLE idear_members").Error; err != nil {
+ t.Fatalf("dropping idear_members: %v", err)
+ }
+
+ var s spy
+ w := as(owner.Subject, h.Roster.Require(s.handler()))
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404: a broken store must not answer differently from a refusal", w.Code)
+ }
+ if got := w.Body.String(); got != appNotFound {
+ t.Errorf("body = %q, want the app's own 404 page %q", got, appNotFound)
+ }
+ if s.called {
+ t.Error("Require called next when it could not resolve the viewer at all")
+ }
+}
diff --git a/roster.go b/roster.go
index d4eecc1..cd18430 100644
--- a/roster.go
+++ b/roster.go
@@ -152,6 +152,44 @@ func normalizeEmail(email string) string {
return strings.ToLower(strings.TrimSpace(email))
}
+// normalizeSubject is the one spelling of a session Subject idear
+// stores or compares. Every Subject WRITE goes through it, and so does
+// the one place a Subject is read back (BySubject), because a
+// canonical form applied to only one side of a comparison is worse
+// than none: it silently stops matching.
+//
+// It is not normalizeEmail under another name, even though the two
+// bodies agree today. They canonicalise different things for different
+// reasons, and a future change to how ADDRESSES are folded must not
+// silently rewrite what a SUBJECT is — a Subject is the join to the
+// app's own identity, and changing its spelling orphans every existing
+// row.
+//
+// THE FAILURE IT EXISTS TO PREVENT, which is not hypothetical. Under
+// keymail the Subject IS the verified address, and auth mints it as
+// the address THE VISITOR TYPED: auth/handlers.go's admit does
+// sessions.Session{Subject: id.Address}, and Identity.Address comes
+// from keymaildev/signin, whose SplitAddress lowercases only the
+// DOMAIN and deliberately preserves the local part's case
+// (signin.go:57-74; flow.go:164 stores the raw typed string). So a
+// first arrival who types "Alice@Corp.Test" — which is what an iOS
+// keyboard capitalises by default — would claim the instance under a
+// lowercased Subject, then sign in successfully forever and 404 on
+// every guarded route forever, /members included, unable to invite
+// anyone. The claim is spent and the instance needs database surgery.
+//
+// Lowercasing BOTH sides is the fix rather than storing the raw typed
+// string, because raw storage still lets one human hold two rows
+// ("alice@" and "Alice@") past a unique index that cannot see they are
+// the same person. signin itself compares addresses with
+// strings.EqualFold (flow.go:227), so case-insensitive is the
+// framework's own notion of address equality.
+//
+// It is a no-op on password's subjects, which are decimal ids.
+func normalizeSubject(subject string) string {
+ return strings.ToLower(strings.TrimSpace(subject))
+}
+
// forbidden builds an ErrForbidden carrying reason, so a log line can
// say what was refused while callers still test with errors.Is.
func forbidden(format string, args ...any) error {
@@ -231,12 +269,12 @@ func (rs *Roster) IsEmpty(ctx context.Context) (bool, error) {
// membership — which the signed-in reconciliation route exists to heal
// (design spec §5). That is a designed path, not an accident.
func (rs *Roster) Claim(ctx context.Context, subject, email, name string) (*Member, error) {
- subject = strings.TrimSpace(subject)
+ subject = normalizeSubject(subject)
if subject == "" {
return nil, fmt.Errorf("%w: Claim needs a non-empty subject", ErrInvalidSubject)
}
m := &Member{
- Subject: subject,
+ Subject: normalizeSubject(subject),
Email: normalizeEmail(email),
Name: strings.TrimSpace(name),
Role: RoleOwner,
@@ -399,7 +437,7 @@ func (rs *Roster) Revoke(ctx context.Context, actor *Member, id int64) error {
// returning member is readmitted by Reactivate, not by a fresh
// invitation; that is exactly why Reactivate exists (design spec §4).
func (rs *Roster) Accept(ctx context.Context, token, subject, name string) (*Member, error) {
- subject = strings.TrimSpace(subject)
+ subject = normalizeSubject(subject)
if subject == "" {
return nil, fmt.Errorf("%w: Accept needs a non-empty subject", ErrInvalidSubject)
}
@@ -454,7 +492,7 @@ func memberFromInvitation(tx *gorm.DB, inv *Invitation, subject, name string) (*
return nil, forbidden("invitation carries role %q, which cannot be granted", string(inv.Role))
}
m := &Member{
- Subject: subject,
+ Subject: normalizeSubject(subject),
Email: inv.Email,
Name: strings.TrimSpace(name),
Role: inv.Role,
@@ -492,7 +530,7 @@ func memberFromInvitation(tx *gorm.DB, inv *Invitation, subject, name string) (*
// Reactivate rather than by a fresh invitation.
func (rs *Roster) acceptByAddress(ctx context.Context, email, subject, name string) (*Member, error) {
email = normalizeEmail(email)
- subject = strings.TrimSpace(subject)
+ subject = normalizeSubject(subject)
if email == "" {
return nil, ErrNoInvitation
}
@@ -544,7 +582,7 @@ func (rs *Roster) acceptByAddress(ctx context.Context, email, subject, name stri
// only invariant in play and the statement either satisfies it or
// fails.
func (rs *Roster) addMember(ctx context.Context, subject, email, name string, role Role) (*Member, error) {
- subject = strings.TrimSpace(subject)
+ subject = normalizeSubject(subject)
if subject == "" {
return nil, fmt.Errorf("%w: addMember needs a non-empty subject", ErrInvalidSubject)
}
@@ -555,7 +593,7 @@ func (rs *Roster) addMember(ctx context.Context, subject, email, name string, ro
return nil, forbidden("ownership arrives only by Claim and moves only by Transfer")
}
m := &Member{
- Subject: subject,
+ Subject: normalizeSubject(subject),
Email: normalizeEmail(email),
Name: strings.TrimSpace(name),
Role: role,
@@ -804,8 +842,14 @@ func (rs *Roster) Transfer(ctx context.Context, owner, to *Member) error {
// to be able to tell a deactivated member from a stranger in order to
// log the difference, even though it answers both with the app's 404.
// Callers decide with Member.Active.
+//
+// The subject is canonicalised on the way in (normalizeSubject), which
+// is the READ half of a pair: every Subject write is canonicalised the
+// same way. Under keymail the Subject is the address the visitor
+// typed, so without this a member who typed a capital would resolve to
+// nothing and 404 forever. See normalizeSubject.
func (rs *Roster) BySubject(ctx context.Context, subject string) (*Member, error) {
- subject = strings.TrimSpace(subject)
+ subject = normalizeSubject(subject)
if subject == "" {
// An empty subject is a request with no session, not a
// wildcard. Answering it from the database would match