Skip to content
Closed
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
12 changes: 12 additions & 0 deletions module/WORK_CONTEXTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,18 @@ The mode is the binding's, never the request's. The chain-preserving exchange,
StartTask and registered-agent actors are unchanged, and a binding without a
mode keeps its owner-only child.

**A parent that names its module's bindings.** A person's parent for a module
that will exchange through its installed bindings may name those bindings
instead of their scopes: `StartTask`'s `operation_bindings` takes binding ids
of the audience module. The host resolves each one the way the exchange will
(`ModuleOperationAudience`: the binding as installed at the organization,
narrowed by that installation) and adds its invoke scopes, keeping each
resource only if the person holds it by the check every mint makes. A binding
the person holds nothing under is refused by name. So a source binding's ids
come from the organization's installation, never from the caller. Only a
direct human-owned mint takes it; an actor's mint and a resource-confined mint
refuse it. A scope the caller also names is carried once.

Resource kinds, actions and resource ids are sorted and unique; wildcards are
refused. Lookup scopes are optional: a binding that declares only invoke scopes
redeems no lookup, and a lookup exchange under it is refused by name. When present,
Expand Down
Binary file modified module/contracts/api/accounts/authority/contract.binpb
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ message StartTaskWorkContextRequest {
(buf.validate.field).string.min_len = 1,
(buf.validate.field).string.max_len = 512
];
// Operation bindings of the audience module, by name, whose invoke scopes
// this capability carries beside authority_scopes. The host resolves each
// name to the binding as installed at org_id, narrowed by that installation
// exactly as the module's delegated exchange redeems it, and keeps each
// resource only if the person holds it. The caller names a binding and never
// its resource ids. A direct human-owned mint only.
repeated string operation_bindings = 11 [
(buf.validate.field).repeated.max_items = 64,
(buf.validate.field).repeated.unique = true,
(buf.validate.field).repeated.items.string = {
min_len: 1
max_len: 256
}
];
}

// StartInstallationTaskRequest is the headless (no-user-present) mint path. The
Expand Down
Binary file modified module/contracts/api/accounts/connect/contract.binpb
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,20 @@ message StartTaskWorkContextRequest {
(buf.validate.field).string.min_len = 1,
(buf.validate.field).string.max_len = 512
];
// Operation bindings of the audience module, by name, whose invoke scopes
// this capability carries beside authority_scopes. The host resolves each
// name to the binding as installed at org_id, narrowed by that installation
// exactly as the module's delegated exchange redeems it, and keeps each
// resource only if the person holds it. The caller names a binding and never
// its resource ids. A direct human-owned mint only.
repeated string operation_bindings = 11 [
(buf.validate.field).repeated.max_items = 64,
(buf.validate.field).repeated.unique = true,
(buf.validate.field).repeated.items.string = {
min_len: 1
max_len: 256
}
];
}

