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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
69 changes: 58 additions & 11 deletions solution.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
})
}
Expand All @@ -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) {
Expand Down
25 changes: 14 additions & 11 deletions solution_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading