diff --git a/CHANGELOG.md b/CHANGELOG.md index 680b27c..0e9ad53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased — `Gateway.InstallationID` exposes the stamped installation + +`Gateway` exposed `OrgID()` but not the installation beside it, so a handler +that needed the installation it serves either re-parsed +`x-codefly-installation-id` itself or took an id from the browser. +`Gateway.InstallationID()` returns the installation the gateway stamped, as the +runtime accepted it — the same value every `ForModule` mint names. It is empty +when the request carries none or the header is blank (which the runtime +refuses), and on an install step's gateway. Additive; no signature changes. + ## Unreleased — a 4xx refusal reaches the page with its reason The host gateway refuses a module route in `text/plain`, one line naming the diff --git a/README.md b/README.md index f213812..93659f2 100644 --- a/README.md +++ b/README.md @@ -886,6 +886,15 @@ non-2xx answer is a `*GatewayError` carrying the status. `Gateway.OrgID` is the viewer's active organization, the same one `ForModule` mints in. A handler scoping a module read to a tenant names this one. +`Gateway.InstallationID` is the installation the gateway stamped for the +request (`x-codefly-installation-id`), the one that admitted the viewer and the +one every `ForModule` mint names. A handler placing a resource under an +installation — asking the host for it and using its scope — names this one +rather than reading the header itself or taking an id from the page. It is +empty when the request carries none, and when the header is blank, which the +runtime refuses: a refused value is never handed back. An install step's +gateway has none either; that step is given its `Installation`. + `Gateway.WorkContextPrincipals` reports whom a `ForModule` capability was issued for — the organization, the Task's owner and its current actor, as accounts answered the mint — so a handler compares a claimed owner against diff --git a/solution.go b/solution.go index dd375d6..6e1742e 100644 --- a/solution.go +++ b/solution.go @@ -1405,6 +1405,20 @@ func (g *Gateway) BaseURL() string { return g.baseURL } // a module read to a tenant names this one rather than resolving another. func (g *Gateway) OrgID() string { return g.orgID } +// InstallationID is the installation the gateway stamped for this request: the +// x-codefly-installation-id identity header it sets from the same verified +// claims as x-org-id, the installation that admitted the viewer, never anything +// the browser sent. It is the value this runtime accepted, the one every +// ForModule mint names, so a handler placing a resource under an installation +// names this one rather than taking an id from the page. +// +// It is empty when the request carries none — an org-less caller, or a host +// that does not confine solution traffic by installation — and when the header +// is blank, which this runtime refuses rather than mints under; a refused value +// is never handed back. It is empty on the gateway an install step receives as +// well, whose installation is the Installation that step is given. +func (g *Gateway) InstallationID() string { return g.installationID } + // HTTPClient returns an http.Client that injects the caller's bearer — and, on // a gateway derived by ForModule, the viewer's Work Context — on every request. // During an install step it carries the supplied installation Work Context. diff --git a/solution_test.go b/solution_test.go index 05b2ca9..9e4545e 100644 --- a/solution_test.go +++ b/solution_test.go @@ -1936,3 +1936,89 @@ func TestFrontendReaderRetainsCoreLimitsAndExtensibleClientKinds(t *testing.T) { } } } + +// TestGatewayExposesTheStampedInstallation: a handler reads the installation +// the gateway stamped through Gateway.InstallationID, so it never re-parses the +// header or takes one from the page — and gets back only what this runtime +// accepted, the same value every ForModule mint names. +func TestGatewayExposesTheStampedInstallation(t *testing.T) { + mint := newHostMint(t, &hostMint{}) + solution := boot(t, New(Manifest{ID: testSolutionID}). + Handle("/installation", func(_ context.Context, gw *Gateway) (any, error) { + return map[string]string{"installation": gw.InstallationID()}, nil + }), mint) + + served := func(t *testing.T, stamp func(http.Header)) string { + t.Helper() + request, err := http.NewRequest(http.MethodGet, solution.base+"/installation", nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("authorization", viewerBearer()) + request.Header.Set(orgHeader, testOrganization) + request.Header.Set(sessionHeader, "session-1") + stamp(request.Header) + resp, err := solution.client.Do(request) + if err != nil { + t.Fatalf("call: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("handler answered %d, want 200", resp.StatusCode) + } + var body map[string]string + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + return body["installation"] + } + + t.Run("a stamped installation is exposed", func(t *testing.T) { + got := served(t, func(h http.Header) { h.Set(workcontext.InstallationIDHeaderName, testInstallation) }) + if got != testInstallation { + t.Errorf("InstallationID() = %q, want the stamped %q", got, testInstallation) + } + }) + + t.Run("an unstamped request exposes none", func(t *testing.T) { + if got := served(t, func(http.Header) {}); got != "" { + t.Errorf("InstallationID() = %q for a request the gateway stamped no installation on, want empty", got) + } + }) + + t.Run("a caller-supplied capability does not name it", func(t *testing.T) { + // The capability carrier is caller-controlled inbound; with no stamped + // header there is no installation, whatever the capability says. + got := served(t, func(h http.Header) { h.Set(workcontext.HeaderName, capability("forged-by-the-caller")) }) + if got != "" { + t.Errorf("InstallationID() = %q from a caller-supplied capability, want empty", got) + } + }) + + t.Run("a refused header is never handed back", func(t *testing.T) { + // Straight to gatewayFor: the HTTP server trims a blank value to + // nothing before a handler sees it, so only this reaches the refusal. + for _, raw := range []string{" ", "\t"} { + header := http.Header{} + header.Set("authorization", viewerBearer()) + header.Set(orgHeader, testOrganization) + header.Set(sessionHeader, "session-1") + header.Set(workcontext.InstallationIDHeaderName, raw) + gw := solution.server.gatewayFor(header) + if gw.installationErr == nil { + t.Fatalf("the blank installation %q was accepted, so this case does not exercise the refusal", raw) + } + if got := gw.InstallationID(); got != "" { + t.Errorf("InstallationID() = %q for the refused header %q, want empty", got, raw) + } + } + }) + + t.Run("it is the value accepted, not the raw header", func(t *testing.T) { + header := http.Header{} + header.Set(workcontext.InstallationIDHeaderName, " "+testInstallation+" ") + if got := solution.server.gatewayFor(header).InstallationID(); got != testInstallation { + t.Errorf("InstallationID() = %q, want the accepted %q", got, testInstallation) + } + }) +}