diff --git a/cmd/run/launcher.go b/cmd/run/launcher.go index 982f3c51..ef433d99 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 @@ -130,7 +132,7 @@ func attachLauncher(ctx context.Context, flow *orchestration.Flow, workspace *re return err } cluster := &launcher{ - vouch: launcherIdentity, 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, @@ -178,6 +180,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 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/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/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) + } +} 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") + } +} 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 +}