The command API, which refuses everything until it knows who is asking
novox/hq ADR 0035: one implementation, several surfaces, and a surface holds no decisions. The act of assigning — including that an assignment which does not resolve is kept and still refused — moved into acts.go, and the command line now calls it too. Two surfaces, one refusal, in the same words. It will not run without --issuer, and refuses at start rather than per request so it is found by whoever ran it rather than by whoever finds it. There is no flag that removes the check. The authenticator is honest about what it is: no token can be verified until an identity provider exists, because that is a module and none is running, so every request is refused and told that the command line still works. A surface that functioned without authentication would be one somebody left running — and the board this stands behind is published on a public name. Four refusals, four tests. The last one first asserted "not 200", which passed because a request with no database fails at the store anyway — it proved nothing about whether the input was checked. It now asserts the specific refusal, and bites when the check is removed.
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The command API: what the mesh can be asked to do, over a network.
|
||||
//
|
||||
// **An adapter and nothing else** (novox/hq ADR 0035). Every route here calls the same function
|
||||
// the command line calls, so a refusal is the same refusal in the same words. Nothing is decided
|
||||
// in this file — the moment it validates something the command line does not, the mesh has two
|
||||
// answers to one question.
|
||||
//
|
||||
// **It refuses everything unless it was told how to know who is asking.** The board is published
|
||||
// on a public name, and this is what stands behind it: an unauthenticated command surface reachable
|
||||
// from the internet is authority over the mesh handed to whoever finds it. So there is no
|
||||
// permissive default and no flag that removes the check — a mesh that has not been told how to
|
||||
// authenticate serves nothing, loudly.
|
||||
//
|
||||
// The intended authenticator is an OAuth2 provider (ADR 0035), which is an ordinary module. Until
|
||||
// one is configured this refuses, which is the correct behaviour rather than a placeholder: a
|
||||
// surface that worked without authentication would be one somebody left running.
|
||||
func apiCommand(ctx context.Context, args []string) error {
|
||||
set := flag.NewFlagSet("api", flag.ContinueOnError)
|
||||
listen := set.String("listen", "127.0.0.1:8081", "where to serve it")
|
||||
issuer := set.String("issuer", "",
|
||||
"the OAuth2 issuer whose tokens this accepts; without it, nothing is served")
|
||||
if _, err := parseAround(set, args); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(*issuer) == "" {
|
||||
// Refused at start rather than per request, so it is discovered by whoever ran it rather
|
||||
// than by whoever finds it.
|
||||
return errors.New(
|
||||
"--issuer is not set, and this serves commands rather than pages: it will not run " +
|
||||
"without being told whose tokens to believe. An OAuth2 provider is an ordinary " +
|
||||
"module (novox/hq ADR 0035)")
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Addr: *listen,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
Handler: commands(mustAuthenticate(*issuer)),
|
||||
}
|
||||
fmt.Printf("the command API is on http://%s\n", *listen)
|
||||
fmt.Printf(" it accepts tokens from %s and refuses everything else\n", *issuer)
|
||||
fmt.Printf(" every route calls what the command line calls\n")
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
closing, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(closing)
|
||||
}()
|
||||
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Authenticator says whether a request may act, and as whom.
|
||||
//
|
||||
// An interface so the check can be driven by a test without an identity provider, and so the one
|
||||
// real implementation is the only thing that has to be right.
|
||||
type Authenticator interface {
|
||||
// Who returns the subject a request is acting as, or an error naming why it may not.
|
||||
Who(r *http.Request) (string, error)
|
||||
}
|
||||
|
||||
// mustAuthenticate is the real one: a bearer token from the configured issuer.
|
||||
//
|
||||
// **Not yet verifying the signature**, and it says so rather than pretending. Verification needs
|
||||
// the issuer's keys, which needs an identity provider to exist — so this refuses every request
|
||||
// until that is built, which is the same answer as having no API at all and is honest about why.
|
||||
func mustAuthenticate(issuer string) Authenticator { return notYet{issuer: issuer} }
|
||||
|
||||
type notYet struct{ issuer string }
|
||||
|
||||
func (n notYet) Who(*http.Request) (string, error) {
|
||||
return "", fmt.Errorf(
|
||||
"this mesh has no way to verify a token from %s yet: the identity provider is a module "+
|
||||
"and none is running. Use the command line, which authenticates through nothing "+
|
||||
"because it is already behind the machine's own login", n.issuer)
|
||||
}
|
||||
|
||||
// commands is the routing, separate so a test can drive it without a listener.
|
||||
func commands(who Authenticator) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc("POST /assign", acting(who, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||
return assign(ctx, open, in.Node, in.Module)
|
||||
}))
|
||||
mux.HandleFunc("POST /unassign", acting(who, func(ctx context.Context, open *stores, in request) (string, error) {
|
||||
return unassign(ctx, open, in.Node, in.Module)
|
||||
}))
|
||||
|
||||
// Anything else is said plainly, because a command surface answering 404 to a verb somebody
|
||||
// expected is indistinguishable from one that is down.
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
refuse(w, http.StatusNotFound, fmt.Errorf(
|
||||
"%s %s is not something this mesh can be asked; it accepts POST /assign and "+
|
||||
"POST /unassign", r.Method, r.URL.Path))
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
type request struct {
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
// acting is the shape every route shares: authenticate, read, act, answer.
|
||||
func acting(
|
||||
who Authenticator,
|
||||
do func(context.Context, *stores, request) (string, error),
|
||||
) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := who.Who(r); err != nil {
|
||||
refuse(w, http.StatusUnauthorized, err)
|
||||
return
|
||||
}
|
||||
var in request
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
refuse(w, http.StatusBadRequest, fmt.Errorf("this is not a request this understands: %w", err))
|
||||
return
|
||||
}
|
||||
if in.Node == "" || in.Module == "" {
|
||||
refuse(w, http.StatusBadRequest, errors.New(`both "node" and "module" are needed`))
|
||||
return
|
||||
}
|
||||
|
||||
open, err := openStores(r.Context())
|
||||
if err != nil {
|
||||
refuse(w, http.StatusServiceUnavailable, err)
|
||||
return
|
||||
}
|
||||
defer open.Close()
|
||||
|
||||
said, err := do(r.Context(), open, in)
|
||||
if err != nil {
|
||||
// **The refusal the command line would have given, unchanged.** Carrying `said` with
|
||||
// it matters: an assignment that was kept and still does not resolve is two facts,
|
||||
// and dropping either makes the answer wrong.
|
||||
answer(w, http.StatusConflict, map[string]any{"said": said, "refused": err.Error()})
|
||||
return
|
||||
}
|
||||
answer(w, http.StatusOK, map[string]any{"said": said})
|
||||
}
|
||||
}
|
||||
|
||||
func answer(w http.ResponseWriter, status int, body map[string]any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func refuse(w http.ResponseWriter, status int, err error) {
|
||||
answer(w, status, map[string]any{"refused": err.Error()})
|
||||
}
|
||||
Reference in New Issue
Block a user