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
22 changes: 15 additions & 7 deletions libraries/authority-core/go/authority/consent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
}
Expand Down
46 changes: 46 additions & 0 deletions libraries/authority-core/go/authority/module_page_test.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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"})
Expand Down Expand Up @@ -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"})
Expand All @@ -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
}
2 changes: 1 addition & 1 deletion libraries/conformance/go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 4 additions & 0 deletions libraries/conformance/go/conformance.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
46 changes: 45 additions & 1 deletion libraries/conformance/go/module_page_roles.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package conformance

import (
"context"
"slices"
"testing"

"github.com/codefly-dev/core/solutionhost"
Expand Down Expand Up @@ -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)}},
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading