diff --git a/README.md b/README.md index 942efdd..8162e74 100644 --- a/README.md +++ b/README.md @@ -359,6 +359,39 @@ is not refused: the lead is clamped to half its lifetime, with a one-time log, because how long a credential lives is the issuer's call and not this runtime's to veto. +#### Naming the Task: `ForTask` + +`ForModule` names a fresh Task on every mint, so no two capabilities — and no +two viewer requests — share one. A module that fences what a caller may read by +the Task its capability was minted under (a durable task runtime does: it +lists and reads only the tasks admitted under the caller's own Task) then +refuses a later request's read of what an earlier request started. When the +unit of work outlives a request, name its Task: + +```go +docs, err := gw.ForTask(ctx, solution.Task{ID: askID}, "documents", + solution.Scope{ResourceKind: "documents", Actions: []string{"read"}}) +``` + +`Task.ID` is sent to accounts' `StartTask` verbatim as `taskId`, and accounts +signs it into the capability as given, so every mint for that id — in this +request or any later one — carries the same Task boundary. It must be a +hyphenated UUID, the only form accounts admits; anything else (including an +empty id, or the brace, URN and unhyphenated forms a general UUID parser +accepts) is refused here, before any request. A solution usually derives it +deterministically from what the work is over, e.g. a UUIDv5 of its content. +Naming a Task grants nothing: accounts still mints under the viewer's own +bearer, so a capability carries only the viewer's authority whatever id it +names. The org and session are the viewer's, exactly as for `ForModule`. + +`Task.TTLSeconds`, when non-zero, is sent as `ttlSeconds` — the lifetime asked +of the issuer, which bounds it and refuses a value outside those bounds. Zero +sends nothing and leaves the issuer's default. + +The cache identity of a `ForTask` ask includes the Task id and the lifetime, so +two Tasks never share a capability; within one request one Task's ask mints +once, exactly as a `ForModule` ask does. + This traffic is not proxied, for the same reason registration traffic is not: every gateway target is composition-local, and these requests carry the viewer's bearer and the capability minted for them in headers. diff --git a/solution.go b/solution.go index d74d1b5..2423b09 100644 --- a/solution.go +++ b/solution.go @@ -2067,11 +2067,59 @@ func workContextScopes(scopes []Scope) []workContextScope { // so a handler that returns it answers 409 (no organization selected) or 403 (no // session — an API key cannot read a composed module) instead of a generic 502. // +// Every mint names a fresh Task — a re-mint within one request as much as +// another request's mint — so no two capabilities share one. A module that +// fences what a caller may read by the Task it was minted under (a durable task +// runtime does) then refuses a later request's read of what an earlier one +// started; name the Task with ForTask instead. +// // The returned gateway holds the ask, not the capability — it resolves one per // request — so a handler may keep it for as long as it keeps the viewer's // 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.forAsk(ctx, startTaskRequest{Audience: audience, AuthorityScopes: workContextScopes(scopes)}) +} + +// Task names the host Task a Work Context is minted under, and asks how long +// the capability should live. +type Task struct { + // ID is sent to accounts verbatim as the Task id, which accounts signs into + // the capability as its boundary. It must be a UUID in the hyphenated form + // accounts validates; any other shape is refused before a request is made. + ID string + // TTLSeconds is the lifetime asked of the issuer. Zero leaves it to the + // issuer's default; the issuer bounds it and refuses a value outside them. + TTLSeconds int32 +} + +// taskIDShape is the form accounts' StartTask admits for a Task id +// (buf.validate string.uuid): hyphenated, any version, either case. +var taskIDShape = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`) + +// ForTask is ForModule under a Task the solution names. Every mint for the same +// task.ID — within one request or across many, now or later — carries the same +// Task boundary, so a module that fences reads by that boundary lets a later +// request read what an earlier one under the same Task started. Accounts signs +// the id as given and keeps no registry of it: naming the Task grants nothing, +// since each capability still carries only the viewer's own authority. +// +// The cache identity includes the Task and the lifetime asked for, so two +// Tasks never share a capability. The org and session are the viewer's, exactly +// as for ForModule. +func (g *Gateway) ForTask(ctx context.Context, task Task, audience string, scopes ...Scope) (*Gateway, error) { + if !taskIDShape.MatchString(task.ID) { + return nil, fmt.Errorf("cannot mint a work context for %q under task %q: a task id must be a hyphenated UUID, which is all accounts admits", audience, task.ID) + } + return g.forAsk(ctx, startTaskRequest{ + TaskID: task.ID, + TTLSeconds: task.TTLSeconds, + Audience: audience, + AuthorityScopes: workContextScopes(scopes), + }) +} + +func (g *Gateway) forAsk(ctx context.Context, ask startTaskRequest) (*Gateway, error) { if g.orgID == "" { // The gateway injects orgHeader from the caller's active org, so it is // present but empty for a viewer with no organization selected and for @@ -2084,7 +2132,7 @@ func (g *Gateway) ForModule(ctx context.Context, audience string, scopes ...Scop // indistinguishable from "this solution is down". return nil, fmt.Errorf( "cannot mint a work context for %q: %s is absent or empty — the gateway injects it from the caller's active org, which is empty for a viewer with no organization selected and for an org-less API key: %w", - audience, orgHeader, &ClientError{StatusCode: http.StatusConflict, Message: "no organization selected"}) + ask.Audience, orgHeader, &ClientError{StatusCode: http.StatusConflict, Message: "no organization selected"}) } if g.sessionID == "" { // Same shape as the org above: the gateway stamps this header for every @@ -2099,16 +2147,12 @@ func (g *Gateway) ForModule(ctx context.Context, audience string, scopes ...Scop // than a conflict. return nil, fmt.Errorf( "cannot mint a work context for %q: %s is absent or empty — the gateway injects it from the verified session and stamps it empty for any caller that authenticates without one: %w", - audience, sessionHeader, &ClientError{StatusCode: http.StatusForbidden, Message: "a user session is required to read composed modules"}) + ask.Audience, sessionHeader, &ClientError{StatusCode: http.StatusForbidden, Message: "a user session is required to read composed modules"}) } - ask := startTaskRequest{ - OrgID: g.orgID, - SessionID: g.sessionID, - Audience: audience, - AuthorityScopes: workContextScopes(scopes), - } - // 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. + ask.OrgID, ask.SessionID = g.orgID, g.sessionID + // The ask is the cache identity, a named Task included. A task id that was + // not named identifies one mint rather than what was asked for, so it is + // filled in per mint, below. key, err := json.Marshal(ask) if err != nil { return nil, err @@ -2137,7 +2181,9 @@ type delegation struct { func (g *Gateway) workContext(ctx context.Context) (codefly.WorkContextToken, error) { return g.contexts.resolve(ctx, g.delegation.key, func(ctx context.Context) (codefly.WorkContextToken, time.Time, error) { ask := g.delegation.ask - ask.TaskID = uuid.NewString() + if ask.TaskID == "" { + ask.TaskID = uuid.NewString() + } return g.mint(ctx, ask) }) } @@ -2152,6 +2198,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) (codefly.WorkContextToken, time.Time, error) { diff --git a/solution_test.go b/solution_test.go index 2042079..ae43ee7 100644 --- a/solution_test.go +++ b/solution_test.go @@ -1119,15 +1119,15 @@ func TestServeSkipsConsumedAPIWithoutSecret(t *testing.T) { // registration — it just retries on its own heartbeat. func TestServeIsolatesFailedModuleRegistration(t *testing.T) { setEndpoint(t, "CODEFLY__ENDPOINT__DOCSTORE__DOCUMENTS__REST__REST", "http://docstore-upstream:9100") - setEndpoint(t, "CODEFLY__ENDPOINT__BILLING__INVOICES__REST__REST", "http://billing-upstream:9200") + setEndpoint(t, "CODEFLY__ENDPOINT__ALPHA__ITEMS__REST__REST", "http://alpha-upstream:9200") t.Setenv("CODEFLY__API_CONSUMES", `[`+ - `{"id":"billing.invoices","module":"billing","service":"invoices","endpoint":"rest","protocol":"rest","as":"billing"},`+ + `{"id":"alpha.items","module":"alpha","service":"items","endpoint":"rest","protocol":"rest","as":"alpha"},`+ `{"id":"docstore.documents","module":"docstore","service":"documents","endpoint":"rest","protocol":"rest","as":"documents"}]`) - t.Setenv(ModuleRegistrationSecretsEnvironmentVariable, "documents:s3cret,billing:stale") + t.Setenv(ModuleRegistrationSecretsEnvironmentVariable, "documents:s3cret,alpha:stale") gw := newFakeGateway(t, &fakeGateway{declared: map[string]string{ "documents": "s3cret", - "billing": "rotated", + "alpha": "rotated", }}) ln, err := net.Listen("tcp", "127.0.0.1:0") @@ -1739,8 +1739,8 @@ func TestParseModuleRegistrationSecrets(t *testing.T) { {name: "one", raw: "documents:s3cret", want: map[string]string{"documents": "s3cret"}}, { name: "several, spaced", - raw: "documents:s3cret, billing:other", - want: map[string]string{"documents": "s3cret", "billing": "other"}, + raw: "documents:s3cret, alpha:other", + want: map[string]string{"documents": "s3cret", "alpha": "other"}, }, // A secret is opaque and base64 may contain "=" and "+"; only the first // ":" separates it from the prefix. @@ -1771,8 +1771,11 @@ type mintRequest struct { SessionID string `json:"sessionId"` Audience string `json:"audience"` AuthorityScopes []workContextScope `json:"authorityScopes"` - Bearer string `json:"-"` - WorkContext string `json:"-"` + // TTLSeconds is nil when the ask carried no ttlSeconds at all, which is + // how "the issuer's default" must reach accounts — not as an explicit 0. + TTLSeconds *int32 `json:"ttlSeconds"` + Bearer string `json:"-"` + WorkContext string `json:"-"` } // moduleCall is what a composed module behind the gateway received: the two @@ -2009,7 +2012,7 @@ func TestForModuleMintsOncePerAsk(t *testing.T) { return nil, err } } - if _, err := g.ForModule(ctx, "billing", Scope{ResourceKind: "invoices", Actions: []string{"read"}}); err != nil { + if _, err := g.ForModule(ctx, "alpha", Scope{ResourceKind: "items", Actions: []string{"read"}}); err != nil { return nil, err } return "done", nil @@ -2212,7 +2215,7 @@ func TestMintCarriesNoOtherModulesCapability(t *testing.T) { } // Chaining off a derived gateway is the natural reach: ForModule is a // method on every Gateway, including one already acting for a module. - if _, err := docs.ForModule(ctx, "billing", Scope{ResourceKind: "invoices", Actions: []string{"read"}}); err != nil { + if _, err := docs.ForModule(ctx, "alpha", Scope{ResourceKind: "items", Actions: []string{"read"}}); err != nil { return nil, err } return "done", nil @@ -2340,7 +2343,7 @@ func TestMintRootsTheTaskInTheViewersVerifiedSession(t *testing.T) { if _, err := readModule(ctx, g, "documents"); err != nil { return nil, err } - if _, err := g.ForModule(ctx, "billing", Scope{ResourceKind: "invoices", Actions: []string{"read"}}); err != nil { + if _, err := g.ForModule(ctx, "alpha", Scope{ResourceKind: "items", Actions: []string{"read"}}); err != nil { return nil, err } return "done", nil diff --git a/work_context_task_test.go b/work_context_task_test.go new file mode 100644 index 0000000..0b78446 --- /dev/null +++ b/work_context_task_test.go @@ -0,0 +1,248 @@ +package solution + +import ( + "context" + "net/http" + "strings" + "testing" +) + +// Two Task ids in the shape accounts admits. The first is mixed-case on purpose: +// accounts admits either case and signs the id as given, so a runtime that +// canonicalised it would name a different boundary than the solution asked for. +const ( + askTask = "A13EC6C4-e43a-5694-911e-63db3dbbf283" + otherTask = "5b0e3c1d-7f2a-4c8e-9d61-0a4b2c3d4e5f" +) + +var readDocuments = Scope{ResourceKind: "documents", Actions: []string{"read"}} + +// receivedMints returns every StartTask the gateway has received so far. The +// fake records a mint before answering it, so once the handler has returned +// every mint it made is here. +func receivedMints(gw *workContextGateway) []mintRequest { + var mints []mintRequest + for { + select { + case mint := <-gw.mints: + mints = append(mints, mint) + default: + return mints + } + } +} + +// runViewerRequest serves one viewer request through handler and fails the test +// unless the solution answered 200. +func runViewerRequest(t *testing.T, gw *workContextGateway, handler Handler) { + t.Helper() + solution := serveHandler(t, gw.URL, handler) + resp := viewerRequest(t, solution.URL) + defer drainAndClose(resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("solution answered %d, want 200", resp.StatusCode) + } +} + +// TestForTaskSendsTheNamedTaskVerbatim is the point of ForTask: every mint of +// one Task, in this request and in a later one, names that Task exactly as the +// solution spelled it, while the session stays the viewer's own. +func TestForTaskSendsTheNamedTaskVerbatim(t *testing.T) { + gw := newWorkContextGateway(t, &workContextGateway{}) + read := func(ctx context.Context, g *Gateway) (any, error) { + docs, err := g.ForTask(ctx, Task{ID: askTask}, "documents", readDocuments) + if err != nil { + return nil, err + } + return getThrough(ctx, docs) + } + // Two viewer requests: each gets its own cache, so each mints. + runViewerRequest(t, gw, read) + runViewerRequest(t, gw, read) + + mints := receivedMints(gw) + if len(mints) != 2 { + t.Fatalf("received %d mints across two requests, want 2", len(mints)) + } + for i, mint := range mints { + if mint.TaskID != askTask { + t.Errorf("mint %d task = %q, want %q verbatim", i, mint.TaskID, askTask) + } + if mint.SessionID != viewerSession { + t.Errorf("mint %d session = %q, want the viewer's %q", i, mint.SessionID, viewerSession) + } + } + for range 2 { + if call := <-gw.calls; call.WorkContext == "" { + t.Error("module read carried no work context") + } + } +} + +// TestForTaskNeverSharesAContextBetweenTasks keys the cache by the Task. Two +// Tasks asking the identical audience and scopes are two capabilities, and each +// derived gateway presents its own; shared, a read meant for one Task would be +// admitted under the other's boundary. +func TestForTaskNeverSharesAContextBetweenTasks(t *testing.T) { + gw := newWorkContextGateway(t, &workContextGateway{}) + runViewerRequest(t, gw, func(ctx context.Context, g *Gateway) (any, error) { + first, err := g.ForTask(ctx, Task{ID: askTask}, "documents", readDocuments) + if err != nil { + return nil, err + } + second, err := g.ForTask(ctx, Task{ID: otherTask}, "documents", readDocuments) + if err != nil { + return nil, err + } + if _, err := getThrough(ctx, first); err != nil { + return nil, err + } + return getThrough(ctx, second) + }) + + mints := receivedMints(gw) + if len(mints) != 2 { + t.Fatalf("received %d mints for two Tasks, want 2 — the cache handed one Task's capability to the other", len(mints)) + } + if mints[0].TaskID != askTask || mints[1].TaskID != otherTask { + t.Errorf("mints named tasks %q then %q, want %q then %q", mints[0].TaskID, mints[1].TaskID, askTask, otherTask) + } + first, second := <-gw.calls, <-gw.calls + if first.WorkContext != "context-documents.1" || second.WorkContext != "context-documents.2" { + t.Errorf("reads carried %q then %q, want each Task's own capability (context-documents.1, context-documents.2)", first.WorkContext, second.WorkContext) + } +} + +// TestForTaskReusesTheContextOfTheSameTask keeps the audited-mint economy of +// ForModule: within one request, one Task's ask mints once however many times it +// is derived and read through. +func TestForTaskReusesTheContextOfTheSameTask(t *testing.T) { + gw := newWorkContextGateway(t, &workContextGateway{}) + runViewerRequest(t, gw, func(ctx context.Context, g *Gateway) (any, error) { + for range 3 { + docs, err := g.ForTask(ctx, Task{ID: askTask}, "documents", readDocuments) + if err != nil { + return nil, err + } + if _, err := getThrough(ctx, docs); err != nil { + return nil, err + } + } + return "done", nil + }) + + if got := gw.mintCount(); got != 1 { + t.Errorf("minted %d capabilities for one Task's ask derived 3 times, want 1", got) + } + for range 3 { + if call := <-gw.calls; call.WorkContext != "context-documents.1" { + t.Errorf("read carried %q, want the one capability minted for the Task", call.WorkContext) + } + } +} + +// TestForTaskRefusesATaskIDAccountsWouldNot refuses, before any request, every +// id outside the hyphenated UUID form accounts validates. The looser forms are +// ones a general UUID parser accepts, so a check built on one would send them +// on to be refused there. An empty id is refused rather than read as "draw +// one": a solution that meant to name its Task and named nothing must not get a +// fresh Task per mint, which is exactly the failure ForTask exists to end. +func TestForTaskRefusesATaskIDAccountsWouldNot(t *testing.T) { + for _, id := range []string{ + "", + "ask-42", + "a13ec6c4e43a5694911e63db3dbbf283", + "{a13ec6c4-e43a-5694-911e-63db3dbbf283}", + "urn:uuid:a13ec6c4-e43a-5694-911e-63db3dbbf283", + " a13ec6c4-e43a-5694-911e-63db3dbbf283", + "a13ec6c4-e43a-5694-911e-63db3dbbf28g", + } { + t.Run(id, func(t *testing.T) { + gw := newWorkContextGateway(t, &workContextGateway{}) + var mintErr error + runViewerRequest(t, gw, func(ctx context.Context, g *Gateway) (any, error) { + _, mintErr = g.ForTask(ctx, Task{ID: id}, "documents", readDocuments) + return "done", nil + }) + if mintErr == nil || !strings.Contains(mintErr.Error(), "hyphenated UUID") { + t.Errorf("ForTask(%q) error = %v, want a refusal naming the UUID form", id, mintErr) + } + if mints := receivedMints(gw); len(mints) != 0 { + t.Errorf("ForTask(%q) sent %d StartTask requests, want 0 — the id must be refused here", id, len(mints)) + } + }) + } +} + +// TestForTaskSendsTheTTLWhenGiven passes the lifetime through to accounts, which +// already accepts it, and sends nothing for zero so the issuer's default holds. +// ForModule, which asks no lifetime, sends none. Each ask runs in its own +// request so each mints regardless of how the cache keys them. +func TestForTaskSendsTheTTLWhenGiven(t *testing.T) { + for _, tc := range []struct { + name string + derive func(context.Context, *Gateway) (*Gateway, error) + want *int32 + }{ + {"ForTask with a lifetime", func(ctx context.Context, g *Gateway) (*Gateway, error) { + return g.ForTask(ctx, Task{ID: askTask, TTLSeconds: 120}, "documents", readDocuments) + }, ptr(int32(120))}, + {"ForTask without one", func(ctx context.Context, g *Gateway) (*Gateway, error) { + return g.ForTask(ctx, Task{ID: askTask}, "documents", readDocuments) + }, nil}, + {"ForModule", func(ctx context.Context, g *Gateway) (*Gateway, error) { + return g.ForModule(ctx, "documents", readDocuments) + }, nil}, + } { + t.Run(tc.name, func(t *testing.T) { + gw := newWorkContextGateway(t, &workContextGateway{}) + runViewerRequest(t, gw, func(ctx context.Context, g *Gateway) (any, error) { + return tc.derive(ctx, g) + }) + mints := receivedMints(gw) + if len(mints) != 1 { + t.Fatalf("received %d mints, want 1", len(mints)) + } + if got := mints[0].TTLSeconds; ttlOf(got) != ttlOf(tc.want) { + t.Errorf("mint sent ttlSeconds %v, want %v", ttlOf(got), ttlOf(tc.want)) + } + }) + } +} + +func ptr[T any](v T) *T { return &v } + +func ttlOf(ttl *int32) any { + if ttl == nil { + return "absent" + } + return *ttl +} + +// TestForModuleStillNamesAFreshTaskPerMint pins that ForModule's behaviour is +// unchanged by ForTask: a re-mint within one request names a new Task, never +// the previous mint's. +func TestForModuleStillNamesAFreshTaskPerMint(t *testing.T) { + gw := newWorkContextGateway(t, &workContextGateway{}) + runViewerRequest(t, gw, func(ctx context.Context, g *Gateway) (any, error) { + docs, err := g.ForModule(ctx, "documents", readDocuments) + if err != nil { + return nil, err + } + lapseCachedCapabilities(g) + return getThrough(ctx, docs) + }) + + mints := receivedMints(gw) + if len(mints) != 2 { + t.Fatalf("received %d mints, want 2 (the first and the re-mint after it lapsed)", len(mints)) + } + for i, mint := range mints { + if !taskIDShape.MatchString(mint.TaskID) { + t.Errorf("mint %d task = %q, want a drawn UUID", i, mint.TaskID) + } + } + if mints[0].TaskID == mints[1].TaskID { + t.Errorf("re-mint reused task %q, want a fresh one per mint", mints[0].TaskID) + } +}