diff --git a/README.md b/README.md index b878337..4fe4bc0 100644 --- a/README.md +++ b/README.md @@ -784,6 +784,14 @@ if err != nil { resp, err := solution.Unary[Req, Resp](ctx, docs, "/docs.v1.Documents/List", &Req{}) ``` +`ForModuleWithLifetime(ctx, audience, lifetime, scopes...)` requests an explicit +issuer-controlled lifetime in whole seconds. Zero preserves the issuer default; +the issuer enforces its maximum. Lifetime participates in the per-request mint +cache identity alongside audience and scopes. A cached token is reused only +while its remaining lifetime exceeds the explicit request; otherwise a fresh +issuer answer is requested. Zero retains the existing renewal rule. It follows the same published +authority ceiling and installation checks as `ForModule`. + `ForModule` mints a Task Work Context through accounts' `StartTask`, presenting the viewer's bearer so accounts resolves the same subject the module would have seen, and this execution's own credential beside it so the issuer knows which diff --git a/boot_test.go b/boot_test.go index 4e4a641..3f5a4b0 100644 --- a/boot_test.go +++ b/boot_test.go @@ -930,7 +930,9 @@ func TestServedContractReportsWhatThisProcessHoldsItselfTo(t *testing.T) { } // boundPort binds a listener and returns it with its port, so a test hands the -// *listener* to the boot rather than a port number. +// *listener* to the boot rather than a port number. The test clients dial +// IPv4 loopback; binding that same address prevents a wildcard socket from +// sharing its port with a different namespace's IPv4 listener. // // This replaces a helper that bound :0, read the port, closed the socket and // returned the number — a time-of-check/time-of-use race against everything @@ -940,7 +942,7 @@ func TestServedContractReportsWhatThisProcessHoldsItselfTo(t *testing.T) { // the port is released by construction. Nothing is released here. func boundPort(t *testing.T) (net.Listener, string) { t.Helper() - ln, err := net.Listen("tcp", ":0") + ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } diff --git a/contract_test.go b/contract_test.go index b8fec53..8aa50da 100644 --- a/contract_test.go +++ b/contract_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/codefly-dev/core/solution/manifest" "github.com/codefly-dev/sdk-go/workcontext" @@ -344,7 +345,7 @@ func TestThisRuntimesContractDoesNotClaimTheRenderersSchema(t *testing.T) { } } -// TestABootedRuntimeHoldsForModuleToThePublishedCeiling is the authority bypass +// TestABootedRuntimeHoldsForModuleLifetimeToThePublishedCeiling is the authority bypass // a second reviewer found, and it defeats the point of publishing a ceiling at // all. // @@ -360,6 +361,15 @@ func TestThisRuntimesContractDoesNotClaimTheRenderersSchema(t *testing.T) { // through its handler, counting what the host was asked — a refusal that still // minted would be no refusal. func TestABootedRuntimeHoldsForModuleToThePublishedCeiling(t *testing.T) { + testBootedRuntimeModuleCeiling(t, false) +} + +func TestABootedRuntimeHoldsForModuleLifetimeToThePublishedCeiling(t *testing.T) { + testBootedRuntimeModuleCeiling(t, true) +} + +func testBootedRuntimeModuleCeiling(t *testing.T, explicitLifetime bool) { + t.Helper() type ask struct { audience string scope Scope @@ -393,7 +403,11 @@ func TestABootedRuntimeHoldsForModuleToThePublishedCeiling(t *testing.T) { localProfile: {"things": {{ResourceKind: "things", Actions: []string{"read"}}}}, }}). Handle("/thing", func(ctx context.Context, gw *Gateway) (any, error) { - _, mintErr = gw.ForModule(ctx, tc.ask.audience, tc.ask.scope) + if explicitLifetime { + _, mintErr = gw.ForModuleWithLifetime(ctx, tc.ask.audience, 601*time.Second, tc.ask.scope) + } else { + _, mintErr = gw.ForModule(ctx, tc.ask.audience, tc.ask.scope) + } return map[string]string{"ok": "yes"}, nil }), mint) diff --git a/listener_allocator_test.go b/listener_allocator_test.go new file mode 100644 index 0000000..1fa4883 --- /dev/null +++ b/listener_allocator_test.go @@ -0,0 +1,68 @@ +package solution + +import ( + "io" + "net" + "net/http" + "testing" + "time" +) + +func TestBoundPortReservesTheExactIPv4LoopbackClientAddress(t *testing.T) { + ln, port := boundPort(t) + host, actualPort, err := net.SplitHostPort(ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + if host != "127.0.0.1" || actualPort != port { + t.Fatalf("allocated address = %q, want retained IPv4 loopback port %s", ln.Addr(), port) + } + competing, err := net.Listen("tcp", net.JoinHostPort("127.0.0.1", port)) + if err == nil { + _ = competing.Close() + t.Fatal("allocated client address was released before listener handoff") + } +} + +func TestBoundPortHandoffRetainsTheListenerServingTheClient(t *testing.T) { + ln, port := boundPort(t) + provideListener(t, ln) + server := takeListener(&Server{}) + if server.boundListener != ln { + t.Fatal("boot did not receive the exact allocated listener") + } + if takeListener(&Server{}).boundListener != nil { + t.Fatal("the allocated listener was handed out twice") + } + owner := t.Name() + httpServer := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, owner) + })} + done := make(chan error, 1) + go func() { done <- httpServer.Serve(server.boundListener) }() + t.Cleanup(func() { + _ = httpServer.Close() + select { + case err := <-done: + if err != http.ErrServerClosed { + t.Errorf("serve: %v", err) + } + case <-time.After(5 * time.Second): + t.Error("retained listener server did not join") + } + }) + client := &http.Client{Transport: &http.Transport{Proxy: nil}, Timeout: 5 * time.Second} + t.Cleanup(client.CloseIdleConnections) + response, err := client.Get("http://" + net.JoinHostPort("127.0.0.1", port)) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + if response.StatusCode != http.StatusOK || string(body) != owner { + t.Fatalf("client reached status %d body %q, want the retained listener owner %q", response.StatusCode, body, owner) + } +} diff --git a/solution.go b/solution.go index 3250e98..b8d27de 100644 --- a/solution.go +++ b/solution.go @@ -3104,6 +3104,20 @@ func scopesOfAsk(wire []workContextScope) []Scope { // request. Minting here as well means an ask accounts refuses fails at this // call rather than inside some later round trip. func (g *Gateway) ForModule(ctx context.Context, audience string, scopes ...Scope) (*Gateway, error) { + return g.forModule(ctx, audience, 0, scopes...) +} + +// ForModuleWithLifetime requests an explicit issuer-controlled lifetime for the +// scoped capability. Zero retains the issuer default; nonzero lifetimes must +// be positive whole seconds. The issuer still enforces its maximum lifetime. +func (g *Gateway) ForModuleWithLifetime(ctx context.Context, audience string, lifetime time.Duration, scopes ...Scope) (*Gateway, error) { + if lifetime < 0 || lifetime%time.Second != 0 || lifetime/time.Second > 1<<31-1 { + return nil, fmt.Errorf("work context lifetime must be nonnegative whole seconds fitting int32") + } + return g.forModule(ctx, audience, int32(lifetime/time.Second), scopes...) +} + +func (g *Gateway) forModule(ctx context.Context, audience string, ttlSeconds int32, scopes ...Scope) (*Gateway, error) { // The contract first, before anything about the caller: asking for // authority this solution never published is this solution's defect, true // of every caller, and it should not be reported only to the ones who @@ -3160,6 +3174,7 @@ func (g *Gateway) ForModule(ctx context.Context, audience string, scopes ...Scop SessionID: g.sessionID, Audience: audience, AuthorityScopes: workContextScopes(scopes), + TTLSeconds: ttlSeconds, } // The ask is the cache identity. The task id names one mint rather than // what was asked for, so it is filled in per mint, below. @@ -3353,6 +3368,7 @@ type startTaskRequest struct { SessionID string `json:"sessionId"` Audience string `json:"audience"` AuthorityScopes []workContextScope `json:"authorityScopes"` + TTLSeconds int32 `json:"ttlSeconds,omitempty"` } func (g *Gateway) mint(ctx context.Context, ask startTaskRequest) (string, time.Time, error) { @@ -3447,6 +3463,12 @@ func (g *Gateway) mint(ctx context.Context, ask startTaskRequest) (string, time. lifetime.Round(time.Second), workContextRenewal, lead.Round(time.Second)) }) } + // An explicit lifetime is also the reuse floor: an older cached token + // must not shorten the authority the next call requested. A newly issued + // token remains the issuer's answer even when transit consumed some life. + if requested := time.Duration(ask.TTLSeconds) * time.Second; requested > lead { + lead = requested + } g.contexts.remember(token, issued.WorkContextPrincipals) return token, issued.ExpiresAt.Add(-lead), nil } diff --git a/task_lifetime_test.go b/task_lifetime_test.go new file mode 100644 index 0000000..696a76b --- /dev/null +++ b/task_lifetime_test.go @@ -0,0 +1,184 @@ +package solution + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/codefly-dev/core/solution/manifest" + "github.com/codefly-dev/sdk-go/workcontext" +) + +func TestForModuleLifetimeWireAndCache(t *testing.T) { + var asks []startTaskRequest + var ttlFields []bool + issuer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var wire map[string]json.RawMessage + if err := json.NewDecoder(r.Body).Decode(&wire); err != nil { + t.Error(err) + } + body, err := json.Marshal(wire) + if err != nil { + t.Error(err) + } + var ask startTaskRequest + if err := json.Unmarshal(body, &ask); err != nil { + t.Error(err) + } + _, explicit := wire["ttlSeconds"] + ttlFields = append(ttlFields, explicit) + asks = append(asks, ask) + writeJSON(w, 200, map[string]any{"token": capability("lifetime-issued"), "expiresAt": time.Now().Add(15 * time.Minute), "orgId": ask.OrgID, "ownerPrincipalId": "viewer", "currentActorPrincipalId": "viewer"}) + })) + defer issuer.Close() + srv := serveHandler(t, issuer.URL, func(ctx context.Context, g *Gateway) (any, error) { + scope := Scope{ResourceKind: "runtime.tasks", Actions: []string{"create"}} + if _, err := g.ForModule(ctx, "runtime", scope); err != nil { + return nil, err + } + for range 2 { + if _, err := g.ForModuleWithLifetime(ctx, "runtime", 601*time.Second, scope); err != nil { + return nil, err + } + } + if _, err := g.ForModuleWithLifetime(ctx, "runtime", 602*time.Second, scope); err != nil { + return nil, err + } + if _, err := g.ForModuleWithLifetime(ctx, "records", 601*time.Second, Scope{ResourceKind: "records.records", Actions: []string{"read"}}); err != nil { + return nil, err + } + return "ok", nil + }) + resp := viewerRequest(t, srv.URL) + defer drainAndClose(resp) + if resp.StatusCode != 200 { + t.Fatalf("handler status %d", resp.StatusCode) + } + if len(asks) != 4 { + t.Fatalf("mint count %d, want four distinct scoped/lifetime asks", len(asks)) + } + for i, want := range []int32{0, 601, 602, 601} { + if asks[i].TTLSeconds != want { + t.Fatalf("ask %d ttl %d want %d", i, asks[i].TTLSeconds, want) + } + if ttlFields[i] != (want != 0) { + t.Fatalf("ask %d explicit TTL field %t, want %t", i, ttlFields[i], want != 0) + } + } + for _, a := range asks { + if a.OrgID != viewerOrg || a.SessionID != viewerSession || a.TaskID == "" || a.InstallationID != testInstallation { + t.Fatal("viewer boundaries lost") + } + } + if asks[3].Audience != "records" || asks[3].AuthorityScopes[0].ResourceKind != "records.records" { + t.Fatal("authority scopes conflated") + } +} + +func TestForModuleLifetimeIssuerRefusal(t *testing.T) { + issuer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var ask startTaskRequest + _ = json.NewDecoder(r.Body).Decode(&ask) + if ask.TTLSeconds != 901 { + t.Errorf("issuer got ttl %d", ask.TTLSeconds) + } + writeJSON(w, 403, map[string]string{"code": "permission_denied", "message": "requested lifetime exceeds issuer maximum"}) + })) + defer issuer.Close() + var got error + srv := serveHandler(t, issuer.URL, func(ctx context.Context, g *Gateway) (any, error) { + _, got = g.ForModuleWithLifetime(ctx, "runtime", 901*time.Second, Scope{ResourceKind: "runtime.tasks", Actions: []string{"create"}}) + return nil, got + }) + resp := viewerRequest(t, srv.URL) + defer drainAndClose(resp) + var refusal *WorkContextRefusal + if !errors.As(got, &refusal) || refusal.StatusCode != 403 || refusal.Message != "requested lifetime exceeds issuer maximum" { + t.Fatalf("issuer refusal lost: %v", got) + } +} + +func TestForModuleLifetimeInvalid(t *testing.T) { + t.Setenv(manifest.APIConsumesEnvironmentVariable, consumesThings) + mint := newHostMint(t, &hostMint{}) + var invalidErrors []error + var invalidMintCount int + var validError error + solution := boot(t, New(Manifest{ID: testSolutionID}). + Consumes(passthroughModule()). + Contract(ModuleContract{Ceilings: map[string]map[string][]Scope{ + localProfile: {"things": {{ResourceKind: "things", Actions: []string{"read"}}}}, + }}). + Handle("/lifetime", func(ctx context.Context, gw *Gateway) (any, error) { + scope := Scope{ResourceKind: "things", Actions: []string{"read"}} + for _, duration := range []time.Duration{-time.Second, time.Millisecond, time.Duration(1<<31) * time.Second} { + _, err := gw.ForModuleWithLifetime(ctx, "things", duration, scope) + invalidErrors = append(invalidErrors, err) + } + invalidMintCount = len(mint.observedStartTasks()) + // The same installed viewer gateway must actually mint a valid ask. + _, validError = gw.ForModuleWithLifetime(ctx, "things", 601*time.Second, scope) + return "ok", nil + }), mint) + request, err := http.NewRequest(http.MethodGet, solution.base+"/lifetime", nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("authorization", viewerBearer()) + request.Header.Set(orgHeader, "org-1") + request.Header.Set(sessionHeader, "session-1") + request.Header.Set(workcontext.InstallationIDHeaderName, testInstallation) + resp, err := solution.client.Do(request) + if err != nil { + t.Fatal(err) + } + drainAndClose(resp) + for index, err := range invalidErrors { + if err == nil || !strings.Contains(err.Error(), "nonnegative whole seconds fitting int32") { + t.Errorf("invalid duration %d did not reach lifetime guard: %v", index, err) + } + } + if len(invalidErrors) != 3 || invalidMintCount != 0 || validError != nil || len(mint.observedStartTasks()) != 1 { + t.Fatalf("same viewer fixture: invalid outcomes=%d invalid issuer calls=%d valid error=%v total issuer calls=%d, want3 guarded errors,0 invalid mints and only1 valid mint", len(invalidErrors), invalidMintCount, validError, len(mint.observedStartTasks())) + } +} + +func TestForModuleLifetimeDoesNotReuseAuthorityBelowRequestedLife(t *testing.T) { + mints := 0 + issuer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var ask startTaskRequest + if err := json.NewDecoder(r.Body).Decode(&ask); err != nil { + t.Error(err) + return + } + if ask.TTLSeconds != 601 { + t.Errorf("requested lifetime %d, want 601", ask.TTLSeconds) + } + mints++ + writeJSON(w, 200, map[string]any{"token": capability("bounded-life"), "expiresAt": time.Now().Add(601 * time.Second), "orgId": ask.OrgID, "ownerPrincipalId": "viewer", "currentActorPrincipalId": "viewer"}) + })) + defer issuer.Close() + srv := serveHandler(t, issuer.URL, func(ctx context.Context, g *Gateway) (any, error) { + scope := Scope{ResourceKind: "runtime.tasks", Actions: []string{"create"}} + if _, err := g.ForModuleWithLifetime(ctx, "runtime", 601*time.Second, scope); err != nil { + return nil, err + } + // The issuer's 601-second answer is now shorter than a 600-second + // operation. This elapsed ruler exercises the real request cache. + time.Sleep(2 * time.Second) + if _, err := g.ForModuleWithLifetime(ctx, "runtime", 601*time.Second, scope); err != nil { + return nil, err + } + return "ok", nil + }) + resp := viewerRequest(t, srv.URL) + defer drainAndClose(resp) + if resp.StatusCode != http.StatusOK || mints != 2 { + t.Fatalf("elapsed explicit lifetime: status=%d issuer mints=%d, want 200 and 2", resp.StatusCode, mints) + } +}