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 "net/http"
7)
8
9// memberCtxKey is the context key Require stashes the viewer under. It
10// is a private struct type, so no other package can collide with it or
11// forge a viewer by writing to a string key of the same name.
12type memberCtxKey struct{}
13
14// From returns the Member Require resolved for this request, and nil
15// when there is none — a handler mounted outside Require, or one
16// mounted inside it that somehow ran anyway.
17//
18// It follows auth.From's shape with one deliberate difference: a nil
19// *Member rather than a (value, ok) pair, because Member.Active
20// already answers correctly for nil and the common call is
21// `if m := idear.From(r); m != nil`.
22func From(r *http.Request) *Member {
23 m, _ := r.Context().Value(memberCtxKey{}).(*Member)
24 return m
25}
26
27// WithMember returns a request whose context carries m for From. It is
28// the stash half of Require, exported for the same reason
29// sessions.WithSession is: a test, or an app that resolves the viewer
30// some other way, must be able to put one there.
31func WithMember(r *http.Request, m *Member) *http.Request {
32 return r.WithContext(context.WithValue(r.Context(), memberCtxKey{}, m))
33}
34
35// Require guards a handler: the request must carry a session subject
36// (Config.Subject) that resolves to a member of this instance, and
37// that member must be ACTIVE. Anything else is answered by
38// Config.NotFound and next is never called. The viewer rides the
39// request context for From.
40//
41// It NEVER redirects. A signed-out request is the upstream
42// middleware's business — mount this inside a sessions.Require (or
43// auth.RequireSession) group and let that decide what a visitor with
44// no session sees. Require's only job is membership.
45//
46// A non-member and a deactivated member are answered IDENTICALLY, on
47// purpose: the difference is a membership oracle, and Config.NotFound
48// must be the same renderer the app gives chi's own NotFound for the
49// same reason. idear logs the distinction it refuses to render.
50//
51// THE TRAP, and it is silent: mounted OUTSIDE the app's session guard,
52// Config.Subject resolves nothing on every request and every request
53// 404s — including requests from the Owner. The response is
54// indistinguishable from a real refusal, so nothing but a log line
55// will tell you. If a correctly-signed-in member is getting 404s from
56// an idear-guarded route, this is the first thing to check.
57func (rs *Roster) Require(next http.Handler) http.Handler {
58 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
59 subject, ok := rs.cfg.Subject(r)
60 if !ok || subject == "" {
61 // Warn, not Info: at this mount point every request will
62 // 404 forever, and the response cannot say so.
63 rs.cfg.Logger.Warn("idear: no session subject on a guarded route; is idear's Require mounted INSIDE the app's session guard?",
64 "path", r.URL.Path)
65 rs.cfg.NotFound(w, r)
66 return
67 }
68
69 m, err := rs.BySubject(r.Context(), subject)
70 switch {
71 case errors.Is(err, ErrNotFound):
72 rs.cfg.Logger.Info("idear: refused a non-member", "subject", subject, "path", r.URL.Path)
73 rs.cfg.NotFound(w, r)
74 return
75 case err != nil:
76 // A storage failure is NOT a membership answer, but it is
77 // rendered as one: 404 is the fail-closed direction, and
78 // inventing a 500 here would hand a prober a signal that
79 // varies with the database rather than with membership.
80 // The log line is the only place the difference exists.
81 rs.cfg.Logger.Error("idear: resolving the viewer failed; refusing as if not a member",
82 "subject", subject, "path", r.URL.Path, "err", err)
83 rs.cfg.NotFound(w, r)
84 return
85 }
86 if !m.Active() {
87 rs.cfg.Logger.Info("idear: refused a deactivated member", "subject", subject, "member_id", m.ID, "path", r.URL.Path)
88 rs.cfg.NotFound(w, r)
89 return
90 }
91
92 next.ServeHTTP(w, WithMember(r, m))
93 })
94}
95
96// RequireRole guards a handler with a rank floor: the viewer must be a
97// member of at least min. Below it, Config.Forbidden answers — 403,
98// not 404, because a member may legitimately know the page exists and
99// merely may not act on it.
100//
101// It STACKS INSIDE Require and is not usable on its own:
102//
103// rs.Require(rs.RequireRole(idear.RoleAdmin)(h)) // correct
104// rs.RequireRole(idear.RoleAdmin)(h) // WRONG
105//
106// Mounted bare it reads a viewer From never put there, and answers a
107// NON-MEMBER 403 — which tells a stranger this route exists and breaks
108// the 404 rule Require holds everywhere else. The refusal is
109// deliberately not softened to 404 here: quietly papering over the
110// mis-mount would leave the route running without the membership check
111// Require performs, which is the worse half of the bug.
112func (rs *Roster) RequireRole(min Role) func(http.Handler) http.Handler {
113 return func(next http.Handler) http.Handler {
114 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
115 m := From(r)
116 if m == nil {
117 rs.cfg.Logger.Warn("idear: RequireRole found no viewer; it must be mounted INSIDE Require",
118 "path", r.URL.Path)
119 rs.cfg.Forbidden(w, r)
120 return
121 }
122 if !m.Active() || !m.Role.AtLeast(min) {
123 rs.cfg.Logger.Info("idear: refused a member below the required rank",
124 "subject", m.Subject, "role", string(m.Role), "min", string(min), "path", r.URL.Path)
125 rs.cfg.Forbidden(w, r)
126 return
127 }
128 next.ServeHTTP(w, r)
129 })
130 }
131}
132