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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions contracts/module/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ package module

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
Expand Down Expand Up @@ -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 }
Expand Down
26 changes: 26 additions & 0 deletions docs/solution-host-binding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <group>/<key>}`) 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
Expand Down
7 changes: 7 additions & 0 deletions solution/manifest/consumes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
169 changes: 169 additions & 0 deletions solution/manifest/consumption_authority.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading