Skip to content
Draft
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
52 changes: 33 additions & 19 deletions contracts/module/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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"`
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
}

Expand Down
8 changes: 8 additions & 0 deletions contracts/module/fixtures.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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},
Expand Down
19 changes: 19 additions & 0 deletions contracts/module/owner_aware_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
72 changes: 72 additions & 0 deletions contracts/module/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -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 }},
Expand All @@ -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},
Expand Down Expand Up @@ -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
}
4 changes: 2 additions & 2 deletions contracts/module/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
52 changes: 52 additions & 0 deletions contracts/module/setup.go
Original file line number Diff line number Diff line change
@@ -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
}
84 changes: 84 additions & 0 deletions contracts/module/setup_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading
Loading