From 9f587b0cb5a9a0cd52012df147c9661eda8961df Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:46:47 -0400 Subject: [PATCH 1/3] feat(core): a module that declares a page declares roles like a solution A module whose signed core manifest declares a frontend (a page) may declare roles in its authority document, held to the solution's rules: an installer role, every role within what it serves or consumes. What a person holds at such a module is the roles granted them on its organization's installation, owner-blind (a module installation has no principal); organization roles no longer apply there. A module declaring no roles keeps the organization's roles; a module declaring roles and no page is refused. Moving a module between organization roles and its own is a narrowing. Co-Authored-By: Claude Fable 5.1 --- .../authority-core/go/authority/issue.go | 12 ++- .../go/authority/module_page_test.go | 92 +++++++++++++++++++ libraries/authority-core/go/decide/decide.go | 7 +- .../authority-core/go/decide/manifest.go | 31 +++++-- .../go/decide/module_page_test.go | 86 +++++++++++++++++ .../authority-core/go/delivered/delivered.go | 22 +++-- .../go/delivered/delivered_test.go | 5 +- libraries/authority-core/go/domain/domain.go | 16 +++- .../code/pkg/business/state/permissions.go | 2 +- 9 files changed, 241 insertions(+), 32 deletions(-) create mode 100644 libraries/authority-core/go/authority/module_page_test.go create mode 100644 libraries/authority-core/go/decide/module_page_test.go diff --git a/libraries/authority-core/go/authority/issue.go b/libraries/authority-core/go/authority/issue.go index 5b166304..f4576265 100644 --- a/libraries/authority-core/go/authority/issue.go +++ b/libraries/authority-core/go/authority/issue.go @@ -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 @@ -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 != "" { diff --git a/libraries/authority-core/go/authority/module_page_test.go b/libraries/authority-core/go/authority/module_page_test.go new file mode 100644 index 00000000..94ab19bb --- /dev/null +++ b/libraries/authority-core/go/authority/module_page_test.go @@ -0,0 +1,92 @@ +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" + 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) + } + _, 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") + 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) +} diff --git a/libraries/authority-core/go/decide/decide.go b/libraries/authority-core/go/decide/decide.go index b0ea4dd3..24d16f77 100644 --- a/libraries/authority-core/go/decide/decide.go +++ b/libraries/authority-core/go/decide/decide.go @@ -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 { @@ -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) diff --git a/libraries/authority-core/go/decide/manifest.go b/libraries/authority-core/go/decide/manifest.go index 828c5e60..3324ee26 100644 --- a/libraries/authority-core/go/decide/manifest.go +++ b/libraries/authority-core/go/decide/manifest.go @@ -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 @@ -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 { @@ -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 @@ -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]) { diff --git a/libraries/authority-core/go/decide/module_page_test.go b/libraries/authority-core/go/decide/module_page_test.go new file mode 100644 index 00000000..f36ce912 --- /dev/null +++ b/libraries/authority-core/go/decide/module_page_test.go @@ -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) + } + } +} diff --git a/libraries/authority-core/go/delivered/delivered.go b/libraries/authority-core/go/delivered/delivered.go index 921cd1fe..801b5052 100644 --- a/libraries/authority-core/go/delivered/delivered.go +++ b/libraries/authority-core/go/delivered/delivered.go @@ -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 @@ -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 { @@ -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 ":" 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 ":" 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 } diff --git a/libraries/authority-core/go/delivered/delivered_test.go b/libraries/authority-core/go/delivered/delivered_test.go index 911d3e24..fff9f424 100644 --- a/libraries/authority-core/go/delivered/delivered_test.go +++ b/libraries/authority-core/go/delivered/delivered_test.go @@ -49,9 +49,12 @@ func TestCoreBindingOperationsAreReadAsScopesPerOperation(t *testing.T) { !scope.Same(b.HeadlessScopes, scope.Normalize([]*basev0.WorkScopeV1{ws("modelservice.profiles", "read")})) || !scope.Same(b.LookupScopes, b.HeadlessScopes) { t.Fatalf("binding %+v", b) } - if bare := delivered.FromCore(&solutionhost.ModuleManifest{Alias: "bare", Generation: 1}); bare.Principal != "bare" || len(bare.Bindings) != 0 { + if bare := delivered.FromCore(&solutionhost.ModuleManifest{Alias: "bare", Generation: 1}); bare.Principal != "bare" || len(bare.Bindings) != 0 || bare.Page { t.Fatalf("a manifest without authority composes its alias alone: %+v", bare) } + if page := delivered.FromCore(&solutionhost.ModuleManifest{Alias: "page", Generation: 1, Frontend: &solutionhost.Frontend{Title: "Page", ExposedModule: "./Page", HostContract: 1}}); !page.Page { + t.Fatal("a manifest declaring a frontend is not read as a page") + } } func TestScopeCeilingsGiveTheModulesKindsTheirActions(t *testing.T) { diff --git a/libraries/authority-core/go/domain/domain.go b/libraries/authority-core/go/domain/domain.go index cb141f10..9f208bbf 100644 --- a/libraries/authority-core/go/domain/domain.go +++ b/libraries/authority-core/go/domain/domain.go @@ -54,15 +54,22 @@ type Binding struct { // Module is what a delivery composed for one module or solution: its // principal (the audience every capability to it names), what it serves, its -// bindings, a solution's roles, and whether it checks resource owners. +// bindings, the roles of a solution or of a module page, and whether it +// checks resource owners. type Module struct { Name, Principal, Kind string Generation uint64 // StandIn is a module a fixture declares until its own delivery replaces it. - StandIn bool + StandIn bool + // Page: core's signed manifest declares a frontend, the page a person + // uses. Only a module with a page may declare roles. It is read when the + // module is applied; decisions read Roles, so it is not part of the + // review digest and a store need not keep it. + Page bool `json:"-"` ResourceKinds []*basev0.WorkScopeV1 Bindings map[string]Binding - // Roles are a solution's roles: levels on the data its principal owns. + // Roles are a solution's roles, levels on the data its principal owns, or + // a module page's, levels on what it serves at its organization. Roles map[string][]*basev0.WorkScopeV1 // OwnedRoles resolve only to the exact ids attached to this installation. OwnedRoles map[string][]*basev0.WorkScopeV1 @@ -198,7 +205,8 @@ func (r *Refusal) Error() string { return r.Reason } // Unwrap is the refusal's sentinel, if any. func (r *Refusal) Unwrap() error { return r.Cause } -// ErrNoInstallerRole names a solution that declares no installer role. +// ErrNoInstallerRole names a solution, or a module page declaring roles, that +// declares no installer role. var ErrNoInstallerRole = errors.New("a solution declares an installer role") // ErrAlreadyInstalled names an install of a target its organization holds diff --git a/modules/authority/services/local/code/pkg/business/state/permissions.go b/modules/authority/services/local/code/pkg/business/state/permissions.go index 7ccab5e5..179d136d 100644 --- a/modules/authority/services/local/code/pkg/business/state/permissions.go +++ b/modules/authority/services/local/code/pkg/business/state/permissions.go @@ -132,7 +132,7 @@ func (s *State) PermissionReport() []string { } } roles := map[string][]*basev0.WorkScopeV1{} - if m.Kind == domain.KindSolution { + if decide.RoleGated(m) { for name, scopes := range m.Roles { // Statically an installation-owned scope covers its kind; which // ids is each installation's, decided at issue. From 2e226051bdc75ff0b46894f3995e52f4d66ef630 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:46:47 -0400 Subject: [PATCH 2/3] test(conformance): a module page's roles entitle their holder alone The suite delivers a module declaring a page with installer and viewer roles: the viewer's holder is issued exactly its scope, another member holds nothing whatever the organization grants, an undeclared role is refused, a module declaring roles and no page is refused, and redelivered without roles the organization's roles decide again. The local testkit declares the two delivery workloads. Co-Authored-By: Claude Fable 5.1 --- libraries/conformance/go/README.md | 1 + libraries/conformance/go/conformance.go | 8 ++ libraries/conformance/go/module_page_roles.go | 101 ++++++++++++++++++ .../code/pkg/business/testkit/testkit.go | 2 +- 4 files changed, 111 insertions(+), 1 deletion(-) create mode 100644 libraries/conformance/go/module_page_roles.go diff --git a/libraries/conformance/go/README.md b/libraries/conformance/go/README.md index 120301de..a507f885 100644 --- a/libraries/conformance/go/README.md +++ b/libraries/conformance/go/README.md @@ -26,6 +26,7 @@ providing different labels does not remove an obligation. | Enduring narrowing through two hops | `ChildScopes` and `ParentLimits`: stored delegations retain complete ceilings; parent action/resource limits and entirely dropped kinds remain capped across target changes. Multiple action/resource rows must not become a cross product. | | Installation-owned role scopes: a role scope limited to what the installation owns, created through verified installation provenance with `create` on the whole kind, or recorded after the existing `AuthorizeTransfer` policy authorizes the change of owner; admin-only attachment, missing personal admin, unknown current owners, and an id another installation owns are refused by name; a viewer reads its installation's resources only, is refused another's with a named reason, and a narrowed child never widens; a detached resource leaves its owner and may be attached again | `InstallationResources`: two installed solutions, A (the viewer's) and B, each serving its own kind, a store module serving the shared kind, and two existing unowned resources; the suite delivers all three modules | | A solution's roles and permissions are identical on every host | `solutionDeclaration` (no host fixture beyond `UnentitledToken`): the suite delivers `conformance-declared` (an installer, two roles reaching `conformance-declared-store` through its binding, an attached-only role and a role covering no operation) and installs it as `AdminToken`. `ReviewOffer` must equal one golden offer on every host (host ids, the opaque digest and `tools_composed` left out); a grant of each role to `UnentitledToken` issues exactly its declared scopes within the ceiling, every other ceiling action refused; a redelivery narrowing two roles and withdrawing one changes both answers to exactly the new declaration and refuses the capabilities issued before it. A host that does not serve `OfferService` yet names why in `ReviewOfferNotServed`, which skips the offer comparison alone and fails once the host serves it. | +| A module page's roles, never the organization's | `modulePageRoles` (no host fixture beyond `UnentitledToken`, `ViewerToken` and `AdminToken`; the delivery workloads `conformance-page-module` and `conformance-headless-roles`): the suite delivers a module whose core manifest declares a frontend with an installer and a viewer role; the viewer granted to `UnentitledToken` issues exactly its scope, owner-blind; `ViewerToken` holds no role there whatever the organization grants; an undeclared role is refused; a module declaring roles and no page is refused; redelivered without roles, the earlier capability is refused and organization roles decide again. | | Installation identity, installed caller, explicit organization, pending, active and revoked states | `InstalledSolution`, `InstalledCredential`, `Lifecycle` | | Absent installation versus no entitlement | `FailedPrecondition` with the absent-installation reason versus `PermissionDenied` with the holds-nothing reason | | Inventory access | Admin and owner allowed; ordinary member, anonymous and foreign-organization reads refused | diff --git a/libraries/conformance/go/conformance.go b/libraries/conformance/go/conformance.go index f25d7b4f..26f25c0f 100644 --- a/libraries/conformance/go/conformance.go +++ b/libraries/conformance/go/conformance.go @@ -216,6 +216,12 @@ func (c *client) deliverWithMethods(ctx context.Context, f Fixture, alias string // when document names any field, the authority's own document signed by the same // key and bound to the manifest by module, generation and digest. func (c *client) deliver(ctx context.Context, f Fixture, alias, kind string, generation uint64, kinds []string, document map[string]any, skew uint64, bindings ...solutionhost.AuthorityBinding) (protoreflect.Message, error) { + return c.deliverPage(ctx, f, nil, alias, kind, generation, kinds, document, skew, bindings...) +} + +// deliverPage delivers as deliver does, with page as core's manifest's +// frontend: nil delivers a component without a page. +func (c *client) deliverPage(ctx context.Context, f Fixture, page *solutionhost.Frontend, alias, kind string, generation uint64, kinds []string, document map[string]any, skew uint64, bindings ...solutionhost.AuthorityBinding) (protoreflect.Message, error) { if f.SignManifest == nil || f.DeliveryCredential == nil { return nil, fmt.Errorf("composition requires a release signer and delivery workload credential") } @@ -228,6 +234,7 @@ func (c *client) deliver(ctx context.Context, f Fixture, alias, kind string, gen Release: solutionhost.Release{Publisher: "conformance", Name: alias, Version: "0.1.0"}, Workloads: []solutionhost.Workload{{Service: alias, Container: "app"}}, Authority: &solutionhost.Authority{Principal: alias, ResourceKinds: kinds}, + Frontend: page, } if len(bindings) > 0 { manifest.Authority.Bindings = bindings @@ -295,6 +302,7 @@ func Run(t *testing.T, conn grpc.ClientConnInterface, descriptors []byte, f Fixt c.admissionStatus(t, ctx, f) c.solutionRoleCases(t, ctx, f) c.solutionDeclaration(t, ctx, f) + c.modulePageRoles(t, ctx, f) c.installationIdentity(t, ctx, f) c.currentInstallation(t, ctx, f) c.sessionDisplayNames(t, ctx, f) diff --git a/libraries/conformance/go/module_page_roles.go b/libraries/conformance/go/module_page_roles.go new file mode 100644 index 00000000..e7844d4a --- /dev/null +++ b/libraries/conformance/go/module_page_roles.go @@ -0,0 +1,101 @@ +package conformance + +import ( + "context" + "testing" + + "github.com/codefly-dev/core/solutionhost" + "google.golang.org/grpc/codes" + "google.golang.org/protobuf/reflect/protoreflect" +) + +// A module that declares a page declares roles as a solution does: what a +// person holds at it is the roles granted them on its organization's +// installation, never the organization's roles. A module without a page +// declares none, and a module page declaring none keeps the organization's. +// The holder is UnentitledToken (no role, no team); ViewerToken is another +// member, whatever the organization grants them. +const ( + pageModule = "conformance-page-module" + pageModuleNote = pageModule + ".note" + headlessModule = "conformance-headless-roles" + headlessNote = headlessModule + ".note" + pageModuleTitle = "Conformance page" +) + +func (c *client) modulePageRoles(t *testing.T, ctx context.Context, f Fixture) { + t.Run("a module that declares a page declares roles like a solution", func(t *testing.T) { + if f.SignManifest == nil || f.SignAuthority == nil || f.DeliveryCredential == nil || f.AdminToken == "" || f.UnentitledToken == "" || f.ViewerToken == "" { + t.Fatal("required conformance fixture missing: signed delivery, an administrator, a viewer and a member holding no role (UnentitledToken)") + } + page := &solutionhost.Frontend{Title: pageModuleTitle, ExposedModule: "./Page", HostContract: 1} + roles := func(note string) map[string]any { + return map[string]any{"scope_ceilings": []any{scopeAsk(note, []string{"read", "write"}, nil)}, "roles": []any{ + map[string]any{"name": "installer", "scopes": []any{scopeAsk(note, []string{"read", "write"}, nil)}}, + map[string]any{"name": "viewer", "scopes": []any{scopeAsk(note, []string{"read"}, nil)}}, + }} + } + if _, err := c.deliverPage(ctx, f, page, pageModule, "module", 1, []string{pageModuleNote}, roles(pageModuleNote), 0); err != nil { + t.Fatalf("deliver %s with a page and roles: %v", pageModule, err) + } + admin := c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": f.AdminToken}) + adminCtx := as(ctx, admin) + var id string + for _, inst := range listMsgs(c.must(t, adminCtx, "host.installation.v1.InstallationService", "List", map[string]any{"organization_id": f.Organization}), "installations") { + if str(inst, "target") == pageModule && enumName(inst, "status") == "STATUS_ACTIVE" { + id = str(inst, "id") + } + } + if id == "" { + t.Fatalf("%s is not installed at %s once delivered", pageModule, f.Organization) + } + holder := c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": f.UnentitledToken}) + role := func(method, name string) error { + _, err := c.call(adminCtx, "host.installation.v1.InstallationService", method, map[string]any{ + "organization_id": f.Organization, "installation_id": id, "role": name, "person_id": str(holder, "person_id")}) + return err + } + issue := func(sess protoreflect.Message) (protoreflect.Message, error) { + return c.call(as(ctx, sess), "host.authority.v1.AuthorityService", "Issue", map[string]any{"person": map[string]any{}, "audience": pageModule}) + } + if err := role("GrantRole", "viewer"); err != nil { + t.Fatal(err) + } + var held protoreflect.Message + t.Run("the role holder is entitled to exactly the role", func(t *testing.T) { + out, err := issue(holder) + if err != nil { + t.Fatal(err) + } + exactScopeOwnedBy(t, msg(out, "context"), pageModuleNote, []string{"read"}, nil, "") + c.must(t, ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(out, "token"), "audience": pageModule}) + held = out + }) + t.Run("another member holds nothing there, whatever the organization grants them", func(t *testing.T) { + _, err := issue(c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": f.ViewerToken})) + refused(t, err, codes.PermissionDenied, c.noRole()) + }) + t.Run("a role the module does not declare is refused", func(t *testing.T) { + refused(t, role("GrantRole", "editor"), codes.NotFound, `declares no role "editor"`) + }) + t.Run("a module declaring roles and no page is refused", func(t *testing.T) { + _, err := c.deliver(ctx, f, headlessModule, "module", 1, []string{headlessNote}, roles(headlessNote), 0) + refused(t, err, codes.PermissionDenied, "no page") + }) + t.Run("a module page declaring no roles keeps the organization's", func(t *testing.T) { + if _, err := c.deliverPage(ctx, f, page, pageModule, "module", 2, []string{pageModuleNote}, map[string]any{"scope_ceilings": []any{scopeAsk(pageModuleNote, []string{"read", "write"}, nil)}}, 0); err != nil { + t.Fatalf("redeliver %s without roles: %v", pageModule, err) + } + if held != nil { + _, err := c.call(ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(held, "token"), "audience": pageModule}) + refused(t, err, codes.PermissionDenied, "") + } + _, err := issue(holder) + refused(t, err, codes.PermissionDenied, "holds nothing") + refused(t, role("GrantRole", "viewer"), codes.NotFound, `declares no role "viewer"`) + }) + if err := role("RevokeRole", "viewer"); err != nil { + t.Fatal(err) + } + }) +} diff --git a/modules/authority/services/local/code/pkg/business/testkit/testkit.go b/modules/authority/services/local/code/pkg/business/testkit/testkit.go index d38234be..1263deef 100644 --- a/modules/authority/services/local/code/pkg/business/testkit/testkit.go +++ b/modules/authority/services/local/code/pkg/business/testkit/testkit.go @@ -305,7 +305,7 @@ func Bare(t *testing.T) *Kit { // manifests from their own workloads. // robin and assistant are declared for the artifact tests, which compose // them themselves. - for _, module := range []string{"model", "runtime", "records", "org-directory", "documents", "wiki", "proposals", "conformance-probe", "conformance-uninstalled", "conformance-solution", "conformance-methods", "robin", "assistant", "evaluation", "principal-a", "principal-b", "principal-c", "principal-store", "principal-legacy-store", "conformance-owner-a", "conformance-owner-b", "conformance-store", "conformance-declared", "conformance-declared-store"} { + for _, module := range []string{"model", "runtime", "records", "org-directory", "documents", "wiki", "proposals", "conformance-probe", "conformance-uninstalled", "conformance-solution", "conformance-methods", "robin", "assistant", "evaluation", "principal-a", "principal-b", "principal-c", "principal-store", "principal-legacy-store", "conformance-owner-a", "conformance-owner-b", "conformance-store", "conformance-declared", "conformance-declared-store", "conformance-page-module", "conformance-headless-roles"} { launcher.Declare(Workload(module)) } launcher.Declare(HopWorkload()) From 56e4fbeae527a769a2332d0f4d55e311bb27b9a7 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:57:19 -0400 Subject: [PATCH 3/3] fix(core): a module page's roles answer ListResources as they answer issuance ListResources fell back to organization roles for any person's capability not through a solution, so a module page's viewer holding no organization role was checked but listed nothing, and an id-scoped organization role narrowed the listing. A module page declaring roles now answers from the roles granted on its installation, as held() does for issuance. The conformance case asserts the holder's listing, a revoked holder's refusal, and organization roles deciding again once the module withdraws its roles. Co-Authored-By: Claude Fable 5.1 --- .../authority-core/go/authority/check.go | 20 +++++- .../go/authority/module_page_test.go | 19 +++++- libraries/conformance/go/README.md | 2 +- libraries/conformance/go/module_page_roles.go | 61 ++++++++++++++++--- 4 files changed, 90 insertions(+), 12 deletions(-) diff --git a/libraries/authority-core/go/authority/check.go b/libraries/authority-core/go/authority/check.go index 6d594547..8a0b6f77 100644 --- a/libraries/authority-core/go/authority/check.go +++ b/libraries/authority-core/go/authority/check.go @@ -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) { @@ -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 diff --git a/libraries/authority-core/go/authority/module_page_test.go b/libraries/authority-core/go/authority/module_page_test.go index 94ab19bb..718a8f7f 100644 --- a/libraries/authority-core/go/authority/module_page_test.go +++ b/libraries/authority-core/go/authority/module_page_test.go @@ -5,6 +5,7 @@ import ( "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" ) @@ -41,7 +42,20 @@ func TestAModulePageRoleEntitlesItsHolderAlone(t *testing.T) { if err := h.check(viewer); err != nil { t.Fatal(err) } - _, err := h.root("maya", "pages", sc("pages.note", "write")) + // 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"} { @@ -55,6 +69,9 @@ func TestAModulePageRoleEntitlesItsHolderAlone(t *testing.T) { 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")) diff --git a/libraries/conformance/go/README.md b/libraries/conformance/go/README.md index a507f885..db8b69df 100644 --- a/libraries/conformance/go/README.md +++ b/libraries/conformance/go/README.md @@ -26,7 +26,7 @@ providing different labels does not remove an obligation. | Enduring narrowing through two hops | `ChildScopes` and `ParentLimits`: stored delegations retain complete ceilings; parent action/resource limits and entirely dropped kinds remain capped across target changes. Multiple action/resource rows must not become a cross product. | | Installation-owned role scopes: a role scope limited to what the installation owns, created through verified installation provenance with `create` on the whole kind, or recorded after the existing `AuthorizeTransfer` policy authorizes the change of owner; admin-only attachment, missing personal admin, unknown current owners, and an id another installation owns are refused by name; a viewer reads its installation's resources only, is refused another's with a named reason, and a narrowed child never widens; a detached resource leaves its owner and may be attached again | `InstallationResources`: two installed solutions, A (the viewer's) and B, each serving its own kind, a store module serving the shared kind, and two existing unowned resources; the suite delivers all three modules | | A solution's roles and permissions are identical on every host | `solutionDeclaration` (no host fixture beyond `UnentitledToken`): the suite delivers `conformance-declared` (an installer, two roles reaching `conformance-declared-store` through its binding, an attached-only role and a role covering no operation) and installs it as `AdminToken`. `ReviewOffer` must equal one golden offer on every host (host ids, the opaque digest and `tools_composed` left out); a grant of each role to `UnentitledToken` issues exactly its declared scopes within the ceiling, every other ceiling action refused; a redelivery narrowing two roles and withdrawing one changes both answers to exactly the new declaration and refuses the capabilities issued before it. A host that does not serve `OfferService` yet names why in `ReviewOfferNotServed`, which skips the offer comparison alone and fails once the host serves it. | -| A module page's roles, never the organization's | `modulePageRoles` (no host fixture beyond `UnentitledToken`, `ViewerToken` and `AdminToken`; the delivery workloads `conformance-page-module` and `conformance-headless-roles`): the suite delivers a module whose core manifest declares a frontend with an installer and a viewer role; the viewer granted to `UnentitledToken` issues exactly its scope, owner-blind; `ViewerToken` holds no role there whatever the organization grants; an undeclared role is refused; a module declaring roles and no page is refused; redelivered without roles, the earlier capability is refused and organization roles decide again. | +| A module page's roles, never the organization's | `modulePageRoles` (no host fixture beyond `UnentitledToken`, `ViewerToken` and `AdminToken`; the delivery workloads `conformance-page-module` and `conformance-headless-roles`): the suite delivers a module whose core manifest declares a frontend with an installer and a viewer role, serving its own kind and `ReadKind`; the viewer granted to `UnentitledToken` issues exactly its scope, owner-blind, and `ListResources` answers by that role (every resource), nothing once revoked; `ViewerToken` holds no role there, its organization's read on `ReadKind` included; an undeclared role is refused; a module declaring roles and no page is refused; redelivered without roles, the earlier capability is refused and `ViewerToken`'s organization roles decide again (`ReadKind` read). | | Installation identity, installed caller, explicit organization, pending, active and revoked states | `InstalledSolution`, `InstalledCredential`, `Lifecycle` | | Absent installation versus no entitlement | `FailedPrecondition` with the absent-installation reason versus `PermissionDenied` with the holds-nothing reason | | Inventory access | Admin and owner allowed; ordinary member, anonymous and foreign-organization reads refused | diff --git a/libraries/conformance/go/module_page_roles.go b/libraries/conformance/go/module_page_roles.go index e7844d4a..cdad912e 100644 --- a/libraries/conformance/go/module_page_roles.go +++ b/libraries/conformance/go/module_page_roles.go @@ -14,7 +14,8 @@ import ( // installation, never the organization's roles. A module without a page // declares none, and a module page declaring none keeps the organization's. // The holder is UnentitledToken (no role, no team); ViewerToken is another -// member, whatever the organization grants them. +// member, whose organization roles read ReadKind: the module serves ReadKind +// too, so they would hold it there were organization roles to decide. const ( pageModule = "conformance-page-module" pageModuleNote = pageModule + ".note" @@ -35,7 +36,11 @@ func (c *client) modulePageRoles(t *testing.T, ctx context.Context, f Fixture) { map[string]any{"name": "viewer", "scopes": []any{scopeAsk(note, []string{"read"}, nil)}}, }} } - if _, err := c.deliverPage(ctx, f, page, pageModule, "module", 1, []string{pageModuleNote}, roles(pageModuleNote), 0); err != nil { + if f.ReadKind == "" { + t.Fatal("required conformance fixture missing: ReadKind, which ViewerToken's organization roles read") + } + served := []string{pageModuleNote, f.ReadKind} + if _, err := c.deliverPage(ctx, f, page, pageModule, "module", 1, served, roles(pageModuleNote), 0); err != nil { t.Fatalf("deliver %s with a page and roles: %v", pageModule, err) } admin := c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": f.AdminToken}) @@ -55,9 +60,19 @@ func (c *client) modulePageRoles(t *testing.T, ctx context.Context, f Fixture) { "organization_id": f.Organization, "installation_id": id, "role": name, "person_id": str(holder, "person_id")}) return err } - issue := func(sess protoreflect.Message) (protoreflect.Message, error) { - return c.call(as(ctx, sess), "host.authority.v1.AuthorityService", "Issue", map[string]any{"person": map[string]any{}, "audience": pageModule}) + issue := func(sess protoreflect.Message, scopes ...any) (protoreflect.Message, error) { + fields := map[string]any{"person": map[string]any{}, "audience": pageModule} + if len(scopes) > 0 { + fields["scopes"] = scopes + } + return c.call(as(ctx, sess), "host.authority.v1.AuthorityService", "Issue", fields) + } + list := func(capability protoreflect.Message) (protoreflect.Message, error) { + return c.call(ctx, "host.authority.v1.AuthorityService", "ListResources", map[string]any{ + "token": str(capability, "token"), "audience": pageModule, "resource_kind": pageModuleNote, "action": "read"}) } + viewer := c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": f.ViewerToken}) + readKind := scopeAsk(f.ReadKind, []string{"read"}, nil) if err := role("GrantRole", "viewer"); err != nil { t.Fatal(err) } @@ -71,10 +86,37 @@ func (c *client) modulePageRoles(t *testing.T, ctx context.Context, f Fixture) { c.must(t, ctx, "host.authority.v1.AuthorityService", "Check", map[string]any{"token": str(out, "token"), "audience": pageModule}) held = out }) - t.Run("another member holds nothing there, whatever the organization grants them", func(t *testing.T) { - _, err := issue(c.must(t, ctx, "host.session.v1.SessionService", "SignIn", map[string]any{"fixture_token": f.ViewerToken})) + t.Run("the role holder lists the page's resources by the role", func(t *testing.T) { + if held == nil { + t.Skip("the holder was issued nothing") + } + listed, err := list(held) + if err != nil { + t.Fatal(err) + } + if got := grantsOf(listed); len(got) != 1 || !got[0].All { + t.Fatalf("the viewer lists %v, want every %s by the role", got, pageModuleNote) + } + }) + t.Run("another member holds and lists nothing there, whatever the organization grants them", func(t *testing.T) { + _, err := issue(viewer) + refused(t, err, codes.PermissionDenied, c.noRole()) + _, err = issue(viewer, readKind) refused(t, err, codes.PermissionDenied, c.noRole()) }) + t.Run("a revoked holder lists nothing", func(t *testing.T) { + if held == nil { + t.Skip("the holder was issued nothing") + } + if err := role("RevokeRole", "viewer"); err != nil { + t.Fatal(err) + } + _, err := list(held) + refused(t, err, codes.PermissionDenied, "") + if err := role("GrantRole", "viewer"); err != nil { + t.Fatal(err) + } + }) t.Run("a role the module does not declare is refused", func(t *testing.T) { refused(t, role("GrantRole", "editor"), codes.NotFound, `declares no role "editor"`) }) @@ -83,7 +125,7 @@ func (c *client) modulePageRoles(t *testing.T, ctx context.Context, f Fixture) { refused(t, err, codes.PermissionDenied, "no page") }) t.Run("a module page declaring no roles keeps the organization's", func(t *testing.T) { - if _, err := c.deliverPage(ctx, f, page, pageModule, "module", 2, []string{pageModuleNote}, map[string]any{"scope_ceilings": []any{scopeAsk(pageModuleNote, []string{"read", "write"}, nil)}}, 0); err != nil { + if _, err := c.deliverPage(ctx, f, page, pageModule, "module", 2, served, map[string]any{"scope_ceilings": []any{scopeAsk(pageModuleNote, []string{"read", "write"}, nil)}}, 0); err != nil { t.Fatalf("redeliver %s without roles: %v", pageModule, err) } if held != nil { @@ -93,6 +135,11 @@ func (c *client) modulePageRoles(t *testing.T, ctx context.Context, f Fixture) { _, err := issue(holder) refused(t, err, codes.PermissionDenied, "holds nothing") refused(t, role("GrantRole", "viewer"), codes.NotFound, `declares no role "viewer"`) + out, err := issue(viewer, readKind) + if err != nil { + t.Fatalf("organization roles do not decide again: %v", err) + } + exactScopeOwnedBy(t, msg(out, "context"), f.ReadKind, []string{"read"}, nil, "") }) if err := role("RevokeRole", "viewer"); err != nil { t.Fatal(err)