From 75333b544cd7d5c32316e15adceac7f7971ae784 Mon Sep 17 00:00:00 2001 From: antoinetoussaint-byte <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:40:13 -0400 Subject: [PATCH] feat(contracts/module): a role's installation_owned_scopes A role can now grant a scope only on what the installation it is held on owns, mirroring the authority's RoleV1.installation_owned_scopes (field 3, codefly-dev/authority#7): roles: - name: viewer scopes: [...] installation_owned_scopes: - {resource_kind: documents.collection, actions: [read]} Each entry is a kind and actions and never ids (CeilingScope has no field for them, so resource_ids is refused as an unknown field). It is held to every rule a role scope is, under the same rule names with "installation-owned" in the message: kind and action grammar, each kind once per list, at least one action, within the unit's own ceilings or a scope a binding spells out for invoke or lookup. A role may grant through it alone. Resolve addresses it like a literal scope, into ResolvedRole.InstallationOwnedGrants, and refuses one two audiences underwrite. Co-Authored-By: Claude Opus 5.5 --- contracts/module/contract.go | 53 +++++++-- contracts/module/fixtures.go | 5 + contracts/module/roles_test.go | 103 ++++++++++++++++++ contracts/module/rules.go | 72 ++++++++---- contracts/module/rules_test.go | 5 +- ...installation-owned-beyond-consumption.yaml | 27 +++++ .../testdata/role-installation-owned-ids.yaml | 28 +++++ ...e-installation-owned-kind-named-twice.yaml | 28 +++++ ...nstallation-owned-kind-without-action.yaml | 26 +++++ .../testdata/roles-installation-owned.yaml | 45 ++++++++ docs/module-contract.md | 29 ++++- 11 files changed, 386 insertions(+), 35 deletions(-) create mode 100644 contracts/module/testdata/role-installation-owned-beyond-consumption.yaml create mode 100644 contracts/module/testdata/role-installation-owned-ids.yaml create mode 100644 contracts/module/testdata/role-installation-owned-kind-named-twice.yaml create mode 100644 contracts/module/testdata/role-installation-owned-kind-without-action.yaml create mode 100644 contracts/module/testdata/roles-installation-owned.yaml diff --git a/contracts/module/contract.go b/contracts/module/contract.go index b47f85bd..75185fdf 100644 --- a/contracts/module/contract.go +++ b/contracts/module/contract.go @@ -315,11 +315,21 @@ type ScopeCeiling struct { } // Role is one role of a unit's own: its name and what it grants — scopes, each -// a resource kind named literally with the actions granted on it, and binding -// operations, each granting what that binding carries for it as resolved. +// a resource kind named literally with the actions granted on it, scopes it +// grants only on what the installation owns, and binding operations, each +// granting what that binding carries for it as resolved. type Role struct { Name string `yaml:"name"` Scopes []CeilingScope `yaml:"scopes,omitempty"` + // InstallationOwnedScopes are scopes the role grants only on the + // resources the installation it is held on owns: those created or + // attached through that installation. Each names a kind and actions and + // no resource ids, since ids are each installation's own, and is held to + // the same rules and the same ceiling as Scopes. The authority resolves + // each, per installation, into the same scope limited to the ids that + // installation owns of the kind; a kind it owns nothing of grants + // nothing, never the whole kind. + InstallationOwnedScopes []CeilingScope `yaml:"installation_owned_scopes,omitempty"` // Bindings grant what one of the unit's bindings carries for an operation // a person causes (invoke, lookup): its ceiling as Resolve derives it — // the scopes its calls require, read from each audience's declarations, @@ -798,6 +808,11 @@ type ResolvedRole struct { // Grants are, per audience, the scopes ":" the // role grants there. Sorted, each once. Grants map[string][]string + // InstallationOwnedGrants are, per audience, the scopes + // ":" the role grants there only on what the + // installation owns, addressed exactly as Grants' literal scopes are. + // Sorted, each once; nil when the role declares none. + InstallationOwnedGrants map[string][]string } // ResolvedBinding is one binding with its audience, resource kind and binding @@ -1061,14 +1076,14 @@ func (contract *Contract) resolveRoles(resolved *Resolved, delivered map[string] slices.Sort(audiences) return slices.Compact(audiences) } - roles := make([]ResolvedRole, 0, len(contract.Roles)) - for _, role := range contract.Roles { - grants := map[string][]string{} - for _, scope := range role.Scopes { + // address adds each literal scope to grants at the one audience that + // underwrites it, appending to ambiguous each scope two underwrite. + address := func(role, label string, scopes []CeilingScope, grants map[string][]string) { + for _, scope := range scopes { for _, action := range scope.Actions { audiences := underwriters(scope.ResourceKind, action) if len(audiences) > 1 { - ambiguous = append(ambiguous, fmt.Sprintf("role %s grants %s:%s, which is underwritten at %s; a role grants a scope at one audience, so it names the binding it means in bindings", role.Name, scope.ResourceKind, action, strings.Join(audiences, " and "))) + ambiguous = append(ambiguous, fmt.Sprintf("role %s grants %s%s:%s, which is underwritten at %s; a role grants a scope at one audience, so it names the binding it means in bindings", role, label, scope.ResourceKind, action, strings.Join(audiences, " and "))) continue } for _, audience := range audiences { @@ -1076,21 +1091,37 @@ func (contract *Contract) resolveRoles(resolved *Resolved, delivered map[string] } } } + } + roles := make([]ResolvedRole, 0, len(contract.Roles)) + for _, role := range contract.Roles { + grants := map[string][]string{} + address(role.Name, "", role.Scopes, grants) for _, granted := range role.Bindings { for _, index := range delivered[granted.Binding] { binding := resolved.Bindings[index] grants[binding.Audience] = append(grants[binding.Audience], binding.Scopes[granted.Operation]...) } } - for audience, scopes := range grants { - slices.Sort(scopes) - grants[audience] = slices.Compact(scopes) + entry := ResolvedRole{Name: role.Name, Grants: sortedGrants(grants)} + if len(role.InstallationOwnedScopes) > 0 { + owned := map[string][]string{} + address(role.Name, "installation-owned ", role.InstallationOwnedScopes, owned) + entry.InstallationOwnedGrants = sortedGrants(owned) } - roles = append(roles, ResolvedRole{Name: role.Name, Grants: grants}) + roles = append(roles, entry) } return roles, ambiguous } +// sortedGrants sorts each audience's scopes and keeps each once. +func sortedGrants(grants map[string][]string) map[string][]string { + for audience, scopes := range grants { + slices.Sort(scopes) + grants[audience] = slices.Compact(scopes) + } + return grants +} + // beyondOwnCeilings lists the scopes, each ":", that // are not within the module's own ceilings, in the order given. func beyondOwnCeilings(own map[string][]string, scopes []string) []string { diff --git a/contracts/module/fixtures.go b/contracts/module/fixtures.go index b74d809a..f7f3a471 100644 --- a/contracts/module/fixtures.go +++ b/contracts/module/fixtures.go @@ -69,6 +69,7 @@ func Fixtures() ([]Fixture, error) { {name: "foreign-methods", file: "foreign-methods.yaml", outcome: OutcomeAccepted}, {name: "roles", file: "roles.yaml", outcome: OutcomeAccepted}, {name: "roles-grant-bindings", file: "roles-grant-bindings.yaml", outcome: OutcomeAccepted}, + {name: "roles-installation-owned", file: "roles-installation-owned.yaml", outcome: OutcomeAccepted}, {name: "binding-self-audience", file: "binding-self-audience.yaml", outcome: OutcomeAccepted}, {name: "self-beside-from", file: "self-beside-from.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: `binding occurrences audience slot writes self beside from "assistant/evidence-audience"`, rule: ruleSlotSelfBesideFrom}, @@ -199,6 +200,10 @@ func Fixtures() ([]Fixture, error) { {name: "role-beyond-own-ceiling", file: "role-beyond-own-ceiling.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: `role "installer" grants assistant.tasks:delete`, rule: ruleRoleWithinRequest}, {name: "role-beyond-consumption", file: "role-beyond-consumption.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: `role "installer" grants tracing.trace:read`, rule: ruleRoleWithinRequest}, {name: "role-headless-permission", file: "role-headless-permission.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: `role "installer" grants evaluation.definitions:publish`, rule: ruleRoleWithinRequest}, + {name: "role-installation-owned-ids", file: "role-installation-owned-ids.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: "resource_ids", rule: ruleKnownFields}, + {name: "role-installation-owned-beyond-consumption", file: "role-installation-owned-beyond-consumption.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: `role "viewer" grants installation-owned documents.collection:write`, rule: ruleRoleWithinRequest}, + {name: "role-installation-owned-kind-without-action", file: "role-installation-owned-kind-without-action.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: `role "viewer" grants installation-owned resource kind "documents.collection" no action`, rule: ruleRoleKindHasAction}, + {name: "role-installation-owned-kind-named-twice", file: "role-installation-owned-kind-named-twice.yaml", outcome: OutcomeRefused, sentinel: ErrInvalid, message: `role "viewer" names installation-owned resource kind "documents.collection" twice`, rule: ruleRoleKindUnique}, } result := make([]Fixture, 0, len(table)) for _, entry := range table { diff --git a/contracts/module/roles_test.go b/contracts/module/roles_test.go index 79d264fd..b7d0004c 100644 --- a/contracts/module/roles_test.go +++ b/contracts/module/roles_test.go @@ -148,3 +148,106 @@ func TestARoleScopeTwoAudiencesUnderwriteIsRefused(t *testing.T) { t.Fatalf("a role scope two audiences underwrite: %v", err) } } + +func installationOwnedContract(t *testing.T) *Contract { + t.Helper() + document, err := fixtures.ReadFile("testdata/roles-installation-owned.yaml") + if err != nil { + t.Fatal(err) + } + contract, err := Parse(document) + if err != nil { + t.Fatal(err) + } + return contract +} + +// TestInstallationOwnedScopesAreReadAndWrittenBack: a role's +// installation_owned_scopes are read as declared, apart from its scopes, and +// Encode writes them back under the same key. +func TestInstallationOwnedScopesAreReadAndWrittenBack(t *testing.T) { + contract := installationOwnedContract(t) + want := []Role{ + {Name: InstallerRole, + Scopes: []CeilingScope{ + {ResourceKind: "assistant.tasks", Actions: []string{"read", "write"}}, + {ResourceKind: "documents.collection", Actions: []string{"read"}}, + }, + InstallationOwnedScopes: []CeilingScope{{ResourceKind: "documents.collection", Actions: []string{"read", "write"}}}}, + {Name: "viewer", + Scopes: []CeilingScope{{ResourceKind: "assistant.tasks", Actions: []string{"read"}}}, + InstallationOwnedScopes: []CeilingScope{{ResourceKind: "documents.collection", Actions: []string{"read"}}}}, + {Name: "auditor", InstallationOwnedScopes: []CeilingScope{ + {ResourceKind: "assistant.tasks", Actions: []string{"read"}}, + {ResourceKind: "documents.collection", Actions: []string{"read"}}, + }}, + } + if !reflect.DeepEqual(contract.Roles, want) { + t.Fatalf("roles read as %+v, declared as %+v", contract.Roles, want) + } + encoded, err := contract.Encode() + if err != nil { + t.Fatalf("a contract with installation-owned scopes must encode: %v", err) + } + if !strings.Contains(string(encoded), "installation_owned_scopes:") { + t.Fatalf("Encode wrote the installation-owned scopes under another key:\n%s", encoded) + } + again, err := Parse(encoded) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(again.Roles, want) { + t.Fatalf("roles written and read back as %+v", again.Roles) + } +} + +// TestInstallationOwnedScopesAreAddressedApart: each installation-owned scope +// is addressed to the one audience that underwrites it, exactly as a literal +// scope is, and kept apart from what the role grants on the whole kind; a role +// declaring none resolves none. +func TestInstallationOwnedScopesAreAddressedApart(t *testing.T) { + values := MapValues{Public: map[string]map[string]string{"assistant": {"collections-audience": "documents"}}} + resolved, err := installationOwnedContract(t).Resolve(values) + if err != nil { + t.Fatal(err) + } + want := []ResolvedRole{ + {Name: InstallerRole, + Grants: map[string][]string{"assistant": {"assistant.tasks:read", "assistant.tasks:write"}, "documents": {"documents.collection:read"}}, + InstallationOwnedGrants: map[string][]string{"documents": {"documents.collection:read", "documents.collection:write"}}}, + {Name: "viewer", + Grants: map[string][]string{"assistant": {"assistant.tasks:read"}}, + InstallationOwnedGrants: map[string][]string{"documents": {"documents.collection:read"}}}, + {Name: "auditor", + Grants: map[string][]string{}, + InstallationOwnedGrants: map[string][]string{"assistant": {"assistant.tasks:read"}, "documents": {"documents.collection:read"}}}, + } + if !reflect.DeepEqual(resolved.Roles, want) { + t.Fatalf("roles resolve to %+v, want %+v", resolved.Roles, want) + } + plain, err := rolesGrantBindingsContract(t).Resolve(rolesValues("documents", "robin,documents"), WithCallees(rolesDeclarations)) + if err != nil { + t.Fatal(err) + } + for _, role := range plain.Roles { + if role.InstallationOwnedGrants != nil { + t.Fatalf("role %s declares no installation-owned scope and resolves %+v", role.Name, role.InstallationOwnedGrants) + } + } +} + +// TestAnInstallationOwnedScopeTwoAudiencesUnderwriteIsRefused: an +// installation-owned scope names no audience either, so two audiences +// underwriting it are refused by name, as for a literal scope. +func TestAnInstallationOwnedScopeTwoAudiencesUnderwriteIsRefused(t *testing.T) { + contract := rolesGrantBindingsContract(t) + contract.Roles[1].InstallationOwnedScopes = []CeilingScope{{ResourceKind: "documents", Actions: []string{"read"}}} + contract.Roles[0].Scopes = contract.Roles[0].Scopes[:1] + if err := contract.Validate(); err != nil { + t.Fatal(err) + } + _, err := contract.Resolve(rolesValues("replica", "robin,documents"), WithCallees(rolesDeclarations)) + if !errors.Is(err, ErrAmbiguousSlot) || !strings.Contains(err.Error(), "role viewer grants installation-owned documents:read, which is underwritten at documents and replica") { + t.Fatalf("an installation-owned scope two audiences underwrite: %v", err) + } +} diff --git a/contracts/module/rules.go b/contracts/module/rules.go index 9f1f1a60..d2126eab 100644 --- a/contracts/module/rules.go +++ b/contracts/module/rules.go @@ -354,11 +354,13 @@ func (contract *Contract) actionLists() []struct { } } for _, role := range contract.Roles { - for _, scope := range role.Scopes { - lists = append(lists, struct { - label string - actions []string - }{"role " + role.Name + " " + scope.ResourceKind, scope.Actions}) + for _, list := range role.scopeLists() { + for _, scope := range list.scopes { + lists = append(lists, struct { + label string + actions []string + }{"role " + role.Name + " " + list.label + scope.ResourceKind, scope.Actions}) + } } } return lists @@ -1021,9 +1023,24 @@ func checkRolesUnique(contract *Contract) error { return nil } +// roleScopes is one of a role's lists of literal scopes, with the label a +// refusal names it by: "" for scopes, "installation-owned " for +// installation_owned_scopes. +type roleScopes struct { + label string + scopes []CeilingScope +} + +// scopeLists are the role's literal scopes, both lists, each held to the same +// rules: an installation-owned scope differs only in what the authority +// resolves it to, never in what a role may name. +func (role Role) scopeLists() []roleScopes { + return []roleScopes{{"", role.Scopes}, {"installation-owned ", role.InstallationOwnedScopes}} +} + func checkRolesGrant(contract *Contract) error { for _, role := range contract.Roles { - if len(role.Scopes) == 0 && len(role.Bindings) == 0 { + if len(role.Scopes) == 0 && len(role.InstallationOwnedScopes) == 0 && len(role.Bindings) == 0 { return fmt.Errorf("%w: role %q grants nothing", ErrInvalid, role.Name) } } @@ -1032,23 +1049,30 @@ func checkRolesGrant(contract *Contract) error { func checkRoleKindNames(contract *Contract) error { for _, role := range contract.Roles { - for _, scope := range role.Scopes { - if !namePattern.MatchString(scope.ResourceKind) { - return fmt.Errorf("%w: role %q resource kind %q is not a lowercase name", ErrInvalid, role.Name, scope.ResourceKind) + for _, list := range role.scopeLists() { + for _, scope := range list.scopes { + if !namePattern.MatchString(scope.ResourceKind) { + return fmt.Errorf("%w: role %q %sresource kind %q is not a lowercase name", ErrInvalid, role.Name, list.label, scope.ResourceKind) + } } } } return nil } +// checkRoleKindsUnique: each list names a kind once. A kind may appear in +// both, each granting its own actions: on the whole kind, and on what the +// installation owns of it. func checkRoleKindsUnique(contract *Contract) error { for _, role := range contract.Roles { - kinds := make([]string, 0, len(role.Scopes)) - for _, scope := range role.Scopes { - kinds = append(kinds, scope.ResourceKind) - } - if duplicate, found := firstDuplicate(kinds); found { - return fmt.Errorf("%w: role %q names resource kind %q twice", ErrInvalid, role.Name, duplicate) + for _, list := range role.scopeLists() { + kinds := make([]string, 0, len(list.scopes)) + for _, scope := range list.scopes { + kinds = append(kinds, scope.ResourceKind) + } + if duplicate, found := firstDuplicate(kinds); found { + return fmt.Errorf("%w: role %q names %sresource kind %q twice", ErrInvalid, role.Name, list.label, duplicate) + } } } return nil @@ -1056,9 +1080,11 @@ func checkRoleKindsUnique(contract *Contract) error { func checkRoleKindsHaveActions(contract *Contract) error { for _, role := range contract.Roles { - for _, scope := range role.Scopes { - if len(scope.Actions) == 0 { - return fmt.Errorf("%w: role %q grants resource kind %q no action", ErrInvalid, role.Name, scope.ResourceKind) + for _, list := range role.scopeLists() { + for _, scope := range list.scopes { + if len(scope.Actions) == 0 { + return fmt.Errorf("%w: role %q grants %sresource kind %q no action", ErrInvalid, role.Name, list.label, scope.ResourceKind) + } } } } @@ -1161,10 +1187,12 @@ func (contract *Contract) grantable() map[string][]string { func checkRolesWithinRequest(contract *Contract) error { grantable := contract.grantable() for _, role := range contract.Roles { - for _, scope := range role.Scopes { - for _, action := range scope.Actions { - if !slices.Contains(grantable[scope.ResourceKind], action) { - return fmt.Errorf("%w: role %q grants %s:%s, which is neither within the module's own scope ceilings nor a scope one of its bindings spells out for invoke or lookup (a role grants what a binding's calls require by naming the binding in its bindings)", ErrInvalid, role.Name, scope.ResourceKind, action) + for _, list := range role.scopeLists() { + for _, scope := range list.scopes { + for _, action := range scope.Actions { + if !slices.Contains(grantable[scope.ResourceKind], action) { + return fmt.Errorf("%w: role %q grants %s%s:%s, which is neither within the module's own scope ceilings nor a scope one of its bindings spells out for invoke or lookup (a role grants what a binding's calls require by naming the binding in its bindings)", ErrInvalid, role.Name, list.label, scope.ResourceKind, action) + } } } } diff --git a/contracts/module/rules_test.go b/contracts/module/rules_test.go index a60c5c2d..0cd92ef6 100644 --- a/contracts/module/rules_test.go +++ b/contracts/module/rules_test.go @@ -114,8 +114,11 @@ func TestEveryFixtureReachesItsOutcome(t *testing.T) { "role-action-not-a-name", "role-beyond-consumption", "role-beyond-own-ceiling", "role-binding-granted-twice", "role-binding-headless", "role-binding-undeclared-operation", "role-binding-unknown", "role-declared-twice", "role-grants-nothing", "role-headless-permission", + "role-installation-owned-beyond-consumption", "role-installation-owned-ids", + "role-installation-owned-kind-named-twice", "role-installation-owned-kind-without-action", "role-kind-named-twice", "role-kind-not-a-name", "role-kind-without-action", - "role-not-a-name", "role-without-installer", "roles", "roles-grant-bindings", "schema-omitted", + "role-not-a-name", "role-without-installer", "roles", "roles-grant-bindings", + "roles-installation-owned", "schema-omitted", "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", diff --git a/contracts/module/testdata/role-installation-owned-beyond-consumption.yaml b/contracts/module/testdata/role-installation-owned-beyond-consumption.yaml new file mode 100644 index 00000000..dc4115e8 --- /dev/null +++ b/contracts/module/testdata/role-installation-owned-beyond-consumption.yaml @@ -0,0 +1,27 @@ +# An installation-owned scope beyond what the unit serves or a binding asks +# for: held to the same ceiling as scopes. +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: + - resource_kind: assistant.tasks + actions: [read, write] +roles: + - name: installer + scopes: + - resource_kind: assistant.tasks + actions: [read, write] + - name: viewer + installation_owned_scopes: + - resource_kind: documents.collection + actions: [read, write] +bindings: + - id: collections + operations: [invoke] + audience: {from: assistant/collections-audience} + scope_ceiling: + invoke: + - resource_kind: documents.collection + actions: [read] +destinations: [] diff --git a/contracts/module/testdata/role-installation-owned-ids.yaml b/contracts/module/testdata/role-installation-owned-ids.yaml new file mode 100644 index 00000000..b1d0d122 --- /dev/null +++ b/contracts/module/testdata/role-installation-owned-ids.yaml @@ -0,0 +1,28 @@ +# An installation-owned scope naming resource ids: its ids are each +# installation's own, so the scope has no field to name them in. +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: + - resource_kind: assistant.tasks + actions: [read, write] +roles: + - name: installer + scopes: + - resource_kind: assistant.tasks + actions: [read, write] + - name: viewer + installation_owned_scopes: + - resource_kind: documents.collection + actions: [read] + resource_ids: [collection-a] +bindings: + - id: collections + operations: [invoke] + audience: {from: assistant/collections-audience} + scope_ceiling: + invoke: + - resource_kind: documents.collection + actions: [read] +destinations: [] diff --git a/contracts/module/testdata/role-installation-owned-kind-named-twice.yaml b/contracts/module/testdata/role-installation-owned-kind-named-twice.yaml new file mode 100644 index 00000000..f9f2260c --- /dev/null +++ b/contracts/module/testdata/role-installation-owned-kind-named-twice.yaml @@ -0,0 +1,28 @@ +# An installation-owned scope list naming one kind twice. +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: + - resource_kind: assistant.tasks + actions: [read, write] +roles: + - name: installer + scopes: + - resource_kind: assistant.tasks + actions: [read, write] + - name: viewer + installation_owned_scopes: + - resource_kind: documents.collection + actions: [read] + - resource_kind: documents.collection + actions: [read] +bindings: + - id: collections + operations: [invoke] + audience: {from: assistant/collections-audience} + scope_ceiling: + invoke: + - resource_kind: documents.collection + actions: [read] +destinations: [] diff --git a/contracts/module/testdata/role-installation-owned-kind-without-action.yaml b/contracts/module/testdata/role-installation-owned-kind-without-action.yaml new file mode 100644 index 00000000..9a541c15 --- /dev/null +++ b/contracts/module/testdata/role-installation-owned-kind-without-action.yaml @@ -0,0 +1,26 @@ +# An installation-owned scope granting its kind no action. +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: + - resource_kind: assistant.tasks + actions: [read, write] +roles: + - name: installer + scopes: + - resource_kind: assistant.tasks + actions: [read, write] + - name: viewer + installation_owned_scopes: + - resource_kind: documents.collection + actions: [] +bindings: + - id: collections + operations: [invoke] + audience: {from: assistant/collections-audience} + scope_ceiling: + invoke: + - resource_kind: documents.collection + actions: [read] +destinations: [] diff --git a/contracts/module/testdata/roles-installation-owned.yaml b/contracts/module/testdata/roles-installation-owned.yaml new file mode 100644 index 00000000..e8d9a4ae --- /dev/null +++ b/contracts/module/testdata/roles-installation-owned.yaml @@ -0,0 +1,45 @@ +# A solution's roles granting scopes only on what the installation they are +# held on owns: the viewer reads the collections its own installation created +# or attached, never every collection in the tenant. An installation-owned +# scope names a kind and actions, never ids, and is held to the same ceiling +# as scopes. A role may grant through installation-owned scopes alone, and a +# kind may appear in both lists, each with its own actions. +schema: codefly/module-contract/v1 +principal: assistant +namespaces: [] +queues: [] +scope_ceilings: + - resource_kind: assistant.tasks + actions: [read, write] +roles: + - name: installer + scopes: + - resource_kind: assistant.tasks + actions: [read, write] + - resource_kind: documents.collection + actions: [read] + installation_owned_scopes: + - resource_kind: documents.collection + actions: [read, write] + - name: viewer + scopes: + - resource_kind: assistant.tasks + actions: [read] + installation_owned_scopes: + - resource_kind: documents.collection + actions: [read] + - name: auditor + installation_owned_scopes: + - resource_kind: assistant.tasks + actions: [read] + - resource_kind: documents.collection + actions: [read] +bindings: + - id: collections + operations: [invoke] + audience: {from: assistant/collections-audience} + scope_ceiling: + invoke: + - resource_kind: documents.collection + actions: [read, write] +destinations: [] diff --git a/docs/module-contract.md b/docs/module-contract.md index 0e28ea0e..7fe37eee 100644 --- a/docs/module-contract.md +++ b/docs/module-contract.md @@ -254,6 +254,33 @@ scopes out belongs to a binding with one audience, never a list). A scope two audiences underwrite is refused under `ErrAmbiguousSlot`, naming both; the role then names the binding it means in `bindings`. +**A role may grant a scope only on what the installation owns.** +`installation_owned_scopes` lists scopes the role grants only on the resources +the installation it is held on owns — those created or attached through it — +spelled like `scopes`: + +```yaml +roles: + - name: viewer + scopes: + - {resource_kind: wiki.page, actions: [read]} + installation_owned_scopes: + - {resource_kind: documents.collection, actions: [read]} +``` + +Each names a kind and actions and never resource ids, since ids are each +installation's own: the entry has no field for them, so one naming +`resource_ids` is refused as an unknown field. It is held to every rule a role +scope is — kind and action grammar, each kind once in the list, at least one +action, within the unit's own ceilings or a scope a binding spells out for +`invoke` or `lookup` — and refused under the same rule names, its message +saying "installation-owned". A role may grant through it alone, and a kind may +appear in both lists, each with its own actions. `Resolved.Roles` addresses it +exactly as a literal scope, in `InstallationOwnedGrants`. The authority +resolves it per installation into the same scope limited to the ids that +installation owns of the kind; a kind it owns nothing of grants nothing, never +the whole kind. + One of the roles is `installer`, which whoever installs the unit holds; each role is a lowercase name declared once and grants at least one kind or binding, each kind named once with at least one action, held to the same kind and action @@ -400,7 +427,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()` (125 documents) ships +`modulecontract.Fixtures()` (130 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