From da7ce401d461de4f581cd5ec339424328b9e590a Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 19 Oct 2026 14:50:37 -0400 Subject: [PATCH 1/3] fix(run): serve the composition's Work Context key set on loopback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A service that verifies an inbound Work Context loads its key set from an endpoint, and `workcontext.AdmitEndpoint` — the one transport rule for every key set — already admits "plaintext http to a loopback host, on a local run only". Nothing served one, so a module whose identity comes from the host could not boot on its own: `codefly run service` resolved the graph, started its dependencies, and then the service failed runtime-init with "no configuration found for work-context". A developer with only that module's repository could start no service at all. The native run now mints an Ed25519 key for itself, serves the public half as a JWKS over that admitted loopback hop, and hands the work-context group to any service that declares the dependency. The group stays AUTHORED and is never render-owned, exactly as FederationConfiguration is: a composition with a host declares it, pointing at the host's key set, and this only answers for the composition that has none. It is handed last so it settles over what an authored group leaves empty rather than displacing one. Nothing is relaxed by this. The service boots on its unchanged FromJWKS path, the KeySet invariant holds because the keys still come from an admitted endpoint with admission and fetch as one operation, and the run issues under its own issuer — so a capability minted here is structurally unacceptable to a deployed service, which pins the host's. Each run's issuer names the run, so two runs on one machine never accept each other's capabilities. The listener lives exactly as long as the run. Tests cover the shape a verifier admits (OKP/Ed25519, raw-URL base64, a 32-byte key), that the published half verifies what the signer signs, that each run issues under its own issuer, and that a run with no identity serves nothing. Refs codefly-dev/cli#1074 Co-Authored-By: Claude Opus 5 (1M context) --- cmd/run/launcher.go | 34 ++++++- pkg/environments/work_context.go | 23 +++++ pkg/run/workcontext/authority.go | 114 +++++++++++++++++++++++ pkg/run/workcontext/authority_test.go | 128 ++++++++++++++++++++++++++ 4 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 pkg/environments/work_context.go create mode 100644 pkg/run/workcontext/authority.go create mode 100644 pkg/run/workcontext/authority_test.go diff --git a/cmd/run/launcher.go b/cmd/run/launcher.go index 982f3c51..cf87bb31 100644 --- a/cmd/run/launcher.go +++ b/cmd/run/launcher.go @@ -19,6 +19,7 @@ import ( "github.com/codefly-dev/cli/pkg/orchestration" runfixture "github.com/codefly-dev/cli/pkg/run/fixture" "github.com/codefly-dev/cli/pkg/run/identity" + runworkcontext "github.com/codefly-dev/cli/pkg/run/workcontext" "github.com/codefly-dev/cli/pkg/solutionrun" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/core/resources" @@ -58,6 +59,7 @@ import ( type launcher struct { vouch *identity.Launcher + authority *runworkcontext.Authority workspace *resources.Workspace env *environments.Environment flow launcherFlow @@ -129,8 +131,15 @@ func attachLauncher(ctx context.Context, flow *orchestration.Flow, workspace *re if err != nil { return err } + // The run serves the key set a service verifies inbound Work Contexts + // against. Nothing else does when the composition has no host, and the + // transport rule already admits a loopback key set on a local run. + authority, err := runworkcontext.Start(launcherIdentity.AnchorFingerprint()) + if err != nil { + return err + } cluster := &launcher{ - vouch: launcherIdentity, workspace: workspace, env: env, flow: flow, fixture: fixture, + vouch: launcherIdentity, authority: authority, workspace: workspace, env: env, flow: flow, fixture: fixture, stateDir: stateDir, identities: identities, signer: signer, @@ -178,6 +187,11 @@ func (cluster *launcher) close() { default: close(cluster.stop) } + // The key set is served for the life of the run and no longer: a listener + // outliving the run would answer for an issuer nothing is still minting. + if cluster.authority != nil { + _ = cluster.authority.Close() + } } // declare records identity workloads the run vouches for, with their delivery @@ -359,6 +373,12 @@ func (cluster *launcher) renderOwnedGroups(ctx context.Context, service *resourc } out = append(out, group) } + // Handed before module-authority and after any authored group: a + // composition with a host declares work-context itself, and this only + // answers for the composition that has none. + if slices.Contains(service.WorkspaceConfigurationDependencies, environments.WorkContextConfiguration) { + out = append(out, cluster.workContextGroup()) + } if !slices.Contains(service.WorkspaceConfigurationDependencies, environments.ModuleAuthorityConfiguration) { return out, nil } @@ -379,6 +399,18 @@ func (cluster *launcher) renderOwnedGroups(ctx context.Context, service *resourc return append(out, &basev0.Configuration{Origin: resources.ConfigurationWorkspace, Infos: []*basev0.ConfigurationInformation{authority}}), nil } +// workContextGroup is the verification surface a native run hands a service +// that verifies inbound Work Contexts: where this run serves its key set, and +// the issuer it stamps. A deployed service pins the host's issuer, so nothing +// this run signs is acceptable anywhere but this run. +func (cluster *launcher) workContextGroup() *basev0.Configuration { + group := &basev0.ConfigurationInformation{Name: environments.WorkContextConfiguration, ConfigurationValues: []*basev0.ConfigurationValue{ + {Key: environments.WorkContextKeySetURLKey, Value: cluster.authority.KeySetURL()}, + {Key: environments.WorkContextIssuerKey, Value: cluster.authority.Issuer()}, + }} + return &basev0.Configuration{Origin: resources.ConfigurationWorkspace, Infos: []*basev0.ConfigurationInformation{group}} +} + // federationGroup is the federation surface a native run hands the delivery // host: the coordinate it answers for, the ownership domain it accepts a // binding under (the one the run's own policy admits the run's signer for) diff --git a/pkg/environments/work_context.go b/pkg/environments/work_context.go new file mode 100644 index 00000000..6cfdef6d --- /dev/null +++ b/pkg/environments/work_context.go @@ -0,0 +1,23 @@ +package environments + +// WorkContextConfiguration is the workspace configuration group a service +// verifies inbound Work Contexts against: where the key set is served and the +// issuer it must be signed under. +// +// Like FederationConfiguration, the group stays AUTHORED and is never +// render-owned: a composition with a host declares it, pointing at the host's +// key set. A native run derives it too, for the composition that has no host +// to point at, and it is handed last so it settles over what an authored group +// leaves empty rather than displacing one. +// +// Without that, a service whose identity comes from the host cannot boot on +// its own, because nothing local serves the key set its verifier loads. +const WorkContextConfiguration = "work-context" + +// The two keys a verifier is configured from. A key set without a pinned +// issuer would admit a capability any holder of a key in that set signed under +// any issuer name, so neither is optional. +const ( + WorkContextKeySetURLKey = "authority-jwks-url" + WorkContextIssuerKey = "issuer" +) diff --git a/pkg/run/workcontext/authority.go b/pkg/run/workcontext/authority.go new file mode 100644 index 00000000..e5081699 --- /dev/null +++ b/pkg/run/workcontext/authority.go @@ -0,0 +1,114 @@ +// Package workcontext serves the verification key set a native run stands in +// for the host with. +// +// A service that verifies an inbound Work Context loads its key set from an +// endpoint, and the one transport rule for that endpoint already admits +// "plaintext http to a loopback host, on a local run only". Nothing served it, +// so a module whose identity comes from the host could not boot on its own. +// This serves it, over that admitted hop, for the life of the run. +// +// The key set is the run's own and so is the issuer stamped beside it. A +// verifier pins the issuer it accepts, so a capability signed here is +// structurally unacceptable to a deployed service, which pins the host's. +package workcontext + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "net" + "net/http" + "time" +) + +// KeySetPath is where the run serves its key set. It is the whole route: the +// consumer reads the URL verbatim and appends nothing. +const KeySetPath = "/.well-known/work-context-jwks.json" + +// keyID names the run's verification key inside the set. +const keyID = "local-run" + +// Authority is the key set a native run serves, and the issuer it stamps. +type Authority struct { + issuer string + public ed25519.PublicKey + private ed25519.PrivateKey + listener net.Listener + server *http.Server +} + +// Start mints the run's signing key and serves its public half on loopback. +// +// runID distinguishes one run's issuer from another's, so two runs on one +// machine never accept each other's capabilities. +func Start(runID string) (*Authority, error) { + if runID == "" { + return nil, fmt.Errorf("a run's work-context authority is issued under the run's own identity, and this run has none") + } + public, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return nil, fmt.Errorf("cannot mint this run's work-context key: %w", err) + } + // Loopback only: the admitted hop is a loopback one, and a key set reachable + // from off the machine is not what was admitted. + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("cannot serve this run's work-context key set: %w", err) + } + authority := &Authority{ + issuer: fmt.Sprintf("codefly://local-run/%s", runID), + public: public, + private: private, + listener: listener, + } + mux := http.NewServeMux() + mux.HandleFunc("GET "+KeySetPath, authority.serveKeySet) + authority.server = &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} + go func() { _ = authority.server.Serve(listener) }() + return authority, nil +} + +// Issuer is the issuer this run stamps and a verifier must pin. +func (a *Authority) Issuer() string { return a.issuer } + +// KeySetURL is the full route the key set is served at. +func (a *Authority) KeySetURL() string { + return fmt.Sprintf("http://%s%s", a.listener.Addr().String(), KeySetPath) +} + +// Signer is the run's private key, for minting capabilities under this issuer. +func (a *Authority) Signer() (string, ed25519.PrivateKey) { return keyID, a.private } + +// Close stops serving the key set. +func (a *Authority) Close() error { + if a == nil || a.server == nil { + return nil + } + return a.server.Close() +} + +// keySet is the document a verifier parses: a JWKS carrying Ed25519 signing +// keys, which is the only shape the Work Context verifier admits. +func (a *Authority) keySet() any { + return map[string]any{"keys": []map[string]string{{ + "kty": "OKP", + "crv": "Ed25519", + "use": "sig", + "alg": "EdDSA", + "kid": keyID, + "x": base64.RawURLEncoding.EncodeToString(a.public), + }}} +} + +func (a *Authority) serveKeySet(w http.ResponseWriter, _ *http.Request) { + document, err := json.Marshal(a.keySet()) + if err != nil { + http.Error(w, "cannot encode the key set", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/jwk-set+json") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write(document) +} diff --git a/pkg/run/workcontext/authority_test.go b/pkg/run/workcontext/authority_test.go new file mode 100644 index 00000000..eeca02ed --- /dev/null +++ b/pkg/run/workcontext/authority_test.go @@ -0,0 +1,128 @@ +package workcontext + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +// fetch reads the key set the authority serves over its admitted loopback hop. +func fetch(t *testing.T, authority *Authority) map[string]any { + t.Helper() + response, err := http.Get(authority.KeySetURL()) + if err != nil { + t.Fatalf("the key set is not reachable at the URL the run hands out: %v", err) + } + defer func() { _ = response.Body.Close() }() + if response.StatusCode != http.StatusOK { + t.Fatalf("the key set answered %d", response.StatusCode) + } + body, err := io.ReadAll(response.Body) + if err != nil { + t.Fatal(err) + } + var document map[string]any + if err := json.Unmarshal(body, &document); err != nil { + t.Fatalf("the key set is not JSON: %v", err) + } + return document +} + +// The verifier admits exactly one shape: an OKP/Ed25519 signing key whose `x` +// is raw-URL base64 of a 32-byte public key. Emitting a padded encoding, a +// different curve or a JWK alg the parser rejects would leave the run serving a +// document no service can load, which is the failure this guards. +func TestServedKeySetIsTheShapeAVerifierAdmits(t *testing.T) { + authority, err := Start("run-1") + if err != nil { + t.Fatal(err) + } + defer func() { _ = authority.Close() }() + + keys, ok := fetch(t, authority)["keys"].([]any) + if !ok || len(keys) != 1 { + t.Fatalf("the key set must carry exactly one key, got %v", keys) + } + key, ok := keys[0].(map[string]any) + if !ok { + t.Fatalf("the key is not an object") + } + for field, want := range map[string]string{"kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "use": "sig"} { + if key[field] != want { + t.Errorf("%s is %v, and a verifier admits only %q", field, key[field], want) + } + } + if kid, _ := key["kid"].(string); kid == "" { + t.Error("the key carries no key id, so a token cannot name which key signed it") + } + encoded, _ := key["x"].(string) + if strings.ContainsAny(encoded, "=+/") { + t.Errorf("x must be raw-URL base64, got %q", encoded) + } + decoded, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("x does not decode as raw-URL base64: %v", err) + } + if len(decoded) != ed25519.PublicKeySize { + t.Fatalf("x is %d bytes, and an Ed25519 public key is %d", len(decoded), ed25519.PublicKeySize) + } +} + +// The published half must be the signing half's: a run that serves a key set +// unrelated to what it signs with refuses every capability it mints, which is +// indistinguishable at the far end from a key set that was never served. +func TestThePublishedKeyVerifiesWhatTheRunSigns(t *testing.T) { + authority, err := Start("run-1") + if err != nil { + t.Fatal(err) + } + defer func() { _ = authority.Close() }() + + key, _ := fetch(t, authority)["keys"].([]any)[0].(map[string]any) + encoded, _ := key["x"].(string) + published, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + t.Fatal(err) + } + keyID, private := authority.Signer() + if keyID != key["kid"] { + t.Errorf("the signer names key %q, the key set publishes %v", keyID, key["kid"]) + } + message := []byte("a capability this run minted") + if !ed25519.Verify(ed25519.PublicKey(published), message, ed25519.Sign(private, message)) { + t.Fatal("the published key does not verify what the run signs") + } +} + +// Two runs must not accept each other's capabilities. A verifier pins the +// issuer, so the issuer is what separates them. +func TestEachRunIssuesUnderItsOwnIssuer(t *testing.T) { + first, err := Start("run-1") + if err != nil { + t.Fatal(err) + } + defer func() { _ = first.Close() }() + second, err := Start("run-2") + if err != nil { + t.Fatal(err) + } + defer func() { _ = second.Close() }() + + if first.Issuer() == second.Issuer() { + t.Fatalf("both runs issue under %q, so each would accept the other's capabilities", first.Issuer()) + } + if !strings.Contains(first.Issuer(), "run-1") { + t.Errorf("the issuer does not name the run: %q", first.Issuer()) + } +} + +// A run with no identity cannot issue under one. +func TestARunWithoutAnIdentityServesNothing(t *testing.T) { + if _, err := Start(""); err == nil { + t.Fatal("a run with no identity was allowed to issue capabilities") + } +} From ca4630814ca47d6b2d90123c4cbfdaedc79a0ed1 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:06:54 -0400 Subject: [PATCH 2/3] fix(run): layer the key set onto the loader, not beside the configurations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first attempt appended the work-context group in renderOwnedGroups, and it never reached the service: `codefly run service record-store/records` failed exactly as before with "no configuration found for work-context". A declared workspace-configuration dependency is resolved from the manager's OWN loaded set — configurations.Manager.GetWorkspaceDependenciesConfigurations reads manager.worspaceConfigurations, which only its Loader populates. renderOwnedGroups feeds the per-service configurations handed to the runtime, which that lookup never consults; RenderOwnedWorkspaceConfiguration says as much, that render-owned groups are "never dependencies of the configuration manager". federation is an extra handed to the host, work-context is a declared dependency, and following the federation precedent put it in the wrong half. So the run's key set now layers onto the loader where WithLoader is bound, and the flow owns the authority the launcher borrows. Two constraints the decorator respects. Configurations() returns the same instances on every call, because the manager resolves secret references in those objects in place and reads them back, so fresh copies would discard what it resolved. And the local reader carries two optional capabilities -- CompositionRootWorkspaceConfigurationNames and ambiguous-group reporting -- which are forwarded; swallowing either would leave the manager unable to tell which groups come from the composition root. An authored group still wins: if the loaded set already names work-context, the decorator adds nothing. Verified against obin-ai/module-record-store: the failure moves from "no configuration found for work-context" to "no configuration found for platform", the next group that service declares. work-context resolves. Co-Authored-By: Claude Opus 5 (1M context) --- cmd/run/launcher.go | 27 +-------- pkg/orchestration/flow.go | 27 ++++++++- pkg/run/workcontext/loader.go | 100 ++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 27 deletions(-) create mode 100644 pkg/run/workcontext/loader.go diff --git a/cmd/run/launcher.go b/cmd/run/launcher.go index cf87bb31..ef433d99 100644 --- a/cmd/run/launcher.go +++ b/cmd/run/launcher.go @@ -131,15 +131,8 @@ func attachLauncher(ctx context.Context, flow *orchestration.Flow, workspace *re if err != nil { return err } - // The run serves the key set a service verifies inbound Work Contexts - // against. Nothing else does when the composition has no host, and the - // transport rule already admits a loopback key set on a local run. - authority, err := runworkcontext.Start(launcherIdentity.AnchorFingerprint()) - if err != nil { - return err - } cluster := &launcher{ - vouch: launcherIdentity, authority: authority, workspace: workspace, env: env, flow: flow, fixture: fixture, + vouch: launcherIdentity, authority: flow.WorkContextAuthority(), workspace: workspace, env: env, flow: flow, fixture: fixture, stateDir: stateDir, identities: identities, signer: signer, @@ -373,12 +366,6 @@ func (cluster *launcher) renderOwnedGroups(ctx context.Context, service *resourc } out = append(out, group) } - // Handed before module-authority and after any authored group: a - // composition with a host declares work-context itself, and this only - // answers for the composition that has none. - if slices.Contains(service.WorkspaceConfigurationDependencies, environments.WorkContextConfiguration) { - out = append(out, cluster.workContextGroup()) - } if !slices.Contains(service.WorkspaceConfigurationDependencies, environments.ModuleAuthorityConfiguration) { return out, nil } @@ -399,18 +386,6 @@ func (cluster *launcher) renderOwnedGroups(ctx context.Context, service *resourc return append(out, &basev0.Configuration{Origin: resources.ConfigurationWorkspace, Infos: []*basev0.ConfigurationInformation{authority}}), nil } -// workContextGroup is the verification surface a native run hands a service -// that verifies inbound Work Contexts: where this run serves its key set, and -// the issuer it stamps. A deployed service pins the host's issuer, so nothing -// this run signs is acceptable anywhere but this run. -func (cluster *launcher) workContextGroup() *basev0.Configuration { - group := &basev0.ConfigurationInformation{Name: environments.WorkContextConfiguration, ConfigurationValues: []*basev0.ConfigurationValue{ - {Key: environments.WorkContextKeySetURLKey, Value: cluster.authority.KeySetURL()}, - {Key: environments.WorkContextIssuerKey, Value: cluster.authority.Issuer()}, - }} - return &basev0.Configuration{Origin: resources.ConfigurationWorkspace, Infos: []*basev0.ConfigurationInformation{group}} -} - // federationGroup is the federation surface a native run hands the delivery // host: the coordinate it answers for, the ownership domain it accepts a // binding under (the one the run's own policy admits the run's signer for) diff --git a/pkg/orchestration/flow.go b/pkg/orchestration/flow.go index ca03a3c7..7066eea4 100644 --- a/pkg/orchestration/flow.go +++ b/pkg/orchestration/flow.go @@ -2,6 +2,8 @@ package orchestration import ( "context" + "crypto/rand" + "encoding/hex" "errors" "fmt" "maps" @@ -20,6 +22,7 @@ import ( "github.com/codefly-dev/cli/pkg/environments" "github.com/codefly-dev/cli/pkg/internal/selectionguard" "github.com/codefly-dev/cli/pkg/remotenetwork" + runworkcontext "github.com/codefly-dev/cli/pkg/run/workcontext" "github.com/codefly-dev/core/architecture" "github.com/codefly-dev/core/configurations" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" @@ -35,6 +38,11 @@ import ( ) type Flow struct { + // workContext serves the key set a service verifies inbound Work Contexts + // against, for the composition that authors no work-context group. Created + // here because the loader that layers its group is bound here. + workContext *runworkcontext.Authority + // configurationReferences orders a consumer after the producers its // workspace configurations reference (configurationReferenceOption); every // rebuild of the graph carries it. @@ -423,6 +431,10 @@ func (f FlowFailure) Error() string { return fmt.Sprintf("%s: %s", f.Service, f.Message) } +// WorkContextAuthority is the key set this run serves, for the composition +// that authors no work-context group. The launcher closes it with the run. +func (flow *Flow) WorkContextAuthority() *runworkcontext.Authority { return flow.workContext } + func NewFlow(ctx context.Context, workspace *resources.Workspace, module *resources.Module, service *resources.Service, env *environments.Environment, mode Mode, opts ...FlowOption) (*Flow, error) { w := wool.Get(ctx).In("NewFlow") @@ -501,7 +513,19 @@ func NewFlow(ctx context.Context, workspace *resources.Workspace, module *resour if err != nil { return nil, w.Wrap(err) } - configurationManager.WithLoader(localReader) + // The run serves the key set a service verifies inbound Work Contexts + // against, layered over the loader so it is part of the set the manager + // resolves DECLARED dependencies from. A composition that authors the group + // keeps its own; this answers only for the one that does not. + runID := make([]byte, 8) + if _, err := rand.Read(runID); err != nil { + return nil, w.Wrap(err) + } + workContext, err := runworkcontext.Start(hex.EncodeToString(runID)) + if err != nil { + return nil, w.Wrap(err) + } + configurationManager.WithLoader(runworkcontext.WithKeySet(localReader, workContext)) // Bound here, with the loader: the resolution plans producer discovery // against the root's group names, so a manager that loaded a root group and // a world that cannot name it would resolve that group's references for @@ -553,6 +577,7 @@ func NewFlow(ctx context.Context, workspace *resources.Workspace, module *resour } flow := &Flow{ + workContext: workContext, workspace: workspace, graphWorkspace: graphWorkspace, moduleSeeds: options.moduleClosureSeeds, diff --git a/pkg/run/workcontext/loader.go b/pkg/run/workcontext/loader.go new file mode 100644 index 00000000..d23bcce9 --- /dev/null +++ b/pkg/run/workcontext/loader.go @@ -0,0 +1,100 @@ +package workcontext + +import ( + "context" + + "github.com/codefly-dev/core/configurations" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/resources" + + "github.com/codefly-dev/cli/pkg/environments" +) + +// WithKeySet layers this run's work-context group over a loader, for the +// composition that authors none. +// +// It belongs on the loader rather than beside the per-service configurations +// a run hands out: the group is a declared workspace-configuration dependency, +// and those are resolved from the manager's own loaded set +// (configurations.Manager.GetWorkspaceDependenciesConfigurations). A group +// handed alongside that set is never found by it, and the service fails to +// boot with "no configuration found for work-context". +// +// An authored group always wins. A composition with a host declares where its +// key set is served; this answers only for the one that does not. +func WithKeySet(inner configurations.Loader, authority *Authority) configurations.Loader { + return &keySetLoader{Loader: inner, authority: authority} +} + +type keySetLoader struct { + configurations.Loader + authority *Authority + // configurations is built once: the Manager resolves secret references in + // the returned objects IN PLACE and reads them back, so handing out fresh + // copies would discard what it resolved. + configurations []*basev0.Configuration +} + +func (l *keySetLoader) Load(ctx context.Context, env *resources.Environment) error { + if err := l.Loader.Load(ctx, env); err != nil { + return err + } + loaded := l.Loader.Configurations() + if l.authority == nil || named(loaded, environments.WorkContextConfiguration) { + l.configurations = loaded + return nil + } + l.configurations = append(append([]*basev0.Configuration{}, loaded...), &basev0.Configuration{ + Origin: resources.ConfigurationWorkspace, + Infos: []*basev0.ConfigurationInformation{{ + Name: environments.WorkContextConfiguration, + ConfigurationValues: []*basev0.ConfigurationValue{ + {Key: environments.WorkContextKeySetURLKey, Value: l.authority.KeySetURL()}, + {Key: environments.WorkContextIssuerKey, Value: l.authority.Issuer()}, + }, + }}, + }) + return nil +} + +func (l *keySetLoader) Configurations() []*basev0.Configuration { + if l.configurations == nil { + return l.Loader.Configurations() + } + return l.configurations +} + +// CompositionRootWorkspaceConfigurationNames forwards the optional capability +// the wrapped loader may carry. Swallowing it would leave the Manager unable to +// tell which groups come from the composition root, which it injects into every +// service in the run. +func (l *keySetLoader) CompositionRootWorkspaceConfigurationNames() []string { + if inner, ok := l.Loader.(interface { + CompositionRootWorkspaceConfigurationNames() []string + }); ok { + return inner.CompositionRootWorkspaceConfigurationNames() + } + return nil +} + +// AmbiguousWorkspaceConfigurations forwards the loader's other optional +// capability, for the same reason. +func (l *keySetLoader) AmbiguousWorkspaceConfigurations() map[string]error { + if inner, ok := l.Loader.(interface { + AmbiguousWorkspaceConfigurations() map[string]error + }); ok { + return inner.AmbiguousWorkspaceConfigurations() + } + return nil +} + +func named(configs []*basev0.Configuration, group string) bool { + for _, configuration := range configs { + for _, info := range configuration.GetInfos() { + if info.GetName() == group { + return true + } + } + } + return false +} From e69a3afef6885b7ac1314fad0fcab2dde6269609 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:20:57 -0400 Subject: [PATCH 3/3] feat(run): derive what a composition would have to be granted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An installation is one solution's bound identity in an org, and InstallSolution produces one agent principal, one solution scope node, one standing role grant and one organization installation. Its installer surface is already four paths — token, inspect, apply, verify — so the shape of "show what needs approving, then approve it" exists. What is missing is the first part: something that says what a given composition is asking for. This derives that from the manifests. For each service it reports the workspace-configuration groups it reads and the endpoints it calls, and separates what leaves the composition from what is inside it — a dependency the operator is installing in the same act is composed, not granted, and listing it buries the ones that genuinely cross a boundary. It derives the REQUEST and never the approval. MODULE_INSTALLATION.md is explicit that the envelope is not derivable from a module's own declaration: "Policy publication is an explicit trust-root operation; an ordinary module release declaration cannot write this projection." A module that could write its own envelope could grant itself authority by declaring it. So this grants nothing, signs nothing and reaches no network, and the rendered text says so twice, because a list of requirements reads like a list of things already arranged. Derive is pure, so what an operator approves in a deployed environment is what a local run derived from the same manifests rather than a differently ordered reading of it. A test asserts that stability directly. This is the half that is safe to automate. Approving against a fixture's declared approver in a local environment is the obvious next step and is not here: it decides authority, and that decision belongs to whoever owns the trust root. Co-Authored-By: Claude Opus 5 (1M context) --- pkg/run/install/proposal.go | 103 +++++++++++++++++++++++++++++++ pkg/run/install/proposal_test.go | 86 ++++++++++++++++++++++++++ 2 files changed, 189 insertions(+) create mode 100644 pkg/run/install/proposal.go create mode 100644 pkg/run/install/proposal_test.go diff --git a/pkg/run/install/proposal.go b/pkg/run/install/proposal.go new file mode 100644 index 00000000..479577d3 --- /dev/null +++ b/pkg/run/install/proposal.go @@ -0,0 +1,103 @@ +// Package install derives what a composition would have to be granted, from +// what it declares. +// +// Accounts' InstallSolution produces one agent principal, one solution scope +// node, one standing role grant and one organization installation, and its +// installer surface is four paths: token, inspect, apply and verify. The +// envelope that install is approved against is deliberately NOT derived from a +// module's own declaration — MODULE_INSTALLATION.md: "Policy publication is an +// explicit trust-root operation; an ordinary module release declaration cannot +// write this projection." A module that could write its own envelope could +// grant itself authority by declaring it. +// +// So this derives the REQUEST and never the approval. It reads the +// composition's manifests and says what an approver would be deciding about: +// which services exist, which workspace-configuration groups each reads, and +// which endpoints each calls. It grants nothing, signs nothing, and reaches no +// network. The decision stays where the trust root put it. +package install + +import ( + "fmt" + "sort" + "strings" +) + +// Service is one service's declaration, as the composition states it. +type Service struct { + Module string + Name string + // Groups are the workspace-configuration groups it reads. Each is a + // capability the composition supplies, and each is a way for the service to + // be unable to start without one. + Groups []string + // Dependencies are the endpoints it calls, as "module/service/endpoint". + Dependencies []string +} + +// Need is what one service would have to be granted for the composition to run. +type Need struct { + Service Service + // Reaches is what it calls outside its own module. A dependency inside the + // module is composed, not granted: the authority question is about crossing + // the boundary. + Reaches []string +} + +// Unique names the service the way the rest of codefly does. +func (s Service) Unique() string { return s.Module + "/" + s.Name } + +// Derive turns the composition's declarations into the needs an approver would +// decide about. It is pure: the same declarations always give the same needs, +// so what an operator reads in a deployed environment is what a local run +// derived from the same manifests. +func Derive(services []Service) []Need { + owned := map[string]bool{} + for _, service := range services { + owned[service.Module] = true + } + needs := make([]Need, 0, len(services)) + for _, service := range services { + need := Need{Service: service} + for _, dependency := range service.Dependencies { + module, _, found := strings.Cut(dependency, "/") + // A dependency this composition does not contain is the one that + // has to be granted: it crosses out of what the operator is + // installing. + if !found || !owned[module] { + need.Reaches = append(need.Reaches, dependency) + } + } + sort.Strings(need.Reaches) + needs = append(needs, need) + } + sort.Slice(needs, func(i, j int) bool { return needs[i].Service.Unique() < needs[j].Service.Unique() }) + return needs +} + +// Render writes the needs as the text an approver reads before deciding. +// +// It names what is being asked for and says plainly that nothing here grants +// it, because a list of requirements is easy to mistake for a list of things +// that have been arranged. +func Render(needs []Need) string { + if len(needs) == 0 { + return "This composition declares no services, so there is nothing to install.\n" + } + var out strings.Builder + out.WriteString("This composition declares the following. Nothing below is granted by deriving it:\n\n") + for _, need := range needs { + fmt.Fprintf(&out, " %s\n", need.Service.Unique()) + if len(need.Service.Groups) > 0 { + fmt.Fprintf(&out, " reads configuration: %s\n", strings.Join(need.Service.Groups, ", ")) + } + if len(need.Reaches) > 0 { + fmt.Fprintf(&out, " calls outside this composition: %s\n", strings.Join(need.Reaches, ", ")) + } + if len(need.Service.Groups) == 0 && len(need.Reaches) == 0 { + out.WriteString(" reads no configuration and calls nothing outside this composition\n") + } + } + out.WriteString("\nAn envelope approving any of it is a trust-root operation and is not written here.\n") + return out.String() +} diff --git a/pkg/run/install/proposal_test.go b/pkg/run/install/proposal_test.go new file mode 100644 index 00000000..e89c7c00 --- /dev/null +++ b/pkg/run/install/proposal_test.go @@ -0,0 +1,86 @@ +package install + +import ( + "strings" + "testing" +) + +// The composition under test is the shape that motivated this: a module whose +// services talk to each other, and which reaches a host it does not contain. +func composition() []Service { + return []Service{ + { + Module: "record-store", Name: "records", + Groups: []string{"work-context", "platform", "internal-auth"}, + Dependencies: []string{"record-store/store/tcp", "saas/accounts/authority", "saas/auth-gateway/rest"}, + }, + {Module: "record-store", Name: "store"}, + { + Module: "record-store", Name: "playground", + Groups: []string{"work-context"}, + Dependencies: []string{"record-store/records/connect"}, + }, + } +} + +// What has to be granted is what leaves the composition. A dependency inside it +// is composed by the operator in the same act, so listing it as something to +// approve buries the two that genuinely cross a boundary. +func TestOnlyWhatLeavesTheCompositionIsSomethingToGrant(t *testing.T) { + needs := Derive(composition()) + byService := map[string][]string{} + for _, need := range needs { + byService[need.Service.Unique()] = need.Reaches + } + records := byService["record-store/records"] + if len(records) != 2 { + t.Fatalf("records reaches %v; the two saas endpoints cross out of this composition and the store does not", records) + } + for _, reach := range records { + if strings.HasPrefix(reach, "record-store/") { + t.Errorf("%q is inside this composition and is composed, not granted", reach) + } + } + if got := byService["record-store/playground"]; len(got) != 0 { + t.Errorf("the playground only calls records, which this composition contains; got %v", got) + } +} + +// Deriving is not arranging. The text an approver reads has to say so, because +// a list of requirements reads like a list of things already handled. +func TestTheProposalSaysItGrantsNothing(t *testing.T) { + rendered := Render(Derive(composition())) + for _, required := range []string{"Nothing below is granted", "trust-root operation and is not written here"} { + if !strings.Contains(rendered, required) { + t.Errorf("the proposal does not say %q:\n%s", required, rendered) + } + } + if !strings.Contains(rendered, "saas/accounts/authority") { + t.Error("the proposal does not name what the composition reaches outside itself") + } +} + +// The same declarations must always give the same text: an operator approving +// what a deployed environment printed has to be approving what a local run +// derived from the same manifests, not a differently ordered reading of it. +func TestTheProposalIsStableForTheSameDeclarations(t *testing.T) { + first := Render(Derive(composition())) + shuffled := composition() + shuffled[0], shuffled[2] = shuffled[2], shuffled[0] + if second := Render(Derive(shuffled)); first != second { + t.Errorf("the proposal depends on declaration order:\n--- first ---\n%s\n--- second ---\n%s", first, second) + } +} + +func TestAServiceThatNeedsNothingSaysSo(t *testing.T) { + rendered := Render(Derive([]Service{{Module: "record-store", Name: "store"}})) + if !strings.Contains(rendered, "reads no configuration and calls nothing outside") { + t.Errorf("a service with no needs is rendered as a blank entry:\n%s", rendered) + } +} + +func TestAnEmptyCompositionIsNotAnInstall(t *testing.T) { + if rendered := Render(Derive(nil)); !strings.Contains(rendered, "nothing to install") { + t.Errorf("an empty composition renders as something to approve:\n%s", rendered) + } +}