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
20 changes: 17 additions & 3 deletions libraries/authority-core/go/authority/check.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,8 @@ type Grant struct {

// ListResources answers which resources of kind a checked capability may act
// on with action, decided now: a person's through a solution are its roles on
// the principal's data, a person's own their organization roles' matchers,
// the principal's data, a person's at a module page declaring roles that
// page's roles granted them, a person's own their organization roles' matchers,
// cut to any ids the capability names; a module's or an installation's own
// work is bounded by the capability alone.
func (s *Service) ListResources(ctx context.Context, token, audience, kind, action string) ([]Grant, error) {
Expand Down Expand Up @@ -132,9 +133,22 @@ func (s *Service) ListResources(ctx context.Context, token, audience, kind, acti
if err != nil {
return err
}
if bounded {
at, err := tx.Module(wc.GetAudience())
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return err
}
switch {
case bounded:
rows = scope.Intersect(effective, ceiling)
} else {
case err == nil && decide.RoleGated(at):
// A module page declaring roles answers from its roles, as
// issuance does, never from organization roles.
h, err := held(tx, p, at, effective)
if err != nil {
return err
}
rows = scope.Intersect(effective, h)
default:
roles, err := orgRoles(tx, p)
if err != nil {
return err
Expand Down
12 changes: 7 additions & 5 deletions libraries/authority-core/go/authority/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,13 @@ type Issued struct {
}

// held is what a person holds at an audience: at a solution, the scopes of the
// roles granted them on its installation, owned by its principal; at a
// module, their organization roles within what it serves, resource-scoped
// roles holding the ids their matchers select of named (decide.OrgScopes).
// roles granted them on its installation, owned by its principal; at a module
// page declaring roles, those of its roles granted them on its installation,
// within what it serves; at any other module, their organization roles within
// what it serves, resource-scoped roles holding the ids their matchers select
// of named (decide.OrgScopes).
func held(tx Tx, p domain.Person, audience domain.Module, named Scopes) (Scopes, error) {
if audience.Kind == domain.KindSolution {
if decide.RoleGated(audience) {
inst, err := installationOf(tx, p.Organization, audience.Name)
if errors.Is(err, domain.ErrNotFound) {
return nil, nil
Expand Down Expand Up @@ -229,7 +231,7 @@ func (s *Service) IssueForPerson(ctx context.Context, who Person, r IssueRequest
if audience.Kind == domain.KindSolution && !audience.OwnerAware {
return decide.OwnerAware(audience, decide.Scopes{{ResourceKind: audience.Name, ResourceOwnerId: inst.PrincipalID}})
}
if audience.Kind == domain.KindSolution && len(heldHere) == 0 {
if decide.RoleGated(audience) && len(heldHere) == 0 {
return domain.Refuse("%s holds no role on %s", p.ID, audience.Name)
}
if r.Binding != "" {
Expand Down
109 changes: 109 additions & 0 deletions libraries/authority-core/go/authority/module_page_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package authority_test

import (
"testing"

"github.com/codefly-dev/authority/libraries/authority-core/go/authority"
"github.com/codefly-dev/authority/libraries/authority-core/go/domain"
resources "github.com/codefly-dev/authority/libraries/resources/go"
basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
)

// pageModule is a module declaring a page; with roles, what a person holds at
// it is the roles granted them on its organization's installation.
func pageModule(generation uint64, roles bool) domain.Module {
m := domain.Module{Name: "pages", Principal: "pages", Kind: domain.KindModule, Generation: generation, Page: true,
ResourceKinds: []*basev0.WorkScopeV1{sc("pages.note", "read", "write")}}
if roles {
m.Roles = map[string][]*basev0.WorkScopeV1{
domain.InstallerRole: {sc("pages.note", "read", "write")},
"viewer": {sc("pages.note", "read")},
}
}
return m
}

// A module page's role holder is entitled to exactly the role; another member,
// one whose organization role covers the kind included, is not; an undeclared
// role is refused; a revoke ends it. Redelivered without roles, the module is
// the organization's again: the role's capabilities end and organization
// roles decide, as for any module, until it declares roles again.
func TestAModulePageRoleEntitlesItsHolderAlone(t *testing.T) {
h := newHarness(t)
h.store.PutOrgRole(domain.OrgRole{Organization: acme, Name: "page-reader", Scopes: []domain.RoleScope{{Kind: "pages.note", Actions: []string{"read"}}}})
h.store.PutPerson(domain.Person{ID: "ines", Organization: acme, Roles: []string{"page-reader"}})
if _, err := h.svc.ApplyModule(h.ctx, pageModule(1, true)); err != nil {
t.Fatal(err)
}

h.grant("pages", "viewer", "maya")
viewer := h.must(h.root("maya", "pages"))
exactly(t, viewer, "pages.note", "", []string{"read"})
if err := h.check(viewer); err != nil {
t.Fatal(err)
}
// What she may list is her role's, not her organization roles': an
// organization role naming one note does not narrow it, and no
// organization role is needed for it.
listed, err := h.svc.ListResources(h.ctx, viewer.Token, "pages", "pages.note", "read")
if err != nil || len(listed) != 1 || !listed[0].All || listed[0].Owner != "" {
t.Fatalf("the viewer lists %v, %v", listed, err)
}
h.store.PutOrgRole(domain.OrgRole{Organization: acme, Name: "one-note", Scopes: []domain.RoleScope{{Kind: "pages.note", Actions: []string{"read"}, Resources: []resources.Grant{{ID: "x"}}}}})
h.store.PutPerson(domain.Person{ID: "maya", Organization: acme, Roles: []string{"one-note"}})
if listed, err = h.svc.ListResources(h.ctx, viewer.Token, "pages", "pages.note", "read"); err != nil || len(listed) != 1 || !listed[0].All {
t.Fatalf("an organization role narrowed the viewer's listing: %v, %v", listed, err)
}
h.store.PutPerson(domain.Person{ID: "maya", Organization: acme})
_, err = h.root("maya", "pages", sc("pages.note", "write"))
refused(t, err, "beyond")
heldIs(t, "maya", h.heldBy(h.person("maya"), acme), "pages:[viewer]")
for _, other := range []string{"remi", "ines", "alex"} {
_, err := h.root(other, "pages")
code(t, err, domain.Denied)
refused(t, err, other+" holds no role on pages")
}
_, err = h.svc.GrantRole(h.ctx, h.admin(acme), authority.RoleRequest{Organization: acme, InstallationID: h.inst(acme, "pages").ID, Role: "editor", PersonID: "remi"})
refused(t, err, `pages declares no role "editor"`)

h.revoke("pages", "viewer", "maya", "")
_, err = h.root("maya", "pages")
refused(t, err, "maya holds no role on pages")
if _, err := h.svc.ListResources(h.ctx, viewer.Token, "pages", "pages.note", "read"); err == nil {
t.Fatal("a revoked viewer still lists the page's notes")
}
h.grant("pages", "viewer", "maya")
viewer = h.must(h.root("maya", "pages"))

if _, err := h.svc.ApplyModule(h.ctx, pageModule(2, false)); err != nil {
t.Fatal(err)
}
if err := h.check(viewer); err == nil {
t.Fatal("a role's capability outlived the module withdrawing its roles")
}
reader := h.must(h.root("ines", "pages"))
exactly(t, reader, "pages.note", "", []string{"read"})
_, err = h.root("maya", "pages")
refused(t, err, "holds nothing")
_, err = h.svc.GrantRole(h.ctx, h.admin(acme), authority.RoleRequest{Organization: acme, InstallationID: h.inst(acme, "pages").ID, Role: "viewer", PersonID: "maya"})
refused(t, err, `pages declares no role "viewer"`)

// Declaring roles again ends what the organization's roles issued.
if _, err := h.svc.ApplyModule(h.ctx, pageModule(3, true)); err != nil {
t.Fatal(err)
}
if err := h.check(reader); err == nil {
t.Fatal("an organization role's capability outlived the module declaring its own roles")
}
}

// A module declaring roles and no page is refused, and nothing of it composes.
func TestAModuleWithoutAPageDeclaresNoRoles(t *testing.T) {
h := newHarness(t)
headless := pageModule(1, true)
headless.Page = false
_, err := h.svc.ApplyModule(h.ctx, headless)
refused(t, err, "declares roles and no page")
_, err = h.root("maya", "pages")
code(t, err, domain.NotFound)
}
7 changes: 6 additions & 1 deletion libraries/authority-core/go/decide/decide.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ func HeldRoles(p domain.Person, inst domain.Installation, grants []domain.RoleGr
// RoleScopes is what roles grant on a solution's installation: the roles'
// scopes, each selecting only the resources the installation's principal
// owns. An installation with no principal grants nothing: its scopes would
// select every owner's resources.
// select every owner's resources. A module page's installation is its
// organization's and has no principal: its roles' scopes select no owner,
// as the organization roles they stand in for select none.
func RoleScopes(solution domain.Module, inst domain.Installation, roles []string) (Scopes, error) {
var literal, attached Scopes
for _, role := range roles {
Expand All @@ -138,6 +140,9 @@ func RoleScopes(solution domain.Module, inst domain.Installation, roles []string
}
}
}
if solution.Kind != domain.KindSolution {
return scope.Normalize(literal), nil
}
if len(literal) > 0 {
if inst.PrincipalID == "" {
return nil, domain.Refuse("installation %s has no principal, so its roles grant nothing", inst.ID)
Expand Down
31 changes: 21 additions & 10 deletions libraries/authority-core/go/decide/manifest.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import (

// ValidateModule holds a delivered module to its contract: a principal, a
// generation, bindings whose lookup and headless scopes stay within invoke;
// a module declares no roles; a solution declares installer, and every role
// stays within what it serves or consumes through invoke/lookup bindings.
// a module without a page declares no roles; a solution, or a module page
// that declares roles, declares installer, and every role stays within what
// it serves or consumes through invoke/lookup bindings.
func ValidateModule(m domain.Module) error {
if err := validateModule(m); err != nil {
return err
Expand Down Expand Up @@ -56,14 +57,16 @@ func validateModule(m domain.Module) error {
if len(m.OwnedRoles) > 0 {
return domain.Refuse("module %q declares installation-owned roles; only a solution declares them", m.Name)
}
if len(m.Roles) > 0 {
return domain.Refuse("module %q declares roles; a module's access is the organization's roles", m.Name)
if len(m.Roles) == 0 {
return nil
}
if !m.Page {
return domain.Refuse("module %q declares roles and no page; a module's access is the organization's roles unless it declares a page", m.Name)
}
return nil
}
if _, ok := m.Roles[domain.InstallerRole]; !ok {
return &domain.Refusal{Code: domain.Denied, Cause: domain.ErrNoInstallerRole,
Reason: fmt.Sprintf("%s declares %d role(s) and none is %q: a solution declares an installer role", m.Name, len(m.Roles), domain.InstallerRole)}
Reason: fmt.Sprintf("%s declares %d role(s) and none is %q: a solution, or a module page declaring roles, declares an installer role", m.Name, len(m.Roles), domain.InstallerRole)}
}
grantable := slices.Clone(m.ResourceKinds)
for _, b := range m.Bindings {
Expand Down Expand Up @@ -98,9 +101,17 @@ func validateModule(m domain.Module) error {
return nil
}

// RoleGated reports whether what a person holds at m is the roles granted on
// its installation: a solution's, or a module page's that declares roles.
// Otherwise it is their organization roles within what m serves.
func RoleGated(m domain.Module) bool {
return m.Kind == domain.KindSolution || len(m.Roles) > 0
}

// Narrowed reports whether next narrows what previous granted: a binding
// removed, readdressed or cut, owner-aware checking withdrawn, or any of a
// solution's roles cut. A narrowing invalidates capabilities already issued.
// removed, readdressed or cut, owner-aware checking withdrawn, any of its
// roles cut, or a module moved between organization roles and its own. A
// narrowing invalidates capabilities already issued.
func Narrowed(previous, next domain.Module) bool {
if previous.OwnerAware && !next.OwnerAware {
return true
Expand All @@ -112,8 +123,8 @@ func Narrowed(previous, next domain.Module) bool {
return true
}
}
if previous.Kind != domain.KindSolution {
return false
if RoleGated(previous) != RoleGated(next) {
return true
}
for name, scopes := range previous.OwnedRoles {
if !scope.Attenuates(scopes, next.OwnedRoles[name]) {
Expand Down
86 changes: 86 additions & 0 deletions libraries/authority-core/go/decide/module_page_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package decide_test

import (
"errors"
"testing"

"github.com/codefly-dev/authority/libraries/authority-core/go/decide"
"github.com/codefly-dev/authority/libraries/authority-core/go/domain"
"github.com/codefly-dev/authority/libraries/authority-core/go/scope"
basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
)

// modulePage is a module declaring a page and roles on what it serves.
func modulePage() domain.Module {
return domain.Module{Name: "pages", Principal: "pages", Kind: domain.KindModule, Generation: 1, Page: true,
ResourceKinds: []*basev0.WorkScopeV1{sc("pages.note", "read", "write")},
Roles: map[string][]*basev0.WorkScopeV1{
domain.InstallerRole: {sc("pages.note", "read", "write")},
"viewer": {sc("pages.note", "read")},
}}
}

// A module that declares a page declares roles as a solution does, held to
// the same rules; a module without a page declares none, and a module page
// declaring none keeps the organization's roles.
func TestAModulePageDeclaresRolesLikeASolution(t *testing.T) {
if err := decide.ValidateModule(modulePage()); err != nil {
t.Fatalf("a module page declaring installer and viewer: %v", err)
}
headless := modulePage()
headless.Page = false
refused(t, decide.ValidateModule(headless), domain.Denied, "declares roles and no page")
noInstaller := modulePage()
delete(noInstaller.Roles, domain.InstallerRole)
if err := decide.ValidateModule(noInstaller); !errors.Is(err, domain.ErrNoInstallerRole) {
t.Fatalf("a module page with no installer role: %v", err)
}
beyond := modulePage()
beyond.Roles["viewer"] = []*basev0.WorkScopeV1{sc("pages.note", "delete")}
refused(t, decide.ValidateModule(beyond), domain.Denied, "grants beyond")
owner := modulePage()
owner.OwnedRoles = map[string][]*basev0.WorkScopeV1{"viewer": {sc("pages.note", "read")}}
refused(t, decide.ValidateModule(owner), domain.Denied, "only a solution")
bare := modulePage()
bare.Roles = nil
if err := decide.ValidateModule(bare); err != nil || decide.RoleGated(bare) {
t.Fatalf("a module page declaring no roles keeps the organization's: %v, gated %v", err, decide.RoleGated(bare))
}
if !decide.RoleGated(modulePage()) {
t.Fatal("a module page declaring roles is not decided by its roles")
}
}

// A module page's installation has no principal: its roles select no owner.
func TestAModulePageRoleSelectsNoOwner(t *testing.T) {
inst := domain.Installation{ID: "acme-pages", Organization: "acme"}
got, err := decide.RoleScopes(modulePage(), inst, []string{"viewer"})
if err != nil || !scope.Same(got, []*basev0.WorkScopeV1{sc("pages.note", "read")}) {
t.Fatalf("viewer holds %v, %v", got, err)
}
if got, err := decide.RoleScopes(modulePage(), inst, []string{"undeclared"}); err != nil || len(got) != 0 {
t.Fatalf("an undeclared role holds %v, %v", got, err)
}
}

// Moving a module between the organization's roles and its own, either way,
// and cutting one of its roles are narrowings.
func TestAModulePageRoleChangeIsANarrowing(t *testing.T) {
page, bare, cut := modulePage(), modulePage(), modulePage()
bare.Roles = nil
cut.Roles = map[string][]*basev0.WorkScopeV1{domain.InstallerRole: page.Roles[domain.InstallerRole], "viewer": nil}
for name, c := range map[string]struct {
previous, next domain.Module
want bool
}{
"roles declared": {bare, page, true},
"roles withdrawn": {page, bare, true},
"a role cut": {page, cut, true},
"unchanged": {page, modulePage(), false},
"a bare redeliver": {bare, bare, false},
} {
if got := decide.Narrowed(c.previous, c.next); got != c.want {
t.Errorf("%s: narrowed %v, want %v", name, got, c.want)
}
}
}
22 changes: 12 additions & 10 deletions libraries/authority-core/go/delivered/delivered.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ type Method struct {
AnyCheckedCaller bool
}

// Role is one of a solution's roles: its scopes, and those it declares
// installation-owned (resolved to the ids each installation owns).
// Role is one of a solution's or a module page's roles: its scopes, and those
// it declares installation-owned (resolved to the ids each installation owns).
type Role struct {
Name string
Scopes, InstallationOwned Scopes
Expand Down Expand Up @@ -149,9 +149,9 @@ func methods(in []Method) (map[string]domain.MethodPolicy, error) {
return out, nil
}

// roles reads the roles a solution declares, by name, and the scopes of each
// it declares installation-owned. Core holds them to the role contract when
// the module is applied (decide.ValidateModule).
// roles reads the roles a solution or a module page declares, by name, and
// the scopes of each it declares installation-owned. Core holds them to the
// role contract when the module is applied (decide.ValidateModule).
func roles(in []Role) (map[string]Scopes, map[string]Scopes, error) {
owned := map[string]Scopes{}
if len(in) == 0 {
Expand Down Expand Up @@ -203,12 +203,14 @@ func Ceiled(m domain.Module, ceilings Scopes) (domain.Module, error) {
}

// FromCore reads core's module manifest as the authority's module. Core's
// manifest carries no roles; a solution's arrive in the document delivered
// with it. The manifest is the ceiling here: a resource kind declared with no
// actions serves whatever the roles grant on it, a binding's operations are
// its scopes, each "<resource kind>:<action>" as core's manifest writes them.
// manifest carries no roles; a solution's or a module page's arrive in the
// document delivered with it, and a manifest declaring a frontend is a page.
// The manifest is the ceiling here: a resource kind declared with no actions
// serves whatever the roles grant on it, a binding's operations are its
// scopes, each "<resource kind>:<action>" as core's manifest writes them.
func FromCore(in *solutionhost.ModuleManifest) domain.Module {
m := domain.Module{Name: in.Alias, Principal: in.Alias, Kind: string(in.Kind), Generation: in.Generation, Bindings: map[string]domain.Binding{}}
m := domain.Module{Name: in.Alias, Principal: in.Alias, Kind: string(in.Kind), Generation: in.Generation, Bindings: map[string]domain.Binding{},
Page: in.Frontend != nil}
if in.Authority == nil {
return m
}
Expand Down
Loading
Loading