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 afd16d58c..6b5677c74 100644 --- a/docs/solution-host-binding.md +++ b/docs/solution-host-binding.md @@ -1219,6 +1219,32 @@ 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 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 slot is as much a rule of this contract as any refusal of a document: two 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 new file mode 100644 index 000000000..29b58f152 --- /dev/null +++ b/solution/manifest/consumption_authority.go @@ -0,0 +1,169 @@ +package manifest + +import ( + "bytes" + "errors" + "fmt" + "io" + "sort" + + "gopkg.in/yaml.v3" + + modulecontract "github.com/codefly-dev/core/contracts/module" +) + +// 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 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. +// +// 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 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 + } + scopes := make([]modulecontract.CeilingScope, 0, len(declaration.Scopes)) + for _, scope := range sortedConsumedScopes(declaration.Scopes) { + scopes = append(scopes, modulecontract.CeilingScope{ResourceKind: scope.ResourceKind, Actions: scope.Actions}) + } + 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.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 new file mode 100644 index 000000000..51cbd2f7f --- /dev/null +++ b/solution/manifest/consumption_authority_test.go @@ -0,0 +1,310 @@ +package manifest_test + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + modulecontract "github.com/codefly-dev/core/contracts/module" + "github.com/codefly-dev/core/solution/manifest" +) + +// 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/v1 +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: identity + protocol: connect + module: platform + service: identity + endpoint: connect + 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] + - resource_kind: documents.collections + actions: [read, list] + - id: chat + protocol: http + module: assistant + service: chat + endpoint: http +lifecycle: + render: true +` + +// 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 +} + +// 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.Equal(t, "consumer", 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 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 TestConsumptionContractIsNilWithoutScopes(t *testing.T) { + m, err := manifest.Load([]byte(validManifest)) + require.NoError(t, err) + contract, err := m.ConsumptionContract("fixture") + require.NoError(t, err) + require.Nil(t, contract) +} + +// The scopes are canonical in the digest. +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) +} + +// 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) + }, "module contract refuses"}, + {"kind declared twice", func(s string) string { + return strings.Replace(s, scoped, scoped+scoped, 1) + }, "module contract refuses"}, + {"no action", func(s string) string { + return strings.Replace(s, " actions: [read]\n", " actions: []\n", 1) + }, "names no action"}, + {"a wildcard action", func(s string) string { + return strings.Replace(s, " actions: [read]\n", " actions: [\"*\"]\n", 1) + }, "module contract refuses"}, + {"action declared twice", func(s string) string { + return strings.Replace(s, " actions: [read]\n", " actions: [read, read]\n", 1) + }, "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) + }, "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") + }) + } +} + +// 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 382dc9185..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,6 +95,37 @@ 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"` + // 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. +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. @@ -154,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) @@ -187,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 } @@ -259,6 +303,9 @@ func validateExposedAPIs(name string, declarations []APIDeclaration) error { {"version", declaration.Version != ""}, {"services", len(declaration.Services) > 0}, {"as", declaration.As != ""}, + {"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) @@ -321,6 +368,61 @@ func validateConsumedAPIs(name string, declarations []APIDeclaration) error { } seenAs[declaration.As] = struct{}{} } + if err := validateConsumedScopes(name, i, declaration); err != nil { + return err + } + } + return nil +} + +// 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 { + 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) + } + 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) + } + for j, scope := range declaration.Scopes { + 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) + } + } + 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 } @@ -435,6 +537,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 +550,17 @@ 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 { + if values == nil { + return nil + } + 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 +} 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