diff --git a/contracts/module/contract.go b/contracts/module/contract.go index 5573c5fe..02dd2247 100644 --- a/contracts/module/contract.go +++ b/contracts/module/contract.go @@ -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 @@ -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. @@ -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 @@ -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 @@ -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() diff --git a/contracts/module/fixtures.go b/contracts/module/fixtures.go index 2f4cc6ae..d941b811 100644 --- a/contracts/module/fixtures.go +++ b/contracts/module/fixtures.go @@ -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}, diff --git a/contracts/module/installation.go b/contracts/module/installation.go new file mode 100644 index 00000000..46a4230e --- /dev/null +++ b/contracts/module/installation.go @@ -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 +} diff --git a/contracts/module/installation_test.go b/contracts/module/installation_test.go new file mode 100644 index 00000000..faf394b6 --- /dev/null +++ b/contracts/module/installation_test.go @@ -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) + } +} diff --git a/contracts/module/rules.go b/contracts/module/rules.go index afc60162..c872b7a2 100644 --- a/contracts/module/rules.go +++ b/contracts/module/rules.go @@ -40,6 +40,15 @@ const ( ruleKeyIsAName = "key-is-a-name" ruleWholeNumbers = "numbers-are-whole" + ruleInstallationStrings = "installation-declaration-strings" + ruleInstallationRole = "installation-role" + ruleInstallationRoleWhitespace = "installation-role-whitespace" + ruleRoleReserved = "role-reserved" + ruleEdgeModuleName = "edge-module-name" + ruleEdgeModuleWhitespace = "edge-module-whitespace" + ruleEdgeModuleUnique = "edge-module-unique" + ruleEdgePresence = "edge-presence" + rulePrincipal = "principal-name" ruleListsDeclared = "lists-declared" ruleListEntryName = "list-entry-name" @@ -129,6 +138,14 @@ func rules() []rule { {name: ruleNoNulls, check: func(*Contract) error { return nil }}, {name: ruleKeyIsAName, check: func(*Contract) error { return nil }}, {name: ruleWholeNumbers, check: func(*Contract) error { return nil }}, + {name: ruleInstallationStrings, check: func(*Contract) error { return nil }}, + {name: ruleInstallationRoleWhitespace, check: checkInstallationRoleWhitespace}, + {name: ruleInstallationRole, check: checkInstallationRole}, + {name: ruleRoleReserved, check: checkRoleReserved}, + {name: ruleEdgeModuleName, check: checkEdgeModuleNames}, + {name: ruleEdgeModuleWhitespace, check: checkEdgeModuleWhitespace}, + {name: ruleEdgeModuleUnique, check: checkEdgeModulesUnique}, + {name: ruleEdgePresence, check: checkEdgePresence}, {name: rulePrincipal, check: checkPrincipal}, {name: ruleListsDeclared, check: checkListsDeclared}, {name: ruleListEntryName, check: checkListEntryNames}, @@ -1201,3 +1218,71 @@ func checkRolesWithinRequest(contract *Contract) error { } return nil } + +func checkInstallationRoleWhitespace(contract *Contract) error { + if strings.TrimSpace(contract.InstallationRole) != contract.InstallationRole { + return fmt.Errorf("%w: installation_role %q has surrounding whitespace; declare the exact role name or none", ErrInvalid, contract.InstallationRole) + } + return nil +} + +func checkInstallationRole(contract *Contract) error { + if contract.InstallationRole == "" || contract.InstallationRole == "none" { + return nil + } + names := make([]string, 0, len(contract.Roles)) + for _, role := range contract.Roles { + if role.Name == contract.InstallationRole { + return nil + } + names = append(names, role.Name) + } + return fmt.Errorf("%w: installation_role %q is not a declared role; declared roles: %v (or declare none)", ErrInvalid, contract.InstallationRole, names) +} + +func checkRoleReserved(contract *Contract) error { + for _, role := range contract.Roles { + if role.Name == "none" { + return fmt.Errorf("%w: role name none is reserved for the installation_role declaration of no standing grant", ErrInvalid) + } + } + return nil +} + +func checkEdgeModuleNames(contract *Contract) error { + for _, edge := range contract.EdgeDependencies { + if edge.Module == "" { + return fmt.Errorf("%w: edge_dependencies module must be non-empty", ErrInvalid) + } + } + return nil +} + +func checkEdgeModuleWhitespace(contract *Contract) error { + for _, edge := range contract.EdgeDependencies { + if strings.TrimSpace(edge.Module) != edge.Module { + return fmt.Errorf("%w: edge_dependencies module %q has surrounding whitespace", ErrInvalid, edge.Module) + } + } + return nil +} + +func checkEdgeModulesUnique(contract *Contract) error { + names := make([]string, 0, len(contract.EdgeDependencies)) + for _, edge := range contract.EdgeDependencies { + names = append(names, edge.Module) + } + if duplicate, found := firstDuplicate(names); found { + return fmt.Errorf("%w: edge_dependencies module %q is declared twice", ErrInvalid, duplicate) + } + return nil +} + +func checkEdgePresence(contract *Contract) error { + for _, edge := range contract.EdgeDependencies { + if edge.Presence != "required" && edge.Presence != "optional" { + return fmt.Errorf("%w: edge_dependencies module %q presence %q must be required or optional", ErrInvalid, edge.Module, edge.Presence) + } + } + return nil +} diff --git a/contracts/module/rules_test.go b/contracts/module/rules_test.go index 5d0ea96a..ff67c3d1 100644 --- a/contracts/module/rules_test.go +++ b/contracts/module/rules_test.go @@ -77,6 +77,15 @@ func TestEveryFixtureReachesItsOutcome(t *testing.T) { names := make([]string, 0, len(all)) for _, fixture := range all { names = append(names, fixture.Name) + if fixture.Outcome == OutcomeAccepted { + contract, err := Parse(fixture.Document) + if err != nil { + t.Fatal(err) + } + if contract.Principal != "assistant" { + t.Errorf("accepted fixture %s declares principal %q; consumers render these unmodified documents as module assistant", fixture.Name, contract.Principal) + } + } } sort.Strings(names) want := []string{ @@ -97,11 +106,14 @@ func TestEveryFixtureReachesItsOutcome(t *testing.T) { "destination-endpoint-not-a-name", "destination-id-not-a-name", "destination-kind-cluster-scoped", "destination-kind-public", "destination-service-not-a-name", "duplicate-binding", + "edge-duplicate", "edge-empty-module", "edge-presence", "edge-presence-unknown", "edge-whitespace", "explicit-scope-action-declared-twice", "explicit-scope-action-not-a-name", "explicit-scope-action-wildcard", "foreign-method-beyond-ceiling", "foreign-method-declared-twice", "foreign-method-not-a-method", "foreign-method-other-kind", "foreign-method-requires-nothing", - "foreign-method-scope-without-action", "foreign-methods", "kind-named-twice", + "foreign-method-scope-without-action", "foreign-methods", + "installation-named", "installation-none", "installation-nonstring", "installation-reserved", + "installation-solution-none", "installation-unknown", "installation-whitespace", "kind-named-twice", "kind-with-no-action", "literal-audience", "lookup-method-not-a-name", "lookup-method-without-lookup", "merge-key", "mixed-ceiling", "namespace-declared-twice", "namespace-not-a-name", "namespaces-omitted", diff --git a/contracts/module/testdata/edge-duplicate.yaml b/contracts/module/testdata/edge-duplicate.yaml new file mode 100644 index 00000000..15e1353e --- /dev/null +++ b/contracts/module/testdata/edge-duplicate.yaml @@ -0,0 +1,10 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [] +bindings: [] +destinations: [] +edge_dependencies: + - {module: other, presence: required} + - {module: other, presence: optional} diff --git a/contracts/module/testdata/edge-empty-module.yaml b/contracts/module/testdata/edge-empty-module.yaml new file mode 100644 index 00000000..57647b8b --- /dev/null +++ b/contracts/module/testdata/edge-empty-module.yaml @@ -0,0 +1,8 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [] +bindings: [] +destinations: [] +edge_dependencies: [{module: "", presence: required}] diff --git a/contracts/module/testdata/edge-presence-unknown.yaml b/contracts/module/testdata/edge-presence-unknown.yaml new file mode 100644 index 00000000..42fafeb8 --- /dev/null +++ b/contracts/module/testdata/edge-presence-unknown.yaml @@ -0,0 +1,8 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [] +bindings: [] +destinations: [] +edge_dependencies: [{module: other, presence: preferred}] diff --git a/contracts/module/testdata/edge-presence.yaml b/contracts/module/testdata/edge-presence.yaml new file mode 100644 index 00000000..fbcda3e6 --- /dev/null +++ b/contracts/module/testdata/edge-presence.yaml @@ -0,0 +1,10 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [] +bindings: [] +destinations: [] +edge_dependencies: + - {module: unknown.catalogue.identity, presence: required} + - {module: another.module, presence: optional} diff --git a/contracts/module/testdata/edge-whitespace.yaml b/contracts/module/testdata/edge-whitespace.yaml new file mode 100644 index 00000000..18a5605f --- /dev/null +++ b/contracts/module/testdata/edge-whitespace.yaml @@ -0,0 +1,8 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [] +bindings: [] +destinations: [] +edge_dependencies: [{module: " other ", presence: required}] diff --git a/contracts/module/testdata/installation-named.yaml b/contracts/module/testdata/installation-named.yaml new file mode 100644 index 00000000..d5960028 --- /dev/null +++ b/contracts/module/testdata/installation-named.yaml @@ -0,0 +1,11 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [{resource_kind: example.items, actions: [read]}] +bindings: [] +destinations: [] +roles: + - name: installer + scopes: [{resource_kind: example.items, actions: [read]}] +installation_role: installer diff --git a/contracts/module/testdata/installation-none.yaml b/contracts/module/testdata/installation-none.yaml new file mode 100644 index 00000000..ffc45d76 --- /dev/null +++ b/contracts/module/testdata/installation-none.yaml @@ -0,0 +1,8 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [] +bindings: [] +destinations: [] +installation_role: none diff --git a/contracts/module/testdata/installation-nonstring.yaml b/contracts/module/testdata/installation-nonstring.yaml new file mode 100644 index 00000000..d9a5309c --- /dev/null +++ b/contracts/module/testdata/installation-nonstring.yaml @@ -0,0 +1,8 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [] +bindings: [] +destinations: [] +edge_dependencies: [{module: 42, presence: required}] diff --git a/contracts/module/testdata/installation-reserved.yaml b/contracts/module/testdata/installation-reserved.yaml new file mode 100644 index 00000000..afa04080 --- /dev/null +++ b/contracts/module/testdata/installation-reserved.yaml @@ -0,0 +1,10 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [{resource_kind: example.items, actions: [read]}] +bindings: [] +destinations: [] +roles: + - name: none + scopes: [{resource_kind: example.items, actions: [read]}] diff --git a/contracts/module/testdata/installation-solution-none.yaml b/contracts/module/testdata/installation-solution-none.yaml new file mode 100644 index 00000000..44d0f995 --- /dev/null +++ b/contracts/module/testdata/installation-solution-none.yaml @@ -0,0 +1,11 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [{resource_kind: example.items, actions: [read]}] +bindings: [] +destinations: [] +roles: + - name: installer + scopes: [{resource_kind: example.items, actions: [read]}] +installation_role: none diff --git a/contracts/module/testdata/installation-unknown.yaml b/contracts/module/testdata/installation-unknown.yaml new file mode 100644 index 00000000..d4e67f33 --- /dev/null +++ b/contracts/module/testdata/installation-unknown.yaml @@ -0,0 +1,11 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [{resource_kind: example.items, actions: [read]}] +bindings: [] +destinations: [] +roles: + - name: installer + scopes: [{resource_kind: example.items, actions: [read]}] +installation_role: ghost diff --git a/contracts/module/testdata/installation-whitespace.yaml b/contracts/module/testdata/installation-whitespace.yaml new file mode 100644 index 00000000..ac1b7f61 --- /dev/null +++ b/contracts/module/testdata/installation-whitespace.yaml @@ -0,0 +1,11 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [{resource_kind: example.items, actions: [read]}] +bindings: [] +destinations: [] +roles: + - name: installer + scopes: [{resource_kind: example.items, actions: [read]}] +installation_role: " installer " diff --git a/contracts/module/testdata/legacy-installation-encode.json b/contracts/module/testdata/legacy-installation-encode.json new file mode 100644 index 00000000..b8896272 --- /dev/null +++ b/contracts/module/testdata/legacy-installation-encode.json @@ -0,0 +1 @@ +{"binding-audiences":"b4a25225d6d8740ced6ed0fea38c075ddde58626ea04fc20c5f121e96f91a2bc","binding-names-calls":"d7646b6005ea27bf14237f0f869e11f29679c083913425ec2173a889ee7830b5","binding-optional":"89605d8f2331bf0d19ceeb2c988e4bf81f8c30aa64f615f2d58614c29c4d0ee8","binding-self-audience":"948ad4a714edeb1c1f98bfc2acef4d4c429a3a8bd9e59dec216cfeb94bff33b2","foreign-methods":"e1fe6d42b480318850148b159badb448217f208ffde41026419fddcede393ea9","owner-aware":"b24c15c4e9954a5315bd37b65aff33a4889b0a8beea1d4b3afa46c37b2209b63","roles":"9e763f79895b13c4d6c5a26af3f65e6d3578dd51193e87b45828b1ef849b7c6a","roles-grant-bindings":"8b0ebbbec41cb32fae1e33b9e3eeddef2e674eb4fe4ddd00de8749ed84584a96","roles-installation-owned":"5ca0ca6b86ac6503b90d735a7495c023ef6befb88119b52c7b8ee04dfedfaba2","upper-case-slot-key":"433a8e662b60cffd905da9a7b9f2aa26896dd271ff98d8d96ceea124135239b4","valid":"bd09a9d5b1aeb19d13fae234524e3289cd0a5bfb70ce384391e1dd324f8a6de8"} diff --git a/docs/module-contract.md b/docs/module-contract.md index 3f780ba4..8291aaee 100644 --- a/docs/module-contract.md +++ b/docs/module-contract.md @@ -213,6 +213,40 @@ the authority issues a person capability to it; the CLI carries the declaration in the signed composition authority document's `owner_aware` field (field 7). This declares an enforcement capability, not an owner identity or a grant. +**Installation declarations express machine intent and edge presence separately.** +`installation_role` is optional: absent or `""` leaves the legacy installer +selection unchanged; literal `none` declares no standing machine grant; any +other value must exactly name a declared role. This never assigns a role to a +person or team. `none` is reserved and cannot be a role name. Existing rules +still refuse every role that grants nothing and still require `installer` when +roles exist; selecting a role does not relax them. A bare module can declare +`none` without declaring roles. + +```yaml +installation_role: none +edge_dependencies: + - {module: robin, presence: required} + - {module: annotations, presence: optional} +``` + +`edge_dependencies[].module` names the exact signed module principal (the +Authority domain's `Module.Principal`), each once, with mandatory +`presence: required|optional`. It grants no authority and declares no operations +or scopes. The host resolves the principal through its existing catalogue to +the live target and binding; a principal is not itself a target UUID. Route +aliases, binding IDs and release strings are never fallbacks. Core does not +require a principal to exist in a local composition. Entries are explicit; +none are inferred from bindings. Absent and empty lists mean the +same thing. Selector and module values with surrounding whitespace are refused, +never trimmed; declaration fields must be strings. + +`Parse`, `Validate`, `Encode` and `Resolve` use the existing contract rules and +carry accepted values unchanged. The new fields use `omitempty`, so contracts +that declare neither retain their previous encoded bytes. CLI owns signed +projection; Authority owns honoring `none` without a synthetic role or standing +grant; SaaS owns dependency review and activation. Parser acceptance alone does +not establish that those consumer paths support installation. + **A unit a person uses declares its roles beside its own vocabulary.** A solution is reached by people, and who may do what on an installation of it is a role the installation grants, never the organisation's. `roles:` lists each @@ -440,10 +474,13 @@ name. An absent field is absent; an empty list is written `[]`. **Every refusal is one named rule.** The package holds its rules in a table (`rules.go`), applied in a fixed order, so a document is refused for one reason, named — the same reason whichever reader refused it. **The kits.** -`modulecontract.Fixtures()` (132 documents) ships +`modulecontract.Fixtures()` (144 documents) ships every accepted and refused document with the sentinel and the message a refusal must carry and the rule it protects, and `Run(t, read)` drives a -reader's own entrypoint through them. `AllResolutionFixtures()` (120 +reader's own entrypoint through them. Accepted documents declare +`principal: assistant`; a consuming renderer uses module `assistant` and passes +the fixture bytes unchanged, preserving its principal/identity guard. +`AllResolutionFixtures()` (120 configurations) does the same for resolution, driven by `RunResolution(t, provider)`. **Both counts are pinned by a test**, and the resolution kit is pinned by NAME and by cases-per-role as well: a documented count written by diff --git a/docs/module-manifest.md b/docs/module-manifest.md index d92eef4a..0a450140 100644 --- a/docs/module-manifest.md +++ b/docs/module-manifest.md @@ -197,3 +197,23 @@ the grant a module's request is intersected with — is the host's record, not part of this document; see the host's documentation. What a module asks for is declared in its module contract, which the CLI resolves into `authority`; see [module-contract.md](module-contract.md). + +## Installation declarations in the signed carrier + +`authority.installation_role` and `authority.edge_dependencies` carry the +module contract's validated installation declarations verbatim. The selector +is empty when undeclared, literal `none` for no standing machine grant, or a +role name. Dependencies reuse `modulecontract.EdgeDependency` with `module` +and `presence` fields; they grant no authority. Core's module-contract parser +validates these declarations; this carrier adds no selection, defaulting or +second declaration validator. + +Canonical encoding retains dependency declaration order and includes both +fields in the signed payload and digest. It does not sort, trim or infer them. +Absent fields add no keys; nil and empty dependency lists encode identically, +so older manifest bytes and signatures remain unchanged. CLI owns populating +these fields from the resolved contract and pairing the Authority document; +Authority and the host own installation semantics and catalogue resolution. +Each dependency's `module` is the exact signed module principal. The existing +host catalogue resolves it to the target and binding; the principal is never +treated as a target UUID, nor replaced by a route alias, binding ID or release. diff --git a/solutionhost/installation_test.go b/solutionhost/installation_test.go new file mode 100644 index 00000000..6c6be511 --- /dev/null +++ b/solutionhost/installation_test.go @@ -0,0 +1,77 @@ +package solutionhost_test + +import ( + "context" + "os" + "testing" + + modulecontract "github.com/codefly-dev/core/contracts/module" + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" +) + +func TestInstallationDeclarationsSurviveSignedDelivery(t *testing.T) { + for _, role := range []string{"", "none", "editor"} { + t.Run(role, func(t *testing.T) { + manifest := valid(t) + manifest.Authority.InstallationRole = role + // Deliberately not sorted; order is part of the authored declaration. + manifest.Authority.EdgeDependencies = []modulecontract.EdgeDependency{ + {Module: "z.catalogue", Presence: "required"}, + {Module: "a.catalogue", Presence: "optional"}, + } + written, err := solutionhost.Marshal(manifest) + require.NoError(t, err) + read, err := solutionhost.Parse(written) + require.NoError(t, err) + payload, err := solutionhost.SignedPayload(read) + require.NoError(t, err) + require.Contains(t, string(payload), `"module":"z.catalogue","presence":"required"`) + require.NotContains(t, string(payload), `"Module"`) + envelope, err := solutionhost.SignEnvelope(payload, signerFor(t, testKeyID)) + require.NoError(t, err) + carrier, err := solutionhost.Carrier(payload, envelope) + require.NoError(t, err) + encoded, err := solutionhost.MarshalSigned(carrier) + require.NoError(t, err) + signed, err := solutionhost.ParseSigned(encoded) + require.NoError(t, err) + delivered, err := solutionhost.VerifyDelivered(context.Background(), signed, testVerifier(t)) + require.NoError(t, err) + got, err := delivered.Manifest() + require.NoError(t, err) + require.Equal(t, role, got.Authority.InstallationRole) + require.Equal(t, manifest.Authority.EdgeDependencies, got.Authority.EdgeDependencies) + require.Equal(t, "model", got.Authority.Bindings[0].ID) + + // Changing either declaration invalidates the original signature. + for _, mutate := range []func(*solutionhost.Authority){ + func(a *solutionhost.Authority) { a.InstallationRole = "another-role" }, + func(a *solutionhost.Authority) { a.EdgeDependencies[0].Presence = "optional" }, + } { + changed, err := solutionhost.Parse(payload) + require.NoError(t, err) + mutate(changed.Authority) + changedBytes, err := changed.CanonicalBytes() + require.NoError(t, err) + _, err = solutionhost.VerifyDelivered(context.Background(), &solutionhost.Signed{Document: changedBytes, Envelope: envelope}, testVerifier(t)) + require.ErrorIs(t, err, solutionhost.ErrUnsigned) + } + }) + } +} + +func TestInstallationCarrierPreservesLegacyCanonicalBytes(t *testing.T) { + // Captured at aeee38fd before extending the signed carrier. + want, err := os.ReadFile("testdata/legacy-installation-manifest.json") + require.NoError(t, err) + for _, edges := range [][]modulecontract.EdgeDependency{nil, {}} { + manifest := valid(t) + manifest.Authority.EdgeDependencies = edges + got, err := manifest.CanonicalBytes() + require.NoError(t, err) + require.Equal(t, string(want), string(got)) + require.NotContains(t, string(got), "installation_role") + require.NotContains(t, string(got), "edge_dependencies") + } +} diff --git a/solutionhost/manifest.go b/solutionhost/manifest.go index 2228e88f..4ad85869 100644 --- a/solutionhost/manifest.go +++ b/solutionhost/manifest.go @@ -230,11 +230,19 @@ type Endpoint struct { } // Authority is what a module asks to be granted. Every list is a request; an -// empty one asks for nothing on that axis. +// empty one asks for nothing on that axis. Installation declarations express +// machine-role selection and edge presence, and confer no authority themselves. type Authority struct { // Principal is the module's principal: its name. Opaque here. Principal string `yaml:"principal" json:"principal"` + // InstallationRole is the Core-validated selector, carried verbatim: empty + // is undeclared; "none" declares no standing grant; otherwise a role name. + InstallationRole string `yaml:"installation_role,omitempty" json:"installation_role,omitempty"` + // EdgeDependencies preserve declaration order and grant no authority. + // Their identities are resolved by the host, never inferred from bindings. + EdgeDependencies []modulecontract.EdgeDependency `yaml:"edge_dependencies,omitempty" json:"edge_dependencies,omitempty"` + // Bindings are the operation bindings the module asks to redeem. Bindings []AuthorityBinding `yaml:"bindings,omitempty" json:"bindings,omitempty"` @@ -611,10 +619,12 @@ func (manifest *ModuleManifest) CanonicalBytes() ([]byte, error) { func (authority *Authority) canonical() *Authority { normalized := Authority{ - Principal: authority.Principal, - Queues: sortedOrNil(authority.Queues), - Namespaces: sortedOrNil(authority.Namespaces), - ResourceKinds: sortedOrNil(authority.ResourceKinds), + InstallationRole: authority.InstallationRole, + EdgeDependencies: slices.Clone(authority.EdgeDependencies), + Principal: authority.Principal, + Queues: sortedOrNil(authority.Queues), + Namespaces: sortedOrNil(authority.Namespaces), + ResourceKinds: sortedOrNil(authority.ResourceKinds), } for _, binding := range authority.Bindings { operations := make(map[string][]string, len(binding.Operations)) diff --git a/solutionhost/testdata/legacy-installation-manifest.json b/solutionhost/testdata/legacy-installation-manifest.json new file mode 100644 index 00000000..2510cd45 --- /dev/null +++ b/solutionhost/testdata/legacy-installation-manifest.json @@ -0,0 +1 @@ +{"alias":"wiki","authority":{"bindings":[{"audience":"modelservice","id":"model","operations":{"headless":["modelservice.profiles:invoke"],"invoke":["modelservice.profiles:invoke","modelservice.profiles:read"]}}],"namespaces":["wiki"],"principal":"wiki","queues":["wiki.index"],"resource_kinds":["wiki.pages"]},"binding":"acme.local.wiki","endpoints":[{"address":"http://localhost:5123","api":"rest","name":"rest","service":"backend"},{"address":"localhost:5124","api":"grpc","name":"grpc","service":"indexer"}],"frontend":{"exposed_module":"./Page","host_contract":1,"order":10,"payload":{"artifact":"wiki-frontend","digest":"sha256:0000000000000000000000000000000000000000000000000000000000000001","media_type":"application/gzip"},"title":"Wiki"},"generation":7,"host":{"coordinate":"obin/local/dev","domain":"obin"},"kind":"module","release":{"name":"wiki","publisher":"obin","version":"0.1.0"},"schema":"codefly/module-manifest/v1","workloads":[{"container":"backend","service":"backend"},{"container":"indexer","service":"indexer"}]} \ No newline at end of file diff --git a/version/info.codefly.yaml b/version/info.codefly.yaml index 35f234e9..18b02220 100644 --- a/version/info.codefly.yaml +++ b/version/info.codefly.yaml @@ -1 +1 @@ -version: 0.19.16 +version: 0.19.17