From 46bbd62c32107b683d393f8618488b7c6265fcd2 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:57:43 -0400 Subject: [PATCH 1/2] feat(solution/manifest): a consumed API declares the scopes it asks for, and derives the solution's authority request A composed solution is a module, but it declares what it consumes in solution.codefly.yaml's api.consumes, and never in a module contract. Nothing derived an authority request from that declaration. So a solution held no authority document and no registry grant, and a host that bounds a person's capability by what the consumer declares had nothing to read for it. An api.consumes entry may now carry `scopes`: the {resource_kind, actions} the solution asks for at the consumed module when it calls it on a person's behalf. They are validated to the module contract's grammar, refused on exposes and on unbound entries, and kept canonical in the digest. A scoped entry whose `as` names another audience than its module is refused, because a runtime addresses the module by `as` and the binding by its name. Manifest.ConsumptionAuthority(principal) derives the same modulecontract.Resolved a contract resolves to. Each scoped entry becomes one invoke binding with the entry's ID, addressed to the consumed module's name, and bounded by exactly the declared scopes. Namespaces, queues and scope ceilings are empty, not absent. An entry without scopes asks for nothing, and a solution with none returns nil. docs/solution-host-binding.md describes it beside the module contract. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/solution-host-binding.md | 19 +++ solution/manifest/consumption_authority.go | 64 ++++++++ .../manifest/consumption_authority_test.go | 154 ++++++++++++++++++ solution/manifest/manifest.go | 88 ++++++++++ 4 files changed, 325 insertions(+) create mode 100644 solution/manifest/consumption_authority.go create mode 100644 solution/manifest/consumption_authority_test.go diff --git a/docs/solution-host-binding.md b/docs/solution-host-binding.md index afd16d58c..6bd505f92 100644 --- a/docs/solution-host-binding.md +++ b/docs/solution-host-binding.md @@ -1219,6 +1219,25 @@ value: agreeing spellings are one value and resolve, and it is the disagreement that has no answer — see the resolution rules below. A publisher writes through `Encode`, never by marshaling the model. +**A solution's consumption is the same request, declared where a solution +already declares it.** A composed solution is a module, but it does not publish a +module contract: what it calls is declared once, in `solution.codefly.yaml`'s +`api.consumes`, which also drives its generated client and its runtime's +upstream map. An `api.consumes` entry may carry `scopes` — `{resource_kind, +actions}` entries in the consumed module's permission vocabulary, the most it asks +for there when it calls that module on a person's behalf — and +`manifest.ConsumptionAuthority(principal)` derives from them the same +`modulecontract.Resolved` a contract resolves to: one **invoke** binding per scoped +entry, its ID the entry's, its audience the consumed module's name (that module's +principal, and the audience a capability for it is addressed to), its ceiling +exactly the declared scopes. A solution composes modules by name, so the audience +is written directly rather than through a slot; `as`, when set on a scoped entry, +must equal the module, because a runtime addresses the module by `as` and the +binding by its name. An entry without scopes asks for no authority — the solution +calls that module with the viewer's own credential, or on nobody's behalf — and a +solution with none returns nil. A renderer reads one source per module: a module +contract or this derivation, never both. + **Resolution is core's too, not a provider's.** A contract's slots resolve against a composition's workspace configuration, and *which* record answers a slot is as much a rule of this contract as any refusal of a document: two diff --git a/solution/manifest/consumption_authority.go b/solution/manifest/consumption_authority.go new file mode 100644 index 000000000..97d3b0d20 --- /dev/null +++ b/solution/manifest/consumption_authority.go @@ -0,0 +1,64 @@ +package manifest + +import ( + "sort" + + modulecontract "github.com/codefly-dev/core/contracts/module" +) + +// ConsumptionAuthority derives the authority request a solution's declared +// consumption makes: one invoke binding per api.consumes entry that asks for +// scopes, addressed to the consumed module and bounded by those scopes. +// +// It is the solution's counterpart of a module contract's outward bindings, +// derived from the one place a solution already declares what it consumes, so +// a renderer derives the solution's authority document from it exactly as it +// derives a module's from its contract — and nothing restates the consumption +// in a second, hand-kept file. principal is the name the solution is composed +// under: a module's principal is its own name, and a composed solution is a +// module. +// +// The audience is the consumed module's name, which is that module's principal +// and the audience a capability for it is addressed to; a module repository +// spells another module only through a slot, but a solution composes modules by +// name, so its declaration names them directly. Each binding is invoke only: a +// solution calling a module on a person's behalf attenuates that person's own +// authority, and has nothing to look up and nothing to do with nobody present. +// +// A solution that asks for no scopes asks for no authority, and this returns +// nil. Namespaces, queues and scope ceilings are a module's own declarations; +// consumption contributes none, and they are returned empty rather than nil, +// because an authority document distinguishes "declares none" from "dropped". +func (m *Manifest) ConsumptionAuthority(principal string) *modulecontract.Resolved { + var bindings []modulecontract.ResolvedBinding + for _, declaration := range m.API.Consumes { + if len(declaration.Scopes) == 0 { + continue + } + var scopes []string + for _, scope := range sortedConsumedScopes(declaration.Scopes) { + for _, action := range scope.Actions { + scopes = append(scopes, scope.ResourceKind+":"+action) + } + } + sort.Strings(scopes) + bindings = append(bindings, modulecontract.ResolvedBinding{ + ID: declaration.ID, + Revision: 1, + Operations: []string{modulecontract.OperationInvoke}, + Audience: declaration.Module, + Scopes: map[string][]string{modulecontract.OperationInvoke: scopes}, + }) + } + if len(bindings) == 0 { + return nil + } + sort.Slice(bindings, func(i, j int) bool { return bindings[i].ID < bindings[j].ID }) + return &modulecontract.Resolved{ + Principal: principal, + Namespaces: []string{}, + Queues: []string{}, + ScopeCeilings: []modulecontract.ScopeCeiling{}, + Bindings: bindings, + } +} diff --git a/solution/manifest/consumption_authority_test.go b/solution/manifest/consumption_authority_test.go new file mode 100644 index 000000000..412ac94f5 --- /dev/null +++ b/solution/manifest/consumption_authority_test.go @@ -0,0 +1,154 @@ +package manifest_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + modulecontract "github.com/codefly-dev/core/contracts/module" + "github.com/codefly-dev/core/solution/manifest" +) + +// consumingManifest is a solution that consumes three modules: the host's +// accounts (with the viewer's own credential — no scopes), a documents module +// it calls on a person's behalf, and a chat module it does not. +const consumingManifest = ` +schema_version: codefly.solution-manifest/v0 +protocol_version: codefly.solution/v0 +agent: + kind: codefly:solution + publisher: codefly.dev + name: fixture + version: 1.2.3 +api: + exposes: + - id: http + protocol: http + consumes: + - id: accounts + protocol: connect + module: saas + service: accounts + endpoint: connect + as: accounts + - id: documents + protocol: rest + module: documents + service: documents + endpoint: rest + as: documents + scopes: + - resource_kind: documents.passages + actions: [read] + - resource_kind: documents.collections + actions: [read, list] + - id: chat + protocol: http + module: assistant + service: chat + endpoint: http +lifecycle: + render: true +` + +// THE DELIVERY HALF: a solution's declared consumption yields the binding its +// authority document is rendered from — addressed to the consumed module, invoke +// only, bounded by exactly the scopes it declared. +func TestConsumptionAuthorityYieldsABindingPerScopedConsumption(t *testing.T) { + m, err := manifest.Load([]byte(consumingManifest)) + require.NoError(t, err) + + resolved := m.ConsumptionAuthority("knowledge") + + require.NotNil(t, resolved) + require.Equal(t, "knowledge", resolved.Principal) + require.Equal(t, []modulecontract.ResolvedBinding{{ + ID: "documents", + Revision: 1, + Operations: []string{modulecontract.OperationInvoke}, + Audience: "documents", + Scopes: map[string][]string{modulecontract.OperationInvoke: { + "documents.collections:list", "documents.collections:read", "documents.passages:read", + }}, + }}, resolved.Bindings, "only the consumption that asks for scopes asks for authority") + // "Declares none" is written out, never dropped. + require.NotNil(t, resolved.Namespaces) + require.NotNil(t, resolved.Queues) + require.NotNil(t, resolved.ScopeCeilings) + require.Empty(t, resolved.Namespaces) +} + +// A solution that asks for no scopes asks for no authority. +func TestConsumptionAuthorityIsNilWithoutScopes(t *testing.T) { + m, err := manifest.Load([]byte(validManifest)) + require.NoError(t, err) + require.Nil(t, m.ConsumptionAuthority("fixture")) +} + +// The scopes are canonical in the digest: the same declaration written in +// another order is the same manifest. +func TestConsumedScopesOrderIndependentDigest(t *testing.T) { + first, err := manifest.Load([]byte(consumingManifest)) + require.NoError(t, err) + reordered := strings.Replace(consumingManifest, + " - resource_kind: documents.passages\n actions: [read]\n - resource_kind: documents.collections\n actions: [read, list]\n", + " - resource_kind: documents.collections\n actions: [list, read]\n - resource_kind: documents.passages\n actions: [read]\n", 1) + require.NotEqual(t, consumingManifest, reordered) + second, err := manifest.Load([]byte(reordered)) + require.NoError(t, err) + + firstDigest, err := first.Digest() + require.NoError(t, err) + secondDigest, err := second.Digest() + require.NoError(t, err) + require.Equal(t, firstDigest, secondDigest) + require.Equal(t, first.ConsumptionAuthority("p"), second.ConsumptionAuthority("p")) +} + +func TestManifestRejectsMalformedConsumedScopes(t *testing.T) { + scoped := " - resource_kind: documents.passages\n actions: [read]\n" + for _, tc := range []struct { + name string + mutate func(string) string + message string + }{ + {"kind not a resource kind", func(s string) string { + return strings.Replace(s, "resource_kind: documents.passages", "resource_kind: Documents:Passages", 1) + }, `resource_kind "Documents:Passages" is not a resource kind`}, + {"kind declared twice", func(s string) string { + return strings.Replace(s, scoped, scoped+scoped, 1) + }, `"documents.passages" is declared twice`}, + {"no action", func(s string) string { + return strings.Replace(s, " actions: [read]\n", " actions: []\n", 1) + }, "names no action"}, + {"action not an action", func(s string) string { + return strings.Replace(s, " actions: [read]\n", " actions: [\"*\"]\n", 1) + }, `actions entry "*" is not an action`}, + {"action declared twice", func(s string) string { + return strings.Replace(s, " actions: [read]\n", " actions: [read, read]\n", 1) + }, `actions entry "read" is declared twice`}, + {"as names another audience", func(s string) string { + return strings.Replace(s, " as: documents\n", " as: docs\n", 1) + }, `asks for scopes at module "documents" but names it "docs" with as`}, + {"scopes on an unbound consumption", func(s string) string { + return strings.Replace(s, + " module: documents\n service: documents\n endpoint: rest\n as: documents\n", "", 1) + }, "asks for authority at no module"}, + } { + t.Run(tc.name, func(t *testing.T) { + mutated := tc.mutate(consumingManifest) + require.NotEqual(t, consumingManifest, mutated, "the mutation must apply") + _, err := manifest.Load([]byte(mutated)) + require.ErrorContains(t, err, tc.message) + }) + } +} + +func TestManifestRejectsScopesOnExposedAPI(t *testing.T) { + exposed := strings.Replace(consumingManifest, + " - id: http\n protocol: http\n", + " - id: http\n protocol: http\n scopes:\n - resource_kind: documents.passages\n actions: [read]\n", 1) + _, err := manifest.Load([]byte(exposed)) + require.ErrorContains(t, err, "api.exposes[0].scopes is not allowed") +} diff --git a/solution/manifest/manifest.go b/solution/manifest/manifest.go index 382dc9185..e22820bec 100644 --- a/solution/manifest/manifest.go +++ b/solution/manifest/manifest.go @@ -88,6 +88,24 @@ type APIDeclaration struct { // As is the facade entry-point name (default: the endpoint's package // short name, e.g. "accounts"). As string `yaml:"as,omitempty" json:"as,omitempty"` + // Scopes are the most authority the solution asks for at the consumed + // module when it calls it on a person's behalf (api.consumes only): each a + // resource kind in that module's permission vocabulary and the actions on + // it. They are what the renderer derives the solution's authority document + // from (see ConsumptionAuthority) — the one declaration of what the + // solution consumes, so no second file restates it. Absent means the + // solution mints no capability for that module: it calls it with the + // viewer's own credential, or not on anyone's behalf at all. + Scopes []ConsumedScope `yaml:"scopes,omitempty" json:"scopes,omitempty"` +} + +// ConsumedScope is one resource kind of a consumed module's permission +// vocabulary and the actions a solution asks for on it. The kind is written +// literally: a permission's namespace is fixed by the module that contributes +// it, as its proto package is, and a solution composes that module by name. +type ConsumedScope struct { + ResourceKind string `yaml:"resource_kind" json:"resource_kind"` + Actions []string `yaml:"actions" json:"actions"` } // Events declares the events a solution emits and consumes. @@ -259,6 +277,7 @@ func validateExposedAPIs(name string, declarations []APIDeclaration) error { {"version", declaration.Version != ""}, {"services", len(declaration.Services) > 0}, {"as", declaration.As != ""}, + {"scopes", len(declaration.Scopes) > 0}, } { if field.set { return fmt.Errorf("%s[%d].%s is not allowed", name, i, field.name) @@ -321,6 +340,61 @@ func validateConsumedAPIs(name string, declarations []APIDeclaration) error { } seenAs[declaration.As] = struct{}{} } + if err := validateConsumedScopes(name, i, declaration); err != nil { + return err + } + } + return nil +} + +// scopeKindPattern and scopeActionPattern are the module contract's grammar for +// a resource kind and an action: a scope is concatenated into +// ":", so a kind carrying a colon or a comma would carry scopes +// nobody declared. +var ( + scopeKindPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*$`) + scopeActionPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) +) + +// validateConsumedScopes holds a consumption's scopes to the grammar its +// authority document is held to, and to the one module they are asked of. +func validateConsumedScopes(name string, i int, declaration APIDeclaration) error { + if len(declaration.Scopes) == 0 { + return nil + } + if declaration.Module == "" { + return fmt.Errorf("%s[%d].scopes asks for authority at no module: scopes are asked of a bound consumption (module, service, endpoint)", name, i) + } + // The audience a capability for a module is addressed to is that module's + // name — its principal — and a runtime addresses the module by `as`. A + // consumption that asks for authority under two names would render a + // binding for one and mint for the other, refused far from here. + if declaration.As != "" && declaration.As != declaration.Module { + return fmt.Errorf("%s[%d] asks for scopes at module %q but names it %q with as; the audience a capability for a module is addressed to is the module's own name, so a consumption that asks for authority sets as to it or leaves it unset", + name, i, declaration.Module, declaration.As) + } + kinds := make(map[string]struct{}, len(declaration.Scopes)) + for j, scope := range declaration.Scopes { + if !scopeKindPattern.MatchString(scope.ResourceKind) { + return fmt.Errorf("%s[%d].scopes[%d].resource_kind %q is not a resource kind", name, i, j, scope.ResourceKind) + } + if _, duplicate := kinds[scope.ResourceKind]; duplicate { + return fmt.Errorf("%s[%d].scopes[%d].resource_kind %q is declared twice; one kind has one entry", name, i, j, scope.ResourceKind) + } + kinds[scope.ResourceKind] = struct{}{} + if len(scope.Actions) == 0 { + return fmt.Errorf("%s[%d].scopes[%d] names no action on %q; a scope permits something", name, i, j, scope.ResourceKind) + } + actions := make(map[string]struct{}, len(scope.Actions)) + for _, action := range scope.Actions { + if !scopeActionPattern.MatchString(action) { + return fmt.Errorf("%s[%d].scopes[%d].actions entry %q is not an action", name, i, j, action) + } + if _, duplicate := actions[action]; duplicate { + return fmt.Errorf("%s[%d].scopes[%d].actions entry %q is declared twice", name, i, j, action) + } + actions[action] = struct{}{} + } } return nil } @@ -435,6 +509,9 @@ func sortedAPIDeclarations(values []APIDeclaration) []APIDeclaration { sort.Strings(services) out[i].Services = services } + if len(out[i].Scopes) > 0 { + out[i].Scopes = sortedConsumedScopes(out[i].Scopes) + } } sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out @@ -445,3 +522,14 @@ func sortedEventDeclarations(values []EventDeclaration) []EventDeclaration { sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) return out } + +func sortedConsumedScopes(values []ConsumedScope) []ConsumedScope { + out := make([]ConsumedScope, 0, len(values)) + for _, scope := range values { + actions := append([]string(nil), scope.Actions...) + sort.Strings(actions) + out = append(out, ConsumedScope{ResourceKind: scope.ResourceKind, Actions: actions}) + } + sort.Slice(out, func(i, j int) bool { return out[i].ResourceKind < out[j].ResourceKind }) + return out +} From 03d6e853a64014b8ce98daed0ac9b6384930cd55 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:28:50 -0400 Subject: [PATCH 2/2] fix(solution/manifest): resolve a consumption's audience like a contract, and read only api.consumes for authority From review: - The audience is a slot ({from: group/key}) on a scoped entry, resolved by the module contract's Resolve, because the audience a module verifies is the composition's to say. The rule tying `as` to the module is gone. - ConsumptionContract builds a module contract, so the contract's rules hold the request's grammar (kinds, actions, slot key meaning, revision) and its Resolve handles secrets and ambiguity. This replaces ConsumptionAuthority, which built a Resolved past the rule kit and duplicated the grammar. - LoadConsumption reads only the schema version and api.consumes, strictly. A section that has nothing to do with consumption no longer fails a reader that derives authority. - The runtime projection (ConsumedAPI) carries the declared scopes. - `scopes: []` is refused. An entry's revision can be bumped. A request requires schema codefly.solution-manifest/v1, and v0 is still read. - Slot marshals as {"from": ...} in JSON, so canonical bytes spell it as YAML does. - Fixtures use structural names. Co-Authored-By: Claude Opus 5.5 (1M context) --- contracts/module/contract.go | 8 + docs/solution-host-binding.md | 41 ++-- solution/manifest/consumes.go | 7 + solution/manifest/consumption_authority.go | 177 +++++++++++--- .../manifest/consumption_authority_test.go | 228 +++++++++++++++--- solution/manifest/manifest.go | 131 ++++++---- .../testdata/consumption/agentless.yaml | 18 ++ .../testdata/consumption/scoped-consumer.yaml | 38 +++ .../consumption/undeclared-section.yaml | 22 ++ 9 files changed, 531 insertions(+), 139 deletions(-) create mode 100644 solution/manifest/testdata/consumption/agentless.yaml create mode 100644 solution/manifest/testdata/consumption/scoped-consumer.yaml create mode 100644 solution/manifest/testdata/consumption/undeclared-section.yaml diff --git a/contracts/module/contract.go b/contracts/module/contract.go index a2b231bb3..9e729e8cb 100644 --- a/contracts/module/contract.go +++ b/contracts/module/contract.go @@ -81,6 +81,7 @@ package module import ( "bytes" + "encoding/json" "errors" "fmt" "io" @@ -395,6 +396,13 @@ func (slot Slot) MarshalYAML() (any, error) { return map[string]string{fieldFrom: slot.From}, nil } +// MarshalJSON writes a slot in the spelling the file uses, {"from": ...}, so a +// document that carries one as JSON — a solution manifest's canonical bytes — +// spells it as its YAML does. +func (slot Slot) MarshalJSON() ([]byte, error) { + return json.Marshal(map[string]string{fieldFrom: slot.From}) +} + // Group and Key split the slot's reference into the workspace configuration // group and the key within it. func (slot Slot) Group() string { group, _, _ := strings.Cut(slot.From, "/"); return group } diff --git a/docs/solution-host-binding.md b/docs/solution-host-binding.md index 6bd505f92..6b5677c74 100644 --- a/docs/solution-host-binding.md +++ b/docs/solution-host-binding.md @@ -1220,23 +1220,30 @@ that has no answer — see the resolution rules below. A publisher writes throug `Encode`, never by marshaling the model. **A solution's consumption is the same request, declared where a solution -already declares it.** A composed solution is a module, but it does not publish a -module contract: what it calls is declared once, in `solution.codefly.yaml`'s -`api.consumes`, which also drives its generated client and its runtime's -upstream map. An `api.consumes` entry may carry `scopes` — `{resource_kind, -actions}` entries in the consumed module's permission vocabulary, the most it asks -for there when it calls that module on a person's behalf — and -`manifest.ConsumptionAuthority(principal)` derives from them the same -`modulecontract.Resolved` a contract resolves to: one **invoke** binding per scoped -entry, its ID the entry's, its audience the consumed module's name (that module's -principal, and the audience a capability for it is addressed to), its ceiling -exactly the declared scopes. A solution composes modules by name, so the audience -is written directly rather than through a slot; `as`, when set on a scoped entry, -must equal the module, because a runtime addresses the module by `as` and the -binding by its name. An entry without scopes asks for no authority — the solution -calls that module with the viewer's own credential, or on nobody's behalf — and a -solution with none returns nil. A renderer reads one source per module: a module -contract or this derivation, never both. +already declares it.** A composed solution is a module, but what it calls is +declared once, in `solution.codefly.yaml`'s `api.consumes`, which also drives its +generated client and its runtime's upstream map. Under schema +`codefly.solution-manifest/v1` an entry may ask for authority at the module it +consumes: `scopes` (`{resource_kind, actions}` in that module's permission +vocabulary), an `audience` slot (`{from: /}`) and an optional +`revision`. `Manifest.ConsumptionContract(principal)` turns the scoped entries +into a **module contract** — one **invoke** binding per entry, its ID the entry's, +its ceiling exactly the declared scopes — so the request is judged by this +package's rules and resolved by its `Resolve`, exactly as a module's published +contract is: the audience a module verifies is the composition's to say and is +not always the name it is composed under, and a solution cannot spell a kind or +an action a contract would refuse. The principal is the name the solution is +composed under. An entry without scopes asks for nothing; `scopes: []` is +refused, not read as absent; a v0 manifest cannot carry a request, so an older +reader refuses the version rather than reading a request as nothing. + +`LoadConsumption` reads the schema version and `api.consumes` strictly and +nothing else, for a renderer that derives authority from the consumption alone: +an unknown or invalid field in a consumption fails, while a section unrelated to +it never fails a render that asks for no authority. The runtime projection +(`ConsumedAPI`, `CODEFLY__API_CONSUMES`) carries the declared scopes, so a runtime +can refuse a mint outside the declaration it was rendered from — written once, in +the YAML. **Resolution is core's too, not a provider's.** A contract's slots resolve against a composition's workspace configuration, and *which* record answers a diff --git a/solution/manifest/consumes.go b/solution/manifest/consumes.go index 4ae81d836..0576c7b9b 100644 --- a/solution/manifest/consumes.go +++ b/solution/manifest/consumes.go @@ -28,6 +28,12 @@ type ConsumedAPI struct { Endpoint string `json:"endpoint"` Protocol string `json:"protocol"` As string `json:"as,omitempty"` + // Scopes are the scopes the entry asks for (see APIDeclaration.Scopes), + // carried so a runtime can refuse to mint for this module outside them: + // the declaration the authority document is rendered from is then the + // ceiling the runtime holds its own asks to, written in one place. Absent + // when the entry asks for nothing. + Scopes []ConsumedScope `json:"scopes,omitempty"` } // ConsumedAPIs returns the runtime projection of the solution's api.consumes, in @@ -51,6 +57,7 @@ func (m *Manifest) ConsumedAPIs() []ConsumedAPI { Endpoint: declaration.Endpoint, Protocol: declaration.Protocol, As: declaration.As, + Scopes: sortedConsumedScopes(declaration.Scopes), }) } if len(out) == 0 { diff --git a/solution/manifest/consumption_authority.go b/solution/manifest/consumption_authority.go index 97d3b0d20..29b58f152 100644 --- a/solution/manifest/consumption_authority.go +++ b/solution/manifest/consumption_authority.go @@ -1,64 +1,169 @@ package manifest import ( + "bytes" + "errors" + "fmt" + "io" "sort" + "gopkg.in/yaml.v3" + modulecontract "github.com/codefly-dev/core/contracts/module" ) -// ConsumptionAuthority derives the authority request a solution's declared -// consumption makes: one invoke binding per api.consumes entry that asks for -// scopes, addressed to the consumed module and bounded by those scopes. +// ConsumptionContract is the module contract a solution's declared consumption +// amounts to: one INVOKE binding per api.consumes entry that asks for scopes — +// its ID the entry's, its audience the entry's audience slot, its ceiling +// exactly the declared scopes, its revision the entry's — under principal, the +// name the solution is composed under (a composed solution is a module, and a +// module's principal is its own name). // -// It is the solution's counterpart of a module contract's outward bindings, -// derived from the one place a solution already declares what it consumes, so -// a renderer derives the solution's authority document from it exactly as it -// derives a module's from its contract — and nothing restates the consumption -// in a second, hand-kept file. principal is the name the solution is composed -// under: a module's principal is its own name, and a composed solution is a -// module. +// It is a contract and not a resolved request on purpose. The request then +// goes through the ONE implementation of the module contract — its rules hold +// the grammar of every kind, action, slot and revision, and its Resolve +// resolves the audience against the composition with the same secret and +// ambiguity refusals a module's contract gets — so a solution's request and a +// module's cannot be read two ways. A renderer derives the authority document +// from it exactly as it derives one from a module's published contract. // -// The audience is the consumed module's name, which is that module's principal -// and the audience a capability for it is addressed to; a module repository -// spells another module only through a slot, but a solution composes modules by -// name, so its declaration names them directly. Each binding is invoke only: a -// solution calling a module on a person's behalf attenuates that person's own -// authority, and has nothing to look up and nothing to do with nobody present. +// Invoke only: a solution calling a module on a person's behalf attenuates +// that person's own authority, and has nothing to look up and nothing to do +// with nobody present. Namespaces, queues, scope ceilings and destinations are +// a module's own declarations; consumption contributes none, and they are +// written empty, not absent. // -// A solution that asks for no scopes asks for no authority, and this returns -// nil. Namespaces, queues and scope ceilings are a module's own declarations; -// consumption contributes none, and they are returned empty rather than nil, -// because an authority document distinguishes "declares none" from "dropped". -func (m *Manifest) ConsumptionAuthority(principal string) *modulecontract.Resolved { - var bindings []modulecontract.ResolvedBinding - for _, declaration := range m.API.Consumes { - if len(declaration.Scopes) == 0 { +// A solution whose entries ask for no scopes asks for no authority: nil, nil. +func (m *Manifest) ConsumptionContract(principal string) (*modulecontract.Contract, error) { + contract := consumptionContract(principal, m.API.Consumes) + if contract == nil { + return nil, nil + } + if err := contract.Validate(); err != nil { + return nil, fmt.Errorf("the authority api.consumes asks for: %w", err) + } + return contract, nil +} + +func consumptionContract(principal string, declarations []APIDeclaration) *modulecontract.Contract { + var bindings []modulecontract.Binding + for _, declaration := range declarations { + if len(declaration.Scopes) == 0 || declaration.Audience == nil { continue } - var scopes []string + scopes := make([]modulecontract.CeilingScope, 0, len(declaration.Scopes)) for _, scope := range sortedConsumedScopes(declaration.Scopes) { - for _, action := range scope.Actions { - scopes = append(scopes, scope.ResourceKind+":"+action) - } + scopes = append(scopes, modulecontract.CeilingScope{ResourceKind: scope.ResourceKind, Actions: scope.Actions}) } - sort.Strings(scopes) - bindings = append(bindings, modulecontract.ResolvedBinding{ - ID: declaration.ID, - Revision: 1, - Operations: []string{modulecontract.OperationInvoke}, - Audience: declaration.Module, - Scopes: map[string][]string{modulecontract.OperationInvoke: scopes}, + bindings = append(bindings, modulecontract.Binding{ + ID: declaration.ID, + Revision: declaration.Revision, + Operations: []string{modulecontract.OperationInvoke}, + Audience: *declaration.Audience, + ScopeCeiling: map[string]modulecontract.Ceiling{modulecontract.OperationInvoke: {Scopes: scopes}}, }) } if len(bindings) == 0 { return nil } sort.Slice(bindings, func(i, j int) bool { return bindings[i].ID < bindings[j].ID }) - return &modulecontract.Resolved{ + return &modulecontract.Contract{ + Schema: modulecontract.SchemaV1, Principal: principal, Namespaces: []string{}, Queues: []string{}, ScopeCeilings: []modulecontract.ScopeCeiling{}, Bindings: bindings, + Destinations: []modulecontract.Destination{}, + } +} + +// consumptionValidationPrincipal stands in for the principal while a manifest +// is validated on its own. The principal is the renderer's to supply — the name +// the solution is composed under — and no binding rule depends on it; the +// renderer validates again, under the real one. +const consumptionValidationPrincipal = "solution" + +// validateConsumption holds every request in api.consumes to the module +// contract's rules. +func validateConsumption(declarations []APIDeclaration) error { + contract := consumptionContract(consumptionValidationPrincipal, declarations) + if contract == nil { + return nil + } + if err := contract.Validate(); err != nil { + return fmt.Errorf("api.consumes asks for authority the module contract refuses: %w", err) + } + return nil +} + +// LoadConsumption reads what a solution manifest declares it CONSUMES, and +// nothing else: the schema version and api.consumes, strictly — an unknown +// field in a consumption, an invalid one, or a request the module contract +// refuses fails — while every other section is left unread. +// +// It exists for a reader that derives authority from the consumption alone. +// Such a reader must not fail on a section it does not read: a solution whose +// agent block or another section does not pass Load asks for no authority +// because of it, and refusing it there would refuse a render that has nothing +// to do with authority. The returned manifest carries the schema version and +// the consumption only. +func LoadConsumption(data []byte) (*Manifest, error) { + var document struct { + SchemaVersion string `yaml:"schema_version"` + API yaml.Node `yaml:"api"` + } + if err := yaml.Unmarshal(data, &document); err != nil { + return nil, fmt.Errorf("decode solution manifest: %w", err) + } + if err := validateSchemaVersion(document.SchemaVersion); err != nil { + return nil, err + } + consumes, err := consumesNode(&document.API) + if err != nil { + return nil, err + } + var declarations []APIDeclaration + if consumes != nil { + raw, err := yaml.Marshal(consumes) + if err != nil { + return nil, fmt.Errorf("decode api.consumes: %w", err) + } + decoder := yaml.NewDecoder(bytes.NewReader(raw)) + decoder.KnownFields(true) + if err := decoder.Decode(&declarations); err != nil && !errors.Is(err, io.EOF) { + return nil, fmt.Errorf("decode api.consumes: %w", err) + } + } + if err := validateConsumedAPIs("api.consumes", declarations); err != nil { + return nil, err + } + if err := validateConsumptionVersion(document.SchemaVersion, declarations); err != nil { + return nil, err + } + if err := validateConsumption(declarations); err != nil { + return nil, err + } + return &Manifest{SchemaVersion: document.SchemaVersion, API: API{Consumes: declarations}}, nil +} + +// consumesNode finds api.consumes in a decoded api mapping, or nil. +func consumesNode(api *yaml.Node) (*yaml.Node, error) { + if api == nil || api.Kind == 0 { + return nil, nil + } + if api.Kind != yaml.MappingNode { + return nil, fmt.Errorf("decode solution manifest: api is not a mapping") + } + var found *yaml.Node + for index := 0; index+1 < len(api.Content); index += 2 { + if api.Content[index].Value != "consumes" { + continue + } + if found != nil { + return nil, fmt.Errorf("decode solution manifest: api.consumes is declared twice") + } + found = api.Content[index+1] } + return found, nil } diff --git a/solution/manifest/consumption_authority_test.go b/solution/manifest/consumption_authority_test.go index 412ac94f5..51cbd2f7f 100644 --- a/solution/manifest/consumption_authority_test.go +++ b/solution/manifest/consumption_authority_test.go @@ -1,6 +1,9 @@ package manifest_test import ( + "encoding/json" + "os" + "path/filepath" "strings" "testing" @@ -10,11 +13,11 @@ import ( "github.com/codefly-dev/core/solution/manifest" ) -// consumingManifest is a solution that consumes three modules: the host's -// accounts (with the viewer's own credential — no scopes), a documents module -// it calls on a person's behalf, and a chat module it does not. +// consumingManifest consumes three modules: a platform module with the +// viewer's own credential (no request), a documents module it calls on a +// person's behalf, and a chat module it does not. const consumingManifest = ` -schema_version: codefly.solution-manifest/v0 +schema_version: codefly.solution-manifest/v1 protocol_version: codefly.solution/v0 agent: kind: codefly:solution @@ -26,18 +29,19 @@ api: - id: http protocol: http consumes: - - id: accounts + - id: identity protocol: connect - module: saas - service: accounts + module: platform + service: identity endpoint: connect - as: accounts + as: identity - id: documents protocol: rest module: documents service: documents endpoint: rest as: documents + audience: {from: documents/documents-prefix} scopes: - resource_kind: documents.passages actions: [read] @@ -52,17 +56,32 @@ lifecycle: render: true ` -// THE DELIVERY HALF: a solution's declared consumption yields the binding its -// authority document is rendered from — addressed to the consumed module, invoke -// only, bounded by exactly the scopes it declared. -func TestConsumptionAuthorityYieldsABindingPerScopedConsumption(t *testing.T) { - m, err := manifest.Load([]byte(consumingManifest)) +// values supplies the composition's configuration the audience slots resolve +// from. +var values = modulecontract.MapValues{Public: map[string]map[string]string{ + "documents": {"DOCUMENTS_PREFIX": "documents"}, + "model": {"MODEL_AUDIENCE": "modelservice"}, +}} + +func resolvedConsumption(t *testing.T, document, principal string) *modulecontract.Resolved { + t.Helper() + m, err := manifest.Load([]byte(document)) + require.NoError(t, err) + contract, err := m.ConsumptionContract(principal) require.NoError(t, err) + require.NotNil(t, contract) + resolved, err := contract.Resolve(values) + require.NoError(t, err) + return resolved +} - resolved := m.ConsumptionAuthority("knowledge") +// THE DELIVERY HALF: a solution's declared consumption is a module contract — +// one invoke binding per scoped entry, its audience resolved from the +// composition, bounded by exactly the scopes it declared. +func TestConsumptionContractIsABindingPerScopedEntry(t *testing.T) { + resolved := resolvedConsumption(t, consumingManifest, "consumer") - require.NotNil(t, resolved) - require.Equal(t, "knowledge", resolved.Principal) + require.Equal(t, "consumer", resolved.Principal) require.Equal(t, []modulecontract.ResolvedBinding{{ ID: "documents", Revision: 1, @@ -71,23 +90,72 @@ func TestConsumptionAuthorityYieldsABindingPerScopedConsumption(t *testing.T) { Scopes: map[string][]string{modulecontract.OperationInvoke: { "documents.collections:list", "documents.collections:read", "documents.passages:read", }}, - }}, resolved.Bindings, "only the consumption that asks for scopes asks for authority") - // "Declares none" is written out, never dropped. + }}, resolved.Bindings, "only the entry that asks for scopes asks for authority") require.NotNil(t, resolved.Namespaces) require.NotNil(t, resolved.Queues) require.NotNil(t, resolved.ScopeCeilings) require.Empty(t, resolved.Namespaces) } +// The audience is the composition's to say, resolved like a contract's: a +// module composed as `model` that verifies `modelservice`, addressed by the +// runtime as `modelservice`. There is no rule tying `as` to the module. +func TestConsumptionAudienceIsResolvedFromTheComposition(t *testing.T) { + document := strings.Replace(consumingManifest, + " - id: chat\n protocol: http\n module: assistant\n service: chat\n endpoint: http\n", + " - id: model\n protocol: rest\n module: model\n service: model\n endpoint: rest\n as: modelservice\n audience: {from: model/model-audience}\n scopes:\n - resource_kind: modelservice.profiles\n actions: [invoke]\n", 1) + require.NotEqual(t, consumingManifest, document) + + resolved := resolvedConsumption(t, document, "consumer") + + audiences := map[string]string{} + for _, binding := range resolved.Bindings { + audiences[binding.ID] = binding.Audience + } + require.Equal(t, map[string]string{"documents": "documents", "model": "modelservice"}, audiences) +} + +// An unresolved audience fails resolution by the contract's own sentinel, as a +// module contract's would. +func TestAnUnresolvedConsumptionAudienceFailsLikeAContract(t *testing.T) { + m, err := manifest.Load([]byte(consumingManifest)) + require.NoError(t, err) + contract, err := m.ConsumptionContract("consumer") + require.NoError(t, err) + _, err = contract.Resolve(modulecontract.MapValues{}) + require.ErrorIs(t, err, modulecontract.ErrUnresolvedSlot) +} + +// Two scoped endpoints of one module are two bindings at one audience. +func TestTwoScopedEndpointsOfOneModule(t *testing.T) { + document := strings.Replace(consumingManifest, + " - id: chat\n", + " - id: documents-grpc\n protocol: grpc\n module: documents\n service: documents\n endpoint: grpc\n as: documents-grpc\n audience: {from: documents/documents-prefix}\n scopes:\n - resource_kind: documents.passages\n actions: [read]\n - id: chat\n", 1) + + resolved := resolvedConsumption(t, document, "consumer") + + require.Len(t, resolved.Bindings, 2) + for _, binding := range resolved.Bindings { + require.Equal(t, "documents", binding.Audience) + } +} + +// The revision is the solution's to bump. +func TestTheConsumptionRevisionIsBumpable(t *testing.T) { + document := strings.Replace(consumingManifest, " audience: {from: documents/documents-prefix}\n", " audience: {from: documents/documents-prefix}\n revision: 3\n", 1) + require.Equal(t, uint64(3), resolvedConsumption(t, document, "consumer").Bindings[0].Revision) +} + // A solution that asks for no scopes asks for no authority. -func TestConsumptionAuthorityIsNilWithoutScopes(t *testing.T) { +func TestConsumptionContractIsNilWithoutScopes(t *testing.T) { m, err := manifest.Load([]byte(validManifest)) require.NoError(t, err) - require.Nil(t, m.ConsumptionAuthority("fixture")) + contract, err := m.ConsumptionContract("fixture") + require.NoError(t, err) + require.Nil(t, contract) } -// The scopes are canonical in the digest: the same declaration written in -// another order is the same manifest. +// The scopes are canonical in the digest. func TestConsumedScopesOrderIndependentDigest(t *testing.T) { first, err := manifest.Load([]byte(consumingManifest)) require.NoError(t, err) @@ -97,58 +165,146 @@ func TestConsumedScopesOrderIndependentDigest(t *testing.T) { require.NotEqual(t, consumingManifest, reordered) second, err := manifest.Load([]byte(reordered)) require.NoError(t, err) - firstDigest, err := first.Digest() require.NoError(t, err) secondDigest, err := second.Digest() require.NoError(t, err) require.Equal(t, firstDigest, secondDigest) - require.Equal(t, first.ConsumptionAuthority("p"), second.ConsumptionAuthority("p")) } -func TestManifestRejectsMalformedConsumedScopes(t *testing.T) { +// THE RUNTIME PROJECTION carries the declared scopes, so a runtime can hold its +// own mints to the declaration the authority document is rendered from. +func TestTheRuntimeProjectionCarriesDeclaredScopes(t *testing.T) { + m, err := manifest.Load([]byte(consumingManifest)) + require.NoError(t, err) + var projected []manifest.ConsumedAPI + require.NoError(t, json.Unmarshal([]byte(m.ConsumedAPIsEnvValue()), &projected)) + byID := map[string]manifest.ConsumedAPI{} + for _, consumed := range projected { + byID[consumed.ID] = consumed + } + require.Equal(t, []manifest.ConsumedScope{ + {ResourceKind: "documents.collections", Actions: []string{"list", "read"}}, + {ResourceKind: "documents.passages", Actions: []string{"read"}}, + }, byID["documents"].Scopes) + require.Nil(t, byID["identity"].Scopes, "an entry that asks for nothing carries nothing") +} + +func TestManifestRejectsMalformedConsumption(t *testing.T) { scoped := " - resource_kind: documents.passages\n actions: [read]\n" for _, tc := range []struct { name string mutate func(string) string message string }{ + {"scopes present and empty", func(s string) string { + return strings.Replace(s, " scopes:\n"+scoped+" - resource_kind: documents.collections\n actions: [read, list]\n", " scopes: []\n", 1) + }, "present and empty"}, + {"no audience", func(s string) string { + return strings.Replace(s, " audience: {from: documents/documents-prefix}\n", "", 1) + }, "names no audience"}, + {"a literal audience", func(s string) string { + return strings.Replace(s, "audience: {from: documents/documents-prefix}", "audience: documents", 1) + }, "module contract refuses"}, + {"an audience key that does not say it is one", func(s string) string { + return strings.Replace(s, "audience: {from: documents/documents-prefix}", "audience: {from: documents/documents-endpoint}", 1) + }, "module contract refuses"}, + {"audience without scopes", func(s string) string { + return strings.Replace(s, " as: identity\n", " as: identity\n audience: {from: platform/identity-audience}\n", 1) + }, "asks for no scopes"}, {"kind not a resource kind", func(s string) string { return strings.Replace(s, "resource_kind: documents.passages", "resource_kind: Documents:Passages", 1) - }, `resource_kind "Documents:Passages" is not a resource kind`}, + }, "module contract refuses"}, {"kind declared twice", func(s string) string { return strings.Replace(s, scoped, scoped+scoped, 1) - }, `"documents.passages" is declared twice`}, + }, "module contract refuses"}, {"no action", func(s string) string { return strings.Replace(s, " actions: [read]\n", " actions: []\n", 1) }, "names no action"}, - {"action not an action", func(s string) string { + {"a wildcard action", func(s string) string { return strings.Replace(s, " actions: [read]\n", " actions: [\"*\"]\n", 1) - }, `actions entry "*" is not an action`}, + }, "module contract refuses"}, {"action declared twice", func(s string) string { return strings.Replace(s, " actions: [read]\n", " actions: [read, read]\n", 1) - }, `actions entry "read" is declared twice`}, - {"as names another audience", func(s string) string { - return strings.Replace(s, " as: documents\n", " as: docs\n", 1) - }, `asks for scopes at module "documents" but names it "docs" with as`}, + }, "module contract refuses"}, {"scopes on an unbound consumption", func(s string) string { - return strings.Replace(s, - " module: documents\n service: documents\n endpoint: rest\n as: documents\n", "", 1) + return strings.Replace(s, " module: documents\n service: documents\n endpoint: rest\n as: documents\n", "", 1) }, "asks for authority at no module"}, + {"a request in a v0 manifest", func(s string) string { + return strings.Replace(s, "codefly.solution-manifest/v1", "codefly.solution-manifest/v0", 1) + }, "declare schema_version: codefly.solution-manifest/v1"}, } { t.Run(tc.name, func(t *testing.T) { mutated := tc.mutate(consumingManifest) require.NotEqual(t, consumingManifest, mutated, "the mutation must apply") _, err := manifest.Load([]byte(mutated)) require.ErrorContains(t, err, tc.message) + _, err = manifest.LoadConsumption([]byte(mutated)) + require.ErrorContains(t, err, tc.message, "the consumption-only read refuses what Load refuses in api.consumes") }) } } -func TestManifestRejectsScopesOnExposedAPI(t *testing.T) { +// ONE GRAMMAR: the kinds the module contract refuses are refused here, and the +// ones it admits are admitted — because it is the module contract that judges. +func TestTheConsumptionGrammarIsTheModuleContracts(t *testing.T) { + for _, kind := range []string{"0documents", "documents.", "documents..x", "a_b", "documents:x", "documents.passages"} { + document := strings.Replace(consumingManifest, "resource_kind: documents.passages", "resource_kind: "+kind, 1) + _, manifestErr := manifest.Load([]byte(document)) + contract := "schema: codefly/module-contract/v1\nprincipal: consumer\nnamespaces: []\nqueues: []\nscope_ceilings: []\nbindings:\n - id: documents\n operations: [invoke]\n audience: {from: documents/documents-prefix}\n scope_ceiling:\n invoke:\n - resource_kind: " + kind + "\n actions: [read]\ndestinations: []\n" + _, contractErr := modulecontract.Parse([]byte(contract)) + require.Equal(t, contractErr == nil, manifestErr == nil, "kind %q: manifest %v, contract %v", kind, manifestErr, contractErr) + } +} + +func TestManifestRejectsRequestFieldsOnExposedAPI(t *testing.T) { exposed := strings.Replace(consumingManifest, " - id: http\n protocol: http\n", " - id: http\n protocol: http\n scopes:\n - resource_kind: documents.passages\n actions: [read]\n", 1) _, err := manifest.Load([]byte(exposed)) require.ErrorContains(t, err, "api.exposes[0].scopes is not allowed") } + +// LoadConsumption reads api.consumes and nothing else: a manifest Load refuses +// on a section that has nothing to do with consumption is read, and asks for +// nothing; a scoped one yields the same request Load does. +func TestLoadConsumptionReadsOnlyTheConsumption(t *testing.T) { + for _, name := range []string{"agentless.yaml", "undeclared-section.yaml"} { + t.Run(name, func(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "consumption", name)) + require.NoError(t, err) + _, err = manifest.Load(data) + require.Error(t, err, "the fixture is one Load refuses") + + m, err := manifest.LoadConsumption(data) + require.NoError(t, err) + require.NotEmpty(t, m.API.Consumes) + contract, err := m.ConsumptionContract("consumer") + require.NoError(t, err) + require.Nil(t, contract, "it asks for nothing") + }) + } + t.Run("scoped-consumer.yaml", func(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "consumption", "scoped-consumer.yaml")) + require.NoError(t, err) + strict, err := manifest.Load(data) + require.NoError(t, err) + narrow, err := manifest.LoadConsumption(data) + require.NoError(t, err) + fromStrict, err := strict.ConsumptionContract("consumer") + require.NoError(t, err) + fromNarrow, err := narrow.ConsumptionContract("consumer") + require.NoError(t, err) + require.Equal(t, fromStrict, fromNarrow) + resolved, err := fromNarrow.Resolve(values) + require.NoError(t, err) + require.Equal(t, "documents", resolved.Bindings[0].Audience) + require.Equal(t, []string{"documents:read"}, resolved.Bindings[0].Scopes[modulecontract.OperationInvoke]) + }) + t.Run("an unknown field inside a consumption still fails", func(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "consumption", "agentless.yaml")) + require.NoError(t, err) + _, err = manifest.LoadConsumption([]byte(strings.Replace(string(data), " as: assistant\n", " as: assistant\n grants: everything\n", 1))) + require.ErrorContains(t, err, "grants") + }) +} diff --git a/solution/manifest/manifest.go b/solution/manifest/manifest.go index e22820bec..8c672688d 100644 --- a/solution/manifest/manifest.go +++ b/solution/manifest/manifest.go @@ -25,6 +25,7 @@ import ( "sort" "github.com/Masterminds/semver/v3" + modulecontract "github.com/codefly-dev/core/contracts/module" solutionv0 "github.com/codefly-dev/core/generated/go/codefly/services/solution/v0" "github.com/codefly-dev/core/policy" "github.com/codefly-dev/core/resources" @@ -33,8 +34,14 @@ import ( ) const ( - FileName = "solution.codefly.yaml" - SchemaVersionV0 = "codefly.solution-manifest/v0" + FileName = "solution.codefly.yaml" + SchemaVersionV0 = "codefly.solution-manifest/v0" + // SchemaVersionV1 adds what a solution asks for at the modules it + // consumes: an api.consumes entry's audience, revision and scopes. A v0 + // manifest is still read, and carries none of them; a manifest using them + // declares v1, so a reader that predates them refuses the version by name + // rather than reading the manifest as one that asks for nothing. + SchemaVersionV1 = "codefly.solution-manifest/v1" ProtocolVersionV0 = "codefly.solution/v0" ) @@ -88,21 +95,34 @@ type APIDeclaration struct { // As is the facade entry-point name (default: the endpoint's package // short name, e.g. "accounts"). As string `yaml:"as,omitempty" json:"as,omitempty"` - // Scopes are the most authority the solution asks for at the consumed - // module when it calls it on a person's behalf (api.consumes only): each a - // resource kind in that module's permission vocabulary and the actions on - // it. They are what the renderer derives the solution's authority document - // from (see ConsumptionAuthority) — the one declaration of what the - // solution consumes, so no second file restates it. Absent means the - // solution mints no capability for that module: it calls it with the - // viewer's own credential, or not on anyone's behalf at all. + // Audience, Revision and Scopes are what the solution ASKS FOR at the + // consumed module when it calls it on a person's behalf (api.consumes only, + // schema v1). They are the one declaration of that request: the renderer + // derives the solution's authority document from them (ConsumptionContract), + // and a runtime is handed the scopes to hold its own mints to + // (ConsumedAPI.Scopes). An entry without scopes asks for nothing: the + // solution calls that module with the viewer's own credential, or on + // nobody's behalf. + // + // Audience is a slot into the composition's configuration ({from: + // /}), resolved exactly as a module contract's binding + // audience is: the audience a module verifies is the composition's to + // say, and it is not always the name the module is composed under. + Audience *modulecontract.Slot `yaml:"audience,omitempty" json:"audience,omitempty"` + // Revision is the binding's revision, at least 1 and 1 when omitted. A + // credential seals it, so the solution bumps it when what the binding + // means changes. + Revision uint64 `yaml:"revision,omitempty" json:"revision,omitempty"` + // Scopes are the {resource_kind, actions} the solution asks for, in the + // consumed module's permission vocabulary. Present means non-empty: + // `scopes: []` is refused, not read as absent. Scopes []ConsumedScope `yaml:"scopes,omitempty" json:"scopes,omitempty"` } // ConsumedScope is one resource kind of a consumed module's permission // vocabulary and the actions a solution asks for on it. The kind is written // literally: a permission's namespace is fixed by the module that contributes -// it, as its proto package is, and a solution composes that module by name. +// it, as its proto package is. type ConsumedScope struct { ResourceKind string `yaml:"resource_kind" json:"resource_kind"` Actions []string `yaml:"actions" json:"actions"` @@ -172,8 +192,8 @@ func (m *Manifest) Validate() error { if m == nil { return fmt.Errorf("solution manifest is required") } - if m.SchemaVersion != SchemaVersionV0 { - return fmt.Errorf("unsupported solution manifest schema version %q", m.SchemaVersion) + if err := validateSchemaVersion(m.SchemaVersion); err != nil { + return err } if m.ProtocolVersion != ProtocolVersionV0 { return fmt.Errorf("unsupported solution protocol version %q", m.ProtocolVersion) @@ -205,6 +225,12 @@ func (m *Manifest) Validate() error { if err := validateConsumedAPIs("api.consumes", m.API.Consumes); err != nil { return err } + if err := validateConsumptionVersion(m.SchemaVersion, m.API.Consumes); err != nil { + return err + } + if err := validateConsumption(m.API.Consumes); err != nil { + return err + } if err := validateIDs("events.emits", len(m.Events.Emits), func(i int) string { return m.Events.Emits[i].ID }); err != nil { return err } @@ -277,7 +303,9 @@ func validateExposedAPIs(name string, declarations []APIDeclaration) error { {"version", declaration.Version != ""}, {"services", len(declaration.Services) > 0}, {"as", declaration.As != ""}, - {"scopes", len(declaration.Scopes) > 0}, + {"audience", declaration.Audience != nil}, + {"revision", declaration.Revision != 0}, + {"scopes", declaration.Scopes != nil}, } { if field.set { return fmt.Errorf("%s[%d].%s is not allowed", name, i, field.name) @@ -347,53 +375,53 @@ func validateConsumedAPIs(name string, declarations []APIDeclaration) error { return nil } -// scopeKindPattern and scopeActionPattern are the module contract's grammar for -// a resource kind and an action: a scope is concatenated into -// ":", so a kind carrying a colon or a comma would carry scopes -// nobody declared. -var ( - scopeKindPattern = regexp.MustCompile(`^[a-z0-9]+(?:[._-][a-z0-9]+)*$`) - scopeActionPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) -) - -// validateConsumedScopes holds a consumption's scopes to the grammar its -// authority document is held to, and to the one module they are asked of. +// validateConsumedScopes holds what an entry asks for to its own shape. The +// grammar of the request itself — the resource kind, the actions, the audience +// slot and its key's meaning, the revision — is the module contract's, and is +// held by it (see validateConsumption), never restated here. func validateConsumedScopes(name string, i int, declaration APIDeclaration) error { - if len(declaration.Scopes) == 0 { + asks := declaration.Scopes != nil + if !asks { + if declaration.Audience != nil || declaration.Revision != 0 { + return fmt.Errorf("%s[%d] names an audience or a revision but asks for no scopes; they address a request, and an entry without scopes asks for nothing", name, i) + } return nil } + if len(declaration.Scopes) == 0 { + return fmt.Errorf("%s[%d].scopes is present and empty; an entry that asks for nothing omits scopes, and one that asks for something names it", name, i) + } if declaration.Module == "" { return fmt.Errorf("%s[%d].scopes asks for authority at no module: scopes are asked of a bound consumption (module, service, endpoint)", name, i) } - // The audience a capability for a module is addressed to is that module's - // name — its principal — and a runtime addresses the module by `as`. A - // consumption that asks for authority under two names would render a - // binding for one and mint for the other, refused far from here. - if declaration.As != "" && declaration.As != declaration.Module { - return fmt.Errorf("%s[%d] asks for scopes at module %q but names it %q with as; the audience a capability for a module is addressed to is the module's own name, so a consumption that asks for authority sets as to it or leaves it unset", - name, i, declaration.Module, declaration.As) + if declaration.Audience == nil { + return fmt.Errorf("%s[%d] asks for scopes at module %q and names no audience; the audience a module verifies is the composition's to say, so a scoped entry names it as a slot, audience: {from: /}", name, i, declaration.Module) } - kinds := make(map[string]struct{}, len(declaration.Scopes)) for j, scope := range declaration.Scopes { - if !scopeKindPattern.MatchString(scope.ResourceKind) { - return fmt.Errorf("%s[%d].scopes[%d].resource_kind %q is not a resource kind", name, i, j, scope.ResourceKind) - } - if _, duplicate := kinds[scope.ResourceKind]; duplicate { - return fmt.Errorf("%s[%d].scopes[%d].resource_kind %q is declared twice; one kind has one entry", name, i, j, scope.ResourceKind) - } - kinds[scope.ResourceKind] = struct{}{} if len(scope.Actions) == 0 { return fmt.Errorf("%s[%d].scopes[%d] names no action on %q; a scope permits something", name, i, j, scope.ResourceKind) } - actions := make(map[string]struct{}, len(scope.Actions)) - for _, action := range scope.Actions { - if !scopeActionPattern.MatchString(action) { - return fmt.Errorf("%s[%d].scopes[%d].actions entry %q is not an action", name, i, j, action) - } - if _, duplicate := actions[action]; duplicate { - return fmt.Errorf("%s[%d].scopes[%d].actions entry %q is declared twice", name, i, j, action) - } - actions[action] = struct{}{} + } + return nil +} + +// validateSchemaVersion admits the two versions this package reads. +func validateSchemaVersion(version string) error { + if version != SchemaVersionV0 && version != SchemaVersionV1 { + return fmt.Errorf("unsupported solution manifest schema version %q", version) + } + return nil +} + +// validateConsumptionVersion refuses a request in a manifest that declares the +// version before requests existed: a reader of v0 would read it as asking for +// nothing. +func validateConsumptionVersion(version string, declarations []APIDeclaration) error { + if version != SchemaVersionV0 { + return nil + } + for i, declaration := range declarations { + if declaration.Scopes != nil || declaration.Audience != nil || declaration.Revision != 0 { + return fmt.Errorf("api.consumes[%d] asks for authority, which %s cannot express; declare schema_version: %s", i, SchemaVersionV0, SchemaVersionV1) } } return nil @@ -524,6 +552,9 @@ func sortedEventDeclarations(values []EventDeclaration) []EventDeclaration { } func sortedConsumedScopes(values []ConsumedScope) []ConsumedScope { + if values == nil { + return nil + } out := make([]ConsumedScope, 0, len(values)) for _, scope := range values { actions := append([]string(nil), scope.Actions...) diff --git a/solution/manifest/testdata/consumption/agentless.yaml b/solution/manifest/testdata/consumption/agentless.yaml new file mode 100644 index 000000000..211ce7760 --- /dev/null +++ b/solution/manifest/testdata/consumption/agentless.yaml @@ -0,0 +1,18 @@ +# A composed solution that names no executor: Load refuses it (agent is +# required), and it asks for no authority. +schema_version: codefly.solution-manifest/v0 +protocol_version: codefly.solution/v0 +api: + exposes: + - id: http + protocol: http + consumes: + - id: assistant + protocol: http + module: assistant + service: chat + endpoint: http + as: assistant +lifecycle: + package: true + render: true diff --git a/solution/manifest/testdata/consumption/scoped-consumer.yaml b/solution/manifest/testdata/consumption/scoped-consumer.yaml new file mode 100644 index 000000000..66d8eec3b --- /dev/null +++ b/solution/manifest/testdata/consumption/scoped-consumer.yaml @@ -0,0 +1,38 @@ +schema_version: codefly.solution-manifest/v1 +protocol_version: codefly.solution/v0 +agent: + publisher: codefly.dev + kind: codefly:solution + name: solution-generic + version: 0.0.1 +api: + exposes: + - id: http + protocol: http + consumes: + - id: identity + protocol: connect + module: platform + service: identity + endpoint: connect + services: [DirectoryService] + as: identity + - id: assistant + protocol: http + module: assistant + service: chat + endpoint: http + as: assistant + - id: documents + protocol: rest + module: documents + service: documents + endpoint: rest + as: documents + audience: {from: documents/documents-prefix} + scopes: + - resource_kind: documents + actions: [read] +lifecycle: + package: true + render: true diff --git a/solution/manifest/testdata/consumption/undeclared-section.yaml b/solution/manifest/testdata/consumption/undeclared-section.yaml new file mode 100644 index 000000000..eb6c4416e --- /dev/null +++ b/solution/manifest/testdata/consumption/undeclared-section.yaml @@ -0,0 +1,22 @@ +# A composed solution carrying a top-level section this core does not model: +# Load refuses it (unknown field), and it asks for no authority. +schema_version: codefly.solution-manifest/v0 +protocol_version: codefly.solution/v0 +sources: + - provider: git + repo: example/handbook + paths: [pages/] +api: + exposes: + - id: http + protocol: http + consumes: + - id: documents + protocol: rest + module: documents + service: documents + endpoint: rest + as: documents +lifecycle: + package: true + render: true