From da5e9b0de91584459d4ae201b4f2855cc7122524 Mon Sep 17 00:00:00 2001 From: Gael Date: Fri, 30 Oct 2026 12:31:39 -0400 Subject: [PATCH] feat(module): declare bounded installation-only setup methods --- contracts/module/contract.go | 52 +++++++----- contracts/module/fixtures.go | 8 ++ contracts/module/owner_aware_test.go | 19 +++++ contracts/module/rules.go | 72 ++++++++++++++++ contracts/module/rules_test.go | 4 +- contracts/module/setup.go | 52 ++++++++++++ contracts/module/setup_test.go | 84 +++++++++++++++++++ .../testdata/owner-aware-kinds-unknown.yaml | 12 +++ .../module/testdata/owner-aware-kinds.yaml | 12 +++ .../module/testdata/setup-duplicate.yaml | 18 ++++ .../module/testdata/setup-empty-methods.yaml | 16 ++++ contracts/module/testdata/setup-optional.yaml | 17 ++++ .../testdata/setup-unknown-binding.yaml | 10 +++ contracts/module/testdata/setup-wildcard.yaml | 16 ++++ contracts/module/testdata/setup.yaml | 16 ++++ docs/module-contract.md | 16 +++- solutionhost/installation_test.go | 6 ++ solutionhost/manifest.go | 19 +++-- 18 files changed, 421 insertions(+), 28 deletions(-) create mode 100644 contracts/module/setup.go create mode 100644 contracts/module/setup_test.go create mode 100644 contracts/module/testdata/owner-aware-kinds-unknown.yaml create mode 100644 contracts/module/testdata/owner-aware-kinds.yaml create mode 100644 contracts/module/testdata/setup-duplicate.yaml create mode 100644 contracts/module/testdata/setup-empty-methods.yaml create mode 100644 contracts/module/testdata/setup-optional.yaml create mode 100644 contracts/module/testdata/setup-unknown-binding.yaml create mode 100644 contracts/module/testdata/setup-wildcard.yaml create mode 100644 contracts/module/testdata/setup.yaml diff --git a/contracts/module/contract.go b/contracts/module/contract.go index 02dd2247..44b70eda 100644 --- a/contracts/module/contract.go +++ b/contracts/module/contract.go @@ -268,6 +268,9 @@ type Contract struct { // or false declares no owner-aware checking. Solutions must declare it to // receive person capabilities from the authority. OwnerAware bool `yaml:"owner_aware,omitempty"` + // OwnerAwareResourceKinds restricts the owner-aware promise to served kinds. + // It is mutually exclusive with the module-wide OwnerAware declaration. + OwnerAwareResourceKinds []string `yaml:"owner_aware_resource_kinds,omitempty"` // Namespaces are the namespaces the module declares — the audit namespaces // it emits under, which an operator binds to its principal. Namespaces []string `yaml:"namespaces"` @@ -287,6 +290,9 @@ type Contract struct { // "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"` + // Setup selects exact calls allowed only during installation, separately + // from standing roles. Requirements are derived from the selected callee. + Setup []Setup `yaml:"setup,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"` @@ -819,16 +825,18 @@ func (contract *Contract) Validate() error { return contract.validate("") } type Resolved struct { // 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 + InstallationRole string + Setup []ResolvedSetup + EdgeDependencies []EdgeDependency + OwnerAware bool + OwnerAwareResourceKinds []string + 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 @@ -910,15 +918,16 @@ func (contract *Contract) resolve(values Values, without string, options ...Reso return nil, err } resolved := &Resolved{ - 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), + InstallationRole: contract.InstallationRole, + EdgeDependencies: slices.Clone(contract.EdgeDependencies), + OwnerAware: contract.OwnerAware, + OwnerAwareResourceKinds: slices.Sorted(slices.Values(contract.OwnerAwareResourceKinds)), + 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() @@ -1102,6 +1111,11 @@ func (contract *Contract) resolve(values Values, without string, options ...Reso if len(calls) > 0 { return nil, callsError(calls) } + var err error + resolved.Setup, err = contract.resolveSetup(resolved, opts.callees) + if err != nil { + return nil, err + } return resolved, nil } diff --git a/contracts/module/fixtures.go b/contracts/module/fixtures.go index d941b811..e0b2d2ed 100644 --- a/contracts/module/fixtures.go +++ b/contracts/module/fixtures.go @@ -61,6 +61,12 @@ func Fixtures() ([]Fixture, error) { outcome Outcome sentinel error }{ + {name: "setup-duplicate", file: "setup-duplicate.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: "must be non-empty and unique", rule: ruleSetupUnique}, + {name: "setup-optional", file: "setup-optional.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: "must be required", rule: ruleSetupTarget}, + {name: "setup-empty-methods", file: "setup-empty-methods.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: "requires exact methods", rule: ruleSetupMethods}, + {name: "setup-wildcard", file: "setup-wildcard.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: "must be exact and unique", rule: ruleSetupMethod}, + {name: "setup", file: "setup.yaml", outcome: OutcomeAccepted}, + {name: "setup-unknown-binding", file: "setup-unknown-binding.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: "is not declared", rule: ruleSetupBinding}, {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}, @@ -73,6 +79,8 @@ func Fixtures() ([]Fixture, error) { {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-kinds", file: "owner-aware-kinds.yaml", outcome: OutcomeAccepted}, + {name: "owner-aware-kinds-unknown", file: "owner-aware-kinds-unknown.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: "must be unique served kinds", rule: ruleOwnerAwareKinds}, {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/owner_aware_test.go b/contracts/module/owner_aware_test.go index 6ead86d4..50deddc6 100644 --- a/contracts/module/owner_aware_test.go +++ b/contracts/module/owner_aware_test.go @@ -77,3 +77,22 @@ func TestOwnerAwareDecodingIsStrict(t *testing.T) { }) } } + +func TestOwnerAwareKindsAreExplicitServedKinds(t *testing.T) { + c := setupContract(t) + c.ScopeCeilings = []ScopeCeiling{{ResourceKind: "example.items", Actions: []string{"read"}}} + for _, kinds := range [][]string{{"missing"}, {"example.items", "example.items"}, {"*"}} { + c.OwnerAwareResourceKinds = kinds + if err := c.Validate(); !errors.Is(err, ErrInvalid) { + t.Fatalf("accepted %v: %v", kinds, err) + } + } + c.OwnerAwareResourceKinds = []string{"example.items"} + if err := c.Validate(); err != nil { + t.Fatal(err) + } + c.OwnerAware = true + if err := c.Validate(); !errors.Is(err, ErrInvalid) { + t.Fatalf("both declarations accepted: %v", err) + } +} diff --git a/contracts/module/rules.go b/contracts/module/rules.go index c872b7a2..a763d53b 100644 --- a/contracts/module/rules.go +++ b/contracts/module/rules.go @@ -33,6 +33,7 @@ const ( ruleSchema = "schema" ruleKnownFields = "known-fields" ruleOwnerAwareBoolean = "owner-aware-boolean" + ruleOwnerAwareKinds = "owner-aware-kinds" ruleOneDocument = "one-document" ruleMappingKeysOnce = "mapping-keys-once" ruleNoMergeKeys = "no-merge-keys" @@ -42,6 +43,11 @@ const ( ruleInstallationStrings = "installation-declaration-strings" ruleInstallationRole = "installation-role" + ruleSetupBinding = "setup-binding" + ruleSetupTarget = "setup-target" + ruleSetupMethods = "setup-methods" + ruleSetupMethod = "setup-method" + ruleSetupUnique = "setup-unique" ruleInstallationRoleWhitespace = "installation-role-whitespace" ruleRoleReserved = "role-reserved" ruleEdgeModuleName = "edge-module-name" @@ -132,6 +138,7 @@ func rules() []rule { {name: ruleSchema, check: checkSchema}, {name: ruleKnownFields, check: func(*Contract) error { return nil }}, {name: ruleOwnerAwareBoolean, check: func(*Contract) error { return nil }}, + {name: ruleOwnerAwareKinds, check: checkOwnerAwareKinds}, {name: ruleOneDocument, check: func(*Contract) error { return nil }}, {name: ruleMappingKeysOnce, check: func(*Contract) error { return nil }}, {name: ruleNoMergeKeys, check: func(*Contract) error { return nil }}, @@ -141,6 +148,11 @@ func rules() []rule { {name: ruleInstallationStrings, check: func(*Contract) error { return nil }}, {name: ruleInstallationRoleWhitespace, check: checkInstallationRoleWhitespace}, {name: ruleInstallationRole, check: checkInstallationRole}, + {name: ruleSetupUnique, check: checkSetupUnique}, + {name: ruleSetupBinding, check: checkSetupBinding}, + {name: ruleSetupTarget, check: checkSetupTarget}, + {name: ruleSetupMethods, check: checkSetupMethods}, + {name: ruleSetupMethod, check: checkSetupMethod}, {name: ruleRoleReserved, check: checkRoleReserved}, {name: ruleEdgeModuleName, check: checkEdgeModuleNames}, {name: ruleEdgeModuleWhitespace, check: checkEdgeModuleWhitespace}, @@ -1286,3 +1298,63 @@ func checkEdgePresence(contract *Contract) error { } return nil } + +func checkSetupUnique(c *Contract) error { + seen := map[string]bool{} + for _, entry := range c.Setup { + if entry.Binding == "" || seen[entry.Binding] { + return fmt.Errorf("%w: setup binding must be non-empty and unique", ErrInvalid) + } + seen[entry.Binding] = true + } + return nil +} +func checkSetupBinding(c *Contract) error { + for _, entry := range c.Setup { + if !slices.ContainsFunc(c.Bindings, func(b Binding) bool { return b.ID == entry.Binding }) { + return fmt.Errorf("%w: setup binding %q is not declared", ErrInvalid, entry.Binding) + } + } + return nil +} +func checkSetupTarget(c *Contract) error { + for _, entry := range c.Setup { + for _, b := range c.Bindings { + if b.ID == entry.Binding && (b.Optional || b.Audience.Self || b.Audiences != nil || !slices.Contains(b.Operations, OperationInvoke)) { + return fmt.Errorf("%w: setup binding %q must be required and invoke one foreign audience", ErrInvalid, entry.Binding) + } + } + } + return nil +} +func checkSetupMethods(c *Contract) error { + for _, entry := range c.Setup { + if len(entry.Methods) == 0 { + return fmt.Errorf("%w: setup binding %q requires exact methods", ErrInvalid, entry.Binding) + } + } + return nil +} +func checkSetupMethod(c *Contract) error { + for _, entry := range c.Setup { + seen := map[string]bool{} + for _, method := range entry.Methods { + if !callPattern.MatchString(method) || strings.ContainsAny(method, "*{}") || seen[method] { + return fmt.Errorf("%w: setup method %q must be exact and unique", ErrInvalid, method) + } + seen[method] = true + } + } + return nil +} + +func checkOwnerAwareKinds(c *Contract) error { + seen := map[string]bool{} + for _, kind := range c.OwnerAwareResourceKinds { + if c.OwnerAware || seen[kind] || !namePattern.MatchString(kind) || !slices.ContainsFunc(c.ScopeCeilings, func(s ScopeCeiling) bool { return s.ResourceKind == kind }) { + return fmt.Errorf("%w: owner_aware_resource_kinds must be unique served kinds without owner_aware true: %q", ErrInvalid, kind) + } + seen[kind] = true + } + return nil +} diff --git a/contracts/module/rules_test.go b/contracts/module/rules_test.go index ff67c3d1..1f8cac8d 100644 --- a/contracts/module/rules_test.go +++ b/contracts/module/rules_test.go @@ -119,7 +119,7 @@ func TestEveryFixtureReachesItsOutcome(t *testing.T) { "namespace-declared-twice", "namespace-not-a-name", "namespaces-omitted", "not-yaml", "null-in-a-bare-ceiling-action", "null-in-an-explicit-scope-action", "null-key-hiding-a-subtree", "operation-admin", "operation-declared-twice", - "operation-delete", "operation-without-ceiling", "owner-aware", "owner-aware-string", "principal-upper-case", + "operation-delete", "operation-without-ceiling", "owner-aware", "owner-aware-kinds", "owner-aware-kinds-unknown", "owner-aware-string", "principal-upper-case", "queue-declared-twice", "queue-not-a-name", "queues-omitted", "resource-kind-from-endpoint-key", "resource-kind-literal", "resource-kind-slot-with-default", "resource-kinds-without-audiences", "revision-fractional", @@ -134,7 +134,7 @@ func TestEveryFixtureReachesItsOutcome(t *testing.T) { "scope-ceiling-kind-declared-twice", "scope-ceiling-kind-not-a-name", "scope-ceiling-without-action", "scope-kind-not-a-name", "self-as-literal", "self-beside-from", "self-beyond-own-ceiling", "self-binding-key", "self-false", - "self-optional-nothing-to-fill", "self-tagged", "self-with-resource-kind", "slot-alias-key", + "self-optional-nothing-to-fill", "self-tagged", "self-with-resource-kind", "setup", "setup-duplicate", "setup-empty-methods", "setup-optional", "setup-unknown-binding", "setup-wildcard", "slot-alias-key", "slot-group-not-a-name", "slot-key-not-a-key", "slot-names-from-twice", "slot-reference-tagged", "slot-with-default", "slot-with-empty-field-name", "slot-without-group", "tenancy", "two-documents", "unknown-destination-kind", diff --git a/contracts/module/setup.go b/contracts/module/setup.go new file mode 100644 index 00000000..f3630775 --- /dev/null +++ b/contracts/module/setup.go @@ -0,0 +1,52 @@ +package module + +import ( + "fmt" + "slices" +) + +// Setup selects a required binding and exact methods for installation only. +// It grants no standing role and carries no caller-authored scope or owner. +type Setup struct { + Binding string `yaml:"binding" json:"binding"` + Methods []string `yaml:"methods" json:"methods"` +} + +// ResolvedSetup preserves the selected methods as well as their callee-derived +// requirements. Methods sharing requirements are not interchangeable grants. +type ResolvedSetup struct { + Binding string `yaml:"binding" json:"binding"` + Methods []string `yaml:"methods" json:"methods"` + Scopes []string `yaml:"scopes" json:"scopes"` +} + +func (contract *Contract) resolveSetup(resolved *Resolved, callees Callees) ([]ResolvedSetup, error) { + var result []ResolvedSetup + for _, setup := range contract.Setup { + i := slices.IndexFunc(resolved.Bindings, func(b ResolvedBinding) bool { return b.ID == setup.Binding }) + if i < 0 || callees == nil { + return nil, fmt.Errorf("%w: setup %s needs its resolved binding and callee declarations", ErrUnresolvedCall, setup.Binding) + } + binding := resolved.Bindings[i] + if binding.Audience == resolved.Principal { + return nil, fmt.Errorf("%w: setup %s resolves to self", ErrUnresolvedCall, setup.Binding) + } + entry := ResolvedSetup{Binding: setup.Binding, Methods: slices.Sorted(slices.Values(setup.Methods))} + for _, method := range entry.Methods { + required, err := callees.Requires(binding.Audience, method) + if err != nil || len(required) == 0 { + return nil, fmt.Errorf("%w: setup %s method %s requires explicit callee requirements: %v", ErrUnresolvedCall, setup.Binding, method, err) + } + for _, scope := range required { + if !slices.Contains(binding.Scopes[OperationInvoke], scope) { + return nil, fmt.Errorf("%w: setup %s method %s requires %s beyond its invoke ceiling", ErrUnresolvedCall, setup.Binding, method, scope) + } + entry.Scopes = append(entry.Scopes, scope) + } + } + slices.Sort(entry.Scopes) + entry.Scopes = slices.Compact(entry.Scopes) + result = append(result, entry) + } + return result, nil +} diff --git a/contracts/module/setup_test.go b/contracts/module/setup_test.go new file mode 100644 index 00000000..cc6fdf1e --- /dev/null +++ b/contracts/module/setup_test.go @@ -0,0 +1,84 @@ +package module + +import ( + "errors" + "os" + "reflect" + "testing" +) + +func setupContract(t *testing.T) *Contract { + t.Helper() + data, err := os.ReadFile("testdata/setup.yaml") + if err != nil { + t.Fatal(err) + } + c, err := Parse(data) + if err != nil { + t.Fatal(err) + } + return c +} + +func TestSetupSelectsExactMethodWithoutStandingGrant(t *testing.T) { + c := setupContract(t) + written, err := c.Encode() + if err != nil { + t.Fatal(err) + } + c, err = Parse(written) + if err != nil { + t.Fatal(err) + } + values := MapValues{Public: map[string]map[string]string{"assistant": {"records-audience": "records"}}} + callees := declaredCallees{"records": { + "/records.v1.RecordsService/DefineRecordType": {"records.types:define"}, + "/records.v1.RecordsService/DefineProjection": {"records.types:define"}, + }} + r, err := c.Resolve(values, WithCallees(callees)) + if err != nil { + t.Fatal(err) + } + want := []ResolvedSetup{{Binding: "schemas", Methods: []string{"/records.v1.RecordsService/DefineRecordType"}, Scopes: []string{"records.types:define"}}} + if !reflect.DeepEqual(r.Setup, want) || len(r.Roles) != 0 || r.InstallationRole != "none" { + t.Fatalf("setup widened or changed: %+v", r) + } + r.Setup[0].Methods[0] = "changed" + if c.Setup[0].Methods[0] == "changed" { + t.Fatal("resolved setup aliases authored storage") + } + if _, err := c.Resolve(values); !errors.Is(err, ErrUnresolvedCall) { + t.Fatalf("missing declarations: %v", err) + } + c.Setup[0].Methods = []string{"/records.v1.RecordsService/Other"} + callees["records"][c.Setup[0].Methods[0]] = []string{"records.records:write"} + if _, err := c.Resolve(values, WithCallees(callees)); !errors.Is(err, ErrUnresolvedCall) { + t.Fatalf("beyond ceiling: %v", err) + } + callees["records"][c.Setup[0].Methods[0]] = nil + if _, err := c.Resolve(values, WithCallees(callees)); !errors.Is(err, ErrUnresolvedCall) { + t.Fatalf("empty requirement: %v", err) + } +} + +func TestSetupRefusesAmbiguousOrUnboundedSelections(t *testing.T) { + for name, mutate := range map[string]func(*Contract){ + "unknown": func(c *Contract) { c.Setup[0].Binding = "missing" }, + "duplicate binding": func(c *Contract) { c.Setup = append(c.Setup, c.Setup[0]) }, + "empty": func(c *Contract) { c.Setup[0].Methods = nil }, + "duplicate method": func(c *Contract) { c.Setup[0].Methods = append(c.Setup[0].Methods, c.Setup[0].Methods[0]) }, + "wildcard": func(c *Contract) { c.Setup[0].Methods = []string{"POST /types/*"} }, + "route parameter": func(c *Contract) { c.Setup[0].Methods = []string{"POST /types/{id}"} }, + "optional": func(c *Contract) { c.Bindings[0].Optional = true }, + "self": func(c *Contract) { c.Bindings[0].Audience = Slot{Self: true} }, + "headless": func(c *Contract) { c.Bindings[0].Operations = []string{OperationHeadless} }, + } { + t.Run(name, func(t *testing.T) { + c := setupContract(t) + mutate(c) + if err := c.Validate(); !errors.Is(err, ErrInvalid) { + t.Fatalf("accepted invalid setup: %v", err) + } + }) + } +} diff --git a/contracts/module/testdata/owner-aware-kinds-unknown.yaml b/contracts/module/testdata/owner-aware-kinds-unknown.yaml new file mode 100644 index 00000000..f183b7ff --- /dev/null +++ b/contracts/module/testdata/owner-aware-kinds-unknown.yaml @@ -0,0 +1,12 @@ +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 +owner_aware_resource_kinds: [unknown.items] diff --git a/contracts/module/testdata/owner-aware-kinds.yaml b/contracts/module/testdata/owner-aware-kinds.yaml new file mode 100644 index 00000000..c28af59f --- /dev/null +++ b/contracts/module/testdata/owner-aware-kinds.yaml @@ -0,0 +1,12 @@ +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 +owner_aware_resource_kinds: [example.items] diff --git a/contracts/module/testdata/setup-duplicate.yaml b/contracts/module/testdata/setup-duplicate.yaml new file mode 100644 index 00000000..74c31480 --- /dev/null +++ b/contracts/module/testdata/setup-duplicate.yaml @@ -0,0 +1,18 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [] +destinations: [] +installation_role: none +bindings: + - id: schemas + audience: {from: assistant/records-audience} + operations: [invoke] + calls: + invoke: [/records.v1.RecordsService/DefineRecordType] +setup: + - binding: schemas + methods: [/records.v1.RecordsService/DefineRecordType] + - binding: schemas + methods: [/records.v1.RecordsService/DefineRecordType] diff --git a/contracts/module/testdata/setup-empty-methods.yaml b/contracts/module/testdata/setup-empty-methods.yaml new file mode 100644 index 00000000..c036cac5 --- /dev/null +++ b/contracts/module/testdata/setup-empty-methods.yaml @@ -0,0 +1,16 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [] +destinations: [] +installation_role: none +bindings: + - id: schemas + audience: {from: assistant/records-audience} + operations: [invoke] + calls: + invoke: [/records.v1.RecordsService/DefineRecordType] +setup: + - binding: schemas + methods: [] diff --git a/contracts/module/testdata/setup-optional.yaml b/contracts/module/testdata/setup-optional.yaml new file mode 100644 index 00000000..5b9161c2 --- /dev/null +++ b/contracts/module/testdata/setup-optional.yaml @@ -0,0 +1,17 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [] +destinations: [] +installation_role: none +bindings: + - id: schemas + optional: true + audience: {from: assistant/records-audience} + operations: [invoke] + calls: + invoke: [/records.v1.RecordsService/DefineRecordType] +setup: + - binding: schemas + methods: [/records.v1.RecordsService/DefineRecordType] diff --git a/contracts/module/testdata/setup-unknown-binding.yaml b/contracts/module/testdata/setup-unknown-binding.yaml new file mode 100644 index 00000000..91ca25f5 --- /dev/null +++ b/contracts/module/testdata/setup-unknown-binding.yaml @@ -0,0 +1,10 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [] +destinations: [] +bindings: [] +setup: + - binding: unknown + methods: [/records.v1.RecordsService/DefineRecordType] diff --git a/contracts/module/testdata/setup-wildcard.yaml b/contracts/module/testdata/setup-wildcard.yaml new file mode 100644 index 00000000..ddb58f74 --- /dev/null +++ b/contracts/module/testdata/setup-wildcard.yaml @@ -0,0 +1,16 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [] +destinations: [] +installation_role: none +bindings: + - id: schemas + audience: {from: assistant/records-audience} + operations: [invoke] + calls: + invoke: [/records.v1.RecordsService/DefineRecordType] +setup: + - binding: schemas + methods: ["POST /types/*"] diff --git a/contracts/module/testdata/setup.yaml b/contracts/module/testdata/setup.yaml new file mode 100644 index 00000000..8a2be5a1 --- /dev/null +++ b/contracts/module/testdata/setup.yaml @@ -0,0 +1,16 @@ +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: [] +destinations: [] +installation_role: none +bindings: + - id: schemas + audience: {from: assistant/records-audience} + operations: [invoke] + calls: + invoke: [/records.v1.RecordsService/DefineRecordType] +setup: + - binding: schemas + methods: [/records.v1.RecordsService/DefineRecordType] diff --git a/docs/module-contract.md b/docs/module-contract.md index 8291aaee..bb4efc7a 100644 --- a/docs/module-contract.md +++ b/docs/module-contract.md @@ -474,7 +474,7 @@ 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()` (144 documents) ships +`modulecontract.Fixtures()` (152 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. Accepted documents declare @@ -510,3 +510,17 @@ lands, the second copies of this model still exist. What each one owes: an unscoped solution: it asks for no operation. When a module also publishes a contract, an empty consumption request does not compete with that contract; nonempty competing requests remain an authoring conflict. + +### Installation-only setup + +`setup` selects `{binding, methods}` separately from roles. Each binding must +be required and invoke one foreign audience. Exact, unique methods are resolved +through that audience's method policies and must fit its invoke ceiling. Core +retains both methods and derived scopes; equal requirements do not grant another +method. The signed authority carrier retains that resolved selection. Empty +setup grants nothing, and `installation_role: none` remains valid. + +`owner_aware_resource_kinds` lists the served kinds whose resource-owner selectors +the module enforces. It cannot accompany `owner_aware: true` (all kinds). Unknown +or duplicate kinds refuse. Empty means none. Delivery preserves the sorted list; +withdrawal must invalidate outstanding owner-selected authority at the host. diff --git a/solutionhost/installation_test.go b/solutionhost/installation_test.go index 6c6be511..8b888900 100644 --- a/solutionhost/installation_test.go +++ b/solutionhost/installation_test.go @@ -15,6 +15,8 @@ func TestInstallationDeclarationsSurviveSignedDelivery(t *testing.T) { t.Run(role, func(t *testing.T) { manifest := valid(t) manifest.Authority.InstallationRole = role + manifest.Authority.Setup = []modulecontract.ResolvedSetup{{Binding: "model", Methods: []string{"/model.v1.Model/Generate"}, Scopes: []string{"model:invoke"}}} + manifest.Authority.OwnerAwareResourceKinds = []string{"example.items"} // Deliberately not sorted; order is part of the authored declaration. manifest.Authority.EdgeDependencies = []modulecontract.EdgeDependency{ {Module: "z.catalogue", Presence: "required"}, @@ -41,12 +43,16 @@ func TestInstallationDeclarationsSurviveSignedDelivery(t *testing.T) { got, err := delivered.Manifest() require.NoError(t, err) require.Equal(t, role, got.Authority.InstallationRole) + require.Equal(t, manifest.Authority.Setup, got.Authority.Setup) + require.Equal(t, manifest.Authority.OwnerAwareResourceKinds, got.Authority.OwnerAwareResourceKinds) 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.Setup[0].Methods[0] = "/model.v1.Model/Other" }, + func(a *solutionhost.Authority) { a.OwnerAwareResourceKinds = []string{"different.kind"} }, func(a *solutionhost.Authority) { a.EdgeDependencies[0].Presence = "optional" }, } { changed, err := solutionhost.Parse(payload) diff --git a/solutionhost/manifest.go b/solutionhost/manifest.go index 4ad85869..cbbfaeb8 100644 --- a/solutionhost/manifest.go +++ b/solutionhost/manifest.go @@ -236,9 +236,14 @@ type Authority struct { // Principal is the module's principal: its name. Opaque here. Principal string `yaml:"principal" json:"principal"` + // OwnerAwareResourceKinds preserves the bounded owner-aware declaration. + OwnerAwareResourceKinds []string `yaml:"owner_aware_resource_kinds,omitempty" json:"owner_aware_resource_kinds,omitempty"` + // 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"` + // Setup is the resolved, reviewed installation-only method grant. + Setup []modulecontract.ResolvedSetup `yaml:"setup,omitempty" json:"setup,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"` @@ -619,12 +624,14 @@ func (manifest *ModuleManifest) CanonicalBytes() ([]byte, error) { func (authority *Authority) canonical() *Authority { normalized := Authority{ - InstallationRole: authority.InstallationRole, - EdgeDependencies: slices.Clone(authority.EdgeDependencies), - Principal: authority.Principal, - Queues: sortedOrNil(authority.Queues), - Namespaces: sortedOrNil(authority.Namespaces), - ResourceKinds: sortedOrNil(authority.ResourceKinds), + InstallationRole: authority.InstallationRole, + Setup: slices.Clone(authority.Setup), + EdgeDependencies: slices.Clone(authority.EdgeDependencies), + Principal: authority.Principal, + OwnerAwareResourceKinds: sortedOrNil(authority.OwnerAwareResourceKinds), + 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))