diff --git a/README.md b/README.md index 942efdd..ca4fc5e 100644 --- a/README.md +++ b/README.md @@ -363,6 +363,136 @@ This traffic is not proxied, for the same reason registration traffic is not: every gateway target is composition-local, and these requests carry the viewer's bearer and the capability minted for them in headers. +### Calling a composed module over its REST binding + +The gateway reaches a composed module only under the REST prefix the solution +federates for it (`/v1//*`), so the module's generated gRPC or Connect +client cannot be pointed at it: its procedure paths are not under that prefix. +What the module does publish is each RPC's `google.api.http` binding, in its +generated descriptor. `Gateway.Transcoded` speaks that binding with the +module's own generated messages: + +```go +docs, err := gw.ForModule(ctx, "documents", + solution.Scope{ResourceKind: "documents", Actions: []string{"read"}}) +if err != nil { + return nil, err +} +var page documentsv1.ListCollectionResponse +err = docs.Transcoded(ctx, "/v1/documents", documentsv1.DocumentsService_ListCollection_FullMethodName, + &documentsv1.ListCollectionRequest{PageSize: 500}, &page) +``` + +The call uses the method's first binding — the primary rule, then its +additional bindings in order — whose path lies under the prefix. The URL, the +HTTP method, and which request fields go into the path, the query or the body +all come from the descriptor. The caller can name no path of its own, and a +method with no binding under the prefix is refused before anything is sent, as +is a multi-segment path variable, an empty or dot-segment path value, and a +request or response of the wrong type. The response is decoded with unknown +fields discarded and is bounded (`MaxResponseBytes`, 8 MiB by default). A +non-2xx answer is a `*GatewayError` carrying the status. + +`Gateway.OrgID` is the viewer's active organization, the same one `ForModule` +mints in. A handler scoping a module read to a tenant names this one. + +`Gateway.WorkContextPrincipals` reports whom a `ForModule` capability was +issued for — the organization, the Task's owner and its current actor, as +accounts answered the mint — so a handler compares a claimed owner against +accounts' own resolution instead of trusting it. It reuses the cached +capability rather than minting again. + +### Letting the page call a consumed module: the passthrough + +The host proxy forwards a solution's page only to the solution's own backend, +so a page cannot reach a composed module's `/v1//*` 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. + +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, a method with no declared response fields, a method with no +scopes of its own or its module's (unless the module is `ViewerBearer`), scopes +on a `ViewerBearer` module or method, and a scope with no resource kind, no +action, or a blank or repeated action. `Response` +names the fields returned to the page; `WholeResponse` must be said explicitly. +`Pin` fixes part of every request server-side (merged with `proto.Merge`): a +pinned scalar replaces the page's value and a pinned repeated value is added to +the page's, so a filter clause the module ANDs stays in force whatever the page +sends. +A module's refusal reaches the page in the Connect error: a `google.rpc.Status` +body keeps its code and message, any other small JSON refusal is the message +verbatim under the code its HTTP status maps to, and anything else is reported +by kind only. `PassthroughOperations` renders the declaration for the interface +artifact, and `InterfaceArtifact(dir, info, ops, modules...)` renders a +backend's whole artifact — its own operations and the passthrough — at the +version its `service.codefly.yaml` declares. + +### Generated messages in a response + +A handler's value is encoded with `encoding/json`, which reads a generated +message's Go struct tags rather than producing protobuf JSON. Wrap an owner's +message in `solution.MessageOf(m)` (`solution.Message[T]`) wherever it sits in +the response, and it encodes as protobuf JSON. To pass on only part of an +owner's message, declare a `FieldMask` once from the paths allowed and +`solution.Apply` it. A field the owner adds later reaches nobody until it is +named, and a path naming no field fails at declaration. + +### Documented operations + +`Server.Operations` registers a list of `solution.Operation`s — each a route, +its handler, and the documentation a reader needs (summary, callers, behaviour, +query parameters, request and response types). `solution.InterfaceDocument` +renders the same list as a Swagger 2.0 artifact, so one declaration is the +source of what is served and what is published, and an operation missing its +documentation is refused. A `Message[T]` field is described from its +descriptor, with protobuf JSON names. + > **Note:** SDK in-process endpoint resolution for a solution composed on an > out-of-repo host depends on codefly-core accepting the composed module path in > its workspace loader (codefly-dev/core#365, merged); the `core`/`sdk-go` pins 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/go.mod b/go.mod index 223cd21..59793e7 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,9 @@ require ( github.com/codefly-dev/core v0.3.41 github.com/codefly-dev/sdk-go v0.1.65 github.com/google/uuid v1.6.0 + google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 google.golang.org/protobuf v1.36.12 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -47,8 +49,6 @@ require ( golang.org/x/sys v0.48.0 // indirect golang.org/x/term v0.46.0 // indirect golang.org/x/text v0.42.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect google.golang.org/grpc v1.83.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/operations.go b/operations.go new file mode 100644 index 0000000..c552dfe --- /dev/null +++ b/operations.go @@ -0,0 +1,415 @@ +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": 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"}) + } + 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": valueSchema(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{}) +) + +// 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. +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.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..e30aa5f --- /dev/null +++ b/operations_test.go @@ -0,0 +1,117 @@ +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"` + Optional *Message[*annotations.HttpRule] `json:"optional,omitempty"` + 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) + } + 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) { + 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/passthrough.go b/passthrough.go new file mode 100644 index 0000000..0bb2889 --- /dev/null +++ b/passthrough.go @@ -0,0 +1,566 @@ +package solution + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "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" + "gopkg.in/yaml.v3" +) + +// --- 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) + // 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, + // 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 + // 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 + // 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 + // 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) + } + 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) + 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()) + } + 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()) + } + 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 +} + +// 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. +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.scopes()...) + if err != nil { + return nil, relayedError(err) + } + 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 { + 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 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)) + } + 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.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.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 { + 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 } + +// 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_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 new file mode 100644 index 0000000..169c553 --- /dev/null +++ b/passthrough_test.go @@ -0,0 +1,374 @@ +package solution + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "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 +// 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 + // 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 + 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) + 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", + "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) + } +} + +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") + } +} + +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") + } +} diff --git a/protowire.go b/protowire.go new file mode 100644 index 0000000..c04095a --- /dev/null +++ b/protowire.go @@ -0,0 +1,176 @@ +package solution + +import ( + "encoding/json" + "fmt" + "sort" + "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) + } +} + +// 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 d74d1b5..8f532ce 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}) @@ -1917,6 +1942,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. @@ -2142,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 @@ -2181,12 +2232,14 @@ 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"` 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) @@ -2227,9 +2280,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. @@ -2241,12 +2325,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) + } +} diff --git a/transcode.go b/transcode.go new file mode 100644 index 0000000..8886a9e --- /dev/null +++ b/transcode.go @@ -0,0 +1,379 @@ +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 { + 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 { + 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) + } +}