From d47fe20103e2c7a138fbd2d2b1951a11bdfd52fb Mon Sep 17 00:00:00 2001 From: Luka Zhupa <5446951+lukazhupa@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:54:45 -0400 Subject: [PATCH] feat(gitops): a module installing prepared bindings asks for each one A service that declares the runnable-bindings group installs every binding the composition prepared, and the mesh already admits it to each owner from that one derivation. Its module's authority request now names each prepared binding too: the prepared key as the binding id, with the audience and the kinds and actions of the binding's resolved policy, and no resource id. Which resources an organization lets the module reach is that organization's consent, written to the module's installation scope. A native run and a publish also list each prepared binding in the installing module's envelope grant: the audience, the kinds and actions without resource ids, and delegation_mode module_actor. This is a labelled stopgap that exists only because the host reads the mode from the envelope; it goes away when the mode travels with the manifest's binding. An entry the composition states for a key is kept, and a module that asks for no authority gains nothing, since the host grants it its envelope whole. --- cmd/run/launcher.go | 10 +- docs/module-manifests.md | 27 +++ docs/runnable.md | 7 + pkg/gitops/authority.go | 11 ++ pkg/gitops/publish.go | 3 + pkg/gitops/publish_envelope.go | 16 ++ pkg/gitops/runnable_grants.go | 296 +++++++++++++++++++++++++++++ pkg/gitops/runnable_grants_test.go | 260 +++++++++++++++++++++++++ 8 files changed, 627 insertions(+), 3 deletions(-) create mode 100644 pkg/gitops/runnable_grants.go create mode 100644 pkg/gitops/runnable_grants_test.go diff --git a/cmd/run/launcher.go b/cmd/run/launcher.go index 982f3c51..7b61e87f 100644 --- a/cmd/run/launcher.go +++ b/cmd/run/launcher.go @@ -740,7 +740,7 @@ func (cluster *launcher) deliver(ctx context.Context, module string) error { // the fixture's platform administrator, unless the host holds it already. A // composition that states no grant writes none. func (cluster *launcher) ensureEnvelope(ctx context.Context, gatewayURL string) error { - grants, err := cluster.grants() + grants, err := cluster.grants(ctx) if err != nil || len(grants) == 0 { return err } @@ -772,12 +772,16 @@ func (cluster *launcher) fixtureAdministrator(ctx context.Context) (runfixture.A // grants each module principal, in the host's own grant shape. A platform base // states its modules' grants and a solution composed on it states its own // principal's; the consuming workspace decides a principal it states -// (runfixture.ReadCompositionGrants). -func (cluster *launcher) grants() (map[string]gitops.EnvelopeGrant, error) { +// (runfixture.ReadCompositionGrants), with an entry for each prepared binding +// a module installs (gitops.WithPreparedBindingGrants). +func (cluster *launcher) grants(ctx context.Context) (map[string]gitops.EnvelopeGrant, error) { principals, err := runfixture.ReadCompositionGrants(cluster.composed(), cluster.env.Name) if err != nil { return nil, err } + if principals, err = gitops.WithPreparedBindingGrants(ctx, cluster.composed(), cluster.env, principals); err != nil { + return nil, err + } out := map[string]gitops.EnvelopeGrant{} for prefix, grant := range principals { out[prefix] = gitops.EnvelopeGrant(grant) diff --git a/docs/module-manifests.md b/docs/module-manifests.md index 0fb52d89..434ceb71 100644 --- a/docs/module-manifests.md +++ b/docs/module-manifests.md @@ -260,6 +260,20 @@ Both at once is refused by name. The request is advisory: the host grants the envelope's grant for the principal, reports requested bindings the grant lacks as a reason on the binding, and never widens or narrows the grant from it. +**A module that installs prepared bindings also asks for each one.** When one +of its services declares the `runnable-bindings` workspace configuration group +([Runnable](runnable.md)), the module's request gains a binding for every +prepared binding the environment's group carries: +`{id: , audience, operations: {invoke, lookup}}`, +with the audience and the kinds and actions of the binding's resolved policy. +It carries **no resource id**: which resources an organization lets the module +reach is that organization's consent, written to the module's installation +scope, which narrows the binding there. A prepared key equal to a binding id +the module's own declaration uses is refused by name, and a value in the group +that is not a prepared binding fails the render by key. A module that asks for +nothing — no contract, no scoped consumption — gains nothing: the host grants +it its envelope whole, and a request would change that. + The contract declares the principal, the operation bindings the module redeems (each with the operations it needs and a scope ceiling per operation), the module's queues and namespaces, its own scope ceilings and the destinations it @@ -526,6 +540,19 @@ does not decide are refused by name, never resolved to either. A native run ignores the floor. Core's `AdmitEnvelope` runs over the base branch's record before the host runs it over its own. +**Prepared bindings are listed for the module that installs them (a +stopgap).** The host reads a binding's delegation mode only from the +envelope's entry for it, so a requested binding the envelope does not list is +never granted. A native run and a publish therefore add, to the grant of each +module that asks for authority and installs prepared bindings, an +`operation_audiences` entry per prepared key the grant does not already state: +the binding's audience, its kinds and actions **without resource ids**, and +`"delegation_mode": "module_actor"`, so a call through it names the installing +module as the actor. An entry the composition states for that key is kept as +stated, and a principal the composition does not grant gains none. This exists +only because the mode is read from the envelope; when the mode travels with +the manifest's binding, it goes away. + **Stating nothing is not revoking.** A composition that delivered an envelope and no longer states one — no `module-envelope.json` in any workspace of the composition for the environment — is refused. Revoking every grant is a diff --git a/docs/runnable.md b/docs/runnable.md index b3253461..9b2bc43e 100644 --- a/docs/runnable.md +++ b/docs/runnable.md @@ -192,6 +192,13 @@ refused when the environment is read, because it would look like either. there is nothing to decline and it would hide a typo, and `[]` on a key that names no derived operation is refused as an unknown key. +A module whose service installs prepared bindings asks for each one in its +manifest's authority request, by its key, with the binding's audience, kinds +and actions and no resource id; the module's installation scope narrows it to +the resources an organization consents to +([module manifests](module-manifests.md#what-a-module-asks-for)). A declined +operation has no prepared binding, so nothing asks for it. + A service that installs a declined operation is refused at its own point: the `runnable-bindings` key it names is not configured, and it reports that key. The decline is the environment's own answer, so the same operation can be diff --git a/pkg/gitops/authority.go b/pkg/gitops/authority.go index bd1ea43e..d8ef6094 100644 --- a/pkg/gitops/authority.go +++ b/pkg/gitops/authority.go @@ -101,6 +101,17 @@ func moduleAuthority( } authority.Bindings = append(authority.Bindings, solutionhost.AuthorityBinding{ID: binding.ID, Audience: binding.Audience, Operations: operations}) } + // A module that installs prepared bindings also asks for each one it + // installs (runnable_grants.go). + if installsPreparedBindings(services) { + grants, err := preparedGrants(provided) + if err != nil { + return nil, fmt.Errorf("module %s: %w", module.Name, err) + } + if err := appendPreparedBindings(authority, module.Name, grants); err != nil { + return nil, err + } + } authority.Queues = append([]string(nil), resolved.Queues...) authority.Namespaces = append([]string(nil), resolved.Namespaces...) for _, ceiling := range resolved.ScopeCeilings { diff --git a/pkg/gitops/publish.go b/pkg/gitops/publish.go index 00edd3c6..434668f0 100644 --- a/pkg/gitops/publish.go +++ b/pkg/gitops/publish.go @@ -2566,6 +2566,9 @@ func stageAndSettleDelivery( if err := publication.declareEnvelope(inventory, environment); err != nil { return nil, err } + if err := publication.listPreparedBindings(ctx, environment); err != nil { + return nil, err + } return settleManifestDelivery(ctx, repo, publication.baseBranch, target, targetPath, environment, inventory, &publication.options) } diff --git a/pkg/gitops/publish_envelope.go b/pkg/gitops/publish_envelope.go index 72865c7d..a44fc9e0 100644 --- a/pkg/gitops/publish_envelope.go +++ b/pkg/gitops/publish_envelope.go @@ -102,6 +102,22 @@ func (p *deliveryPublication) declareEnvelope(inventory *Inventory, environment return nil } +// listPreparedBindings lists, in the envelope this publish delivers, each +// prepared binding a module installs, exactly as a native run lists them +// (WithPreparedBindingGrants), so a cell grants what a run grants. An +// envelope this publish does not deliver is left alone. +func (p *deliveryPublication) listPreparedBindings(ctx context.Context, environment string) error { + if p.options.Envelope == nil { + return nil + } + principals, err := WithPreparedBindingGrants(ctx, p.workspace, p.env, p.options.Envelope.Principals) + if err != nil { + return fmt.Errorf("list the prepared bindings in the module envelope for %s: %w", environment, err) + } + p.options.Envelope.Principals = principals + return nil +} + // settleEnvelope settles, signs and writes the envelope beside the manifests // already settled, and returns the delivery record and file list extended by // it. Nothing to deliver and nothing delivered before leaves both unchanged. diff --git a/pkg/gitops/runnable_grants.go b/pkg/gitops/runnable_grants.go new file mode 100644 index 00000000..f2295fbc --- /dev/null +++ b/pkg/gitops/runnable_grants.go @@ -0,0 +1,296 @@ +package gitops + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "sort" + "strings" + + "github.com/codefly-dev/cli/pkg/environments" + runnablespkg "github.com/codefly-dev/cli/pkg/runnables" + "github.com/codefly-dev/core/configurations" + modulecontract "github.com/codefly-dev/core/contracts/module" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/resources" + corerunnable "github.com/codefly-dev/core/runnable" + "github.com/codefly-dev/core/solutionhost" +) + +// --- What a module that installs prepared bindings is granted --- +// +// A service that declares the runnable-bindings workspace configuration group +// installs every binding the composition prepared, and calls each owner with +// a capability exchanged for the call (derivedRunnableReach). The mesh already +// admits it to those owners from that one derivation; this is the same +// derivation read for authority: the module's request names each prepared +// binding it installs, so the host has a binding to exchange through. +// +// A binding is named by its prepared key (MODULE__OPERATION), the one id the +// installer reads the value under, and carries the audience and the resource +// kinds and actions the binding's resolved policy states. It never carries a +// resource id: which resources an organization lets the module reach is that +// organization's consent, written to the module's installation scope, which +// narrows the binding there. +// +// Only a module that asks for authority gains these bindings. A module that +// publishes no request asks for nothing, and the host grants it its envelope +// whole; adding a request would change what it is granted, so its envelope +// stays the composition's to state. + +// preparedGrant is what one prepared binding asks for, without resource ids. +type preparedGrant struct { + key string + audience string + invoke []preparedScope + lookup []preparedScope +} + +// preparedScope is one resource kind and the actions a binding asks for on it. +type preparedScope struct { + kind string + actions []string +} + +// installsPreparedBindings reports whether any of a module's services declares +// the runnable-bindings group, which is what installing prepared bindings is. +func installsPreparedBindings(services []*resources.Service) bool { + for _, service := range services { + if service != nil && slices.Contains(service.WorkspaceConfigurationDependencies, runnablespkg.RunnableBindingsGroup) { + return true + } + } + return false +} + +// preparedGrants reads every binding the composition prepared for the +// environment, sorted by key. A value that is not a prepared binding fails the +// read by key: it is generated output, and one that does not decode would +// install nothing. +func preparedGrants(provided *configurations.WorkspaceConfigurations) ([]preparedGrant, error) { + if provided == nil { + return nil, nil + } + var grants []preparedGrant + for _, info := range provided.Infos { + if info.GetName() != runnablespkg.RunnableBindingsGroup { + continue + } + for _, value := range info.GetConfigurationValues() { + prepared, err := corerunnable.DecodePrepared([]byte(value.GetValue())) + if err != nil { + return nil, fmt.Errorf("%s/%s is not a prepared binding; regenerate it with `codefly generate runnable-bindings`: %w", runnablespkg.RunnableBindingsGroup, value.GetKey(), err) + } + policy := prepared.GetPolicy() + grants = append(grants, preparedGrant{ + key: value.GetKey(), + audience: policy.GetAudience(), + invoke: kindScopes(policy.GetInvokeScopes()), + lookup: kindScopes(policy.GetLookupScopes()), + }) + } + } + sort.Slice(grants, func(i, j int) bool { return grants[i].key < grants[j].key }) + for i := 1; i < len(grants); i++ { + if grants[i].key == grants[i-1].key { + return nil, fmt.Errorf("%s/%s is supplied twice; a prepared binding has one value", runnablespkg.RunnableBindingsGroup, grants[i].key) + } + } + return grants, nil +} + +// kindScopes keeps each scope's kind and actions and drops its resource ids, +// merging scopes over one kind, sorted by kind and action. +func kindScopes(scopes []*basev0.WorkScopeV1) []preparedScope { + byKind := map[string][]string{} + for _, scope := range scopes { + kind := scope.GetResourceKind() + for _, action := range scope.GetActions() { + if !slices.Contains(byKind[kind], action) { + byKind[kind] = append(byKind[kind], action) + } + } + } + out := make([]preparedScope, 0, len(byKind)) + for kind, actions := range byKind { + sort.Strings(actions) + out = append(out, preparedScope{kind: kind, actions: actions}) + } + sort.Slice(out, func(i, j int) bool { return out[i].kind < out[j].kind }) + return out +} + +// requestBinding is the binding the module's request asks for. +func (g *preparedGrant) requestBinding() solutionhost.AuthorityBinding { + operations := map[string][]string{modulecontract.OperationInvoke: scopeStrings(g.invoke)} + if len(g.lookup) > 0 { + operations[modulecontract.OperationLookup] = scopeStrings(g.lookup) + } + return solutionhost.AuthorityBinding{ID: g.key, Audience: g.audience, Operations: operations} +} + +func scopeStrings(scopes []preparedScope) []string { + var out []string + for _, scope := range scopes { + for _, action := range scope.actions { + out = append(out, scope.kind+":"+action) + } + } + sort.Strings(out) + return out +} + +// appendPreparedBindings adds a binding per prepared key to a module's request. +// A key the module's own contract already uses is refused by name: one id, two +// declarations of what it asks. +func appendPreparedBindings(authority *solutionhost.Authority, module string, grants []preparedGrant) error { + for i := range grants { + grant := &grants[i] + for _, existing := range authority.Bindings { + if existing.ID == grant.key { + return fmt.Errorf("module %s declares binding %s and installs the prepared binding %s/%s under the same id; a binding has one declaration", module, existing.ID, runnablespkg.RunnableBindingsGroup, grant.key) + } + } + authority.Bindings = append(authority.Bindings, grant.requestBinding()) + } + return nil +} + +// --- The envelope entry: a labelled stopgap --- +// +// The host takes a binding's delegation mode only from the envelope's entry +// for it, so a requested binding the envelope does not list is never granted, +// and one listed without a mode exchanges a child that does not name the +// installing module as its actor. Until the mode travels with the request, +// the envelope therefore lists each prepared binding an asking module +// installs: the binding's audience, its kinds and actions WITHOUT resource ids +// — the installation scope narrows them per organization — and the +// module_actor mode, so a call through it is recorded as the installing +// module acting for the person. An entry the composition states for the same +// key is the composition's and is kept as stated. +// +// STOPGAP: this exists only because the host reads the mode from the envelope. +// When the mode moves to the manifest's binding, delete it. + +// preparedBindingDelegationMode is the mode every generated entry carries. +const preparedBindingDelegationMode = "module_actor" + +// envelopeScope is one scope of an envelope entry, in the host's grant shape. +type envelopeScope struct { + ResourceKind string `json:"resource_kind"` + Actions []string `json:"actions"` +} + +// envelopeEntry is one operation audience of an envelope grant, in the host's +// grant shape. +type envelopeEntry struct { + DelegationMode string `json:"delegation_mode"` + Audience string `json:"audience"` + InvokeScopes []envelopeScope `json:"invoke_scopes"` + LookupScopes []envelopeScope `json:"lookup_scopes,omitempty"` +} + +func (g *preparedGrant) envelopeEntry() envelopeEntry { + entry := envelopeEntry{DelegationMode: preparedBindingDelegationMode, Audience: g.audience} + for _, scope := range g.invoke { + entry.InvokeScopes = append(entry.InvokeScopes, envelopeScope{ResourceKind: scope.kind, Actions: slices.Clone(scope.actions)}) + } + for _, scope := range g.lookup { + entry.LookupScopes = append(entry.LookupScopes, envelopeScope{ResourceKind: scope.kind, Actions: slices.Clone(scope.actions)}) + } + return entry +} + +// WithPreparedBindingGrants returns the composition's envelope grants with an +// entry for each prepared binding a module installs, for every module that +// asks for authority and whose principal the composition grants. A +// composition that grants nothing is returned unchanged: an envelope nobody +// states is not one this writes. +func WithPreparedBindingGrants(ctx context.Context, workspace *resources.Workspace, env *environments.Environment, principals map[string]json.RawMessage) (map[string]json.RawMessage, error) { + if len(principals) == 0 || workspace == nil || env == nil { + return principals, nil + } + modules, err := workspace.LoadModules(ctx) + if err != nil { + return nil, err + } + var installers []string + for _, module := range modules { + if _, granted := principals[module.Name]; !granted { + continue + } + services, loadErr := loadModuleServices(ctx, module) + if loadErr != nil { + return nil, loadErr + } + if !installsPreparedBindings(services) { + continue + } + request, requestErr := authorityRequestOf(module) + if requestErr != nil { + return nil, requestErr + } + if request.undeclared != "" { + continue + } + installers = append(installers, module.Name) + } + if len(installers) == 0 { + return principals, nil + } + provided, err := configurations.ReadWorkspaceConfigurations(ctx, workspace, env.Runtime()) + if err != nil { + return nil, fmt.Errorf("read the prepared bindings the envelope lists: %w", err) + } + grants, err := preparedGrants(provided) + if err != nil || len(grants) == 0 { + return principals, err + } + out := make(map[string]json.RawMessage, len(principals)) + for principal, grant := range principals { + out[principal] = grant + } + for _, principal := range installers { + merged, err := withPreparedEntries(principal, out[principal], grants) + if err != nil { + return nil, err + } + out[principal] = merged + } + return out, nil +} + +// withPreparedEntries adds the generated entries to one principal's grant, +// keeping every entry the grant already states. +func withPreparedEntries(principal string, grant json.RawMessage, grants []preparedGrant) (json.RawMessage, error) { + fields := map[string]json.RawMessage{} + if trimmed := strings.TrimSpace(string(grant)); trimmed != "" && trimmed != "null" { + if err := json.Unmarshal(grant, &fields); err != nil { + return nil, fmt.Errorf("the envelope grant of %s is not an object: %w", principal, err) + } + } + audiences := map[string]json.RawMessage{} + if raw, ok := fields["operation_audiences"]; ok && strings.TrimSpace(string(raw)) != "null" { + if err := json.Unmarshal(raw, &audiences); err != nil { + return nil, fmt.Errorf("the envelope grant of %s states operation_audiences that are not an object: %w", principal, err) + } + } + for i := range grants { + prepared := &grants[i] + if _, stated := audiences[prepared.key]; stated { + continue + } + entry, err := json.Marshal(prepared.envelopeEntry()) + if err != nil { + return nil, err + } + audiences[prepared.key] = entry + } + encoded, err := json.Marshal(audiences) + if err != nil { + return nil, err + } + fields["operation_audiences"] = encoded + return json.Marshal(fields) +} diff --git a/pkg/gitops/runnable_grants_test.go b/pkg/gitops/runnable_grants_test.go new file mode 100644 index 00000000..708c7e0e --- /dev/null +++ b/pkg/gitops/runnable_grants_test.go @@ -0,0 +1,260 @@ +package gitops + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/codefly-dev/cli/pkg/environments" + modulecontract "github.com/codefly-dev/core/contracts/module" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + runnablev0 "github.com/codefly-dev/core/generated/go/codefly/runnable/v0" + "github.com/codefly-dev/core/resources" + corerunnable "github.com/codefly-dev/core/runnable" + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/durationpb" +) + +const ( + // grantsEnvironment is the one environment the installer workspace declares. + grantsEnvironment = "test" + // preparedKey is the key the fixture's one prepared binding is configured + // under, as `generate runnable-bindings` spells it. + preparedKey = "OWNER__ITEMS_APPLY" +) + +// preparedValue is a prepared binding of the shape `generate runnable-bindings` +// writes: an owner's operation, its call, its contract and its resolved +// policy — whose invoke and lookup scopes name resource ids, as a resolved +// scope slot does. +func preparedValue(t *testing.T) string { + t.Helper() + const spelling = "/acme.items.v1.Items/Apply" + value, err := corerunnable.EncodePrepared(&runnablev0.PreparedBinding{ + Operation: &runnablev0.PreparedOperation{Module: "owner", Service: "items", Endpoint: "grpc", Spelling: spelling}, + Call: &runnablev0.PreparedCall{ + Address: "http://items-connect.owner:8081", + Route: &runnablev0.PreparedCall_Connect{Connect: &runnablev0.ConnectProcedure{Procedure: spelling}}, + }, + Contract: &basev0.RunnableContract{ + Protocol: resources.RunnableServiceProtocolV1, + Input: &basev0.RunnableSchema{Fields: []*basev0.RunnableField{{Name: "item", Type: basev0.RunnableField_STRING}}}, + Output: &basev0.RunnableSchema{Fields: []*basev0.RunnableField{{Name: "applied", Type: basev0.RunnableField_BOOLEAN}}}, + }, + Policy: &runnablev0.Operation{ + AttemptTimeout: durationpb.New(10 * time.Second), + TotalTimeout: durationpb.New(time.Minute), + MaxAttempts: 3, + Backoff: durationpb.New(time.Second), + RetryableCodes: []string{"UNAVAILABLE"}, + Audience: "acme.items", + InvokeScopes: []*basev0.WorkScopeV1{{ResourceKind: "acme.item", Actions: []string{"write", "read"}, ResourceIds: []string{"item-1"}}}, + LookupScopes: []*basev0.WorkScopeV1{{ResourceKind: "acme.item", Actions: []string{"read"}, ResourceIds: []string{"item-1"}}}, + Completion: basev0.RunnableExecution_COMPLETION_CALL, + }, + }) + require.NoError(t, err) + return string(value) +} + +// installerWorkspace is a workspace composing module "shop", publishing +// authorityContract, whose service "api" installs prepared bindings when +// asked to, with the configuration the contract's slots resolve from and the +// environment's runnable-bindings group. +type installerWorkspace struct { + workspace *resources.Workspace + module *resources.Module + env *environments.Environment +} + +func writeInstallerWorkspace(t *testing.T, installs bool, bindings string) installerWorkspace { + t.Helper() + root := t.TempDir() + groups := []string{"shop"} + if installs { + groups = append(groups, "runnable-bindings") + } + files := map[string]string{ + resources.WorkspaceConfigurationName: "name: acme\nlayout: modules\nmodules:\n - name: shop\nenvironments:\n - name: " + grantsEnvironment + "\n namespace: acme\n", + filepath.Join("modules", "shop", resources.ModuleConfigurationName): "kind: module\nname: shop\nservices:\n - name: api\n", + filepath.Join("modules", "shop", "services", "api", resources.ServiceConfigurationName): exposedHostedServiceYAML("api", groups...) + "module-identity: true\n", + filepath.Join("modules", "shop", modulecontract.FileName): authorityContract, + filepath.Join("configurations", grantsEnvironment, "assistant.env"): "MODEL_AUDIENCE=model-gateway\nMODEL_RESOURCE_KIND=modelservice.profiles\nANNOTATIONS_PREFIX=annotations\n", + } + if bindings != "" { + files[filepath.Join("configurations", grantsEnvironment, "runnable-bindings.env")] = "# Generated by `codefly generate runnable-bindings`. DO NOT EDIT: regenerate.\n" + bindings + } + for rel, content := range files { + full := filepath.Join(root, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(content), 0o644)) + } + ctx := context.Background() + workspace, err := resources.LoadWorkspaceFromDir(ctx, root) + require.NoError(t, err) + module, err := workspace.LoadModuleFromName(ctx, "shop") + require.NoError(t, err) + return installerWorkspace{workspace: workspace, module: module, env: selectedEnvironment(t, workspace, grantsEnvironment)} +} + +// authority is the authority block the shop module's manifest carries, as +// the render derives it. +func (w installerWorkspace) authority(t *testing.T) (*solutionhost.Authority, error) { + t.Helper() + return shopAuthority(t, w.workspace, w.module, loadServices(t, w.workspace, "shop", "api"), w.env) +} + +// contractBindings is what the shop contract asks for (authorityContract). +func contractBindings() []solutionhost.AuthorityBinding { + return []solutionhost.AuthorityBinding{ + {ID: "model", Audience: "model-gateway", Operations: map[string][]string{ + "invoke": {"modelservice.profiles:invoke", "modelservice.profiles:read"}, + "lookup": {"modelservice.profiles:read"}, + }}, + {ID: "annotations", Audience: "annotations", Operations: map[string][]string{ + "headless": {"annotations.annotations:redact", "annotations.vocabularies:write"}, + }}, + } +} + +// TestAModuleInstallingPreparedBindingsAsksForEachOne pins the request half: +// a module whose service installs prepared bindings asks, beside what its +// contract asks, for each prepared binding by its key — with the binding's +// audience and its kinds and actions, and never a resource id. +func TestAModuleInstallingPreparedBindingsAsksForEachOne(t *testing.T) { + w := writeInstallerWorkspace(t, true, preparedKey+"="+preparedValue(t)+"\n") + + authority, err := w.authority(t) + require.NoError(t, err) + require.Equal(t, append(contractBindings(), solutionhost.AuthorityBinding{ + ID: preparedKey, Audience: "acme.items", Operations: map[string][]string{ + "invoke": {"acme.item:read", "acme.item:write"}, + "lookup": {"acme.item:read"}, + }, + }), authority.Bindings) +} + +// TestAModuleInstallingNoPreparedBindingAsksForNone: the same composition, +// with a service that does not declare the group, asks for its contract alone. +func TestAModuleInstallingNoPreparedBindingAsksForNone(t *testing.T) { + w := writeInstallerWorkspace(t, false, preparedKey+"="+preparedValue(t)+"\n") + + authority, err := w.authority(t) + require.NoError(t, err) + require.Equal(t, contractBindings(), authority.Bindings) +} + +// TestAModuleAskingForNothingGainsNoPreparedBinding: a module that publishes +// no request asks for nothing, so the host grants it its envelope whole; a +// prepared binding it installs adds neither a request nor an envelope entry. +func TestAModuleAskingForNothingGainsNoPreparedBinding(t *testing.T) { + w := writeInstallerWorkspace(t, true, preparedKey+"="+preparedValue(t)+"\n") + require.NoError(t, os.Remove(filepath.Join(w.module.Dir(), modulecontract.FileName))) + + authority, err := w.authority(t) + require.NoError(t, err) + require.Equal(t, &solutionhost.Authority{Principal: "shop"}, authority) + + principals := map[string]json.RawMessage{"shop": json.RawMessage(`{"resources":["shop.items"]}`)} + listed, err := WithPreparedBindingGrants(context.Background(), w.workspace, w.env, principals) + require.NoError(t, err) + require.Equal(t, principals, listed) +} + +// TestAPreparedBindingUnderAContractBindingsIdIsRefused: one id, two +// declarations of what it asks, is refused by name. +func TestAPreparedBindingUnderAContractBindingsIdIsRefused(t *testing.T) { + w := writeInstallerWorkspace(t, true, "model="+preparedValue(t)+"\n") + + _, err := w.authority(t) + require.ErrorContains(t, err, "declares binding model and installs the prepared binding runnable-bindings/model") +} + +// TestAValueThatIsNotAPreparedBindingIsRefusedByKey: the group is generated +// output, and a value that does not decode installs nothing. +func TestAValueThatIsNotAPreparedBindingIsRefusedByKey(t *testing.T) { + w := writeInstallerWorkspace(t, true, preparedKey+"={}\n") + + _, err := w.authority(t) + require.ErrorContains(t, err, "runnable-bindings/"+preparedKey+" is not a prepared binding") +} + +// TestTheEnvelopeListsEachPreparedBindingWithTheModuleActorMode pins the +// stopgap half: the installing module's grant gains an entry per prepared +// binding — its audience, its kinds and actions without resource ids, and the +// module_actor mode — and every other field and entry is kept as stated. +func TestTheEnvelopeListsEachPreparedBindingWithTheModuleActorMode(t *testing.T) { + w := writeInstallerWorkspace(t, true, preparedKey+"="+preparedValue(t)+"\n") + model := `{"audience":"model-gateway","invoke_scopes":[{"resource_kind":"modelservice.profiles","actions":["invoke","read"]}]}` + principals := map[string]json.RawMessage{ + "shop": json.RawMessage(`{"resources":["shop.items"],"operation_audiences":{"model":` + model + `}}`), + "other": json.RawMessage(`{"resources":["other.items"]}`), + } + + listed, err := WithPreparedBindingGrants(context.Background(), w.workspace, w.env, principals) + require.NoError(t, err) + require.JSONEq(t, `{ + "resources": ["shop.items"], + "operation_audiences": { + "model": `+model+`, + "`+preparedKey+`": { + "delegation_mode": "module_actor", + "audience": "acme.items", + "invoke_scopes": [{"resource_kind": "acme.item", "actions": ["read", "write"]}], + "lookup_scopes": [{"resource_kind": "acme.item", "actions": ["read"]}] + } + } + }`, string(listed["shop"])) + require.JSONEq(t, `{"resources":["other.items"]}`, string(listed["other"])) + require.NotContains(t, string(listed["shop"]), "item-1") +} + +// TestAnEntryTheCompositionStatesForAPreparedKeyIsKept: the composition's own +// entry for a key is its statement, and is never replaced. +func TestAnEntryTheCompositionStatesForAPreparedKeyIsKept(t *testing.T) { + w := writeInstallerWorkspace(t, true, preparedKey+"="+preparedValue(t)+"\n") + stated := `{"operation_audiences":{"` + preparedKey + `":{"audience":"acme.items","invoke_scopes":[{"resource_kind":"acme.item","actions":["read"]}]}}}` + + listed, err := WithPreparedBindingGrants(context.Background(), w.workspace, w.env, map[string]json.RawMessage{"shop": json.RawMessage(stated)}) + require.NoError(t, err) + require.JSONEq(t, stated, string(listed["shop"])) +} + +// TestAnEnvelopeWithoutPreparedBindingsIsUnchanged: a composition that +// prepares nothing, or grants nothing, writes the envelope it states. +func TestAnEnvelopeWithoutPreparedBindingsIsUnchanged(t *testing.T) { + w := writeInstallerWorkspace(t, true, "") + principals := map[string]json.RawMessage{"shop": json.RawMessage(`{"resources":["shop.items"]}`)} + + listed, err := WithPreparedBindingGrants(context.Background(), w.workspace, w.env, principals) + require.NoError(t, err) + require.Equal(t, principals, listed) + + none, err := WithPreparedBindingGrants(context.Background(), w.workspace, w.env, map[string]json.RawMessage{}) + require.NoError(t, err) + require.Empty(t, none) +} + +// TestAPublishListsPreparedBindingsInTheEnvelopeItDelivers: a publish lists +// them exactly as a native run does, and leaves alone an envelope it does +// not deliver. +func TestAPublishListsPreparedBindingsInTheEnvelopeItDelivers(t *testing.T) { + w := writeInstallerWorkspace(t, true, preparedKey+"="+preparedValue(t)+"\n") + publication := &deliveryPublication{workspace: w.workspace, env: w.env} + require.NoError(t, publication.listPreparedBindings(context.Background(), grantsEnvironment)) + require.Nil(t, publication.options.Envelope) + + publication.options.Envelope = &declaredEnvelope{Principals: map[string]json.RawMessage{"shop": json.RawMessage(`{}`)}, Stated: true, StatesPrincipals: true} + require.NoError(t, publication.listPreparedBindings(context.Background(), grantsEnvironment)) + var grant struct { + OperationAudiences map[string]struct { + DelegationMode string `json:"delegation_mode"` + } `json:"operation_audiences"` + } + require.NoError(t, json.Unmarshal(publication.options.Envelope.Principals["shop"], &grant)) + require.Equal(t, "module_actor", grant.OperationAudiences[preparedKey].DelegationMode) +}