rastrillo / idear Public

Clone
git clone https://amadan.net/rastrillo/idear

Plain git — no account needed to clone.

Download

Download this file

1package idear
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "net/http"
9 "strings"
10 "time"
11
12 "amadan.net/rastrillo/rastrillo/sessions"
13 "gorm.io/gorm"
14)
15
16// defaultInviteTTL is how long an invitation stays redeemable when the
17// app does not say. Seven days: long enough to survive a weekend and a
18// forwarded mail, short enough that a link found in an old inbox is
19// already dead. Round 1 of the bake-off penalised immortal invitation
20// tokens; this is that finding applied before it is earned twice.
21const defaultInviteTTL = 7 * 24 * time.Hour
22
23// Config configures New. DB is required; everything else has a
24// serviceable default.
25type Config struct {
26 // DB is the app's database, as *gorm.DB — d.G from rastrillo/db.
27 // idear's migrations (Schema, merged into the app's BootSchema)
28 // must already be applied before any method runs.
29 //
30 // It is expected to be rastrillo/db's split pool: one writer
31 // connection, several readers, routed per statement. That routing
32 // is why every mutation below funnels through tx and why nothing
33 // inside a transaction may touch rs.cfg.DB — see tx.
34 DB *gorm.DB
35
36 // OpenSignUp admits any verified address at RoleMember with no
37 // invitation. It is read by the admission adapters, not by the
38 // store: the roster itself never decides policy it was not asked
39 // about.
40 OpenSignUp bool
41
42 // InviteTTL is how long an invitation Invite mints stays
43 // redeemable. Default defaultInviteTTL.
44 InviteTTL time.Duration
45
46 // Subject resolves the viewer's session Subject from a request.
47 // Default: sessions.Current(r).Subject. It exists as an override
48 // for an app whose viewer arrives some other way; it reads a
49 // session the caller must ALREADY have resolved, so idear's
50 // middleware must be mounted inside the app's session guard.
51 Subject func(*http.Request) (string, bool)
52
53 // EmailForSubject resolves a session Subject to the address the
54 // APP knows that subject by. It is OPTIONAL, and it buys exactly
55 // one thing: reconciliation (POST /invitations/{token}) can then
56 // require the invitation to have been issued to this viewer's
57 // address — the same rule admission applies.
58 //
59 // It is not consulted under keymail, where the Subject IS the
60 // verified address and idear answers the question itself. It is
61 // for the PASSWORD path, where the Subject is an opaque decimal
62 // user id that only the app's own user table can resolve:
63 //
64 // EmailForSubject: func(ctx context.Context, subject string) (string, error) {
65 // id, err := strconv.ParseInt(subject, 10, 64)
66 // if err != nil {
67 // return "", nil
68 // }
69 // var u User
70 // switch err := g.WithContext(ctx).Where("id = ?", id).Take(&u).Error; {
71 // case errors.Is(err, gorm.ErrRecordNotFound):
72 // return "", nil
73 // case err != nil:
74 // return "", err
75 // }
76 // return u.Email, nil
77 // }
78 //
79 // LEFT NIL, PASSWORD RECONCILIATION TRUSTS POSSESSION OF THE
80 // TOKEN ALONE. idear cannot then tell whose address an invitation
81 // was issued to relative to the viewer, so any signed-in ORPHAN —
82 // someone with an app user row and no member row, which is the
83 // create-succeeded-then-member-write-failed case and the
84 // claim-race loser — may spend ANY live token they get hold of,
85 // at whatever role it carries. That population is small and not
86 // freely manufacturable, but a token is not hard to come by: with
87 // HandlerConfig.Deliver nil it goes into a browser flash cookie,
88 // and a token in a URL rides into history, referrers and logs.
89 // Set this hook and that redemption is refused.
90 //
91 // THE CONTRACT. Return the address, or "" WITH A NIL ERROR when
92 // the subject resolves to nobody — idear refuses the redemption
93 // in that case, because a subject it cannot place must not be
94 // admitted on a token alone. A non-nil error is a STORAGE
95 // FAILURE, answered 500 and never rendered as a policy refusal.
96 // The returned address is normalised before it is compared, so
97 // the app may hand back whatever spelling its own table holds.
98 EmailForSubject func(ctx context.Context, subject string) (string, error)
99
100 // NotFound answers a request from someone who is not an active
101 // member. It MUST be the same renderer the app gives chi's own
102 // NotFound: an app with a custom 404 page and idear's default
103 // http.NotFound produces two distinguishable 404s, and that delta
104 // is a membership oracle. Default http.NotFound.
105 NotFound func(http.ResponseWriter, *http.Request)
106
107 // Forbidden answers a member who may see a page but may not act on
108 // it. Default: 403 with plain text.
109 Forbidden func(http.ResponseWriter, *http.Request)
110
111 Logger *slog.Logger
112}
113
114// Roster is the store: who is in this instance, at what role, and who
115// may change that. Build exactly one per process (New) and share it —
116// it holds no per-request state.
117//
118// Every mutation below is ONE transaction, and every invariant is
119// enforced INSIDE the transaction that maintains it. An invariant
120// checked outside its transaction is not an invariant: it is a
121// prediction, and a concurrent writer is under no obligation to honour
122// it. That is the whole reason this type exists rather than a handful
123// of queries at the call sites.
124type Roster struct {
125 cfg Config
126}
127
128// New validates cfg and returns a ready *Roster.
129func New(cfg Config) (*Roster, error) {
130 if cfg.DB == nil {
131 return nil, errors.New("idear: Config.DB is required")
132 }
133 if cfg.InviteTTL == 0 {
134 cfg.InviteTTL = defaultInviteTTL
135 }
136 if cfg.Subject == nil {
137 cfg.Subject = func(r *http.Request) (string, bool) {
138 sess, ok := sessions.Current(r)
139 if !ok || sess.Subject == "" {
140 return "", false
141 }
142 return sess.Subject, true
143 }
144 }
145 if cfg.NotFound == nil {
146 cfg.NotFound = http.NotFound
147 }
148 if cfg.Forbidden == nil {
149 cfg.Forbidden = func(w http.ResponseWriter, r *http.Request) {
150 http.Error(w, "Forbidden", http.StatusForbidden)
151 }
152 }
153 if cfg.Logger == nil {
154 cfg.Logger = slog.Default()
155 }
156 return &Roster{cfg: cfg}, nil
157}
158
159// OpenSignUp reports whether this instance admits any verified address
160// at RoleMember with no invitation. The admission adapters read it;
161// the store never does.
162func (rs *Roster) OpenSignUp() bool { return rs.cfg.OpenSignUp }
163
164// InviteTTL is how long a freshly minted invitation stays redeemable.
165func (rs *Roster) InviteTTL() time.Duration { return rs.cfg.InviteTTL }
166
167// now is the one clock the store reads, and it is UTC on purpose —
168// twice over.
169//
170// The obvious reason is that every row is UTC. The second is a trap:
171// timestamps reach SQLite through the driver as time.Time.String(),
172// and String() appends " m=+0.000000001" to any time that still
173// carries a monotonic reading. A stored value with that suffix breaks
174// the text comparison the CAS in Accept depends on — "expires_at > ?"
175// would compare a monotonic-tagged string against a plain one and
176// answer nonsense. time.Now().UTC() strips the monotonic reading;
177// time.Now() alone does not, and neither does Add on top of it.
178func (rs *Roster) now() time.Time { return time.Now().UTC() }
179
180// tx runs fn in one transaction.
181//
182// Inside fn, use ONLY tx. A statement issued against rs.cfg.DB from
183// inside fn does not join the transaction: it goes to the pool whose
184// single writer connection this transaction is already holding, waits
185// for a connection that cannot be released until fn returns, and
186// HANGS — it does not error. If a test of this package ever hangs,
187// that is the first thing to look for.
188func (rs *Roster) tx(ctx context.Context, fn func(tx *gorm.DB) error) error {
189 return rs.cfg.DB.WithContext(ctx).Transaction(fn)
190}
191
192// normalizeEmail is the one spelling of an address idear stores or
193// compares. Addresses arrive from forms and identity plugins with
194// stray whitespace and arbitrary case, and an invitation whose Email
195// matches only when the invitee retypes the capitalisation they were
196// sent is not a working invitation. Every write and every comparison
197// goes through here so both sides are normalised the same way.
198func normalizeEmail(email string) string {
199 return strings.ToLower(strings.TrimSpace(email))
200}
201
202// normalizeSubject is the one spelling of a session Subject idear
203// stores or compares. Every Subject WRITE goes through it, and so does
204// the one place a Subject is read back (BySubject), because a
205// canonical form applied to only one side of a comparison is worse
206// than none: it silently stops matching.
207//
208// It is not normalizeEmail under another name, even though the two
209// bodies agree today. They canonicalise different things for different
210// reasons, and a future change to how ADDRESSES are folded must not
211// silently rewrite what a SUBJECT is — a Subject is the join to the
212// app's own identity, and changing its spelling orphans every existing
213// row.
214//
215// THE FAILURE IT EXISTS TO PREVENT, which is not hypothetical. Under
216// keymail the Subject IS the verified address, and auth mints it as
217// the address THE VISITOR TYPED: auth/handlers.go's admit does
218// sessions.Session{Subject: id.Address}, and Identity.Address comes
219// from keymaildev/signin, whose SplitAddress lowercases only the
220// DOMAIN and deliberately preserves the local part's case
221// (signin.go:57-74; flow.go:164 stores the raw typed string). So a
222// first arrival who types "Alice@Corp.Test" — which is what an iOS
223// keyboard capitalises by default — would claim the instance under a
224// lowercased Subject, then sign in successfully forever and 404 on
225// every guarded route forever, /members included, unable to invite
226// anyone. The claim is spent and the instance needs database surgery.
227//
228// Lowercasing BOTH sides is the fix rather than storing the raw typed
229// string, because raw storage still lets one human hold two rows
230// ("alice@" and "Alice@") past a unique index that cannot see they are
231// the same person. signin itself compares addresses with
232// strings.EqualFold (flow.go:227), so case-insensitive is the
233// framework's own notion of address equality.
234//
235// It is a no-op on password's subjects, which are decimal ids.
236func normalizeSubject(subject string) string {
237 return strings.ToLower(strings.TrimSpace(subject))
238}
239
240// forbidden builds an ErrForbidden carrying reason, so a log line can
241// say what was refused while callers still test with errors.Is.
242func forbidden(format string, args ...any) error {
243 return fmt.Errorf("%w: %s", ErrForbidden, fmt.Sprintf(format, args...))
244}
245
246// checkInviteRole is the role gate Invite and SetRole share.
247//
248// RoleOwner is refused OUTRIGHT here, on every path, for every actor —
249// including an actor who IS the Owner. Ownership moves only by
250// Transfer, which is the only operation that demotes the outgoing
251// Owner in the same transaction as it promotes the incoming one, and
252// therefore the only one that keeps "exactly one Owner" true at every
253// commit boundary. Any other route to RoleOwner is a second Owner.
254//
255// The second rule is subtler and closes an escalation: the granted
256// role must be within the actor's REACH — the same predicate MayActOn
257// uses to decide whom they may manage (see reaches). By default that
258// is STRICTLY BELOW: an Admin may invite or set only Member, because
259// an Admin who could mint a peer Admin would put the new Admin beyond
260// the granter's reach, and beyond the reach of every other Admin too.
261// "Admins manage Members only" (design spec §5) has to hold for
262// creation as well as for management, or it holds for neither.
263//
264// Built with PeerGrants, reach includes the actor's own rank, for
265// granting and managing alike — so an Admin may mint an Admin
266// precisely because they, and every other Admin, may then manage one.
267func checkInviteRole(actor *Member, role Role) error {
268 if !role.Valid() {
269 return fmt.Errorf("%w: %q", ErrInvalidRole, string(role))
270 }
271 if role == RoleOwner {
272 return forbidden("ownership moves only by Transfer, never by grant")
273 }
274 if !reaches(actor.Role, role) {
275 return forbidden("a %s may not grant %s", actor.Role, role)
276 }
277 return nil
278}
279
280// loadMember re-reads one member by id inside tx. Every mutation that
281// takes an *Member argument re-reads it through here rather than
282// trusting the struct it was handed: that struct was read before the
283// transaction opened, so its Role and DeactivatedAt are a claim about
284// the past. The row inside the transaction is the fact.
285func loadMember(tx *gorm.DB, id int64) (*Member, error) {
286 var m Member
287 if err := tx.Where("id = ?", id).Take(&m).Error; err != nil {
288 if errors.Is(err, gorm.ErrRecordNotFound) {
289 return nil, ErrNotFound
290 }
291 return nil, err
292 }
293 return &m, nil
294}
295
296// IsEmpty reports whether the roster has ZERO ROWS — not zero ACTIVE
297// rows. A roster whose members have every one been deactivated is not
298// empty, and must not reopen the claim: doing so would hand a stranger
299// Owner of an instance full of dormant data.
300//
301// It is advisory, and answered from the read pool, so it may lag a
302// concurrent Claim by one WAL snapshot. Nothing depends on it being
303// current: Claim re-asks the same question inside its own transaction,
304// which is where the answer is binding.
305func (rs *Roster) IsEmpty(ctx context.Context) (bool, error) {
306 var n int64
307 if err := rs.cfg.DB.WithContext(ctx).Model(&Member{}).Count(&n).Error; err != nil {
308 return false, err
309 }
310 return n == 0, nil
311}
312
313// Claim makes the first arrival the Owner of an unclaimed instance.
314//
315// It succeeds only when idear_members holds ZERO ROWS — not zero
316// ACTIVE rows — and the emptiness test and the insert are ONE
317// statement, so nothing can come between them. Two concurrent first
318// signups therefore produce exactly one Owner; the loser gets
319// ErrOwnerExists and is an orphan — a user row in the app with no
320// membership — which the signed-in reconciliation route exists to heal
321// (design spec §5). That is a designed path, not an accident.
322func (rs *Roster) Claim(ctx context.Context, subject, email, name string) (*Member, error) {
323 subject = normalizeSubject(subject)
324 if subject == "" {
325 return nil, fmt.Errorf("%w: Claim needs a non-empty subject", ErrInvalidSubject)
326 }
327 m := &Member{
328 Subject: normalizeSubject(subject),
329 Email: normalizeEmail(email),
330 Name: strings.TrimSpace(name),
331 Role: RoleOwner,
332 }
333 now := rs.now()
334 err := rs.tx(ctx, func(tx *gorm.DB) error {
335 // One statement, so the emptiness test and the insert cannot
336 // be separated by anything at all — not by another
337 // transaction, and not by another PROCESS. A count followed
338 // by an insert is also correct here, but only because
339 // rastrillo/db caps the writer pool at one connection; a
340 // second process on the same file (a rolling deploy, a cron
341 // worker) breaks that and the pair degrades to
342 // SQLITE_BUSY_SNAPSHOT — no double Owner, but an opaque
343 // driver error where the caller is owed ErrOwnerExists.
344 // Correctness that depends on an unrelated setting in another
345 // package is what breaks two releases later.
346 res := tx.Exec(`INSERT INTO idear_members
347 (subject, email, name, role, deactivated_at, created_at, updated_at)
348 SELECT ?, ?, ?, ?, NULL, ?, ?
349 WHERE NOT EXISTS (SELECT 1 FROM idear_members)`,
350 m.Subject, m.Email, m.Name, m.Role, now, now)
351 if res.Error != nil {
352 return res.Error
353 }
354 // Zero rows means the NOT EXISTS failed: the roster has rows.
355 // ALL rows, not just active ones — a fully deactivated roster
356 // is not empty and must not reopen the claim.
357 if res.RowsAffected != 1 {
358 return ErrOwnerExists
359 }
360 // Read the row back for its id and timestamps. Safe inside
361 // this transaction for the same reason Accept's read-back is:
362 // the insert above already claimed it.
363 return tx.Where("subject = ?", m.Subject).Take(m).Error
364 })
365 if err != nil {
366 return nil, err
367 }
368 return m, nil
369}
370
371// Invite mints an invitation to join at role and returns the plaintext
372// token EXACTLY ONCE — only its SHA-256 digest is stored, so a leaked
373// database yields no usable links and idear cannot re-send the old one
374// (invite again instead).
375//
376// actor must be an active member of at least Admin rank, and role must
377// be within actor's reach — strictly below their rank, or up to and
378// including it under PeerGrants; RoleOwner is refused for everyone.
379// See checkInviteRole. The actor is re-read inside the transaction, so
380// an admin deactivated a moment ago cannot still hand out invitations.
381//
382// RE-INVITING SUPERSEDES. Any invitation for the same address that is
383// still unaccepted and unrevoked is REVOKED in this same transaction,
384// so an address has at most one live invitation at a time. That is not
385// tidiness — it is the only semantics an admin would predict, and
386// without it the two identity paths disagree about which of several
387// coexisting invitations is spent:
388//
389// - keymail redeems by address, and acceptByAddress takes the OLDEST
390// redeemable row. Re-inviting Alice at the corrected higher role
391// would be silently ignored, and re-inviting her at a corrected
392// LOWER role would leave the stale higher one live for her to
393// escalate past the admin's intent.
394// - password redeems by token, so the invitee lands at whichever of
395// the several links they happen to click.
396//
397// The revocation uses the same conditions Revoke does — unaccepted and
398// unrevoked, expiry not consulted, since an expired row is already
399// dead and revoking it costs nothing — so a spent invitation is never
400// rewritten and the record of what was accepted stays true.
401func (rs *Roster) Invite(ctx context.Context, actor *Member, email string, role Role) (*Invitation, string, error) {
402 if actor == nil {
403 return nil, "", forbidden("no actor")
404 }
405 email = normalizeEmail(email)
406 if email == "" {
407 return nil, "", fmt.Errorf("%w: Invite needs a non-empty email", ErrInvalidEmail)
408 }
409 token, err := newToken()
410 if err != nil {
411 return nil, "", fmt.Errorf("idear: minting invitation token: %w", err)
412 }
413
414 now := rs.now()
415 inv := &Invitation{
416 Email: email,
417 Role: role,
418 TokenHash: hashToken(token),
419 // The .UTC() is redundant today — rs.now() already returns
420 // UTC and Add preserves the location — and it stays anyway.
421 // Expiry is decided by a TEXT comparison over
422 // time.Time.String(), and String() renders the zone: a value
423 // in Europe/Dublin becomes "... +0100 IST", which sorts
424 // against "... +0000 UTC" by the offset characters and gives
425 // an answer unrelated to which instant is later. That is a
426 // worse failure than the monotonic-reading trap in rs.now(),
427 // because it is silent and seasonal. One call pins it at the
428 // only site whose value is ever compared in SQL.
429 ExpiresAt: now.Add(rs.cfg.InviteTTL).UTC(),
430 }
431 err = rs.tx(ctx, func(tx *gorm.DB) error {
432 cur, err := loadMember(tx, actor.ID)
433 if err != nil {
434 return err
435 }
436 if err := mayManage(cur); err != nil {
437 return err
438 }
439 if err := checkInviteRole(cur, role); err != nil {
440 return err
441 }
442 // A removed member is restored, not re-invited: admission
443 // would refuse the invitation anyway, and the invitee would
444 // learn that only at the end of the link.
445 var gone Member
446 switch err := tx.Where("email = ?", email).First(&gone).Error; {
447 case err == nil && !gone.Active():
448 return ErrRemovedInvitee
449 case err != nil && !errors.Is(err, gorm.ErrRecordNotFound):
450 return err
451 }
452 // Supersede, in the SAME transaction as the create: a reader
453 // must never see two live invitations for one address, and a
454 // revocation that committed without its replacement would
455 // leave the invitee holding nothing.
456 if err := tx.Model(&Invitation{}).
457 Where("email = ? AND accepted_at IS NULL AND revoked_at IS NULL", email).
458 Update("revoked_at", now).Error; err != nil {
459 return err
460 }
461 inv.InvitedBy = cur.ID
462 return tx.Create(inv).Error
463 })
464 if err != nil {
465 return nil, "", err
466 }
467 return inv, token, nil
468}
469
470// Revoke kills an outstanding invitation. It refuses with
471// ErrNoInvitation when there is nothing to kill — no such id, or one
472// already accepted or already revoked — which is the same answer
473// Accept gives, so neither call distinguishes the cases for a caller
474// who should not be told them apart.
475//
476// The update is conditional and rows-affected-checked, so Revoke
477// racing Accept resolves one way or the other and never both: whichever
478// transaction commits first leaves the other's WHERE clause matching
479// nothing.
480func (rs *Roster) Revoke(ctx context.Context, actor *Member, id int64) error {
481 if actor == nil {
482 return forbidden("no actor")
483 }
484 now := rs.now()
485 return rs.tx(ctx, func(tx *gorm.DB) error {
486 cur, err := loadMember(tx, actor.ID)
487 if err != nil {
488 return err
489 }
490 if err := mayManage(cur); err != nil {
491 return err
492 }
493 res := tx.Model(&Invitation{}).
494 Where("id = ? AND accepted_at IS NULL AND revoked_at IS NULL", id).
495 Update("revoked_at", now)
496 if res.Error != nil {
497 return res.Error
498 }
499 if res.RowsAffected != 1 {
500 return ErrNoInvitation
501 }
502 return nil
503 })
504}
505
506// Accept redeems an invitation token and writes the Member it buys.
507//
508// The invitation is consumed by COMPARE-AND-SWAP, not by lookup: one
509// conditional UPDATE that requires the row to be unaccepted, unrevoked
510// and unexpired, with rows-affected checked, in the same transaction
511// as the Member insert. A lookup followed by a write is not the same
512// thing — between the two, Revoke can commit, and the invitation is
513// admitted after it was withdrawn.
514//
515// Single use cannot be delegated to the app's own unique-email index
516// either: idear can neither see that index nor enforce it, and the
517// app's user row is written by code idear does not control. The CAS is
518// the invariant, and it is the only one.
519//
520// The role is taken from the stored invitation, never from a caller,
521// and RoleOwner is refused even here — an owner-role invitation should
522// be impossible to mint, and a row that carries one is corruption, not
523// permission.
524//
525// A subject that already has a Member row — including a DEACTIVATED
526// one, because removal is never a delete — collides with the unique
527// index and rolls the whole transaction back, invitation included. A
528// returning member is readmitted by Reactivate, not by a fresh
529// invitation; that is exactly why Reactivate exists (design spec §4).
530func (rs *Roster) Accept(ctx context.Context, token, subject, name string) (*Member, error) {
531 subject = normalizeSubject(subject)
532 if subject == "" {
533 return nil, fmt.Errorf("%w: Accept needs a non-empty subject", ErrInvalidSubject)
534 }
535 if token == "" {
536 return nil, ErrNoInvitation
537 }
538 hash := hashToken(token)
539 now := rs.now()
540
541 var m *Member
542 err := rs.tx(ctx, func(tx *gorm.DB) error {
543 res := tx.Model(&Invitation{}).
544 Where("token_hash = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?", hash, now).
545 Update("accepted_at", now)
546 if res.Error != nil {
547 return res.Error
548 }
549 if res.RowsAffected != 1 {
550 return ErrNoInvitation
551 }
552
553 // Safe only because the CAS above just claimed this row inside
554 // this transaction: the read cannot see a competing writer.
555 var inv Invitation
556 if err := tx.Where("token_hash = ?", hash).Take(&inv).Error; err != nil {
557 return err
558 }
559 var mErr error
560 m, mErr = memberFromInvitation(tx, &inv, subject, name)
561 return mErr
562 })
563 if err != nil {
564 return nil, err
565 }
566 return m, nil
567}
568
569// memberFromInvitation writes the Member a JUST-CONSUMED invitation
570// buys, inside the same transaction that consumed it.
571//
572// It is shared by the two paths that spend an invitation — Accept, by
573// token, under password; acceptByAddress, by verified address, under
574// keymail — so the rule they enforce about the granted role is one
575// piece of code that cannot drift between them.
576//
577// The role is taken from the stored invitation, never from a caller,
578// and RoleOwner is refused even here: an owner-role invitation should
579// be impossible to mint, and a row that carries one is corruption, not
580// permission.
581func memberFromInvitation(tx *gorm.DB, inv *Invitation, subject, name string) (*Member, error) {
582 if !inv.Role.Valid() || inv.Role == RoleOwner {
583 return nil, forbidden("invitation carries role %q, which cannot be granted", string(inv.Role))
584 }
585 m := &Member{
586 Subject: normalizeSubject(subject),
587 Email: inv.Email,
588 Name: strings.TrimSpace(name),
589 Role: inv.Role,
590 }
591 if err := tx.Create(m).Error; err != nil {
592 return nil, err
593 }
594 return m, nil
595}
596
597// acceptByAddress is Accept for the keymail path, which has a VERIFIED
598// ADDRESS and no token: it redeems the oldest still-redeemable
599// invitation for email and writes the Member it buys.
600//
601// Invite revokes any live invitation for an address before writing a
602// new one, so "the oldest" is normally "the only one". The Order("id")
603// stays because this must still be deterministic against rows written
604// by a seed, a migration or a hand-repaired database — and because a
605// SELECT with no ORDER BY is a coin toss, not a rule.
606//
607// It exists rather than reusing Accept because only the plaintext
608// token can address a row by token_hash, and keymail never sees one —
609// auth's whole flow is "we mailed this address a link and it came
610// back". The address is the credential there, and ONLY there; the
611// password path must have the token as well, because password verifies
612// no address at all. See Roster.Authorize and Roster.Admitting.
613//
614// The consumption is a CAS with the same three conditions Accept uses,
615// in the same transaction as the Member write, for the same reason: a
616// lookup followed by a write lets Revoke commit in between and admits
617// an invitation after it was withdrawn. The SELECT above it only picks
618// a candidate — every condition is re-stated in the UPDATE and
619// rows-affected is checked, so the selection being stale costs a
620// refusal and never an admission.
621//
622// A subject that already has a Member row — a DEACTIVATED one
623// included, because removal is never a delete — collides with the
624// unique index and rolls the whole transaction back, invitation
625// included. Authorize never reaches here in that case (it refuses a
626// deactivated member first), and a returning member is readmitted by
627// Reactivate rather than by a fresh invitation.
628func (rs *Roster) acceptByAddress(ctx context.Context, email, subject, name string) (*Member, error) {
629 email = normalizeEmail(email)
630 subject = normalizeSubject(subject)
631 if email == "" {
632 return nil, ErrNoInvitation
633 }
634 if subject == "" {
635 return nil, fmt.Errorf("%w: acceptByAddress needs a non-empty subject", ErrInvalidSubject)
636 }
637 now := rs.now()
638
639 var m *Member
640 err := rs.tx(ctx, func(tx *gorm.DB) error {
641 var inv Invitation
642 err := tx.Where("email = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?", email, now).
643 Order("id").Take(&inv).Error
644 if errors.Is(err, gorm.ErrRecordNotFound) {
645 return ErrNoInvitation
646 }
647 if err != nil {
648 return err
649 }
650 res := tx.Model(&Invitation{}).
651 Where("id = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?", inv.ID, now).
652 Update("accepted_at", now)
653 if res.Error != nil {
654 return res.Error
655 }
656 if res.RowsAffected != 1 {
657 return ErrNoInvitation
658 }
659 m, err = memberFromInvitation(tx, &inv, subject, name)
660 return err
661 })
662 if err != nil {
663 return nil, err
664 }
665 return m, nil
666}
667
668// addMember writes a plain Member row at role, with no invitation
669// behind it. It is the OPEN SIGN-UP path and nothing else: every other
670// way into the roster carries an invariant (Claim's zero rows,
671// Accept's CAS) that this deliberately has none of.
672//
673// It is unexported for that reason — an exported "just add someone"
674// would be a way around every one of those invariants — and it refuses
675// RoleOwner outright, because ownership arrives only by Claim and
676// moves only by Transfer.
677//
678// One INSERT, so no transaction: the unique index on Subject is the
679// only invariant in play and the statement either satisfies it or
680// fails.
681func (rs *Roster) addMember(ctx context.Context, subject, email, name string, role Role) (*Member, error) {
682 subject = normalizeSubject(subject)
683 if subject == "" {
684 return nil, fmt.Errorf("%w: addMember needs a non-empty subject", ErrInvalidSubject)
685 }
686 if !role.Valid() {
687 return nil, fmt.Errorf("%w: %q", ErrInvalidRole, string(role))
688 }
689 if role == RoleOwner {
690 return nil, forbidden("ownership arrives only by Claim and moves only by Transfer")
691 }
692 m := &Member{
693 Subject: normalizeSubject(subject),
694 Email: normalizeEmail(email),
695 Name: strings.TrimSpace(name),
696 Role: role,
697 }
698 if err := rs.cfg.DB.WithContext(ctx).Create(m).Error; err != nil {
699 return nil, err
700 }
701 return m, nil
702}
703
704// SetRole changes target's role.
705//
706// Refusals, in the order they are checked and for the reason each is
707// checked where it is:
708//
709// - the new role must be one of the three, and must not be
710// RoleOwner: ownership moves only by Transfer (checkInviteRole).
711// - actor must clear the authority floor — active, at least Admin —
712// so a plain Member is refused for lacking authority and learns
713// nothing about the target.
714// - the target must not BE the Owner: ErrLastOwner. Demoting the
715// Owner by this route would leave the instance with no Owner at
716// all, so this is an invariant and not a permission, and it is
717// checked against the row inside the transaction.
718// - MayActOn(actor, target) for the rest of the matrix.
719//
720// A deactivated target may have their role changed; it takes effect if
721// and when they are reactivated.
722func (rs *Roster) SetRole(ctx context.Context, actor, target *Member, role Role) error {
723 if actor == nil || target == nil {
724 return forbidden("no actor or no target")
725 }
726 if !role.Valid() {
727 return fmt.Errorf("%w: %q", ErrInvalidRole, string(role))
728 }
729 if role == RoleOwner {
730 return forbidden("ownership moves only by Transfer, never by SetRole")
731 }
732 return rs.tx(ctx, func(tx *gorm.DB) error {
733 cur, err := loadMember(tx, actor.ID)
734 if err != nil {
735 return err
736 }
737 if err := mayManage(cur); err != nil {
738 return err
739 }
740 tgt, err := loadMember(tx, target.ID)
741 if err != nil {
742 return err
743 }
744 if tgt.Role == RoleOwner {
745 return ErrLastOwner
746 }
747 if err := MayActOn(cur, tgt); err != nil {
748 return err
749 }
750 // checkInviteRole repeats the RoleOwner refusal above, and
751 // deliberately: the early one answers before a transaction is
752 // opened, this one answers against the actor's CURRENT rank.
753 // Deleting either leaves the other holding, which a mutation
754 // run confirmed. RoleOwner is refused twice regardless of
755 // build (the early guard, and checkInviteRole's own owner
756 // branch) and in the default build a third time, by the
757 // strictly-below rank rule no actor can clear for owner.
758 // Under PeerGrants the Owner's rank DOES reach owner, so the
759 // first two are the whole defence there — which is why
760 // neither may go.
761 if err := checkInviteRole(cur, role); err != nil {
762 return err
763 }
764 res := tx.Model(&Member{}).Where("id = ? AND role <> ?", tgt.ID, RoleOwner).
765 Update("role", role)
766 if res.Error != nil {
767 return res.Error
768 }
769 if res.RowsAffected != 1 {
770 return ErrLastOwner
771 }
772 return nil
773 })
774}
775
776// Deactivate removes target from the roster — by setting
777// DeactivatedAt, never by deleting the row, because a deleted row
778// dangles every AuthorID in the app's own tables.
779//
780// The Owner can never be deactivated (ErrLastOwner), checked against
781// the row inside the transaction. That check is what makes Transfer
782// racing a Deactivate of the same target safe from this side: if the
783// transfer commits first, this transaction re-reads a target who is
784// now the Owner and refuses, instead of deactivating the Owner it just
785// became.
786//
787// Deactivating an already-deactivated member is a no-op, not an error.
788func (rs *Roster) Deactivate(ctx context.Context, actor, target *Member) error {
789 if actor == nil || target == nil {
790 return forbidden("no actor or no target")
791 }
792 now := rs.now()
793 return rs.tx(ctx, func(tx *gorm.DB) error {
794 cur, err := loadMember(tx, actor.ID)
795 if err != nil {
796 return err
797 }
798 if err := mayManage(cur); err != nil {
799 return err
800 }
801 tgt, err := loadMember(tx, target.ID)
802 if err != nil {
803 return err
804 }
805 if tgt.Role == RoleOwner {
806 return ErrLastOwner
807 }
808 if err := MayActOn(cur, tgt); err != nil {
809 return err
810 }
811 if !tgt.Active() {
812 return nil
813 }
814 res := tx.Model(&Member{}).
815 Where("id = ? AND deactivated_at IS NULL AND role <> ?", tgt.ID, RoleOwner).
816 Update("deactivated_at", now)
817 if res.Error != nil {
818 return res.Error
819 }
820 if res.RowsAffected != 1 {
821 return ErrLastOwner
822 }
823 return nil
824 })
825}
826
827// Reactivate readmits a deactivated member at the role they still
828// carry.
829//
830// It is a first-class operation and not a convenience: Subject is
831// unique and removal is deactivation, so without Reactivate a removed
832// person can never be readmitted by ANY path — keymail's Authorize
833// sees the inactive row and refuses, password re-signup hits the app's
834// duplicate-email check, and a fresh invitation's Member insert
835// collides with the dead row (design spec §4).
836//
837// Reactivating an already-active member is a no-op, not an error.
838func (rs *Roster) Reactivate(ctx context.Context, actor, target *Member) error {
839 if actor == nil || target == nil {
840 return forbidden("no actor or no target")
841 }
842 return rs.tx(ctx, func(tx *gorm.DB) error {
843 cur, err := loadMember(tx, actor.ID)
844 if err != nil {
845 return err
846 }
847 if err := mayManage(cur); err != nil {
848 return err
849 }
850 tgt, err := loadMember(tx, target.ID)
851 if err != nil {
852 return err
853 }
854 if err := MayActOn(cur, tgt); err != nil {
855 return err
856 }
857 if tgt.Active() {
858 return nil
859 }
860 res := tx.Model(&Member{}).
861 Where("id = ? AND deactivated_at IS NOT NULL", tgt.ID).
862 Update("deactivated_at", nil)
863 if res.Error != nil {
864 return res.Error
865 }
866 if res.RowsAffected != 1 {
867 return ErrNotFound
868 }
869 return nil
870 })
871}
872
873// Transfer hands ownership of the instance from owner to to, in one
874// transaction: demote the outgoing Owner to Admin, promote the
875// incoming one to Owner. Exactly one Owner exists at every commit
876// boundary, including this one — there is no instant, even inside the
877// transaction, at which the instance has two Owners or none.
878//
879// Both rows are RE-READ inside the transaction, and two things
880// confirmed about them:
881//
882// - the actor is still the Owner. Six concurrent transfers therefore
883// resolve to one: the first commits, and every other transaction
884// re-reads an actor who is now an Admin. Round 1 of the bake-off
885// found exactly this bug in a hand-rolled version, and found it
886// only because someone ran it as an actual race.
887// - the target is still ACTIVE. Without this, a transfer racing a
888// Deactivate of the same target produces a DEACTIVATED Owner: an
889// instance with nobody able to administer it and nobody able to be
890// promoted, because MayActOn lets no rank act on an Owner. It is
891// unrecoverable through idear's own API, which is what makes it
892// worth a re-read rather than a comment.
893//
894// Both updates are conditional and rows-affected-checked as well, so
895// the invariant is stated at the statement and not only in the
896// preceding reads.
897func (rs *Roster) Transfer(ctx context.Context, owner, to *Member) error {
898 if owner == nil || to == nil {
899 return forbidden("no owner or no target")
900 }
901 if owner.ID == to.ID {
902 return forbidden("ownership cannot be transferred to its current holder")
903 }
904 return rs.tx(ctx, func(tx *gorm.DB) error {
905 cur, err := loadMember(tx, owner.ID)
906 if err != nil {
907 return err
908 }
909 if !cur.Active() || cur.Role != RoleOwner {
910 return forbidden("only the current owner may transfer ownership")
911 }
912 tgt, err := loadMember(tx, to.ID)
913 if err != nil {
914 return err
915 }
916 if !tgt.Active() {
917 return forbidden("ownership cannot be transferred to a deactivated member")
918 }
919
920 res := tx.Model(&Member{}).Where("id = ? AND role = ?", cur.ID, RoleOwner).
921 Update("role", RoleAdmin)
922 if res.Error != nil {
923 return res.Error
924 }
925 if res.RowsAffected != 1 {
926 return forbidden("ownership moved before this transfer could complete")
927 }
928 res = tx.Model(&Member{}).Where("id = ? AND deactivated_at IS NULL", tgt.ID).
929 Update("role", RoleOwner)
930 if res.Error != nil {
931 return res.Error
932 }
933 if res.RowsAffected != 1 {
934 return forbidden("the target stopped being an active member before this transfer could complete")
935 }
936 return nil
937 })
938}
939
940// BySubject resolves a session Subject to its member row, active or
941// not. It deliberately does NOT filter on Active: the middleware has
942// to be able to tell a deactivated member from a stranger in order to
943// log the difference, even though it answers both with the app's 404.
944// Callers decide with Member.Active.
945//
946// The subject is canonicalised on the way in (normalizeSubject), which
947// is the READ half of a pair: every Subject write is canonicalised the
948// same way. Under keymail the Subject is the address the visitor
949// typed, so without this a member who typed a capital would resolve to
950// nothing and 404 forever. See normalizeSubject.
951func (rs *Roster) BySubject(ctx context.Context, subject string) (*Member, error) {
952 subject = normalizeSubject(subject)
953 if subject == "" {
954 // An empty subject is a request with no session, not a
955 // wildcard. Answering it from the database would match
956 // whichever row happens to have an empty subject.
957 return nil, ErrNotFound
958 }
959 var m Member
960 err := rs.cfg.DB.WithContext(ctx).Where("subject = ?", subject).Take(&m).Error
961 if errors.Is(err, gorm.ErrRecordNotFound) {
962 return nil, ErrNotFound
963 }
964 if err != nil {
965 return nil, err
966 }
967 return &m, nil
968}
969
970// ByID resolves a member id, active or not. See BySubject.
971func (rs *Roster) ByID(ctx context.Context, id int64) (*Member, error) {
972 var m Member
973 err := rs.cfg.DB.WithContext(ctx).Where("id = ?", id).Take(&m).Error
974 if errors.Is(err, gorm.ErrRecordNotFound) {
975 return nil, ErrNotFound
976 }
977 if err != nil {
978 return nil, err
979 }
980 return &m, nil
981}
982
983// Members lists the whole roster, deactivated members included — the
984// members page shows them so they can be restored, and a list that
985// hid them would make Reactivate unreachable from the UI.
986//
987// The order is Owner, then Admins, then Members, then by id. It is
988// spelled out as a CASE rather than ORDER BY role because the column
989// is text: alphabetical order would put Admin above Owner.
990func (rs *Roster) Members(ctx context.Context) ([]Member, error) {
991 var out []Member
992 err := rs.cfg.DB.WithContext(ctx).
993 Order("CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END").
994 Order("id").
995 Find(&out).Error
996 if err != nil {
997 return nil, err
998 }
999 return out, nil
1000}
1001
1002// PendingInvitations lists the invitations that can still be redeemed
1003// right now: unaccepted, unrevoked and unexpired. Expired and spent
1004// rows stay in the table as a record; they are simply not offered.
1005func (rs *Roster) PendingInvitations(ctx context.Context) ([]Invitation, error) {
1006 var out []Invitation
1007 err := rs.cfg.DB.WithContext(ctx).
1008 Where("accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?", rs.now()).
1009 Order("id").
1010 Find(&out).Error
1011 if err != nil {
1012 return nil, err
1013 }
1014 return out, nil
1015}
1016
1017// pendingInvitation resolves a plaintext token to its invitation, and
1018// refuses with ErrNoInvitation for every unusable case alike: no such
1019// token, already accepted, revoked, or expired. One answer, not four —
1020// the holder of a token is not entitled to learn WHICH.
1021//
1022// It is ADVISORY and it is unexported because of that. It is answered
1023// from the read pool, so it can lag a Revoke by a WAL snapshot, and it
1024// is a lookup rather than a consumption. Nothing may admit on its
1025// answer alone: Accept's CAS re-checks all three conditions inside the
1026// transaction that spends the invitation, and that is where the answer
1027// binds. Admission uses this only to decide the EMAIL MATCH, which is
1028// the one fact about an invitation that never changes after it is
1029// written.
1030func (rs *Roster) pendingInvitation(ctx context.Context, token string) (*Invitation, error) {
1031 if token == "" {
1032 return nil, ErrNoInvitation
1033 }
1034 var inv Invitation
1035 err := rs.cfg.DB.WithContext(ctx).Where("token_hash = ?", hashToken(token)).Take(&inv).Error
1036 if errors.Is(err, gorm.ErrRecordNotFound) {
1037 return nil, ErrNoInvitation
1038 }
1039 if err != nil {
1040 return nil, err
1041 }
1042 if !inv.Pending(rs.now()) {
1043 return nil, ErrNoInvitation
1044 }
1045 return &inv, nil
1046}
1047