diff --git a/libraries/authority-core/go/authority/consent.go b/libraries/authority-core/go/authority/consent.go index 1544819..4620e43 100644 --- a/libraries/authority-core/go/authority/consent.go +++ b/libraries/authority-core/go/authority/consent.go @@ -47,7 +47,8 @@ func offerDigest(m domain.Module) (string, error) { // Review reads one consistent offer. It never supplies an operation identity or // approves anything; the caller must retain the returned values when consenting. -// It is an administrator's read: the consent tuple is what Install takes. +// It is an administrator's read: the consent tuple is what Install takes, and +// a module page, which composition installs, answers none. func (s *Service) Review(ctx context.Context, caller Person, org, target string) (domain.ConsentApproval, error) { offer, err := s.reviewOffer(ctx, caller, org, target, false) return offer.Approval, err @@ -62,7 +63,8 @@ type ReviewedOffer struct { Modules []domain.Module } -// ReviewOffer reads one solution's offer. An administrator of org reads it +// ReviewOffer reads one solution's offer, or a module page's that declares +// roles. An administrator of org reads it // with the consent tuple Install takes. A member of org who holds a role on // the solution's active installation there (granted to them or to a team of // theirs) reads the same declaration, roles, ceiling and installation without @@ -101,10 +103,14 @@ func (s *Service) reviewOffer(ctx context.Context, caller Person, org, target st if err != nil { return notFound(err) } - if m.Kind != domain.KindSolution { - return domain.Missing("%q is not a composed solution; a module is installed by composition", target) + // A module page declaring roles is reviewed as a solution is: its + // roles are what its Setup shows and grants. Its installation is + // still composition's (Install refuses a module), so its review + // carries no consent tuple: there is nothing to approve. + if !decide.RoleGated(m) { + return domain.Missing("%q is not a composed solution or a module page declaring roles; a module is installed by composition", target) } - if refused == nil { + if refused == nil && m.Kind == domain.KindSolution { digest, err := offerDigest(m) if err != nil { return err @@ -177,7 +183,9 @@ func checkConsent(m domain.Module, existing domain.Installation, a *domain.Conse return false, nil } -// Offers lists delivered solution targets, not composition-installed modules. +// Offers lists what an administrator of org sets up: delivered solutions, and +// module pages declaring roles (composition installs them; their Setup grants +// their roles). A module without roles is the organization's roles' alone. func (s *Service) Offers(ctx context.Context, caller Person, org string) ([]domain.Module, error) { var out []domain.Module err := s.store.Read(ctx, func(tx Tx) error { @@ -186,7 +194,7 @@ func (s *Service) Offers(ctx context.Context, caller Person, org string) ([]doma } modules, err := tx.Modules() for _, m := range modules { - if m.Kind == domain.KindSolution { + if decide.RoleGated(m) { out = append(out, m) } } diff --git a/libraries/authority-core/go/authority/module_page_test.go b/libraries/authority-core/go/authority/module_page_test.go index 718a8f7..8df9029 100644 --- a/libraries/authority-core/go/authority/module_page_test.go +++ b/libraries/authority-core/go/authority/module_page_test.go @@ -1,12 +1,14 @@ package authority_test import ( + "slices" "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" + "github.com/google/uuid" ) // pageModule is a module declaring a page; with roles, what a person holds at @@ -36,6 +38,31 @@ func TestAModulePageRoleEntitlesItsHolderAlone(t *testing.T) { t.Fatal(err) } + // Its offer is reviewed as a solution's: the roles its Setup shows. Its + // installation stays composition's, so the review carries no consent + // tuple, and Install refuses it whatever it is handed. + offer, err := h.svc.ReviewOffer(h.ctx, h.admin(acme), acme, "pages") + if err != nil || len(offer.Module.Roles) != 2 || offer.Installation.ID != h.inst(acme, "pages").ID || offer.Approval != (domain.ConsentApproval{}) { + t.Fatalf("the administrator reviews the module page's offer: %+v, %v", offer, err) + } + if approval, err := h.svc.Review(h.ctx, h.admin(acme), acme, "pages"); err != nil || approval != (domain.ConsentApproval{}) { + t.Fatalf("a module page's review answers a consent tuple: %+v, %v", approval, err) + } + // Its review answers no tuple to hand Install, and a well-formed tuple + // made up for it is refused for what the target is. + _, err = h.svc.Install(h.ctx, h.admin(acme), acme, "pages", "", "", h.approval(acme, "pages"), nil) + refused(t, err, "INSTALLATION_APPROVAL_REQUIRED") + made := &domain.ConsentApproval{TargetID: "pages", Generation: 1, Digest: "sha256:made-up", OperationID: uuid.NewString()} + _, err = h.svc.Install(h.ctx, h.admin(acme), acme, "pages", "", "", made, nil) + refused(t, err, "a module is installed by composition") + // The administrator's Setup lists it beside the solutions; a member's + // read of the list is refused. + if names := h.offers(acme); !slices.Contains(names, "pages") || !slices.Contains(names, "a") || slices.Contains(names, "store") { + t.Fatalf("the offers are the solutions and the module page, not the bare store: %v", names) + } + _, err = h.svc.Offers(h.ctx, h.person("maya"), acme) + code(t, err, domain.Denied) + h.grant("pages", "viewer", "maya") viewer := h.must(h.root("maya", "pages")) exactly(t, viewer, "pages.note", "", []string{"read"}) @@ -83,6 +110,11 @@ func TestAModulePageRoleEntitlesItsHolderAlone(t *testing.T) { } reader := h.must(h.root("ines", "pages")) exactly(t, reader, "pages.note", "", []string{"read"}) + _, err = h.svc.ReviewOffer(h.ctx, h.admin(acme), acme, "pages") + refused(t, err, "a module is installed by composition") + if names := h.offers(acme); slices.Contains(names, "pages") { + t.Fatalf("a module page withdrawing its roles is still offered: %v", names) + } _, 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"}) @@ -107,3 +139,17 @@ func TestAModuleWithoutAPageDeclaresNoRoles(t *testing.T) { _, err = h.root("maya", "pages") code(t, err, domain.NotFound) } + +// offers are the names an administrator of org is offered to set up. +func (h *harness) offers(org string) []string { + h.t.Helper() + offered, err := h.svc.Offers(h.ctx, h.admin(org), org) + if err != nil { + h.t.Fatal(err) + } + var names []string + for _, m := range offered { + names = append(names, m.Name) + } + return names +} diff --git a/libraries/conformance/go/README.md b/libraries/conformance/go/README.md index db8b69d..945ed79 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, 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). | +| 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`; `PagePayload` when the host applies a page only from its prepared signed payload): the suite delivers a module whose core manifest declares a frontend with an installer and a viewer role, serving its own kind and `ReadKind`; an administrator's `ListOffers` lists it, and `ReviewOffer` answers its two roles and its installation to the administrator and to the role holder with no consent tuple (composition installs it), and refuses `ViewerToken`; 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), and it is no longer offered. | | 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 26f25c0..ff1f36d 100644 --- a/libraries/conformance/go/conformance.go +++ b/libraries/conformance/go/conformance.go @@ -98,6 +98,10 @@ type Fixture struct { // SignAuthority signs the authority's own document delivered with a manifest, // with the key SignManifest signs with. Required for method-policy conformance. SignAuthority func(document []byte) (envelope []byte, err error) + // PagePayload is the signed payload the page a module_page_roles delivery + // declares carries, for a host that applies a page only once its signed + // payload is prepared. Nil delivers the page declaration alone. + PagePayload *solutionhost.FrontendPayload // Chain is a required delegate chain: a person signs in, // is issued at Start, and each hop is taken by its module on the previous diff --git a/libraries/conformance/go/module_page_roles.go b/libraries/conformance/go/module_page_roles.go index cdad912..7e42614 100644 --- a/libraries/conformance/go/module_page_roles.go +++ b/libraries/conformance/go/module_page_roles.go @@ -2,6 +2,7 @@ package conformance import ( "context" + "slices" "testing" "github.com/codefly-dev/core/solutionhost" @@ -29,7 +30,7 @@ func (c *client) modulePageRoles(t *testing.T, ctx context.Context, f Fixture) { 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} + page := &solutionhost.Frontend{Title: pageModuleTitle, ExposedModule: "./Page", HostContract: 1, Payload: f.PagePayload} 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)}}, @@ -76,6 +77,46 @@ func (c *client) modulePageRoles(t *testing.T, ctx context.Context, f Fixture) { if err := role("GrantRole", "viewer"); err != nil { t.Fatal(err) } + offered := func(t *testing.T) bool { + t.Helper() + listed := c.must(t, adminCtx, "host.installation.v1.OfferService", "ListOffers", map[string]any{"organization_id": f.Organization}) + for _, o := range listMsgs(listed, "offers") { + if str(o, "target") == pageModule { + return true + } + } + return false + } + t.Run("its setup reviews its roles, with nothing to consent to", func(t *testing.T) { + if f.ReviewOfferNotServed != "" { + t.Skipf("this host does not serve OfferService yet: %s", f.ReviewOfferNotServed) + } + if !offered(t) { + t.Fatalf("an administrator's offers at %s do not list %s", f.Organization, pageModule) + } + review := func(sess protoreflect.Message) (protoreflect.Message, error) { + return c.call(as(ctx, sess), "host.installation.v1.OfferService", "ReviewOffer", map[string]any{"organization_id": f.Organization, "target": pageModule}) + } + for name, sess := range map[string]protoreflect.Message{"the administrator": admin, "the role holder": holder} { + out, err := review(sess) + if err != nil { + t.Fatalf("%s reviews %s: %v", name, pageModule, err) + } + var roles []string + for _, r := range listMsgs(out, "roles") { + roles = append(roles, str(r, "name")) + } + slices.Sort(roles) + if !slices.Equal(roles, []string{"installer", "viewer"}) || str(msg(out, "installation"), "id") != id { + t.Fatalf("%s reviews roles %v at installation %q, want [installer viewer] at %q", name, roles, str(msg(out, "installation"), "id"), id) + } + if out.Has(out.Descriptor().Fields().ByName("approval")) { + t.Fatalf("%s is handed a consent tuple for %s, which composition installs", name, pageModule) + } + } + _, err := review(viewer) + refused(t, err, codes.PermissionDenied, "requires an administrator") + }) var held protoreflect.Message t.Run("the role holder is entitled to exactly the role", func(t *testing.T) { out, err := issue(holder) @@ -140,6 +181,9 @@ func (c *client) modulePageRoles(t *testing.T, ctx context.Context, f Fixture) { t.Fatalf("organization roles do not decide again: %v", err) } exactScopeOwnedBy(t, msg(out, "context"), f.ReadKind, []string{"read"}, nil, "") + if f.ReviewOfferNotServed == "" && offered(t) { + t.Fatalf("%s is still offered once it declares no roles", pageModule) + } }) if err := role("RevokeRole", "viewer"); err != nil { t.Fatal(err) diff --git a/modules/authority/services/local/code/plugins/installation/offers.go b/modules/authority/services/local/code/plugins/installation/offers.go index c682a84..846b3ac 100644 --- a/modules/authority/services/local/code/plugins/installation/offers.go +++ b/modules/authority/services/local/code/plugins/installation/offers.go @@ -59,7 +59,8 @@ type Offers struct { runnables RunnableReader } -// ListOffers lists the delivered solutions, for an administrator. +// ListOffers lists what an administrator sets up: the delivered solutions and +// the module pages declaring roles (the core decides which). func (o *Offers) ListOffers(ctx context.Context, req *installationv1.ListOffersRequest) (*installationv1.ListOffersResponse, error) { caller, err := o.plugin.caller(ctx) if err != nil {