diff --git a/CHANGELOG.md b/CHANGELOG.md index 051c5dc..fd383f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## Unreleased — a child names the owner its parent is selected to + +- **A child of a person's context at an installation names the + installation's principal.** Since codefly-dev/authority#7, the authority + issues a person's context at a solution with every role scope selected to + the installation's principal (`resource_owner_id`), and holds a child of it + within what that owner holds there. `issueChild` asked owner-blind, which + reads as every owner's resources, so every relayed call through a solution + was refused: `a child widens what its owner holds through binding + "documents"`. The runtime now reads the owner from the authority's own + `Check` answer (the current actor's granted scopes, or the authority scopes + when no actor hopped), keeps it on the request's viewer, and names it on + every scope a `ForParent` child asks. Scopes selecting no owner, or more than + one, name none: the runtime never picks between owners. Nothing is decoded + here; the owner is the authority's answer. +- The authority client and hop libraries move to codefly-dev/authority main + `f41cc2ba` (`WorkScopeV1.resource_owner_id`); `go` moves to 1.27.3 with them. + ## Unreleased — review fixes for #75: the authority is asked about the operation dispatched (breaking) From the independent review of #75 (r1): diff --git a/authority.go b/authority.go index 6b7cc35..d7c88ad 100644 --- a/authority.go +++ b/authority.go @@ -165,14 +165,44 @@ func principalsOf(wc *authorityv1.WorkContextV1) (WorkContextPrincipals, []strin return principals, actors } +// selectedOwner is the one owner a checked Work Context's effective scopes +// select: the current actor's granted scopes, or the authority scopes when no +// actor has hopped. A person's context at a solution carries its installation +// roles selected to the installation's principal, and the authority holds a +// child of it within what that owner holds there (authority-core: a child is +// the owner's current grants intersected with the binding), so an ask naming +// no owner reads as every owner's resources and is refused as a widening. "" +// when the scopes select no owner, or more than one: the runtime never picks +// between owners, and the authority refuses an ask that had to name one. +func selectedOwner(wc *authorityv1.WorkContextV1) string { + effective := wc.GetAuthorityScopes() + if chain := wc.GetActorChain(); len(chain) > 0 { + effective = chain[len(chain)-1].GetGrantedScopes() + } + owner := "" + for _, scope := range effective { + switch selected := scope.GetResourceOwnerId(); { + case selected == "", selected == owner: + case owner == "": + owner = selected + default: + return "" + } + } + return owner +} + // issueChild asks the authority, through the hop library's Outbound, for a // child of the viewer's Work Context through one of this module's bindings: // the audience is the binding's, and the scopes the binding's invoke ceiling -// narrowed to what was asked and to what the person holds there. -func (h *hostAuthority) issueChild(ctx context.Context, parent, binding string, scopes []workContextScope) (string, time.Time, WorkContextPrincipals, error) { +// narrowed to what was asked and to what the person holds there. Each asked +// scope names owner, the owner the parent's effective scopes select +// (selectedOwner), so the ask stays within what that owner holds rather than +// widening to every owner's resources; an empty owner names none. +func (h *hostAuthority) issueChild(ctx context.Context, parent, binding, owner string, scopes []workContextScope) (string, time.Time, WorkContextPrincipals, error) { asked := make([]*authorityv1.WorkScopeV1, 0, len(scopes)) for _, scope := range scopes { - asked = append(asked, &authorityv1.WorkScopeV1{ResourceKind: scope.ResourceKind, Actions: scope.Actions, ResourceIds: scope.ResourceIDs}) + asked = append(asked, &authorityv1.WorkScopeV1{ResourceKind: scope.ResourceKind, Actions: scope.Actions, ResourceIds: scope.ResourceIDs, ResourceOwnerId: owner}) } ctx, cancel := context.WithTimeout(ctx, authorityCallTimeout) defer cancel() diff --git a/authority_test.go b/authority_test.go index 466f02a..52008fb 100644 --- a/authority_test.go +++ b/authority_test.go @@ -45,6 +45,13 @@ const edgeContext = "edge-context-for-the-page" // (InvalidArgument). const malformedContext = "malformed-context" +// solutionContext is a person's Work Context at the page as a solution: the +// installation's roles, each selected to the installation's principal. +const solutionContext = "person-through-the-installation" + +// installationPrincipal is the principal solutionContext's scopes select. +const installationPrincipal = "installation-principal" + // robinChild is a module's tool call to the page: Maya's Work Context, issued // by the authority as a child through robin's declared binding, robin acting, // addressed to the page. @@ -63,6 +70,9 @@ func (a *interfaceAuthority) Check(_ context.Context, r *authorityv1.CheckReques case r.GetAudience() != "page": case r.GetToken() == edgeContext: wc = &authorityv1.WorkContextV1{TenantId: "org-from-the-authority", OwnerPrincipalId: "person-1", SessionId: "session-1", Audience: "page"} + case r.GetToken() == solutionContext: + wc = &authorityv1.WorkContextV1{TenantId: "org-from-the-authority", OwnerPrincipalId: "person-1", SessionId: "session-1", Audience: "page", + AuthorityScopes: []*authorityv1.WorkScopeV1{{ResourceKind: "pages", Actions: []string{"read", "write"}, ResourceOwnerId: installationPrincipal}}} case r.GetToken() == robinChild: wc = &authorityv1.WorkContextV1{TenantId: "org-from-the-authority", OwnerPrincipalId: "maya", Audience: "page", ActorChain: []*authorityv1.WorkActorV1{{PrincipalId: "robin", PrincipalKind: "module"}}} @@ -485,3 +495,87 @@ func TestAMalformedRequestIsRefusedWithTheAuthoritysReason(t *testing.T) { t.Fatalf("an outage maps to %d, want 503", got) } } + +// A child names the owner its parent's effective scopes select: the current +// actor's granted scopes, or the authority scopes when no actor has hopped. +// The authority holds the child within what that owner holds, so an owner-blind +// ask would read as every owner's resources. Scopes selecting no owner name +// none; scopes selecting two owners name none either, rather than picking one. +func TestTheSelectedOwnerIsTheOneTheEffectiveScopesSelect(t *testing.T) { + scope := func(owner string) *authorityv1.WorkScopeV1 { + return &authorityv1.WorkScopeV1{ResourceKind: "pages", Actions: []string{"read"}, ResourceOwnerId: owner} + } + for name, c := range map[string]struct { + wc *authorityv1.WorkContextV1 + want string + }{ + "no scopes": {&authorityv1.WorkContextV1{}, ""}, + "owner-blind scopes": {&authorityv1.WorkContextV1{AuthorityScopes: []*authorityv1.WorkScopeV1{scope("")}}, ""}, + "one owner": {&authorityv1.WorkContextV1{AuthorityScopes: []*authorityv1.WorkScopeV1{scope("p"), scope(""), scope("p")}}, "p"}, + "two owners": {&authorityv1.WorkContextV1{AuthorityScopes: []*authorityv1.WorkScopeV1{scope("p"), scope("q")}}, ""}, + "the current actor's, not the owner's": {&authorityv1.WorkContextV1{ + AuthorityScopes: []*authorityv1.WorkScopeV1{scope("p")}, + ActorChain: []*authorityv1.WorkActorV1{ + {PrincipalId: "first", GrantedScopes: []*authorityv1.WorkScopeV1{scope("q")}}, + {PrincipalId: "robin", GrantedScopes: []*authorityv1.WorkScopeV1{scope("r")}}, + }}, "r"}, + "an actor granted no owner": {&authorityv1.WorkContextV1{ + AuthorityScopes: []*authorityv1.WorkScopeV1{scope("p")}, + ActorChain: []*authorityv1.WorkActorV1{{PrincipalId: "robin", GrantedScopes: []*authorityv1.WorkScopeV1{scope("")}}}}, ""}, + } { + if got := selectedOwner(c.wc); got != c.want { + t.Errorf("%s: selected %q, want %q", name, got, c.want) + } + } +} + +// Every scope of a child of a person's context at an installation names the +// installation's principal, the owner the authority checked that context's +// scopes are selected to; a child of a context selecting no owner names none, +// and a gateway built outside a request names none either. +func TestAChildNamesTheOwnerItsParentIsSelectedTo(t *testing.T) { + s, stand := authorityServer(t) + ctx := context.Background() + ask := []Scope{{ResourceKind: "documents", Actions: []string{"read"}}, {ResourceKind: "documents.collection", Actions: []string{"create"}, ResourceIDs: []string{"c-1"}}} + for _, c := range []struct{ parent, want string }{{solutionContext, installationPrincipal}, {edgeContext, ""}} { + gw, err := s.viewerGateway(ctx, c.parent, "") + if err != nil { + t.Fatal(err) + } + child, err := gw.ForParent(ctx, "documents", ask...) + if err != nil { + t.Fatal(err) + } + if _, err := child.WorkContext(ctx); err != nil { + t.Fatal(err) + } + asked := stand.asked() + scopes := asked[len(asked)-1].GetScopes() + if asked[len(asked)-1].GetParentToken() != c.parent || len(scopes) != 2 { + t.Fatalf("a child of %s was asked %v", c.parent, asked[len(asked)-1]) + } + for _, scope := range scopes { + if scope.GetResourceOwnerId() != c.want { + t.Errorf("a child of %s asked %v, want owner %q", c.parent, scope, c.want) + } + } + if ids := scopes[1].GetResourceIds(); !slices.Equal(ids, []string{"c-1"}) { + t.Errorf("a child of %s lost the ids it asked for: %v", c.parent, ids) + } + } + bare := s.newGateway() + bare.parentContext = solutionContext + child, err := bare.ForParent(ctx, "documents", ask...) + if err != nil { + t.Fatal(err) + } + if _, err := child.WorkContext(ctx); err != nil { + t.Fatal(err) + } + asked := stand.asked() + for _, scope := range asked[len(asked)-1].GetScopes() { + if scope.GetResourceOwnerId() != "" { + t.Errorf("a gateway with no checked viewer named owner %q", scope.GetResourceOwnerId()) + } + } +} diff --git a/go.mod b/go.mod index baf7197..f36cba1 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,12 @@ module github.com/codefly-dev/solution-runtime-go -go 1.27.0 +go 1.27.3 require ( connectrpc.com/connect v1.21.0 - github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025034102-06d0d3b85674 - github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025180225-7d00337b3572 - github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda + github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261027012935-f41cc2baa2ec + github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027012935-f41cc2baa2ec + github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 github.com/codefly-dev/sdk-go/workcontext v0.1.1-0.20261018052022-70dc7a546458 github.com/modelcontextprotocol/go-sdk v1.8.0 diff --git a/go.sum b/go.sum index 5dc47e0..23692ba 100644 --- a/go.sum +++ b/go.sum @@ -30,12 +30,12 @@ github.com/cheggaaa/pb/v3 v3.2.1 h1:aprZbFRG+B7+ug76S8QZ6Y1PW168UHzOmbC3wa+aU6I= github.com/cheggaaa/pb/v3 v3.2.1/go.mod h1:U9hSVxoKqJrZIE3PkFG1xXXNaK/Ilzg+EF/scpXzEdw= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025034102-06d0d3b85674 h1:9rm7DjJ/JrZibZ0G+X+k0Xzg6kNqqwQpvcddjraseNo= -github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261025034102-06d0d3b85674/go.mod h1:LO/atMY4yLVDyo8801X4iTHoPBUOFvMdvkU97RY9OoU= -github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025180225-7d00337b3572 h1:1VjuCwVLSh94xNFFBqqUswYJq+DJwRmgbtvCksXHhlQ= -github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261025180225-7d00337b3572/go.mod h1:97YbI5CbCt3rEJ+TQZLEMZ7OwTzvehJ3uwTOD/i64+s= -github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda h1:ytTihtIEfVhQ7llQl+ls5swIL8JgJu89X0wlDMeqM+o= -github.com/codefly-dev/core v0.19.6-0.20261018051341-b81d92e6feda/go.mod h1:0uX/7lTBVaGV0RI2Mb3mpEr/PPdx/jZqXHym/U8FVDI= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261027012935-f41cc2baa2ec h1:gjLHn5hsteMDx8TeNj+KPBsa06nLwzAEhyR2IfC3JWs= +github.com/codefly-dev/authority/libraries/authority-client/go v0.0.0-20261027012935-f41cc2baa2ec/go.mod h1:4hRjiWuJOKzrEk8AS87pbWCbpiEmhEvQBGQaJo5Hmkg= +github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027012935-f41cc2baa2ec h1:QhncdaWaMERAGpZthlSXjKroXNl/L8llPtBMX+e5w/A= +github.com/codefly-dev/authority/libraries/hop/go v0.0.0-20261027012935-f41cc2baa2ec/go.mod h1:NqCbvIWbkbCzSpUUYQjP8Jv+LpFnDRbVhPdAj67DHhE= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2 h1:I1wL0ANXhTdQuEAnxXF+s4+rKILlBu/QHILq4Skyi3I= +github.com/codefly-dev/core v0.19.7-0.20261026092055-b23dc13543b2/go.mod h1:1ina3kP+YSAL4tlZvnEYd4P8bFGnJECfkP86ojeeF1A= github.com/codefly-dev/gortk v0.2.0 h1:7bOlS5valYz2zil+fZctQNcPCYBcPj86abcw9N8h1hQ= github.com/codefly-dev/gortk v0.2.0/go.mod h1:dDWUMFgAP063OCGhTEpV7FFUj9+zTcxxO/nV80VKEwg= github.com/codefly-dev/sdk-go v0.4.1-0.20261018052022-70dc7a546458 h1:hi+KhotTLMOJ/PUSzVO/Axfho7yZ2UOjrCFfrLI1EKU= @@ -142,6 +142,14 @@ github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg= +github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRtzAscm/zF23XxJlbECiGPyRicsX+Ak= github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= diff --git a/passthrough_authority.go b/passthrough_authority.go index ea07cd4..ba0366a 100644 --- a/passthrough_authority.go +++ b/passthrough_authority.go @@ -132,7 +132,7 @@ func (s *Server) viewerGateway(ctx context.Context, carried, route string) (*Gat gw.parentContext = carried gw.orgID = viewer.GetTenantId() principals, actors := principalsOf(viewer) - gw.viewer = &requestViewer{principals: principals, actors: actors} + gw.viewer = &requestViewer{principals: principals, actors: actors, owner: selectedOwner(viewer)} return gw, nil } diff --git a/passthrough_authority_test.go b/passthrough_authority_test.go index e248dea..4236c06 100644 --- a/passthrough_authority_test.go +++ b/passthrough_authority_test.go @@ -195,9 +195,18 @@ func TestThePassthroughRelaysAsTheViewer(t *testing.T) { t.Fatalf("the module was called %+v (tenant %q)", call, tenant) } asked := stand.asked()[0] - if asked.GetParentToken() != edgeContext || asked.GetOperationBindingId() != "things-binding" || len(asked.GetScopes()) != 1 || asked.GetScopes()[0].GetResourceKind() != "things" { + if asked.GetParentToken() != edgeContext || asked.GetOperationBindingId() != "things-binding" || len(asked.GetScopes()) != 1 || asked.GetScopes()[0].GetResourceKind() != "things" || + asked.GetScopes()[0].GetResourceOwnerId() != "" { t.Fatalf("the child was asked %v", asked) } + // A person at an installation: the relayed child names the owner the + // authority checked the request's context is selected to. + if code, answer := connectCall(t, page.URL+relayedSearchPath, solutionContext, `{"entryId":"e1"}`); code != http.StatusOK { + t.Fatalf("a person at the installation was answered %d %v", code, answer) + } + if asked := stand.asked(); len(asked) != 2 || asked[1].GetParentToken() != solutionContext || asked[1].GetScopes()[0].GetResourceOwnerId() != installationPrincipal { + t.Fatalf("the installation's child was asked %v", asked[len(asked)-1]) + } // What is not declared is not served, whatever the module offers; a // request the edge did not send carries no viewer. for _, undeclared := range []string{"/v1/page" + getPath, "/v1/page/modules/tenants/relayed.v1.Things/Search"} { @@ -208,7 +217,7 @@ func TestThePassthroughRelaysAsTheViewer(t *testing.T) { if code, answer := connectCall(t, page.URL+relayedSearchPath, "", `{}`); code != http.StatusUnauthorized || answer["code"] != "unauthenticated" { t.Errorf("no Work Context answered %d %v", code, answer) } - if len(module.observed()) != 1 { + if len(module.observed()) != 2 { t.Fatalf("the module was called %d times", len(module.observed())) } } diff --git a/solution.go b/solution.go index 8474154..b3a6d61 100644 --- a/solution.go +++ b/solution.go @@ -1010,7 +1010,7 @@ func (g *Gateway) issue(ctx context.Context, ask issueAsk) (string, time.Time, e var principals WorkContextPrincipals var err error if ask.child { - token, renew, principals, err = g.host.issueChild(ctx, g.parentContext, ask.binding, ask.scopes) + token, renew, principals, err = g.host.issueChild(ctx, g.parentContext, ask.binding, g.viewer.selectedOwner(), ask.scopes) } else { token, renew, principals, err = g.host.issueForBinding(ctx, ask.organization, ask.binding) } @@ -1064,6 +1064,18 @@ func (g *Gateway) WorkContextPrincipals(ctx context.Context) (WorkContextPrincip type requestViewer struct { principals WorkContextPrincipals actors []string + // owner is the one owner the context's effective scopes select + // (selectedOwner), which every child of it names; "" when none or several. + owner string +} + +// selectedOwner is the owner a child of the viewer's context names, "" on a +// gateway built outside a request. +func (v *requestViewer) selectedOwner() string { + if v == nil { + return "" + } + return v.owner } // ActorChain is who acts, in order, under the Work Context the request