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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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):
Expand Down
36 changes: 33 additions & 3 deletions authority.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
94 changes: 94 additions & 0 deletions authority_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"}}}
Expand Down Expand Up @@ -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())
}
}
}
8 changes: 4 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 14 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down Expand Up @@ -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=
Expand Down
2 changes: 1 addition & 1 deletion passthrough_authority.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
13 changes: 11 additions & 2 deletions passthrough_authority_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"} {
Expand All @@ -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()))
}
}
Expand Down
14 changes: 13 additions & 1 deletion solution.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
Loading