package idear import ( "context" "errors" "net/http" ) // memberCtxKey is the context key Require stashes the viewer under. It // is a private struct type, so no other package can collide with it or // forge a viewer by writing to a string key of the same name. type memberCtxKey struct{} // From returns the Member Require resolved for this request, and nil // when there is none — a handler mounted outside Require, or one // mounted inside it that somehow ran anyway. // // It follows auth.From's shape with one deliberate difference: a nil // *Member rather than a (value, ok) pair, because Member.Active // already answers correctly for nil and the common call is // `if m := idear.From(r); m != nil`. func From(r *http.Request) *Member { m, _ := r.Context().Value(memberCtxKey{}).(*Member) return m } // WithMember returns a request whose context carries m for From. It is // the stash half of Require, exported for the same reason // sessions.WithSession is: a test, or an app that resolves the viewer // some other way, must be able to put one there. func WithMember(r *http.Request, m *Member) *http.Request { return r.WithContext(context.WithValue(r.Context(), memberCtxKey{}, m)) } // Require guards a handler: the request must carry a session subject // (Config.Subject) that resolves to a member of this instance, and // that member must be ACTIVE. Anything else is answered by // Config.NotFound and next is never called. The viewer rides the // request context for From. // // It NEVER redirects. A signed-out request is the upstream // middleware's business — mount this inside a sessions.Require (or // auth.RequireSession) group and let that decide what a visitor with // no session sees. Require's only job is membership. // // A non-member and a deactivated member are answered IDENTICALLY, on // purpose: the difference is a membership oracle, and Config.NotFound // must be the same renderer the app gives chi's own NotFound for the // same reason. idear logs the distinction it refuses to render. // // THE TRAP, and it is silent: mounted OUTSIDE the app's session guard, // Config.Subject resolves nothing on every request and every request // 404s — including requests from the Owner. The response is // indistinguishable from a real refusal, so nothing but a log line // will tell you. If a correctly-signed-in member is getting 404s from // an idear-guarded route, this is the first thing to check. func (rs *Roster) Require(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { subject, ok := rs.cfg.Subject(r) if !ok || subject == "" { // Warn, not Info: at this mount point every request will // 404 forever, and the response cannot say so. rs.cfg.Logger.Warn("idear: no session subject on a guarded route; is idear's Require mounted INSIDE the app's session guard?", "path", r.URL.Path) rs.cfg.NotFound(w, r) return } m, err := rs.BySubject(r.Context(), subject) switch { case errors.Is(err, ErrNotFound): rs.cfg.Logger.Info("idear: refused a non-member", "subject", subject, "path", r.URL.Path) rs.cfg.NotFound(w, r) return case err != nil: // A storage failure is NOT a membership answer, but it is // rendered as one: 404 is the fail-closed direction, and // inventing a 500 here would hand a prober a signal that // varies with the database rather than with membership. // The log line is the only place the difference exists. rs.cfg.Logger.Error("idear: resolving the viewer failed; refusing as if not a member", "subject", subject, "path", r.URL.Path, "err", err) rs.cfg.NotFound(w, r) return } if !m.Active() { rs.cfg.Logger.Info("idear: refused a deactivated member", "subject", subject, "member_id", m.ID, "path", r.URL.Path) rs.cfg.NotFound(w, r) return } next.ServeHTTP(w, WithMember(r, m)) }) } // RequireRole guards a handler with a rank floor: the viewer must be a // member of at least min. Below it, Config.Forbidden answers — 403, // not 404, because a member may legitimately know the page exists and // merely may not act on it. // // It STACKS INSIDE Require and is not usable on its own: // // rs.Require(rs.RequireRole(idear.RoleAdmin)(h)) // correct // rs.RequireRole(idear.RoleAdmin)(h) // WRONG // // Mounted bare it reads a viewer From never put there, and answers a // NON-MEMBER 403 — which tells a stranger this route exists and breaks // the 404 rule Require holds everywhere else. The refusal is // deliberately not softened to 404 here: quietly papering over the // mis-mount would leave the route running without the membership check // Require performs, which is the worse half of the bug. func (rs *Roster) RequireRole(min Role) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { m := From(r) if m == nil { rs.cfg.Logger.Warn("idear: RequireRole found no viewer; it must be mounted INSIDE Require", "path", r.URL.Path) rs.cfg.Forbidden(w, r) return } if !m.Active() || !m.Role.AtLeast(min) { rs.cfg.Logger.Info("idear: refused a member below the required rank", "subject", m.Subject, "role", string(m.Role), "min", string(min), "path", r.URL.Path) rs.cfg.Forbidden(w, r) return } next.ServeHTTP(w, r) }) } }