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 "log/slog"
7 "net/http"
8 "strconv"
9 "strings"
10
11 "amadan.net/rastrillo/rastrillo/flash"
12)
13
14// The copy every refusal renders. Fixed strings, never the error's own
15// text: a store error can carry a subject, an address or a driver
16// message, and a page that echoed it would publish them to whoever
17// provoked it. The log line gets the detail; the page gets the class.
18const (
19 invalidCopy = "That request was not valid."
20 forbiddenCopy = "You may not do that."
21 lastOwnerCopy = "The owner cannot be removed. Transfer ownership first."
22 noInvitationCopy = "That invitation is no longer available."
23 failedCopy = "Something went wrong. Please try again."
24 signInFirstCopy = "Sign in first, then open this invitation link again."
25 removedInviteeCopy = "That address belongs to somebody who was removed from the team. Restore them instead of inviting them again."
26 rateLimitedCopy = "Too many requests. Please wait a moment and try again."
27)
28
29// The notices a successful mutation flashes. They are one-shot display
30// state (rastrillo/flash), read back by Members on the redirect that
31// follows.
32const (
33 invitedNotice = "Invitation created."
34 deliveredNotice = "Invitation sent."
35 undeliveredNotice = "The invitation was created but could not be sent. Revoke it and try again."
36 revokedNotice = "Invitation revoked."
37 roleNotice = "Role updated."
38 removedNotice = "Member removed."
39 restoredNotice = "Member restored."
40 transferNotice = "Ownership transferred."
41 joinedNotice = "Welcome — your membership is set up."
42 alreadyNotice = "You are already a member here."
43)
44
45// flashError is the flash Kind that lands in MembersPage.Error rather
46// than MembersPage.Notice. Anything else is a notice.
47const flashError = "error"
48
49// MembersPage is what RenderMembers receives.
50//
51// Grantable is the roles the VIEWER may hand out, and a role selector
52// must be built from it rather than from the three constants. An Admin
53// may grant only Member — checkInviteRole refuses a grant that is not
54// STRICTLY BELOW the actor's own rank, unless built with PeerGrants —
55// so a form that offered Admin to an Admin would 403 on every submit,
56// and one that offered Owner would 403 for everybody including the
57// Owner. It is computed by
58// Grantable, which asks the same predicate the store enforces, so the
59// selector cannot drift from the rule.
60type MembersPage struct {
61 Viewer *Member
62 Members []Member
63 Invitations []Invitation
64 Grantable []Role
65 Error string
66 Notice string
67}
68
69// InvitationPage is what RenderInvitation receives, on the public
70// invitation routes.
71//
72// THERE IS NO ADDRESS FIELD, and that is the design rather than an
73// omission. GET /invitations/{token} is an unauthenticated lookup of a
74// secret: it names the instance and the role so the holder knows what
75// they are accepting, and it must not echo the invited address, which
76// would turn a leaked or guessed link into a disclosure. A renderer
77// cannot print what it is never handed.
78//
79// SignedIn says the viewer has a session; Reconcile says they have one
80// AND no member row — the orphan POST is for. A template shows the
81// "accept" button to a Reconcile viewer, and a "sign up with this
82// link" form to everybody else.
83type InvitationPage struct {
84 Role Role
85 Site string
86 Token string
87 Error string
88 SignedIn bool
89 Reconcile bool
90 // Join says the app can start the invitee's sign-in from here
91 // (HandlerConfig.Begin is set and the viewer holds no session): the
92 // page shows one button, and pressing it sends the sign-in link to
93 // the invited address. The address itself is deliberately NOT here
94 // — a token in the wrong hands must not name its owner
95 // (TestInvitationPageDoesNotDiscloseTheAddress); the person pressing
96 // the button knows which inbox they opened the link in.
97 Join bool
98}
99
100// HandlerConfig wires the flows to the app's own shell. Roster and
101// both renderers are required; everything else has a serviceable
102// default.
103//
104// There is deliberately NO NotFound here, though the design sketch
105// shows one: the 404 renderer lives on idear.Config, because Require
106// is a method on the Roster and answers non-members long before a
107// handler runs. Two hooks would mean two 404 pages, and a 404 that
108// varies with which layer refused is the membership oracle the whole
109// design is arranged to avoid. Set Config.NotFound to the same
110// renderer the app gives chi's own NotFound and every refusal in idear
111// renders through it.
112type HandlerConfig struct {
113 Roster *Roster
114 RenderMembers func(w http.ResponseWriter, r *http.Request, d MembersPage)
115 RenderInvitation func(w http.ResponseWriter, r *http.Request, d InvitationPage)
116
117 // Site names this instance on the invitation page — "Acme's
118 // board".
119 //
120 // SET IT IN PRODUCTION. The default is the request's Host, and
121 // Host is a CLIENT-SUPPLIED HEADER: on the public, unauthenticated
122 // invitation page, an attacker who mails somebody a link and can
123 // steer the Host (a permissive reverse proxy, a wildcard vhost,
124 // a raw request) has the page render text of their choosing as
125 // the instance's name — "Acme Security, verify your password at
126 // ...". idear escapes nothing on the app's behalf either; the
127 // renderer owns that. The default is honest about where the
128 // request landed, which is useful in development and is not a
129 // name you should show a stranger.
130 Site string
131
132 // MembersPath is where a successful mutation redirects. Default
133 // "/members". An app that mounts the members page elsewhere must
134 // say so here, or every 303 lands on a 404.
135 MembersPath string
136
137 // InvitationPath is the prefix a redeemable link is built from:
138 // InvitationPath + token. Default "/invitations/".
139 InvitationPath string
140
141 // Deliver mails the invitation link. It is given the request (for
142 // an origin, a locale, a logger), the invitation and the link.
143 //
144 // When it is nil idear puts the LINK ITSELF in the flash notice,
145 // so a bare mount is usable: the token is shown exactly once, to
146 // the admin who minted it, and can then be pasted into whatever
147 // channel the team uses.
148 //
149 // That fallback puts a live credential in a cookie, and
150 // rastrillo/flash sets HttpOnly, SameSite=Lax and MaxAge=60 but
151 // NOT Secure — so on a plain-http origin the token crosses the
152 // wire in clear text, and it is written by a package idear does
153 // not own, so idear cannot add the flag. An app that can send
154 // mail should set this and keep the token out of the browser
155 // entirely; NewHandlers logs a warning when it is nil.
156 Deliver func(r *http.Request, inv *Invitation, link string) error
157
158 // Begin, when set, lets the invitee start their first sign-in from
159 // the invitation page instead of being told to go and find one.
160 //
161 // An invitation link is a week-long secret that sits in an inbox,
162 // gets forwarded and lands in logs, so following it must prove
163 // nothing by itself. What it CAN do is start the app's own sign-in
164 // for the invited address: POST /invitations/{token} from a visitor
165 // with no session looks up the pending invitation and hands its
166 // address to Begin, which sends the app's sign-in link (or keymail
167 // ceremony) to that address and answers the request. Whoever holds
168 // the inbox follows the link; admission on the way, through
169 // Roster.Authorize, redeems the invitation. Whoever merely holds
170 // the invitation link gets a page saying an email was sent — to an
171 // inbox they cannot read — and the token stays pending, untouched.
172 //
173 // The hook owns the response: redirect, or render. Return an error
174 // only when nothing was written. The app's own budget on sign-in
175 // links bounds a visitor pressing the button over and over, on
176 // top of RateLimit here.
177 Begin func(w http.ResponseWriter, r *http.Request, address string) error
178
179 // RateLimit bounds the two public routes. See RateLimit — the
180 // zero value is the documented default, and there is no way to
181 // turn the limiter off.
182 RateLimit RateLimit
183
184 // ClientKey is the rate-limit key for a request. Default: the IP
185 // half of RemoteAddr. Behind a reverse proxy, set this to read
186 // the app's own TRUSTED forwarding header — see clientIP for why
187 // idear will not guess at one.
188 ClientKey func(r *http.Request) string
189}
190
191// Handlers is idear's HTTP surface: the members page, the six
192// management mutations, and the two public invitation routes. Build
193// one at boot and mount Routes.
194type Handlers struct {
195 cfg HandlerConfig
196 limit *limiter
197}
198
199// NewHandlers validates cfg and returns the handlers. It errors unless
200// the Roster and BOTH renderers are set, following jobs.NewHandlers:
201// a nil renderer is a nil call in a request, and a boot error is a
202// better place to learn about it than a production panic.
203func NewHandlers(cfg HandlerConfig) (*Handlers, error) {
204 if cfg.Roster == nil {
205 return nil, errors.New("idear: HandlerConfig.Roster is required")
206 }
207 if cfg.RenderMembers == nil {
208 return nil, errors.New("idear: HandlerConfig.RenderMembers is required")
209 }
210 if cfg.RenderInvitation == nil {
211 return nil, errors.New("idear: HandlerConfig.RenderInvitation is required")
212 }
213 if cfg.MembersPath == "" {
214 cfg.MembersPath = "/members"
215 }
216 if cfg.InvitationPath == "" {
217 cfg.InvitationPath = "/invitations/"
218 }
219 if cfg.ClientKey == nil {
220 cfg.ClientKey = clientIP
221 }
222 if cfg.Deliver == nil {
223 // Once, at boot, rather than on every invitation: the
224 // fallback is usable and it is not what a deployed app should
225 // be doing. See HandlerConfig.Deliver.
226 cfg.Roster.cfg.Logger.Warn("idear: no HandlerConfig.Deliver, so invitation LINKS will be flashed to the inviting admin's browser in a cookie that rastrillo/flash does not mark Secure; set Deliver to mail them instead")
227 }
228 return &Handlers{cfg: cfg, limit: newLimiter(cfg.RateLimit)}, nil
229}
230
231// Route is one mounted handler: the method and pattern idear suggests,
232// the handler ALREADY WRAPPED in the middleware it requires, and the
233// rank that wrapping enforces.
234//
235// The handler is pre-guarded on purpose. Require and RequireRole have
236// a stacking order that is wrong in two different ways when it is got
237// wrong (RequireRole mounted bare 403s a stranger, which tells them
238// the route exists; Require mounted outside the app's session guard
239// 404s everyone including the Owner), so Routes hands out the correct
240// composition rather than a bare handler and a comment. What an app
241// still owns is the paths and the session guard these are mounted
242// inside.
243//
244// Min is the rank floor the handler enforces, and Public says the
245// route is unauthenticated. They are not decoration: they let a
246// caller — an app's route audit, or idear's own authorization suite —
247// DERIVE what each route should refuse instead of restating a list
248// that can drift from the one being mounted.
249type Route struct {
250 Method string
251 Pattern string
252 Handler http.Handler
253 Min Role
254 Public bool
255}
256
257// Routes is the whole HTTP surface, in the order the design lists it.
258//
259// The patterns are defaults; paths belong to the app. An app that
260// mounts them elsewhere must set MembersPath and InvitationPath to
261// match, since those are what the redirects and the invitation links
262// are built from.
263func (h *Handlers) Routes() []Route {
264 rs := h.cfg.Roster
265 guard := func(min Role, fn http.HandlerFunc) http.Handler {
266 return rs.Require(rs.RequireRole(min)(fn))
267 }
268 return []Route{
269 {http.MethodGet, "/members", guard(RoleMember, h.Members), RoleMember, false},
270 {http.MethodPost, "/members/invitations", guard(RoleAdmin, h.Invite), RoleAdmin, false},
271 {http.MethodPost, "/members/invitations/{id}/revoke", guard(RoleAdmin, h.Revoke), RoleAdmin, false},
272 {http.MethodPost, "/members/{id}/role", guard(RoleAdmin, h.SetRole), RoleAdmin, false},
273 {http.MethodPost, "/members/{id}/remove", guard(RoleAdmin, h.Remove), RoleAdmin, false},
274 {http.MethodPost, "/members/{id}/restore", guard(RoleAdmin, h.Restore), RoleAdmin, false},
275 {http.MethodPost, "/members/transfer", guard(RoleOwner, h.Transfer), RoleOwner, false},
276 {http.MethodGet, "/invitations/{token}", http.HandlerFunc(h.Invitation), "", true},
277 {http.MethodPost, "/invitations/{token}", http.HandlerFunc(h.Accept), "", true},
278 }
279}
280
281// Grantable is the roles actor may hand out, highest first — what a
282// role selector must be built from.
283//
284// It is derived from the store's OWN predicates and never from a
285// second copy of the rule: mayManage is the authority floor Invite and
286// SetRole check first (active, and at least Admin), and
287// checkInviteRole is the rank rule they check next, both inside their
288// transactions. So the form and the store cannot disagree, and a
289// deactivated actor — whose row outlives their privileges, because
290// removal is never a delete — is offered nothing at all. RoleOwner is
291// never in it for anybody: ownership moves only by Transfer.
292func Grantable(actor *Member) []Role {
293 if actor == nil || mayManage(actor) != nil {
294 return nil
295 }
296 var out []Role
297 for _, role := range []Role{RoleOwner, RoleAdmin, RoleMember} {
298 if checkInviteRole(actor, role) == nil {
299 out = append(out, role)
300 }
301 }
302 return out
303}
304
305// Members is GET /members: the roster, the pending invitations, and
306// whatever the last mutation flashed.
307func (h *Handlers) Members(w http.ResponseWriter, r *http.Request) {
308 viewer, ok := h.viewer(w, r)
309 if !ok {
310 return
311 }
312 d := h.membersPage(r.Context(), viewer)
313 if f, ok := flash.Take(w, r); ok {
314 if f.Kind == flashError {
315 d.Error = f.Message
316 } else {
317 d.Notice = f.Message
318 }
319 }
320 h.cfg.RenderMembers(w, r, d)
321}
322
323// Invite is POST /members/invitations.
324//
325// The role IS read from the form here, and that is safe for exactly
326// one reason: checkInviteRole refuses RoleOwner outright, for every
327// actor including the Owner, and refuses anything beyond the actor's
328// reach. A posted role can therefore only ever be worth LESS than the
329// poster already holds — or, built with PeerGrants, as much and never
330// more. Every other field is read by
331// name too — there is no struct binding anywhere in this file, so a
332// field nobody named cannot arrive.
333func (h *Handlers) Invite(w http.ResponseWriter, r *http.Request) {
334 viewer, ok := h.viewer(w, r)
335 if !ok {
336 return
337 }
338 role, valid := ParseRole(field(r, "role"))
339 if !valid {
340 h.refuse(w, r, viewer, ErrInvalidRole)
341 return
342 }
343 inv, token, err := h.cfg.Roster.Invite(r.Context(), viewer, field(r, "email"), role)
344 if err != nil {
345 h.refuse(w, r, viewer, err)
346 return
347 }
348
349 link := h.cfg.InvitationPath + token
350 if h.cfg.Deliver == nil {
351 // No delivery hook: the link is the notice. See
352 // HandlerConfig.Deliver.
353 h.done(w, r, invitedNotice+" "+link)
354 return
355 }
356 if err := h.cfg.Deliver(r, inv, link); err != nil {
357 h.log().Error("idear: an invitation was created but could not be delivered",
358 "invitation_id", inv.ID, "err", err)
359 h.flash(w, r, flashError, undeliveredNotice)
360 return
361 }
362 h.done(w, r, deliveredNotice)
363}
364
365// Revoke is POST /members/invitations/{id}/revoke. The id comes from
366// the URL, never from the body.
367func (h *Handlers) Revoke(w http.ResponseWriter, r *http.Request) {
368 viewer, ok := h.viewer(w, r)
369 if !ok {
370 return
371 }
372 id, err := pathID(r)
373 if err != nil {
374 h.refuse(w, r, viewer, err)
375 return
376 }
377 if err := h.cfg.Roster.Revoke(r.Context(), viewer, id); err != nil {
378 h.refuse(w, r, viewer, err)
379 return
380 }
381 h.done(w, r, revokedNotice)
382}
383
384// SetRole is POST /members/{id}/role — the target from the URL, the
385// new role from the form. See Invite for why a posted role is safe
386// here and cannot reach Owner.
387func (h *Handlers) SetRole(w http.ResponseWriter, r *http.Request) {
388 viewer, target, ok := h.target(w, r)
389 if !ok {
390 return
391 }
392 role, valid := ParseRole(field(r, "role"))
393 if !valid {
394 h.refuse(w, r, viewer, ErrInvalidRole)
395 return
396 }
397 if err := h.cfg.Roster.SetRole(r.Context(), viewer, target, role); err != nil {
398 h.refuse(w, r, viewer, err)
399 return
400 }
401 h.done(w, r, roleNotice)
402}
403
404// Remove is POST /members/{id}/remove: deactivation, never a delete.
405func (h *Handlers) Remove(w http.ResponseWriter, r *http.Request) {
406 viewer, target, ok := h.target(w, r)
407 if !ok {
408 return
409 }
410 if err := h.cfg.Roster.Deactivate(r.Context(), viewer, target); err != nil {
411 h.refuse(w, r, viewer, err)
412 return
413 }
414 h.done(w, r, removedNotice)
415}
416
417// Restore is POST /members/{id}/restore: readmission at the role the
418// row still carries. It is the only way back in for someone who was
419// removed — see Roster.Reactivate.
420func (h *Handlers) Restore(w http.ResponseWriter, r *http.Request) {
421 viewer, target, ok := h.target(w, r)
422 if !ok {
423 return
424 }
425 if err := h.cfg.Roster.Reactivate(r.Context(), viewer, target); err != nil {
426 h.refuse(w, r, viewer, err)
427 return
428 }
429 h.done(w, r, restoredNotice)
430}
431
432// Transfer is POST /members/transfer, the one mutation whose target id
433// comes from the FORM and not the URL: the path has no id in it,
434// because the actor is always the current Owner and the route is not
435// addressed by them.
436//
437// No role is read here at all. Transfer is the only path to RoleOwner,
438// and it decides both roles itself.
439func (h *Handlers) Transfer(w http.ResponseWriter, r *http.Request) {
440 viewer, ok := h.viewer(w, r)
441 if !ok {
442 return
443 }
444 id, err := parseID(field(r, "member"))
445 if err != nil {
446 h.refuse(w, r, viewer, err)
447 return
448 }
449 target, err := h.cfg.Roster.ByID(r.Context(), id)
450 if err != nil {
451 h.refuse(w, r, viewer, err)
452 return
453 }
454 if err := h.cfg.Roster.Transfer(r.Context(), viewer, target); err != nil {
455 h.refuse(w, r, viewer, err)
456 return
457 }
458 h.done(w, r, transferNotice)
459}
460
461// Invitation is GET /invitations/{token}: PUBLIC, unauthenticated, and
462// a lookup of a secret.
463//
464// It renders the ROLE and the INSTANCE, and never the invited address:
465// InvitationPage has no field for one. Every unusable token — no such
466// token, spent, revoked, expired — renders the SAME copy at the same
467// status, because ErrNoInvitation is deliberately one error and not
468// four, and a page that told them apart would answer questions the
469// holder is not entitled to ask.
470//
471// It is rate-limited. See RateLimit.
472func (h *Handlers) Invitation(w http.ResponseWriter, r *http.Request) {
473 if !h.allow(w, r) {
474 return
475 }
476 token := r.PathValue("token")
477 signedIn, reconcile := h.standing(r)
478
479 inv, err := h.cfg.Roster.pendingInvitation(r.Context(), token)
480 if err != nil {
481 h.deadInvitation(w, r, err, signedIn)
482 return
483 }
484 h.cfg.RenderInvitation(w, r, InvitationPage{
485 Role: inv.Role,
486 Site: h.site(r),
487 Token: token,
488 SignedIn: signedIn,
489 Reconcile: reconcile,
490 Join: h.cfg.Begin != nil && !signedIn,
491 })
492}
493
494// Accept is POST /invitations/{token}: the RECONCILIATION route, and
495// the only path by which a member row is created from an already-live
496// session.
497//
498// It exists because admission cannot be one transaction. Roster.
499// Admitting calls the app's opaque Create and then writes the member;
500// a failure between them leaves a user row with no membership, and a
501// retry does NOT heal it — the retry's Create fails on the now
502// duplicate email and password.Signup renders that as 422 without ever
503// reaching the member write. The orphan can sign IN and 404s on every
504// guarded route forever. Two concurrent first signups reach the same
505// place with no failure at all: one wins the Claim and the
506// ErrOwnerExists loser is an orphan.
507//
508// What it does, in order, for a viewer who is SIGNED IN:
509//
510// 1. already a member: nothing to reconcile — a notice and a
511// redirect, and the token is NOT spent.
512// 2. a DEACTIVATED member: refused. Readmission is Restore, by an
513// admin; an invitation must not be a way for a removed person to
514// let themselves back in.
515// 3. the roster has ZERO ROWS: Claim, exactly as admission would
516// have. An unclaimed instance's first account is its Owner, and
517// the only way to be signed in against an empty roster is to be
518// the orphan whose Claim did not commit.
519// 4. otherwise: the token must be REDEEMABLE BY THIS VIEWER —
520// issued to their address, wherever idear can learn it (keymail's
521// Subject is the address; under password, Config.EmailForSubject
522// resolves one) — and then Accept it, through the CAS in the
523// store, so a Revoke racing this never admits. Under password
524// with no resolver there is no address to match and the token
525// alone decides; that gap is Config.EmailForSubject's whole
526// subject matter.
527//
528// The member row is written by the STORE, from the live session
529// Subject, and never assembled here: Subject is canonicalised on every
530// write (normalizeSubject), and under keymail the raw subject is the
531// address as the visitor typed it. A Member literal built in this
532// handler would reintroduce exactly the bug that canonicalisation
533// exists to close.
534//
535// It needs a VALID TOKEN. A claim-race loser holds none, so they are
536// healed only after somebody invites them — at which point they redeem
537// here rather than through sign-up, which would fail on the duplicate
538// email. That is the design spec's corrected wording and it is the
539// reason this route is not a general "make me a member" button.
540//
541// It is rate-limited. See RateLimit.
542func (h *Handlers) Accept(w http.ResponseWriter, r *http.Request) {
543 if !h.allow(w, r) {
544 return
545 }
546 token := r.PathValue("token")
547 rs := h.cfg.Roster
548 ctx := r.Context()
549
550 subject, ok := rs.cfg.Subject(r)
551 subject = strings.TrimSpace(subject)
552 if !ok || subject == "" {
553 if h.cfg.Begin != nil {
554 // Not signed in, and the app can start that from here: the
555 // hook sends its sign-in link to the invited address and
556 // answers. The token is not touched — admission redeems it
557 // when the link is followed, by whoever holds the inbox.
558 inv, err := rs.pendingInvitation(ctx, token)
559 if err != nil {
560 h.deadInvitation(w, r, err, false)
561 return
562 }
563 if err := h.cfg.Begin(w, r, inv.Email); err != nil {
564 h.log().Error("idear: the app could not begin the invitee's sign-in", "err", err)
565 h.failInvitation(w, r, token)
566 }
567 return
568 }
569 // Not signed in. Reconciliation writes a member for the
570 // session in hand; there is no session in hand. The page says
571 // so rather than pretending the token is bad — and it says it
572 // at 403, because this is a refusal and a 200 would let a
573 // caller read "sign in first" as "accepted". The token is not
574 // touched, so the invitee can redeem it once they have a
575 // session.
576 w.WriteHeader(http.StatusForbidden)
577 h.cfg.RenderInvitation(w, r, InvitationPage{
578 Site: h.site(r),
579 Token: token,
580 Error: signInFirstCopy,
581 })
582 return
583 }
584
585 // 1 and 2: an existing row, active or not.
586 switch m, err := rs.BySubject(ctx, subject); {
587 case err == nil && m.Active():
588 h.done(w, r, alreadyNotice)
589 return
590 case err == nil:
591 h.log().Info("idear: a deactivated member tried to redeem an invitation; readmission is Restore",
592 "subject", subject, "member_id", m.ID)
593 h.deadInvitation(w, r, ErrNoInvitation, true)
594 return
595 case errors.Is(err, ErrNotFound):
596 // The orphan. Carry on.
597 default:
598 h.log().Error("idear: reconciliation could not resolve the viewer", "subject", subject, "err", err)
599 h.failInvitation(w, r, token)
600 return
601 }
602
603 // 3: the unclaimed instance.
604 empty, err := rs.IsEmpty(ctx)
605 if err != nil {
606 h.log().Error("idear: reconciliation could not count the roster", "subject", subject, "err", err)
607 h.failInvitation(w, r, token)
608 return
609 }
610 if empty {
611 switch _, err := rs.Claim(ctx, subject, addressOf(subject), ""); {
612 case err == nil:
613 h.log().Info("idear: reconciliation claimed the instance for a signed-in orphan", "subject", subject)
614 h.done(w, r, joinedNotice)
615 return
616 case errors.Is(err, ErrOwnerExists):
617 // Somebody claimed it between the count and here. They
618 // may still hold an invitation of their own; fall through.
619 default:
620 h.log().Error("idear: reconciliation could not claim the instance", "subject", subject, "err", err)
621 h.failInvitation(w, r, token)
622 return
623 }
624 }
625
626 // 4: the token — plus the email match, WHEREVER IDEAR CAN MAKE
627 // ONE.
628 //
629 // It can when the session Subject IS an address, which is what
630 // keymail mints; and it can under password when the app supplied
631 // Config.EmailForSubject to resolve its own user id. Either way
632 // the rule is the one admission applies: possession of the token
633 // AND an email match.
634 //
635 // With NEITHER — the password path, no resolver — the token alone
636 // is the credential here, and that is not parity with admission,
637 // which always has a submitted address to match against. A
638 // signed-in orphan can then spend any live token they get hold
639 // of, at whatever role it carries. Config.EmailForSubject states
640 // that asymmetry in full; it is written down rather than papered
641 // over, and setting the hook closes it.
642 addr, known, err := rs.addressFor(ctx, subject)
643 if err != nil {
644 h.log().Error("idear: reconciliation could not resolve the viewer's address; refusing",
645 "subject", subject, "err", err)
646 h.failInvitation(w, r, token)
647 return
648 }
649 if known {
650 inv, err := rs.pendingInvitation(ctx, token)
651 switch {
652 case err != nil:
653 // An unusable token, or a lookup that failed: one answer
654 // for all of them, as everywhere else on this route.
655 h.deadInvitation(w, r, ErrNoInvitation, true)
656 return
657 case addr == "":
658 // The resolver ran and placed this subject nowhere. Fail
659 // closed: a viewer idear cannot identify must not redeem
660 // an invitation issued to one it can.
661 h.log().Warn("idear: reconciliation could not place the viewer's subject in the app's own records; refusing",
662 "subject", subject)
663 h.deadInvitation(w, r, ErrNoInvitation, true)
664 return
665 case normalizeEmail(inv.Email) != addr:
666 h.log().Warn("idear: reconciliation presented an invitation issued to another address",
667 "subject", subject)
668 h.deadInvitation(w, r, ErrNoInvitation, true)
669 return
670 }
671 }
672
673 m, err := rs.Accept(ctx, token, subject, "")
674 if err != nil {
675 if !errors.Is(err, ErrNoInvitation) {
676 h.log().Error("idear: reconciliation could not redeem an invitation", "subject", subject, "err", err)
677 h.failInvitation(w, r, token)
678 return
679 }
680 h.deadInvitation(w, r, err, true)
681 return
682 }
683 h.log().Info("idear: reconciliation healed an orphan", "subject", subject, "member_id", m.ID, "role", string(m.Role))
684 h.done(w, r, joinedNotice)
685}
686
687// viewer is the guarded handlers' first line: the member Require
688// resolved.
689//
690// A nil viewer here is a MOUNT BUG — the handler ran outside Require —
691// and it is answered with the app's 404 and a loud log line rather
692// than a panic or, worse, a nil actor handed to the store. Every store
693// mutation refuses a nil actor, so this is defence in depth; what it
694// buys is a log line that names the cause.
695func (h *Handlers) viewer(w http.ResponseWriter, r *http.Request) (*Member, bool) {
696 m := From(r)
697 if m == nil {
698 h.log().Error("idear: a handler ran with no viewer; it must be mounted INSIDE Roster.Require",
699 "path", r.URL.Path)
700 h.cfg.Roster.cfg.NotFound(w, r)
701 return nil, false
702 }
703 return m, true
704}
705
706// target resolves the viewer and the {id} in the path to a member row.
707// A miss renders the app's own 404 — the same page a non-member gets,
708// through the same hook.
709func (h *Handlers) target(w http.ResponseWriter, r *http.Request) (*Member, *Member, bool) {
710 viewer, ok := h.viewer(w, r)
711 if !ok {
712 return nil, nil, false
713 }
714 id, err := pathID(r)
715 if err != nil {
716 h.refuse(w, r, viewer, err)
717 return nil, nil, false
718 }
719 target, err := h.cfg.Roster.ByID(r.Context(), id)
720 if err != nil {
721 h.refuse(w, r, viewer, err)
722 return nil, nil, false
723 }
724 return viewer, target, true
725}
726
727// done is the success path: flash a notice, then 303 back to the
728// members page so a refresh cannot repost the mutation.
729func (h *Handlers) done(w http.ResponseWriter, r *http.Request, notice string) {
730 h.flash(w, r, "notice", notice)
731}
732
733func (h *Handlers) flash(w http.ResponseWriter, r *http.Request, kind, msg string) {
734 flash.Set(w, kind, msg)
735 http.Redirect(w, r, h.cfg.MembersPath, http.StatusSeeOther)
736}
737
738// refuse renders a store refusal at the status its CLASS earns.
739//
740// The classes are the whole point of errors.go: ErrInvalid is 400,
741// ErrForbidden is 403 — and ErrLastOwner unwraps to ErrForbidden, so
742// this package's most security-relevant refusal renders 403 and not
743// 500 — ErrNotFound is the app's own 404 page, and anything left is a
744// 500 with no detail on it. Matching the CLASS with errors.Is rather
745// than the sentinel is what keeps a new sentinel from silently
746// becoming a server error.
747func (h *Handlers) refuse(w http.ResponseWriter, r *http.Request, viewer *Member, err error) {
748 switch {
749 case errors.Is(err, ErrNotFound):
750 // A member id that resolves to nothing is answered exactly as
751 // a non-member is: same hook, same bytes.
752 h.cfg.Roster.cfg.NotFound(w, r)
753 case errors.Is(err, ErrNoInvitation):
754 h.page(w, r, viewer, http.StatusNotFound, noInvitationCopy)
755 case errors.Is(err, ErrLastOwner):
756 // Checked before the ErrForbidden arm it unwraps to, because
757 // the reason is true for every actor at every rank and the
758 // generic copy would imply somebody senior could do it.
759 h.page(w, r, viewer, http.StatusForbidden, lastOwnerCopy)
760 case errors.Is(err, ErrRemovedInvitee):
761 h.page(w, r, viewer, http.StatusBadRequest, removedInviteeCopy)
762 case errors.Is(err, ErrInvalid):
763 h.page(w, r, viewer, http.StatusBadRequest, invalidCopy)
764 case errors.Is(err, ErrForbidden):
765 h.page(w, r, viewer, http.StatusForbidden, forbiddenCopy)
766 default:
767 h.log().Error("idear: a members mutation failed", "path", r.URL.Path, "err", err)
768 h.page(w, r, viewer, http.StatusInternalServerError, failedCopy)
769 }
770}
771
772// page renders the members page carrying an error, at status.
773//
774// The status is written BEFORE the renderer runs, so an app's renderer
775// must not write its own — the first WriteHeader wins and a second one
776// is a logged no-op. That is the cost of keeping Status off MembersPage;
777// what it buys is that a renderer cannot accidentally answer 200 to a
778// refusal.
779func (h *Handlers) page(w http.ResponseWriter, r *http.Request, viewer *Member, status int, msg string) {
780 d := h.membersPage(r.Context(), viewer)
781 d.Error = msg
782 w.WriteHeader(status)
783 h.cfg.RenderMembers(w, r, d)
784}
785
786// membersPage gathers what the members page shows. A listing failure
787// is logged and rendered as an EMPTY list rather than as a 500: this
788// is also the failure path's own renderer, and a refusal that turned
789// into a server error because the list behind it could not be read
790// would report the wrong problem.
791func (h *Handlers) membersPage(ctx context.Context, viewer *Member) MembersPage {
792 d := MembersPage{Viewer: viewer, Grantable: Grantable(viewer)}
793 members, err := h.cfg.Roster.Members(ctx)
794 if err != nil {
795 h.log().Error("idear: listing the roster failed", "err", err)
796 }
797 d.Members = members
798 invitations, err := h.cfg.Roster.PendingInvitations(ctx)
799 if err != nil {
800 h.log().Error("idear: listing pending invitations failed", "err", err)
801 }
802 d.Invitations = invitations
803 return d
804}
805
806// deadInvitation renders the one answer every unusable invitation
807// gets. A storage failure is NOT routed here — it renders failedCopy
808// instead, because telling someone their live invitation is dead
809// because the database hiccuped sends them to an admin for a new one
810// that will fail the same way.
811func (h *Handlers) deadInvitation(w http.ResponseWriter, r *http.Request, err error, signedIn bool) {
812 if !errors.Is(err, ErrNoInvitation) {
813 h.log().Error("idear: looking up an invitation failed", "err", err)
814 w.WriteHeader(http.StatusInternalServerError)
815 h.cfg.RenderInvitation(w, r, InvitationPage{Site: h.site(r), Error: failedCopy, SignedIn: signedIn})
816 return
817 }
818 w.WriteHeader(http.StatusNotFound)
819 h.cfg.RenderInvitation(w, r, InvitationPage{Site: h.site(r), Error: noInvitationCopy, SignedIn: signedIn})
820}
821
822// failInvitation is the invitation routes' 500: a storage failure, with
823// nothing about it on the page.
824func (h *Handlers) failInvitation(w http.ResponseWriter, r *http.Request, token string) {
825 w.WriteHeader(http.StatusInternalServerError)
826 h.cfg.RenderInvitation(w, r, InvitationPage{
827 Site: h.site(r),
828 Token: token,
829 Error: failedCopy,
830 SignedIn: true,
831 })
832}
833
834// standing answers the two questions the invitation page asks about
835// the viewer: are they signed in, and are they the orphan this route
836// is for. A deactivated member is NOT a reconcile candidate — they
837// have a row, and Accept refuses them.
838func (h *Handlers) standing(r *http.Request) (signedIn, reconcile bool) {
839 rs := h.cfg.Roster
840 subject, ok := rs.cfg.Subject(r)
841 if !ok || strings.TrimSpace(subject) == "" {
842 return false, false
843 }
844 switch _, err := rs.BySubject(r.Context(), subject); {
845 case err == nil:
846 return true, false
847 case errors.Is(err, ErrNotFound):
848 return true, true
849 default:
850 h.log().Error("idear: resolving the invitation viewer failed", "err", err)
851 return true, false
852 }
853}
854
855// allow spends one rate-limit token, answering 429 itself when there
856// is none. Plain text: this is the response an abusive client gets,
857// and rendering the app's own page for it would put a template render
858// on the cheapest path an attacker has.
859func (h *Handlers) allow(w http.ResponseWriter, r *http.Request) bool {
860 if h.limit.allow(h.cfg.ClientKey(r)) {
861 return true
862 }
863 h.log().Warn("idear: rate-limited a public invitation request", "path", r.URL.Path)
864 w.Header().Set("Retry-After", strconv.Itoa(int(h.limit.every.Seconds())))
865 http.Error(w, rateLimitedCopy, http.StatusTooManyRequests)
866 return false
867}
868
869// site names the instance on the invitation page. With no configured
870// Site this is the request's Host — a client-supplied header. See
871// HandlerConfig.Site.
872func (h *Handlers) site(r *http.Request) string {
873 if h.cfg.Site != "" {
874 return h.cfg.Site
875 }
876 return r.Host
877}
878
879// log is the Roster's logger. The handlers deliberately share it
880// rather than taking one of their own: the distinctions idear refuses
881// to render — which member was refused, whether a 404 was a stranger
882// or a deactivated member — are only ever visible in the log, and they
883// must all land in the same place.
884func (h *Handlers) log() *slog.Logger { return h.cfg.Roster.cfg.Logger }
885
886// field reads ONE named field from the POSTED BODY.
887//
888// By name, one at a time, never by binding a struct: a struct binding
889// accepts every field the struct has, and the fields idear's structs
890// have include Role and DeactivatedAt. It reads PostForm and not Form
891// as well, so a value in the QUERY STRING cannot stand in for a body
892// field — otherwise a link could carry ?role=owner into a POST whose
893// body never mentioned one.
894func field(r *http.Request, name string) string {
895 if r.PostForm == nil {
896 // An unparseable body leaves an empty form behind, which every
897 // caller here treats as a missing field: 400, not a default.
898 //
899 // MULTIPART IS PARSED EXPLICITLY. ParseForm does not populate
900 // PostForm for multipart/form-data — it parses only the query
901 // — so an app whose members form carries a file input would
902 // otherwise 400 on every submit with nothing to explain it.
903 // The failure direction was right and the diagnosis was
904 // impossible.
905 //
906 // The memory bound is small because nothing here reads a file:
907 // these are three short text fields. Anything larger spills to
908 // a temp file, which is why this runs only on the
909 // admin-guarded mutations — the public routes read no fields
910 // at all.
911 if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") {
912 _ = r.ParseMultipartForm(multipartMemory)
913 // Values are already copied into PostForm; nothing below
914 // reads a file, so the temp files this may have spilled
915 // need not outlive the request.
916 if r.MultipartForm != nil {
917 r.MultipartForm.RemoveAll()
918 }
919 } else {
920 _ = r.ParseForm()
921 }
922 }
923 return r.PostForm.Get(name)
924}
925
926// multipartMemory bounds what a multipart mutation may hold in memory.
927// idear reads only short text fields; the limit exists so a body that
928// is not that costs nothing.
929const multipartMemory = 1 << 20
930
931// pathID is the {id} wildcard, as an int64. Ids come from the URL on
932// every route that has one.
933func pathID(r *http.Request) (int64, error) {
934 return parseID(r.PathValue("id"))
935}
936
937func parseID(s string) (int64, error) {
938 id, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
939 if err != nil || id <= 0 {
940 return 0, ErrInvalidID
941 }
942 return id, nil
943}
944
945// addressOf returns subject when it is an ADDRESS — which is what
946// keymail mints as the session Subject — and "" when it is anything
947// else, such as password's decimal user id. It is the one place idear
948// decides whether it knows the viewer's address from the session
949// alone.
950func addressOf(subject string) string {
951 if strings.Contains(subject, "@") {
952 return subject
953 }
954 return ""
955}
956
957// addressFor answers what address a session Subject belongs to, and —
958// the part that matters — whether idear CAN know at all.
959//
960// known false is the honest "no idea": a subject that is not an
961// address, on an app that supplied no Config.EmailForSubject. The
962// caller must not read that as "no match"; it is the absence of a
963// question, and reconciliation's behaviour in that case is the
964// documented permissive path.
965//
966// known true with an empty address is a different answer: the app's
967// own resolver ran and placed the subject nowhere. That is a refusal,
968// not an unknown.
969//
970// The address is normalised on the way out so callers compare like
971// with like, and so an app may return whatever spelling its user table
972// happens to hold.
973func (rs *Roster) addressFor(ctx context.Context, subject string) (string, bool, error) {
974 if addr := addressOf(subject); addr != "" {
975 return normalizeEmail(addr), true, nil
976 }
977 if rs.cfg.EmailForSubject == nil {
978 return "", false, nil
979 }
980 addr, err := rs.cfg.EmailForSubject(ctx, subject)
981 if err != nil {
982 return "", true, err
983 }
984 return normalizeEmail(addr), true, nil
985}
986