Skip to content
Merged
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
53 changes: 42 additions & 11 deletions contracts/module/contract.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -798,6 +808,11 @@ type ResolvedRole struct {
// Grants are, per audience, the scopes "<resource kind>:<action>" the
// role grants there. Sorted, each once.
Grants map[string][]string
// InstallationOwnedGrants are, per audience, the scopes
// "<resource kind>:<action>" 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
Expand Down Expand Up @@ -1061,36 +1076,52 @@ 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 {
grants[audience] = append(grants[audience], scope.ResourceKind+":"+action)
}
}
}
}
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 "<resource kind>:<action>", that
// are not within the module's own ceilings, in the order given.
func beyondOwnCeilings(own map[string][]string, scopes []string) []string {
Expand Down
5 changes: 5 additions & 0 deletions contracts/module/fixtures.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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 {
Expand Down
103 changes: 103 additions & 0 deletions contracts/module/roles_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
72 changes: 50 additions & 22 deletions contracts/module/rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
Expand All @@ -1032,33 +1049,42 @@ 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
}

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)
}
}
}
}
Expand Down Expand Up @@ -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)
}
}
}
}
Expand Down
5 changes: 4 additions & 1 deletion contracts/module/rules_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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: []
Loading
Loading