rastrillo / idear Public

idear: the membership gate and the two identity adapters (Task 4)

Require/RequireRole guard a route on active membership and rank; the
non-member and the deactivated member get the app's own 404, byte for
byte, because the difference between them is a membership oracle.
RequireRole answers 403 and stacks inside Require.

Admission is the part this design was revised around. The rule is
POSSESSION OF THE INVITATION TOKEN PLUS AN EMAIL MATCH, never an email
match alone: password.Signup verifies no address, so email-match
admission would let anyone who learns that admin@corp.test was invited
register it with their own password first and land at the invited role.
The token reaches Admitting only through CarryToken, which is mandatory
on the password path — without it every invited signup is refused, which
is loud and safe rather than quiet and permissive.

Admitting decides the role before reading anything else the form says:
zero rows claims as Owner; a live token whose Email matches the
normalised address takes the invitation's role; OpenSignUp takes Member;
otherwise password.Refuse with ONE constant string that never names the
address, because the 403 is already a distinguishable outcome and copy
that varied would make it a finer oracle than the status alone.

Authorize fills auth.Config.Authorize. It admits on the address alone
because auth calls it only after mailing that address a link and seeing
it come back — the asymmetry with password is documented at both ends. It
has no error channel, so it logs the distinction between a storage
failure and a policy denial that it cannot render.

Store side: acceptByAddress is Accept's sibling for the tokenless keymail
path, sharing memberFromInvitation so the role rule cannot drift between
them; addMember is the open-signup insert and refuses RoleOwner outright.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 7fc0ee0a939e5ac603d69ddbc69275fd9ac3f9f2 parent 35714e8
6 files changed, +1627 −8
  • admit.go +350 −0
  • admit_test.go +703 −0
  • go.sum +28 −0
  • middleware.go +131 −0
  • middleware_test.go +254 −0
  • roster.go +161 −8
