From 9b7c4c5941b8eb6c5b112e0f612d09943ca95988 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:40:52 -0400 Subject: [PATCH 1/7] feat: call composed modules over their REST binding; documented operations A solution reaches a composed module only under the REST prefix it federates (/v1//*), so the module's generated gRPC/Connect client cannot be pointed at the gateway, and solutions were hand-building paths, query strings and wire structs for every module read. Gateway.Transcoded calls an RPC with the module's own generated messages over the method's google.api.http binding under the consumed prefix. Everything about the request comes from the descriptor: the caller names no path, and a method with no binding under the prefix is refused before anything is sent. Alongside it, what a handler needs to hand an owner's message on: Message[T] encodes a generated message as protobuf JSON inside a response, and FieldMask/Apply pass on only a declared allowlist of its fields, so an owner field added later reaches no browser until someone names it. Operation, Server.Operations and InterfaceDocument move the documented-route declaration and its Swagger 2.0 artifact into the runtime, so one list is the source of what a solution serves and what it publishes. A Message[T] field is described from its descriptor. Gateway.OrgID exposes the active organization ForModule mints in, so a handler scopes a module read to the same tenant its capability names. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 53 ++++++ go.mod | 2 +- operations.go | 401 +++++++++++++++++++++++++++++++++++++++++++++ operations_test.go | 112 +++++++++++++ protowire.go | 150 +++++++++++++++++ solution.go | 7 + transcode.go | 378 ++++++++++++++++++++++++++++++++++++++++++ transcode_test.go | 311 +++++++++++++++++++++++++++++++++++ 8 files changed, 1413 insertions(+), 1 deletion(-) create mode 100644 operations.go create mode 100644 operations_test.go create mode 100644 protowire.go create mode 100644 transcode.go create mode 100644 transcode_test.go diff --git a/README.md b/README.md index 942efdd..187a3c1 100644 --- a/README.md +++ b/README.md @@ -363,6 +363,59 @@ 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//*`), 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. + +### 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 diff --git a/go.mod b/go.mod index 223cd21..78c871c 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ 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 ) @@ -47,7 +48,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 diff --git a/operations.go b/operations.go new file mode 100644 index 0000000..dca9db4 --- /dev/null +++ b/operations.go @@ -0,0 +1,401 @@ +package solution + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "unicode" + + "google.golang.org/protobuf/reflect/protoreflect" +) + +// --- Documented operations --- +// +// A solution's backend surface is its routes. Declaring each route as an +// Operation — the path, the handler, and the documentation a reader needs — +// makes one list the source of both what is served and what is published: +// Operations registers exactly these handlers, and InterfaceDocument renders +// exactly these into the interface artifact a solution commits and CI +// re-generates. A route cannot be served undocumented, and an operation cannot +// be documented without being served. + +// Operation is one public route of a solution backend. +// +// Summary, Callers and Behavior are all required: InterfaceDocument refuses an +// operation missing any of them, which keeps an undocumented route out of the +// artifact. Errors are not declared per operation, because the runtime gives +// every operation the same failures. +type Operation struct { + // Path is the gateway-relative route, e.g. "/sources". + Path string + // Method is the documented HTTP method, lowercase; empty means "get". + Method string + // Summary is the one-sentence functional statement of what the operation + // does, written for a reader who will never see the code: no leading + // article, no trailing period. + Summary string + // Callers states who may call the operation. + Callers string + // Behavior states what the caller gets back, and the pagination or limits + // that shape it. + Behavior string + // Query lists the documented query parameters. + Query []QueryParameter + // Request is the zero value of the JSON body the operation reads, if any. + Request any + // Response is the zero value of the body the operation returns on 200; + // the artifact's schema is derived from its type. A generated protobuf + // message is carried as Message[T] and described from its descriptor. + Response any + // Exactly one of Handler and RequestHandler answers the route. + Handler Handler + RequestHandler RequestHandler +} + +// QueryParameter is one documented query parameter of an Operation. +type QueryParameter struct { + Name string + Required bool +} + +// Operations registers each operation's handler at its path. +func (s *Server) Operations(ops ...Operation) *Server { + for _, op := range ops { + if op.RequestHandler != nil { + s.HandleRequest(op.Path, op.RequestHandler) + } else { + s.Handle(op.Path, op.Handler) + } + } + return s +} + +// InterfaceInfo is the document-level description of a solution's interface +// artifact. +type InterfaceInfo struct { + // Title and Description head the document; Version is the service version + // the artifact describes. + Title string + Version string + Description string + // BasePath is where the host gateway publishes the routes, e.g. + // "/solutions/": the backend serves them at its own root, but a caller + // only ever reaches them prefixed. + BasePath string + // OperationPrefix starts every operationId ("Wiki" renders "/sources" as + // "Wiki_Sources"); Tag is the one tag every operation carries. + OperationPrefix string + Tag string +} + +// InterfaceDocument renders ops as a Swagger 2.0 document — the dialect the +// platform's proto-generated modules emit, so one reader handles every +// repository's artifact — indented, with a trailing newline. +func InterfaceDocument(info InterfaceInfo, ops []Operation) ([]byte, error) { + doc, err := swaggerDocument(info, ops) + if err != nil { + return nil, err + } + out, err := json.MarshalIndent(doc, "", " ") + if err != nil { + return nil, err + } + return append(out, '\n'), nil +} + +func swaggerDocument(info InterfaceInfo, ops []Operation) (map[string]any, error) { + definitions := map[string]any{ + "Error": map[string]any{ + "type": "object", + "properties": map[string]any{"error": map[string]any{"type": "string"}}, + }, + } + paths := map[string]any{} + for _, op := range ops { + if err := op.validate(); err != nil { + return nil, err + } + method := op.Method + if method == "" { + method = "get" + } + endpoint := map[string]any{ + "operationId": operationID(info.OperationPrefix, op.Path), + "summary": op.Summary, + "description": op.Callers + "\n\n" + op.Behavior, + "tags": []string{info.Tag}, + "responses": operationResponses(op, definitions), + } + parameters := []any{} + if op.Request != nil { + parameters = append(parameters, map[string]any{"name": "body", "in": "body", "required": true, "schema": schemaFor(reflect.TypeOf(op.Request), definitions)}) + } + for _, query := range op.Query { + parameters = append(parameters, map[string]any{"name": query.Name, "in": "query", "required": query.Required, "type": "string"}) + } + if len(parameters) > 0 { + endpoint["parameters"] = parameters + } + paths[op.Path] = map[string]any{method: endpoint} + } + return map[string]any{ + "swagger": "2.0", + "info": map[string]any{ + "title": info.Title, + "version": info.Version, + "description": info.Description, + }, + "basePath": info.BasePath, + "consumes": []string{"application/json"}, + "produces": []string{"application/json"}, + "securityDefinitions": map[string]any{ + "bearer": map[string]any{ + "type": "apiKey", + "name": "Authorization", + "in": "header", + "description": "The signed-in person's access token, forwarded by the host. Every operation requires it.", + }, + }, + "security": []any{map[string]any{"bearer": []string{}}}, + "paths": paths, + "definitions": definitions, + }, nil +} + +// operationResponses describes what one operation answers. The 200 schema is +// derived from the operation's response type; the failures are the runtime's, +// not the handler's: it rejects a request with no bearer, and turns an error a +// handler returns into a failure status. +func operationResponses(op Operation, definitions map[string]any) map[string]any { + return map[string]any{ + "200": map[string]any{ + "description": "A successful response.", + "schema": schemaFor(reflect.TypeOf(op.Response), definitions), + }, + "401": map[string]any{ + "description": "The request carried no bearer token.", + "schema": map[string]any{"$ref": "#/definitions/Error"}, + }, + "502": map[string]any{ + "description": "A call this operation makes through the host gateway failed.", + "schema": map[string]any{"$ref": "#/definitions/Error"}, + }, + } +} + +// validate reports an operation that cannot be rendered because it is +// undocumented or unserved. +func (o Operation) validate() error { + switch { + case !strings.HasPrefix(o.Path, "/"): + return fmt.Errorf("operation path %q must be gateway-relative", o.Path) + case o.Summary == "": + return fmt.Errorf("operation %s has no summary", o.Path) + case o.Callers == "": + return fmt.Errorf("operation %s does not say who may call it", o.Path) + case o.Behavior == "": + return fmt.Errorf("operation %s does not say what it returns, or its pagination and limits", o.Path) + case o.Response == nil: + return fmt.Errorf("operation %s declares no response body", o.Path) + case o.Handler == nil && o.RequestHandler == nil: + return fmt.Errorf("operation %s has no handler", o.Path) + case o.Handler != nil && o.RequestHandler != nil: + return fmt.Errorf("operation %s declares two handlers", o.Path) + } + return nil +} + +// operationID renders "/sources" as "_Sources"; a nested path +// contributes one segment per level rather than carrying a slash into an +// identifier. +func operationID(prefix, path string) string { + id := prefix + for _, segment := range strings.Split(strings.Trim(path, "/"), "/") { + id += "_" + title(segment) + } + return id +} + +// messageDescribed is a response value carrying a generated message +// (Message[T]); its schema comes from the descriptor, not the Go struct. +type messageDescribed interface { + messageDescriptor() protoreflect.MessageDescriptor +} + +var ( + messageDescribedType = reflect.TypeOf((*messageDescribed)(nil)).Elem() + rawMessageType = reflect.TypeOf(json.RawMessage{}) +) + +// schemaFor derives a JSON schema from a response type, registering a +// definition for every struct it reaches so a self-referencing shape +// terminates in a $ref instead of recursing forever. +func schemaFor(t reflect.Type, definitions map[string]any) map[string]any { + if t == rawMessageType { + return map[string]any{"type": "object"} + } + if t.Implements(messageDescribedType) { + return messageSchema(reflect.Zero(t).Interface().(messageDescribed).messageDescriptor(), definitions) + } + switch t.Kind() { + case reflect.Pointer: + return schemaFor(t.Elem(), definitions) + case reflect.Struct: + name := title(t.Name()) + // An unnamed struct has no definition to be referenced by: keyed on "" + // it would register a definition nothing can address and emit a $ref + // that resolves to nothing. Naming the type is the fix. + if name == "" { + panic(fmt.Sprintf("no definition name for the anonymous struct %s; give the field a named type", t)) + } + if _, seen := definitions[name]; !seen { + definitions[name] = map[string]any{} + definitions[name] = structSchema(t, definitions) + } + return map[string]any{"$ref": "#/definitions/" + name} + case reflect.Slice, reflect.Array: + return map[string]any{"type": "array", "items": schemaFor(t.Elem(), definitions)} + case reflect.Map: + return map[string]any{"type": "object", "additionalProperties": schemaFor(t.Elem(), definitions)} + case reflect.Interface: + // The empty interface is a value of any JSON shape, which an empty + // schema describes exactly. An interface with methods describes a Go + // contract, not a JSON one. + if t.NumMethod() > 0 { + panic(fmt.Sprintf("no schema for the interface %s; a response field must be a JSON shape", t)) + } + return map[string]any{} + case reflect.String: + return map[string]any{"type": "string"} + case reflect.Bool: + return map[string]any{"type": "boolean"} + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return map[string]any{"type": "integer"} + case reflect.Float32, reflect.Float64: + return map[string]any{"type": "number"} + } + // Generation-time only: a response field of a kind with no JSON schema is + // a mistake to fix, not a case to degrade for. + panic(fmt.Sprintf("no schema for %s (kind %s)", t, t.Kind())) +} + +func structSchema(t reflect.Type, definitions map[string]any) map[string]any { + properties := map[string]any{} + for i := range t.NumField() { + field := t.Field(i) + if !field.IsExported() { + continue + } + name, _, _ := strings.Cut(field.Tag.Get("json"), ",") + if name == "-" { + continue + } + if name == "" { + name = field.Name + } + properties[name] = schemaFor(field.Type, definitions) + } + return map[string]any{"type": "object", "properties": properties} +} + +// messageSchema describes a generated message as its protobuf JSON: lowerCamel +// field names, 64-bit integers as strings, enums by name, and the well-known +// types in their JSON form. Each message is one definition named after its +// full name. +func messageSchema(md protoreflect.MessageDescriptor, definitions map[string]any) map[string]any { + if wkt, ok := wellKnownSchema(md); ok { + return wkt + } + name := definitionName(md.FullName()) + if _, seen := definitions[name]; !seen { + definitions[name] = map[string]any{} + properties := map[string]any{} + fields := md.Fields() + for i := range fields.Len() { + fd := fields.Get(i) + properties[fd.JSONName()] = fieldSchema(fd, definitions) + } + definitions[name] = map[string]any{"type": "object", "properties": properties} + } + return map[string]any{"$ref": "#/definitions/" + name} +} + +func fieldSchema(fd protoreflect.FieldDescriptor, definitions map[string]any) map[string]any { + if fd.IsMap() { + return map[string]any{"type": "object", "additionalProperties": singularSchema(fd.MapValue(), definitions)} + } + if fd.IsList() { + return map[string]any{"type": "array", "items": singularSchema(fd, definitions)} + } + return singularSchema(fd, definitions) +} + +func singularSchema(fd protoreflect.FieldDescriptor, definitions map[string]any) map[string]any { + switch fd.Kind() { + case protoreflect.StringKind: + return map[string]any{"type": "string"} + case protoreflect.BytesKind: + return map[string]any{"type": "string", "format": "byte"} + case protoreflect.BoolKind: + return map[string]any{"type": "boolean"} + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind: + return map[string]any{"type": "integer", "format": "int32"} + case protoreflect.Uint32Kind, protoreflect.Fixed32Kind: + return map[string]any{"type": "integer", "format": "int64"} + case protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind: + return map[string]any{"type": "string", "format": "int64"} + case protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + return map[string]any{"type": "string", "format": "uint64"} + case protoreflect.FloatKind: + return map[string]any{"type": "number", "format": "float"} + case protoreflect.DoubleKind: + return map[string]any{"type": "number", "format": "double"} + case protoreflect.EnumKind: + values := fd.Enum().Values() + names := make([]string, 0, values.Len()) + for i := range values.Len() { + names = append(names, string(values.Get(i).Name())) + } + return map[string]any{"type": "string", "enum": names} + case protoreflect.MessageKind, protoreflect.GroupKind: + return messageSchema(fd.Message(), definitions) + } + panic(fmt.Sprintf("no schema for field %s of kind %s", fd.FullName(), fd.Kind())) +} + +func wellKnownSchema(md protoreflect.MessageDescriptor) (map[string]any, bool) { + switch md.FullName() { + case "google.protobuf.Timestamp": + return map[string]any{"type": "string", "format": "date-time"}, true + case "google.protobuf.Duration", "google.protobuf.FieldMask": + return map[string]any{"type": "string"}, true + case "google.protobuf.Struct": + return map[string]any{"type": "object"}, true + case "google.protobuf.Value", "google.protobuf.Any": + return map[string]any{}, true + case "google.protobuf.ListValue": + return map[string]any{"type": "array", "items": map[string]any{}}, true + case "google.protobuf.Empty": + return map[string]any{"type": "object"}, true + } + return nil, false +} + +// definitionName renders "documents.v1.Document" as "DocumentsV1Document". +func definitionName(name protoreflect.FullName) string { + var out strings.Builder + for _, part := range strings.Split(string(name), ".") { + out.WriteString(title(part)) + } + return out.String() +} + +func title(s string) string { + if s == "" { + return s + } + r := []rune(s) + r[0] = unicode.ToUpper(r[0]) + return string(r) +} diff --git a/operations_test.go b/operations_test.go new file mode 100644 index 0000000..c75c84d --- /dev/null +++ b/operations_test.go @@ -0,0 +1,112 @@ +package solution + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "google.golang.org/genproto/googleapis/api/annotations" +) + +type documentedTree struct { + ID string `json:"id"` + Rule Message[*annotations.HttpRule] `json:"rule"` + Children []*documentedTree `json:"children"` + Facets map[string]any `json:"facets"` +} + +type documentedResponse struct { + Nodes []*documentedTree `json:"nodes"` +} + +func documentedOperations() []Operation { + return []Operation{{ + Path: "/nodes/tree", + Summary: "List the nodes", + Callers: "Signed-in viewers.", + Behavior: "Returns every node.", + Query: []QueryParameter{{Name: "pageToken"}}, + Response: documentedResponse{}, + Handler: func(context.Context, *Gateway) (any, error) { return documentedResponse{}, nil }, + }} +} + +func TestInterfaceDocumentDescribesGeneratedMessagesFromTheirDescriptor(t *testing.T) { + raw, err := InterfaceDocument(InterfaceInfo{Title: "example backend", Version: "1.2.3", BasePath: "/solutions/example", OperationPrefix: "Example", Tag: "example"}, documentedOperations()) + if err != nil { + t.Fatal(err) + } + if !strings.HasSuffix(string(raw), "}\n") { + t.Fatalf("the artifact has no trailing newline") + } + var doc struct { + BasePath string `json:"basePath"` + Paths map[string]map[string]map[string]any `json:"paths"` + Definitions map[string]struct { + Properties map[string]map[string]any `json:"properties"` + } `json:"definitions"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + if doc.BasePath != "/solutions/example" || doc.Paths["/nodes/tree"]["get"]["operationId"] != "Example_Nodes_Tree" { + t.Fatalf("document = %s", raw) + } + rule, ok := doc.Definitions["GoogleApiHttpRule"] + if !ok { + t.Fatalf("the generated message has no definition: %v", doc.Definitions) + } + // Protobuf JSON names, not Go struct tags. + if _, ok := rule.Properties["responseBody"]; !ok { + t.Fatalf("definition does not use JSON names: %v", rule.Properties) + } + if items := rule.Properties["additionalBindings"]["items"].(map[string]any); items["$ref"] != "#/definitions/GoogleApiHttpRule" { + t.Fatalf("a self-referencing message does not terminate in a $ref: %v", items) + } + if _, ok := doc.Definitions["DocumentedTree"]; !ok { + t.Fatalf("struct definitions are missing: %v", doc.Definitions) + } +} + +func TestInterfaceDocumentRefusesAnUndocumentedOperation(t *testing.T) { + for _, broken := range []func(*Operation){ + func(o *Operation) { o.Summary = "" }, + func(o *Operation) { o.Callers = "" }, + func(o *Operation) { o.Behavior = "" }, + func(o *Operation) { o.Response = nil }, + func(o *Operation) { o.Handler = nil }, + func(o *Operation) { o.Path = "nodes" }, + func(o *Operation) { + o.RequestHandler = func(*http.Request, *Gateway) (any, error) { return nil, nil } + }, + } { + ops := documentedOperations() + broken(&ops[0]) + if _, err := InterfaceDocument(InterfaceInfo{}, ops); err == nil { + t.Fatalf("an invalid operation rendered: %+v", ops[0]) + } + } +} + +func TestOperationsServesEachDeclaredRoute(t *testing.T) { + s := New(Manifest{ID: "example"}).Operations(documentedOperations()...) + handler, ok := s.handlers["/nodes/tree"] + if !ok { + t.Fatalf("the declared route was not registered: %v", s.handlers) + } + srv := httptest.NewServer(s.wrapRequest(handler)) + defer srv.Close() + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/nodes/tree", nil) + req.Header.Set("authorization", "Bearer viewer") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } +} diff --git a/protowire.go b/protowire.go new file mode 100644 index 0000000..f487705 --- /dev/null +++ b/protowire.go @@ -0,0 +1,150 @@ +package solution + +import ( + "encoding/json" + "fmt" + "strings" + + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// --- Generated messages on a handler's wire --- +// +// A handler returns any JSON-serializable value and the runtime encodes it with +// encoding/json. A generated protobuf message is not one: encoding/json reads +// its Go struct tags (snake_case names, int64 as numbers, oneofs as Go +// interfaces), which is not the protobuf JSON a browser SDK parses. A handler +// that returns an owner's message therefore wraps it in Message, which encodes +// with protojson wherever the value sits in the response. + +// Message carries a generated protobuf message inside a handler's response and +// encodes it as protobuf JSON (lowerCamel names, int64 as strings, enums by +// name, well-known types in their JSON form). A nil message encodes as null. +type Message[T proto.Message] struct { + Value T +} + +// MessageOf wraps m for a handler's response. +func MessageOf[T proto.Message](m T) Message[T] { return Message[T]{Value: m} } + +// MarshalJSON encodes the message as protobuf JSON. +func (m Message[T]) MarshalJSON() ([]byte, error) { + if any(m.Value) == nil || !m.Value.ProtoReflect().IsValid() { + return []byte("null"), nil + } + return protojson.Marshal(m.Value) +} + +// messageDescriptor lets the operation documentation describe the wrapped +// message from its descriptor rather than from its Go struct. +func (m Message[T]) messageDescriptor() protoreflect.MessageDescriptor { + return m.Value.ProtoReflect().Descriptor() +} + +var _ json.Marshaler = Message[proto.Message]{} + +// FieldMask is a declared allowlist of the fields of one message type that a +// handler may pass on. Build it once, at package level, from the paths it +// allows; Apply then drops everything else. An owner adding a field to its +// message later does not widen what reaches the browser: a new field is +// outside every mask until someone names it. +// +// A path is a dotted chain of proto field names ("entry.entry_id"). Naming a +// message field allows the whole submessage; naming a path inside it allows +// only that part. Paths into a repeated message apply to every element. +type FieldMask struct { + desc protoreflect.MessageDescriptor + root *maskNode +} + +type maskNode struct { + all bool + children map[protoreflect.Name]*maskNode +} + +// NewFieldMask declares an allowlist over the message type of example. Every +// path must name fields that exist; an unknown path is an error, so a mask +// cannot silently allow nothing after a rename. +func NewFieldMask(example proto.Message, paths ...string) (FieldMask, error) { + desc := example.ProtoReflect().Descriptor() + root := &maskNode{children: map[protoreflect.Name]*maskNode{}} + for _, path := range paths { + node, current := root, desc + names := strings.Split(path, ".") + for i, name := range names { + fd := current.Fields().ByName(protoreflect.Name(name)) + if fd == nil { + return FieldMask{}, fmt.Errorf("field mask over %s: %q has no field %q", desc.FullName(), path, name) + } + child := node.children[fd.Name()] + if child == nil { + child = &maskNode{children: map[protoreflect.Name]*maskNode{}} + node.children[fd.Name()] = child + } + node = child + if i == len(names)-1 { + node.all = true + break + } + if fd.Kind() != protoreflect.MessageKind || fd.IsMap() { + return FieldMask{}, fmt.Errorf("field mask over %s: %q descends into %q, which is not a message", desc.FullName(), path, name) + } + current = fd.Message() + } + } + return FieldMask{desc: desc, root: root}, nil +} + +// MustFieldMask is NewFieldMask for package-level declarations: an invalid +// mask is a programming error found at start-up. +func MustFieldMask(example proto.Message, paths ...string) FieldMask { + mask, err := NewFieldMask(example, paths...) + if err != nil { + panic(err) + } + return mask +} + +// Apply returns a copy of m carrying only the allowed fields. m is not +// modified. It panics if m is not the type the mask was declared over. +func Apply[T proto.Message](mask FieldMask, m T) T { + if any(m) == nil || !m.ProtoReflect().IsValid() { + return m + } + if got := m.ProtoReflect().Descriptor().FullName(); got != mask.desc.FullName() { + panic(fmt.Sprintf("field mask over %s applied to %s", mask.desc.FullName(), got)) + } + out := proto.Clone(m).(T) + prune(out.ProtoReflect(), mask.root) + return out +} + +func prune(msg protoreflect.Message, node *maskNode) { + if node.all { + return + } + // Clearing is deferred past Range: a message is not mutated while it is + // being ranged over. + var disallowed []protoreflect.FieldDescriptor + msg.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { + child := node.children[fd.Name()] + switch { + case child == nil: + disallowed = append(disallowed, fd) + case child.all: + case fd.IsList(): + list := v.List() + for i := 0; i < list.Len(); i++ { + prune(list.Get(i).Message(), child) + } + default: + prune(v.Message(), child) + } + return true + }) + for _, fd := range disallowed { + msg.Clear(fd) + } +} diff --git a/solution.go b/solution.go index d74d1b5..15d169a 100644 --- a/solution.go +++ b/solution.go @@ -1917,6 +1917,13 @@ func newGateway(baseURL, bearer, orgID, sessionID string) *Gateway { func (g *Gateway) BaseURL() string { return g.baseURL } +// OrgID is the viewer's active organization: the x-org-id identity header the +// gateway stamped from the verified bearer, never anything the browser sent. +// It is empty for a viewer with no organization selected and for an org-less +// API key. It is the organization ForModule mints in, so a handler that scopes +// a module read to a tenant names this one rather than resolving another. +func (g *Gateway) OrgID() string { return g.orgID } + // HTTPClient returns an http.Client that injects the caller's bearer — and, on // a gateway derived by ForModule, the viewer's Work Context — on every request. // Satisfies connect.HTTPClient. Advanced escape hatch — prefer Unary. diff --git a/transcode.go b/transcode.go new file mode 100644 index 0000000..e0b1d69 --- /dev/null +++ b/transcode.go @@ -0,0 +1,378 @@ +package solution + +import ( + "bytes" + "context" + "encoding/base64" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + + "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" +) + +// --- Transcoded module calls --- +// +// The gateway reaches the host's own API over Connect, but a composed module +// only by the REST prefix the solution federates for it (/v1//*, see +// "Consumed-module federation" in the README). A module's generated client — +// gRPC or Connect — therefore cannot be pointed at the gateway: its procedure +// paths are not under that prefix. What a module does publish is the +// google.api.http binding of each RPC, carried in the generated descriptor. +// Transcoded speaks that binding, so a solution calls a composed module with +// the module's own generated request and response messages, and never builds a +// path, a query string or a wire struct by hand. + +// DefaultTranscodedResponseLimit bounds a transcoded response body unless the +// call sets its own bound with MaxResponseBytes. +const DefaultTranscodedResponseLimit = 8 << 20 + +// TranscodeOption adjusts one Transcoded call. +type TranscodeOption func(*transcodeOptions) + +type transcodeOptions struct { + maxResponseBytes int64 +} + +// MaxResponseBytes bounds the response body of one call. A larger response is +// an error, never a truncated message. +func MaxResponseBytes(n int64) TranscodeOption { + return func(o *transcodeOptions) { o.maxResponseBytes = n } +} + +// Transcoded calls one RPC of a composed module through the gateway, over the +// module's HTTP/JSON binding. +// +// method is the RPC's full name as its generated code names it — the +// "/package.Service/Method" constant a gRPC or Connect stub declares — and is +// resolved in the global protobuf registry, so the module's generated package +// must be linked in. prefix is the REST prefix the solution consumes the module +// under ("/v1/documents"): the call uses the method's first HTTP binding whose +// path lies under it. The URL, the HTTP method, 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. A method with no binding under prefix is refused before +// anything is sent. +// +// req and resp must be the method's input and output message types. The +// response is decoded with unknown fields discarded, so a module adding a +// field does not break its consumers. A non-2xx answer is a *GatewayError +// carrying the status, so a handler that returns it keeps an actionable status. +func (g *Gateway) Transcoded(ctx context.Context, prefix, method string, req, resp proto.Message, opts ...TranscodeOption) error { + options := transcodeOptions{maxResponseBytes: DefaultTranscodedResponseLimit} + for _, opt := range opts { + opt(&options) + } + call, err := transcodeRequest(prefix, method, req, resp) + if err != nil { + return err + } + var body io.Reader + if call.body != nil { + body = bytes.NewReader(call.body) + } + httpReq, err := http.NewRequestWithContext(ctx, call.verb, g.baseURL+call.path, body) + if err != nil { + return err + } + httpReq.Header.Set("accept", "application/json") + if call.body != nil { + httpReq.Header.Set("content-type", "application/json") + } + httpResp, err := g.HTTPClient().Do(httpReq) + if err != nil { + return fmt.Errorf("%s %s: %w", call.verb, call.template, err) + } + defer drainAndClose(httpResp) + if httpResp.StatusCode < 200 || httpResp.StatusCode > 299 { + return fmt.Errorf("%s %s: %w", call.verb, call.template, &GatewayError{StatusCode: httpResp.StatusCode}) + } + raw, err := io.ReadAll(io.LimitReader(httpResp.Body, options.maxResponseBytes+1)) + if err != nil { + return fmt.Errorf("%s %s: %w", call.verb, call.template, err) + } + if int64(len(raw)) > options.maxResponseBytes { + return fmt.Errorf("%s %s: response exceeds %d bytes", call.verb, call.template, options.maxResponseBytes) + } + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(raw, resp); err != nil { + return fmt.Errorf("%s %s: invalid %s: %w", call.verb, call.template, resp.ProtoReflect().Descriptor().FullName(), err) + } + return nil +} + +// transcodedCall is one request, fully resolved from the descriptor. +type transcodedCall struct { + verb string + template string // the binding's path template, for errors + path string // path and query, relative to the gateway + body []byte // nil when the binding carries no body +} + +func transcodeRequest(prefix, method string, req, resp proto.Message) (transcodedCall, error) { + if !cleanPrefix(prefix) { + return transcodedCall{}, fmt.Errorf("transcoded call: prefix %q must be an absolute path such as /v1/, with no trailing slash, dot segment, query or fragment", prefix) + } + md, err := methodDescriptor(method) + if err != nil { + return transcodedCall{}, err + } + if got, want := req.ProtoReflect().Descriptor().FullName(), md.Input().FullName(); got != want { + return transcodedCall{}, fmt.Errorf("transcoded call %s: request is %s, want %s", md.FullName(), got, want) + } + if got, want := resp.ProtoReflect().Descriptor().FullName(), md.Output().FullName(); got != want { + return transcodedCall{}, fmt.Errorf("transcoded call %s: response is %s, want %s", md.FullName(), got, want) + } + rule, verb, template, err := bindingUnder(md, prefix) + if err != nil { + return transcodedCall{}, err + } + msg := req.ProtoReflect() + path, bound, err := expandTemplate(md, template, msg) + if err != nil { + return transcodedCall{}, err + } + call := transcodedCall{verb: verb, template: template} + var queryExcluded protoreflect.FieldDescriptor + switch rule.GetBody() { + case "": + case "*": + call.body, err = protojson.Marshal(req) + if err != nil { + return transcodedCall{}, err + } + call.path = path + return call, nil + default: + fd := msg.Descriptor().Fields().ByName(protoreflect.Name(rule.GetBody())) + if fd == nil || fd.Kind() != protoreflect.MessageKind || fd.IsList() || fd.IsMap() { + return transcodedCall{}, fmt.Errorf("transcoded call %s: body %q is not a singular message field", md.FullName(), rule.GetBody()) + } + call.body, err = protojson.Marshal(msg.Get(fd).Message().Interface()) + if err != nil { + return transcodedCall{}, err + } + queryExcluded = fd + } + query := url.Values{} + if err := encodeQuery(query, "", msg, bound, queryExcluded); err != nil { + return transcodedCall{}, fmt.Errorf("transcoded call %s: %w", md.FullName(), err) + } + call.path = path + if encoded := query.Encode(); encoded != "" { + call.path += "?" + encoded + } + return call, nil +} + +func cleanPrefix(prefix string) bool { + if !strings.HasPrefix(prefix, "/") || strings.HasSuffix(prefix, "/") || strings.ContainsAny(prefix, "?#{}*:\\") { + return false + } + for _, segment := range strings.Split(prefix[1:], "/") { + if segment == "" || segment == "." || segment == ".." { + return false + } + } + return true +} + +// methodDescriptor resolves "/package.Service/Method" (or the dotted +// "package.Service.Method") in the global registry. +func methodDescriptor(method string) (protoreflect.MethodDescriptor, error) { + name := strings.TrimPrefix(method, "/") + if i := strings.LastIndex(name, "/"); i >= 0 { + name = name[:i] + "." + name[i+1:] + } + d, err := protoregistry.GlobalFiles.FindDescriptorByName(protoreflect.FullName(name)) + if err != nil { + return nil, fmt.Errorf("transcoded call: method %q is not in the protobuf registry (is its generated package linked in?): %w", method, err) + } + md, ok := d.(protoreflect.MethodDescriptor) + if !ok { + return nil, fmt.Errorf("transcoded call: %q names a %T, not a method", method, d) + } + return md, nil +} + +// bindingUnder returns the first HTTP binding of md — the primary rule, then +// its additional bindings in order — whose path lies under prefix. +func bindingUnder(md protoreflect.MethodDescriptor, prefix string) (*annotations.HttpRule, string, string, error) { + primary, _ := proto.GetExtension(md.Options(), annotations.E_Http).(*annotations.HttpRule) + if primary == nil { + return nil, "", "", fmt.Errorf("transcoded call %s: the method declares no google.api.http binding", md.FullName()) + } + rules := append([]*annotations.HttpRule{primary}, primary.GetAdditionalBindings()...) + for _, rule := range rules { + verb, template := ruleVerb(rule) + if template == prefix || strings.HasPrefix(template, prefix+"/") || strings.HasPrefix(template, prefix+":") { + return rule, verb, template, nil + } + } + return nil, "", "", fmt.Errorf("transcoded call %s: no google.api.http binding under %s", md.FullName(), prefix) +} + +func ruleVerb(rule *annotations.HttpRule) (string, string) { + switch pattern := rule.GetPattern().(type) { + case *annotations.HttpRule_Get: + return http.MethodGet, pattern.Get + case *annotations.HttpRule_Post: + return http.MethodPost, pattern.Post + case *annotations.HttpRule_Put: + return http.MethodPut, pattern.Put + case *annotations.HttpRule_Delete: + return http.MethodDelete, pattern.Delete + case *annotations.HttpRule_Patch: + return http.MethodPatch, pattern.Patch + case *annotations.HttpRule_Custom: + return pattern.Custom.GetKind(), pattern.Custom.GetPath() + } + return "", "" +} + +// expandTemplate fills each {field} variable of template from msg. Only +// single-segment variables are supported ({field} and {field=*}); a +// multi-segment one is refused rather than guessed. Each value must be set and +// is path-escaped, so a value can never add a segment of its own. +func expandTemplate(md protoreflect.MethodDescriptor, template string, msg protoreflect.Message) (string, map[string]bool, error) { + bound := map[string]bool{} + var out strings.Builder + rest := template + for { + open := strings.IndexByte(rest, '{') + if open < 0 { + out.WriteString(rest) + return out.String(), bound, nil + } + end := strings.IndexByte(rest[open:], '}') + if end < 0 { + return "", nil, fmt.Errorf("transcoded call %s: malformed path template %q", md.FullName(), template) + } + out.WriteString(rest[:open]) + variable := rest[open+1 : open+end] + field, pattern, hasPattern := strings.Cut(variable, "=") + if hasPattern && pattern != "*" { + return "", nil, fmt.Errorf("transcoded call %s: path variable {%s} is multi-segment, which is not supported", md.FullName(), variable) + } + value, err := scalarAt(msg, field) + if err != nil { + return "", nil, fmt.Errorf("transcoded call %s: path variable {%s}: %w", md.FullName(), field, err) + } + if value == "" { + return "", nil, fmt.Errorf("transcoded call %s: path variable {%s} is empty", md.FullName(), field) + } + if value == "." || value == ".." { + // Escaping leaves a dot segment as it is, and a normalizing hop + // would resolve it to a different route. + return "", nil, fmt.Errorf("transcoded call %s: path variable {%s} is a dot segment", md.FullName(), field) + } + out.WriteString(url.PathEscape(value)) + bound[field] = true + rest = rest[open+end+1:] + } +} + +// scalarAt reads the scalar at a dotted field path of msg as its query/path +// text form. +func scalarAt(msg protoreflect.Message, path string) (string, error) { + names := strings.Split(path, ".") + for i, name := range names { + fd := msg.Descriptor().Fields().ByName(protoreflect.Name(name)) + if fd == nil { + return "", fmt.Errorf("no field %q in %s", name, msg.Descriptor().FullName()) + } + if fd.IsList() || fd.IsMap() { + return "", fmt.Errorf("field %q is repeated", name) + } + if i < len(names)-1 { + if fd.Kind() != protoreflect.MessageKind { + return "", fmt.Errorf("field %q is not a message", name) + } + msg = msg.Get(fd).Message() + continue + } + return scalarText(fd, msg.Get(fd)) + } + return "", fmt.Errorf("empty field path") +} + +func scalarText(fd protoreflect.FieldDescriptor, v protoreflect.Value) (string, error) { + switch fd.Kind() { + case protoreflect.StringKind: + return v.String(), nil + case protoreflect.BoolKind: + return strconv.FormatBool(v.Bool()), nil + case protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Sfixed32Kind, + protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Sfixed64Kind: + return strconv.FormatInt(v.Int(), 10), nil + case protoreflect.Uint32Kind, protoreflect.Fixed32Kind, protoreflect.Uint64Kind, protoreflect.Fixed64Kind: + return strconv.FormatUint(v.Uint(), 10), nil + case protoreflect.FloatKind, protoreflect.DoubleKind: + return strconv.FormatFloat(v.Float(), 'g', -1, 64), nil + case protoreflect.BytesKind: + return base64.URLEncoding.EncodeToString(v.Bytes()), nil + case protoreflect.EnumKind: + if ev := fd.Enum().Values().ByNumber(v.Enum()); ev != nil { + return string(ev.Name()), nil + } + return strconv.FormatInt(int64(v.Enum()), 10), nil + } + return "", fmt.Errorf("field %q of kind %s has no query form", fd.Name(), fd.Kind()) +} + +// encodeQuery adds every populated field of msg not bound by the path (or sent +// as the body) as query parameters, by proto field name — the form the +// HTTP/JSON transcoder reads. Nested messages flatten to dotted names; repeated +// scalars repeat the key. A map or a repeated message has no query form and is +// refused rather than silently dropped. +func encodeQuery(query url.Values, parent string, msg protoreflect.Message, bound map[string]bool, excluded protoreflect.FieldDescriptor) error { + var err error + msg.Range(func(fd protoreflect.FieldDescriptor, v protoreflect.Value) bool { + if excluded != nil && fd == excluded { + return true + } + name := string(fd.Name()) + if parent != "" { + name = parent + "." + name + } + if bound[name] { + return true + } + switch { + case fd.IsMap(): + err = fmt.Errorf("map field %q has no query form", name) + case fd.IsList(): + if fd.Kind() == protoreflect.MessageKind || fd.Kind() == protoreflect.GroupKind { + err = fmt.Errorf("repeated message field %q has no query form", name) + break + } + list := v.List() + for i := 0; i < list.Len() && err == nil; i++ { + var text string + if text, err = scalarText(fd, list.Get(i)); err == nil { + query.Add(name, text) + } + } + case fd.Kind() == protoreflect.MessageKind || fd.Kind() == protoreflect.GroupKind: + if strings.HasPrefix(string(fd.Message().FullName()), "google.protobuf.") { + // A well-known type has a JSON form of its own (a timestamp is + // RFC 3339 text), which flattening its fields would not produce. + err = fmt.Errorf("well-known type field %q has no query form here; send it in a body", name) + break + } + err = encodeQuery(query, name, v.Message(), bound, nil) + default: + var text string + if text, err = scalarText(fd, v); err == nil { + query.Set(name, text) + } + } + return err == nil + }) + return err +} diff --git a/transcode_test.go b/transcode_test.go new file mode 100644 index 0000000..2dc95ce --- /dev/null +++ b/transcode_test.go @@ -0,0 +1,311 @@ +package solution + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "google.golang.org/genproto/googleapis/api/annotations" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/reflect/protoregistry" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/dynamicpb" +) + +// transcodeTestFile is a module API the way a generated package registers +// one: a service whose methods carry google.api.http bindings, one of them +// with an additional binding under a different prefix. +var transcodeTestFile = func() protoreflect.FileDescriptor { + str := descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum() + i32 := descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum() + i64 := descriptorpb.FieldDescriptorProto_TYPE_INT64.Enum() + msg := descriptorpb.FieldDescriptorProto_TYPE_MESSAGE.Enum() + opt := descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum() + rep := descriptorpb.FieldDescriptorProto_LABEL_REPEATED.Enum() + field := func(name string, number int32, label *descriptorpb.FieldDescriptorProto_Label, typ *descriptorpb.FieldDescriptorProto_Type, typeName string) *descriptorpb.FieldDescriptorProto { + f := &descriptorpb.FieldDescriptorProto{Name: proto.String(name), Number: proto.Int32(number), Label: label, Type: typ, JsonName: proto.String(jsonName(name))} + if typeName != "" { + f.TypeName = proto.String(typeName) + } + return f + } + http := func(rule *annotations.HttpRule) *descriptorpb.MethodOptions { + options := &descriptorpb.MethodOptions{} + proto.SetExtension(options, annotations.E_Http, rule) + return options + } + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String("solution_runtime_test/things.proto"), + Package: proto.String("things.v1"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + {Name: proto.String("Sub"), Field: []*descriptorpb.FieldDescriptorProto{field("name", 1, opt, str, "")}}, + {Name: proto.String("GetThingRequest"), Field: []*descriptorpb.FieldDescriptorProto{ + field("tenant", 1, opt, str, ""), + field("entry_id", 2, opt, str, ""), + field("page_size", 3, opt, i32, ""), + field("ids", 4, rep, str, ""), + field("filter", 5, opt, msg, ".things.v1.Sub"), + }}, + {Name: proto.String("Thing"), Field: []*descriptorpb.FieldDescriptorProto{ + field("entry_id", 1, opt, str, ""), + field("big", 2, opt, i64, ""), + field("secret", 3, opt, str, ""), + field("sub", 4, opt, msg, ".things.v1.Sub"), + field("subs", 5, rep, msg, ".things.v1.Sub"), + }}, + }, + Service: []*descriptorpb.ServiceDescriptorProto{{ + Name: proto.String("Things"), + Method: []*descriptorpb.MethodDescriptorProto{ + {Name: proto.String("Get"), InputType: proto.String(".things.v1.GetThingRequest"), OutputType: proto.String(".things.v1.Thing"), + Options: http(&annotations.HttpRule{ + Pattern: &annotations.HttpRule_Get{Get: "/v1/tenants/{tenant}/things/{entry_id}"}, + AdditionalBindings: []*annotations.HttpRule{{Pattern: &annotations.HttpRule_Get{Get: "/v1/things/{entry_id}"}}}, + })}, + {Name: proto.String("Search"), InputType: proto.String(".things.v1.GetThingRequest"), OutputType: proto.String(".things.v1.Thing"), + Options: http(&annotations.HttpRule{Pattern: &annotations.HttpRule_Post{Post: "/v1/things/search"}, Body: "*"})}, + {Name: proto.String("Unbound"), InputType: proto.String(".things.v1.GetThingRequest"), OutputType: proto.String(".things.v1.Thing")}, + {Name: proto.String("Wide"), InputType: proto.String(".things.v1.GetThingRequest"), OutputType: proto.String(".things.v1.Thing"), + Options: http(&annotations.HttpRule{Pattern: &annotations.HttpRule_Get{Get: "/v1/things/{entry_id=**}"}})}, + }, + }}, + } + fd, err := protodesc.NewFile(file, protoregistry.GlobalFiles) + if err != nil { + panic(err) + } + if err := protoregistry.GlobalFiles.RegisterFile(fd); err != nil { + panic(err) + } + return fd +}() + +func jsonName(name string) string { + parts := strings.Split(name, "_") + for i := 1; i < len(parts); i++ { + parts[i] = title(parts[i]) + } + return strings.Join(parts, "") +} + +func thingMessage(name string) *dynamicpb.Message { + return dynamicpb.NewMessage(transcodeTestFile.Messages().ByName(protoreflect.Name(name))) +} + +func setField(m *dynamicpb.Message, name string, v protoreflect.Value) { + m.Set(m.Descriptor().Fields().ByName(protoreflect.Name(name)), v) +} + +type recorded struct { + method, uri, body string +} + +func transcodeServer(t *testing.T, status int, reply string) (*Gateway, *recorded, *atomic.Int32) { + t.Helper() + got := &recorded{} + hits := &atomic.Int32{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits.Add(1) + body, _ := io.ReadAll(r.Body) + got.method, got.uri, got.body = r.Method, r.URL.RequestURI(), string(body) + w.WriteHeader(status) + _, _ = io.WriteString(w, reply) + })) + t.Cleanup(srv.Close) + return newGateway(srv.URL, "Bearer viewer", "org-1", "session-1"), got, hits +} + +func TestTranscodedUsesTheBindingUnderThePrefix(t *testing.T) { + gw, got, _ := transcodeServer(t, http.StatusOK, `{"entryId":"e/1","big":"9007199254740993","added":"by a newer owner"}`) + req := thingMessage("GetThingRequest") + setField(req, "tenant", protoreflect.ValueOfString("org-1")) + setField(req, "entry_id", protoreflect.ValueOfString("e/1")) + setField(req, "page_size", protoreflect.ValueOfInt32(100)) + ids := req.Mutable(req.Descriptor().Fields().ByName("ids")).List() + ids.Append(protoreflect.ValueOfString("a")) + ids.Append(protoreflect.ValueOfString("b")) + filter := req.Mutable(req.Descriptor().Fields().ByName("filter")).Message() + filter.Set(filter.Descriptor().Fields().ByName("name"), protoreflect.ValueOfString("x")) + resp := thingMessage("Thing") + + if err := gw.Transcoded(context.Background(), "/v1/things", "/things.v1.Things/Get", req, resp); err != nil { + t.Fatal(err) + } + if got.method != http.MethodGet { + t.Fatalf("method = %s", got.method) + } + // The additional binding is the one under the prefix; the path variable is + // escaped so its slash cannot add a segment; every other set field is a + // query parameter by proto name. + want := "/v1/things/e%2F1?filter.name=x&ids=a&ids=b&page_size=100&tenant=org-1" + if got.uri != want { + t.Fatalf("uri = %s, want %s", got.uri, want) + } + if got.body != "" { + t.Fatalf("a GET binding sent a body: %q", got.body) + } + if v := resp.Get(resp.Descriptor().Fields().ByName("big")).Int(); v != 9007199254740993 { + t.Fatalf("big = %d", v) + } +} + +func TestTranscodedSendsTheBodyTheBindingNames(t *testing.T) { + gw, got, _ := transcodeServer(t, http.StatusOK, `{}`) + req := thingMessage("GetThingRequest") + setField(req, "tenant", protoreflect.ValueOfString("org-1")) + setField(req, "page_size", protoreflect.ValueOfInt32(5)) + if err := gw.Transcoded(context.Background(), "/v1/things", "/things.v1.Things/Search", req, thingMessage("Thing")); err != nil { + t.Fatal(err) + } + if got.method != http.MethodPost || got.uri != "/v1/things/search" { + t.Fatalf("request = %s %s", got.method, got.uri) + } + var body map[string]any + if err := json.Unmarshal([]byte(got.body), &body); err != nil || body["tenant"] != "org-1" || body["pageSize"] != float64(5) { + t.Fatalf("body = %s (%v)", got.body, err) + } +} + +func TestTranscodedRefusesBeforeSending(t *testing.T) { + get := thingMessage("GetThingRequest") + setField(get, "entry_id", protoreflect.ValueOfString("e1")) + cases := []struct { + name, prefix, method string + req, resp proto.Message + want string + }{ + {"no binding under the prefix", "/v1/other", "/things.v1.Things/Get", get, thingMessage("Thing"), "no google.api.http binding under /v1/other"}, + {"a prefix that is only a string prefix", "/v1/thing", "/things.v1.Things/Get", get, thingMessage("Thing"), "no google.api.http binding"}, + {"no binding at all", "/v1/things", "/things.v1.Things/Unbound", get, thingMessage("Thing"), "declares no google.api.http binding"}, + {"a multi-segment variable", "/v1/things", "/things.v1.Things/Wide", get, thingMessage("Thing"), "multi-segment"}, + {"an unknown method", "/v1/things", "/things.v1.Things/Missing", get, thingMessage("Thing"), "not in the protobuf registry"}, + {"the wrong request type", "/v1/things", "/things.v1.Things/Get", thingMessage("Thing"), thingMessage("Thing"), "request is things.v1.Thing"}, + {"the wrong response type", "/v1/things", "/things.v1.Things/Get", get, thingMessage("Sub"), "response is things.v1.Sub"}, + {"an empty path variable", "/v1/things", "/things.v1.Things/Get", thingMessage("GetThingRequest"), thingMessage("Thing"), "{entry_id} is empty"}, + {"a dot-segment value", "/v1/things", "/things.v1.Things/Get", func() proto.Message { + m := thingMessage("GetThingRequest") + setField(m, "entry_id", protoreflect.ValueOfString("..")) + return m + }(), thingMessage("Thing"), "is a dot segment"}, + {"a trailing slash", "/v1/things/", "/things.v1.Things/Get", get, thingMessage("Thing"), "must be an absolute path"}, + {"a relative prefix", "v1/things", "/things.v1.Things/Get", get, thingMessage("Thing"), "must be an absolute path"}, + {"a dot segment", "/v1/../things", "/things.v1.Things/Get", get, thingMessage("Thing"), "must be an absolute path"}, + {"a query", "/v1/things?x=1", "/things.v1.Things/Get", get, thingMessage("Thing"), "must be an absolute path"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gw, _, hits := transcodeServer(t, http.StatusOK, `{}`) + err := gw.Transcoded(context.Background(), tc.prefix, tc.method, tc.req, tc.resp) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want %q", err, tc.want) + } + if hits.Load() != 0 { + t.Fatalf("a refused call reached the gateway") + } + }) + } +} + +func TestTranscodedKeepsTheStatusOfAFailure(t *testing.T) { + gw, _, _ := transcodeServer(t, http.StatusForbidden, `{"code":7,"message":"denied"}`) + req := thingMessage("GetThingRequest") + setField(req, "entry_id", protoreflect.ValueOfString("e1")) + err := gw.Transcoded(context.Background(), "/v1/things", "/things.v1.Things/Get", req, thingMessage("Thing")) + var status *GatewayError + if !errors.As(err, &status) || status.StatusCode != http.StatusForbidden { + t.Fatalf("err = %v, want a GatewayError with 403", err) + } +} + +func TestTranscodedBoundsTheResponse(t *testing.T) { + gw, _, _ := transcodeServer(t, http.StatusOK, `{"entryId":"`+strings.Repeat("x", 64)+`"}`) + req := thingMessage("GetThingRequest") + setField(req, "entry_id", protoreflect.ValueOfString("e1")) + err := gw.Transcoded(context.Background(), "/v1/things", "/things.v1.Things/Get", req, thingMessage("Thing"), MaxResponseBytes(32)) + if err == nil || !strings.Contains(err.Error(), "exceeds 32 bytes") { + t.Fatalf("err = %v", err) + } +} + +func TestTranscodedPresentsTheViewersCredentials(t *testing.T) { + var authorization string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authorization = r.Header.Get("authorization") + _, _ = io.WriteString(w, `{}`) + })) + defer srv.Close() + gw := newGateway(srv.URL, "Bearer viewer", "org-1", "session-1") + req := thingMessage("GetThingRequest") + setField(req, "entry_id", protoreflect.ValueOfString("e1")) + if err := gw.Transcoded(context.Background(), "/v1/things", "/things.v1.Things/Get", req, thingMessage("Thing")); err != nil { + t.Fatal(err) + } + if authorization != "Bearer viewer" { + t.Fatalf("authorization = %q", authorization) + } +} + +func TestFieldMaskKeepsOnlyWhatItNames(t *testing.T) { + thing := thingMessage("Thing") + setField(thing, "entry_id", protoreflect.ValueOfString("e1")) + setField(thing, "big", protoreflect.ValueOfInt64(7)) + setField(thing, "secret", protoreflect.ValueOfString("storage://bucket/key")) + sub := thing.Mutable(thing.Descriptor().Fields().ByName("sub")).Message() + sub.Set(sub.Descriptor().Fields().ByName("name"), protoreflect.ValueOfString("kept")) + subs := thing.Mutable(thing.Descriptor().Fields().ByName("subs")).List() + element := subs.NewElement() + element.Message().Set(element.Message().Descriptor().Fields().ByName("name"), protoreflect.ValueOfString("kept too")) + subs.Append(element) + + mask := MustFieldMask(thing, "entry_id", "sub", "subs.name") + out := Apply(mask, thing) + fields := out.Descriptor().Fields() + if out.Has(fields.ByName("secret")) || out.Has(fields.ByName("big")) { + t.Fatalf("unnamed fields survived: %v", out) + } + if out.Get(fields.ByName("entry_id")).String() != "e1" || out.Get(fields.ByName("sub")).Message().Get(sub.Descriptor().Fields().ByName("name")).String() != "kept" { + t.Fatalf("named fields were dropped: %v", out) + } + if out.Get(fields.ByName("subs")).List().Len() != 1 { + t.Fatalf("repeated field was dropped: %v", out) + } + if !thing.Has(fields.ByName("secret")) { + t.Fatalf("Apply modified its input") + } + if _, err := NewFieldMask(thing, "entry_id", "storage_uri"); err == nil { + t.Fatalf("a mask naming an unknown field was accepted") + } + if _, err := NewFieldMask(thing, "entry_id.x"); err == nil { + t.Fatalf("a mask descending into a scalar was accepted") + } +} + +func TestMessageEncodesProtobufJSON(t *testing.T) { + rule := &annotations.HttpRule{Pattern: &annotations.HttpRule_Get{Get: "/v1/x"}, ResponseBody: "items"} + out, err := json.Marshal(struct { + Rule Message[*annotations.HttpRule] `json:"rule"` + None Message[*annotations.HttpRule] `json:"none"` + }{Rule: MessageOf(rule)}) + if err != nil { + t.Fatal(err) + } + var decoded map[string]map[string]any + if err := json.Unmarshal(out, &decoded); err != nil { + t.Fatal(err) + } + if decoded["rule"]["responseBody"] != "items" || decoded["rule"]["get"] != "/v1/x" { + t.Fatalf("not protobuf JSON: %s", out) + } + if !strings.Contains(string(out), `"none":null`) { + t.Fatalf("a nil message did not encode as null: %s", out) + } +} From d5e5c50e17123da9d89822764a991bd4ee546456 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:51:54 -0400 Subject: [PATCH 2/7] feat: report whom a ForModule capability was issued for A handler that must check a claimed owner against the capability it reads with had to mint its own Task Work Context (with an invented session) to see accounts' owner and actor. Gateway.WorkContextPrincipals returns the organization, owner and current actor accounts answered the ForModule mint with, reusing the cached capability. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 6 ++++++ solution.go | 52 ++++++++++++++++++++++++++++++++++++++++++++++-- solution_test.go | 35 +++++++++++++++++++++++++++++++- 3 files changed, 90 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 187a3c1..798b4af 100644 --- a/README.md +++ b/README.md @@ -396,6 +396,12 @@ 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. + ### Generated messages in a response A handler's value is encoded with `encoding/json`, which reads a generated diff --git a/solution.go b/solution.go index 15d169a..533ad7c 100644 --- a/solution.go +++ b/solution.go @@ -2194,6 +2194,7 @@ func (g *Gateway) mint(ctx context.Context, ask startTaskRequest) (codefly.WorkC var issued struct { Token string `json:"token"` ExpiresAt time.Time `json:"expiresAt"` + WorkContextPrincipals } if err := json.NewDecoder(resp.Body).Decode(&issued); err != nil { return codefly.WorkContextToken{}, time.Time{}, fmt.Errorf("work context mint for %q returned invalid json: %w", ask.Audience, err) @@ -2234,9 +2235,40 @@ func (g *Gateway) mint(ctx context.Context, ask startTaskRequest) (codefly.WorkC lifetime.Round(time.Second), workContextRenewal, lead.Round(time.Second)) }) } + g.contexts.remember(token, issued.WorkContextPrincipals) return token, issued.ExpiresAt.Add(-lead), nil } +// WorkContextPrincipals is who accounts says a minted capability acts for, as +// it answered the mint: the organization, the Task's owner, and its current +// actor. They are accounts' own resolution of the forwarded bearer, never +// anything the handler or the browser supplied. +type WorkContextPrincipals struct { + OrgID string `json:"orgId"` + OwnerPrincipalID string `json:"ownerPrincipalId"` + CurrentActorPrincipalID string `json:"currentActorPrincipalId"` +} + +// WorkContextPrincipals reports whom the capability this gateway acts under was +// issued for — on a gateway derived by ForModule, resolving (and if need be +// minting) the capability exactly as a request would. A handler compares them +// against an identity it was handed rather than trusting that identity. On a +// gateway that acts under no capability it is an error. +func (g *Gateway) WorkContextPrincipals(ctx context.Context) (WorkContextPrincipals, error) { + if g.delegation == nil { + return WorkContextPrincipals{}, errors.New("this gateway acts under no work context; derive one with ForModule") + } + token, err := g.workContext(ctx) + if err != nil { + return WorkContextPrincipals{}, err + } + principals, ok := g.contexts.principals(token) + if !ok || principals.OwnerPrincipalID == "" || principals.CurrentActorPrincipalID == "" || principals.OrgID == "" { + return WorkContextPrincipals{}, errors.New("the work context issuance named no organization, owner or actor") + } + return principals, nil +} + // workContextCache holds the capabilities minted while serving one request. The // gateway a handler receives owns it and every gateway derived from that one // shares it, so the cache lives exactly as long as the viewer's request. @@ -2248,12 +2280,28 @@ type workContextCache struct { // fans out would otherwise spend one audited mint per goroutine for a // capability they all share. minting map[string]*pendingMint + // issuedFor holds, per issued capability, whom accounts said it acts for. + issuedFor map[string]WorkContextPrincipals +} + +func (c *workContextCache) remember(token codefly.WorkContextToken, principals WorkContextPrincipals) { + c.mu.Lock() + defer c.mu.Unlock() + c.issuedFor[token.Encoded()] = principals +} + +func (c *workContextCache) principals(token codefly.WorkContextToken) (WorkContextPrincipals, bool) { + c.mu.Lock() + defer c.mu.Unlock() + principals, ok := c.issuedFor[token.Encoded()] + return principals, ok } func newWorkContextCache() *workContextCache { return &workContextCache{ - minted: map[string]issuedWorkContext{}, - minting: map[string]*pendingMint{}, + minted: map[string]issuedWorkContext{}, + minting: map[string]*pendingMint{}, + issuedFor: map[string]WorkContextPrincipals{}, } } diff --git a/solution_test.go b/solution_test.go index 2042079..da17a0d 100644 --- a/solution_test.go +++ b/solution_test.go @@ -1845,7 +1845,7 @@ func (g *workContextGateway) serve(w http.ResponseWriter, r *http.Request) { if g.mintDelay > 0 { time.Sleep(g.mintDelay) } - issued := map[string]any{"token": token} + issued := map[string]any{"token": token, "orgId": mint.OrgID, "ownerPrincipalId": "viewer-principal", "currentActorPrincipalId": "viewer-principal"} if !g.omitExpiry { issued["expiresAt"] = time.Now().Add(g.tokenTTL).UTC().Format(time.RFC3339Nano) } @@ -2526,3 +2526,36 @@ func TestManifestURLIsNeverTheListenAddress(t *testing.T) { } } } + +// TestWorkContextPrincipalsReportsWhomAccountsIssuedFor: a handler comparing a +// claimed owner against the capability reads accounts' own answer to the mint, +// and a gateway acting under no capability has none to report. +func TestWorkContextPrincipalsReportsWhomAccountsIssuedFor(t *testing.T) { + gw := newWorkContextGateway(t, &workContextGateway{}) + var got WorkContextPrincipals + var bare error + solution := serveHandler(t, gw.URL, func(ctx context.Context, g *Gateway) (any, error) { + _, bare = g.WorkContextPrincipals(ctx) + docs, err := g.ForModule(ctx, "documents", Scope{ResourceKind: "documents", Actions: []string{"read"}}) + if err != nil { + return nil, err + } + got, err = docs.WorkContextPrincipals(ctx) + return "done", err + }) + resp := viewerRequest(t, solution.URL) + defer drainAndClose(resp) + if resp.StatusCode != http.StatusOK { + t.Fatalf("solution answered %d, want 200", resp.StatusCode) + } + want := WorkContextPrincipals{OrgID: viewerOrg, OwnerPrincipalID: "viewer-principal", CurrentActorPrincipalID: "viewer-principal"} + if got != want { + t.Fatalf("principals = %+v, want %+v", got, want) + } + if bare == nil { + t.Fatal("a gateway acting under no work context reported principals") + } + if n := gw.mintCount(); n != 1 { + t.Fatalf("minted %d capabilities, want 1: reading the principals reuses the capability", n) + } +} From 2007f853dcfbc2fca718ee1539f37d39093165db Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 25 Sep 2026 05:01:46 -0400 Subject: [PATCH 3/7] fix: describe a pointer to a generated message by the message *Message[T] carries Message[T]'s methods, so the interface check matched it first and called the descriptor on a nil pointer. Unwrap pointers before it. Co-Authored-By: Claude Opus 5.5 (1M context) --- operations.go | 8 ++++++-- operations_test.go | 15 ++++++++++----- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/operations.go b/operations.go index dca9db4..236b19c 100644 --- a/operations.go +++ b/operations.go @@ -235,12 +235,16 @@ func schemaFor(t reflect.Type, definitions map[string]any) map[string]any { if t == rawMessageType { return map[string]any{"type": "object"} } + // A pointer is described by what it points to. It is unwrapped first: + // *Message[T] also has Message[T]'s methods, and its zero value is a nil + // pointer no method can be called on. + if t.Kind() == reflect.Pointer { + return schemaFor(t.Elem(), definitions) + } if t.Implements(messageDescribedType) { return messageSchema(reflect.Zero(t).Interface().(messageDescribed).messageDescriptor(), definitions) } switch t.Kind() { - case reflect.Pointer: - return schemaFor(t.Elem(), definitions) case reflect.Struct: name := title(t.Name()) // An unnamed struct has no definition to be referenced by: keyed on "" diff --git a/operations_test.go b/operations_test.go index c75c84d..e30aa5f 100644 --- a/operations_test.go +++ b/operations_test.go @@ -12,10 +12,11 @@ import ( ) type documentedTree struct { - ID string `json:"id"` - Rule Message[*annotations.HttpRule] `json:"rule"` - Children []*documentedTree `json:"children"` - Facets map[string]any `json:"facets"` + ID string `json:"id"` + Rule Message[*annotations.HttpRule] `json:"rule"` + Optional *Message[*annotations.HttpRule] `json:"optional,omitempty"` + Children []*documentedTree `json:"children"` + Facets map[string]any `json:"facets"` } type documentedResponse struct { @@ -66,9 +67,13 @@ func TestInterfaceDocumentDescribesGeneratedMessagesFromTheirDescriptor(t *testi if items := rule.Properties["additionalBindings"]["items"].(map[string]any); items["$ref"] != "#/definitions/GoogleApiHttpRule" { t.Fatalf("a self-referencing message does not terminate in a $ref: %v", items) } - if _, ok := doc.Definitions["DocumentedTree"]; !ok { + tree, ok := doc.Definitions["DocumentedTree"] + if !ok { t.Fatalf("struct definitions are missing: %v", doc.Definitions) } + if tree.Properties["optional"]["$ref"] != "#/definitions/GoogleApiHttpRule" { + t.Fatalf("a pointer to a generated message is not described by it: %v", tree.Properties["optional"]) + } } func TestInterfaceDocumentRefusesAnUndocumentedOperation(t *testing.T) { From 001880b1541f410f7b4a622e1383d720b960d001 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:31:37 -0400 Subject: [PATCH 4/7] feat: let a solution's page call a consumed module's declared operations A page reaches only its solution's own backend, so every module read it made needed a handler in the solution that decoded the request, minted the viewer's capability, called the module and re-encoded the answer: the same relay in every solution, once per operation. Server.Consumes declares instead which operations of which consumed module the page may call. Each is served as a Connect unary procedure at /modules///, so the module's generated client is used unmodified. A call is answered by the module as the viewer: the runtime mints the declared Work Context (ForModule), or forwards only the bearer to a module that authenticates the viewer itself, and forwards the request over the method's google.api.http binding (Transcoded). Least privilege by default: an undeclared method is not_found; Serve refuses a module absent from api.consumes, a method without a binding under /v1/, a streaming method, and a method that names no response fields; responses carry only the fields the declaration names unless WholeResponse is explicit. A module's refusal reaches the page in the Connect error. PassthroughOperations documents the declaration for the interface artifact. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 41 +++++ gateway_error.go | 12 +- operations.go | 14 +- passthrough.go | 426 ++++++++++++++++++++++++++++++++++++++++++++ passthrough_test.go | 297 ++++++++++++++++++++++++++++++ protowire.go | 26 +++ solution.go | 27 ++- transcode.go | 3 +- 8 files changed, 840 insertions(+), 6 deletions(-) create mode 100644 passthrough.go create mode 100644 passthrough_test.go diff --git a/README.md b/README.md index 798b4af..a2e11d4 100644 --- a/README.md +++ b/README.md @@ -402,6 +402,47 @@ 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//*` 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///`, so the page uses the module's own +generated client (connect-es) unmodified, with the base URL +`/modules/`. 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. + +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/`, a +streaming method, and a method with no declared response fields. `Response` +names the fields returned to the page; `WholeResponse` must be said explicitly. +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. + ### Generated messages in a response A handler's value is encoded with `encoding/json`, which reads a generated diff --git a/gateway_error.go b/gateway_error.go index f087fa3..c5ab365 100644 --- a/gateway_error.go +++ b/gateway_error.go @@ -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) } diff --git a/operations.go b/operations.go index 236b19c..c552dfe 100644 --- a/operations.go +++ b/operations.go @@ -129,7 +129,7 @@ func swaggerDocument(info InterfaceInfo, ops []Operation) (map[string]any, error } parameters := []any{} if op.Request != nil { - parameters = append(parameters, map[string]any{"name": "body", "in": "body", "required": true, "schema": schemaFor(reflect.TypeOf(op.Request), definitions)}) + parameters = append(parameters, map[string]any{"name": "body", "in": "body", "required": true, "schema": valueSchema(op.Request, definitions)}) } for _, query := range op.Query { parameters = append(parameters, map[string]any{"name": query.Name, "in": "query", "required": query.Required, "type": "string"}) @@ -171,7 +171,7 @@ func operationResponses(op Operation, definitions map[string]any) map[string]any return map[string]any{ "200": map[string]any{ "description": "A successful response.", - "schema": schemaFor(reflect.TypeOf(op.Response), definitions), + "schema": valueSchema(op.Response, definitions), }, "401": map[string]any{ "description": "The request carried no bearer token.", @@ -228,6 +228,16 @@ var ( rawMessageType = reflect.TypeOf(json.RawMessage{}) ) +// valueSchema describes a declared request or response value: a message known +// only by its descriptor (a passthrough operation's) from that descriptor, and +// anything else from its type. +func valueSchema(v any, definitions map[string]any) map[string]any { + if described, ok := v.(describedMessage); ok { + return messageSchema(described.md, definitions) + } + return schemaFor(reflect.TypeOf(v), definitions) +} + // schemaFor derives a JSON schema from a response type, registering a // definition for every struct it reaches so a self-referencing shape // terminates in a $ref instead of recursing forever. diff --git a/passthrough.go b/passthrough.go new file mode 100644 index 0000000..4dea6c6 --- /dev/null +++ b/passthrough.go @@ -0,0 +1,426 @@ +package solution + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "sort" + "strings" + "time" + + "connectrpc.com/connect" + "github.com/codefly-dev/core/solution/manifest" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/dynamicpb" +) + +// --- Consumed-module passthrough --- +// +// A solution's page reaches only its own backend: the host proxy forwards the +// browser to /solutions//…, never to a composed module's /v1//* prefix, +// and a module that authenticates by Work Context would refuse the browser's +// bearer anyway. Without this, every module read a page makes needs a handler +// in the solution that decodes the browser's request, mints the viewer's +// capability, calls the module and re-encodes the answer — the same code in +// every solution, once per operation. +// +// Consumes declares, instead, which operations of which consumed module the +// page may call. The runtime serves each one as a Connect unary procedure at +// +// /modules/// +// +// so the module's own generated client (connect-es, connect-go) is used +// unmodified with the base URL `/modules/`. Each call is answered +// by the module, as the viewer: the runtime mints the viewer's Work Context for +// the module (Gateway.ForModule) — or forwards only the bearer, for a module +// that authenticates the viewer itself — and forwards the request over the +// method's google.api.http binding (Gateway.Transcoded). The browser names a +// declared procedure and a request message; it never names a path, a prefix, +// an audience or an authority. Authorization stays where it is: at the module. +// +// Least privilege is the default throughout. A method that is not declared is +// not served. A module whose `as` the solution does not consume is refused at +// boot. A declared method returns only the response fields its mask names, +// unless the declaration explicitly passes the whole response. + +// ConsumedModule declares the operations of one consumed module the solution's +// page may call through the passthrough. +type ConsumedModule struct { + // As is the `as` of the module's api.consumes entry: the gateway prefix + // /v1/ the module is federated under, and the audience a Work Context + // for it is minted for. A module the solution does not consume under this + // name is refused at boot. + As string + // Scopes is the authority minted for the viewer on every call (ForModule), + // no more than the declared operations need. Exactly one of Scopes and + // ViewerBearer is set. + Scopes []Scope + // ViewerBearer forwards only the viewer's bearer: for a module that + // authenticates the viewer and mints its own authority from that bearer, + // so a capability minted here would be one it never reads. + ViewerBearer bool + // Methods is the allowlist. Nothing else of the module is reachable. + Methods []ConsumedMethod +} + +// ConsumedMethod is one operation of a consumed module the page may call. +type ConsumedMethod struct { + // Name is the RPC's full name as its generated code declares it, the + // "/package.Service/Method" constant a gRPC or Connect stub exports. The + // module's generated package must be linked into the solution. + Name string + // Response is the allowlist of response fields returned to the page. A + // field the module adds later is dropped until someone names it here. + Response FieldMask + // WholeResponse passes every response field, and must be said explicitly: + // the zero value of a declaration returns nothing it did not name. + WholeResponse bool + // MaxRequestBytes bounds the request message the page sends; zero is + // DefaultPassthroughRequestLimit. + MaxRequestBytes int + // MaxResponseBytes bounds the module's answer; zero is + // DefaultTranscodedResponseLimit. + MaxResponseBytes int64 + // Timeout bounds one call, the mint included; zero is + // DefaultPassthroughTimeout. + Timeout time.Duration +} + +const ( + // PassthroughPathPrefix is where the passthrough serves: a declared method + // is at PassthroughPathPrefix + + "/" + /. + PassthroughPathPrefix = "/modules/" + // DefaultPassthroughRequestLimit bounds a request message unless the + // method sets its own bound. + DefaultPassthroughRequestLimit = 1 << 20 + // DefaultPassthroughTimeout bounds one call unless the method sets its own. + DefaultPassthroughTimeout = 60 * time.Second + // relayedMessageLimit bounds the module refusal text relayed to the page. + relayedMessageLimit = 1024 +) + +// Consumes declares the consumed-module operations the page may call. It is +// chainable and may be called more than once; Serve refuses a declaration that +// cannot be served before it listens. +func (s *Server) Consumes(modules ...ConsumedModule) *Server { + s.consumed = append(s.consumed, modules...) + return s +} + +// passthroughRoute is one declared method, resolved against its descriptor. +type passthroughRoute struct { + path string + module ConsumedModule + method ConsumedMethod + md protoreflect.MethodDescriptor + verb string + // template is the module binding the call is forwarded over. + template string +} + +// resolvePassthrough checks every declaration against the generated +// descriptors and returns the routes, keyed on path. Every refusal is a +// mistake in the solution's declaration, found before anything is served. +func resolvePassthrough(modules []ConsumedModule) (map[string]passthroughRoute, error) { + routes := map[string]passthroughRoute{} + seen := map[string]bool{} + for _, module := range modules { + prefix := "/v1/" + module.As + switch { + case module.As == "" || strings.ContainsAny(module.As, "/?#{}*:\\") || !cleanPrefix(prefix): + return nil, fmt.Errorf("consumed module %q: as must be one path segment, the `as` of its api.consumes entry", module.As) + case seen[module.As]: + return nil, fmt.Errorf("consumed module %q is declared twice", module.As) + case module.ViewerBearer == (len(module.Scopes) > 0): + return nil, fmt.Errorf("consumed module %q: declare exactly one of Scopes (the authority minted for the viewer) and ViewerBearer (the module authenticates the viewer itself)", module.As) + case len(module.Methods) == 0: + return nil, fmt.Errorf("consumed module %q declares no methods", module.As) + } + seen[module.As] = true + for _, method := range module.Methods { + md, err := methodDescriptor(method.Name) + if err != nil { + return nil, fmt.Errorf("consumed module %q: %w", module.As, err) + } + if md.IsStreamingClient() || md.IsStreamingServer() { + return nil, fmt.Errorf("consumed module %q: %s streams; only unary methods pass through", module.As, md.FullName()) + } + _, verb, template, err := bindingUnder(md, prefix) + if err != nil { + return nil, fmt.Errorf("consumed module %q: %w", module.As, err) + } + switch { + case method.WholeResponse && method.Response.desc != nil: + return nil, fmt.Errorf("consumed module %q: %s declares both a response mask and the whole response", module.As, md.FullName()) + case !method.WholeResponse && method.Response.desc == nil: + return nil, fmt.Errorf("consumed module %q: %s declares no response fields; name them in Response, or pass WholeResponse explicitly", module.As, md.FullName()) + case method.Response.desc != nil && method.Response.desc.FullName() != md.Output().FullName(): + return nil, fmt.Errorf("consumed module %q: %s answers %s, but its response mask is over %s", module.As, md.FullName(), md.Output().FullName(), method.Response.desc.FullName()) + } + path := PassthroughPathPrefix + module.As + "/" + string(md.Parent().FullName()) + "/" + string(md.Name()) + if _, dup := routes[path]; dup { + return nil, fmt.Errorf("consumed module %q: %s is declared twice", module.As, md.FullName()) + } + routes[path] = passthroughRoute{path: path, module: module, method: method, md: md, verb: verb, template: template} + } + } + return routes, nil +} + +// checkConsumed refuses a declaration for a module the solution does not +// consume under that name: the gateway would federate no /v1/ prefix for +// it, so every call would fail at the gateway instead of here. +func checkConsumed(modules []ConsumedModule, consumed []manifest.ConsumedAPI) error { + names := map[string]bool{} + for _, c := range consumed { + names[c.As] = true + } + for _, module := range modules { + if !names[module.As] { + return fmt.Errorf("consumed module %q is declared for the page, but api.consumes lists no module as %q", module.As, module.As) + } + } + return nil +} + +// validatePassthrough is Serve's boot check of the declaration. +func (s *Server) validatePassthrough() (map[string]passthroughRoute, error) { + routes, err := resolvePassthrough(s.consumed) + if err != nil || len(s.consumed) == 0 { + return routes, err + } + consumed, err := manifest.ParseConsumedAPIs(s.cfg.apiConsumes) + if err != nil { + return nil, fmt.Errorf("consumed modules cannot be checked against api.consumes: %w", err) + } + return routes, checkConsumed(s.consumed, consumed) +} + +// passthroughHandler serves every route under PassthroughPathPrefix. Each +// route is a Connect unary handler over the method's own descriptor, so every +// protocol the module's generated clients speak is accepted and every refusal +// is written in the caller's protocol. +func (s *Server) passthroughHandler(routes map[string]passthroughRoute) http.Handler { + mux := http.NewServeMux() + for path, route := range routes { + mux.Handle(path, s.passthroughRoute(route)) + } + errorWriter := connect.NewErrorWriter() + return withCORSHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if _, ok := routes[r.URL.Path]; !ok { + // Not declared, so not served — whatever the module offers. + if errorWriter.IsSupported(r) { + _ = errorWriter.Write(w, r, connect.NewError(connect.CodeNotFound, errors.New("no such operation"))) + return + } + http.NotFound(w, r) + return + } + mux.ServeHTTP(w, r) + })) +} + +func (s *Server) passthroughRoute(route passthroughRoute) http.Handler { + md := route.md + initialize := func(spec connect.Spec, msg any) error { + dynamic, ok := msg.(*dynamicpb.Message) + if !ok { + return fmt.Errorf("passthrough %s: unexpected message %T", md.FullName(), msg) + } + if spec.IsClient { + *dynamic = *dynamicpb.NewMessage(md.Output()) + } else { + *dynamic = *dynamicpb.NewMessage(md.Input()) + } + return nil + } + limit := route.method.MaxRequestBytes + if limit <= 0 { + limit = DefaultPassthroughRequestLimit + } + procedure := "/" + string(md.Parent().FullName()) + "/" + string(md.Name()) + handler := connect.NewUnaryHandler(procedure, func(ctx context.Context, req *connect.Request[dynamicpb.Message]) (*connect.Response[dynamicpb.Message], error) { + resp, err := s.forward(ctx, route, req) + if err != nil { + return nil, err + } + return connect.NewResponse(resp), nil + }, + connect.WithSchema(md), + connect.WithRequestInitializer(initialize), + connect.WithReadMaxBytes(limit), + ) + return http.StripPrefix(PassthroughPathPrefix+route.module.As, handler) +} + +// forward answers one call as the viewer: it mints the declared authority (or +// forwards the bearer alone), calls the module over its binding, and returns +// only the declared response fields. +func (s *Server) forward(ctx context.Context, route passthroughRoute, req *connect.Request[dynamicpb.Message]) (*dynamicpb.Message, error) { + bearer := req.Header().Get("authorization") + if bearer == "" { + return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("missing bearer")) + } + timeout := route.method.Timeout + if timeout <= 0 { + timeout = DefaultPassthroughTimeout + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + gw := newGateway(s.cfg.gatewayURL, bearer, req.Header().Get(orgHeader), req.Header().Get(sessionHeader)) + if !route.module.ViewerBearer { + acting, err := gw.ForModule(ctx, route.module.As, route.module.Scopes...) + if err != nil { + return nil, relayedError(err) + } + gw = acting + } + out := dynamicpb.NewMessage(route.md.Output()) + var opts []TranscodeOption + if route.method.MaxResponseBytes > 0 { + opts = append(opts, MaxResponseBytes(route.method.MaxResponseBytes)) + } + if err := gw.Transcoded(ctx, "/v1/"+route.module.As, route.method.Name, req.Msg, out, opts...); err != nil { + return nil, relayedError(err) + } + if !route.method.WholeResponse { + out = Apply(route.method.Response, out) + } + return out, nil +} + +// relayedError turns a failed call into the Connect error the page receives. +// A module's own refusal keeps its meaning: a google.rpc.Status body keeps its +// code and message exactly, and any other small JSON refusal is relayed as the +// message, verbatim, with the code its HTTP status maps to — it is the module's +// answer to the page's own request. Anything else (a transport failure, an +// undecodable answer) is reported by kind only, never by its internal detail. +func relayedError(err error) error { + var clientErr *ClientError + if errors.As(err, &clientErr) && clientErr != nil { + return connect.NewError(codeForStatus(clientErr.StatusCode), errors.New(clientErr.Message)) + } + var gatewayErr *GatewayError + if errors.As(err, &gatewayErr) && gatewayErr != nil { + var status struct { + Code *int32 `json:"code"` + Message string `json:"message"` + } + detail := gatewayErr.detail + if json.Unmarshal(detail, &status) == nil && status.Code != nil && *status.Code > 0 && *status.Code <= 16 { + return connect.NewError(connect.Code(*status.Code), errors.New(truncated(status.Message))) + } + message := http.StatusText(gatewayErr.StatusCode) + if len(detail) > 0 && len(detail) <= relayedMessageLimit && json.Valid(detail) { + message = string(detail) + } + return connect.NewError(codeForStatus(gatewayErr.StatusCode), errors.New(message)) + } + switch { + case errors.Is(err, context.DeadlineExceeded): + return connect.NewError(connect.CodeDeadlineExceeded, errors.New("the module did not answer in time")) + case errors.Is(err, context.Canceled): + return connect.NewError(connect.CodeCanceled, errors.New("the call was canceled")) + } + return connect.NewError(connect.CodeUnavailable, errors.New("the module call failed")) +} + +func truncated(s string) string { + if len(s) > relayedMessageLimit { + return s[:relayedMessageLimit] + } + return s +} + +// codeForStatus maps a module's HTTP status to the Connect code a generated +// client decodes. +func codeForStatus(status int) connect.Code { + switch status { + case http.StatusBadRequest: + return connect.CodeInvalidArgument + case http.StatusUnauthorized: + return connect.CodeUnauthenticated + case http.StatusForbidden: + return connect.CodePermissionDenied + case http.StatusNotFound, http.StatusGone: + return connect.CodeNotFound + case http.StatusConflict: + return connect.CodeAborted + case http.StatusPreconditionFailed, http.StatusUnprocessableEntity: + return connect.CodeFailedPrecondition + case http.StatusRequestEntityTooLarge, http.StatusTooManyRequests: + return connect.CodeResourceExhausted + case 499: + return connect.CodeCanceled + case http.StatusNotImplemented: + return connect.CodeUnimplemented + case http.StatusGatewayTimeout: + return connect.CodeDeadlineExceeded + case http.StatusInternalServerError: + return connect.CodeInternal + } + if status >= 400 && status < 500 { + return connect.CodeFailedPrecondition + } + return connect.CodeUnavailable +} + +// PassthroughOperations documents the declared methods for the interface +// artifact, in path order, so a solution's committed description of what its +// page may call is rendered from the same declaration the runtime serves. +func PassthroughOperations(modules ...ConsumedModule) ([]Operation, error) { + routes, err := resolvePassthrough(modules) + if err != nil { + return nil, err + } + paths := make([]string, 0, len(routes)) + for path := range routes { + paths = append(paths, path) + } + sort.Strings(paths) + ops := make([]Operation, 0, len(paths)) + for _, path := range paths { + route := routes[path] + callers := fmt.Sprintf("Signed-in viewers. The %s module authorizes every call itself, under a Work Context minted for the viewer (audience %q, %s).", + route.module.As, route.module.As, scopeText(route.module.Scopes)) + if route.module.ViewerBearer { + callers = fmt.Sprintf("Signed-in viewers. The %s module authenticates the viewer's bearer and authorizes every call itself.", route.module.As) + } + behavior := fmt.Sprintf("A Connect unary call, forwarded to the module's %s %s binding. ", route.verb, route.template) + if route.method.WholeResponse { + behavior += "The module's whole response is returned." + } else { + behavior += "Only these response fields are returned: " + strings.Join(route.method.Response.paths(), ", ") + "." + } + ops = append(ops, Operation{ + Path: path, + Method: "post", + Summary: fmt.Sprintf("Call %s on the %s module as the viewer", route.md.FullName(), route.module.As), + Callers: callers, + Behavior: behavior, + Request: describedMessage{route.md.Input()}, + Response: describedMessage{route.md.Output()}, + // Documentation only: InterfaceDocument requires a handler, and the + // passthrough, not Operations, serves these routes. + Handler: func(context.Context, *Gateway) (any, error) { return nil, errors.New("served by the passthrough") }, + }) + } + return ops, nil +} + +func scopeText(scopes []Scope) string { + parts := make([]string, 0, len(scopes)) + for _, scope := range scopes { + parts = append(parts, scope.ResourceKind+":["+strings.Join(scope.Actions, ",")+"]") + } + return strings.Join(parts, " ") +} + +// describedMessage documents a message known only by its descriptor. +type describedMessage struct { + md protoreflect.MessageDescriptor +} + +func (d describedMessage) messageDescriptor() protoreflect.MessageDescriptor { return d.md } diff --git a/passthrough_test.go b/passthrough_test.go new file mode 100644 index 0000000..629b946 --- /dev/null +++ b/passthrough_test.go @@ -0,0 +1,297 @@ +package solution + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/codefly-dev/core/solution/manifest" + codefly "github.com/codefly-dev/sdk-go" +) + +// passthroughModule is the "things" module of transcodeTestFile as a solution +// declares it: Search passes through, with only entry_id and big returned. +func passthroughModule() ConsumedModule { + return ConsumedModule{ + As: "things", + Scopes: []Scope{{ResourceKind: "things", Actions: []string{"read"}}}, + Methods: []ConsumedMethod{{ + Name: "/things.v1.Things/Search", + Response: MustFieldMask(thingMessage("Thing"), "entry_id", "big"), + }}, + } +} + +// moduleGateway stands in for the host gateway: the accounts mint, and the +// module behind /v1/things. +type moduleGateway struct { + *httptest.Server + status int + reply string + + mu sync.Mutex + mints []mintRequest + calls []*http.Request + bodys []string +} + +func newModuleGateway(t *testing.T, status int, reply string) *moduleGateway { + t.Helper() + g := &moduleGateway{status: status, reply: reply} + g.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + g.mu.Lock() + defer g.mu.Unlock() + body, _ := io.ReadAll(r.Body) + if r.URL.Path == workContextStartTaskProcedure { + var mint mintRequest + _ = json.Unmarshal(body, &mint) + mint.Bearer = r.Header.Get("authorization") + g.mints = append(g.mints, mint) + writeJSON(w, http.StatusOK, map[string]any{ + "token": "context-" + mint.Audience + ".1", "orgId": mint.OrgID, + "ownerPrincipalId": "viewer", "currentActorPrincipalId": "viewer", + "expiresAt": time.Now().Add(5 * time.Minute).UTC().Format(time.RFC3339Nano), + }) + return + } + g.calls = append(g.calls, r.Clone(r.Context())) + g.bodys = append(g.bodys, string(body)) + w.WriteHeader(g.status) + _, _ = io.WriteString(w, g.reply) + })) + t.Cleanup(g.Close) + return g +} + +// call sends one Connect unary JSON request, as connect-es does, to the +// solution's passthrough and returns the status and decoded body. +func call(t *testing.T, s *Server, path, bearer, body string) (int, map[string]any) { + t.Helper() + routes, err := resolvePassthrough(s.consumed) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)) + req.Header.Set("content-type", "application/json") + req.Header.Set("connect-protocol-version", "1") + if bearer != "" { + req.Header.Set("authorization", bearer) + } + req.Header.Set(orgHeader, "org-1") + req.Header.Set(sessionHeader, "session-1") + rec := httptest.NewRecorder() + s.passthroughHandler(routes).ServeHTTP(rec, req) + var out map[string]any + if rec.Body.Len() > 0 { + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("status %d, body %q: %v", rec.Code, rec.Body.String(), err) + } + } + return rec.Code, out +} + +func passthroughServer(gatewayURL string, modules ...ConsumedModule) *Server { + s := New(Manifest{ID: "test"}).Consumes(modules...) + s.cfg.gatewayURL = gatewayURL + return s +} + +const searchPath = "/modules/things/things.v1.Things/Search" + +func TestPassthroughAnswersADeclaredMethodAsTheViewer(t *testing.T) { + gw := newModuleGateway(t, http.StatusOK, `{"entryId":"e1","big":"7","secret":"s3cret","sub":{"name":"x"}}`) + s := passthroughServer(gw.URL, passthroughModule()) + + status, body := call(t, s, searchPath, "Bearer viewer", `{"entryId":"e1","pageSize":3}`) + if status != http.StatusOK { + t.Fatalf("status %d: %v", status, body) + } + // Only the declared response fields reach the page. + if body["entryId"] != "e1" || body["big"] != "7" || body["secret"] != nil || body["sub"] != nil { + t.Fatalf("response = %v, want only entryId and big", body) + } + // The module was called over its binding, with the viewer's capability. + if len(gw.calls) != 1 || gw.calls[0].Method != http.MethodPost || gw.calls[0].URL.Path != "/v1/things/search" { + t.Fatalf("module calls = %v", gw.calls) + } + var sent map[string]any + if err := json.Unmarshal([]byte(gw.bodys[0]), &sent); err != nil || sent["entryId"] != "e1" || sent["pageSize"] != float64(3) { + t.Fatalf("module received %q", gw.bodys[0]) + } + if got := gw.calls[0].Header.Get(codefly.WorkContextHeaderName); got != "context-things.1" { + t.Fatalf("work context = %q, want the one minted for the module", got) + } + if gw.calls[0].Header.Get("authorization") != "Bearer viewer" { + t.Fatal("the viewer's bearer was not forwarded") + } + if len(gw.mints) != 1 || gw.mints[0].Audience != "things" || gw.mints[0].OrgID != "org-1" || gw.mints[0].SessionID != "session-1" || + len(gw.mints[0].AuthorityScopes) != 1 || gw.mints[0].AuthorityScopes[0].ResourceKind != "things" { + t.Fatalf("mints = %+v, want one for the declared audience and scope, in the viewer's org and session", gw.mints) + } +} + +func TestPassthroughForwardsOnlyTheBearerToAModuleThatAuthenticatesTheViewer(t *testing.T) { + gw := newModuleGateway(t, http.StatusOK, `{"entryId":"e1"}`) + module := passthroughModule() + module.Scopes, module.ViewerBearer = nil, true + s := passthroughServer(gw.URL, module) + + if status, body := call(t, s, searchPath, "Bearer viewer", `{"entryId":"e1"}`); status != http.StatusOK { + t.Fatalf("status %d: %v", status, body) + } + if len(gw.mints) != 0 { + t.Fatalf("minted %d capabilities for a module that reads the bearer", len(gw.mints)) + } + if gw.calls[0].Header.Get(codefly.WorkContextHeaderName) != "" || gw.calls[0].Header.Get("authorization") != "Bearer viewer" { + t.Fatal("want the viewer's bearer and no capability") + } +} + +func TestPassthroughServesOnlyWhatIsDeclared(t *testing.T) { + gw := newModuleGateway(t, http.StatusOK, `{}`) + s := passthroughServer(gw.URL, passthroughModule()) + for _, path := range []string{ + "/modules/things/things.v1.Things/Get", // the module's, but not declared + "/modules/other/things.v1.Things/Search", // not a consumed module + "/modules/things/../things/x", // not a procedure + "/modules/things/things.v1.Things/Search/", // not the procedure + } { + status, body := call(t, s, path, "Bearer viewer", `{}`) + if status != http.StatusNotFound || body["code"] != "not_found" { + t.Errorf("%s: status %d %v, want not_found", path, status, body) + } + } + if len(gw.calls) != 0 || len(gw.mints) != 0 { + t.Fatalf("an undeclared call reached the gateway: %d calls, %d mints", len(gw.calls), len(gw.mints)) + } +} + +func TestPassthroughRequiresTheViewersBearer(t *testing.T) { + gw := newModuleGateway(t, http.StatusOK, `{}`) + s := passthroughServer(gw.URL, passthroughModule()) + status, body := call(t, s, searchPath, "", `{}`) + if status != http.StatusUnauthorized || body["code"] != "unauthenticated" { + t.Fatalf("status %d %v", status, body) + } + if len(gw.calls)+len(gw.mints) != 0 { + t.Fatal("an anonymous call reached the gateway") + } +} + +func TestPassthroughForwardsOnlyTheMethodsOwnFields(t *testing.T) { + gw := newModuleGateway(t, http.StatusOK, `{}`) + s := passthroughServer(gw.URL, passthroughModule()) + // A field the method's request does not declare is not the page's to add: + // the request is re-encoded from the method's message, so it never reaches + // the module, and it can name no path or prefix of its own. + status, body := call(t, s, searchPath, "Bearer viewer", `{"entryId":"e1","path":"/v1/other","prefix":"/v1/admin"}`) + if status != http.StatusOK { + t.Fatalf("status %d %v", status, body) + } + if gw.calls[0].URL.Path != "/v1/things/search" || strings.Contains(gw.bodys[0], "other") || strings.Contains(gw.bodys[0], "admin") { + t.Fatalf("module received %s %q", gw.calls[0].URL, gw.bodys[0]) + } +} + +func TestPassthroughRelaysTheModulesRefusal(t *testing.T) { + for _, tc := range []struct { + name, reply string + status int + wantStatus int + wantCode string + wantMessage string + }{ + {"a google.rpc.Status keeps its code and message", `{"code":5,"message":"document not found"}`, http.StatusNotFound, http.StatusNotFound, "not_found", "document not found"}, + {"another JSON refusal is relayed verbatim", `{"failure":{"code":"HISTORY_UNAVAILABLE","admission":"unknown"}}`, http.StatusConflict, http.StatusConflict, "aborted", `{"failure":{"code":"HISTORY_UNAVAILABLE","admission":"unknown"}}`}, + {"a non-JSON refusal says only its status", `upstream connect error: 10.0.0.7:8080 refused`, http.StatusBadGateway, http.StatusServiceUnavailable, "unavailable", "Bad Gateway"}, + } { + t.Run(tc.name, func(t *testing.T) { + gw := newModuleGateway(t, tc.status, tc.reply) + s := passthroughServer(gw.URL, passthroughModule()) + status, body := call(t, s, searchPath, "Bearer viewer", `{}`) + if status != tc.wantStatus || body["code"] != tc.wantCode || body["message"] != tc.wantMessage { + t.Fatalf("status %d %v, want %d %s %q", status, body, tc.wantStatus, tc.wantCode, tc.wantMessage) + } + }) + } +} + +func TestPassthroughRefusesADeclarationItCannotServe(t *testing.T) { + search := func(m ConsumedMethod) ConsumedModule { + module := passthroughModule() + module.Methods = []ConsumedMethod{m} + return module + } + mask := MustFieldMask(thingMessage("Thing"), "entry_id") + for name, modules := range map[string][]ConsumedModule{ + "no as": {{Scopes: passthroughModule().Scopes, Methods: passthroughModule().Methods}}, + "an as with a path": {{As: "things/v2", Scopes: passthroughModule().Scopes, Methods: passthroughModule().Methods}}, + "neither scopes nor bearer": {{As: "things", Methods: passthroughModule().Methods}}, + "both scopes and bearer": {{As: "things", ViewerBearer: true, Scopes: passthroughModule().Scopes, Methods: passthroughModule().Methods}}, + "no methods": {{As: "things", ViewerBearer: true}}, + "a method not in the registry": {search(ConsumedMethod{Name: "/things.v1.Things/Nope", Response: mask})}, + "a method with no binding": {search(ConsumedMethod{Name: "/things.v1.Things/Unbound", Response: mask})}, + "no response fields": {search(ConsumedMethod{Name: "/things.v1.Things/Search"})}, + "a mask and the whole response": {search(ConsumedMethod{Name: "/things.v1.Things/Search", Response: mask, WholeResponse: true})}, + "a mask over another message": {search(ConsumedMethod{Name: "/things.v1.Things/Search", Response: MustFieldMask(thingMessage("Sub"), "name")})}, + "a method twice": {{As: "things", ViewerBearer: true, Methods: []ConsumedMethod{passthroughModule().Methods[0], passthroughModule().Methods[0]}}}, + "a module twice": {passthroughModule(), passthroughModule()}, + } { + if _, err := resolvePassthrough(modules); err == nil { + t.Errorf("%s: accepted", name) + } + } + // A binding under another prefix is not a binding under the declared one. + module := passthroughModule() + module.As = "other" + if _, err := resolvePassthrough([]ConsumedModule{module}); err == nil || !strings.Contains(err.Error(), "no google.api.http binding under /v1/other") { + t.Errorf("a module whose methods have no binding under its prefix: %v", err) + } +} + +func TestPassthroughRefusesAModuleTheSolutionDoesNotConsume(t *testing.T) { + modules := []ConsumedModule{passthroughModule()} + if err := checkConsumed(modules, []manifest.ConsumedAPI{{ID: "things", Module: "things", As: "things"}}); err != nil { + t.Fatalf("a consumed module was refused: %v", err) + } + if err := checkConsumed(modules, []manifest.ConsumedAPI{{ID: "things", Module: "things", As: "stuff"}}); err == nil { + t.Fatal("a module consumed under another name was accepted") + } + if err := checkConsumed(modules, nil); err == nil { + t.Fatal("a module the solution does not consume was accepted") + } +} + +func TestPassthroughOperationsDocumentTheDeclaration(t *testing.T) { + ops, err := PassthroughOperations(passthroughModule()) + if err != nil { + t.Fatal(err) + } + doc, err := InterfaceDocument(InterfaceInfo{Title: "t", Version: "0.0.1", Description: "d", BasePath: "/solutions/test", OperationPrefix: "Test", Tag: "test"}, ops) + if err != nil { + t.Fatal(err) + } + var parsed struct { + Paths map[string]map[string]map[string]any `json:"paths"` + Definitions map[string]any `json:"definitions"` + } + if err := json.Unmarshal(doc, &parsed); err != nil { + t.Fatal(err) + } + op := parsed.Paths[searchPath]["post"] + if op == nil { + t.Fatalf("paths = %v", parsed.Paths) + } + description, _ := op["description"].(string) + if !strings.Contains(description, "POST /v1/things/search") || !strings.Contains(description, "big, entry_id") || !strings.Contains(description, "things:[read]") { + t.Fatalf("description = %q", description) + } + if parsed.Definitions["ThingsV1Thing"] == nil || parsed.Definitions["ThingsV1GetThingRequest"] == nil { + t.Fatalf("definitions = %v", parsed.Definitions) + } +} diff --git a/protowire.go b/protowire.go index f487705..c04095a 100644 --- a/protowire.go +++ b/protowire.go @@ -3,6 +3,7 @@ package solution import ( "encoding/json" "fmt" + "sort" "strings" "google.golang.org/protobuf/encoding/protojson" @@ -148,3 +149,28 @@ func prune(msg protoreflect.Message, node *maskNode) { msg.Clear(fd) } } + +// paths lists the allowed field paths, sorted: each leaf of the tree, the way +// the declaration named it. Documentation reads it; Apply does not. +func (mask FieldMask) paths() []string { + var out []string + var walk func(prefix string, node *maskNode) + walk = func(prefix string, node *maskNode) { + if node.all { + out = append(out, prefix) + return + } + for name, child := range node.children { + path := string(name) + if prefix != "" { + path = prefix + "." + path + } + walk(path, child) + } + } + if mask.root != nil { + walk("", mask.root) + } + sort.Strings(out) + return out +} diff --git a/solution.go b/solution.go index 533ad7c..fa1f1fd 100644 --- a/solution.go +++ b/solution.go @@ -218,7 +218,11 @@ func (e *ClientError) Error() string { return e.Message } type Server struct { manifest Manifest handlers map[string]RequestHandler - cfg config + // consumed is the passthrough declaration (Consumes); passthrough is its + // resolved routes, checked by Serve before it listens. + consumed []ConsumedModule + passthrough map[string]passthroughRoute + cfg config // registrationInterval is how long a registration heartbeat waits between // beats. Zero means defaultRegistrationInterval. Per-server rather than a // package value so a test can drive several beats without every other @@ -259,6 +263,9 @@ type config struct { // so validate() must say so rather than report each empty value as // something the composition forgot to provision. environmentLoadErr error + // apiConsumes is the api.consumes projection Codefly injected, which the + // passthrough declaration is checked against before the boot listens. + apiConsumes string } func env(key, fallback string) string { @@ -465,6 +472,7 @@ func loadConfig(ctx context.Context, id string) config { solutionSecret: solutionRegistrationSecret(ctx), moduleSecrets: parseModuleRegistrationSecrets(env(ModuleRegistrationSecretsEnvironmentVariable, "")), runtimeContext: strings.TrimSpace(env(resources.RuntimeContextPrefix, "")), + apiConsumes: env(manifest.APIConsumesEnvironmentVariable, ""), } cfg.registrationInterval, cfg.registrationIntervalErr = registrationIntervalFromEnv() return cfg @@ -758,6 +766,13 @@ func (s *Server) Serve() error { if err := s.cfg.validate(); err != nil { return fmt.Errorf("solution %q: %w", s.manifest.ID, err) } + // After the environment is loaded: the declaration is checked against the + // api.consumes projection Codefly injected. + routes, err := s.validatePassthrough() + if err != nil { + return fmt.Errorf("solution %q: %w", s.manifest.ID, err) + } + s.passthrough = routes ln, err := net.Listen("tcp", ":"+s.cfg.port) if err != nil { return err @@ -778,6 +793,16 @@ func (s *Server) serve(ctx context.Context, ln net.Listener) error { mux.HandleFunc(path, withCORS(s.wrapRequest(handler))) } mux.Handle("/assets/", http.StripPrefix("/assets/", withCORSHandler(s.assetsHandler()))) + if len(s.consumed) > 0 { + routes := s.passthrough + if routes == nil { + var err error + if routes, err = resolvePassthrough(s.consumed); err != nil { + return err + } + } + mux.Handle(PassthroughPathPrefix, s.passthroughHandler(routes)) + } manifestBody, _ := json.Marshal(s.manifestMap()) upstreamBody, _ := json.Marshal(map[string]string{"id": s.manifest.ID, "upstream": s.cfg.selfUpstream}) diff --git a/transcode.go b/transcode.go index e0b1d69..8886a9e 100644 --- a/transcode.go +++ b/transcode.go @@ -91,7 +91,8 @@ func (g *Gateway) Transcoded(ctx context.Context, prefix, method string, req, re } defer drainAndClose(httpResp) if httpResp.StatusCode < 200 || httpResp.StatusCode > 299 { - return fmt.Errorf("%s %s: %w", call.verb, call.template, &GatewayError{StatusCode: httpResp.StatusCode}) + detail, _ := io.ReadAll(io.LimitReader(httpResp.Body, gatewayErrorDetailLimit)) + return fmt.Errorf("%s %s: %w", call.verb, call.template, &GatewayError{StatusCode: httpResp.StatusCode, detail: detail}) } raw, err := io.ReadAll(io.LimitReader(httpResp.Body, options.maxResponseBytes+1)) if err != nil { From 2c2a2f460ed4dc626d5c278dbce64b6ac4c42f43 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:37:15 -0400 Subject: [PATCH 5/7] feat: pin part of a passthrough request server-side A solution sometimes fixes part of what its page may ask a module: a filter clause selecting the collection an installation serves, say. Without a way to say so in the declaration, that is a relay handler again. ConsumedMethod.Pin is merged into every request before it is forwarded (proto.Merge), so a pinned scalar replaces the page's value and a pinned repeated value is added to the page's: a clause the module ANDs stays in force whatever the page sends. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 ++++ passthrough.go | 27 +++++++++++++++++++++++++++ passthrough_test.go | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 65 insertions(+) diff --git a/README.md b/README.md index a2e11d4..6477a8b 100644 --- a/README.md +++ b/README.md @@ -437,6 +437,10 @@ 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/`, a streaming method, and a method with no declared response fields. `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 diff --git a/passthrough.go b/passthrough.go index 4dea6c6..40226f2 100644 --- a/passthrough.go +++ b/passthrough.go @@ -12,6 +12,8 @@ import ( "connectrpc.com/connect" "github.com/codefly-dev/core/solution/manifest" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/types/dynamicpb" ) @@ -77,6 +79,13 @@ type ConsumedMethod struct { // WholeResponse passes every response field, and must be said explicitly: // the zero value of a declaration returns nothing it did not name. WholeResponse bool + // Pin, when set, is merged into every request the page sends before it is + // forwarded (proto.Merge): a scalar it sets replaces the page's value, a + // message merges, and a repeated field is appended. It is how a solution + // fixes part of a request server-side — a filter clause the module ANDs + // with the page's own, say — so the page can narrow the call but not undo + // the pin. It must be the method's request type. + Pin proto.Message // MaxRequestBytes bounds the request message the page sends; zero is // DefaultPassthroughRequestLimit. MaxRequestBytes int @@ -159,6 +168,9 @@ func resolvePassthrough(modules []ConsumedModule) (map[string]passthroughRoute, case method.Response.desc != nil && method.Response.desc.FullName() != md.Output().FullName(): return nil, fmt.Errorf("consumed module %q: %s answers %s, but its response mask is over %s", module.As, md.FullName(), md.Output().FullName(), method.Response.desc.FullName()) } + if method.Pin != nil && method.Pin.ProtoReflect().Descriptor().FullName() != md.Input().FullName() { + return nil, fmt.Errorf("consumed module %q: %s reads %s, but its pin is a %s", module.As, md.FullName(), md.Input().FullName(), method.Pin.ProtoReflect().Descriptor().FullName()) + } path := PassthroughPathPrefix + module.As + "/" + string(md.Parent().FullName()) + "/" + string(md.Name()) if _, dup := routes[path]; dup { return nil, fmt.Errorf("consumed module %q: %s is declared twice", module.As, md.FullName()) @@ -277,6 +289,17 @@ func (s *Server) forward(ctx context.Context, route passthroughRoute, req *conne } gw = acting } + if route.method.Pin != nil { + // Merged through the wire form: the pin is the generated type, the + // request a dynamic message over the same descriptor. + pinned, err := proto.Marshal(route.method.Pin) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, errors.New("the declared pin cannot be encoded")) + } + if err := (proto.UnmarshalOptions{Merge: true}).Unmarshal(pinned, req.Msg); err != nil { + return nil, connect.NewError(connect.CodeInternal, errors.New("the declared pin cannot be applied")) + } + } out := dynamicpb.NewMessage(route.md.Output()) var opts []TranscodeOption if route.method.MaxResponseBytes > 0 { @@ -389,6 +412,10 @@ func PassthroughOperations(modules ...ConsumedModule) ([]Operation, error) { callers = fmt.Sprintf("Signed-in viewers. The %s module authenticates the viewer's bearer and authorizes every call itself.", route.module.As) } behavior := fmt.Sprintf("A Connect unary call, forwarded to the module's %s %s binding. ", route.verb, route.template) + if route.method.Pin != nil { + pin, _ := protojson.Marshal(route.method.Pin) + behavior += fmt.Sprintf("Every request is merged with the declared pin %s first. ", pin) + } if route.method.WholeResponse { behavior += "The module's whole response is returned." } else { diff --git a/passthrough_test.go b/passthrough_test.go index 629b946..4ffb66f 100644 --- a/passthrough_test.go +++ b/passthrough_test.go @@ -12,6 +12,7 @@ import ( "github.com/codefly-dev/core/solution/manifest" codefly "github.com/codefly-dev/sdk-go" + "google.golang.org/protobuf/reflect/protoreflect" ) // passthroughModule is the "things" module of transcodeTestFile as a solution @@ -295,3 +296,36 @@ func TestPassthroughOperationsDocumentTheDeclaration(t *testing.T) { t.Fatalf("definitions = %v", parsed.Definitions) } } + +func TestPassthroughMergesTheDeclaredPinIntoEveryRequest(t *testing.T) { + gw := newModuleGateway(t, http.StatusOK, `{}`) + pin := thingMessage("GetThingRequest") + setField(pin, "tenant", protoreflect.ValueOfString("pinned-tenant")) + ids := pin.NewField(pin.Descriptor().Fields().ByName("ids")) + ids.List().Append(protoreflect.ValueOfString("pinned-id")) + pin.Set(pin.Descriptor().Fields().ByName("ids"), ids) + module := passthroughModule() + module.Methods[0].Pin = pin + s := passthroughServer(gw.URL, module) + + if status, body := call(t, s, searchPath, "Bearer viewer", `{"tenant":"page-tenant","ids":["page-id"]}`); status != http.StatusOK { + t.Fatalf("status %d %v", status, body) + } + var sent struct { + Tenant string `json:"tenant"` + IDs []string `json:"ids"` + } + if err := json.Unmarshal([]byte(gw.bodys[0]), &sent); err != nil { + t.Fatal(err) + } + // A pinned scalar replaces the page's; a pinned repeated value is added to + // the page's, so a module that ANDs them keeps the pin whatever the page sends. + if sent.Tenant != "pinned-tenant" || len(sent.IDs) != 2 || sent.IDs[0] != "page-id" || sent.IDs[1] != "pinned-id" { + t.Fatalf("module received %q", gw.bodys[0]) + } + + module.Methods[0].Pin = thingMessage("Thing") + if _, err := resolvePassthrough([]ConsumedModule{module}); err == nil { + t.Fatal("a pin of another type was accepted") + } +} From 8ea2cca6eeeb0aad36faf2d1e91168f7b32dc7d0 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:35:31 -0400 Subject: [PATCH 6/7] feat: render a backend's interface artifact from its declaration and manifest version Every solution rendered its interface artifact the same way: read the version from service.codefly.yaml, then render its operations. A solution whose page calls consumed modules also added the passthrough declaration. InterfaceArtifact does this in one call, and ServiceVersion reads the manifest's version, so the artifact carries the version the platform ships. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 4 +++- go.mod | 2 +- passthrough.go | 42 ++++++++++++++++++++++++++++++++++++++++++ passthrough_test.go | 29 +++++++++++++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 6477a8b..a973f69 100644 --- a/README.md +++ b/README.md @@ -445,7 +445,9 @@ 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. +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 diff --git a/go.mod b/go.mod index 78c871c..59793e7 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( 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 ( @@ -50,5 +51,4 @@ require ( golang.org/x/text v0.42.0 // 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 ) diff --git a/passthrough.go b/passthrough.go index 40226f2..b60f27a 100644 --- a/passthrough.go +++ b/passthrough.go @@ -6,6 +6,8 @@ import ( "errors" "fmt" "net/http" + "os" + "path/filepath" "sort" "strings" "time" @@ -16,6 +18,7 @@ import ( "google.golang.org/protobuf/proto" "google.golang.org/protobuf/reflect/protoreflect" "google.golang.org/protobuf/types/dynamicpb" + "gopkg.in/yaml.v3" ) // --- Consumed-module passthrough --- @@ -451,3 +454,42 @@ type describedMessage struct { } func (d describedMessage) messageDescriptor() protoreflect.MessageDescriptor { return d.md } + +// InterfaceArtifact renders the interface artifact of the solution backend +// rooted at dir: its own operations and the consumed-module operations its +// page may call, at the version its service.codefly.yaml declares. The +// artifact carries the version the platform ships, never a second number kept +// in step by hand. An empty info.Version is read from the manifest. +func InterfaceArtifact(dir string, info InterfaceInfo, ops []Operation, modules ...ConsumedModule) ([]byte, error) { + if info.Version == "" { + version, err := ServiceVersion(dir) + if err != nil { + return nil, err + } + info.Version = version + } + consumed, err := PassthroughOperations(modules...) + if err != nil { + return nil, err + } + return InterfaceDocument(info, append(append([]Operation{}, ops...), consumed...)) +} + +// ServiceVersion is the version the service manifest (service.codefly.yaml) +// in dir declares. +func ServiceVersion(dir string) (string, error) { + data, err := os.ReadFile(filepath.Join(dir, "service.codefly.yaml")) + if err != nil { + return "", err + } + var svc struct { + Version string `yaml:"version"` + } + if err := yaml.Unmarshal(data, &svc); err != nil { + return "", fmt.Errorf("service.codefly.yaml: %w", err) + } + if svc.Version == "" { + return "", fmt.Errorf("service.codefly.yaml declares no version") + } + return svc.Version, nil +} diff --git a/passthrough_test.go b/passthrough_test.go index 4ffb66f..f23abc6 100644 --- a/passthrough_test.go +++ b/passthrough_test.go @@ -1,10 +1,13 @@ package solution import ( + "context" "encoding/json" "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "sync" "testing" @@ -329,3 +332,29 @@ func TestPassthroughMergesTheDeclaredPinIntoEveryRequest(t *testing.T) { t.Fatal("a pin of another type was accepted") } } + +func TestInterfaceArtifactCarriesTheManifestVersionAndBothSurfaces(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "service.codefly.yaml"), []byte("kind: service\nname: backend\nversion: 1.2.3\n"), 0o644); err != nil { + t.Fatal(err) + } + own := Operation{Path: "/own", Summary: "s", Callers: "c", Behavior: "b", Response: map[string]string{}, + Handler: func(context.Context, *Gateway) (any, error) { return nil, nil }} + doc, err := InterfaceArtifact(dir, InterfaceInfo{Title: "t", BasePath: "/solutions/test", OperationPrefix: "Test", Tag: "test"}, []Operation{own}, passthroughModule()) + if err != nil { + t.Fatal(err) + } + var parsed struct { + Info struct{ Version string } `json:"info"` + Paths map[string]any `json:"paths"` + } + if err := json.Unmarshal(doc, &parsed); err != nil { + t.Fatal(err) + } + if parsed.Info.Version != "1.2.3" || parsed.Paths["/own"] == nil || parsed.Paths[searchPath] == nil { + t.Fatalf("artifact: version %q, paths %v", parsed.Info.Version, parsed.Paths) + } + if _, err := InterfaceArtifact(t.TempDir(), InterfaceInfo{}, nil); err == nil { + t.Fatal("a backend with no service manifest rendered a version") + } +} From ca2fac13a7519e96bb3e2b7812d5df57928a9d9d Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:53:03 -0400 Subject: [PATCH 7/7] feat: mint the viewer's authority per consumed method, not per module A consumed method may declare its own Scopes, which replace the module's for a call to that method. One module's methods that need different authority are declared under one `as`, and each call mints only its own method's scopes, so a viewer who lacks one method's authority is refused that method as permission_denied (with the issuer's reason, naming the permission) and is still served the others. Declarations that could never mint are refused at boot. A mint refusal is now a typed WorkContextRefusal that still unwraps to the GatewayError it was. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 26 +++++- passthrough.go | 83 ++++++++++++++++-- passthrough_scopes_test.go | 174 +++++++++++++++++++++++++++++++++++++ passthrough_test.go | 14 +++ solution.go | 24 ++++- 5 files changed, 312 insertions(+), 9 deletions(-) create mode 100644 passthrough_scopes_test.go diff --git a/README.md b/README.md index a973f69..ca4fc5e 100644 --- a/README.md +++ b/README.md @@ -432,10 +432,34 @@ 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/`, a -streaming method, and a method with no declared response fields. `Response` +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 diff --git a/passthrough.go b/passthrough.go index b60f27a..0bb2889 100644 --- a/passthrough.go +++ b/passthrough.go @@ -58,9 +58,10 @@ type ConsumedModule struct { // for it is minted for. A module the solution does not consume under this // name is refused at boot. As string - // Scopes is the authority minted for the viewer on every call (ForModule), - // no more than the declared operations need. Exactly one of Scopes and - // ViewerBearer is set. + // Scopes is the authority minted for the viewer on every call (ForModule) + // of a method that declares none of its own, no more than those methods + // need. A module that is not ViewerBearer sets Scopes, or sets Scopes on + // every one of its methods. Scopes []Scope // ViewerBearer forwards only the viewer's bearer: for a module that // authenticates the viewer and mints its own authority from that bearer, @@ -76,6 +77,14 @@ type ConsumedMethod struct { // "/package.Service/Method" constant a gRPC or Connect stub exports. The // module's generated package must be linked into the solution. Name string + // Scopes, when set, is the authority minted for the viewer on a call to + // this method, instead of the module's Scopes — never in addition to them. + // It is how one module's methods that need different authority are + // declared under one `as`: each call mints only what its own method needs, + // so a viewer who lacks one method's authority is refused that method + // (permission_denied) and is still served every other. Not allowed on a + // ViewerBearer module, which mints nothing. + Scopes []Scope // Response is the allowlist of response fields returned to the page. A // field the module adds later is dropped until someone names it here. Response FieldMask @@ -145,11 +154,14 @@ func resolvePassthrough(modules []ConsumedModule) (map[string]passthroughRoute, return nil, fmt.Errorf("consumed module %q: as must be one path segment, the `as` of its api.consumes entry", module.As) case seen[module.As]: return nil, fmt.Errorf("consumed module %q is declared twice", module.As) - case module.ViewerBearer == (len(module.Scopes) > 0): + case module.ViewerBearer && len(module.Scopes) > 0: return nil, fmt.Errorf("consumed module %q: declare exactly one of Scopes (the authority minted for the viewer) and ViewerBearer (the module authenticates the viewer itself)", module.As) case len(module.Methods) == 0: return nil, fmt.Errorf("consumed module %q declares no methods", module.As) } + if err := checkScopes(module.Scopes); err != nil { + return nil, fmt.Errorf("consumed module %q: %w", module.As, err) + } seen[module.As] = true for _, method := range module.Methods { md, err := methodDescriptor(method.Name) @@ -171,6 +183,15 @@ func resolvePassthrough(modules []ConsumedModule) (map[string]passthroughRoute, case method.Response.desc != nil && method.Response.desc.FullName() != md.Output().FullName(): return nil, fmt.Errorf("consumed module %q: %s answers %s, but its response mask is over %s", module.As, md.FullName(), md.Output().FullName(), method.Response.desc.FullName()) } + switch { + case module.ViewerBearer && len(method.Scopes) > 0: + return nil, fmt.Errorf("consumed module %q: %s declares Scopes, but the module is ViewerBearer: it mints nothing, so the scopes would never be asked for", module.As, md.FullName()) + case !module.ViewerBearer && len(module.Scopes) == 0 && len(method.Scopes) == 0: + return nil, fmt.Errorf("consumed module %q: %s declares no Scopes and the module declares none either; name the authority minted for the viewer, or declare the module ViewerBearer", module.As, md.FullName()) + } + if err := checkScopes(method.Scopes); err != nil { + return nil, fmt.Errorf("consumed module %q: %s: %w", module.As, md.FullName(), err) + } if method.Pin != nil && method.Pin.ProtoReflect().Descriptor().FullName() != md.Input().FullName() { return nil, fmt.Errorf("consumed module %q: %s reads %s, but its pin is a %s", module.As, md.FullName(), md.Input().FullName(), method.Pin.ProtoReflect().Descriptor().FullName()) } @@ -184,6 +205,39 @@ func resolvePassthrough(modules []ConsumedModule) (map[string]passthroughRoute, return routes, nil } +// checkScopes refuses a scope that could not name an authority: one with no +// resource kind, no action, or a blank or duplicated action. +func checkScopes(scopes []Scope) error { + for _, scope := range scopes { + if strings.TrimSpace(scope.ResourceKind) == "" || scope.ResourceKind != strings.TrimSpace(scope.ResourceKind) { + return fmt.Errorf("a scope names no resource kind (or pads it with spaces)") + } + if len(scope.Actions) == 0 { + return fmt.Errorf("scope %q names no action", scope.ResourceKind) + } + seen := map[string]bool{} + for _, action := range scope.Actions { + if strings.TrimSpace(action) == "" || action != strings.TrimSpace(action) { + return fmt.Errorf("scope %q names a blank action (or pads one with spaces)", scope.ResourceKind) + } + if seen[action] { + return fmt.Errorf("scope %q names the action %q twice", scope.ResourceKind, action) + } + seen[action] = true + } + } + return nil +} + +// scopes is the authority a call to this route mints for the viewer: the +// method's own, else the module's. Never the union. +func (r passthroughRoute) scopes() []Scope { + if len(r.method.Scopes) > 0 { + return r.method.Scopes + } + return r.module.Scopes +} + // checkConsumed refuses a declaration for a module the solution does not // consume under that name: the gateway would federate no /v1/ prefix for // it, so every call would fail at the gateway instead of here. @@ -286,7 +340,7 @@ func (s *Server) forward(ctx context.Context, route passthroughRoute, req *conne defer cancel() gw := newGateway(s.cfg.gatewayURL, bearer, req.Header().Get(orgHeader), req.Header().Get(sessionHeader)) if !route.module.ViewerBearer { - acting, err := gw.ForModule(ctx, route.module.As, route.module.Scopes...) + acting, err := gw.ForModule(ctx, route.module.As, route.scopes()...) if err != nil { return nil, relayedError(err) } @@ -324,6 +378,23 @@ func (s *Server) forward(ctx context.Context, route passthroughRoute, req *conne // answer to the page's own request. Anything else (a transport failure, an // undecodable answer) is reported by kind only, never by its internal detail. func relayedError(err error) error { + var refusal *WorkContextRefusal + if errors.As(err, &refusal) && refusal != nil && refusal.StatusCode >= 400 && refusal.StatusCode < 500 { + // The issuer refused the viewer's authority for this call: say so, in + // its own code and words (they name the missing permission), rather + // than as a failed module call. It is the viewer's own authorization, + // so nothing of another principal is disclosed. + code := codeForStatus(refusal.StatusCode) + var parsed connect.Code + if refusal.Code != "" && parsed.UnmarshalText([]byte(refusal.Code)) == nil && parsed != connect.CodeUnknown { + code = parsed + } + message := "the viewer's authority for the " + refusal.Audience + " module was refused" + if refusal.Message != "" { + message += ": " + truncated(refusal.Message) + } + return connect.NewError(code, errors.New(message)) + } var clientErr *ClientError if errors.As(err, &clientErr) && clientErr != nil { return connect.NewError(codeForStatus(clientErr.StatusCode), errors.New(clientErr.Message)) @@ -410,7 +481,7 @@ func PassthroughOperations(modules ...ConsumedModule) ([]Operation, error) { for _, path := range paths { route := routes[path] callers := fmt.Sprintf("Signed-in viewers. The %s module authorizes every call itself, under a Work Context minted for the viewer (audience %q, %s).", - route.module.As, route.module.As, scopeText(route.module.Scopes)) + route.module.As, route.module.As, scopeText(route.scopes())) if route.module.ViewerBearer { callers = fmt.Sprintf("Signed-in viewers. The %s module authenticates the viewer's bearer and authorizes every call itself.", route.module.As) } diff --git a/passthrough_scopes_test.go b/passthrough_scopes_test.go new file mode 100644 index 0000000..092d88b --- /dev/null +++ b/passthrough_scopes_test.go @@ -0,0 +1,174 @@ +package solution + +import ( + "net/http" + "strings" + "testing" +) + +// perMethodModule declares two methods of one module that need different +// authority: Search a member's read, Get an administrator's inspect. +func perMethodModule() ConsumedModule { + return ConsumedModule{ + As: "things", + Methods: []ConsumedMethod{ + { + Name: "/things.v1.Things/Search", + Scopes: []Scope{{ResourceKind: "things", Actions: []string{"list"}}}, + WholeResponse: true, + }, + { + Name: "/things.v1.Things/Get", + Scopes: []Scope{{ResourceKind: "things", Actions: []string{"inspect"}}}, + WholeResponse: true, + }, + }, + } +} + +const getPath = "/modules/things/things.v1.Things/Get" + +func mintedActions(m mintRequest) string { + var parts []string + for _, scope := range m.AuthorityScopes { + parts = append(parts, scope.ResourceKind+":"+strings.Join(scope.Actions, ",")) + } + return strings.Join(parts, " ") +} + +func TestPassthroughMintsOnlyTheCalledMethodsScopes(t *testing.T) { + gw := newModuleGateway(t, http.StatusOK, `{"entryId":"e1"}`) + s := passthroughServer(gw.URL, perMethodModule()) + + if status, body := call(t, s, searchPath, "Bearer viewer", `{"entryId":"e1"}`); status != http.StatusOK { + t.Fatalf("search: status %d: %v", status, body) + } + if status, body := call(t, s, getPath, "Bearer viewer", `{"entryId":"e1"}`); status != http.StatusOK { + t.Fatalf("get: status %d: %v", status, body) + } + if len(gw.mints) != 2 { + t.Fatalf("mints = %+v, want one per call", gw.mints) + } + // Least privilege: each mint carries its own method's scope and nothing + // of the other's — never the union. + if got := mintedActions(gw.mints[0]); got != "things:list" { + t.Fatalf("search minted %q, want things:list only", got) + } + if got := mintedActions(gw.mints[1]); got != "things:inspect" { + t.Fatalf("get minted %q, want things:inspect only", got) + } + if gw.mints[0].Audience != "things" || gw.mints[1].Audience != "things" { + t.Fatalf("both mints are for the module's one audience: %+v", gw.mints) + } +} + +func TestPassthroughMethodScopesReplaceTheModules(t *testing.T) { + gw := newModuleGateway(t, http.StatusOK, `{"entryId":"e1"}`) + module := perMethodModule() + module.Scopes = []Scope{{ResourceKind: "things", Actions: []string{"read"}}} + module.Methods[0].Scopes = nil // Search falls back to the module's. + s := passthroughServer(gw.URL, module) + + call(t, s, searchPath, "Bearer viewer", `{}`) + call(t, s, getPath, "Bearer viewer", `{"entryId":"e1"}`) + if len(gw.mints) != 2 || mintedActions(gw.mints[0]) != "things:read" || mintedActions(gw.mints[1]) != "things:inspect" { + t.Fatalf("mints = %+v, want module scope for search and method scope (alone) for get", gw.mints) + } +} + +func TestPassthroughRefusesOnlyTheMethodWhoseAuthorityTheViewerLacks(t *testing.T) { + gw := newModuleGateway(t, http.StatusOK, `{"entryId":"e1"}`) + gw.deny = "inspect" + s := passthroughServer(gw.URL, perMethodModule()) + + status, body := call(t, s, getPath, "Bearer member", `{"entryId":"e1"}`) + if status != http.StatusForbidden || body["code"] != "permission_denied" { + t.Fatalf("get without inspect: status %d, body %v; want 403 permission_denied", status, body) + } + if msg, _ := body["message"].(string); !strings.Contains(msg, "things:inspect") { + t.Fatalf("message %q does not name the missing permission", msg) + } + if len(gw.calls) != 0 { + t.Fatalf("the module was called without a capability: %v", gw.calls) + } + // The member's own method is unaffected, and its mint was not widened. + status, body = call(t, s, searchPath, "Bearer member", `{"entryId":"e1"}`) + if status != http.StatusOK || body["entryId"] != "e1" { + t.Fatalf("search after a refused get: status %d, body %v", status, body) + } + if last := gw.mints[len(gw.mints)-1]; mintedActions(last) != "things:list" { + t.Fatalf("search minted %q, want things:list only", mintedActions(last)) + } + if len(gw.calls) != 1 { + t.Fatalf("module calls = %d, want only the search", len(gw.calls)) + } +} + +func TestPassthroughRefusesAnUnservableScopeDeclarationAtBoot(t *testing.T) { + search := func(scopes ...Scope) ConsumedMethod { + return ConsumedMethod{Name: "/things.v1.Things/Search", Scopes: scopes, WholeResponse: true} + } + read := Scope{ResourceKind: "things", Actions: []string{"read"}} + cases := map[string]struct { + module ConsumedModule + want string + }{ + "method scopes on a viewer-bearer module": { + ConsumedModule{As: "things", ViewerBearer: true, Methods: []ConsumedMethod{search(read)}}, + "ViewerBearer", + }, + "no scopes anywhere": { + ConsumedModule{As: "things", Methods: []ConsumedMethod{search()}}, + "declares no Scopes", + }, + "one method without scopes and none on the module": { + ConsumedModule{As: "things", Methods: []ConsumedMethod{search(read), {Name: "/things.v1.Things/Get", WholeResponse: true}}}, + "Get declares no Scopes", + }, + "empty action list on a method": { + ConsumedModule{As: "things", Methods: []ConsumedMethod{search(Scope{ResourceKind: "things"})}}, + "names no action", + }, + "empty action list on the module": { + ConsumedModule{As: "things", Scopes: []Scope{{ResourceKind: "things", Actions: []string{}}}, Methods: []ConsumedMethod{search()}}, + "names no action", + }, + "blank action": { + ConsumedModule{As: "things", Methods: []ConsumedMethod{search(Scope{ResourceKind: "things", Actions: []string{" "}})}}, + "blank action", + }, + "no resource kind": { + ConsumedModule{As: "things", Methods: []ConsumedMethod{search(Scope{Actions: []string{"read"}})}}, + "no resource kind", + }, + "duplicated action": { + ConsumedModule{As: "things", Methods: []ConsumedMethod{search(Scope{ResourceKind: "things", Actions: []string{"read", "read"}})}}, + "twice", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + _, err := resolvePassthrough([]ConsumedModule{tc.module}) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want one mentioning %q", err, tc.want) + } + }) + } +} + +func TestPassthroughOperationsDocumentEachMethodsOwnScopes(t *testing.T) { + ops, err := PassthroughOperations(perMethodModule()) + if err != nil { + t.Fatal(err) + } + found := map[string]string{} + for _, op := range ops { + found[op.Path] = op.Callers + } + if !strings.Contains(found[searchPath], "things:[list]") || strings.Contains(found[searchPath], "inspect") { + t.Fatalf("search callers = %q", found[searchPath]) + } + if !strings.Contains(found[getPath], "things:[inspect]") || strings.Contains(found[getPath], "list") { + t.Fatalf("get callers = %q", found[getPath]) + } +} diff --git a/passthrough_test.go b/passthrough_test.go index f23abc6..169c553 100644 --- a/passthrough_test.go +++ b/passthrough_test.go @@ -37,6 +37,9 @@ type moduleGateway struct { *httptest.Server status int reply string + // deny, when set, is an action the viewer does not hold: a mint asking for + // it is refused the way accounts refuses one. + deny string mu sync.Mutex mints []mintRequest @@ -56,6 +59,17 @@ func newModuleGateway(t *testing.T, status int, reply string) *moduleGateway { _ = json.Unmarshal(body, &mint) mint.Bearer = r.Header.Get("authorization") g.mints = append(g.mints, mint) + for _, scope := range mint.AuthorityScopes { + for _, action := range scope.Actions { + if g.deny != "" && action == g.deny { + writeJSON(w, http.StatusForbidden, map[string]any{ + "code": "permission_denied", + "message": "owner is not allowed " + scope.ResourceKind + ":" + action + " at requested scope", + }) + return + } + } + } writeJSON(w, http.StatusOK, map[string]any{ "token": "context-" + mint.Audience + ".1", "orgId": mint.OrgID, "ownerPrincipalId": "viewer", "currentActorPrincipalId": "viewer", diff --git a/solution.go b/solution.go index fa1f1fd..8f532ce 100644 --- a/solution.go +++ b/solution.go @@ -2174,6 +2174,25 @@ func (g *Gateway) workContext(ctx context.Context) (codefly.WorkContextToken, er }) } +// WorkContextRefusal is the issuer's refusal to mint a Work Context: most +// often an authority the viewer does not hold ("owner is not allowed +// kind:action at requested scope"). It keeps the issuer's Connect code and +// message, so a caller can tell "you lack this permission" from "the gateway +// never routed the mint", and unwraps to the GatewayError it has always been. +type WorkContextRefusal struct { + Audience string + StatusCode int + // Code is the issuer's Connect code ("permission_denied"), when it sent one. + Code string + Message string +} + +func (e *WorkContextRefusal) Error() string { + return fmt.Sprintf("work context mint for %q rejected (status %d, %q): %s", e.Audience, e.StatusCode, e.Code, e.Message) +} + +func (e *WorkContextRefusal) Unwrap() error { return &GatewayError{StatusCode: e.StatusCode} } + // startTaskRequest is saas.accounts.v1.StartTaskWorkContextRequest on the wire. // The runtime speaks it as Connect JSON rather than linking the accounts client: // a solution's host is not this package's dependency. Leaving actorPrincipalId @@ -2213,8 +2232,9 @@ func (g *Gateway) mint(ctx context.Context, ask startTaskRequest) (codefly.WorkC Message string `json:"message"` } _ = json.NewDecoder(io.LimitReader(resp.Body, 4<<10)).Decode(&refusal) - return codefly.WorkContextToken{}, time.Time{}, fmt.Errorf("work context mint for %q rejected (status %d, %q): %s: %w", - ask.Audience, resp.StatusCode, refusal.Code, refusal.Message, &GatewayError{StatusCode: resp.StatusCode}) + return codefly.WorkContextToken{}, time.Time{}, &WorkContextRefusal{ + Audience: ask.Audience, StatusCode: resp.StatusCode, Code: refusal.Code, Message: refusal.Message, + } } var issued struct { Token string `json:"token"`