Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions solution.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
86 changes: 86 additions & 0 deletions solution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
Loading