Skip to content
Closed
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
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions boot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
18 changes: 16 additions & 2 deletions contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/codefly-dev/core/solution/manifest"
"github.com/codefly-dev/sdk-go/workcontext"
Expand Down Expand Up @@ -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.
//
Expand 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
Expand Down Expand Up @@ -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)

Expand Down
68 changes: 68 additions & 0 deletions listener_allocator_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
22 changes: 22 additions & 0 deletions solution.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}
Expand Down
184 changes: 184 additions & 0 deletions task_lifetime_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}