From abf133c4ed7a4f6e9e649f46eb38391c82739901 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Fri, 23 Oct 2026 18:26:42 -0400 Subject: [PATCH 01/13] feat: run under the authority interface when the composition names one A page's runtime could only run in front of the saas host: it refused to boot without the host gateway, read its viewer from headers the gateway stamped and minted at the host's StartTask route. Under the authority interface (codefly-dev/authority) none of that exists, and a page that cannot boot cannot be developed against it. When the composition names the authority (authority/endpoint): - Boot needs no gateway and no broker credential. - The viewer is the authority's answer about the request's bearer (session.Current); headers the request wrote are ignored, and an unknown bearer is answered 401 before the handler runs. - ForModule's Work Context is issued by authority.Issue for the person, with the viewer's bearer, and held until due for renewal. - ForBinding (new, authority only): the module's own Work Context through one of its declared bindings, in the viewer's organization, with the workload credential the run attests. - WorkContext (new): the token a gateway acts under, for a caller reaching a module itself over gRPC through the hop. - Gateway passthrough has no gateway under the authority and is refused at boot by name. Without authority/endpoint nothing changes, so saas still runs locally. Tests: viewer from the session with forged headers ignored, unknown bearer refused (sabotage-checked), viewer and binding issues and their caching, a refused issue, config without a gateway, ForBinding refused without the authority. Existing suite unchanged and green. README: "Under the authority interface". Co-Authored-By: Claude Opus 5.5 --- README.md | 25 ++++++ authority.go | 189 ++++++++++++++++++++++++++++++++++++++++++++ authority_test.go | 194 ++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 5 +- go.sum | 6 ++ solution.go | 72 +++++++++++++++-- 6 files changed, 483 insertions(+), 8 deletions(-) create mode 100644 authority.go create mode 100644 authority_test.go diff --git a/README.md b/README.md index 93659f2..b6a9a7c 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,31 @@ after fixing the cause — runs the step again, and at most must therefore converge: it may run again for an installation it already set up, after a retry, an eviction or a restart. +## Under the authority interface + +When the composition names the authority interface (workspace configuration +`authority/endpoint`: the authority gateway on a laptop, the host in a cell; see +codefly-dev/authority), the runtime asks it what it otherwise asks the saas +gateway, and needs neither the gateway nor the broker credential: + +- **Who a request is for**: the authority's `session.Current` for the request's + bearer. `OrgID` is that session's organization; `x-org-id`, `x-session-id` and + the installation header a request wrote are ignored, and a bearer the + authority does not know is answered 401 before the handler runs. +- **`ForModule(audience, scopes...)`**: the viewer's Work Context, issued by + `authority.Issue` for the person with the viewer's bearer, held until it is due + for renewal. +- **`ForBinding(binding)`** (authority only): this module's own Work Context + through one of its declared bindings, in the viewer's organization, issued with + the workload credential the run attests (`CODEFLY__WORKLOAD_TOKEN_FILE`). +- **`WorkContext(ctx)`**: the token either gateway acts under, for a caller that + reaches a module itself — over gRPC through the hop. + +Gateway passthrough (`Consumes`) has no gateway to relay through and is refused +at boot; a page calls each module through the hop instead. Without +`authority/endpoint` nothing changes: the runtime runs against the saas host as +before, so a composition can still run saas locally. + ## Configuration On boot `Serve` calls `codefly.Init(ctx)`, which loads Codefly's injected diff --git a/authority.go b/authority.go new file mode 100644 index 0000000..f5ab31c --- /dev/null +++ b/authority.go @@ -0,0 +1,189 @@ +package solution + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "strings" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + sessionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/session/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + grpcmetadata "google.golang.org/grpc/metadata" +) + +// The authority interface (codefly-dev/authority): the authority gateway on a +// laptop, the host in a cell. When the composition names one +// (authority/endpoint), the runtime asks it everything it otherwise asks the +// saas gateway: who a request's bearer is (session Current) and the Work +// Context a call to a module carries (authority Issue). No gateway stands in +// front of the page, so nothing is read from a header the request wrote, and +// the runtime mints nothing. +const ( + // AuthorityInterfaceGroup and AuthorityEndpointKey name where the + // composition says which authority answers. + AuthorityInterfaceGroup = "authority" + AuthorityEndpointKey = "endpoint" + // authorityCallTimeout bounds one call to the authority. + authorityCallTimeout = 10 * time.Second +) + +// ErrNoViewer is a request whose bearer the authority does not know. +var ErrNoViewer = errors.New("the authority knows no session for this bearer") + +// hostAuthority is the runtime's connection to the authority interface. +type hostAuthority struct { + conn *grpc.ClientConn + authority authorityv1.AuthorityServiceClient + session sessionv1.SessionServiceClient + // credential is this workload's credential as the run attests it, read on + // every call so a rotation takes effect. + credential func() (string, error) +} + +// dialAuthority connects to the authority at endpoint. The connection is +// in-cell transport, which the mesh carries; the runtime terminates no TLS to +// it. +func dialAuthority(endpoint, tokenFile string) (*hostAuthority, error) { + target := strings.TrimSpace(endpoint) + for _, scheme := range []string{"grpc://", "http://", "tcp://"} { + target = strings.TrimPrefix(target, scheme) + } + if target == "" { + return nil, fmt.Errorf("%s/%s names no address", AuthorityInterfaceGroup, AuthorityEndpointKey) + } + conn, err := grpc.NewClient(target, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, fmt.Errorf("%s/%s: %w", AuthorityInterfaceGroup, AuthorityEndpointKey, err) + } + return &hostAuthority{ + conn: conn, + authority: authorityv1.NewAuthorityServiceClient(conn), + session: sessionv1.NewSessionServiceClient(conn), + credential: func() (string, error) { + raw, err := os.ReadFile(tokenFile) + if err != nil || strings.TrimSpace(string(raw)) == "" { + return "", fmt.Errorf("this workload's credential at %s is missing or empty (declare module-identity)", tokenFile) + } + return strings.TrimSpace(string(raw)), nil + }, + }, nil +} + +// withBearer carries the request's bearer on a call to the authority. +func withBearer(ctx context.Context, bearer string) context.Context { + return grpcmetadata.AppendToOutgoingContext(ctx, "authorization", bearer) +} + +// viewer asks the authority who bearer is. +func (h *hostAuthority) viewer(ctx context.Context, bearer string) (*sessionv1.Session, error) { + ctx, cancel := context.WithTimeout(ctx, authorityCallTimeout) + defer cancel() + sess, err := h.session.Current(withBearer(ctx, bearer), &sessionv1.CurrentRequest{}) + if err != nil || sess.GetOrganizationId() == "" { + return nil, ErrNoViewer + } + return sess, nil +} + +// issueForViewer asks the authority for the viewer's Work Context at one +// audience, within the scopes asked: what ForModule's gateway carries. +func (h *hostAuthority) issueForViewer(ctx context.Context, bearer string, ask startTaskRequest) (string, time.Time, WorkContextPrincipals, error) { + req := &authorityv1.IssueRequest{ + As: &authorityv1.IssueRequest_Person{Person: &authorityv1.PersonSession{}}, + Audience: ask.Audience, + } + for _, scope := range ask.AuthorityScopes { + req.Scopes = append(req.Scopes, &authorityv1.WorkScopeV1{ResourceKind: scope.ResourceKind, Actions: scope.Actions}) + } + return h.issue(withBearer(ctx, bearer), req) +} + +// issueForBinding asks the authority for this module's own Work Context +// through one of its declared bindings, in the viewer's organization: the +// binding's headless scopes, with no person in it. +func (h *hostAuthority) issueForBinding(ctx context.Context, organization, binding string) (string, time.Time, WorkContextPrincipals, error) { + credential, err := h.credential() + if err != nil { + return "", time.Time{}, WorkContextPrincipals{}, err + } + return h.issue(ctx, &authorityv1.IssueRequest{ + As: &authorityv1.IssueRequest_Workload{Workload: &authorityv1.WorkloadCredential{Credential: credential}}, + OrganizationId: organization, + OperationBindingId: binding, + }) +} + +func (h *hostAuthority) issue(ctx context.Context, req *authorityv1.IssueRequest) (string, time.Time, WorkContextPrincipals, error) { + ctx, cancel := context.WithTimeout(ctx, authorityCallTimeout) + defer cancel() + issued, err := h.authority.Issue(ctx, req) + if err != nil { + return "", time.Time{}, WorkContextPrincipals{}, &WorkContextRefusal{Audience: req.GetAudience(), StatusCode: refusalStatus(err)} + } + wc := issued.GetContext() + principals := WorkContextPrincipals{OrgID: wc.GetTenantId(), OwnerPrincipalID: wc.GetOwnerPrincipalId(), CurrentActorPrincipalID: wc.GetOwnerPrincipalId()} + if chain := wc.GetActorChain(); len(chain) > 0 { + principals.CurrentActorPrincipalID = chain[len(chain)-1].GetPrincipalId() + } + expires := time.Unix(issued.GetExpiresAtUnix(), 0) + lifetime := time.Until(expires) + if issued.GetToken() == "" || lifetime < workContextMinimumLifetime { + return "", time.Time{}, WorkContextPrincipals{}, fmt.Errorf("the authority issued no usable Work Context for %q", req.GetAudience()) + } + lead := workContextRenewal + if half := lifetime / 2; half < lead { + lead = half + } + return issued.GetToken(), expires.Add(-lead), principals, nil +} + +func (h *hostAuthority) Close() error { return h.conn.Close() } + +// refusalStatus is the HTTP status an authority refusal answers a page with. +func refusalStatus(err error) int { + switch status.Code(err) { + case codes.Unauthenticated: + return http.StatusUnauthorized + case codes.PermissionDenied, codes.NotFound, codes.FailedPrecondition: + return http.StatusForbidden + case codes.Unavailable, codes.DeadlineExceeded: + return http.StatusServiceUnavailable + } + return http.StatusBadGateway +} + +// WorkContext is the Work Context this gateway acts under, issued (and renewed) +// for ForModule's or ForBinding's ask: what a caller presents to a module it +// reaches itself, over gRPC through the hop for example. +func (g *Gateway) WorkContext(ctx context.Context) (string, error) { + if g.delegation == nil && g.carriedContext == "" { + return "", errors.New("this gateway acts under no Work Context: call ForModule or ForBinding first") + } + return g.workContext(ctx) +} + +// ForBinding is ForModule for this module's own work: a Work Context through +// one of its declared bindings, in the viewer's organization, carrying the +// binding's headless scopes and no person. Only the authority issues one. +func (g *Gateway) ForBinding(ctx context.Context, binding string) (*Gateway, error) { + if g.host == nil { + return nil, errors.New("ForBinding is issued by the authority interface; this composition names none (authority/endpoint)") + } + if g.orgID == "" { + return nil, &ClientError{StatusCode: http.StatusConflict, Message: "no organization selected"} + } + acting := *g + acting.delegation = &delegation{key: "binding\x00" + g.orgID + "\x00" + binding, ask: startTaskRequest{OrgID: g.orgID, Binding: binding}} + if _, err := acting.workContext(ctx); err != nil { + return nil, g.surfaced(err) + } + return &acting, nil +} diff --git a/authority_test.go b/authority_test.go new file mode 100644 index 0000000..6b09d28 --- /dev/null +++ b/authority_test.go @@ -0,0 +1,194 @@ +package solution + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" + sessionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/session/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + grpcmetadata "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +// interfaceAuthority answers the session and authority interfaces for one known +// bearer, and records every issue it is asked. +type interfaceAuthority struct { + sessionv1.UnimplementedSessionServiceServer + authorityv1.UnimplementedAuthorityServiceServer + mu sync.Mutex + issued []*authorityv1.IssueRequest + bearer []string +} + +const sessionBearer = "Bearer viewer-session" + +func authorityBearerOf(ctx context.Context) string { + md, _ := grpcmetadata.FromIncomingContext(ctx) + if values := md.Get("authorization"); len(values) == 1 { + return values[0] + } + return "" +} + +func (a *interfaceAuthority) Current(ctx context.Context, _ *sessionv1.CurrentRequest) (*sessionv1.Session, error) { + if authorityBearerOf(ctx) != sessionBearer { + return nil, status.Error(codes.Unauthenticated, "no session") + } + return &sessionv1.Session{SessionId: "session-1", PersonId: "person-1", OrganizationId: "org-from-the-authority"}, nil +} + +func (a *interfaceAuthority) Issue(ctx context.Context, r *authorityv1.IssueRequest) (*authorityv1.IssueResponse, error) { + a.mu.Lock() + defer a.mu.Unlock() + a.issued = append(a.issued, proto.Clone(r).(*authorityv1.IssueRequest)) + a.bearer = append(a.bearer, authorityBearerOf(ctx)) + if r.GetAudience() == "refused" { + return nil, status.Error(codes.PermissionDenied, "person-1 holds nothing there") + } + return &authorityv1.IssueResponse{ + Token: "issued-" + r.GetAudience() + r.GetOperationBindingId(), + ExpiresAtUnix: time.Now().Add(5 * time.Minute).Unix(), + Context: &authorityv1.WorkContextV1{TenantId: "org-from-the-authority", OwnerPrincipalId: "person-1"}, + }, nil +} + +func (a *interfaceAuthority) asked() []*authorityv1.IssueRequest { + a.mu.Lock() + defer a.mu.Unlock() + return append([]*authorityv1.IssueRequest(nil), a.issued...) +} + +// authorityServer is a Server composed with the stand-in authority, as a +// composition naming authority/endpoint composes it. +func authorityServer(t *testing.T) (*Server, *interfaceAuthority) { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + stand := &interfaceAuthority{} + g := grpc.NewServer() + sessionv1.RegisterSessionServiceServer(g, stand) + authorityv1.RegisterAuthorityServiceServer(g, stand) + go func() { _ = g.Serve(l) }() + t.Cleanup(g.Stop) + credential := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(credential, []byte("workload-credential"), 0o600); err != nil { + t.Fatal(err) + } + host, err := dialAuthority("grpc://"+l.Addr().String(), credential) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = host.Close() }) + return &Server{manifest: Manifest{ID: "page"}, host: host}, stand +} + +// Who a request is for is the authority's answer about its bearer: a header the +// request wrote names nothing, and a bearer the authority does not know is +// refused before the handler runs. +func TestUnderTheAuthorityTheViewerIsTheSessionTheBearerBelongsTo(t *testing.T) { + s, _ := authorityServer(t) + var seen *Gateway + handler := s.wrapRequest(func(_ *http.Request, gw *Gateway) (any, error) { + seen = gw + return map[string]string{}, nil + }) + request := httptest.NewRequest(http.MethodGet, "/v1/page/types", nil) + request.Header.Set("authorization", sessionBearer) + request.Header.Set(orgHeader, "org-the-caller-wrote") + request.Header.Set(sessionHeader, "session-the-caller-wrote") + answer := httptest.NewRecorder() + handler(answer, request) + if answer.Code != http.StatusOK || seen == nil || seen.OrgID() != "org-from-the-authority" || seen.sessionID != "session-1" { + t.Fatalf("answered %d with org %q", answer.Code, seen.OrgID()) + } + seen = nil + forged := httptest.NewRequest(http.MethodGet, "/v1/page/types", nil) + forged.Header.Set("authorization", "Bearer not-a-session") + forged.Header.Set(orgHeader, "org-the-caller-wrote") + answer = httptest.NewRecorder() + handler(answer, forged) + if answer.Code != http.StatusUnauthorized || seen != nil { + t.Fatalf("an unknown bearer answered %d and reached the handler: %v", answer.Code, seen != nil) + } +} + +// ForModule's Work Context is the viewer's, issued by the authority with the +// viewer's bearer, and held until it is due for renewal; ForBinding's is this +// module's own, issued with its workload credential in the viewer's +// organization through the named binding. +func TestUnderTheAuthorityEveryWorkContextIsIssued(t *testing.T) { + s, stand := authorityServer(t) + gw := s.gatewayFor(http.Header{"Authorization": {sessionBearer}}) + gw.orgID, gw.sessionID = "org-from-the-authority", "session-1" + ctx := context.Background() + + viewer, err := gw.ForModule(ctx, "records", Scope{ResourceKind: "records.types", Actions: []string{"read"}}) + if err != nil { + t.Fatal(err) + } + token, err := viewer.WorkContext(ctx) + if err != nil || token != "issued-records" { + t.Fatalf("viewer token %q: %v", token, err) + } + if again, _ := viewer.WorkContext(ctx); again != token || len(stand.asked()) != 1 { + t.Fatalf("a held Work Context was issued again: %d issues", len(stand.asked())) + } + asked := stand.asked()[0] + if asked.GetPerson() == nil || asked.GetAudience() != "records" || stand.bearer[0] != sessionBearer || + len(asked.GetScopes()) != 1 || asked.GetScopes()[0].GetResourceKind() != "records.types" { + t.Fatalf("viewer issue asked %v with bearer %q", asked, stand.bearer[0]) + } + + own, err := gw.ForBinding(ctx, "records-types") + if err != nil { + t.Fatal(err) + } + if token, err := own.WorkContext(ctx); err != nil || token != "issued-records-types" { + t.Fatalf("binding token %q: %v", token, err) + } + asked = stand.asked()[1] + if asked.GetWorkload().GetCredential() != "workload-credential" || asked.GetOrganizationId() != "org-from-the-authority" || asked.GetOperationBindingId() != "records-types" { + t.Fatalf("binding issue asked %v", asked) + } + + if _, err := gw.ForModule(ctx, "refused"); err == nil || !strings.Contains(err.Error(), "403") { + t.Fatalf("a refused issue was not a refusal: %v", err) + } + if _, err := gw.WorkContext(ctx); err == nil { + t.Fatal("a gateway with no ask handed out a Work Context") + } +} + +// A composition that names the authority needs no saas gateway and no broker +// credential; one that names neither still refuses to start. +func TestAConfigNamingTheAuthorityNeedsNoGateway(t *testing.T) { + cfg := config{port: "8080", profile: "local", authorityEndpoint: "127.0.0.1:1", requiresAuthority: true} + if err := cfg.validate(); err != nil { + t.Fatalf("a composition naming the authority was refused: %v", err) + } + cfg.authorityEndpoint = "" + if err := cfg.validate(); err == nil || !strings.Contains(err.Error(), "gateway") { + t.Fatalf("a composition naming neither started: %v", err) + } +} + +// ForBinding is the authority's alone. +func TestForBindingWithoutTheAuthorityIsRefused(t *testing.T) { + gw := newGateway("https://gateway.test", sessionBearer, "org", "session") + if _, err := gw.ForBinding(context.Background(), "records-types"); err == nil || !strings.Contains(err.Error(), "authority/endpoint") { + t.Fatalf("ForBinding without the authority: %v", err) + } +} diff --git a/go.mod b/go.mod index 84d1f80..be26f0d 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/google/uuid v1.6.0 github.com/modelcontextprotocol/go-sdk v1.8.0 golang.org/x/tools v0.52.0 - google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc + google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 ) @@ -24,6 +24,7 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023221258-84ff743d12b7 // indirect github.com/go-openapi/analysis v1.0.0 // indirect github.com/go-openapi/errors v0.22.9 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect @@ -64,6 +65,6 @@ require ( golang.org/x/term v0.47.0 // indirect golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.15.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect google.golang.org/grpc v1.84.0 // indirect ) diff --git a/go.sum b/go.sum index 3a06415..cf009aa 100644 --- a/go.sum +++ b/go.sum @@ -30,6 +30,8 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023221258-84ff743d12b7 h1:+KfZOgc7bK4vLJWpr6pdHO+TlLNZ9ziSo3mxfQKQY6s= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023221258-84ff743d12b7/go.mod h1:wxXASIpNKX7bzQzd0ZB17Xj2CFlO1ALRHqDaEoER+is= github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda h1:ytTihtIEfVhQ7llQl+ls5swIL8JgJu89X0wlDMeqM+o= github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda/go.mod h1:0uX/7lTBVaGV0RI2Mb3mpEr/PPdx/jZqXHym/U8FVDI= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= @@ -237,8 +239,12 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE= google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU= +google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b h1:nB3spRIaSPtxJpxb0k41yljROvm2Jrwfs2pSNdKS5y8= +google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b/go.mod h1:9N4vtjEwvgQKEdlFVQO0M44ErhecQ9DCSQfGoZ51M6w= google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg= google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 h1:QG6hS4zFplRZvOQedzei4t2pT/zkQc3VR1u+S9/rcAw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc= google.golang.org/grpc v1.84.0 h1:soMyaPJ8pAak5PIQ0DGBUir0XRo2fRoMqhNWMLlLxO0= google.golang.org/grpc v1.84.0/go.mod h1:ljCht0DrxQrXBDRTZp52Qxh3Ffk8CdYm2sj4O2QN2C0= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= diff --git a/solution.go b/solution.go index 6e1742e..f66692a 100644 --- a/solution.go +++ b/solution.go @@ -161,6 +161,8 @@ type Server struct { mcp *mcpSurface mcpRedeclared bool cfg config + // host is the authority interface, when the composition names one. + host *hostAuthority // declaredContract is the authority contract the author declared // (Contract); contract is that contract resolved for the profile this // process runs under, which is what it publishes. @@ -219,6 +221,10 @@ type config struct { listenHost string port string gatewayURL string + // authorityEndpoint is the authority interface the composition names + // (authority/endpoint). When set, the runtime asks it instead of the saas + // gateway (authority.go), and no gateway is required. + authorityEndpoint string // dialled is every address this process talks to, assembled at boot. // // It exists so nothing has to re-read the configuration to ask "is this @@ -542,11 +548,13 @@ func loadConfig(ctx context.Context, id string, initErr error) config { transport.value, transport.set, transport.err = query.WorkspaceValueIfSet(workcontext.MeshTransportGroup, workcontext.MeshTransportKey) internalToken, _ := query.WorkspaceSecret("module-authority", "INTERNAL_TOKEN") + authorityEndpoint, _ := query.WorkspaceConfiguration(AuthorityInterfaceGroup, AuthorityEndpointKey) cfg := config{ brokerInternalToken: strings.TrimSpace(internalToken), port: port, listenHost: listenHost(self), gatewayURL: gatewayURL, + authorityEndpoint: strings.TrimSpace(authorityEndpoint), assetsDir: env("ASSETS_DIR", "../fe-remote/dist"), dialled: dialledAddresses(gatewayURL), projectedTokenPath: strings.TrimSpace(env(ProjectedTokenFileEnvironmentVariable, DefaultProjectedTokenFile)), @@ -663,7 +671,10 @@ func (c config) validate() error { // that attests which workload this process is, and every viewer call // carries the viewer's bearer and the capability minted for them. So it // has to be resolved, and it has to be a destination those may travel to. - if u, err := url.Parse(c.gatewayURL); err != nil || !u.IsAbs() || u.Host == "" { + if c.authorityEndpoint != "" { + // The authority answers who a viewer is and issues every Work + // Context; there is no gateway to resolve and no broker to reach. + } else if u, err := url.Parse(c.gatewayURL); err != nil || !u.IsAbs() || u.Host == "" { if c.environmentLoadErr != nil { return fmt.Errorf("unresolved gateway URL %q, and loading Codefly's injected environment failed first: %w — nothing the SDK resolves can be trusted to be absent until that is fixed", redactedURL(c.gatewayURL), c.environmentLoadErr) @@ -681,11 +692,12 @@ func (c config) validate() error { // Userinfo, a query and a fragment are refused by it too: a credential in // a URL is a secret this runtime would log at boot and net/http would // strip from the request, so it is disclosed and never used. - if _, err := workcontext.AdmitEndpoint(c.gatewayURL, c.transport, "gateway"); err != nil { + if c.authorityEndpoint != "" { + } else if _, err := workcontext.AdmitEndpoint(c.gatewayURL, c.transport, "gateway"); err != nil { return fmt.Errorf("gateway URL %q is not a destination the projected service-account token, the viewer's bearer and the capability minted for them may travel to: %w — set GATEWAY_URL, or the composition's gateway endpoint, to one that is", redactedURL(c.gatewayURL), err) } - if c.requiresAuthority && c.brokerInternalToken == "" { + if c.requiresAuthority && c.authorityEndpoint == "" && c.brokerInternalToken == "" { return fmt.Errorf("missing module-authority/INTERNAL_TOKEN: provision the broker perimeter credential as a workspace secret") } if err := resources.ValidateConfigurationProfileName(c.profile); err != nil { @@ -897,7 +909,20 @@ func (s *Server) start(ctx context.Context) (net.Listener, error) { // The authority-bearing values first: the principal is part of what the // contract publishes, and a value the platform never provisioned is named // here rather than discovered when a mint is refused. - if s.cfg.requiresAuthority { + if s.cfg.authorityEndpoint != "" { + authority, err := dialAuthority(s.cfg.authorityEndpoint, s.cfg.projectedTokenPath) + if err != nil { + return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) + } + s.host = authority + if len(s.consumed) > 0 { + // Passthrough relays a page's call to a consumed module through the + // saas gateway. Under the authority there is no such gateway: a + // page reaches a module through the hop with ForModule's Work + // Context, and nothing passes through this process. + return nil, fmt.Errorf("solution %q consumes modules through the gateway passthrough, which the authority interface has no gateway for: call each module through the hop with Gateway.ForModule and Gateway.WorkContext", s.manifest.ID) + } + } else if s.cfg.requiresAuthority { if err := s.openAuthority(ctx); err != nil { return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) } @@ -1270,7 +1295,18 @@ func (s *Server) wrapRequest(handler RequestHandler) http.HandlerFunc { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing bearer"}) return } - result, err := handler(r, s.gatewayFor(r.Header)) + gw := s.gatewayFor(r.Header) + if s.host != nil { + // Who the request is for is the authority's answer about the + // bearer, never a header the request wrote. + viewer, err := s.host.viewer(r.Context(), bearer) + if err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "the authority knows no session for this bearer"}) + return + } + gw.orgID, gw.sessionID, gw.installationID, gw.installationErr = viewer.GetOrganizationId(), viewer.GetSessionId(), "", nil + } + result, err := handler(r, gw) if err != nil { status, message := handlerErrorResponse(err) writeJSON(w, status, map[string]string{"error": message}) @@ -1328,6 +1364,9 @@ type Gateway struct { // is given. It holds the ask rather than a capability: see delegation. delegation *delegation contexts *workContextCache + // host, when set, is the authority interface that issues this gateway's + // Work Contexts (authority.go). + host *hostAuthority // workload is this execution's own credential, presented on the mint this // runtime runs for a viewer so the issuer knows which module is asking and // can hold the mint to the installation and binding that credential is @@ -1370,6 +1409,7 @@ func (s *Server) gatewayFor(header http.Header) *Gateway { // is absent. gw.installationID, gw.installationErr = viewerInstallation(header) gw.id, gw.report = s.manifest.ID, &s.attestation + gw.host = s.host gw.workload = func(ctx context.Context) (executionCredential, error) { return s.acquireForInstallation(ctx, gw.orgID, gw.installationID) } @@ -1827,7 +1867,7 @@ func (g *Gateway) ForModule(ctx context.Context, audience string, scopes ...Scop "cannot mint a work context for %q: %s is absent or empty — the gateway injects it from the verified session and stamps it empty for any caller that authenticates without one: %w", audience, sessionHeader, &ClientError{StatusCode: http.StatusForbidden, Message: "a user session is required to read composed modules"}) } - if g.installationID == "" { + if g.installationID == "" && g.host == nil { if g.installationErr != nil { return nil, g.surfaced(fmt.Errorf("cannot mint a work context for %q: %w. The host requires an installation on every mint and refuses one naming none, and an organization is not an installation — one org may hold several", audience, g.installationErr)) @@ -2045,9 +2085,29 @@ type startTaskRequest struct { SessionID string `json:"sessionId"` Audience string `json:"audience"` AuthorityScopes []workContextScope `json:"authorityScopes"` + // Binding, under the authority, asks for this module's own Work Context + // through one of its declared bindings (ForBinding). The saas route has no + // such ask, so it is never sent there. + Binding string `json:"-"` } func (g *Gateway) mint(ctx context.Context, ask startTaskRequest) (string, time.Time, error) { + if g.host != nil { + var token string + var renew time.Time + var principals WorkContextPrincipals + var err error + if ask.Binding != "" { + token, renew, principals, err = g.host.issueForBinding(ctx, ask.OrgID, ask.Binding) + } else { + token, renew, principals, err = g.host.issueForViewer(ctx, g.bearer, ask) + } + if err != nil { + return "", time.Time{}, err + } + g.contexts.remember(token, principals) + return token, renew, nil + } body, err := json.Marshal(ask) if err != nil { return "", time.Time{}, err From 6db411a467e40f0053b38c4e604baf172a7a79d9 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Fri, 23 Oct 2026 20:16:49 -0400 Subject: [PATCH 02/13] fix: a binding ask is held to its binding, not to the published ceiling ForBinding asks for a binding's own Work Context and names no scopes: the authority issues the binding's declared headless scopes and nothing else. The published-ceiling check every mint passes refused it as "a capability with no scopes at all" whenever a contract was resolved, which is every real boot; the test missed it because its gateway carried no contract. The check now applies to scoped asks only, and the test carries ceilings. Found by running the directory page against the authority gateway. Co-Authored-By: Claude Opus 5.5 --- authority_test.go | 8 +++++++- solution.go | 9 +++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/authority_test.go b/authority_test.go index 6b09d28..03ec52e 100644 --- a/authority_test.go +++ b/authority_test.go @@ -152,6 +152,12 @@ func TestUnderTheAuthorityEveryWorkContextIsIssued(t *testing.T) { t.Fatalf("viewer issue asked %v with bearer %q", asked, stand.bearer[0]) } + // The published contract's ceilings govern what a viewer is asked for; a + // binding ask names no scopes and is held to the binding by the authority. + gw.ceilings = map[string][]Scope{ + "records": {{ResourceKind: "records.types", Actions: []string{"read"}}}, + "refused": {{ResourceKind: "documents", Actions: []string{"read"}}}, + } own, err := gw.ForBinding(ctx, "records-types") if err != nil { t.Fatal(err) @@ -164,7 +170,7 @@ func TestUnderTheAuthorityEveryWorkContextIsIssued(t *testing.T) { t.Fatalf("binding issue asked %v", asked) } - if _, err := gw.ForModule(ctx, "refused"); err == nil || !strings.Contains(err.Error(), "403") { + if _, err := gw.ForModule(ctx, "refused", Scope{ResourceKind: "documents", Actions: []string{"read"}}); err == nil || !strings.Contains(err.Error(), "403") { t.Fatalf("a refused issue was not a refusal: %v", err) } if _, err := gw.WorkContext(ctx); err == nil { diff --git a/solution.go b/solution.go index f66692a..8d6678d 100644 --- a/solution.go +++ b/solution.go @@ -2026,8 +2026,13 @@ func (g *Gateway) workContext(ctx context.Context) (string, error) { // a delegation reaching it by some future path would otherwise carry // an unchecked ask, and the cost is a comparison against a declaration // already in memory. - if err := g.withinPublishedCeiling(ask.Audience, scopesOfAsk(ask.AuthorityScopes)); err != nil { - return "", time.Time{}, err + // A binding ask (ForBinding) names no scopes: the authority issues the + // binding's declared headless scopes and nothing else, so the binding + // is its ceiling and there is no asked scope to hold to this one. + if ask.Binding == "" { + if err := g.withinPublishedCeiling(ask.Audience, scopesOfAsk(ask.AuthorityScopes)); err != nil { + return "", time.Time{}, err + } } ask.TaskID = uuid.NewString() return g.mint(ctx, ask) From e821f67430e481011e3cf2b0440dc953b9e96a42 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Fri, 23 Oct 2026 20:31:18 -0400 Subject: [PATCH 03/13] feat: under the authority, a solution with no published ceiling is bounded by the authority A module page consumes nothing through passthrough, so it has no ceiling it can publish here, and every ForModule it made was refused locally as "no binding for that audience" before the authority was asked. Under the authority the viewer's Work Context is bounded by the authority itself: what the viewer holds, cut to what the audience serves. A solution that publishes no ceiling is now held to that alone; an ask for nothing is still refused before it leaves, and a solution that publishes ceilings is still held to them. Saas mode is unchanged. Known gap: the authority should hold a page's declared bindings as its ceiling, so the bound is the module's declaration rather than the viewer's holdings. Found by running the directory page against the authority gateway. Co-Authored-By: Claude Opus 5.5 --- authority_test.go | 26 ++++++++++++++++++++++++++ solution.go | 8 ++++++++ 2 files changed, 34 insertions(+) diff --git a/authority_test.go b/authority_test.go index 03ec52e..3d4afdc 100644 --- a/authority_test.go +++ b/authority_test.go @@ -198,3 +198,29 @@ func TestForBindingWithoutTheAuthorityIsRefused(t *testing.T) { t.Fatalf("ForBinding without the authority: %v", err) } } + +// Under the authority, a solution that publishes no ceiling (a module page) is +// bounded by the authority alone; an empty ask is still refused before it +// reaches the authority, and a solution that publishes ceilings stays held to +// them. +func TestUnderTheAuthorityAnUnpublishedCeilingIsTheAuthoritys(t *testing.T) { + s, stand := authorityServer(t) + gw := s.gatewayFor(http.Header{"Authorization": {sessionBearer}}) + gw.orgID, gw.sessionID = "org-from-the-authority", "session-1" + gw.ceilings = map[string][]Scope{} + ctx := context.Background() + viewer, err := gw.ForModule(ctx, "documents", Scope{ResourceKind: "documents", Actions: []string{"read"}}) + if err != nil { + t.Fatalf("a page's viewer read was refused locally: %v", err) + } + if token, err := viewer.WorkContext(ctx); err != nil || token != "issued-documents" || len(stand.asked()) != 1 { + t.Fatalf("the authority was not asked: %q, %v", token, err) + } + if _, err := gw.ForModule(ctx, "documents"); err == nil { + t.Fatal("an ask for nothing reached the authority") + } + gw.ceilings = map[string][]Scope{"records": {{ResourceKind: "records.types", Actions: []string{"read"}}}} + if _, err := gw.ForModule(ctx, "documents", Scope{ResourceKind: "documents", Actions: []string{"read"}}); err == nil { + t.Fatal("a solution publishing ceilings asked outside them") + } +} diff --git a/solution.go b/solution.go index 8d6678d..ebfbfc9 100644 --- a/solution.go +++ b/solution.go @@ -1973,6 +1973,14 @@ func (g *Gateway) withinPublishedCeiling(audience string, scopes []Scope) error return fmt.Errorf("this solution asked %q for resource kind %q with no actions: an action-less scope states no authority and is inside every ceiling by construction: %w", audience, scope.ResourceKind, &ClientError{StatusCode: http.StatusForbidden, Message: "this solution asked for a scope with no actions"}) } + // Under the authority interface, a solution that publishes no ceiling — a + // module page, which consumes nothing through passthrough and so has no + // ceiling it could publish here — is bounded by the authority alone: what + // the viewer holds, cut to what the audience serves. One that publishes + // ceilings is still held to them below. + if g.host != nil && len(g.ceilings) == 0 { + return nil + } } ceiling, declared := g.ceilings[audience] // A declared audience with an empty ceiling is a module this solution From 01b163cf81f9080de8eee5d9b4d899e20684b0f0 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sat, 24 Oct 2026 00:10:45 -0400 Subject: [PATCH 04/13] feat!: under the authority, a page acts on the edge's Work Context, never a session bearer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under the authority interface the page is reached through the authority's edge, which signs the person in and sends each request on with the person's Work Context addressed to this module. The runtime now takes that context as the request's identity — the authority's Check of it says who the viewer is — and issues every call a person makes to a module as a child of it, through one of the module's declared bindings (ForParent: parent token, the workload as actor, the audience derived by the authority from the binding). ForBinding is unchanged; ForModule is the saas route and is refused under the authority, naming ForParent. No session bearer reaches the runtime, and session.Current is no longer asked. Breaking for a page under the authority: call ForParent with a binding where it called ForModule with an audience. The saas path is unchanged. Tests: the viewer is the edge's context (a forged one, none, or a bearer alone is 401; sabotage-checked), ForParent's issue shape and holding, a refusal, and ForModule refused under the authority. README updated. Co-Authored-By: Claude Opus 5.5 --- README.md | 34 +++++++---- authority.go | 98 ++++++++++++++++++++---------- authority_test.go | 149 ++++++++++++++++++---------------------------- solution.go | 71 ++++++++++++++-------- 4 files changed, 193 insertions(+), 159 deletions(-) diff --git a/README.md b/README.md index b6a9a7c..9173314 100644 --- a/README.md +++ b/README.md @@ -184,20 +184,28 @@ up, after a retry, an eviction or a restart. When the composition names the authority interface (workspace configuration `authority/endpoint`: the authority gateway on a laptop, the host in a cell; see -codefly-dev/authority), the runtime asks it what it otherwise asks the saas -gateway, and needs neither the gateway nor the broker credential: - -- **Who a request is for**: the authority's `session.Current` for the request's - bearer. `OrgID` is that session's organization; `x-org-id`, `x-session-id` and - the installation header a request wrote are ignored, and a bearer the - authority does not know is answered 401 before the handler runs. -- **`ForModule(audience, scopes...)`**: the viewer's Work Context, issued by - `authority.Issue` for the person with the viewer's bearer, held until it is due - for renewal. +codefly-dev/authority), the page is reached through the authority's **edge**, +the auth gateway's role: the browser's one origin, which signs the person in and +sends each request on with the person's Work Context addressed to this module +(`x-codefly-work-context`). The runtime needs neither the saas gateway nor the +broker credential, and holds no session bearer: + +- **Who a request is for**: the authority's `Check` of that Work Context, + addressed to this module. `OrgID` is its organization; a header the request + wrote about itself is ignored, and a request with no context, or one the + authority does not check, is answered 401 before the handler runs. +- **`ForParent(binding, scopes...)`** (authority only): the viewer's own call to + a module, a child of the edge's Work Context through one of this module's + declared bindings, issued by `authority.Issue` with the workload credential the + run attests as the actor. The authority derives the audience from the binding + and grants within its invoke ceiling and what the person holds there; held + until it is due for renewal. - **`ForBinding(binding)`** (authority only): this module's own Work Context - through one of its declared bindings, in the viewer's organization, issued with - the workload credential the run attests (`CODEFLY__WORKLOAD_TOKEN_FILE`). -- **`WorkContext(ctx)`**: the token either gateway acts under, for a caller that + through one of its declared bindings, in the viewer's organization, with no + person in it. +- **`ForModule`** is the saas route; under the authority it is refused, naming + `ForParent`. +- **`WorkContext(ctx)`**: the token a gateway acts under, for a caller that reaches a module itself — over gRPC through the hop. Gateway passthrough (`Consumes`) has no gateway to relay through and is refused diff --git a/authority.go b/authority.go index f5ab31c..3848ca3 100644 --- a/authority.go +++ b/authority.go @@ -2,6 +2,9 @@ package solution import ( "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" "errors" "fmt" "net/http" @@ -13,19 +16,19 @@ import ( "google.golang.org/grpc/status" authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" - sessionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/session/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" - grpcmetadata "google.golang.org/grpc/metadata" ) // The authority interface (codefly-dev/authority): the authority gateway on a // laptop, the host in a cell. When the composition names one -// (authority/endpoint), the runtime asks it everything it otherwise asks the -// saas gateway: who a request's bearer is (session Current) and the Work -// Context a call to a module carries (authority Issue). No gateway stands in -// front of the page, so nothing is read from a header the request wrote, and -// the runtime mints nothing. +// (authority/endpoint), the page is reached through the authority's edge (the +// auth gateway's role), which signs the person in and sends each request on +// with the person's Work Context addressed to this module. The runtime holds +// no session bearer: it asks the authority's Check who that context is for, +// and every call it makes to a module is issued by the authority as a child of +// that context through one of this module's bindings (ForParent), or as this +// module's own work (ForBinding). The runtime mints nothing. const ( // AuthorityInterfaceGroup and AuthorityEndpointKey name where the // composition says which authority answers. @@ -35,14 +38,20 @@ const ( authorityCallTimeout = 10 * time.Second ) -// ErrNoViewer is a request whose bearer the authority does not know. -var ErrNoViewer = errors.New("the authority knows no session for this bearer") +// WorkContextHeader carries the person's Work Context the edge sends a request +// on with. +const WorkContextHeader = "x-codefly-work-context" + +// ErrNoViewer is a request whose Work Context the authority does not check. +var ErrNoViewer = errors.New("the authority does not check the Work Context this request carries for this module") // hostAuthority is the runtime's connection to the authority interface. type hostAuthority struct { conn *grpc.ClientConn authority authorityv1.AuthorityServiceClient - session sessionv1.SessionServiceClient + // principal is this module's: the audience a context the edge sends it is + // addressed to. + principal string // credential is this workload's credential as the run attests it, read on // every call so a rotation takes effect. credential func() (string, error) @@ -51,7 +60,7 @@ type hostAuthority struct { // dialAuthority connects to the authority at endpoint. The connection is // in-cell transport, which the mesh carries; the runtime terminates no TLS to // it. -func dialAuthority(endpoint, tokenFile string) (*hostAuthority, error) { +func dialAuthority(endpoint, tokenFile, principal string) (*hostAuthority, error) { target := strings.TrimSpace(endpoint) for _, scheme := range []string{"grpc://", "http://", "tcp://"} { target = strings.TrimPrefix(target, scheme) @@ -66,7 +75,7 @@ func dialAuthority(endpoint, tokenFile string) (*hostAuthority, error) { return &hostAuthority{ conn: conn, authority: authorityv1.NewAuthorityServiceClient(conn), - session: sessionv1.NewSessionServiceClient(conn), + principal: principal, credential: func() (string, error) { raw, err := os.ReadFile(tokenFile) if err != nil || strings.TrimSpace(string(raw)) == "" { @@ -77,33 +86,37 @@ func dialAuthority(endpoint, tokenFile string) (*hostAuthority, error) { }, nil } -// withBearer carries the request's bearer on a call to the authority. -func withBearer(ctx context.Context, bearer string) context.Context { - return grpcmetadata.AppendToOutgoingContext(ctx, "authorization", bearer) -} - -// viewer asks the authority who bearer is. -func (h *hostAuthority) viewer(ctx context.Context, bearer string) (*sessionv1.Session, error) { +// viewer asks the authority's Check whose the Work Context a request carries +// is, addressed to this module. A context the authority does not check is no +// viewer, whatever it claims. +func (h *hostAuthority) viewer(ctx context.Context, workContext string) (*authorityv1.WorkContextV1, error) { ctx, cancel := context.WithTimeout(ctx, authorityCallTimeout) defer cancel() - sess, err := h.session.Current(withBearer(ctx, bearer), &sessionv1.CurrentRequest{}) - if err != nil || sess.GetOrganizationId() == "" { + checked, err := h.authority.Check(ctx, &authorityv1.CheckRequest{Token: workContext, Audience: h.principal}) + if err != nil || checked.GetContext().GetTenantId() == "" { return nil, ErrNoViewer } - return sess, nil + return checked.GetContext(), nil } -// issueForViewer asks the authority for the viewer's Work Context at one -// audience, within the scopes asked: what ForModule's gateway carries. -func (h *hostAuthority) issueForViewer(ctx context.Context, bearer string, ask startTaskRequest) (string, time.Time, WorkContextPrincipals, error) { +// issueChild asks the authority for a child of the viewer's Work Context +// through one of this module's bindings: the audience is the binding's, and the +// scopes the binding's invoke ceiling narrowed to what was asked and to what +// the person holds there. +func (h *hostAuthority) issueChild(ctx context.Context, parent, binding string, scopes []workContextScope) (string, time.Time, WorkContextPrincipals, error) { + credential, err := h.credential() + if err != nil { + return "", time.Time{}, WorkContextPrincipals{}, err + } req := &authorityv1.IssueRequest{ - As: &authorityv1.IssueRequest_Person{Person: &authorityv1.PersonSession{}}, - Audience: ask.Audience, + As: &authorityv1.IssueRequest_ParentToken{ParentToken: parent}, + Actor: &authorityv1.WorkloadCredential{Credential: credential}, + OperationBindingId: binding, } - for _, scope := range ask.AuthorityScopes { - req.Scopes = append(req.Scopes, &authorityv1.WorkScopeV1{ResourceKind: scope.ResourceKind, Actions: scope.Actions}) + for _, scope := range scopes { + req.Scopes = append(req.Scopes, &authorityv1.WorkScopeV1{ResourceKind: scope.ResourceKind, Actions: scope.Actions, ResourceIds: scope.ResourceIDs}) } - return h.issue(withBearer(ctx, bearer), req) + return h.issue(ctx, req) } // issueForBinding asks the authority for this module's own Work Context @@ -160,12 +173,35 @@ func refusalStatus(err error) int { return http.StatusBadGateway } +// ForParent is the viewer's own call to a module, under the authority: a child +// of the Work Context the edge sent this request with, through one of this +// module's declared bindings. The authority derives the audience from the +// binding and grants within the binding's invoke ceiling and what the person +// holds there; scopes narrow it further. +func (g *Gateway) ForParent(ctx context.Context, binding string, scopes ...Scope) (*Gateway, error) { + if g.host == nil || g.parentContext == "" { + return nil, errors.New("ForParent is issued by the authority interface, as a child of the Work Context the edge sends a request with; this request carries none") + } + acting := *g + wire := make([]workContextScope, len(scopes)) + for i, scope := range scopes { + wire[i] = workContextScope{ResourceKind: scope.ResourceKind, Actions: append([]string(nil), scope.Actions...), ResourceIDs: append([]string(nil), scope.ResourceIDs...)} + } + sum := sha256.Sum256([]byte(g.parentContext)) + key, _ := json.Marshal(wire) + acting.delegation = &delegation{key: "parent\x00" + hex.EncodeToString(sum[:]) + "\x00" + binding + "\x00" + string(key), ask: startTaskRequest{OrgID: g.orgID, Binding: binding, AuthorityScopes: wire, child: true}} + if _, err := acting.workContext(ctx); err != nil { + return nil, g.surfaced(err) + } + return &acting, nil +} + // WorkContext is the Work Context this gateway acts under, issued (and renewed) // for ForModule's or ForBinding's ask: what a caller presents to a module it // reaches itself, over gRPC through the hop for example. func (g *Gateway) WorkContext(ctx context.Context) (string, error) { if g.delegation == nil && g.carriedContext == "" { - return "", errors.New("this gateway acts under no Work Context: call ForModule or ForBinding first") + return "", errors.New("this gateway acts under no Work Context: call ForParent, ForModule or ForBinding first") } return g.workContext(ctx) } diff --git a/authority_test.go b/authority_test.go index 3d4afdc..1993078 100644 --- a/authority_test.go +++ b/authority_test.go @@ -13,51 +13,40 @@ import ( "time" authorityv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/authority/v1" - sessionv1 "github.com/codefly-dev/authority/libraries/authority-client/go/gen/host/session/v1" "google.golang.org/grpc" "google.golang.org/grpc/codes" - grpcmetadata "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" "google.golang.org/protobuf/proto" ) -// interfaceAuthority answers the session and authority interfaces for one known -// bearer, and records every issue it is asked. +// interfaceAuthority answers the authority interface: it checks one known Work +// Context, the one the edge sends a request with for the page, and records +// every issue it is asked. type interfaceAuthority struct { - sessionv1.UnimplementedSessionServiceServer authorityv1.UnimplementedAuthorityServiceServer mu sync.Mutex issued []*authorityv1.IssueRequest - bearer []string } -const sessionBearer = "Bearer viewer-session" +// edgeContext is the person's Work Context the edge sends the page. +const edgeContext = "edge-context-for-the-page" -func authorityBearerOf(ctx context.Context) string { - md, _ := grpcmetadata.FromIncomingContext(ctx) - if values := md.Get("authorization"); len(values) == 1 { - return values[0] +func (a *interfaceAuthority) Check(_ context.Context, r *authorityv1.CheckRequest) (*authorityv1.CheckResponse, error) { + if r.GetToken() != edgeContext || r.GetAudience() != "page" { + return nil, status.Error(codes.Unauthenticated, "not a context for this audience") } - return "" + return &authorityv1.CheckResponse{Context: &authorityv1.WorkContextV1{TenantId: "org-from-the-authority", OwnerPrincipalId: "person-1", SessionId: "session-1", Audience: "page"}}, nil } -func (a *interfaceAuthority) Current(ctx context.Context, _ *sessionv1.CurrentRequest) (*sessionv1.Session, error) { - if authorityBearerOf(ctx) != sessionBearer { - return nil, status.Error(codes.Unauthenticated, "no session") - } - return &sessionv1.Session{SessionId: "session-1", PersonId: "person-1", OrganizationId: "org-from-the-authority"}, nil -} - -func (a *interfaceAuthority) Issue(ctx context.Context, r *authorityv1.IssueRequest) (*authorityv1.IssueResponse, error) { +func (a *interfaceAuthority) Issue(_ context.Context, r *authorityv1.IssueRequest) (*authorityv1.IssueResponse, error) { a.mu.Lock() defer a.mu.Unlock() a.issued = append(a.issued, proto.Clone(r).(*authorityv1.IssueRequest)) - a.bearer = append(a.bearer, authorityBearerOf(ctx)) - if r.GetAudience() == "refused" { - return nil, status.Error(codes.PermissionDenied, "person-1 holds nothing there") + if r.GetOperationBindingId() == "refused" { + return nil, status.Error(codes.PermissionDenied, "person-1 holds nothing through that binding") } return &authorityv1.IssueResponse{ - Token: "issued-" + r.GetAudience() + r.GetOperationBindingId(), + Token: "issued-" + r.GetOperationBindingId(), ExpiresAtUnix: time.Now().Add(5 * time.Minute).Unix(), Context: &authorityv1.WorkContextV1{TenantId: "org-from-the-authority", OwnerPrincipalId: "person-1"}, }, nil @@ -79,7 +68,6 @@ func authorityServer(t *testing.T) (*Server, *interfaceAuthority) { } stand := &interfaceAuthority{} g := grpc.NewServer() - sessionv1.RegisterSessionServiceServer(g, stand) authorityv1.RegisterAuthorityServiceServer(g, stand) go func() { _ = g.Serve(l) }() t.Cleanup(g.Stop) @@ -87,7 +75,7 @@ func authorityServer(t *testing.T) (*Server, *interfaceAuthority) { if err := os.WriteFile(credential, []byte("workload-credential"), 0o600); err != nil { t.Fatal(err) } - host, err := dialAuthority("grpc://"+l.Addr().String(), credential) + host, err := dialAuthority("grpc://"+l.Addr().String(), credential, "page") if err != nil { t.Fatal(err) } @@ -95,69 +83,67 @@ func authorityServer(t *testing.T) (*Server, *interfaceAuthority) { return &Server{manifest: Manifest{ID: "page"}, host: host}, stand } -// Who a request is for is the authority's answer about its bearer: a header the -// request wrote names nothing, and a bearer the authority does not know is -// refused before the handler runs. -func TestUnderTheAuthorityTheViewerIsTheSessionTheBearerBelongsTo(t *testing.T) { +// Who a request is for is the authority's Check of the Work Context the edge +// sent it with: a header the request wrote about itself names nothing, a +// context the authority does not check is refused before the handler runs, and +// a bearer alone is no viewer, since no session bearer reaches the page. +func TestUnderTheAuthorityTheViewerIsTheEdgesWorkContext(t *testing.T) { s, _ := authorityServer(t) var seen *Gateway handler := s.wrapRequest(func(_ *http.Request, gw *Gateway) (any, error) { seen = gw - return map[string]string{}, nil + return map[string]string{"ok": "yes"}, nil }) request := httptest.NewRequest(http.MethodGet, "/v1/page/types", nil) - request.Header.Set("authorization", sessionBearer) + request.Header.Set(WorkContextHeader, edgeContext) request.Header.Set(orgHeader, "org-the-caller-wrote") - request.Header.Set(sessionHeader, "session-the-caller-wrote") answer := httptest.NewRecorder() handler(answer, request) - if answer.Code != http.StatusOK || seen == nil || seen.OrgID() != "org-from-the-authority" || seen.sessionID != "session-1" { - t.Fatalf("answered %d with org %q", answer.Code, seen.OrgID()) - } - seen = nil - forged := httptest.NewRequest(http.MethodGet, "/v1/page/types", nil) - forged.Header.Set("authorization", "Bearer not-a-session") - forged.Header.Set(orgHeader, "org-the-caller-wrote") - answer = httptest.NewRecorder() - handler(answer, forged) - if answer.Code != http.StatusUnauthorized || seen != nil { - t.Fatalf("an unknown bearer answered %d and reached the handler: %v", answer.Code, seen != nil) + if answer.Code != http.StatusOK || seen == nil || seen.OrgID() != "org-from-the-authority" || seen.sessionID != "session-1" || seen.parentContext != edgeContext { + t.Fatalf("answered %d with %+v", answer.Code, seen) + } + for name, header := range map[string]http.Header{ + "no Work Context": {}, + "a forged Work Context": {http.CanonicalHeaderKey(WorkContextHeader): {"forged"}}, + "a session bearer alone": {"Authorization": {"Bearer viewer-session"}}, + } { + request := httptest.NewRequest(http.MethodGet, "/v1/page/types", nil) + request.Header = header + answer := httptest.NewRecorder() + handler(answer, request) + if answer.Code != http.StatusUnauthorized { + t.Errorf("%s answered %d", name, answer.Code) + } } } -// ForModule's Work Context is the viewer's, issued by the authority with the -// viewer's bearer, and held until it is due for renewal; ForBinding's is this -// module's own, issued with its workload credential in the viewer's -// organization through the named binding. +// ForParent's Work Context is a child of the edge's, issued by the authority +// through the named binding with this workload as actor, held until it is due +// for renewal; ForBinding's is this module's own, in the viewer's organization. +// A person's call never goes by ForModule under the authority. func TestUnderTheAuthorityEveryWorkContextIsIssued(t *testing.T) { s, stand := authorityServer(t) - gw := s.gatewayFor(http.Header{"Authorization": {sessionBearer}}) - gw.orgID, gw.sessionID = "org-from-the-authority", "session-1" + gw := s.gatewayFor(http.Header{}) + gw.parentContext, gw.orgID, gw.sessionID = edgeContext, "org-from-the-authority", "session-1" ctx := context.Background() - viewer, err := gw.ForModule(ctx, "records", Scope{ResourceKind: "records.types", Actions: []string{"read"}}) + viewer, err := gw.ForParent(ctx, "documents-read", Scope{ResourceKind: "documents", Actions: []string{"read"}}) if err != nil { t.Fatal(err) } token, err := viewer.WorkContext(ctx) - if err != nil || token != "issued-records" { - t.Fatalf("viewer token %q: %v", token, err) + if err != nil || token != "issued-documents-read" { + t.Fatalf("child token %q: %v", token, err) } if again, _ := viewer.WorkContext(ctx); again != token || len(stand.asked()) != 1 { t.Fatalf("a held Work Context was issued again: %d issues", len(stand.asked())) } asked := stand.asked()[0] - if asked.GetPerson() == nil || asked.GetAudience() != "records" || stand.bearer[0] != sessionBearer || - len(asked.GetScopes()) != 1 || asked.GetScopes()[0].GetResourceKind() != "records.types" { - t.Fatalf("viewer issue asked %v with bearer %q", asked, stand.bearer[0]) + if asked.GetParentToken() != edgeContext || asked.GetActor().GetCredential() != "workload-credential" || asked.GetOperationBindingId() != "documents-read" || + asked.GetAudience() != "" || len(asked.GetScopes()) != 1 || asked.GetScopes()[0].GetResourceKind() != "documents" { + t.Fatalf("child issue asked %v", asked) } - // The published contract's ceilings govern what a viewer is asked for; a - // binding ask names no scopes and is held to the binding by the authority. - gw.ceilings = map[string][]Scope{ - "records": {{ResourceKind: "records.types", Actions: []string{"read"}}}, - "refused": {{ResourceKind: "documents", Actions: []string{"read"}}}, - } own, err := gw.ForBinding(ctx, "records-types") if err != nil { t.Fatal(err) @@ -170,12 +156,21 @@ func TestUnderTheAuthorityEveryWorkContextIsIssued(t *testing.T) { t.Fatalf("binding issue asked %v", asked) } - if _, err := gw.ForModule(ctx, "refused", Scope{ResourceKind: "documents", Actions: []string{"read"}}); err == nil || !strings.Contains(err.Error(), "403") { + if _, err := gw.ForParent(ctx, "refused", Scope{ResourceKind: "documents", Actions: []string{"read"}}); err == nil || !strings.Contains(err.Error(), "403") { t.Fatalf("a refused issue was not a refusal: %v", err) } + gw.ceilings = map[string][]Scope{} + if _, err := gw.ForModule(ctx, "documents", Scope{ResourceKind: "documents", Actions: []string{"read"}}); err == nil || !strings.Contains(err.Error(), "ForParent") { + t.Fatalf("ForModule under the authority: %v", err) + } if _, err := gw.WorkContext(ctx); err == nil { t.Fatal("a gateway with no ask handed out a Work Context") } + // A request the edge did not send carries no parent to issue a child of. + bare := s.gatewayFor(http.Header{}) + if _, err := bare.ForParent(ctx, "documents-read"); err == nil { + t.Fatal("ForParent issued a child of no context") + } } // A composition that names the authority needs no saas gateway and no broker @@ -193,34 +188,8 @@ func TestAConfigNamingTheAuthorityNeedsNoGateway(t *testing.T) { // ForBinding is the authority's alone. func TestForBindingWithoutTheAuthorityIsRefused(t *testing.T) { - gw := newGateway("https://gateway.test", sessionBearer, "org", "session") + gw := newGateway("https://gateway.test", "Bearer viewer-session", "org", "session") if _, err := gw.ForBinding(context.Background(), "records-types"); err == nil || !strings.Contains(err.Error(), "authority/endpoint") { t.Fatalf("ForBinding without the authority: %v", err) } } - -// Under the authority, a solution that publishes no ceiling (a module page) is -// bounded by the authority alone; an empty ask is still refused before it -// reaches the authority, and a solution that publishes ceilings stays held to -// them. -func TestUnderTheAuthorityAnUnpublishedCeilingIsTheAuthoritys(t *testing.T) { - s, stand := authorityServer(t) - gw := s.gatewayFor(http.Header{"Authorization": {sessionBearer}}) - gw.orgID, gw.sessionID = "org-from-the-authority", "session-1" - gw.ceilings = map[string][]Scope{} - ctx := context.Background() - viewer, err := gw.ForModule(ctx, "documents", Scope{ResourceKind: "documents", Actions: []string{"read"}}) - if err != nil { - t.Fatalf("a page's viewer read was refused locally: %v", err) - } - if token, err := viewer.WorkContext(ctx); err != nil || token != "issued-documents" || len(stand.asked()) != 1 { - t.Fatalf("the authority was not asked: %q, %v", token, err) - } - if _, err := gw.ForModule(ctx, "documents"); err == nil { - t.Fatal("an ask for nothing reached the authority") - } - gw.ceilings = map[string][]Scope{"records": {{ResourceKind: "records.types", Actions: []string{"read"}}}} - if _, err := gw.ForModule(ctx, "documents", Scope{ResourceKind: "documents", Actions: []string{"read"}}); err == nil { - t.Fatal("a solution publishing ceilings asked outside them") - } -} diff --git a/solution.go b/solution.go index ebfbfc9..aa1ef26 100644 --- a/solution.go +++ b/solution.go @@ -910,7 +910,7 @@ func (s *Server) start(ctx context.Context) (net.Listener, error) { // contract publishes, and a value the platform never provisioned is named // here rather than discovered when a mint is refused. if s.cfg.authorityEndpoint != "" { - authority, err := dialAuthority(s.cfg.authorityEndpoint, s.cfg.projectedTokenPath) + authority, err := dialAuthority(s.cfg.authorityEndpoint, s.cfg.projectedTokenPath, s.manifest.ID) if err != nil { return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) } @@ -1290,21 +1290,31 @@ func (s *Server) handleCapabilities(w http.ResponseWriter, _ *http.Request) { func (s *Server) wrapRequest(handler RequestHandler) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - bearer := r.Header.Get("authorization") - if bearer == "" { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing bearer"}) - return - } - gw := s.gatewayFor(r.Header) + var gw *Gateway if s.host != nil { - // Who the request is for is the authority's answer about the - // bearer, never a header the request wrote. - viewer, err := s.host.viewer(r.Context(), bearer) + // Under the authority the edge sends the request on with the + // person's Work Context for this module, and who it is for is the + // authority's Check of that context, never a header the request + // wrote about itself. No session bearer reaches this process. + carried := r.Header.Get(WorkContextHeader) + if carried == "" { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no Work Context: this page is reached through the authority's edge, which signs the person in"}) + return + } + viewer, err := s.host.viewer(r.Context(), carried) if err != nil { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "the authority knows no session for this bearer"}) + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()}) return } - gw.orgID, gw.sessionID, gw.installationID, gw.installationErr = viewer.GetOrganizationId(), viewer.GetSessionId(), "", nil + gw = s.gatewayFor(http.Header{}) + gw.parentContext = carried + gw.orgID, gw.sessionID, gw.installationID, gw.installationErr = viewer.GetTenantId(), viewer.GetSessionId(), "", nil + } else { + if r.Header.Get("authorization") == "" { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing bearer"}) + return + } + gw = s.gatewayFor(r.Header) } result, err := handler(r, gw) if err != nil { @@ -1337,6 +1347,10 @@ func drainAndClose(resp *http.Response) { type Gateway struct { baseURL string bearer string + // parentContext is, under the authority, the person's Work Context the + // edge sent the request with: the parent every ForParent child is issued + // from. Never forwarded as it is. + parentContext string // carriedContext is the install call's opaque bearer, carried unchanged // to the host for verification. Viewer gateways never inherit an inbound // capability: their Work Context is minted through ForModule instead. @@ -1973,14 +1987,14 @@ func (g *Gateway) withinPublishedCeiling(audience string, scopes []Scope) error return fmt.Errorf("this solution asked %q for resource kind %q with no actions: an action-less scope states no authority and is inside every ceiling by construction: %w", audience, scope.ResourceKind, &ClientError{StatusCode: http.StatusForbidden, Message: "this solution asked for a scope with no actions"}) } - // Under the authority interface, a solution that publishes no ceiling — a - // module page, which consumes nothing through passthrough and so has no - // ceiling it could publish here — is bounded by the authority alone: what - // the viewer holds, cut to what the audience serves. One that publishes - // ceilings is still held to them below. - if g.host != nil && len(g.ceilings) == 0 { - return nil - } + // Under the authority interface, a solution that publishes no ceiling — a + // module page, which consumes nothing through passthrough and so has no + // ceiling it could publish here — is bounded by the authority alone: what + // the viewer holds, cut to what the audience serves. One that publishes + // ceilings is still held to them below. + if g.host != nil && len(g.ceilings) == 0 { + return nil + } } ceiling, declared := g.ceilings[audience] // A declared audience with an empty ceiling is a module this solution @@ -2099,9 +2113,11 @@ type startTaskRequest struct { Audience string `json:"audience"` AuthorityScopes []workContextScope `json:"authorityScopes"` // Binding, under the authority, asks for this module's own Work Context - // through one of its declared bindings (ForBinding). The saas route has no - // such ask, so it is never sent there. + // through one of its declared bindings (ForBinding), or with child, for a + // child of the viewer's through it (ForParent). The saas route has no such + // ask, so neither is ever sent there. Binding string `json:"-"` + child bool } func (g *Gateway) mint(ctx context.Context, ask startTaskRequest) (string, time.Time, error) { @@ -2110,10 +2126,15 @@ func (g *Gateway) mint(ctx context.Context, ask startTaskRequest) (string, time. var renew time.Time var principals WorkContextPrincipals var err error - if ask.Binding != "" { + switch { + case ask.child: + token, renew, principals, err = g.host.issueChild(ctx, g.parentContext, ask.Binding, ask.AuthorityScopes) + case ask.Binding != "": token, renew, principals, err = g.host.issueForBinding(ctx, ask.OrgID, ask.Binding) - } else { - token, renew, principals, err = g.host.issueForViewer(ctx, g.bearer, ask) + default: + // A person's call reaches a module as a child of the context the + // edge sent, through a binding: ForParent. + err = &ClientError{StatusCode: http.StatusInternalServerError, Message: "under the authority a page calls a module through one of its bindings: Gateway.ForParent"} } if err != nil { return "", time.Time{}, err From 8cd484308d2a35413d1b9c4ad944f9d157bd8bb2 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 25 Oct 2026 04:43:10 -0400 Subject: [PATCH 05/13] feat: under the authority, the passthrough relays as the viewer; refusals keep their reason Under the authority interface the passthrough was refused at boot, so every solution that consumed a module carried its own relay: decode the page's Connect call, issue the viewer's child Work Context through its binding, call the module over gRPC through the hop, pin and mask as declared. The wiki and proposals held labelled copies of the same code. The runtime now serves the same Consumes declaration under the authority. A consumed module whose new ConsumedModule.Authority names its configuration slots (the module's gRPC address and the binding id) is relayed over gRPC as the viewer: a child of the edge's Work Context issued through that binding with the method's declared scopes (ForParent), the declared pin merged in, the response allowlist applied, at /v1//modules//, where the edge forwards the solution's requests unchanged. The hop's address is admitted by the SDK's one rule for a credential's destination (AdmitEndpoint). A module naming no slots is not relayed and its routes stay the solution's; a missing slot, a ViewerBearer or a server-streaming relayed method refuses the boot. Reused: resolvePassthrough, the Connect handler and its protocol handling, ForParent, Apply, codeForStatus, relayedError's 4xx/5xx line; the pin merge and the viewer check were extracted rather than restated. WorkContextRefusal.Reason keeps the authority's reason for refusing an issue (not a transport failure's text; the saas broker's body is still never kept), and handlers and the passthrough relay it. A refusal from a solution's own route reaches a Connect or gRPC client as a Connect error with the authority's code instead of {"error": ...}, which Connect clients ignore; a plain REST caller still gets the JSON envelope. Tests: relay as the viewer (child issue, pin, mask, unrelayed module 404, no Work Context 401), the authority's and the module's refusals keep code and reason, a module failure leaks no text, boot refusals naming the slot and a cleartext off-machine address, refusals in the caller's protocol, the reason on WorkContextRefusal. Sabotage-checked. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 16 ++ README.md | 19 ++- authority.go | 13 +- contract.go | 6 + gateway_error.go | 32 ++++ go.mod | 4 +- passthrough.go | 63 ++++++-- passthrough_authority.go | 225 ++++++++++++++++++++++++++++ passthrough_authority_test.go | 267 ++++++++++++++++++++++++++++++++++ solution.go | 53 +++---- 10 files changed, 656 insertions(+), 42 deletions(-) create mode 100644 passthrough_authority.go create mode 100644 passthrough_authority_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e9ad53..33031dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## Unreleased — the passthrough under the authority; refusals keep their reason + +Under the authority interface the passthrough is no longer refused at boot: a +consumed module whose new `ConsumedModule.Authority` names its configuration +slots (gRPC address, binding id) is relayed over gRPC as the viewer, a child +of the edge's Work Context issued through that binding, pinned and masked as +declared, at `/v1//modules/…`. The copies solutions carried of this relay +can be deleted. + +`WorkContextRefusal.Reason` carries the authority interface's reason for a +refusal (a transport failure keeps none, and the saas broker's body is still +never retained); handlers and the passthrough relay it. A refusal from a +solution's own route reaches a Connect or gRPC client as a Connect error, code +and message, instead of `{"error": …}`, which Connect clients ignore; a plain +REST caller (no `Connect-Protocol-Version`) still gets `{"error": …}`. + ## Unreleased — `Gateway.InstallationID` exposes the stamped installation `Gateway` exposed `OrgID()` but not the installation beside it, so a handler diff --git a/README.md b/README.md index 9173314..77e467b 100644 --- a/README.md +++ b/README.md @@ -208,8 +208,23 @@ broker credential, and holds no session bearer: - **`WorkContext(ctx)`**: the token a gateway acts under, for a caller that reaches a module itself — over gRPC through the hop. -Gateway passthrough (`Consumes`) has no gateway to relay through and is refused -at boot; a page calls each module through the hop instead. Without +- **The passthrough** (`Consumes`) is served from the same declaration, at + `/v1//modules///` (the edge routes a solution + at `/v1/` and forwards requests unchanged). A consumed module whose + `Authority` names its configuration slots — the module's gRPC address, the + hop in front of it, and the id of this solution's binding for it — is relayed + over gRPC as the viewer: a child of the edge's Work Context through that + binding with the method's declared scopes, the pin merged in and the + response allowlist applied. A module that names no slots is not relayed, and + its routes are the solution's. A missing slot, a ViewerBearer or a + server-streaming relayed method refuses the boot. A solution that publishes + no ceiling is bounded by the authority. +- **Every refusal names its reason.** The authority's reason for refusing an + issue is kept (`WorkContextRefusal.Reason`), and a refusal reaches a Connect + or gRPC caller as a Connect error with the authority's code — a Connect + client reads nothing else — and a plain REST caller as `{"error": …}`. + +Without `authority/endpoint` nothing changes: the runtime runs against the saas host as before, so a composition can still run saas locally. diff --git a/authority.go b/authority.go index 3848ca3..a3374a0 100644 --- a/authority.go +++ b/authority.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "connectrpc.com/connect" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" @@ -93,6 +94,9 @@ func (h *hostAuthority) viewer(ctx context.Context, workContext string) (*author ctx, cancel := context.WithTimeout(ctx, authorityCallTimeout) defer cancel() checked, err := h.authority.Check(ctx, &authorityv1.CheckRequest{Token: workContext, Audience: h.principal}) + if err != nil && refusalStatus(err) < http.StatusInternalServerError { + return nil, fmt.Errorf("%w: %s", ErrNoViewer, status.Convert(err).Message()) + } if err != nil || checked.GetContext().GetTenantId() == "" { return nil, ErrNoViewer } @@ -139,7 +143,14 @@ func (h *hostAuthority) issue(ctx context.Context, req *authorityv1.IssueRequest defer cancel() issued, err := h.authority.Issue(ctx, req) if err != nil { - return "", time.Time{}, WorkContextPrincipals{}, &WorkContextRefusal{Audience: req.GetAudience(), StatusCode: refusalStatus(err)} + refusal := &WorkContextRefusal{Audience: req.GetAudience(), StatusCode: refusalStatus(err), code: connect.Code(status.Code(err))} + // The authority's reason for refusing the ask is the refusal's; a + // failure to reach it is nobody's refusal, and its text, which can name + // an address, is not kept. + if refusal.StatusCode < http.StatusInternalServerError { + refusal.Reason = status.Convert(err).Message() + } + return "", time.Time{}, WorkContextPrincipals{}, refusal } wc := issued.GetContext() principals := WorkContextPrincipals{OrgID: wc.GetTenantId(), OwnerPrincipalID: wc.GetOwnerPrincipalId(), CurrentActorPrincipalID: wc.GetOwnerPrincipalId()} diff --git a/contract.go b/contract.go index ddb1777..3a9894a 100644 --- a/contract.go +++ b/contract.go @@ -189,6 +189,12 @@ func (s *Server) resolveContract() (effectiveContract, error) { // disagreed would be this one. Principal: s.principal, } + // Under the authority, a solution that publishes no ceiling is bounded by + // the authority: each call is issued through one of its bindings, within + // that binding's ceiling and what the person holds. + if s.host != nil && len(s.declaredContract.Ceilings) == 0 { + return contract, nil + } bindings, err := checkContract(s.consumed, s.declaredContract, s.cfg.profile) if err != nil { return contract, err diff --git a/gateway_error.go b/gateway_error.go index b8f0f08..1a0ebe8 100644 --- a/gateway_error.go +++ b/gateway_error.go @@ -35,6 +35,11 @@ func handlerErrorResponse(err error) (int, string) { if errors.As(err, &clientErr) && clientErr != nil && clientErr.StatusCode >= 400 && clientErr.StatusCode <= 499 { return clientErr.StatusCode, clientErr.Message } + // The authority's refusal of the ask names its reason (WorkContextRefusal). + var refusal *WorkContextRefusal + if errors.As(err, &refusal) && refusal != nil && refusal.Reason != "" && refusal.StatusCode >= 400 && refusal.StatusCode <= 499 { + return refusal.StatusCode, "the authority refused this call: " + refusal.Reason + } var gatewayErr *GatewayError if errors.As(err, &gatewayErr) && gatewayErr != nil { status := gatewayErr.StatusCode @@ -110,3 +115,30 @@ func handlerErrorResponse(err error) (int, string) { // private details in ordinary errors returned to the runtime. return http.StatusBadGateway, err.Error() } + +// connectRefusals recognises a Connect or gRPC caller. A Connect unary request +// is told apart from a plain REST POST by its protocol header, which every +// Connect client sends; the kit's REST helpers do not. +var connectRefusals = connect.NewErrorWriter(connect.WithRequireConnectProtocolHeader()) + +// writeRefusal answers a refused request in the caller's protocol: a Connect +// error — code and message — to a Connect or gRPC client, which reads nothing +// else, and {"error": message} to a plain REST caller. The code is the +// authority's own when the refusal is the authority's, a module's when a +// handler relayed one, and otherwise the one the status maps to. +func writeRefusal(w http.ResponseWriter, r *http.Request, status int, message string, err error) { + if !connectRefusals.IsSupported(r) { + writeJSON(w, status, map[string]string{"error": message}) + return + } + code := codeForStatus(status) + var refusal *WorkContextRefusal + var rpc *connect.Error + switch { + case errors.As(err, &refusal) && refusal != nil && refusal.code != 0: + code = refusal.code + case errors.As(err, &rpc) && rpc != nil: + code = rpc.Code() + } + _ = connectRefusals.Write(w, r, connect.NewError(code, errors.New(message))) +} diff --git a/go.mod b/go.mod index be26f0d..558c3a1 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.27.0 require ( connectrpc.com/connect v1.21.0 + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023221258-84ff743d12b7 github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda github.com/codefly-dev/saas-sdk-go v0.1.3-0.20261014043138-b7169d3c4095 github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 @@ -12,6 +13,7 @@ require ( github.com/modelcontextprotocol/go-sdk v1.8.0 golang.org/x/tools v0.52.0 google.golang.org/genproto/googleapis/api v0.0.0-20261019180522-ce908d29299b + google.golang.org/grpc v1.84.0 google.golang.org/protobuf v1.36.12 gopkg.in/yaml.v3 v3.0.1 ) @@ -24,7 +26,6 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.5.0 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261023221258-84ff743d12b7 // indirect github.com/go-openapi/analysis v1.0.0 // indirect github.com/go-openapi/errors v0.22.9 // indirect github.com/go-openapi/jsonpointer v1.0.1 // indirect @@ -66,5 +67,4 @@ require ( golang.org/x/text v0.43.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20261012220534-4341edf3f941 // indirect - google.golang.org/grpc v1.84.0 // indirect ) diff --git a/passthrough.go b/passthrough.go index 80496a0..ea6aded 100644 --- a/passthrough.go +++ b/passthrough.go @@ -75,6 +75,11 @@ type ConsumedModule struct { ViewerBearer bool // Methods is the allowlist. Nothing else of the module is reachable. Methods []ConsumedMethod + // Authority, under the authority interface, names the configuration + // slots that say where the module is and which of this solution's + // bindings the viewer's calls are issued through (passthrough_authority.go). + // A module that names none is not relayed there. Ignored under the gateway. + Authority AuthorityRelay } // ConsumedMethod is one operation of a consumed module the page may call. @@ -306,6 +311,12 @@ func (s *Server) validatePassthrough() (map[string]passthroughRoute, error) { if s.cfg.consumes.err != nil { return nil, fmt.Errorf("consumed modules cannot be checked against api.consumes: %w", s.cfg.consumes.err) } + // Under the authority nothing goes through the gateway's federation, so + // there is no /v1/ prefix to check for: a relayed module is reached + // through the hop its slots name, and openRelays checks those. + if s.cfg.authorityEndpoint != "" { + return routes, nil + } return routes, checkConsumed(s.consumed, s.cfg.consumes.targets) } @@ -328,6 +339,14 @@ func (s *Server) mountPassthrough(mux *http.ServeMux) error { return err } } + if s.host != nil { + // Under the authority: the relayed modules, at the route the edge + // forwards this solution's requests on unchanged. + if routes = relayedRoutes(routes, s.relays); len(routes) > 0 { + mux.Handle(s.authorityRoute()+PassthroughPathPrefix, http.StripPrefix(s.authorityRoute(), s.passthroughHandler(routes))) + } + return nil + } mux.Handle(PassthroughPathPrefix, s.passthroughHandler(routes)) return nil } @@ -434,8 +453,12 @@ func (s *Server) passthroughRoute(route passthroughRoute) http.Handler { ) return http.StripPrefix(PassthroughPathPrefix+route.module.As, handler) } + forward := s.forward + if s.host != nil { + forward = s.relay + } 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) + resp, err := forward(ctx, route, req) if err != nil { return nil, err } @@ -492,20 +515,29 @@ func (s *Server) authorize(ctx context.Context, route passthroughRoute, header h } 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, msg); err != nil { - return nil, connect.NewError(connect.CodeInternal, errors.New("the declared pin cannot be applied")) - } + if err := applyPin(route, msg); err != nil { + return nil, err } return gw, nil } +// applyPin merges the declared pin into the request. It is merged through the +// wire form: the pin is the generated type, the request a dynamic message over +// the same descriptor. +func applyPin(route passthroughRoute, msg *dynamicpb.Message) error { + if route.method.Pin == nil { + return nil + } + pinned, err := proto.Marshal(route.method.Pin) + if err != nil { + return connect.NewError(connect.CodeInternal, errors.New("the declared pin cannot be encoded")) + } + if err := (proto.UnmarshalOptions{Merge: true}).Unmarshal(pinned, msg); err != nil { + return connect.NewError(connect.CodeInternal, errors.New("the declared pin cannot be applied")) + } + return 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 @@ -515,7 +547,14 @@ func (s *Server) authorize(ctx context.Context, route passthroughRoute, header h func relayedError(err error) error { var refusal *WorkContextRefusal if errors.As(err, &refusal) && refusal != nil && refusal.StatusCode >= 400 && refusal.StatusCode < 500 { - return connect.NewError(codeForStatus(refusal.StatusCode), errors.New("the viewer's authority for this module was refused")) + code, message := codeForStatus(refusal.StatusCode), "the viewer's authority for this module was refused" + if refusal.code != 0 { + code = refusal.code + } + if refusal.Reason != "" { + message += ": " + truncated(refusal.Reason) + } + return connect.NewError(code, errors.New(message)) } var streamErr *StreamError if errors.As(err, &streamErr) && streamErr != nil { diff --git a/passthrough_authority.go b/passthrough_authority.go new file mode 100644 index 0000000..e667e97 --- /dev/null +++ b/passthrough_authority.go @@ -0,0 +1,225 @@ +package solution + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "log" + "net/http" + "strings" + + "connectrpc.com/connect" + "github.com/codefly-dev/sdk-go/workcontext" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + grpcmetadata "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/dynamicpb" +) + +// --- The passthrough under the authority interface --- +// +// Under the authority there is no saas gateway to transcode through. The same +// declaration (Consumes) is served instead by relaying each declared method +// over gRPC to the module, through the hop in front of it, as the viewer: a +// child of the Work Context the edge sent the request with, issued by the +// authority through one of this solution's bindings (ForParent) and carrying +// the method's declared scopes. The pin and the response allowlist apply +// exactly as they do under the gateway, and the module authorizes every call. +// +// Where the module is and which binding the child is issued through are the +// composition's to say, in two workspace configuration slots the module's +// declaration names (AuthorityRelay). A consumed module that names none is not +// relayed under the authority: its routes are left to the solution. +// +// The page reaches this backend through the edge, which routes a solution at +// /v1/ and forwards each request unchanged, so the passthrough is served at +// /v1//modules/// — the path the module's +// generated client already calls with the base URL `/modules/`. + +// AuthorityRelay names, for one consumed module, the workspace configuration +// slots the composition fills under the authority interface: the module's gRPC +// address (the hop in front of it, an ${endpoint:…} reference the run +// resolves) and the id this solution's binding for the module is installed +// under. All three are set, or none. +type AuthorityRelay struct { + Group string + EndpointKey string + BindingKey string +} + +func (a AuthorityRelay) declared() bool { return a != AuthorityRelay{} } + +// moduleRelay is one relayed module: the binding its children are issued +// through and its connection. +type moduleRelay struct { + binding string + conn *grpc.ClientConn +} + +// authorityRoute is where the authority's edge routes this solution. +func (s *Server) authorityRoute() string { return "/v1/" + s.manifest.ID } + +// openRelays reads every relayed module's slots and opens its connection, or +// refuses the boot naming what is missing: a backend that cannot name a +// binding cannot be issued a capability, and that belongs at start-up. +func (s *Server) openRelays(routes map[string]passthroughRoute, lookup func(group, key string) (string, error)) (map[string]*moduleRelay, error) { + relays := map[string]*moduleRelay{} + for _, module := range s.consumed { + a := module.Authority + if !a.declared() { + continue + } + if a.Group == "" || a.EndpointKey == "" || a.BindingKey == "" { + return nil, fmt.Errorf("consumed module %q: its authority relay names a group, an endpoint slot and a binding slot, all three", module.As) + } + if module.ViewerBearer { + return nil, fmt.Errorf("consumed module %q is ViewerBearer: under the authority interface no bearer reaches this process, so it cannot be relayed", module.As) + } + for _, route := range routes { + if route.module.As == module.As && route.md.IsStreamingServer() { + return nil, fmt.Errorf("consumed module %q: %s streams its answer, which is not relayed under the authority interface", module.As, route.md.FullName()) + } + } + slot := func(key, what string) (string, error) { + value, err := lookup(a.Group, key) + if err != nil || strings.TrimSpace(value) == "" { + return "", fmt.Errorf("consumed module %q: %s/%s names no %s; the composition supplies it under the authority interface", module.As, a.Group, key, what) + } + return strings.TrimSpace(value), nil + } + endpoint, err := slot(a.EndpointKey, "address") + if err != nil { + return nil, err + } + binding, err := slot(a.BindingKey, "binding") + if err != nil { + return nil, err + } + conn, err := dialModule(endpoint, s.cfg.transport, a.Group+"/"+a.EndpointKey) + if err != nil { + return nil, fmt.Errorf("consumed module %q: %w", module.As, err) + } + relays[module.As] = &moduleRelay{binding: binding, conn: conn} + } + return relays, nil +} + +// dialModule connects to a module's gRPC address. The viewer's Work Context +// travels on it, so where it may go is the SDK's one rule for a credential's +// destination (workcontext.AdmitEndpoint): https anywhere; cleartext to a +// loopback host on a local run, or to an in-cluster address the mesh carries. +// A bare host:port, as an endpoint reference resolves, is cleartext. +func dialModule(endpoint string, transport workcontext.TransportSource, what string) (*grpc.ClientConn, error) { + raw := strings.TrimSpace(endpoint) + if !strings.Contains(raw, "://") { + raw = "http://" + raw + } + admitted, err := workcontext.AdmitEndpoint(raw, transport, what) + if err != nil { + return nil, err + } + creds := insecure.NewCredentials() + if admitted.Scheme == "https" { + creds = credentials.NewTLS(&tls.Config{MinVersion: tls.VersionTLS12}) + } + return grpc.NewClient(admitted.Host, grpc.WithTransportCredentials(creds)) +} + +// relayedRoutes is the passthrough under the authority: the routes of the +// modules that are relayed. +func relayedRoutes(routes map[string]passthroughRoute, relays map[string]*moduleRelay) map[string]passthroughRoute { + relayed := map[string]passthroughRoute{} + for path, route := range routes { + if relays[route.module.As] != nil { + relayed[path] = route + } + } + return relayed +} + +// viewerGateway is the gateway for the person whose Work Context the edge sent +// a request with: who it is for is the authority's Check of that context, never +// a header the request wrote about itself. +func (s *Server) viewerGateway(ctx context.Context, carried string) (*Gateway, error) { + if carried == "" { + return nil, errors.New("no Work Context: this page is reached through the authority's edge, which signs the person in") + } + viewer, err := s.host.viewer(ctx, carried) + if err != nil { + return nil, err + } + gw := s.gatewayFor(http.Header{}) + gw.parentContext = carried + gw.orgID, gw.sessionID, gw.installationID, gw.installationErr = viewer.GetTenantId(), viewer.GetSessionId(), "", nil + return gw, nil +} + +// relay answers one call as the viewer under the authority: the child Work +// Context issued through the module's binding with the declared scopes, the +// pin merged in, the module called over gRPC, and only the declared response +// fields returned. +func (s *Server) relay(ctx context.Context, route passthroughRoute, req *connect.Request[dynamicpb.Message]) (*dynamicpb.Message, error) { + relay := s.relays[route.module.As] + if relay == nil { + return nil, connect.NewError(connect.CodeNotFound, errors.New("no such operation")) + } + gw, err := s.viewerGateway(ctx, req.Header().Get(WorkContextHeader)) + if err != nil { + return nil, connect.NewError(connect.CodeUnauthenticated, err) + } + timeout := route.method.Timeout + if timeout <= 0 { + timeout = DefaultPassthroughTimeout + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + acting, err := gw.ForParent(ctx, relay.binding, route.scopes()...) + if err != nil { + return nil, relayedError(err) + } + token, err := acting.WorkContext(ctx) + if err != nil { + return nil, relayedError(err) + } + if err := applyPin(route, req.Msg); err != nil { + return nil, err + } + limit := route.method.MaxResponseBytes + if limit <= 0 { + limit = DefaultTranscodedResponseLimit + } + out := dynamicpb.NewMessage(route.md.Output()) + procedure := "/" + string(route.md.Parent().FullName()) + "/" + string(route.md.Name()) + calling := grpcmetadata.AppendToOutgoingContext(ctx, WorkContextHeader, token) + if err := relay.conn.Invoke(calling, procedure, req.Msg, out, grpc.MaxCallRecvMsgSize(int(limit))); err != nil { + return nil, s.moduleRefusal(route, err) + } + if !route.method.WholeResponse { + out = Apply(route.method.Response, out) + } + return out, nil +} + +// moduleRefusal turns a module's gRPC answer into the Connect error the page +// receives, on the line relayedError draws for the gateway: a refusal of the +// page's own request keeps its code and the module's reason; anything else — +// unavailable, internal, a transport failure — is reported by kind and logged +// where an operator reads it, since its text may name what the page must not. +func (s *Server) moduleRefusal(route passthroughRoute, err error) error { + st := status.Convert(err) + switch st.Code() { + case codes.InvalidArgument, codes.NotFound, codes.AlreadyExists, codes.PermissionDenied, codes.Unauthenticated, + codes.FailedPrecondition, codes.Aborted, codes.OutOfRange, codes.ResourceExhausted, codes.Unimplemented: + return connect.NewError(connect.Code(st.Code()), errors.New(truncated(st.Message()))) + case codes.DeadlineExceeded: + return relayedError(context.DeadlineExceeded) + case codes.Canceled: + return relayedError(context.Canceled) + } + log.Printf("solution %q: relaying %s to %q failed: %v", s.manifest.ID, route.md.FullName(), route.module.As, err) + return connect.NewError(connect.CodeUnavailable, errors.New("the module call failed")) +} diff --git a/passthrough_authority_test.go b/passthrough_authority_test.go new file mode 100644 index 0000000..523a433 --- /dev/null +++ b/passthrough_authority_test.go @@ -0,0 +1,267 @@ +package solution + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + grpcmetadata "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/dynamicpb" +) + +// relayedModule stands in for a module behind the hop: it answers every +// method with a Thing, or with the refusal it is told to give, and records the +// Work Context and the request each call carried. +type relayedModule struct { + refuse error + mu sync.Mutex + calls []relayedCall +} + +type relayedCall struct { + method, workContext string + request *dynamicpb.Message +} + +func (m *relayedModule) serve(t *testing.T) string { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + g := grpc.NewServer(grpc.UnknownServiceHandler(func(_ any, stream grpc.ServerStream) error { + method, _ := grpc.MethodFromServerStream(stream) + in := thingMessage("GetThingRequest") + if err := stream.RecvMsg(in); err != nil { + return err + } + md, _ := grpcmetadata.FromIncomingContext(stream.Context()) + m.mu.Lock() + m.calls = append(m.calls, relayedCall{method: method, workContext: strings.Join(md.Get(WorkContextHeader), ","), request: in}) + refuse := m.refuse + m.mu.Unlock() + if refuse != nil { + return refuse + } + out := thingMessage("Thing") + setField(out, "entry_id", protoreflect.ValueOfString(in.Get(in.Descriptor().Fields().ByName("entry_id")).String())) + setField(out, "big", protoreflect.ValueOfInt64(42)) + setField(out, "secret", protoreflect.ValueOfString("never-to-the-page")) + return stream.SendMsg(out) + })) + go func() { _ = g.Serve(l) }() + t.Cleanup(g.Stop) + return l.Addr().String() +} + +// relayedPage is a page under the authority consuming the "things" module, +// relayed through the hop at the address its slots name, served as Serve +// mounts it. +func relayedPage(t *testing.T, binding string, refuse error) (*httptest.Server, *relayedModule, *interfaceAuthority) { + t.Helper() + s, stand := authorityServer(t) + module := &relayedModule{refuse: refuse} + slots := map[string]string{"page/THINGS_ENDPOINT": module.serve(t), "page/THINGS_BINDING": binding} + declared := passthroughModule() + declared.Methods[0].Pin = func() *dynamicpb.Message { + pin := thingMessage("GetThingRequest") + setField(pin, "tenant", protoreflect.ValueOfString("pinned-tenant")) + return pin + }() + declared.Authority = AuthorityRelay{Group: "page", EndpointKey: "THINGS_ENDPOINT", BindingKey: "THINGS_BINDING"} + notRelayed := ConsumedModule{As: "tenants", Scopes: declared.Scopes, Methods: []ConsumedMethod{{Name: "/things.v1.Things/Get", WholeResponse: true}}} + s.Consumes(declared, notRelayed) + s.cfg.transport = meshTransport{local: true} + routes, err := resolvePassthrough(s.consumed) + if err != nil { + t.Fatal(err) + } + if s.relays, err = s.openRelays(routes, func(group, key string) (string, error) { return slots[group+"/"+key], nil }); err != nil { + t.Fatal(err) + } + mux := http.NewServeMux() + if err := s.mountPassthrough(mux); err != nil { + t.Fatal(err) + } + page := httptest.NewServer(mux) + t.Cleanup(page.Close) + return page, module, stand +} + +// connectCall posts one Connect unary JSON call as the module's generated +// client does, with the Work Context the edge sends it on with. +func connectCall(t *testing.T, url, workContext, body string) (int, map[string]any) { + t.Helper() + request, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(body)) + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Connect-Protocol-Version", "1") + if workContext != "" { + request.Header.Set(WorkContextHeader, workContext) + } + response, err := http.DefaultClient.Do(request) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + raw, _ := io.ReadAll(response.Body) + answer := map[string]any{} + _ = json.Unmarshal(raw, &answer) + return response.StatusCode, answer +} + +const relayedSearchPath = "/v1/page/modules/things/things.v1.Things/Search" + +// Under the authority the passthrough relays a declared method to the module +// through the hop, as the viewer: a child of the edge's Work Context issued +// through the module's binding with the declared scopes, the pin merged in, +// and only the declared response fields returned. +func TestUnderTheAuthorityThePassthroughRelaysAsTheViewer(t *testing.T) { + page, module, stand := relayedPage(t, "things-binding", nil) + code, answer := connectCall(t, page.URL+relayedSearchPath, edgeContext, `{"entryId":"e1","tenant":"the-page-wrote"}`) + if code != http.StatusOK || answer["entryId"] != "e1" || answer["big"] != "42" || answer["secret"] != nil { + t.Fatalf("answered %d %v", code, answer) + } + call := module.calls[0] + tenant := call.request.Get(call.request.Descriptor().Fields().ByName("tenant")).String() + if call.method != "/things.v1.Things/Search" || call.workContext != "issued-things-binding" || tenant != "pinned-tenant" { + t.Fatalf("the module was called %+v (tenant %q)", call, tenant) + } + asked := stand.asked()[0] + if asked.GetParentToken() != edgeContext || asked.GetOperationBindingId() != "things-binding" || len(asked.GetScopes()) != 1 || asked.GetScopes()[0].GetResourceKind() != "things" { + t.Fatalf("the child was asked %v", asked) + } + // A module the composition names no slots for is not relayed here, and a + // request the edge did not send carries no viewer. + if code, _ := connectCall(t, page.URL+"/v1/page/modules/tenants/things.v1.Things/Get", edgeContext, `{}`); code != http.StatusNotFound { + t.Errorf("a module with no slots answered %d", code) + } + if code, answer := connectCall(t, page.URL+relayedSearchPath, "", `{}`); code != http.StatusUnauthorized || answer["code"] != "unauthenticated" { + t.Errorf("no Work Context answered %d %v", code, answer) + } + if len(module.calls) != 1 { + t.Fatalf("the module was called %d times", len(module.calls)) + } +} + +// The authority's refusal reaches the page with the authority's code and its +// reason; a module's refusal of the page's request keeps its code and reason; +// a module failure is reported by kind, and its text reaches no page. +func TestUnderTheAuthorityARelayedRefusalNamesItsReason(t *testing.T) { + page, _, _ := relayedPage(t, "refused", nil) + code, answer := connectCall(t, page.URL+relayedSearchPath, edgeContext, `{}`) + if code != http.StatusForbidden || answer["code"] != "permission_denied" || !strings.Contains(answer["message"].(string), "person-1 holds nothing through that binding") { + t.Fatalf("the authority's refusal answered %d %v", code, answer) + } + page, _, _ = relayedPage(t, "things-binding", status.Error(codes.NotFound, "no such thing in this collection")) + if code, answer := connectCall(t, page.URL+relayedSearchPath, edgeContext, `{}`); code != http.StatusNotFound || answer["message"] != "no such thing in this collection" { + t.Fatalf("the module's refusal answered %d %v", code, answer) + } + page, _, _ = relayedPage(t, "things-binding", status.Error(codes.Internal, "dial tcp 10.0.0.7:5432: refused")) + if code, answer := connectCall(t, page.URL+relayedSearchPath, edgeContext, `{}`); code != http.StatusServiceUnavailable || strings.Contains(answer["message"].(string), "10.0.0.7") { + t.Fatalf("a module failure answered %d %v", code, answer) + } +} + +// A relay the composition cannot complete refuses the boot, naming the slot; +// a cleartext address that leaves the machine is not one the viewer's Work +// Context may travel to. +func TestUnderTheAuthorityARelayNeedsItsSlots(t *testing.T) { + s, _ := authorityServer(t) + declared := passthroughModule() + declared.Authority = AuthorityRelay{Group: "page", EndpointKey: "THINGS_ENDPOINT", BindingKey: "THINGS_BINDING"} + s.Consumes(declared) + routes, err := resolvePassthrough(s.consumed) + if err != nil { + t.Fatal(err) + } + for name, c := range map[string]struct { + slots map[string]string + want string + }{ + "no binding": {map[string]string{"THINGS_ENDPOINT": "127.0.0.1:1"}, "page/THINGS_BINDING"}, + "no address": {map[string]string{"THINGS_BINDING": "b"}, "page/THINGS_ENDPOINT"}, + "cleartext off the machine": {map[string]string{"THINGS_ENDPOINT": "hop.example.com:9090", "THINGS_BINDING": "b"}, "plaintext"}, + } { + _, err := s.openRelays(routes, func(_, key string) (string, error) { + if v, ok := c.slots[key]; ok { + return v, nil + } + return "", errors.New("unset") + }) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s: %v", name, err) + } + } +} + +// A refusal on a route the solution serves itself is a Connect error to a +// Connect client, which reads nothing else, and {"error"} to a REST caller; +// an authority refusal a handler returns keeps the authority's code and reason. +func TestARefusalIsWrittenInTheCallersProtocol(t *testing.T) { + s, _ := authorityServer(t) + handler := s.wrapRequest(func(r *http.Request, gw *Gateway) (any, error) { + _, err := gw.ForParent(r.Context(), "refused", Scope{ResourceKind: "things", Actions: []string{"read"}}) + return nil, err + }) + call := func(connectClient bool, workContext string) (int, map[string]any) { + request := httptest.NewRequest(http.MethodPost, "/v1/page/things", strings.NewReader("{}")) + request.Header.Set("Content-Type", "application/json") + if connectClient { + request.Header.Set("Connect-Protocol-Version", "1") + } + if workContext != "" { + request.Header.Set(WorkContextHeader, workContext) + } + recorder := httptest.NewRecorder() + handler(recorder, request) + answer := map[string]any{} + _ = json.Unmarshal(recorder.Body.Bytes(), &answer) + return recorder.Code, answer + } + if code, answer := call(true, edgeContext); code != http.StatusForbidden || answer["code"] != "permission_denied" || !strings.Contains(answer["message"].(string), "person-1 holds nothing through that binding") { + t.Errorf("a Connect client was answered %d %v", code, answer) + } + if code, answer := call(false, edgeContext); code != http.StatusForbidden || !strings.Contains(answer["error"].(string), "person-1 holds nothing through that binding") || answer["code"] != nil { + t.Errorf("a REST caller was answered %d %v", code, answer) + } + if code, answer := call(true, ""); code != http.StatusUnauthorized || answer["code"] != "unauthenticated" || answer["message"] == "" { + t.Errorf("a Connect client with no Work Context was answered %d %v", code, answer) + } + if code, answer := call(false, "forged"); code != http.StatusUnauthorized || !strings.Contains(answer["error"].(string), "not a context for this audience") { + t.Errorf("a forged Work Context was answered %d %v", code, answer) + } +} + +// The authority's reason survives the refusal; a failure to reach it is not a +// refusal and keeps no text. +func TestAWorkContextRefusalKeepsTheAuthoritysReason(t *testing.T) { + s, _ := authorityServer(t) + gw := s.gatewayFor(http.Header{}) + gw.parentContext = edgeContext + _, err := gw.ForParent(context.Background(), "refused") + var refusal *WorkContextRefusal + if !errors.As(err, &refusal) || refusal.Reason != "person-1 holds nothing through that binding" || refusal.StatusCode != http.StatusForbidden || !strings.Contains(err.Error(), refusal.Reason) { + t.Fatalf("the refusal: %#v", err) + } + if status, message := handlerErrorResponse(err); status != http.StatusForbidden || !strings.Contains(message, refusal.Reason) { + t.Fatalf("a handler's refusal answered %d %q", status, message) + } + _ = s.host.conn.Close() + unreachable := s.gatewayFor(http.Header{}) + unreachable.orgID = "org-from-the-authority" + _, err = unreachable.ForBinding(context.Background(), "any") + if !errors.As(err, &refusal) || refusal.Reason != "" { + t.Fatalf("an unreachable authority: %#v", err) + } +} diff --git a/solution.go b/solution.go index aa1ef26..536b5a4 100644 --- a/solution.go +++ b/solution.go @@ -155,6 +155,9 @@ type Server struct { // resolved routes, checked by Serve before it listens. consumed []ConsumedModule passthrough map[string]passthroughRoute + // relays is, under the authority interface, each relayed consumed module's + // binding and connection (passthrough_authority.go). + relays map[string]*moduleRelay // mcp is the MCP server the solution declared with ServeMCP, nil when it // declared none; mcpRedeclared records a second ServeMCP, which the boot // refuses rather than letting the last call quietly be the only surface. @@ -915,12 +918,10 @@ func (s *Server) start(ctx context.Context) (net.Listener, error) { return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) } s.host = authority - if len(s.consumed) > 0 { - // Passthrough relays a page's call to a consumed module through the - // saas gateway. Under the authority there is no such gateway: a - // page reaches a module through the hop with ForModule's Work - // Context, and nothing passes through this process. - return nil, fmt.Errorf("solution %q consumes modules through the gateway passthrough, which the authority interface has no gateway for: call each module through the hop with Gateway.ForModule and Gateway.WorkContext", s.manifest.ID) + // The passthrough relays each consumed module the composition names a + // hop and a binding for, as the viewer, through the authority. + if s.relays, err = s.openRelays(routes, codefly.For(ctx).WorkspaceConfiguration); err != nil { + return nil, fmt.Errorf("solution %q: %w", s.manifest.ID, err) } } else if s.cfg.requiresAuthority { if err := s.openAuthority(ctx); err != nil { @@ -1294,24 +1295,16 @@ func (s *Server) wrapRequest(handler RequestHandler) http.HandlerFunc { if s.host != nil { // Under the authority the edge sends the request on with the // person's Work Context for this module, and who it is for is the - // authority's Check of that context, never a header the request - // wrote about itself. No session bearer reaches this process. - carried := r.Header.Get(WorkContextHeader) - if carried == "" { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "no Work Context: this page is reached through the authority's edge, which signs the person in"}) - return - } - viewer, err := s.host.viewer(r.Context(), carried) - if err != nil { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()}) + // authority's Check of that context. No session bearer reaches this + // process. + var err error + if gw, err = s.viewerGateway(r.Context(), r.Header.Get(WorkContextHeader)); err != nil { + writeRefusal(w, r, http.StatusUnauthorized, err.Error(), err) return } - gw = s.gatewayFor(http.Header{}) - gw.parentContext = carried - gw.orgID, gw.sessionID, gw.installationID, gw.installationErr = viewer.GetTenantId(), viewer.GetSessionId(), "", nil } else { if r.Header.Get("authorization") == "" { - writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing bearer"}) + writeRefusal(w, r, http.StatusUnauthorized, "missing bearer", nil) return } gw = s.gatewayFor(r.Header) @@ -1319,7 +1312,7 @@ func (s *Server) wrapRequest(handler RequestHandler) http.HandlerFunc { result, err := handler(r, gw) if err != nil { status, message := handlerErrorResponse(err) - writeJSON(w, status, map[string]string{"error": message}) + writeRefusal(w, r, status, message, err) return } writeJSON(w, http.StatusOK, result) @@ -2061,20 +2054,30 @@ func (g *Gateway) workContext(ctx context.Context) (string, error) { }) } -// WorkContextRefusal carries only the HTTP status and the locally requested +// WorkContextRefusal carries the HTTP status and the locally requested // audience. Broker response diagnostics are never retained or returned. // Code and Message were removed; consumers should inspect StatusCode. type WorkContextRefusal struct { Audience string StatusCode int + // Reason is the authority interface's own reason for a refusal ("the + // owner holds nothing at this audience"), kept so the refusal names it. + // Only the authority's refusals of the ask carry one; a saas broker's body + // is never retained, and neither is a transport failure's text. + Reason string + // code is the authority's gRPC status, which a Connect caller is answered + // with rather than a code re-derived from StatusCode. + code connect.Code } func (e *WorkContextRefusal) Error() string { message := "request failed" - switch e.StatusCode { - case http.StatusUnauthorized, http.StatusForbidden: + switch { + case e.Reason != "": + message = e.Reason + case e.StatusCode == http.StatusUnauthorized, e.StatusCode == http.StatusForbidden: message = "request refused" - case http.StatusServiceUnavailable: + case e.StatusCode == http.StatusServiceUnavailable: message = "execution not established" } return fmt.Sprintf("work context mint: HTTP %d: %s", e.StatusCode, message) From d9203b19739ddf0f0d5b518458af3128ea097c8e Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 25 Oct 2026 04:48:11 -0400 Subject: [PATCH 06/13] fix: a relayed module's grpc:// address is cleartext, as the authority endpoint's is dialAuthority strips grpc:// from authority/endpoint; a relay slot holding the same spelling was refused as a scheme that is not https. It is now read as cleartext and admitted by the same SDK rule. The relay test dials grpc://. Co-Authored-By: Claude Opus 5.5 --- passthrough_authority.go | 5 +++-- passthrough_authority_test.go | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/passthrough_authority.go b/passthrough_authority.go index e667e97..10abd90 100644 --- a/passthrough_authority.go +++ b/passthrough_authority.go @@ -112,9 +112,10 @@ func (s *Server) openRelays(routes map[string]passthroughRoute, lookup func(grou // travels on it, so where it may go is the SDK's one rule for a credential's // destination (workcontext.AdmitEndpoint): https anywhere; cleartext to a // loopback host on a local run, or to an in-cluster address the mesh carries. -// A bare host:port, as an endpoint reference resolves, is cleartext. +// A bare host:port, as an endpoint reference resolves, and grpc:// are +// cleartext. func dialModule(endpoint string, transport workcontext.TransportSource, what string) (*grpc.ClientConn, error) { - raw := strings.TrimSpace(endpoint) + raw := strings.TrimPrefix(strings.TrimSpace(endpoint), "grpc://") if !strings.Contains(raw, "://") { raw = "http://" + raw } diff --git a/passthrough_authority_test.go b/passthrough_authority_test.go index 523a433..a6ed43a 100644 --- a/passthrough_authority_test.go +++ b/passthrough_authority_test.go @@ -72,7 +72,7 @@ func relayedPage(t *testing.T, binding string, refuse error) (*httptest.Server, t.Helper() s, stand := authorityServer(t) module := &relayedModule{refuse: refuse} - slots := map[string]string{"page/THINGS_ENDPOINT": module.serve(t), "page/THINGS_BINDING": binding} + slots := map[string]string{"page/THINGS_ENDPOINT": "grpc://" + module.serve(t), "page/THINGS_BINDING": binding} declared := passthroughModule() declared.Methods[0].Pin = func() *dynamicpb.Message { pin := thingMessage("GetThingRequest") From 9ea6271f236d9939bca045f579c1760ccc01ffb5 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 25 Oct 2026 09:27:56 -0400 Subject: [PATCH 07/13] feat: under the authority, a route is held to the solution's delivered policy; a module's tool call acts for the person The authority's Check of a request's Work Context named no method, so any context addressed to the solution, a module's child issued for a tool call among them, reached every route. The Check now names the route, " " as the solution serves it, the spelling the authority matches against the "