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 "github.com/carlosframework/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 rank STRICTLY BELOW the actor's. An Admin may invite or
257// set only Member, because an Admin who could mint a peer Admin has
258// escalated — MayActOn refuses acting on an equal rank, so the new
259// Admin would be beyond the granter's reach, and beyond the reach of
260// every other Admin too. "Admins manage Members only" (design spec §5)
261// has to hold for creation as well as for management, or it holds for
262// neither.
263func checkInviteRole(actor *Member, role Role) error {
264 if !role.Valid() {
265 return fmt.Errorf("%w: %q", ErrInvalidRole, string(role))
266 }
267 if role == RoleOwner {
268 return forbidden("ownership moves only by Transfer, never by grant")
269 }
270 if rank(role) >= rank(actor.Role) {
271 return forbidden("a %s may not grant %s", actor.Role, role)
272 }
273 return nil
274}
275
276// loadMember re-reads one member by id inside tx. Every mutation that
277// takes an *Member argument re-reads it through here rather than
278// trusting the struct it was handed: that struct was read before the
279// transaction opened, so its Role and DeactivatedAt are a claim about
280// the past. The row inside the transaction is the fact.
281func loadMember(tx *gorm.DB, id int64) (*Member, error) {
282 var m Member
283 if err := tx.Where("id = ?", id).Take(&m).Error; err != nil {
284 if errors.Is(err, gorm.ErrRecordNotFound) {
285 return nil, ErrNotFound
286 }
287 return nil, err
288 }
289 return &m, nil
290}
291
292// IsEmpty reports whether the roster has ZERO ROWS — not zero ACTIVE
293// rows. A roster whose members have every one been deactivated is not
294// empty, and must not reopen the claim: doing so would hand a stranger
295// Owner of an instance full of dormant data.
296//
297// It is advisory, and answered from the read pool, so it may lag a
298// concurrent Claim by one WAL snapshot. Nothing depends on it being
299// current: Claim re-asks the same question inside its own transaction,
300// which is where the answer is binding.
301func (rs *Roster) IsEmpty(ctx context.Context) (bool, error) {
302 var n int64
303 if err := rs.cfg.DB.WithContext(ctx).Model(&Member{}).Count(&n).Error; err != nil {
304 return false, err
305 }
306 return n == 0, nil
307}
308
309// Claim makes the first arrival the Owner of an unclaimed instance.
310//
311// It succeeds only when idear_members holds ZERO ROWS — not zero
312// ACTIVE rows — and the emptiness test and the insert are ONE
313// statement, so nothing can come between them. Two concurrent first
314// signups therefore produce exactly one Owner; the loser gets
315// ErrOwnerExists and is an orphan — a user row in the app with no
316// membership — which the signed-in reconciliation route exists to heal
317// (design spec §5). That is a designed path, not an accident.
318func (rs *Roster) Claim(ctx context.Context, subject, email, name string) (*Member, error) {
319 subject = normalizeSubject(subject)
320 if subject == "" {
321 return nil, fmt.Errorf("%w: Claim needs a non-empty subject", ErrInvalidSubject)
322 }
323 m := &Member{
324 Subject: normalizeSubject(subject),
325 Email: normalizeEmail(email),
326 Name: strings.TrimSpace(name),
327 Role: RoleOwner,
328 }
329 now := rs.now()
330 err := rs.tx(ctx, func(tx *gorm.DB) error {
331 // One statement, so the emptiness test and the insert cannot
332 // be separated by anything at all — not by another
333 // transaction, and not by another PROCESS. A count followed
334 // by an insert is also correct here, but only because
335 // rastrillo/db caps the writer pool at one connection; a
336 // second process on the same file (a rolling deploy, a cron
337 // worker) breaks that and the pair degrades to
338 // SQLITE_BUSY_SNAPSHOT — no double Owner, but an opaque
339 // driver error where the caller is owed ErrOwnerExists.
340 // Correctness that depends on an unrelated setting in another
341 // package is what breaks two releases later.
342 res := tx.Exec(`INSERT INTO idear_members
343 (subject, email, name, role, deactivated_at, created_at, updated_at)
344 SELECT ?, ?, ?, ?, NULL, ?, ?
345 WHERE NOT EXISTS (SELECT 1 FROM idear_members)`,
346 m.Subject, m.Email, m.Name, m.Role, now, now)
347 if res.Error != nil {
348 return res.Error
349 }
350 // Zero rows means the NOT EXISTS failed: the roster has rows.
351 // ALL rows, not just active ones — a fully deactivated roster
352 // is not empty and must not reopen the claim.
353 if res.RowsAffected != 1 {
354 return ErrOwnerExists
355 }
356 // Read the row back for its id and timestamps. Safe inside
357 // this transaction for the same reason Accept's read-back is:
358 // the insert above already claimed it.
359 return tx.Where("subject = ?", m.Subject).Take(m).Error
360 })
361 if err != nil {
362 return nil, err
363 }
364 return m, nil
365}
366
367// Invite mints an invitation to join at role and returns the plaintext
368// token EXACTLY ONCE — only its SHA-256 digest is stored, so a leaked
369// database yields no usable links and idear cannot re-send the old one
370// (invite again instead).
371//
372// actor must be an active member of at least Admin rank, and role must
373// rank strictly below actor's; RoleOwner is refused for everyone. See
374// checkInviteRole. The actor is re-read inside the transaction, so an
375// admin deactivated a moment ago cannot still hand out invitations.
376//
377// RE-INVITING SUPERSEDES. Any invitation for the same address that is
378// still unaccepted and unrevoked is REVOKED in this same transaction,
379// so an address has at most one live invitation at a time. That is not
380// tidiness — it is the only semantics an admin would predict, and
381// without it the two identity paths disagree about which of several
382// coexisting invitations is spent:
383//
384// - keymail redeems by address, and acceptByAddress takes the OLDEST
385// redeemable row. Re-inviting Alice at the corrected higher role
386// would be silently ignored, and re-inviting her at a corrected
387// LOWER role would leave the stale higher one live for her to
388// escalate past the admin's intent.
389// - password redeems by token, so the invitee lands at whichever of
390// the several links they happen to click.
391//
392// The revocation uses the same conditions Revoke does — unaccepted and
393// unrevoked, expiry not consulted, since an expired row is already
394// dead and revoking it costs nothing — so a spent invitation is never
395// rewritten and the record of what was accepted stays true.
396func (rs *Roster) Invite(ctx context.Context, actor *Member, email string, role Role) (*Invitation, string, error) {
397 if actor == nil {
398 return nil, "", forbidden("no actor")
399 }
400 email = normalizeEmail(email)
401 if email == "" {
402 return nil, "", fmt.Errorf("%w: Invite needs a non-empty email", ErrInvalidEmail)
403 }
404 token, err := newToken()
405 if err != nil {
406 return nil, "", fmt.Errorf("idear: minting invitation token: %w", err)
407 }
408
409 now := rs.now()
410 inv := &Invitation{
411 Email: email,
412 Role: role,
413 TokenHash: hashToken(token),
414 // The .UTC() is redundant today — rs.now() already returns
415 // UTC and Add preserves the location — and it stays anyway.
416 // Expiry is decided by a TEXT comparison over
417 // time.Time.String(), and String() renders the zone: a value
418 // in Europe/Dublin becomes "... +0100 IST", which sorts
419 // against "... +0000 UTC" by the offset characters and gives
420 // an answer unrelated to which instant is later. That is a
421 // worse failure than the monotonic-reading trap in rs.now(),
422 // because it is silent and seasonal. One call pins it at the
423 // only site whose value is ever compared in SQL.
424 ExpiresAt: now.Add(rs.cfg.InviteTTL).UTC(),
425 }
426 err = rs.tx(ctx, func(tx *gorm.DB) error {
427 cur, err := loadMember(tx, actor.ID)
428 if err != nil {
429 return err
430 }
431 if err := mayManage(cur); err != nil {
432 return err
433 }
434 if err := checkInviteRole(cur, role); err != nil {
435 return err
436 }
437 // Supersede, in the SAME transaction as the create: a reader
438 // must never see two live invitations for one address, and a
439 // revocation that committed without its replacement would
440 // leave the invitee holding nothing.
441 if err := tx.Model(&Invitation{}).
442 Where("email = ? AND accepted_at IS NULL AND revoked_at IS NULL", email).
443 Update("revoked_at", now).Error; err != nil {
444 return err
445 }
446 inv.InvitedBy = cur.ID
447 return tx.Create(inv).Error
448 })
449 if err != nil {
450 return nil, "", err
451 }
452 return inv, token, nil
453}
454
455// Revoke kills an outstanding invitation. It refuses with
456// ErrNoInvitation when there is nothing to kill — no such id, or one
457// already accepted or already revoked — which is the same answer
458// Accept gives, so neither call distinguishes the cases for a caller
459// who should not be told them apart.
460//
461// The update is conditional and rows-affected-checked, so Revoke
462// racing Accept resolves one way or the other and never both: whichever
463// transaction commits first leaves the other's WHERE clause matching
464// nothing.
465func (rs *Roster) Revoke(ctx context.Context, actor *Member, id int64) error {
466 if actor == nil {
467 return forbidden("no actor")
468 }
469 now := rs.now()
470 return rs.tx(ctx, func(tx *gorm.DB) error {
471 cur, err := loadMember(tx, actor.ID)
472 if err != nil {
473 return err
474 }
475 if err := mayManage(cur); err != nil {
476 return err
477 }
478 res := tx.Model(&Invitation{}).
479 Where("id = ? AND accepted_at IS NULL AND revoked_at IS NULL", id).
480 Update("revoked_at", now)
481 if res.Error != nil {
482 return res.Error
483 }
484 if res.RowsAffected != 1 {
485 return ErrNoInvitation
486 }
487 return nil
488 })
489}
490
491// Accept redeems an invitation token and writes the Member it buys.
492//
493// The invitation is consumed by COMPARE-AND-SWAP, not by lookup: one
494// conditional UPDATE that requires the row to be unaccepted, unrevoked
495// and unexpired, with rows-affected checked, in the same transaction
496// as the Member insert. A lookup followed by a write is not the same
497// thing — between the two, Revoke can commit, and the invitation is
498// admitted after it was withdrawn.
499//
500// Single use cannot be delegated to the app's own unique-email index
501// either: idear can neither see that index nor enforce it, and the
502// app's user row is written by code idear does not control. The CAS is
503// the invariant, and it is the only one.
504//
505// The role is taken from the stored invitation, never from a caller,
506// and RoleOwner is refused even here — an owner-role invitation should
507// be impossible to mint, and a row that carries one is corruption, not
508// permission.
509//
510// A subject that already has a Member row — including a DEACTIVATED
511// one, because removal is never a delete — collides with the unique
512// index and rolls the whole transaction back, invitation included. A
513// returning member is readmitted by Reactivate, not by a fresh
514// invitation; that is exactly why Reactivate exists (design spec §4).
515func (rs *Roster) Accept(ctx context.Context, token, subject, name string) (*Member, error) {
516 subject = normalizeSubject(subject)
517 if subject == "" {
518 return nil, fmt.Errorf("%w: Accept needs a non-empty subject", ErrInvalidSubject)
519 }
520 if token == "" {
521 return nil, ErrNoInvitation
522 }
523 hash := hashToken(token)
524 now := rs.now()
525
526 var m *Member
527 err := rs.tx(ctx, func(tx *gorm.DB) error {
528 res := tx.Model(&Invitation{}).
529 Where("token_hash = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?", hash, now).
530 Update("accepted_at", now)
531 if res.Error != nil {
532 return res.Error
533 }
534 if res.RowsAffected != 1 {
535 return ErrNoInvitation
536 }
537
538 // Safe only because the CAS above just claimed this row inside
539 // this transaction: the read cannot see a competing writer.
540 var inv Invitation
541 if err := tx.Where("token_hash = ?", hash).Take(&inv).Error; err != nil {
542 return err
543 }
544 var mErr error
545 m, mErr = memberFromInvitation(tx, &inv, subject, name)
546 return mErr
547 })
548 if err != nil {
549 return nil, err
550 }
551 return m, nil
552}
553
554// memberFromInvitation writes the Member a JUST-CONSUMED invitation
555// buys, inside the same transaction that consumed it.
556//
557// It is shared by the two paths that spend an invitation — Accept, by
558// token, under password; acceptByAddress, by verified address, under
559// keymail — so the rule they enforce about the granted role is one
560// piece of code that cannot drift between them.
561//
562// The role is taken from the stored invitation, never from a caller,
563// and RoleOwner is refused even here: an owner-role invitation should
564// be impossible to mint, and a row that carries one is corruption, not
565// permission.
566func memberFromInvitation(tx *gorm.DB, inv *Invitation, subject, name string) (*Member, error) {
567 if !inv.Role.Valid() || inv.Role == RoleOwner {
568 return nil, forbidden("invitation carries role %q, which cannot be granted", string(inv.Role))
569 }
570 m := &Member{
571 Subject: normalizeSubject(subject),
572 Email: inv.Email,
573 Name: strings.TrimSpace(name),
574 Role: inv.Role,
575 }
576 if err := tx.Create(m).Error; err != nil {
577 return nil, err
578 }
579 return m, nil
580}
581
582// acceptByAddress is Accept for the keymail path, which has a VERIFIED
583// ADDRESS and no token: it redeems the oldest still-redeemable
584// invitation for email and writes the Member it buys.
585//
586// Invite revokes any live invitation for an address before writing a
587// new one, so "the oldest" is normally "the only one". The Order("id")
588// stays because this must still be deterministic against rows written
589// by a seed, a migration or a hand-repaired database — and because a
590// SELECT with no ORDER BY is a coin toss, not a rule.
591//
592// It exists rather than reusing Accept because only the plaintext
593// token can address a row by token_hash, and keymail never sees one —
594// auth's whole flow is "we mailed this address a link and it came
595// back". The address is the credential there, and ONLY there; the
596// password path must have the token as well, because password verifies
597// no address at all. See Roster.Authorize and Roster.Admitting.
598//
599// The consumption is a CAS with the same three conditions Accept uses,
600// in the same transaction as the Member write, for the same reason: a
601// lookup followed by a write lets Revoke commit in between and admits
602// an invitation after it was withdrawn. The SELECT above it only picks
603// a candidate — every condition is re-stated in the UPDATE and
604// rows-affected is checked, so the selection being stale costs a
605// refusal and never an admission.
606//
607// A subject that already has a Member row — a DEACTIVATED one
608// included, because removal is never a delete — collides with the
609// unique index and rolls the whole transaction back, invitation
610// included. Authorize never reaches here in that case (it refuses a
611// deactivated member first), and a returning member is readmitted by
612// Reactivate rather than by a fresh invitation.
613func (rs *Roster) acceptByAddress(ctx context.Context, email, subject, name string) (*Member, error) {
614 email = normalizeEmail(email)
615 subject = normalizeSubject(subject)
616 if email == "" {
617 return nil, ErrNoInvitation
618 }
619 if subject == "" {
620 return nil, fmt.Errorf("%w: acceptByAddress needs a non-empty subject", ErrInvalidSubject)
621 }
622 now := rs.now()
623
624 var m *Member
625 err := rs.tx(ctx, func(tx *gorm.DB) error {
626 var inv Invitation
627 err := tx.Where("email = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?", email, now).
628 Order("id").Take(&inv).Error
629 if errors.Is(err, gorm.ErrRecordNotFound) {
630 return ErrNoInvitation
631 }
632 if err != nil {
633 return err
634 }
635 res := tx.Model(&Invitation{}).
636 Where("id = ? AND accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?", inv.ID, now).
637 Update("accepted_at", now)
638 if res.Error != nil {
639 return res.Error
640 }
641 if res.RowsAffected != 1 {
642 return ErrNoInvitation
643 }
644 m, err = memberFromInvitation(tx, &inv, subject, name)
645 return err
646 })
647 if err != nil {
648 return nil, err
649 }
650 return m, nil
651}
652
653// addMember writes a plain Member row at role, with no invitation
654// behind it. It is the OPEN SIGN-UP path and nothing else: every other
655// way into the roster carries an invariant (Claim's zero rows,
656// Accept's CAS) that this deliberately has none of.
657//
658// It is unexported for that reason — an exported "just add someone"
659// would be a way around every one of those invariants — and it refuses
660// RoleOwner outright, because ownership arrives only by Claim and
661// moves only by Transfer.
662//
663// One INSERT, so no transaction: the unique index on Subject is the
664// only invariant in play and the statement either satisfies it or
665// fails.
666func (rs *Roster) addMember(ctx context.Context, subject, email, name string, role Role) (*Member, error) {
667 subject = normalizeSubject(subject)
668 if subject == "" {
669 return nil, fmt.Errorf("%w: addMember needs a non-empty subject", ErrInvalidSubject)
670 }
671 if !role.Valid() {
672 return nil, fmt.Errorf("%w: %q", ErrInvalidRole, string(role))
673 }
674 if role == RoleOwner {
675 return nil, forbidden("ownership arrives only by Claim and moves only by Transfer")
676 }
677 m := &Member{
678 Subject: normalizeSubject(subject),
679 Email: normalizeEmail(email),
680 Name: strings.TrimSpace(name),
681 Role: role,
682 }
683 if err := rs.cfg.DB.WithContext(ctx).Create(m).Error; err != nil {
684 return nil, err
685 }
686 return m, nil
687}
688
689// SetRole changes target's role.
690//
691// Refusals, in the order they are checked and for the reason each is
692// checked where it is:
693//
694// - the new role must be one of the three, and must not be
695// RoleOwner: ownership moves only by Transfer (checkInviteRole).
696// - actor must clear the authority floor — active, at least Admin —
697// so a plain Member is refused for lacking authority and learns
698// nothing about the target.
699// - the target must not BE the Owner: ErrLastOwner. Demoting the
700// Owner by this route would leave the instance with no Owner at
701// all, so this is an invariant and not a permission, and it is
702// checked against the row inside the transaction.
703// - MayActOn(actor, target) for the rest of the matrix.
704//
705// A deactivated target may have their role changed; it takes effect if
706// and when they are reactivated.
707func (rs *Roster) SetRole(ctx context.Context, actor, target *Member, role Role) error {
708 if actor == nil || target == nil {
709 return forbidden("no actor or no target")
710 }
711 if !role.Valid() {
712 return fmt.Errorf("%w: %q", ErrInvalidRole, string(role))
713 }
714 if role == RoleOwner {
715 return forbidden("ownership moves only by Transfer, never by SetRole")
716 }
717 return rs.tx(ctx, func(tx *gorm.DB) error {
718 cur, err := loadMember(tx, actor.ID)
719 if err != nil {
720 return err
721 }
722 if err := mayManage(cur); err != nil {
723 return err
724 }
725 tgt, err := loadMember(tx, target.ID)
726 if err != nil {
727 return err
728 }
729 if tgt.Role == RoleOwner {
730 return ErrLastOwner
731 }
732 if err := MayActOn(cur, tgt); err != nil {
733 return err
734 }
735 // checkInviteRole repeats the RoleOwner refusal above, and
736 // deliberately: the early one answers before a transaction is
737 // opened, this one answers against the actor's CURRENT rank.
738 // Deleting either leaves the other holding, which a mutation
739 // run confirmed — RoleOwner is refused three ways here (the
740 // early guard, this branch, and the strictly-below-actor rank
741 // rule, which no actor can clear for owner) and the suite only
742 // goes red when all three are gone.
743 if err := checkInviteRole(cur, role); err != nil {
744 return err
745 }
746 res := tx.Model(&Member{}).Where("id = ? AND role <> ?", tgt.ID, RoleOwner).
747 Update("role", role)
748 if res.Error != nil {
749 return res.Error
750 }
751 if res.RowsAffected != 1 {
752 return ErrLastOwner
753 }
754 return nil
755 })
756}
757
758// Deactivate removes target from the roster — by setting
759// DeactivatedAt, never by deleting the row, because a deleted row
760// dangles every AuthorID in the app's own tables.
761//
762// The Owner can never be deactivated (ErrLastOwner), checked against
763// the row inside the transaction. That check is what makes Transfer
764// racing a Deactivate of the same target safe from this side: if the
765// transfer commits first, this transaction re-reads a target who is
766// now the Owner and refuses, instead of deactivating the Owner it just
767// became.
768//
769// Deactivating an already-deactivated member is a no-op, not an error.
770func (rs *Roster) Deactivate(ctx context.Context, actor, target *Member) error {
771 if actor == nil || target == nil {
772 return forbidden("no actor or no target")
773 }
774 now := rs.now()
775 return rs.tx(ctx, func(tx *gorm.DB) error {
776 cur, err := loadMember(tx, actor.ID)
777 if err != nil {
778 return err
779 }
780 if err := mayManage(cur); err != nil {
781 return err
782 }
783 tgt, err := loadMember(tx, target.ID)
784 if err != nil {
785 return err
786 }
787 if tgt.Role == RoleOwner {
788 return ErrLastOwner
789 }
790 if err := MayActOn(cur, tgt); err != nil {
791 return err
792 }
793 if !tgt.Active() {
794 return nil
795 }
796 res := tx.Model(&Member{}).
797 Where("id = ? AND deactivated_at IS NULL AND role <> ?", tgt.ID, RoleOwner).
798 Update("deactivated_at", now)
799 if res.Error != nil {
800 return res.Error
801 }
802 if res.RowsAffected != 1 {
803 return ErrLastOwner
804 }
805 return nil
806 })
807}
808
809// Reactivate readmits a deactivated member at the role they still
810// carry.
811//
812// It is a first-class operation and not a convenience: Subject is
813// unique and removal is deactivation, so without Reactivate a removed
814// person can never be readmitted by ANY path — keymail's Authorize
815// sees the inactive row and refuses, password re-signup hits the app's
816// duplicate-email check, and a fresh invitation's Member insert
817// collides with the dead row (design spec §4).
818//
819// Reactivating an already-active member is a no-op, not an error.
820func (rs *Roster) Reactivate(ctx context.Context, actor, target *Member) error {
821 if actor == nil || target == nil {
822 return forbidden("no actor or no target")
823 }
824 return rs.tx(ctx, func(tx *gorm.DB) error {
825 cur, err := loadMember(tx, actor.ID)
826 if err != nil {
827 return err
828 }
829 if err := mayManage(cur); err != nil {
830 return err
831 }
832 tgt, err := loadMember(tx, target.ID)
833 if err != nil {
834 return err
835 }
836 if err := MayActOn(cur, tgt); err != nil {
837 return err
838 }
839 if tgt.Active() {
840 return nil
841 }
842 res := tx.Model(&Member{}).
843 Where("id = ? AND deactivated_at IS NOT NULL", tgt.ID).
844 Update("deactivated_at", nil)
845 if res.Error != nil {
846 return res.Error
847 }
848 if res.RowsAffected != 1 {
849 return ErrNotFound
850 }
851 return nil
852 })
853}
854
855// Transfer hands ownership of the instance from owner to to, in one
856// transaction: demote the outgoing Owner to Admin, promote the
857// incoming one to Owner. Exactly one Owner exists at every commit
858// boundary, including this one — there is no instant, even inside the
859// transaction, at which the instance has two Owners or none.
860//
861// Both rows are RE-READ inside the transaction, and two things
862// confirmed about them:
863//
864// - the actor is still the Owner. Six concurrent transfers therefore
865// resolve to one: the first commits, and every other transaction
866// re-reads an actor who is now an Admin. Round 1 of the bake-off
867// found exactly this bug in a hand-rolled version, and found it
868// only because someone ran it as an actual race.
869// - the target is still ACTIVE. Without this, a transfer racing a
870// Deactivate of the same target produces a DEACTIVATED Owner: an
871// instance with nobody able to administer it and nobody able to be
872// promoted, because MayActOn lets no rank act on an Owner. It is
873// unrecoverable through idear's own API, which is what makes it
874// worth a re-read rather than a comment.
875//
876// Both updates are conditional and rows-affected-checked as well, so
877// the invariant is stated at the statement and not only in the
878// preceding reads.
879func (rs *Roster) Transfer(ctx context.Context, owner, to *Member) error {
880 if owner == nil || to == nil {
881 return forbidden("no owner or no target")
882 }
883 if owner.ID == to.ID {
884 return forbidden("ownership cannot be transferred to its current holder")
885 }
886 return rs.tx(ctx, func(tx *gorm.DB) error {
887 cur, err := loadMember(tx, owner.ID)
888 if err != nil {
889 return err
890 }
891 if !cur.Active() || cur.Role != RoleOwner {
892 return forbidden("only the current owner may transfer ownership")
893 }
894 tgt, err := loadMember(tx, to.ID)
895 if err != nil {
896 return err
897 }
898 if !tgt.Active() {
899 return forbidden("ownership cannot be transferred to a deactivated member")
900 }
901
902 res := tx.Model(&Member{}).Where("id = ? AND role = ?", cur.ID, RoleOwner).
903 Update("role", RoleAdmin)
904 if res.Error != nil {
905 return res.Error
906 }
907 if res.RowsAffected != 1 {
908 return forbidden("ownership moved before this transfer could complete")
909 }
910 res = tx.Model(&Member{}).Where("id = ? AND deactivated_at IS NULL", tgt.ID).
911 Update("role", RoleOwner)
912 if res.Error != nil {
913 return res.Error
914 }
915 if res.RowsAffected != 1 {
916 return forbidden("the target stopped being an active member before this transfer could complete")
917 }
918 return nil
919 })
920}
921
922// BySubject resolves a session Subject to its member row, active or
923// not. It deliberately does NOT filter on Active: the middleware has
924// to be able to tell a deactivated member from a stranger in order to
925// log the difference, even though it answers both with the app's 404.
926// Callers decide with Member.Active.
927//
928// The subject is canonicalised on the way in (normalizeSubject), which
929// is the READ half of a pair: every Subject write is canonicalised the
930// same way. Under keymail the Subject is the address the visitor
931// typed, so without this a member who typed a capital would resolve to
932// nothing and 404 forever. See normalizeSubject.
933func (rs *Roster) BySubject(ctx context.Context, subject string) (*Member, error) {
934 subject = normalizeSubject(subject)
935 if subject == "" {
936 // An empty subject is a request with no session, not a
937 // wildcard. Answering it from the database would match
938 // whichever row happens to have an empty subject.
939 return nil, ErrNotFound
940 }
941 var m Member
942 err := rs.cfg.DB.WithContext(ctx).Where("subject = ?", subject).Take(&m).Error
943 if errors.Is(err, gorm.ErrRecordNotFound) {
944 return nil, ErrNotFound
945 }
946 if err != nil {
947 return nil, err
948 }
949 return &m, nil
950}
951
952// ByID resolves a member id, active or not. See BySubject.
953func (rs *Roster) ByID(ctx context.Context, id int64) (*Member, error) {
954 var m Member
955 err := rs.cfg.DB.WithContext(ctx).Where("id = ?", id).Take(&m).Error
956 if errors.Is(err, gorm.ErrRecordNotFound) {
957 return nil, ErrNotFound
958 }
959 if err != nil {
960 return nil, err
961 }
962 return &m, nil
963}
964
965// Members lists the whole roster, deactivated members included — the
966// members page shows them so they can be restored, and a list that
967// hid them would make Reactivate unreachable from the UI.
968//
969// The order is Owner, then Admins, then Members, then by id. It is
970// spelled out as a CASE rather than ORDER BY role because the column
971// is text: alphabetical order would put Admin above Owner.
972func (rs *Roster) Members(ctx context.Context) ([]Member, error) {
973 var out []Member
974 err := rs.cfg.DB.WithContext(ctx).
975 Order("CASE role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END").
976 Order("id").
977 Find(&out).Error
978 if err != nil {
979 return nil, err
980 }
981 return out, nil
982}
983
984// PendingInvitations lists the invitations that can still be redeemed
985// right now: unaccepted, unrevoked and unexpired. Expired and spent
986// rows stay in the table as a record; they are simply not offered.
987func (rs *Roster) PendingInvitations(ctx context.Context) ([]Invitation, error) {
988 var out []Invitation
989 err := rs.cfg.DB.WithContext(ctx).
990 Where("accepted_at IS NULL AND revoked_at IS NULL AND expires_at > ?", rs.now()).
991 Order("id").
992 Find(&out).Error
993 if err != nil {
994 return nil, err
995 }
996 return out, nil
997}
998
999// pendingInvitation resolves a plaintext token to its invitation, and
1000// refuses with ErrNoInvitation for every unusable case alike: no such
1001// token, already accepted, revoked, or expired. One answer, not four —
1002// the holder of a token is not entitled to learn WHICH.
1003//
1004// It is ADVISORY and it is unexported because of that. It is answered
1005// from the read pool, so it can lag a Revoke by a WAL snapshot, and it
1006// is a lookup rather than a consumption. Nothing may admit on its
1007// answer alone: Accept's CAS re-checks all three conditions inside the
1008// transaction that spends the invitation, and that is where the answer
1009// binds. Admission uses this only to decide the EMAIL MATCH, which is
1010// the one fact about an invitation that never changes after it is
1011// written.
1012func (rs *Roster) pendingInvitation(ctx context.Context, token string) (*Invitation, error) {
1013 if token == "" {
1014 return nil, ErrNoInvitation
1015 }
1016 var inv Invitation
1017 err := rs.cfg.DB.WithContext(ctx).Where("token_hash = ?", hashToken(token)).Take(&inv).Error
1018 if errors.Is(err, gorm.ErrRecordNotFound) {
1019 return nil, ErrNoInvitation
1020 }
1021 if err != nil {
1022 return nil, err
1023 }
1024 if !inv.Pending(rs.now()) {
1025 return nil, ErrNoInvitation
1026 }
1027 return &inv, nil
1028}
1029