From 8ea6c260ac763714167ee81e150e97c7b6c80fc1 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 26 Oct 2026 22:04:41 -0400 Subject: [PATCH] fix: a child names the owner its parent's scopes are selected to Since codefly-dev/authority#7 a person's Work Context at a solution carries its installation roles selected to the installation's principal, and the authority holds a child within what that owner holds (child = current grants intersected with the binding). issueChild asked owner-blind, which reads as every owner's resources, so every relayed call through a solution was refused as "a child widens what its owner holds through binding ...". The viewer now records the one owner the authority's Check answer selects (the current actor's granted scopes, or the authority scopes when no actor hopped), and every ForParent ask names it; none or several owners name none. The authority client and hop move to authority main f41cc2ba for WorkScopeV1.resource_owner_id. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 18 +++++++ authority.go | 36 ++++++++++++-- authority_test.go | 94 +++++++++++++++++++++++++++++++++++ go.mod | 8 +-- go.sum | 20 +++++--- passthrough_authority.go | 2 +- passthrough_authority_test.go | 13 ++++- solution.go | 14 +++++- 8 files changed, 188 insertions(+), 17 deletions(-) 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