From 76cf76c4ca7c35f0f56875d25a2e50dd629d6987 Mon Sep 17 00:00:00 2001 From: mark wang Date: Thu, 29 Oct 2026 14:47:32 -0400 Subject: [PATCH 1/2] feat(edge): mint an effect id for each module call A module operation that keeps receipts (sdk-go/receipts, RECOVERY_RECEIPT) requires an x-codefly-effect-id on every call, and the edge forwards no x-codefly-* header a browser sent. So a browser could never reach a guarded operation: obin-ai/module-robin#633 found chat's rename would break for the UI the moment it is guarded. The edge now sets a fresh effect id (uuid) on each module call, after stripping the browser's x-codefly-* headers, and none on frontend calls. It stays within the edge's rule ("no x-codefly-* header leaves the edge unless the edge set it"), and since a browser never names an effect it can't replay another person's receipt: receipts are keyed by tenant, effect and method, not by person. Each call is its own effect, so a browser retry is a new attempt, as it is today. Co-Authored-By: Claude Opus 5.5 --- README.md | 7 ++- modules/authority/services/edge/code/edge.go | 18 +++++-- .../services/edge/code/effect_test.go | 48 +++++++++++++++++++ .../edge/code/environment_proxy_test.go | 2 +- modules/authority/services/edge/code/go.mod | 1 + modules/authority/services/edge/code/go.sum | 12 +++++ 6 files changed, 82 insertions(+), 6 deletions(-) create mode 100644 modules/authority/services/edge/code/effect_test.go diff --git a/README.md b/README.md index ca173c01..b967b0db 100644 --- a/README.md +++ b/README.md @@ -759,7 +759,12 @@ 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. 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 2f593907..7874fd90 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,9 @@ func proxy(target *url.URL, token string, w http.ResponseWriter, r *http.Request if token != "" { pr.Out.Header.Set(WorkContextKey, token) } + if effect != "" { + 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 00000000..77e3bafd --- /dev/null +++ b/modules/authority/services/edge/code/effect_test.go @@ -0,0 +1,48 @@ +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 + } + if res, _ := f.do(t, http.MethodGet, "/v1/records/types", http.Header{"Cookie": {cookie.String()}, "X-Codefly-Effect-Id": {"forged"}}); res.StatusCode != http.StatusOK { + t.Fatalf("answered %d", res.StatusCode) + } + seen, _ := f.rest.last() + first := minted(seen) + if first == "forged" { + t.Fatal("the browser named the effect") + } + 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}}); res.StatusCode != http.StatusOK { + t.Fatalf("a Connect call answered %d", res.StatusCode) + } + if seen, _ := f.connect.last(); minted(seen) == first { + t.Fatal("the Connect call reused an effect") + } +} diff --git a/modules/authority/services/edge/code/environment_proxy_test.go b/modules/authority/services/edge/code/environment_proxy_test.go index 193af6ea..5586bfb7 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 577affc5..d2488bd9 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 5ef2fd38..dd4e4fac 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= From 7a2439a6c6f3045aaa385e5b08fbdc3666b164bb Mon Sep 17 00:00:00 2001 From: mark wang Date: Thu, 29 Oct 2026 15:02:54 -0400 Subject: [PATCH 2/2] fix(edge): drop a caller's Idempotency-Key when minting the effect id The receipts SDK accepts Idempotency-Key as the HTTP spelling of the effect id. The edge kept it, so a caller sending one put two effect ids on the call, and both SDK adapters refuse that with InvalidArgument before the operation runs. Strip it whenever the edge mints the effect id; the test now sends one on REST and Connect and checks neither reaches the module. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 ++- modules/authority/services/edge/code/edge.go | 3 +++ .../authority/services/edge/code/effect_test.go | 15 +++++++++------ 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index b967b0db..a0c69916 100644 --- a/README.md +++ b/README.md @@ -760,7 +760,8 @@ 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` and a -fresh `x-codefly-effect-id` (`receipts.EffectIDHeaderName`) for the call. An +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 diff --git a/modules/authority/services/edge/code/edge.go b/modules/authority/services/edge/code/edge.go index 7874fd90..2f805ca4 100644 --- a/modules/authority/services/edge/code/edge.go +++ b/modules/authority/services/edge/code/edge.go @@ -477,6 +477,9 @@ func proxy(target *url.URL, token, effect string, w http.ResponseWriter, r *http 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) } }, diff --git a/modules/authority/services/edge/code/effect_test.go b/modules/authority/services/edge/code/effect_test.go index 77e3bafd..2b41e688 100644 --- a/modules/authority/services/edge/code/effect_test.go +++ b/modules/authority/services/edge/code/effect_test.go @@ -24,13 +24,16 @@ func TestEachModuleCallCarriesAnEffectIDTheEdgeMinted(t *testing.T) { } return id } - if res, _ := f.do(t, http.MethodGet, "/v1/records/types", http.Header{"Cookie": {cookie.String()}, "X-Codefly-Effect-Id": {"forged"}}); res.StatusCode != http.StatusOK { + // 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" { - t.Fatal("the browser named the effect") + 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) @@ -39,10 +42,10 @@ func TestEachModuleCallCarriesAnEffectIDTheEdgeMinted(t *testing.T) { 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}}); res.StatusCode != http.StatusOK { + 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 { - t.Fatal("the Connect call reused an effect") + 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) } }