diff --git a/README.md b/README.md index ca173c0..a0c6991 100644 --- a/README.md +++ b/README.md @@ -759,7 +759,13 @@ frontend. For a module call the edge issues a Work Context for the person at the module (`Issue` with the module as the audience and no scopes, so the token carries what the person's roles grant there). It caches the context until shortly before it expires. It strips `Authorization`, `Cookie` and every -`x-codefly-*` header the browser sent, then sets `x-codefly-work-context`. The +`x-codefly-*` header the browser sent, then sets `x-codefly-work-context` and a +fresh `x-codefly-effect-id` (`receipts.EffectIDHeaderName`) for the call, dropping +any `Idempotency-Key`, the SDK's other spelling of the effect id. An +operation that keeps receipts (Codefly's `sdk-go/receipts`) requires an effect id +on every call; the edge names each one so a browser never can, and so can never +replay another person's receipt. Each module call is its own effect: a browser +retry is a new attempt. The module's faces dial the hop, which checks the method. A write authenticated by the cookie must carry this origin in `Origin`. Each refusal names its reason. diff --git a/modules/authority/services/edge/code/edge.go b/modules/authority/services/edge/code/edge.go index 2f59390..2f805ca 100644 --- a/modules/authority/services/edge/code/edge.go +++ b/modules/authority/services/edge/code/edge.go @@ -18,6 +18,8 @@ import ( installationv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/installation/v1" sessionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/session/v1" "github.com/codefly-dev/authority/libraries/authority-client/go/host" + "github.com/codefly-dev/sdk-go/receipts" + "github.com/google/uuid" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" @@ -151,7 +153,7 @@ func (e *Edge) ServeHTTP(w http.ResponseWriter, r *http.Request) { refuse(w, http.StatusNotFound, "no module serves "+r.URL.Path) return } - proxy(e.Frontend, "", w, r) + proxy(e.Frontend, "", "", w, r) } // route is the module a path is for and the face that serves it. @@ -197,7 +199,9 @@ func (e *Edge) toModule(w http.ResponseWriter, r *http.Request, module Module, t refuseCall(w, r, code, "the authority refused this person at "+module.Name+": "+status.Convert(err).Message()) return } - proxy(target, wc.token, w, r) + // Each module call is its own effect, minted here: an operation that keeps + // receipts requires an effect id, and a browser may not name one. + proxy(target, wc.token, uuid.NewString(), w, r) } // workContext is the person's Work Context at module, issued by the authority @@ -450,8 +454,11 @@ func sameOrigin(r *http.Request) bool { // proxy sends r on to target, with the Work Context the edge holds for it and // nothing a caller said about who it is: no Authorization, no Cookie and no -// x-codefly-* header leaves the edge unless the edge set it. -func proxy(target *url.URL, token string, w http.ResponseWriter, r *http.Request) { +// x-codefly-* header leaves the edge unless the edge set it. effect, when set, +// is the effect id the call is attempted under: the edge names every module +// call's effect, so a caller can never name one and replay another person's +// receipt. +func proxy(target *url.URL, token, effect string, w http.ResponseWriter, r *http.Request) { (&httputil.ReverseProxy{ // Never through a proxy from the environment, which would read the // person's Work Context. @@ -469,6 +476,12 @@ func proxy(target *url.URL, token string, w http.ResponseWriter, r *http.Request if token != "" { pr.Out.Header.Set(WorkContextKey, token) } + if effect != "" { + // Idempotency-Key is the SDK's HTTP spelling of the effect id; + // a caller's would be a second effect id beside the minted one. + pr.Out.Header.Del(receipts.IdempotencyKeyHeaderName) + pr.Out.Header.Set(receipts.EffectIDHeaderName, effect) + } }, ErrorHandler: func(w http.ResponseWriter, _ *http.Request, err error) { refuse(w, http.StatusBadGateway, "the module did not answer: "+err.Error()) diff --git a/modules/authority/services/edge/code/effect_test.go b/modules/authority/services/edge/code/effect_test.go new file mode 100644 index 0000000..2b41e68 --- /dev/null +++ b/modules/authority/services/edge/code/effect_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "net/http" + "testing" + + "github.com/codefly-dev/sdk-go/receipts" + "github.com/google/uuid" +) + +// A module operation that keeps receipts requires an effect id on every call, +// and the edge forwards no x-codefly-* header a browser sent. So the edge +// mints one for each module call: a browser can't name an effect, and so can +// never replay another person's receipt, yet its call still reaches a guarded +// operation. +func TestEachModuleCallCarriesAnEffectIDTheEdgeMinted(t *testing.T) { + f := newFixture(t) + cookie := f.signIn(t) + minted := func(seen http.Header) string { + t.Helper() + id := seen.Get(receipts.EffectIDHeaderName) + if _, err := uuid.Parse(id); err != nil { + t.Fatalf("the module received effect id %q: %v", id, err) + } + return id + } + // Idempotency-Key is the SDK's HTTP spelling of the same effect id: left in + // place beside the minted one, it would be a second effect id, which the SDK + // refuses before running the operation. + if res, _ := f.do(t, http.MethodGet, "/v1/records/types", http.Header{"Cookie": {cookie.String()}, "X-Codefly-Effect-Id": {"forged"}, receipts.IdempotencyKeyHeaderName: {"forged"}}); res.StatusCode != http.StatusOK { + t.Fatalf("answered %d", res.StatusCode) + } + seen, _ := f.rest.last() + first := minted(seen) + if first == "forged" || seen.Get(receipts.IdempotencyKeyHeaderName) != "" { + t.Fatalf("the browser named the effect: %v", seen) + } + if res, _ := f.do(t, http.MethodGet, "/v1/records/types", http.Header{"Cookie": {cookie.String()}}); res.StatusCode != http.StatusOK { + t.Fatalf("answered %d", res.StatusCode) + } + if seen, _ := f.rest.last(); minted(seen) == first { + t.Fatal("two calls were minted one effect") + } + // The Connect face, by the service its path names. + if res, _ := f.do(t, http.MethodPost, "/records.v1.RecordsService/Query", http.Header{"Cookie": {cookie.String()}, "Origin": {f.edge.URL}, receipts.IdempotencyKeyHeaderName: {"forged"}}); res.StatusCode != http.StatusOK { + t.Fatalf("a Connect call answered %d", res.StatusCode) + } + if seen, _ := f.connect.last(); minted(seen) == first || seen.Get(receipts.IdempotencyKeyHeaderName) != "" { + t.Fatalf("the Connect call reused an effect or kept the caller's: %v", seen) + } +} diff --git a/modules/authority/services/edge/code/environment_proxy_test.go b/modules/authority/services/edge/code/environment_proxy_test.go index 193af6e..5586bfb 100644 --- a/modules/authority/services/edge/code/environment_proxy_test.go +++ b/modules/authority/services/edge/code/environment_proxy_test.go @@ -48,7 +48,7 @@ func TestTheEdgeNeverForwardsThePersonsWorkContextThroughAnEnvironmentProxy(t *t ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) defer cancel() before := proxied.Load() - proxy(target, "SECRET-WC", httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/v1/documents", nil).WithContext(ctx)) + proxy(target, "SECRET-WC", "", httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/v1/documents", nil).WithContext(ctx)) if n := proxied.Load() - before; n != 0 { t.Fatalf("the edge forwarded the Work Context through the environment proxy (%d connections)", n) } diff --git a/modules/authority/services/edge/code/go.mod b/modules/authority/services/edge/code/go.mod index 577affc..d2488bd 100644 --- a/modules/authority/services/edge/code/go.mod +++ b/modules/authority/services/edge/code/go.mod @@ -41,6 +41,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/oklog/ulid/v2 v2.1.2 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 // indirect github.com/yoheimuta/go-protoparser/v4 v4.14.2 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba // indirect diff --git a/modules/authority/services/edge/code/go.sum b/modules/authority/services/edge/code/go.sum index 5ef2fd3..dd4e4fa 100644 --- a/modules/authority/services/edge/code/go.sum +++ b/modules/authority/services/edge/code/go.sum @@ -44,6 +44,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= +github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.8.2 h1:9d9DEokx5W7qlr0shkFXIxEA7+TTD+llwzcykSlxMNc= @@ -129,6 +131,14 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= @@ -198,6 +208,8 @@ golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba h1:Ck8QetSgk912qxWLMCKxd0in+ golang.org/x/exp v0.0.0-20260908205506-85c1c2202aba/go.mod h1:50RgIsmK7OwqzTTeqcSXQW8SswW0o8fRcDxmqGluJ8E= golang.org/x/net v0.60.0 h1:79p50tfZlm0J9YfoDsSi639qSXNGVwEzOPLCxM2FsYU= golang.org/x/net v0.60.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA= golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=