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) +}