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
130 changes: 130 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,136 @@ 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.

### Calling a composed module over its REST binding

The gateway reaches a composed module only under the REST prefix the solution
federates for it (`/v1/<as>/*`), so the module's generated gRPC or Connect
client cannot be pointed at it: its procedure paths are not under that prefix.
What the module does publish is each RPC's `google.api.http` binding, in its
generated descriptor. `Gateway.Transcoded` speaks that binding with the
module's own generated messages:

```go
docs, err := gw.ForModule(ctx, "documents",
solution.Scope{ResourceKind: "documents", Actions: []string{"read"}})
if err != nil {
return nil, err
}
var page documentsv1.ListCollectionResponse
err = docs.Transcoded(ctx, "/v1/documents", documentsv1.DocumentsService_ListCollection_FullMethodName,
&documentsv1.ListCollectionRequest{PageSize: 500}, &page)
```

The call uses the method's first binding — the primary rule, then its
additional bindings in order — whose path lies under the prefix. The URL, the
HTTP method, and which request fields go into the path, the query or the body
all come from the descriptor. The caller can name no path of its own, and a
method with no binding under the prefix is refused before anything is sent, as
is a multi-segment path variable, an empty or dot-segment path value, and a
request or response of the wrong type. The response is decoded with unknown
fields discarded and is bounded (`MaxResponseBytes`, 8 MiB by default). A
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.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
accounts' own resolution instead of trusting it. It reuses the cached
capability rather than minting again.

### Letting the page call a consumed module: the passthrough

The host proxy forwards a solution's page only to the solution's own backend,
so a page cannot reach a composed module's `/v1/<as>/*` prefix, and a module
that authenticates by Work Context would refuse its bearer anyway. Instead of a
handler per module operation, a solution declares which operations its page
may call:

```go
solution.New(manifest).Consumes(solution.ConsumedModule{
As: "documents", // the `as` of its api.consumes entry
Scopes: []solution.Scope{{ResourceKind: "documents", Actions: []string{"read"}}},
Methods: []solution.ConsumedMethod{{
Name: documentsv1.DocumentsService_GetDocument_FullMethodName,
Response: solution.MustFieldMask(&documentsv1.GetDocumentResponse{}, "document.entry"),
}},
})
```

Each declared method is served as a Connect unary procedure at
`/modules/<as>/<package.Service>/<Method>`, so the page uses the module's own
generated client (connect-es) unmodified, with the base URL
`<apiBase>/modules/<as>`. A call is answered by the module as the viewer: the
runtime mints the viewer's Work Context with the declared scopes (`ForModule`),
or, with `ViewerBearer`, forwards only the bearer to a module that
authenticates the viewer itself, and forwards the request over the method's
binding (`Transcoded`). The page names a procedure and a request message,
never a path, a prefix, an audience or an authority, and authorization stays at
the module.

Authority is minted per method. A method may declare its own `Scopes`, which
replace the module's for a call to that method — never add to them — so one
module's methods that need different authority are declared under one `as`:

```go
solution.ConsumedModule{
As: "tasks",
Methods: []solution.ConsumedMethod{
{Name: ownListMethod, Scopes: []solution.Scope{{ResourceKind: "tasks", Actions: []string{"list"}}}, WholeResponse: true},
{Name: orgListMethod, Scopes: []solution.Scope{{ResourceKind: "tasks", Actions: []string{"inspect"}}}, WholeResponse: true},
},
}
```

Each call mints only its own method's authority. A viewer who lacks one
method's authority is refused that method as `permission_denied`, with the
issuer's reason (which names the missing permission), and is still served
every other method; the mint is never widened or retried with less. Every
method of a module that is not `ViewerBearer` needs scopes, its own or the
module's, and `ViewerBearer` takes none at either level.

Least privilege by default. A method that is not declared is `not_found`. At
boot, `Serve` refuses a module whose `as` is not in the solution's api.consumes,
a method missing from the registry or with no binding under `/v1/<as>`, a
streaming method, a method with no declared response fields, a method with no
scopes of its own or its module's (unless the module is `ViewerBearer`), scopes
on a `ViewerBearer` module or method, and a scope with no resource kind, no
action, or a blank or repeated action. `Response`
names the fields returned to the page; `WholeResponse` must be said explicitly.
`Pin` fixes part of every request server-side (merged with `proto.Merge`): a
pinned scalar replaces the page's value and a pinned repeated value is added to
the page's, so a filter clause the module ANDs stays in force whatever the page
sends.
A module's refusal reaches the page in the Connect error: a `google.rpc.Status`
body keeps its code and message, any other small JSON refusal is the message
verbatim under the code its HTTP status maps to, and anything else is reported
by kind only. `PassthroughOperations` renders the declaration for the interface
artifact, and `InterfaceArtifact(dir, info, ops, modules...)` renders a
backend's whole artifact — its own operations and the passthrough — at the
version its `service.codefly.yaml` declares.

### Generated messages in a response

A handler's value is encoded with `encoding/json`, which reads a generated
message's Go struct tags rather than producing protobuf JSON. Wrap an owner's
message in `solution.MessageOf(m)` (`solution.Message[T]`) wherever it sits in
the response, and it encodes as protobuf JSON. To pass on only part of an
owner's message, declare a `FieldMask` once from the paths allowed and
`solution.Apply` it. A field the owner adds later reaches nobody until it is
named, and a path naming no field fails at declaration.

### Documented operations

`Server.Operations` registers a list of `solution.Operation`s — each a route,
its handler, and the documentation a reader needs (summary, callers, behaviour,
query parameters, request and response types). `solution.InterfaceDocument`
renders the same list as a Swagger 2.0 artifact, so one declaration is the
source of what is served and what is published, and an operation missing its
documentation is refused. A `Message[T]` field is described from its
descriptor, with protobuf JSON names.

> **Note:** SDK in-process endpoint resolution for a solution composed on an
> out-of-repo host depends on codefly-core accepting the composed module path in
> its workspace loader (codefly-dev/core#365, merged); the `core`/`sdk-go` pins
Expand Down
12 changes: 10 additions & 2 deletions gateway_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,20 @@ import (

// GatewayError carries a non-success HTTP status received through the host
// gateway. REST adapters should return this rather than flattening the status
// into a string. It never carries response bodies, credentials or headers.
// Handlers may wrap it; only a generic status message reaches the browser.
// into a string. It exposes no response body, credential or header. Handlers
// may wrap it; only a generic status message reaches the browser.
type GatewayError struct {
StatusCode int
// detail is the head of a module's refusal body (Transcoded keeps at most
// gatewayErrorDetailLimit bytes). Only the consumed-module passthrough reads
// it, to relay the module's own refusal to the page that made the call; a
// handler's error never exposes it.
detail []byte
}

// gatewayErrorDetailLimit bounds the refusal body a GatewayError keeps.
const gatewayErrorDetailLimit = 4 << 10

func (e *GatewayError) Error() string {
return fmt.Sprintf("gateway returned HTTP %d", e.StatusCode)
}
Expand Down
4 changes: 2 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ require (
github.com/codefly-dev/core v0.3.41
github.com/codefly-dev/sdk-go v0.1.65
github.com/google/uuid v1.6.0
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688
google.golang.org/protobuf v1.36.12
gopkg.in/yaml.v3 v3.0.1
)

require (
Expand Down Expand Up @@ -47,8 +49,6 @@ require (
golang.org/x/sys v0.48.0 // indirect
golang.org/x/term v0.46.0 // indirect
golang.org/x/text v0.42.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect
google.golang.org/grpc v1.83.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
Loading
Loading