diff --git a/admit.go b/admit.go
new file mode 100644
index 0000000..1683ce7
--- /dev/null
+++ b/admit.go
@@ -0,0 +1,350 @@
+package idear
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "strconv"
+
+ "github.com/carlosframework/rastrillo/password"
+)
+
+// refusedCopy is THE message every refusal renders, for every refused
+// address, on every path.
+//
+// It is a constant and not a format string, and that is a security
+// property rather than a style choice. password.Signup answers a
+// refusal 403 and a duplicate email 422, so a refused address is
+// already distinguishable from a registered one — an existence bit the
+// old always-say-duplicate behaviour hid. The design accepts that
+// trade because the duplicate message was simply FALSE. What it does
+// not accept is making the 403 a finer oracle than the status code
+// alone: copy that named the address, or that differed between "you
+// were invited but hold no token" and "you were never invited", would
+// answer questions the visitor is not entitled to ask.
+//
+// Which address was refused, and why, is logged instead. The framework
+// deliberately does not log refusals; that is idear's job.
+const refusedCopy = "Sign-up here is by invitation."
+
+// 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.
+func refused() error { return password.Refuse(refusedCopy) }
+
+// inviteTokenCtxKey is the context key CarryToken stashes the posted
+// invitation token under — a private struct type, so nothing outside
+// this package can plant one.
+type inviteTokenCtxKey struct{}
+
+// CarryToken reads the "invite" field from a posted form and stashes
+// it in the request context, where Admitting reads it back.
+//
+// It exists because password.Config.Create is
+// func(ctx, email, hash) (int64, error): it receives NO *http.Request,
+// so admission cannot read the token off the form itself. It does
+// receive r.Context(). This middleware is the whole bridge:
+//
+// mux.Handle("POST /signup", rs.CarryToken(http.HandlerFunc(ph.Signup)))
+//
+// MOUNTING IT IS MANDATORY on the password path. Without it the token
+// never reaches admission, every invited signup is refused, and the
+// instance is effectively closed — which is loud and safe rather than
+// quiet and permissive, and is exactly why admission refuses a
+// token-less signup instead of falling back to the address.
+//
+// It parses the form so it can read one field, and http.Request
+// caches that parse, so password.Signup's own ParseForm downstream is
+// a no-op rather than a second read of an already-consumed body. One
+// consequence is worth knowing: a body that FAILS to parse fails here,
+// and downstream ParseForm then returns nil against the empty form it
+// left behind — the request proceeds as a signup with no email, which
+// password re-renders as "Enter a valid email address."
+func (rs *Roster) CarryToken(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if err := r.ParseForm(); err != nil {
+ rs.cfg.Logger.Warn("idear: could not parse the signup form to carry its invitation token",
+ "path", r.URL.Path, "err", err)
+ next.ServeHTTP(w, r)
+ return
+ }
+ if token := r.PostFormValue("invite"); token != "" {
+ r = r.WithContext(context.WithValue(r.Context(), inviteTokenCtxKey{}, token))
+ }
+ next.ServeHTTP(w, r)
+ })
+}
+
+// inviteToken reads back what CarryToken stashed, or "".
+func inviteToken(ctx context.Context) string {
+ token, _ := ctx.Value(inviteTokenCtxKey{}).(string)
+ return token
+}
+
+// subjectForID is the Subject a Member row must carry for an app whose
+// identity plugin is password.
+//
+// password mints its session as
+// sessions.Session{Subject: strconv.FormatInt(id, 10), ...} —
+// signInAndRedirect in password/handlers.go, the one place both Signin
+// and Signup pass through. Any other spelling here produces a member
+// row that no session this app ever mints can resolve: the person
+// signs in successfully and then 404s on every guarded route forever.
+func subjectForID(id int64) string { return strconv.FormatInt(id, 10) }
+
+// Admitting wraps the app's own user-creating function with idear's
+// admission policy, for password.Config.Create:
+//
+// password.New(password.Config{Create: rs.Admitting(createUser(d.G)), ...})
+//
+// It decides the ROLE BEFORE it reads anything else the form says —
+// the role is never read from the form on any path — in this order:
+//
+// 1. the roster has ZERO ROWS: the first arrival claims the instance
+// as Owner;
+// 2. the request carries a VALID invitation token (unexpired,
+// unrevoked, unaccepted) whose Email equals the submitted address:
+// the invitation's role;
+// 3. Config.OpenSignUp: RoleMember;
+// 4. otherwise: refused.
+//
+// POSSESSION OF THE TOKEN IS REQUIRED. An email match alone is not
+// enough, and this is the vulnerability the whole design was revised
+// around: password.Signup never verifies an address, so admitting on
+// the address alone would let anyone who learns that admin@corp.test
+// was invited register that address with their OWN password first and
+// land at the invited role. The token arrives only via CarryToken; see
+// its doc comment for what happens when that is not mounted.
+//
+// Rule 2 compares NORMALISED addresses on both sides. Invitation
+// emails are stored trimmed and lowercased, and password lowercases
+// and trims before calling Create; comparing anything else would make
+// an invitation match only when the invitee retyped the exact
+// capitalisation they were sent.
+//
+// On success it calls the app's create, then writes the Member with
+// Subject = subjectForID(id), CAS-accepting the invitation in the same
+// transaction as the member write.
+//
+// THE ORPHAN, which is designed and not an accident: create and the
+// member write cannot be one transaction, because create is the app's
+// opaque function over the app's own tables. A failure between them
+// leaves a user row with no membership. That is the fail-closed
+// direction — the person can sign in and 404s, rather than being
+// admitted unmembered — and a plain retry does NOT heal it (the retry's
+// create fails on the now-duplicate email and never reaches the member
+// write). The signed-in reconciliation route, POST /invitations/{token},
+// is what heals it, and it is a designed path.
+//
+// A losing racer in a first-signup claim is the same orphan by another
+// route, and is refused with the same copy.
+func (rs *Roster) Admitting(create func(ctx context.Context, email, hash string) (int64, error)) func(ctx context.Context, email, hash string) (int64, error) {
+ return func(ctx context.Context, email, hash string) (int64, error) {
+ if create == nil {
+ // A wiring bug, refused as a storage failure rather than
+ // as policy: it must not read to a visitor as "you are not
+ // invited", and it must not create anybody either.
+ return 0, errors.New("idear: Admitting was given a nil create function")
+ }
+ addr := normalizeEmail(email)
+ if addr == "" {
+ return 0, fmt.Errorf("%w: admission needs a non-empty email", ErrInvalidEmail)
+ }
+ token := inviteToken(ctx)
+
+ // 1. The claim. Advisory: IsEmpty reads the read pool and can
+ // lag a concurrent Claim by a WAL snapshot. Both ways of being
+ // wrong are fail-closed — a stale "not empty" refuses a first
+ // signup that could have claimed, and a stale "empty" reaches
+ // Claim, whose own transaction is where the answer binds.
+ empty, err := rs.IsEmpty(ctx)
+ if err != nil {
+ rs.cfg.Logger.Error("idear: admission could not count the roster", "email", addr, "err", err)
+ return 0, fmt.Errorf("idear: admission: %w", err)
+ }
+ if empty {
+ // Deliberately unconditional, ahead of any token: an
+ // unclaimed instance's first account is its Owner, which
+ // outranks any role an invitation could carry. An
+ // invitation presented here simply stays pending.
+ id, err := create(ctx, addr, hash)
+ if err != nil {
+ return 0, err
+ }
+ if _, err := rs.Claim(ctx, subjectForID(id), addr, ""); err != nil {
+ if errors.Is(err, ErrOwnerExists) {
+ rs.cfg.Logger.Warn("idear: admission lost the claim race; the app user is an ORPHAN until the reconciliation route heals it",
+ "email", addr, "app_id", id)
+ return 0, refused()
+ }
+ rs.cfg.Logger.Error("idear: admission could not claim the instance; the app user is an ORPHAN",
+ "email", addr, "app_id", id, "err", err)
+ return 0, fmt.Errorf("idear: admission: %w", err)
+ }
+ rs.cfg.Logger.Info("idear: admitted the first account as owner", "email", addr, "app_id", id)
+ return id, nil
+ }
+
+ // 2. The token, PLUS an email match. The lookup is advisory
+ // too — Accept's CAS is what actually consumes the invitation,
+ // and it re-checks unexpired/unrevoked/unaccepted inside its
+ // own transaction. Only the email match is decided here, and
+ // an invitation's Email is never updated after it is written,
+ // so there is nothing for a racing writer to change under it.
+ if token != "" {
+ inv, err := rs.pendingInvitation(ctx, token)
+ switch {
+ case err == nil && normalizeEmail(inv.Email) == addr:
+ id, err := create(ctx, addr, hash)
+ if err != nil {
+ return 0, err
+ }
+ if _, err := rs.Accept(ctx, token, subjectForID(id), ""); err != nil {
+ rs.cfg.Logger.Error("idear: admission could not redeem an invitation it had just validated; the app user is an ORPHAN",
+ "email", addr, "app_id", id, "err", err)
+ return 0, fmt.Errorf("idear: admission: %w", err)
+ }
+ rs.cfg.Logger.Info("idear: admitted an invited address", "email", addr, "app_id", id, "role", string(inv.Role))
+ return id, nil
+ case err == nil:
+ // The token is real and live, but it is not this
+ // address's. Never admitted on that basis, and never
+ // told apart from a bad token in the response.
+ rs.cfg.Logger.Warn("idear: signup presented an invitation token issued to another address",
+ "email", addr)
+ case errors.Is(err, ErrNoInvitation):
+ rs.cfg.Logger.Warn("idear: signup presented an invitation token that is not redeemable",
+ "email", addr)
+ default:
+ rs.cfg.Logger.Error("idear: admission could not look up an invitation", "email", addr, "err", err)
+ return 0, fmt.Errorf("idear: admission: %w", err)
+ }
+ // Falls through: an unusable token is worth no more than
+ // no token at all, so an OPEN instance still admits at
+ // RoleMember and a closed one refuses. What it can never
+ // do is contribute a role.
+ }
+
+ // 3. Open sign-up.
+ if rs.cfg.OpenSignUp {
+ id, err := create(ctx, addr, hash)
+ if err != nil {
+ return 0, err
+ }
+ if _, err := rs.addMember(ctx, subjectForID(id), addr, "", RoleMember); err != nil {
+ rs.cfg.Logger.Error("idear: admission could not write an open-signup member; the app user is an ORPHAN",
+ "email", addr, "app_id", id, "err", err)
+ return 0, fmt.Errorf("idear: admission: %w", err)
+ }
+ rs.cfg.Logger.Info("idear: admitted an open sign-up", "email", addr, "app_id", id)
+ return id, nil
+ }
+
+ // 4. Refused. The address is logged because the copy cannot
+ // carry it; had_token says whether CarryToken delivered
+ // anything at all, which is how a mis-mounted CarryToken is
+ // told apart from a genuinely uninvited visitor.
+ rs.cfg.Logger.Info("idear: refused a signup", "email", addr, "had_token", token != "")
+ return 0, refused()
+ }
+}
+
+// Authorize is the keymail adapter: it fills auth.Config.Authorize,
+// whose contract is "given a VERIFIED address, may it have a session?"
+//
+// auth.New(auth.Config{Sessions: sess, Authorize: rs.Authorize})
+//
+// In order: an ACTIVE member is admitted; an empty roster is claimed by
+// the caller as Owner; otherwise a pending invitation FOR THAT ADDRESS
+// is accepted, writing the member with Subject = the address, which is
+// what auth mints as the session subject (auth/handlers.go's admit:
+// sessions.Session{Subject: id.Address}). Anything else is refused.
+//
+// WHY THIS MAY ADMIT ON THE ADDRESS ALONE, where Admitting may not:
+// auth calls this only after delivering a link to that address and
+// seeing it come back, so the address IS the verified credential here.
+// password verifies nothing, which is why its path demands the token
+// as well. Never call Authorize with an address a visitor merely
+// typed.
+//
+// It returns a bool with NO ERROR CHANNEL, so a database failure is
+// indistinguishable from a policy denial to the visitor — auth renders
+// both as the same 403. idear logs the distinction it cannot render;
+// an operator watching for "This address is verified but not admitted
+// here." should read the log before believing it is policy.
+//
+// Two further asymmetries with the password path, stated so nobody
+// reads the guarantee as uniform. Authorize runs BEFORE auth's
+// SecondFactor hook, so a Member row can be written for a sign-in a
+// 2FA gate never completes — self-healing on the next attempt. And
+// only keymail's admission consults this at all: password's Signin
+// runs Lookup, Verify and mint with no idear involvement, so a
+// deactivated member can still MINT a session under password. What
+// 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()
+
+ addr := normalizeEmail(address)
+ if addr == "" {
+ rs.cfg.Logger.Warn("idear: keymail admission asked about an empty address")
+ return false
+ }
+
+ // Under keymail the session Subject IS the verified address, so
+ // the member lookup is by address.
+ m, err := rs.BySubject(ctx, addr)
+ switch {
+ case err == nil && m.Active():
+ return true
+ case err == nil:
+ rs.cfg.Logger.Info("idear: keymail admission refused a deactivated member",
+ "address", addr, "member_id", m.ID)
+ return false
+ case errors.Is(err, ErrNotFound):
+ // Not a member yet. Carry on to the claim and the invitation.
+ default:
+ rs.cfg.Logger.Error("idear: keymail admission could not resolve the address; refusing, though this is NOT a policy denial",
+ "address", addr, "err", err)
+ return false
+ }
+
+ empty, err := rs.IsEmpty(ctx)
+ if err != nil {
+ rs.cfg.Logger.Error("idear: keymail admission could not count the roster; refusing, though this is NOT a policy denial",
+ "address", addr, "err", err)
+ return false
+ }
+ if empty {
+ switch _, err := rs.Claim(ctx, addr, addr, ""); {
+ case err == nil:
+ rs.cfg.Logger.Info("idear: keymail admission claimed the instance", "address", addr)
+ return true
+ case errors.Is(err, ErrOwnerExists):
+ // Lost the race to another first arrival. They may still
+ // hold an invitation of their own, so this is not the end
+ // of the road.
+ rs.cfg.Logger.Info("idear: keymail admission lost the claim race", "address", addr)
+ default:
+ rs.cfg.Logger.Error("idear: keymail admission could not claim the instance; refusing, though this is NOT a policy denial",
+ "address", addr, "err", err)
+ return false
+ }
+ }
+
+ switch m, err := rs.acceptByAddress(ctx, addr, addr, ""); {
+ case err == nil:
+ rs.cfg.Logger.Info("idear: keymail admission redeemed an invitation", "address", addr, "role", string(m.Role))
+ return true
+ case errors.Is(err, ErrNoInvitation):
+ rs.cfg.Logger.Info("idear: keymail admission refused an address with no member row and no redeemable invitation",
+ "address", addr)
+ default:
+ rs.cfg.Logger.Error("idear: keymail admission could not redeem an invitation; refusing, though this is NOT necessarily a policy denial",
+ "address", addr, "err", err)
+ }
+ return false
+}
diff --git a/admit_test.go b/admit_test.go
new file mode 100644
index 0000000..0124023
--- /dev/null
+++ b/admit_test.go
@@ -0,0 +1,703 @@
+package idear_test
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/carlosframework/rastrillo/password"
+ "github.com/carlosframework/rastrillo/sessions"
+
+ "amadan.net/rastrillo/idear"
+ "amadan.net/rastrillo/idear/internal/ideartest"
+)
+
+// ------------------------------------------------------- the app half
+
+// users stands in for the app's own user table — the thing idear does
+// NOT own. It records every call, so a test can assert the strongest
+// property a refusal has: that no app user was created at all.
+//
+// Ids start at 42 rather than 1 on purpose. Member row ids also start
+// at 1, so a Subject built from the wrong integer would still match by
+// luck in every test if both sequences agreed.
+type users struct {
+ mu sync.Mutex
+ next int64
+ byEmail map[string]int64
+ calls []string
+ fail error // when set, Create fails the way a duplicate would
+}
+
+func newUsers() *users { return &users{next: 41, byEmail: map[string]int64{}} }
+
+func (u *users) create(ctx context.Context, email, hash string) (int64, error) {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ u.calls = append(u.calls, email)
+ if u.fail != nil {
+ return 0, u.fail
+ }
+ if _, dup := u.byEmail[email]; dup {
+ return 0, errors.New("users: that email is already registered")
+ }
+ u.next++
+ u.byEmail[email] = u.next
+ return u.next, nil
+}
+
+func (u *users) lookup(ctx context.Context, email string) (int64, string, error) {
+ return 0, "", sql.ErrNoRows
+}
+
+func (u *users) created() []string {
+ u.mu.Lock()
+ defer u.mu.Unlock()
+ return append([]string(nil), u.calls...)
+}
+
+// ------------------------------------------------------- the driver
+
+// attempt runs ONE signup the way a mounted app does: an HTTP POST
+// carrying the form, through CarryToken when carry is true, into a
+// handler that calls the admitted Create with r.Context() and nothing
+// else — because r.Context() is all password.Signup hands it.
+//
+// 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.
+func attempt(t *testing.T, rs *idear.Roster, u *users, form url.Values, carry bool) (int64, error) {
+ t.Helper()
+ admitted := rs.Admitting(u.create)
+
+ var (
+ id int64
+ err error
+ )
+ inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ 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")
+ })
+
+ var h http.Handler = inner
+ if carry {
+ h = rs.CarryToken(inner)
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ h.ServeHTTP(httptest.NewRecorder(), req)
+ return id, err
+}
+
+func signupForm(email, token string) url.Values {
+ v := url.Values{"email": {email}, "password": {"a-long-enough-password"}}
+ if token != "" {
+ v.Set("invite", token)
+ }
+ return v
+}
+
+// memberFor re-reads the roster row for an address, or nil.
+func memberFor(t *testing.T, h *ideartest.Harness, email string) *idear.Member {
+ t.Helper()
+ var out []idear.Member
+ if err := h.DB.G.Where("email = ?", email).Find(&out).Error; err != nil {
+ t.Fatalf("looking up %q: %v", email, err)
+ }
+ switch len(out) {
+ case 0:
+ return nil
+ case 1:
+ return &out[0]
+ default:
+ t.Fatalf("roster holds %d rows for %q, want at most 1", len(out), email)
+ return nil
+ }
+}
+
+func mustRefuse(t *testing.T, id int64, err error) {
+ t.Helper()
+ if err == nil {
+ t.Fatalf("admission returned id %d and no error, want a refusal", id)
+ }
+ if !errors.Is(err, password.ErrRefused) {
+ t.Fatalf("admission error %v does not wrap password.ErrRefused; password renders anything else as \"already registered\" at 422", err)
+ }
+ if id != 0 {
+ t.Errorf("a refusal returned id %d, want 0", id)
+ }
+}
+
+// invited seeds a claimed instance plus one pending invitation, and
+// returns the plaintext token.
+func invited(t *testing.T, h *ideartest.Harness, email string, role idear.Role) (*idear.Invitation, string) {
+ t.Helper()
+ owner := h.Owner()
+ inv, token, err := h.Roster.Invite(h.Ctx(), owner, email, role)
+ if err != nil {
+ t.Fatalf("Invite(%q, %s): %v", email, role, err)
+ }
+ return inv, token
+}
+
+// ------------------------------------------------------ the tests
+
+// TestAdmittingRequiresTheToken is THE regression test of this task.
+//
+// An invitation exists for admin@corp.test. Someone who merely LEARNED
+// that address signs up as it, with no invite field at all. If
+// admission ever regresses to email-match-only, this signup succeeds at
+// RoleAdmin and this test is the one that goes red.
+//
+// password.Signup never verifies an address, so email-match admission
+// would hand the invited role to whoever registers the address first.
+func TestAdmittingRequiresTheToken(t *testing.T) {
+ h := guardedHarness(t)
+ invited(t, h, "admin@corp.test", idear.RoleAdmin)
+
+ u := newUsers()
+ id, err := attempt(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("an uninvited signup for an INVITED address created %+v; the token is the credential, not the address", got)
+ }
+ if calls := u.created(); len(calls) != 0 {
+ t.Errorf("the app's Create ran %v for a refused signup; a refusal must cost no app user", calls)
+ }
+}
+
+// TestAdmittingWithoutCarryTokenRefusesAnInvitedSignup pins the
+// failure mode of a mis-wired app.
+//
+// The form carries a perfectly valid token, but CarryToken is not
+// mounted, so the token never reaches the context and admission cannot
+// see it. The result must be a refusal — loud and safe — and never a
+// quiet fall-through that admits on the address alone.
+func TestAdmittingWithoutCarryTokenRefusesAnInvitedSignup(t *testing.T) {
+ h := guardedHarness(t)
+ _, token := invited(t, h, "admin@corp.test", idear.RoleAdmin)
+
+ u := newUsers()
+ id, err := attempt(t, h.Roster, u, signupForm("admin@corp.test", token), false)
+
+ mustRefuse(t, id, err)
+ if got := memberFor(t, h, "admin@corp.test"); got != nil {
+ t.Fatalf("admission created %+v with CarryToken unmounted", got)
+ }
+}
+
+// TestAdmittingAdmitsWithTheToken is the positive control the two
+// tests above need: without it they would both pass against an
+// implementation that refuses everything.
+func TestAdmittingAdmitsWithTheToken(t *testing.T) {
+ h := guardedHarness(t)
+ inv, token := invited(t, h, "admin@corp.test", idear.RoleAdmin)
+
+ u := newUsers()
+ id, err := attempt(t, h.Roster, u, signupForm("admin@corp.test", token), true)
+ if err != nil {
+ t.Fatalf("an invited signup holding its own token was refused: %v", err)
+ }
+
+ m := memberFor(t, h, "admin@corp.test")
+ if m == nil {
+ t.Fatal("admission succeeded but wrote no member row")
+ }
+ if m.Role != idear.RoleAdmin {
+ t.Errorf("role = %s, want %s — the role comes from the invitation, never from the form", m.Role, idear.RoleAdmin)
+ }
+ // Subject is what password mints as the session subject:
+ // strconv.FormatInt(id, 10) — see password/handlers.go's
+ // signInAndRedirect.
+ if want := strconv.FormatInt(id, 10); m.Subject != want {
+ t.Errorf("Subject = %q, want %q; a Subject password never mints is a member row no session can ever resolve", m.Subject, want)
+ }
+ if got := h.Invitation(inv.ID); got.AcceptedAt == nil {
+ t.Error("the invitation was not consumed; it must be single use")
+ }
+
+ // And it is single use: the same token cannot buy a second member.
+ u2 := newUsers()
+ id2, err2 := attempt(t, h.Roster, u2, signupForm("admin@corp.test", token), true)
+ mustRefuse(t, id2, err2)
+}
+
+// TestAdmittingRejectsMismatchedEmail: possession of a token is not a
+// wildcard. The token is valid; the address is somebody else's.
+func TestAdmittingRejectsMismatchedEmail(t *testing.T) {
+ h := guardedHarness(t)
+ inv, token := invited(t, h, "admin@corp.test", idear.RoleAdmin)
+
+ u := newUsers()
+ id, err := attempt(t, h.Roster, u, signupForm("attacker@corp.test", token), true)
+
+ mustRefuse(t, id, err)
+ if got := memberFor(t, h, "attacker@corp.test"); got != nil {
+ t.Fatalf("a stolen token admitted %+v", got)
+ }
+ if got := h.Invitation(inv.ID); got.AcceptedAt != nil {
+ t.Error("the invitation was consumed by a signup it did not match")
+ }
+}
+
+// TestAdmittingMatchesTheEmailAfterNormalisation is the other half of
+// the match rule: addresses are stored trimmed and lowercased, so an
+// invitation written as "Admin@Corp.Test" must still match the address
+// password hands over.
+func TestAdmittingMatchesTheEmailAfterNormalisation(t *testing.T) {
+ h := guardedHarness(t)
+ _, token := invited(t, h, " Admin@Corp.Test ", idear.RoleMember)
+
+ u := newUsers()
+ if _, err := attempt(t, h.Roster, u, signupForm("admin@corp.test", token), true); err != nil {
+ t.Fatalf("a normalised address failed to match its own invitation: %v", err)
+ }
+ if memberFor(t, h, "admin@corp.test") == nil {
+ t.Fatal("no member row for the admitted address")
+ }
+}
+
+func TestAdmittingRejectsExpiredAndRevokedTokens(t *testing.T) {
+ t.Run("expired", func(t *testing.T) {
+ h := guardedHarness(t)
+ inv, token := invited(t, h, "late@corp.test", idear.RoleAdmin)
+ h.Expire(inv.ID)
+
+ u := newUsers()
+ id, err := attempt(t, h.Roster, u, signupForm("late@corp.test", token), true)
+
+ mustRefuse(t, id, err)
+ if got := memberFor(t, h, "late@corp.test"); got != nil {
+ t.Fatalf("an expired invitation admitted %+v", got)
+ }
+ })
+
+ t.Run("revoked", func(t *testing.T) {
+ h := guardedHarness(t)
+ inv, token := invited(t, h, "gone@corp.test", idear.RoleAdmin)
+ if err := h.Roster.Revoke(h.Ctx(), h.TheOwner(), inv.ID); err != nil {
+ t.Fatalf("Revoke: %v", err)
+ }
+
+ u := newUsers()
+ id, err := attempt(t, h.Roster, u, signupForm("gone@corp.test", token), true)
+
+ mustRefuse(t, id, err)
+ if got := memberFor(t, h, "gone@corp.test"); got != nil {
+ t.Fatalf("a revoked invitation admitted %+v", got)
+ }
+ })
+}
+
+func TestAdmittingClaimsFirstAccountAsOwner(t *testing.T) {
+ h := guardedHarness(t)
+ if n := h.CountMembers(); n != 0 {
+ t.Fatalf("the roster starts with %d rows, want 0", n)
+ }
+
+ u := newUsers()
+ id, err := attempt(t, h.Roster, u, signupForm("first@corp.test", ""), true)
+ if err != nil {
+ t.Fatalf("the first signup into an empty roster was refused: %v", err)
+ }
+
+ owner := h.TheOwner()
+ if owner.Email != "first@corp.test" {
+ t.Errorf("owner email = %q, want first@corp.test", owner.Email)
+ }
+ if want := strconv.FormatInt(id, 10); owner.Subject != want {
+ t.Errorf("Subject = %q, want %q", owner.Subject, want)
+ }
+
+ // The claim closes behind them: the second arrival is refused,
+ // because the roster is no longer empty and they hold no token.
+ u2 := newUsers()
+ id2, err2 := attempt(t, h.Roster, u2, signupForm("second@corp.test", ""), true)
+ mustRefuse(t, id2, err2)
+ if n := h.CountMembers(); n != 1 {
+ t.Errorf("roster has %d rows after a refused second signup, want 1", n)
+ }
+}
+
+func TestAdmittingOpenSignUpJoinsAsMember(t *testing.T) {
+ h := ideartest.NewWith(t, idear.Config{OpenSignUp: true})
+ h.Owner() // the instance is already claimed
+
+ u := newUsers()
+ id, err := attempt(t, h.Roster, u, signupForm("anyone@corp.test", ""), true)
+ if err != nil {
+ t.Fatalf("open sign-up refused an uninvited address: %v", err)
+ }
+
+ m := memberFor(t, h, "anyone@corp.test")
+ if m == nil {
+ t.Fatal("open sign-up wrote no member row")
+ }
+ if m.Role != idear.RoleMember {
+ t.Errorf("role = %s, want %s", m.Role, idear.RoleMember)
+ }
+ if want := strconv.FormatInt(id, 10); m.Subject != want {
+ t.Errorf("Subject = %q, want %q", m.Subject, want)
+ }
+}
+
+// TestAdmittingOpenSignUpDoesNotHonourAMismatchedToken: an open
+// instance still refuses to read a role off somebody else's
+// invitation. Falling through to RoleMember is the whole point of the
+// ordering — falling through to the invitation's role would be an
+// escalation available to anyone who found a link.
+func TestAdmittingOpenSignUpDoesNotHonourAMismatchedToken(t *testing.T) {
+ h := ideartest.NewWith(t, idear.Config{OpenSignUp: true})
+ owner := h.Owner()
+ _, token, err := h.Roster.Invite(h.Ctx(), owner, "admin@corp.test", idear.RoleAdmin)
+ if err != nil {
+ t.Fatalf("Invite: %v", err)
+ }
+
+ u := newUsers()
+ if _, err := attempt(t, h.Roster, u, signupForm("attacker@corp.test", token), true); err != nil {
+ t.Fatalf("open sign-up refused: %v", err)
+ }
+ m := memberFor(t, h, "attacker@corp.test")
+ if m == nil {
+ t.Fatal("open sign-up wrote no member row")
+ }
+ if m.Role != idear.RoleMember {
+ t.Fatalf("role = %s, want %s; a token for another address must not set the role", m.Role, idear.RoleMember)
+ }
+}
+
+// TestAdmittingRefusalUsesConstantCopy: the 403 password renders is a
+// distinguishable outcome, so its COPY must not make it a finer one.
+// One string for every refused address, never interpolating the
+// address.
+func TestAdmittingRefusalUsesConstantCopy(t *testing.T) {
+ h := guardedHarness(t)
+ invited(t, h, "admin@corp.test", idear.RoleAdmin)
+
+ const (
+ one = "admin@corp.test" // invited, but holding no token
+ two = "stranger@corp.test" // never invited at all
+ )
+
+ u := newUsers()
+ _, err1 := attempt(t, h.Roster, u, signupForm(one, ""), true)
+ _, err2 := attempt(t, h.Roster, u, signupForm(two, ""), true)
+ mustRefuse(t, 0, err1)
+ mustRefuse(t, 0, err2)
+
+ // password renders the *refusal's own message, so that is the
+ // string a visitor reads.
+ m1, m2 := err1.Error(), err2.Error()
+ if m1 != m2 {
+ t.Fatalf("refusal copy differs: %q vs %q; two refused addresses must read identically", m1, m2)
+ }
+ for _, addr := range []string{one, two, "corp.test"} {
+ if strings.Contains(m1, addr) {
+ t.Errorf("refusal copy %q contains %q; an interpolated address turns the 403 into an oracle", m1, addr)
+ }
+ }
+ if m1 == "" {
+ t.Error("refusal copy is empty; password would fall back to its own generic string")
+ }
+}
+
+// TestAdmittingSubjectMatchesThePasswordSession is the evidence, not
+// the assertion: it runs the REAL password.Signup over a real sessions
+// core and checks that the session it mints resolves, through
+// idear.Require, to the member row admission wrote. Reading
+// strconv.FormatInt(id, 10) out of password/handlers.go proves what
+// the code says today; this proves the two agree.
+func TestAdmittingSubjectMatchesThePasswordSession(t *testing.T) {
+ h := ideartest.NewWith(t, idear.Config{OpenSignUp: true})
+
+ sess, err := sessions.New(sessions.Config{DB: h.DB.Writer(), Origin: "http://app.test"})
+ if err != nil {
+ t.Fatalf("sessions.New: %v", err)
+ }
+ u := newUsers()
+ render := func(w http.ResponseWriter, r *http.Request, d password.PageData) {
+ fmt.Fprintf(w, "form: %s", d.Error)
+ }
+ ph, err := password.New(password.Config{
+ Sessions: sess,
+ Lookup: u.lookup,
+ Create: h.Roster.Admitting(u.create),
+ RenderSignin: render,
+ RenderSignup: render,
+ })
+ if err != nil {
+ t.Fatalf("password.New: %v", err)
+ }
+
+ var seen struct{ session, member, role string }
+ mux := http.NewServeMux()
+ mux.Handle("POST /signup", h.Roster.CarryToken(http.HandlerFunc(ph.Signup)))
+ mux.Handle("GET /whoami", sess.Middleware(h.Roster.Require(http.HandlerFunc(
+ func(w http.ResponseWriter, r *http.Request) {
+ s, _ := sessions.Current(r)
+ m := idear.From(r)
+ seen.session, seen.member, seen.role = s.Subject, m.Subject, string(m.Role)
+ }))))
+
+ form := signupForm("first@corp.test", "")
+ req := httptest.NewRequest(http.MethodPost, "http://app.test/signup", strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ w := httptest.NewRecorder()
+ mux.ServeHTTP(w, req)
+ if w.Code != http.StatusSeeOther {
+ t.Fatalf("signup status = %d, want 303; body %q", w.Code, w.Body.String())
+ }
+ cookies := w.Result().Cookies()
+ if len(cookies) == 0 {
+ t.Fatal("signup minted no session cookie")
+ }
+
+ who := httptest.NewRequest(http.MethodGet, "http://app.test/whoami", nil)
+ for _, c := range cookies {
+ who.AddCookie(c)
+ }
+ w2 := httptest.NewRecorder()
+ mux.ServeHTTP(w2, who)
+ if w2.Code != http.StatusOK {
+ t.Fatalf("whoami status = %d, want 200; the minted session did not resolve to a member", w2.Code)
+ }
+ if seen.session != seen.member {
+ t.Errorf("session Subject %q != member Subject %q", seen.session, seen.member)
+ }
+ if want := strconv.FormatInt(u.byEmail["first@corp.test"], 10); seen.member != want {
+ t.Errorf("member Subject = %q, want %q (the app id password formats)", seen.member, want)
+ }
+ if seen.role != string(idear.RoleOwner) {
+ t.Errorf("role = %q, want owner: the first account through the real handler claims the instance", seen.role)
+ }
+}
+
+// ---------------------------------------------------------- Authorize
+
+// TestAuthorizeAdmitsInvitedAddressOnce: under keymail the address IS
+// verified, so a pending invitation for it is the credential — and it
+// is spent exactly once. Deactivating the member and asking again must
+// be false: the consumed invitation cannot readmit them (readmission
+// is Reactivate's job), and nothing may quietly mint a second row.
+func TestAuthorizeAdmitsInvitedAddressOnce(t *testing.T) {
+ h := guardedHarness(t)
+ inv, _ := invited(t, h, "new@corp.test", idear.RoleMember)
+
+ if !h.Roster.Authorize("new@corp.test") {
+ t.Fatal("Authorize refused an address holding a pending invitation")
+ }
+ m := memberFor(t, h, "new@corp.test")
+ if m == nil {
+ t.Fatal("Authorize admitted the address but wrote no member row")
+ }
+ if m.Subject != "new@corp.test" {
+ t.Errorf("Subject = %q, want the address itself — keymail's session subject IS the verified address", m.Subject)
+ }
+ if m.Role != idear.RoleMember {
+ t.Errorf("role = %s, want %s", m.Role, idear.RoleMember)
+ }
+ spent := h.Invitation(inv.ID)
+ if spent.AcceptedAt == nil {
+ t.Fatal("the invitation was not consumed")
+ }
+
+ // An active member is admitted again without touching an
+ // invitation at all.
+ if !h.Roster.Authorize("new@corp.test") {
+ t.Fatal("Authorize refused an active member")
+ }
+
+ // Now remove them. The invitation is spent, so there is nothing
+ // left to readmit on.
+ if err := h.Roster.Deactivate(h.Ctx(), h.TheOwner(), m); err != nil {
+ t.Fatalf("Deactivate: %v", err)
+ }
+ if h.Roster.Authorize("new@corp.test") {
+ t.Fatal("Authorize admitted a deactivated member; a consumed invitation must not readmit them")
+ }
+ if n := h.CountMembers(); n != 2 {
+ t.Errorf("roster has %d rows, want 2 (the owner and the deactivated member)", n)
+ }
+ if again := h.Invitation(inv.ID); !again.AcceptedAt.Equal(*spent.AcceptedAt) {
+ t.Error("the invitation was consumed a second time")
+ }
+}
+
+func TestAuthorizeClaimsAnEmptyRoster(t *testing.T) {
+ h := guardedHarness(t)
+ if !h.Roster.Authorize("first@corp.test") {
+ t.Fatal("Authorize refused the first arrival into an empty roster")
+ }
+ owner := h.TheOwner()
+ if owner.Subject != "first@corp.test" || owner.Role != idear.RoleOwner {
+ t.Errorf("claimed %+v, want the address as Subject at owner", owner)
+ }
+ // And the claim is closed behind them.
+ if h.Roster.Authorize("second@corp.test") {
+ t.Fatal("Authorize claimed a second owner")
+ }
+ if n := h.CountMembers(); n != 1 {
+ t.Errorf("roster has %d rows, want 1", n)
+ }
+}
+
+func TestAuthorizeRefusesAStranger(t *testing.T) {
+ h := guardedHarness(t)
+ h.Owner()
+ if h.Roster.Authorize("stranger@corp.test") {
+ t.Fatal("Authorize admitted an address with no member row and no invitation")
+ }
+ if got := memberFor(t, h, "stranger@corp.test"); got != nil {
+ t.Fatalf("a refused address left %+v behind", got)
+ }
+}
+
+// TestAuthorizeNormalisesTheAddress: auth hands over whatever the
+// visitor typed into the magic-link form. Stored addresses are trimmed
+// and lowercased, so an untrimmed one must still resolve — otherwise a
+// member is locked out by their own capitalisation.
+func TestAuthorizeNormalisesTheAddress(t *testing.T) {
+ h := guardedHarness(t)
+ h.Owner()
+ h.MemberAs("member@corp.test", "member@corp.test", "", idear.RoleMember)
+ if !h.Roster.Authorize(" Member@Corp.Test ") {
+ t.Fatal("Authorize refused an active member whose address arrived unnormalised")
+ }
+ if h.Roster.Authorize("") {
+ t.Fatal("Authorize admitted an empty address")
+ }
+}
+
+// TestAuthorizeRejectsExpiredAndRevokedInvitations: the keymail path
+// admits on a verified address, so the invitation's own liveness is
+// the ONLY thing standing between a withdrawn offer and a session.
+func TestAuthorizeRejectsExpiredAndRevokedInvitations(t *testing.T) {
+ t.Run("expired", func(t *testing.T) {
+ h := guardedHarness(t)
+ inv, _ := invited(t, h, "late@corp.test", idear.RoleAdmin)
+ h.Expire(inv.ID)
+
+ if h.Roster.Authorize("late@corp.test") {
+ t.Fatal("Authorize admitted an expired invitation")
+ }
+ if got := memberFor(t, h, "late@corp.test"); got != nil {
+ t.Fatalf("an expired invitation wrote %+v", got)
+ }
+ if got := h.Invitation(inv.ID); got.AcceptedAt != nil {
+ t.Error("an expired invitation was marked accepted")
+ }
+ })
+
+ t.Run("revoked", func(t *testing.T) {
+ h := guardedHarness(t)
+ inv, _ := invited(t, h, "gone@corp.test", idear.RoleAdmin)
+ if err := h.Roster.Revoke(h.Ctx(), h.TheOwner(), inv.ID); err != nil {
+ t.Fatalf("Revoke: %v", err)
+ }
+
+ if h.Roster.Authorize("gone@corp.test") {
+ t.Fatal("Authorize admitted a revoked invitation")
+ }
+ if got := memberFor(t, h, "gone@corp.test"); got != nil {
+ t.Fatalf("a revoked invitation wrote %+v", got)
+ }
+ if got := h.Invitation(inv.ID); got.AcceptedAt != nil {
+ t.Error("a revoked invitation was marked accepted")
+ }
+ })
+}
+
+// TestConcurrentAuthorizeClaimsOneOwner races the keymail path's own
+// claim. Authorize has no error channel and swallows ErrOwnerExists to
+// carry on to the invitation check, so the loser must come out as a
+// plain refusal — and the roster must come out with exactly ONE owner,
+// not two.
+//
+// Twenty fresh instances, because a single scheduling of a race that
+// passes proves only that one scheduling passed.
+func TestConcurrentAuthorizeClaimsOneOwner(t *testing.T) {
+ for i := 0; i < 20; i++ {
+ h := ideartest.New(t)
+
+ const racers = 6
+ var (
+ wg sync.WaitGroup
+ mu sync.Mutex
+ admitted []string
+ )
+ start := make(chan struct{})
+ for j := 0; j < racers; j++ {
+ addr := fmt.Sprintf("racer-%d@corp.test", j)
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ <-start
+ if h.Roster.Authorize(addr) {
+ mu.Lock()
+ admitted = append(admitted, addr)
+ mu.Unlock()
+ }
+ }()
+ }
+ close(start)
+ wg.Wait()
+
+ if len(admitted) != 1 {
+ t.Fatalf("round %d: %d racers were admitted (%v), want exactly 1", i, len(admitted), admitted)
+ }
+ owner := h.TheOwner()
+ if owner.Subject != admitted[0] {
+ t.Fatalf("round %d: the owner is %q but %q was the admitted racer", i, owner.Subject, admitted[0])
+ }
+ if n := h.CountMembers(); n != 1 {
+ t.Fatalf("round %d: roster has %d rows, want 1", i, n)
+ }
+ }
+}
+
+// TestAdmittingOpenSignUpStillHonoursAMatchingToken pins the ORDER of
+// rules 2 and 3, which is invisible until an instance is open.
+//
+// An open instance admits everyone at RoleMember, so it is tempting to
+// answer that first and skip the invitation lookup entirely. Doing so
+// silently demotes every invited Admin to Member on the day someone
+// flips OpenSignUp on, and leaves their invitation pending — a live
+// credential for a role its holder was told they already had.
+func TestAdmittingOpenSignUpStillHonoursAMatchingToken(t *testing.T) {
+ h := ideartest.NewWith(t, idear.Config{OpenSignUp: true})
+ owner := h.Owner()
+ inv, token, err := h.Roster.Invite(h.Ctx(), owner, "admin@corp.test", idear.RoleAdmin)
+ if err != nil {
+ t.Fatalf("Invite: %v", err)
+ }
+
+ u := newUsers()
+ if _, err := attempt(t, h.Roster, u, signupForm("admin@corp.test", token), true); err != nil {
+ t.Fatalf("an invited signup into an open instance was refused: %v", err)
+ }
+
+ m := memberFor(t, h, "admin@corp.test")
+ if m == nil {
+ t.Fatal("no member row")
+ }
+ if m.Role != idear.RoleAdmin {
+ t.Errorf("role = %s, want %s: the invitation is checked BEFORE open sign-up", m.Role, idear.RoleAdmin)
+ }
+ if got := h.Invitation(inv.ID); got.AcceptedAt == nil {
+ t.Error("the invitation was left pending by a signup that redeemed it")
+ }
+}
diff --git a/go.sum b/go.sum
index f11315a..aab4073 100644
--- a/go.sum
+++ b/go.sum
@@ -1,35 +1,63 @@
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/carlosframework/rastrillo v0.18.1-0.20260823225238-7439afc0d687 h1:sfD7narHWcha0swHvv3vblvzxhbwgHRI8kI5F4P/YgQ=
github.com/carlosframework/rastrillo v0.18.1-0.20260823225238-7439afc0d687/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
+github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
+github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
+modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/middleware.go b/middleware.go
new file mode 100644
index 0000000..9d9cd52
--- /dev/null
+++ b/middleware.go
@@ -0,0 +1,131 @@
+package idear
+
+import (
+ "context"
+ "errors"
+ "net/http"
+)
+
+// memberCtxKey is the context key Require stashes the viewer under. It
+// is a private struct type, so no other package can collide with it or
+// forge a viewer by writing to a string key of the same name.
+type memberCtxKey struct{}
+
+// From returns the Member Require resolved for this request, and nil
+// when there is none — a handler mounted outside Require, or one
+// mounted inside it that somehow ran anyway.
+//
+// It follows auth.From's shape with one deliberate difference: a nil
+// *Member rather than a (value, ok) pair, because Member.Active
+// already answers correctly for nil and the common call is
+// `if m := idear.From(r); m != nil`.
+func From(r *http.Request) *Member {
+ m, _ := r.Context().Value(memberCtxKey{}).(*Member)
+ return m
+}
+
+// WithMember returns a request whose context carries m for From. It is
+// the stash half of Require, exported for the same reason
+// sessions.WithSession is: a test, or an app that resolves the viewer
+// some other way, must be able to put one there.
+func WithMember(r *http.Request, m *Member) *http.Request {
+ return r.WithContext(context.WithValue(r.Context(), memberCtxKey{}, m))
+}
+
+// Require guards a handler: the request must carry a session subject
+// (Config.Subject) that resolves to a member of this instance, and
+// that member must be ACTIVE. Anything else is answered by
+// Config.NotFound and next is never called. The viewer rides the
+// request context for From.
+//
+// It NEVER redirects. A signed-out request is the upstream
+// middleware's business — mount this inside a sessions.Require (or
+// auth.RequireSession) group and let that decide what a visitor with
+// no session sees. Require's only job is membership.
+//
+// A non-member and a deactivated member are answered IDENTICALLY, on
+// purpose: the difference is a membership oracle, and Config.NotFound
+// must be the same renderer the app gives chi's own NotFound for the
+// same reason. idear logs the distinction it refuses to render.
+//
+// THE TRAP, and it is silent: mounted OUTSIDE the app's session guard,
+// Config.Subject resolves nothing on every request and every request
+// 404s — including requests from the Owner. The response is
+// indistinguishable from a real refusal, so nothing but a log line
+// will tell you. If a correctly-signed-in member is getting 404s from
+// an idear-guarded route, this is the first thing to check.
+func (rs *Roster) Require(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ subject, ok := rs.cfg.Subject(r)
+ if !ok || subject == "" {
+ // Warn, not Info: at this mount point every request will
+ // 404 forever, and the response cannot say so.
+ rs.cfg.Logger.Warn("idear: no session subject on a guarded route; is idear's Require mounted INSIDE the app's session guard?",
+ "path", r.URL.Path)
+ rs.cfg.NotFound(w, r)
+ return
+ }
+
+ m, err := rs.BySubject(r.Context(), subject)
+ switch {
+ case errors.Is(err, ErrNotFound):
+ rs.cfg.Logger.Info("idear: refused a non-member", "subject", subject, "path", r.URL.Path)
+ rs.cfg.NotFound(w, r)
+ return
+ case err != nil:
+ // A storage failure is NOT a membership answer, but it is
+ // rendered as one: 404 is the fail-closed direction, and
+ // inventing a 500 here would hand a prober a signal that
+ // varies with the database rather than with membership.
+ // The log line is the only place the difference exists.
+ rs.cfg.Logger.Error("idear: resolving the viewer failed; refusing as if not a member",
+ "subject", subject, "path", r.URL.Path, "err", err)
+ rs.cfg.NotFound(w, r)
+ return
+ }
+ if !m.Active() {
+ rs.cfg.Logger.Info("idear: refused a deactivated member", "subject", subject, "member_id", m.ID, "path", r.URL.Path)
+ rs.cfg.NotFound(w, r)
+ return
+ }
+
+ next.ServeHTTP(w, WithMember(r, m))
+ })
+}
+
+// RequireRole guards a handler with a rank floor: the viewer must be a
+// member of at least min. Below it, Config.Forbidden answers — 403,
+// not 404, because a member may legitimately know the page exists and
+// merely may not act on it.
+//
+// It STACKS INSIDE Require and is not usable on its own:
+//
+// rs.Require(rs.RequireRole(idear.RoleAdmin)(h)) // correct
+// rs.RequireRole(idear.RoleAdmin)(h) // WRONG
+//
+// Mounted bare it reads a viewer From never put there, and answers a
+// NON-MEMBER 403 — which tells a stranger this route exists and breaks
+// the 404 rule Require holds everywhere else. The refusal is
+// deliberately not softened to 404 here: quietly papering over the
+// mis-mount would leave the route running without the membership check
+// Require performs, which is the worse half of the bug.
+func (rs *Roster) RequireRole(min Role) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ m := From(r)
+ if m == nil {
+ rs.cfg.Logger.Warn("idear: RequireRole found no viewer; it must be mounted INSIDE Require",
+ "path", r.URL.Path)
+ rs.cfg.Forbidden(w, r)
+ return
+ }
+ if !m.Active() || !m.Role.AtLeast(min) {
+ rs.cfg.Logger.Info("idear: refused a member below the required rank",
+ "subject", m.Subject, "role", string(m.Role), "min", string(min), "path", r.URL.Path)
+ rs.cfg.Forbidden(w, r)
+ return
+ }
+ next.ServeHTTP(w, r)
+ })
+ }
+}
diff --git a/middleware_test.go b/middleware_test.go
new file mode 100644
index 0000000..e4784e2
--- /dev/null
+++ b/middleware_test.go
@@ -0,0 +1,254 @@
+package idear_test
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/carlosframework/rastrillo/sessions"
+
+ "amadan.net/rastrillo/idear"
+ "amadan.net/rastrillo/idear/internal/ideartest"
+)
+
+// The two renderers below are deliberately NOT http.NotFound's and
+// http.Error's default output.
+//
+// Config.NotFound must be the same renderer the app gives chi's own
+// NotFound: an app with a custom 404 page and idear's default
+// http.NotFound produces two DISTINGUISHABLE 404s, and that delta is
+// the membership oracle the design forbids. A test that left the
+// default in place could not tell whether the hook was consulted at
+// all.
+const (
+ appNotFound = "the app's own 404 page"
+ appForbidden = "the app's own 403 page"
+)
+
+// guardedHarness is a roster whose refusals are the app's own pages.
+func guardedHarness(t *testing.T) *ideartest.Harness {
+ t.Helper()
+ return ideartest.NewWith(t, idear.Config{
+ NotFound: func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ io.WriteString(w, appNotFound)
+ },
+ Forbidden: func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ io.WriteString(w, appForbidden)
+ },
+ })
+}
+
+// spy is the guarded handler: it records that it ran and what viewer
+// idear.From handed it. A middleware test that only checked the status
+// code would not notice a Require that answered 404 AND still called
+// next.
+type spy struct {
+ called bool
+ member *idear.Member
+}
+
+func (s *spy) handler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ s.called = true
+ s.member = idear.From(r)
+ io.WriteString(w, "the guarded page")
+ })
+}
+
+// as issues one GET through h carrying subject's session, exactly as
+// the app's own session middleware would have stashed it. An EMPTY
+// subject means no session at all — which is what Require sees when it
+// is mounted outside the app's session guard.
+func as(subject string, h http.Handler) *httptest.ResponseRecorder {
+ r := httptest.NewRequest(http.MethodGet, "/members", nil)
+ if subject != "" {
+ r = sessions.WithSession(r, sessions.Session{Subject: subject})
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, r)
+ return w
+}
+
+func TestRequireAdmitsAnActiveMember(t *testing.T) {
+ h := guardedHarness(t)
+ owner := h.Owner()
+
+ var s spy
+ w := as(owner.Subject, h.Roster.Require(s.handler()))
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200; body %q", w.Code, w.Body.String())
+ }
+ if !s.called {
+ t.Fatal("Require did not call next for an active member")
+ }
+ if s.member == nil {
+ t.Fatal("idear.From returned nil inside Require; the viewer must ride the context")
+ }
+ if s.member.ID != owner.ID || s.member.Role != idear.RoleOwner {
+ t.Errorf("From = %+v, want the owner %+v", s.member, owner)
+ }
+}
+
+func TestRequireAnswersNotFoundForNonMember(t *testing.T) {
+ h := guardedHarness(t)
+ h.Owner() // the instance is claimed; the visitor is simply not in it
+
+ var s spy
+ w := as("a-subject-with-no-row", h.Roster.Require(s.handler()))
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", w.Code)
+ }
+ if got := w.Body.String(); got != appNotFound {
+ t.Errorf("body = %q, want the app's own 404 page %q; a different 404 is a membership oracle", got, appNotFound)
+ }
+ if s.called {
+ t.Error("Require called next for a non-member")
+ }
+ if loc := w.Header().Get("Location"); loc != "" {
+ t.Errorf("Require redirected to %q; signed-out handling belongs to the upstream sessions.Require", loc)
+ }
+}
+
+func TestRequireAnswersNotFoundForDeactivatedMember(t *testing.T) {
+ h := guardedHarness(t)
+ h.Owner()
+ gone := h.Deactivated(idear.RoleAdmin)
+
+ var s spy
+ w := as(gone.Subject, h.Roster.Require(s.handler()))
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404; a deactivated member keeps their row and loses every privilege it carried", w.Code)
+ }
+ if s.called {
+ t.Error("Require called next for a deactivated member")
+ }
+
+ // The two refusals must be INDISTINGUISHABLE. A deactivated member
+ // who could tell their own answer apart from a stranger's has been
+ // told that this instance knows them, which is the same oracle a
+ // custom 404 page would open.
+ var s2 spy
+ stranger := as("a-subject-with-no-row", h.Roster.Require(s2.handler()))
+ if w.Code != stranger.Code || w.Body.String() != stranger.Body.String() {
+ t.Errorf("deactivated member got %d/%q, stranger got %d/%q; the two must be byte-identical",
+ w.Code, w.Body.String(), stranger.Code, stranger.Body.String())
+ }
+}
+
+// TestRequireAnswersNotFoundWithNoSession pins the design's loudest
+// silent trap: mounted OUTSIDE the app's session guard, Config.Subject
+// resolves nothing and every request 404s — including a real member's.
+// Correct, and undetectable from the response, which is why it is a
+// test and a doc comment rather than a comment alone.
+func TestRequireAnswersNotFoundWithNoSession(t *testing.T) {
+ h := guardedHarness(t)
+ owner := h.Owner()
+
+ var s spy
+ w := as("", h.Roster.Require(s.handler()))
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404", w.Code)
+ }
+ if s.called {
+ t.Error("Require called next with no session subject")
+ }
+ if loc := w.Header().Get("Location"); loc != "" {
+ t.Errorf("Require redirected to %q; it must never redirect", loc)
+ }
+ // And the same viewer, with a session, is admitted — so the 404
+ // above is the missing session and not a broken lookup.
+ var s2 spy
+ if got := as(owner.Subject, h.Roster.Require(s2.handler())); got.Code != http.StatusOK {
+ t.Fatalf("the same member WITH a session got %d, want 200", got.Code)
+ }
+}
+
+func TestRequireRoleForbidsBelowMinimum(t *testing.T) {
+ h := guardedHarness(t)
+ h.Owner()
+ plain := h.Member(idear.RoleMember)
+
+ var s spy
+ // Stacked, which is the only supported mounting: RequireRole
+ // INSIDE Require.
+ guard := h.Roster.Require(h.Roster.RequireRole(idear.RoleAdmin)(s.handler()))
+ w := as(plain.Subject, guard)
+
+ if w.Code != http.StatusForbidden {
+ t.Fatalf("status = %d, want 403; a member may legitimately see the page and merely may not act", w.Code)
+ }
+ if w.Code == http.StatusNotFound {
+ t.Error("RequireRole answered 404; 404 is for non-members, 403 is for insufficient rank")
+ }
+ if got := w.Body.String(); got != appForbidden {
+ t.Errorf("body = %q, want the app's own 403 page %q", got, appForbidden)
+ }
+ if s.called {
+ t.Error("RequireRole called next for a member below the minimum")
+ }
+}
+
+func TestRequireRoleAdmitsAtAndAboveMinimum(t *testing.T) {
+ h := guardedHarness(t)
+ owner := h.Owner()
+ admin := h.Member(idear.RoleAdmin)
+
+ for _, m := range []*idear.Member{admin, owner} {
+ var s spy
+ guard := h.Roster.Require(h.Roster.RequireRole(idear.RoleAdmin)(s.handler()))
+ w := as(m.Subject, guard)
+ if w.Code != http.StatusOK || !s.called {
+ t.Errorf("%s got %d (next called: %v), want 200 and next called", m.Role, w.Code, s.called)
+ }
+ }
+}
+
+func TestFromIsNilWithoutRequire(t *testing.T) {
+ var s spy
+ as("whoever", s.handler())
+ if s.member != nil {
+ t.Errorf("idear.From = %+v outside Require, want nil", s.member)
+ }
+}
+
+// TestRequireHonoursSubjectNotOk pins the OTHER half of the subject
+// guard. Config.Subject returns (string, bool), and an override is
+// free to return a non-empty string alongside ok=false — a stale
+// cookie's subject, say, or a half-resolved session. The bool is the
+// answer; the string is not. Reading the string and ignoring the bool
+// admits exactly the viewer the override was refusing.
+func TestRequireHonoursSubjectNotOk(t *testing.T) {
+ h := guardedHarness(t)
+ owner := h.Owner()
+
+ rs, err := idear.New(idear.Config{
+ DB: h.DB.G,
+ Subject: func(r *http.Request) (string, bool) {
+ // A real subject, refused.
+ return owner.Subject, false
+ },
+ NotFound: func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusNotFound)
+ io.WriteString(w, appNotFound)
+ },
+ })
+ if err != nil {
+ t.Fatalf("idear.New: %v", err)
+ }
+
+ var s spy
+ w := as(owner.Subject, rs.Require(s.handler()))
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status = %d, want 404: Config.Subject said ok=false", w.Code)
+ }
+ if s.called {
+ t.Error("Require called next for a subject its own resolver refused")
+ }
+}
diff --git a/roster.go b/roster.go
index fe75d5a..d4eecc1 100644
--- a/roster.go
+++ b/roster.go
@@ -427,16 +427,102 @@ func (rs *Roster) Accept(ctx context.Context, token, subject, name string) (*Mem
if err := tx.Where("token_hash = ?", hash).Take(&inv).Error; err != nil {
return err
}
- if !inv.Role.Valid() || inv.Role == RoleOwner {
- return forbidden("invitation carries role %q, which cannot be granted", string(inv.Role))
+ var mErr error
+ m, mErr = memberFromInvitation(tx, &inv, subject, name)
+ return mErr
+ })
+ if err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
+// memberFromInvitation writes the Member a JUST-CONSUMED invitation
+// buys, inside the same transaction that consumed it.
+//
+// It is shared by the two paths that spend an invitation — Accept, by
+// token, under password; acceptByAddress, by verified address, under
+// keymail — so the rule they enforce about the granted role is one
+// piece of code that cannot drift between them.
+//
+// The role is taken from the stored invitation, never from a caller,
+// and RoleOwner is refused even here: an owner-role invitation should
+// be impossible to mint, and a row that carries one is corruption, not
+// permission.
+func memberFromInvitation(tx *gorm.DB, inv *Invitation, subject, name string) (*Member, error) {
+ if !inv.Role.Valid() || inv.Role == RoleOwner {
+ return nil, forbidden("invitation carries role %q, which cannot be granted", string(inv.Role))
+ }
+ m := &Member{
+ Subject: subject,
+ Email: inv.Email,
+ Name: strings.TrimSpace(name),
+ Role: inv.Role,
+ }
+ if err := tx.Create(m).Error; err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
+// acceptByAddress is Accept for the keymail path, which has a VERIFIED
+// ADDRESS and no token: it redeems the oldest still-redeemable
+// invitation for email and writes the Member it buys.
+//
+// It exists rather than reusing Accept because only the plaintext
+// token can address a row by token_hash, and keymail never sees one —
+// auth's whole flow is "we mailed this address a link and it came
+// back". The address is the credential there, and ONLY there; the
+// password path must have the token as well, because password verifies
+// no address at all. See Roster.Authorize and Roster.Admitting.
+//
+// The consumption is a CAS with the same three conditions Accept uses,
+// in the same transaction as the Member write, for the same reason: a
+// lookup followed by a write lets Revoke commit in between and admits
+// an invitation after it was withdrawn. The SELECT above it only picks
+// a candidate — every condition is re-stated in the UPDATE and
+// rows-affected is checked, so the selection being stale costs a
+// refusal and never an admission.
+//
+// A subject that already has a Member row — a DEACTIVATED one
+// included, because removal is never a delete — collides with the
+// unique index and rolls the whole transaction back, invitation
+// included. Authorize never reaches here in that case (it refuses a
+// deactivated member first), and a returning member is readmitted by
+// 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)
+ if email == "" {
+ return nil, ErrNoInvitation
+ }
+ if subject == "" {
+ return nil, fmt.Errorf("%w: acceptByAddress needs a non-empty subject", ErrInvalidSubject)
+ }
+ now := rs.now()
+
+ var m *Member
+ err := rs.tx(ctx, func(tx *gorm.DB) error {
+ var inv Invitation
+ err := tx.Where("email = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?", email, now).
+ Order("id").Take(&inv).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return ErrNoInvitation
+ }
+ if err != nil {
+ return err
}
- m = &Member{
- Subject: subject,
- Email: inv.Email,
- Name: strings.TrimSpace(name),
- Role: inv.Role,
+ res := tx.Model(&Invitation{}).
+ Where("id = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?", inv.ID, now).
+ Update("accepted_at", now)
+ if res.Error != nil {
+ return res.Error
}
- return tx.Create(m).Error
+ if res.RowsAffected != 1 {
+ return ErrNoInvitation
+ }
+ m, err = memberFromInvitation(tx, &inv, subject, name)
+ return err
})
if err != nil {
return nil, err
@@ -444,6 +530,42 @@ func (rs *Roster) Accept(ctx context.Context, token, subject, name string) (*Mem
return m, nil
}
+// addMember writes a plain Member row at role, with no invitation
+// behind it. It is the OPEN SIGN-UP path and nothing else: every other
+// way into the roster carries an invariant (Claim's zero rows,
+// Accept's CAS) that this deliberately has none of.
+//
+// It is unexported for that reason — an exported "just add someone"
+// would be a way around every one of those invariants — and it refuses
+// RoleOwner outright, because ownership arrives only by Claim and
+// moves only by Transfer.
+//
+// One INSERT, so no transaction: the unique index on Subject is the
+// 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)
+ if subject == "" {
+ return nil, fmt.Errorf("%w: addMember needs a non-empty subject", ErrInvalidSubject)
+ }
+ if !role.Valid() {
+ return nil, fmt.Errorf("%w: %q", ErrInvalidRole, string(role))
+ }
+ if role == RoleOwner {
+ return nil, forbidden("ownership arrives only by Claim and moves only by Transfer")
+ }
+ m := &Member{
+ Subject: subject,
+ Email: normalizeEmail(email),
+ Name: strings.TrimSpace(name),
+ Role: role,
+ }
+ if err := rs.cfg.DB.WithContext(ctx).Create(m).Error; err != nil {
+ return nil, err
+ }
+ return m, nil
+}
+
// SetRole changes target's role.
//
// Refusals, in the order they are checked and for the reason each is
@@ -747,3 +869,34 @@ func (rs *Roster) PendingInvitations(ctx context.Context) ([]Invitation, error)
}
return out, nil
}
+
+// pendingInvitation resolves a plaintext token to its invitation, and
+// refuses with ErrNoInvitation for every unusable case alike: no such
+// token, already accepted, revoked, or expired. One answer, not four —
+// the holder of a token is not entitled to learn WHICH.
+//
+// It is ADVISORY and it is unexported because of that. It is answered
+// from the read pool, so it can lag a Revoke by a WAL snapshot, and it
+// is a lookup rather than a consumption. Nothing may admit on its
+// answer alone: Accept's CAS re-checks all three conditions inside the
+// transaction that spends the invitation, and that is where the answer
+// binds. Admission uses this only to decide the EMAIL MATCH, which is
+// the one fact about an invitation that never changes after it is
+// written.
+func (rs *Roster) pendingInvitation(ctx context.Context, token string) (*Invitation, error) {
+ if token == "" {
+ return nil, ErrNoInvitation
+ }
+ var inv Invitation
+ err := rs.cfg.DB.WithContext(ctx).Where("token_hash = ?", hashToken(token)).Take(&inv).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return nil, ErrNoInvitation
+ }
+ if err != nil {
+ return nil, err
+ }
+ if !inv.Pending(rs.now()) {
+ return nil, ErrNoInvitation
+ }
+ return &inv, nil
+}