diff --git a/contracts/module/contract.go b/contracts/module/contract.go index 75185fdf..eac89932 100644 --- a/contracts/module/contract.go +++ b/contracts/module/contract.go @@ -263,6 +263,11 @@ type Contract struct { // Principal is the principal every credential the module mints is issued // to: the module's own name. Principal string `yaml:"principal"` + // OwnerAware declares that the module enforces resource-owner selectors + // in Work Context scopes. It is never inferred from roles or kinds; absent + // or false declares no owner-aware checking. Solutions must declare it to + // receive person capabilities from the authority. + OwnerAware bool `yaml:"owner_aware,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"` @@ -747,6 +752,22 @@ func parse(data []byte, without string) (*Contract, error) { return nil, fmt.Errorf("%w: the mapping at %s carries a key that is not a name (%q); these mappings are keyed by name", ErrInvalid, defect.Path, defect.Detail) } + if without != ruleOwnerAwareBoolean { + mapping := wire.ReadMapping(tree.Content[0], func(key string, value *yaml.Node) (bool, error) { + if key == "owner_aware" { + for value.Kind == yaml.AliasNode { + value = value.Alias + } + if value.Kind != yaml.ScalarNode || value.ShortTag() != "!!bool" { + return true, fmt.Errorf("%w: owner_aware must be a boolean (true or false), declaring whether the module enforces resource-owner selectors", ErrInvalid) + } + } + return true, nil + }) + if mapping.Err != nil { + return nil, mapping.Err + } + } decoder := yaml.NewDecoder(bytes.NewReader(data)) decoder.KnownFields(without != ruleKnownFields) var contract Contract @@ -770,6 +791,7 @@ 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 @@ -858,6 +880,7 @@ 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), diff --git a/contracts/module/fixtures.go b/contracts/module/fixtures.go index f7f3a471..2f4cc6ae 100644 --- a/contracts/module/fixtures.go +++ b/contracts/module/fixtures.go @@ -61,6 +61,8 @@ func Fixtures() ([]Fixture, error) { outcome Outcome sentinel error }{ + {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}, {name: "upper-case-slot-key", file: "upper-case-slot-key.yaml", outcome: OutcomeAccepted}, {name: "binding-names-calls", file: "binding-names-calls.yaml", outcome: OutcomeAccepted}, diff --git a/contracts/module/owner_aware_test.go b/contracts/module/owner_aware_test.go new file mode 100644 index 00000000..6ead86d4 --- /dev/null +++ b/contracts/module/owner_aware_test.go @@ -0,0 +1,79 @@ +package module + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestOwnerAwareIsAnExplicitDeclaration(t *testing.T) { + for _, declaration := range []string{"", "owner_aware: false\n", "owner_aware: true\n"} { + t.Run(strings.TrimSpace(declaration), func(t *testing.T) { + data := append(fixture(t), []byte(declaration)...) + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, FileName), data, 0o600); err != nil { + t.Fatal(err) + } + contract, err := Load(dir) + if err != nil { + t.Fatal(err) + } + want := strings.Contains(declaration, "true") + if contract.OwnerAware != want { + t.Fatalf("owner-aware = %v, want %v", contract.OwnerAware, want) + } + resolved, err := contract.Resolve(values()) + if err != nil { + t.Fatal(err) + } + if resolved.OwnerAware != want { + t.Fatalf("resolution changed owner-aware to %v", resolved.OwnerAware) + } + encoded, err := contract.Encode() + if err != nil { + t.Fatal(err) + } + again, err := Parse(encoded) + if err != nil { + t.Fatal(err) + } + if again.OwnerAware != want { + t.Fatal("encoding changed the declaration") + } + }) + } + // Neither roles nor owned scopes assert that the solution checks owners. + data, err := os.ReadFile(filepath.Join("testdata", "roles-installation-owned.yaml")) + if err != nil { + t.Fatal(err) + } + contract, err := Parse(data) + if err != nil { + t.Fatal(err) + } + if contract.OwnerAware { + t.Fatal("roles implied owner-aware checking") + } +} + +func TestOwnerAwareDecodingIsStrict(t *testing.T) { + for _, field := range []string{ + "owner_aware: yes", "owner_aware: no", `owner_aware: "true"`, + "owner_aware: 1", "owner_aware: []", "owner_aware: {}", "owner_aware: null", + "owner_aware: true\nowner_aware: false", "owner-aware: true", + "owner_aware: &value yes\nrequire_method_policies: *value", + "require_method_policies: &value yes\nowner_aware: *value", + } { + t.Run(field, func(t *testing.T) { + _, err := Parse(append(fixture(t), []byte(field+"\n")...)) + if !errors.Is(err, ErrInvalid) { + t.Fatalf("expected invalid declaration, got %v", err) + } + if !strings.Contains(err.Error(), "owner_aware") && !strings.Contains(err.Error(), "owner-aware") { + t.Fatalf("refusal does not name the declaration: %v", err) + } + }) + } +} diff --git a/contracts/module/rules.go b/contracts/module/rules.go index d2126eab..afc60162 100644 --- a/contracts/module/rules.go +++ b/contracts/module/rules.go @@ -29,15 +29,16 @@ type rule struct { // The rules, by name. A refused fixture names one of these; a reader that // drops one fails the kit on that fixture. const ( - ruleWellFormed = "well-formed" - ruleSchema = "schema" - ruleKnownFields = "known-fields" - ruleOneDocument = "one-document" - ruleMappingKeysOnce = "mapping-keys-once" - ruleNoMergeKeys = "no-merge-keys" - ruleNoNulls = "no-explicit-nulls" - ruleKeyIsAName = "key-is-a-name" - ruleWholeNumbers = "numbers-are-whole" + ruleWellFormed = "well-formed" + ruleSchema = "schema" + ruleKnownFields = "known-fields" + ruleOwnerAwareBoolean = "owner-aware-boolean" + ruleOneDocument = "one-document" + ruleMappingKeysOnce = "mapping-keys-once" + ruleNoMergeKeys = "no-merge-keys" + ruleNoNulls = "no-explicit-nulls" + ruleKeyIsAName = "key-is-a-name" + ruleWholeNumbers = "numbers-are-whole" rulePrincipal = "principal-name" ruleListsDeclared = "lists-declared" @@ -121,6 +122,7 @@ func rules() []rule { {name: ruleWellFormed, inherent: true, check: func(*Contract) error { return nil }}, {name: ruleSchema, check: checkSchema}, {name: ruleKnownFields, check: func(*Contract) error { return nil }}, + {name: ruleOwnerAwareBoolean, check: func(*Contract) error { return nil }}, {name: ruleOneDocument, check: func(*Contract) error { return nil }}, {name: ruleMappingKeysOnce, check: func(*Contract) error { return nil }}, {name: ruleNoMergeKeys, check: func(*Contract) error { return nil }}, diff --git a/contracts/module/rules_test.go b/contracts/module/rules_test.go index 0cd92ef6..5d0ea96a 100644 --- a/contracts/module/rules_test.go +++ b/contracts/module/rules_test.go @@ -107,7 +107,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", "principal-upper-case", + "operation-delete", "operation-without-ceiling", "owner-aware", "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", diff --git a/contracts/module/testdata/owner-aware-string.yaml b/contracts/module/testdata/owner-aware-string.yaml new file mode 100644 index 00000000..707e6514 --- /dev/null +++ b/contracts/module/testdata/owner-aware-string.yaml @@ -0,0 +1,7 @@ +schema: codefly/module-contract/v1 +principal: assistant +owner_aware: yes +namespaces: [] +queues: [] +bindings: [] +destinations: [] diff --git a/contracts/module/testdata/owner-aware.yaml b/contracts/module/testdata/owner-aware.yaml new file mode 100644 index 00000000..c6e127d3 --- /dev/null +++ b/contracts/module/testdata/owner-aware.yaml @@ -0,0 +1,7 @@ +schema: codefly/module-contract/v1 +principal: assistant +owner_aware: true +namespaces: [] +queues: [] +bindings: [] +destinations: [] diff --git a/docs/module-contract.md b/docs/module-contract.md index 7fe37eee..e7a7897a 100644 --- a/docs/module-contract.md +++ b/docs/module-contract.md @@ -200,6 +200,16 @@ that ceiling, and the list names each method once, each requiring at least one action. The render delivers them beside the methods its published contracts declare, so the hop in front of the module decides them like any other. +**A module declares owner-aware checking explicitly.** Top-level +`owner_aware: true` declares that the module enforces resource-owner selectors +in Work Context scopes. Omitted or `false` declares no such checking. The field +is a boolean; strings, including YAML 1.1 spellings such as `yes`, are refused. +`Parse`, `Encode` and `Resolve` preserve this declaration. Roles and +`installation_owned_scopes` never imply it. A solution must declare it before +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. + **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 @@ -427,7 +437,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()` (130 documents) ships +`modulecontract.Fixtures()` (132 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