// StartInstallationTaskRequest is the headless (no-user-present) mint path. The
Expand Down
4 changes: 2 additions & 2 deletions module/contracts/api/catalog.codefly.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"kind": "protobuf",
"package": "saas.accounts.v1",
"path": "contracts/api/accounts/authority/contract.binpb",
"digest": "sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7",
"digest": "sha256:21f848eb59ce4aca4903d354b66131c8e9595771d505d762fa07e730f32c4c4c",
"services": [
{
"name": "ModuleCapabilitiesService",
Expand Down Expand Up @@ -65,7 +65,7 @@
"kind": "protobuf",
"package": "saas.accounts.v1",
"path": "contracts/api/accounts/connect/contract.binpb",
"digest": "sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7",
"digest": "sha256:21f848eb59ce4aca4903d354b66131c8e9595771d505d762fa07e730f32c4c4c",
"services": [
{
"name": "APIKeyService",
Expand Down
4 changes: 2 additions & 2 deletions module/module.package.codefly.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ services:
kind: protobuf
package: saas.accounts.v1
path: contracts/api/accounts/authority/contract.binpb
digest: sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7
digest: sha256:21f848eb59ce4aca4903d354b66131c8e9595771d505d762fa07e730f32c4c4c
- endpoint: connect
kind: protobuf
package: saas.accounts.v1
path: contracts/api/accounts/connect/contract.binpb
digest: sha256:373a8f3a22b7968c36dd9faffb9be5ef64213b1de2a532f5e567007bd65caaa7
digest: sha256:21f848eb59ce4aca4903d354b66131c8e9595771d505d762fa07e730f32c4c4c
- name: auth-gateway
endpoints: [grpc, rest]
api-contracts:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package adapters

import (
"context"
"slices"

accountsauth "accounts/pkg/auth"
"accounts/pkg/business"
gen "accounts/pkg/gen/saas/accounts/v1"

"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/proto"
)

// requireOperationBindingMint refuses operation_bindings outside the one shape
// that takes them: a direct human-owned mint. An actor's ceiling and a confined
// credential's consumer ceiling are decided on the scopes a caller names, so
// neither is widened by a name resolved here.
func requireOperationBindingMint(ctx context.Context, req *gen.StartTaskWorkContextRequest) error {
if len(req.GetOperationBindings()) == 0 {
return nil
}
if req.GetActorPrincipalId() != "" {
return status.Error(codes.InvalidArgument, "operation_bindings is for a direct human-owned mint, not an actor's")
}
if _, confined := accountsauth.VerifiedResourceConfinement(ctx); confined {
return status.Error(codes.InvalidArgument, "operation_bindings is refused on a resource-confined mint")
}
if service == nil {
return status.Error(codes.Unavailable, "module authority unavailable")
}
return nil
}

// withOperationBindingScopes adds the invoke scopes of each named operation
// binding of the audience module to the scopes a caller asked for.
//
// A name resolves through service.ModuleOperationAudience, the resolution the
// module's delegated exchange redeems the binding through: the binding as
// installed at the organisation, narrowed by that installation. So the resource
// ids come from the installation, never from the caller, and the capability
// carries exactly what the exchange will ask of it. Each resource is kept only
// if the person holds it, by the same check every mint makes; a binding the
// person holds nothing under is refused by name. A scope the caller already
// named is not added twice.
func (s *WorkContextAuthorityServer) withOperationBindingScopes(ctx context.Context, orgID, audience string, asked []*gen.WorkContextScope, names []string) ([]*gen.WorkContextScope, error) {
ownerID, err := s.authorizeOwner(ctx, orgID)
if err != nil {
return nil, err
}
module := business.ModuleCaller{PrincipalID: business.ModulePrincipalID(audience), BoundOrg: orgID}
out := slices.Clone(asked)
for _, name := range names {
binding, err := service.ModuleOperationAudience(ctx, module, orgID, audience, name)
if err != nil {
return nil, err
}
held := 0
for _, scope := range binding.InvokeScopes {
kept, err := s.heldBindingScope(ctx, orgID, ownerID, audience, scope)
if err != nil {
return nil, err
}
if kept == nil {
continue
}
held++
if !slices.ContainsFunc(out, func(existing *gen.WorkContextScope) bool { return proto.Equal(existing, kept) }) {
out = append(out, kept)
}
}
if held == 0 {
return nil, status.Errorf(codes.PermissionDenied, "the person holds nothing under operation binding %q", name)
}
}
return out, nil
}

// heldBindingScope is the part of one binding scope the person holds: every
// action of the scope, on each resource it names, or on its kind when it names
// none. Nil when the person holds none of it.
func (s *WorkContextAuthorityServer) heldBindingScope(ctx context.Context, orgID, ownerID, audience string, scope business.ModuleOperationScope) (*gen.WorkContextScope, error) {
candidate := func(ids []string) *gen.WorkContextScope {
return &gen.WorkContextScope{ResourceKind: scope.ResourceKind, Actions: slices.Clone(scope.Actions), ResourceIds: ids}
}
holds := func(c *gen.WorkContextScope) (bool, error) {
permissions, _, err := workContextScopes([]*gen.WorkContextScope{c}, mintContentReads(audience))
if err != nil {
return false, status.Error(codes.Internal, err.Error())
}
_, err = s.resolveAuthority(ctx, orgID, ownerID, "", permissions)
switch status.Code(err) {
case codes.OK:
return true, nil
case codes.PermissionDenied:
return false, nil
}
return false, err
}
if len(scope.ResourceIDs) == 0 {
if ok, err := holds(candidate(nil)); !ok || err != nil {
return nil, err
}
return candidate(nil), nil
}
var ids []string
for _, id := range scope.ResourceIDs {
ok, err := holds(candidate([]string{id}))
if err != nil {
return nil, err
}
if ok {
ids = append(ids, id)
}
}
if len(ids) == 0 {
return nil, nil
}
return candidate(ids), nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package adapters

import (
"context"
"maps"
"testing"

"accounts/pkg/auth"
"accounts/pkg/business"
gen "accounts/pkg/gen/saas/accounts/v1"

corework "github.com/codefly-dev/core/workcontext"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

var bindingRunScope = []*gen.WorkContextScope{{ResourceKind: "runs", Actions: []string{"execute"}}}

func (c moduleActorCase) startTaskNaming(bindings ...string) (*gen.IssuedWorkContext, error) {
caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{})
return c.server.StartTask(caller, &gen.StartTaskWorkContextRequest{
OrgId: readOrg, TaskId: uuid.NewString(), Audience: "reader",
AuthorityScopes: bindingRunScope, OperationBindings: bindings,
})
}

// A parent that names its module's binding carries that binding's installed
// scopes, resource ids included, without the caller naming them; and the
// module's exchange of that binding redeems it.
func TestStartTaskCarriesANamedBindingAsInstalled(t *testing.T) {
c := moduleActorFixture(t, moduleActorScopes)
parent, err := c.startTaskNaming("act-source")
require.NoError(t, err)
decoded, err := corework.Decode(parent.Token)
require.NoError(t, err)
scopes := wireScopes(decoded.Context().AuthorityScopes)
require.Len(t, scopes, 2)
require.Equal(t, "runs", scopes[0].ResourceKind)
require.Equal(t, business.SourceOperationResourceKind, scopes[1].ResourceKind)
require.Equal(t, []string{"invoke", "read"}, scopes[1].Actions)
require.Equal(t, []string{actorSource}, scopes[1].ResourceIds)

issued, err := c.exchange("act-source", parent.Token, c.moduleToken, false)
require.NoError(t, err)
child, err := corework.Decode(issued.Token)
require.NoError(t, err)
require.Equal(t, []string{actorSource}, child.Context().ActorChain[0].GrantedScopes[0].ResourceIds)
}

// A caller that also names the binding's scope gets it once.
func TestStartTaskDoesNotRepeatANamedScope(t *testing.T) {
c := moduleActorFixture(t, moduleActorScopes)
caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{})
explicit := append(append([]*gen.WorkContextScope(nil), bindingRunScope...),
&gen.WorkContextScope{ResourceKind: business.SourceOperationResourceKind, Actions: []string{"invoke", "read"}, ResourceIds: []string{actorSource}})
parent, err := c.server.StartTask(caller, &gen.StartTaskWorkContextRequest{
OrgId: readOrg, TaskId: uuid.NewString(), Audience: "reader", AuthorityScopes: explicit, OperationBindings: []string{"act-source"},
})
require.NoError(t, err)
decoded, err := corework.Decode(parent.Token)
require.NoError(t, err)
require.Len(t, decoded.Context().AuthorityScopes, 2)
}

func TestStartTaskOperationBindingRefusals(t *testing.T) {
t.Run("a binding the module is not installed with", func(t *testing.T) {
c := moduleActorFixture(t, moduleActorScopes)
_, err := c.startTaskNaming("no-such-binding")
require.Equal(t, codes.PermissionDenied, status.Code(err))
require.Contains(t, status.Convert(err).Message(), "installed operation audience required")
})
t.Run("an installation narrowed away from the binding's sources", func(t *testing.T) {
c := moduleActorFixture(t, moduleActorScopes)
live := c.f.live[business.ModulePrincipalID("reader")]
scope := live.Scope
scope.Follows = false
scope.OperationAudiences = maps.Clone(scope.OperationAudiences)
narrowed := scope.OperationAudiences["act-source"]
elsewhere := []business.ModuleOperationScope{{ResourceKind: business.SourceOperationResourceKind, Actions: []string{"invoke", "read"}, ResourceIDs: []string{"source-b"}}}
narrowed.InvokeScopes, narrowed.LookupScopes = elsewhere, elsewhere
scope.OperationAudiences["act-source"] = narrowed
live.Scope = scope
_, err := c.startTaskNaming("act-source")
require.Equal(t, codes.PermissionDenied, status.Code(err))
require.Contains(t, status.Convert(err).Message(), "installed operation audience required")
})
t.Run("a binding the person holds nothing under, refused by name", func(t *testing.T) {
c := moduleActorFixture(t, moduleActorScopes)
c.server.authority = sourceAccessWithdrawn{}
_, err := c.startTaskNaming("act-source")
require.Equal(t, codes.PermissionDenied, status.Code(err))
require.Contains(t, status.Convert(err).Message(), `"act-source"`)
})
t.Run("an actor's mint", func(t *testing.T) {
c := moduleActorFixture(t, moduleActorScopes)
caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{})
_, err := c.server.StartTask(caller, &gen.StartTaskWorkContextRequest{
OrgId: readOrg, TaskId: uuid.NewString(), Audience: "reader", ActorPrincipalId: uuid.NewString(),
AuthorityScopes: bindingRunScope, OperationBindings: []string{"act-source"},
})
require.Equal(t, codes.InvalidArgument, status.Code(err))
})
}
15 changes: 13 additions & 2 deletions module/services/accounts/code/pkg/adapters/work_context_rpcs.go
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,10 @@ func (s *WorkContextAuthorityServer) StartTask(
if err := requireVocabularyAudience(ctx, req.GetAudience()); err != nil {
return nil, err
}
if err := requireOperationBindingMint(ctx, req); err != nil {
return nil, err
}
authorityScopes := req.GetAuthorityScopes()
eligible := mintContentReads(req.GetAudience())
var standing []string
if service != nil && req.GetActorPrincipalId() == "" {
Expand All @@ -551,8 +555,15 @@ func (s *WorkContextAuthorityServer) StartTask(
return nil, err
}
defer release()
// Inside the authority span, so the installation the bindings resolve
// against is the one this capability is signed under.
if names := req.GetOperationBindings(); len(names) > 0 {
if authorityScopes, err = s.withOperationBindingScopes(ctx, req.GetOrgId(), req.GetAudience(), authorityScopes, names); err != nil {
return nil, err
}
}
var kinds []string
for _, scope := range req.GetAuthorityScopes() {
for _, scope := range authorityScopes {
if len(scope.GetActions()) == 1 && scope.GetActions()[0] == "read" {
kinds = append(kinds, scope.GetResourceKind())
}
Expand All @@ -565,7 +576,7 @@ func (s *WorkContextAuthorityServer) StartTask(
direct := eligible
eligible = func(kind string) bool { return direct(kind) || slices.Contains(foreign, kind) }
}
permissions, scopes, err := workContextScopes(req.GetAuthorityScopes(), eligible)
permissions, scopes, err := workContextScopes(authorityScopes, eligible)
if err != nil {
return nil, status.Error(codes.InvalidArgument, err.Error())
}
Expand Down
Loading
Loading