Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion cmd/run/launcher.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -58,6 +59,7 @@ import (

type launcher struct {
vouch *identity.Launcher
authority *runworkcontext.Authority
workspace *resources.Workspace
env *environments.Environment
flow launcherFlow
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions pkg/environments/work_context.go
Original file line number Diff line number Diff line change
@@ -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"
)
27 changes: 26 additions & 1 deletion pkg/orchestration/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"maps"
Expand All @@ -20,6 +22,7 @@
"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"
Expand All @@ -35,6 +38,11 @@
)

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.
Expand Down Expand Up @@ -423,6 +431,10 @@
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")

Expand Down Expand Up @@ -501,7 +513,19 @@
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 {

Check failure on line 521 in pkg/orchestration/flow.go

View workflow job for this annotation

GitHub Actions / lint

shadow: declaration of "err" shadows declaration at line 456 (govet)
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
Expand Down Expand Up @@ -553,6 +577,7 @@
}

flow := &Flow{
workContext: workContext,
workspace: workspace,
graphWorkspace: graphWorkspace,
moduleSeeds: options.moduleClosureSeeds,
Expand Down
103 changes: 103 additions & 0 deletions pkg/run/install/proposal.go
Original file line number Diff line number Diff line change
@@ -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 }

Check failure on line 48 in pkg/run/install/proposal.go

View workflow job for this annotation

GitHub Actions / lint

hugeParam: s is heavy (80 bytes); consider passing it by pointer (gocritic)

// 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()
}
86 changes: 86 additions & 0 deletions pkg/run/install/proposal_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading