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
57 changes: 42 additions & 15 deletions contracts/module/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,13 @@ type Contract struct {
// on a person's behalf. One of them is InstallerRole. A bare module, whose
// access is the organisation's roles, declares none.
Roles []Role `yaml:"roles,omitempty"`
// InstallationRole selects the agent's standing role: empty is undeclared,
// "none" declares no standing grant, otherwise an exact declared role name.
// It never selects a role for a person or team.
InstallationRole string `yaml:"installation_role,omitempty"`
// EdgeDependencies name modules reached with the person's Work Context.
// They declare presence only, confer no authority, and are not binding IDs.
EdgeDependencies []EdgeDependency `yaml:"edge_dependencies,omitempty"`
// Bindings are the operation bindings the module redeems, outward.
Bindings []Binding `yaml:"bindings"`
// Destinations are the endpoints the module exposes for something else to
Expand All @@ -304,6 +311,15 @@ type Contract struct {
RequireMethodPolicies bool `yaml:"require_method_policies,omitempty"`
}

// EdgeDependency names an exact signed module principal. The host resolves it
// through its existing catalogue to a target and binding; it is not a target ID.
// An empty list and an omitted list both declare no edge dependencies.
type EdgeDependency struct {
// Module is the signed principal, never a route alias, binding ID or release.
Module string `yaml:"module" json:"module"`
Presence string `yaml:"presence" json:"presence"` // "required" or "optional", with no default.
}

// ForeignMethod is one served method of a protocol the module does not own,
// by its full gRPC name, and what a call to it must hold, in the module's own
// vocabulary.
Expand Down Expand Up @@ -773,6 +789,11 @@ func parse(data []byte, without string) (*Contract, error) {
return nil, mapping.Err
}
}
if without != ruleInstallationStrings {
if err := checkInstallationStrings(tree.Content[0]); err != nil {
return nil, err
}
}
decoder := yaml.NewDecoder(bytes.NewReader(data))
decoder.KnownFields(without != ruleKnownFields)
var contract Contract
Expand All @@ -796,14 +817,18 @@ func (contract *Contract) Validate() error { return contract.validate("") }
// Resolved is a contract with every slot resolved to the value the composition
// supplies, ready to be derived into a manifest's authority.
type Resolved struct {
OwnerAware bool
Principal string
Namespaces []string
Queues []string
ScopeCeilings []ScopeCeiling
Bindings []ResolvedBinding
Destinations []Destination
ForeignMethods []ForeignMethod
// Installation declarations are carried verbatim, without catalogue lookup
// or deriving dependencies from resolved binding audiences.
InstallationRole string
EdgeDependencies []EdgeDependency
OwnerAware bool
Principal string
Namespaces []string
Queues []string
ScopeCeilings []ScopeCeiling
Bindings []ResolvedBinding
Destinations []Destination
ForeignMethods []ForeignMethod
// Roles are the contract's roles, in declaration order, each grant
// addressed to the audience it is held at.
Roles []ResolvedRole
Expand Down Expand Up @@ -885,13 +910,15 @@ func (contract *Contract) resolve(values Values, without string, options ...Reso
return nil, err
}
resolved := &Resolved{
OwnerAware: contract.OwnerAware,
Principal: contract.Principal,
Namespaces: slices.Clone(contract.Namespaces),
Queues: slices.Clone(contract.Queues),
ScopeCeilings: slices.Clone(contract.ScopeCeilings),
Destinations: slices.Clone(contract.Destinations),
ForeignMethods: slices.Clone(contract.ForeignMethods),
InstallationRole: contract.InstallationRole,
EdgeDependencies: slices.Clone(contract.EdgeDependencies),
OwnerAware: contract.OwnerAware,
Principal: contract.Principal,
Namespaces: slices.Clone(contract.Namespaces),
Queues: slices.Clone(contract.Queues),
ScopeCeilings: slices.Clone(contract.ScopeCeilings),
Destinations: slices.Clone(contract.Destinations),
ForeignMethods: slices.Clone(contract.ForeignMethods),
}
var unresolved, secret, ambiguous, calls []string
own := contract.ownCeilings()
Expand Down
12 changes: 12 additions & 0 deletions contracts/module/fixtures.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ func Fixtures() ([]Fixture, error) {
outcome Outcome
sentinel error
}{
{name: "installation-none", file: "installation-none.yaml", outcome: OutcomeAccepted},
{name: "installation-named", file: "installation-named.yaml", outcome: OutcomeAccepted},
{name: "installation-solution-none", file: "installation-solution-none.yaml", outcome: OutcomeAccepted},
{name: "edge-presence", file: "edge-presence.yaml", outcome: OutcomeAccepted},
{name: "installation-unknown", file: "installation-unknown.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: "is not a declared role", rule: ruleInstallationRole},
{name: "installation-whitespace", file: "installation-whitespace.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: "has surrounding whitespace", rule: ruleInstallationRoleWhitespace},
{name: "installation-reserved", file: "installation-reserved.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: "role name none is reserved", rule: ruleRoleReserved},
{name: "installation-nonstring", file: "installation-nonstring.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: "must be a string", rule: ruleInstallationStrings},
{name: "edge-empty-module", file: "edge-empty-module.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: "module must be non-empty", rule: ruleEdgeModuleName},
{name: "edge-whitespace", file: "edge-whitespace.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: "has surrounding whitespace", rule: ruleEdgeModuleWhitespace},
{name: "edge-duplicate", file: "edge-duplicate.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: "is declared twice", rule: ruleEdgeModuleUnique},
{name: "edge-presence-unknown", file: "edge-presence-unknown.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: "must be required or optional", rule: ruleEdgePresence},
{name: "owner-aware", file: "owner-aware.yaml", outcome: OutcomeAccepted},
{name: "owner-aware-string", file: "owner-aware-string.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: "owner_aware must be a boolean", rule: ruleOwnerAwareBoolean},
{name: "valid", file: "valid.module.contract.codefly.yaml", outcome: OutcomeAccepted},
Expand Down
49 changes: 49 additions & 0 deletions contracts/module/installation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package module

import (
"fmt"

"github.com/codefly-dev/core/internal/wire"
"gopkg.in/yaml.v3"
)

// Check scalar types before yaml's typed decoder can turn a number or boolean
// into a string. Mapping traversal stays on the existing strict wire path.
func checkInstallationStrings(root *yaml.Node) error {
unalias := func(node *yaml.Node) *yaml.Node {
for node.Kind == yaml.AliasNode {
node = node.Alias
}
return node
}
check := func(field string, node *yaml.Node) error {
node = unalias(node)
if node.Kind != yaml.ScalarNode || node.ShortTag() != "!!str" {
return fmt.Errorf("%w: installation declaration %s must be a string", ErrInvalid, field)
}
return nil
}
mapping := wire.ReadMapping(root, func(key string, value *yaml.Node) (bool, error) {
switch key {
case "installation_role":
return true, check(key, value)
case "edge_dependencies":
value = unalias(value)
if value.Kind == yaml.SequenceNode {
for _, entry := range value.Content {
fields := wire.ReadMapping(unalias(entry), func(field string, node *yaml.Node) (bool, error) {
if field == "module" || field == "presence" {
return true, check("edge_dependencies."+field, node)
}
return true, nil
})
if fields.Err != nil {
return true, fields.Err
}
}
}
}
return true, nil
})
return mapping.Err
}
161 changes: 161 additions & 0 deletions contracts/module/installation_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package module

import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"os"
"reflect"
"strings"
"testing"
)

func TestInstallationDeclarationsSurviveEncodeAndResolve(t *testing.T) {
data, err := os.ReadFile("testdata/roles.yaml")
if err != nil {
t.Fatal(err)
}
for _, selector := range []string{"", "none", "editor"} {
t.Run(selector, func(t *testing.T) {
contract, err := Parse(append(append([]byte(nil), data...), []byte("installation_role: \""+selector+"\"\nedge_dependencies: [{module: catalogue.other, presence: required}]\n")...))
if err != nil {
t.Fatal(err)
}
encoded, err := contract.Encode()
if err != nil {
t.Fatal(err)
}
again, err := Parse(encoded)
if err != nil {
t.Fatal(err)
}
resolved, err := again.Resolve(values())
if err != nil {
t.Fatal(err)
}
if again.InstallationRole != selector || resolved.InstallationRole != selector || !reflect.DeepEqual(resolved.EdgeDependencies, contract.EdgeDependencies) {
t.Fatalf("declarations changed: parsed %+v, resolved %+v", again, resolved)
}
if resolved.Bindings[0].ID != "documents" || resolved.EdgeDependencies[0].Module != "catalogue.other" || len(resolved.EdgeDependencies) != 1 {
t.Fatal("binding identity changed or became a dependency")
}
resolved.EdgeDependencies[0].Module = "changed"
if again.EdgeDependencies[0].Module != "catalogue.other" {
t.Fatal("Resolve aliased declaration storage")
}
})
}
contract, err := Parse(data)
if err != nil {
t.Fatal(err)
}
resolved, err := contract.Resolve(values())
if err != nil {
t.Fatal(err)
}
if len(resolved.EdgeDependencies) != 0 {
t.Fatal("bindings inferred undeclared dependencies")
}
contract.InstallationRole = "editor"
contract.Roles[1].Scopes = nil
if err := contract.Validate(); !errors.Is(err, ErrInvalid) || !strings.Contains(err.Error(), "grants nothing") {
t.Fatalf("selected empty role escaped the existing rule: %v", err)
}
contract.InstallationRole = "none"
if err := contract.Validate(); !errors.Is(err, ErrInvalid) || !strings.Contains(err.Error(), "grants nothing") {
t.Fatalf("none waived an existing role rule: %v", err)
}
}

func TestInstallationDeclarationShapesAreStrict(t *testing.T) {
for _, field := range []string{
"installation_role: true", "installation_role: 42", "installation_role: null",
"installation_role: []", "installation_role: {}", "installation_role: !custom none",
"edge_dependencies: {}", "edge_dependencies: [42]", "edge_dependencies: null",
"edge_dependencies: [{module: false, presence: required}]",
"edge_dependencies: [{module: !custom other, presence: required}]",
"edge_dependencies: [{module: other, presence: true}]",
"edge_dependencies: [{module: other, presence: null}]",
"edge_dependencies: [{module: other}]",
"edge_dependencies: [{presence: required}]",
"edge_dependencies: [{module: other, presence: required, operations: [read]}]",
"edge_dependencies: [{module: other, module: another, presence: required}]",
"edge_dependencies: [{module: &identity 42, presence: required}, {module: *identity, presence: optional}]",
"installation_role: none\ninstallation_role: ghost",
} {
t.Run(field, func(t *testing.T) {
if _, err := Parse(append(fixture(t), []byte(field+"\n")...)); !errors.Is(err, ErrInvalid) {
t.Fatalf("invalid declaration accepted: %v", err)
}
})
}
}

func TestProgrammaticInstallationDeclarationsUseTheSameRules(t *testing.T) {
for _, change := range []func(*Contract){
func(c *Contract) { c.InstallationRole = "ghost" },
func(c *Contract) { c.InstallationRole = " none " },
func(c *Contract) { c.EdgeDependencies = []EdgeDependency{{Module: "", Presence: "required"}} },
func(c *Contract) { c.EdgeDependencies = []EdgeDependency{{Module: " other ", Presence: "required"}} },
func(c *Contract) {
c.EdgeDependencies = []EdgeDependency{{Module: "other", Presence: "required"}, {Module: "other", Presence: "optional"}}
},
func(c *Contract) { c.EdgeDependencies = []EdgeDependency{{Module: "other", Presence: ""}} },
} {
contract, err := Parse(fixture(t))
if err != nil {
t.Fatal(err)
}
change(contract)
if !errors.Is(contract.Validate(), ErrInvalid) {
t.Fatal("Validate accepted invalid declaration")
}
if _, err := contract.Encode(); !errors.Is(err, ErrInvalid) {
t.Fatalf("Encode: %v", err)
}
if _, err := contract.Resolve(values()); !errors.Is(err, ErrInvalid) {
t.Fatalf("Resolve: %v", err)
}
}
}

func TestInstallationFieldsDoNotChangeLegacyEncodedBytes(t *testing.T) {
// Captured from every accepted fixture at ef374a91 before adding fields.
data, err := os.ReadFile("testdata/legacy-installation-encode.json")
if err != nil {
t.Fatal(err)
}
var expected map[string]string
if err := json.Unmarshal(data, &expected); err != nil {
t.Fatal(err)
}
all, err := Fixtures()
if err != nil {
t.Fatal(err)
}
for _, fixture := range all {
want, legacy := expected[fixture.Name]
if !legacy {
continue
}
contract, err := Parse(fixture.Document)
if err != nil {
t.Fatal(err)
}
for _, empty := range [][]EdgeDependency{nil, {}} {
contract.EdgeDependencies = empty
encoded, err := contract.Encode()
if err != nil {
t.Fatal(err)
}
if got := fmt.Sprintf("%x", sha256.Sum256(encoded)); got != want {
t.Fatalf("legacy fixture %s bytes changed: %s != %s", fixture.Name, got, want)
}
}
delete(expected, fixture.Name)
}
if len(expected) != 0 {
t.Fatalf("legacy fixtures missing: %v", expected)
}
}
Loading
Loading