From cf972cc2ecbdef9240de42bfdbd2243cc776aeed Mon Sep 17 00:00:00 2001 From: Luka Zhupa <5446951+lukazhupa@users.noreply.github.com> Date: Wed, 21 Oct 2026 18:02:28 -0400 Subject: [PATCH 1/2] feat(accounts): a module_actor binding names the exchanging module as the child's actor An installed operation binding marked `delegation_mode: module_actor` exchanges a person's parent through Core's Child with the authenticated calling module as its one actor hop, for any invoke scope and for a module or `solution:` audience. It reuses the installed content-read child: the binding id is namespaced (`installed-actor/`), commits to both installations' lifetimes, resolves for Core's seal check and is confirmed by the existing module-hop revision check. The hop is journaled. Ordinary bindings, the chain-preserving exchange and StartTask are unchanged. Part of #1200. --- module/WORK_CONTEXTS.md | 30 ++ module/services/accounts/AUTHZ.md | 6 + .../pkg/adapters/delegated_read_audience.go | 17 +- .../pkg/adapters/delegated_record_access.go | 2 +- .../pkg/adapters/installed_content_read.go | 34 ++- .../adapters/installed_content_read_test.go | 1 + .../adapters/installed_module_actor_test.go | 260 ++++++++++++++++++ .../code/pkg/adapters/readable_sources.go | 2 +- .../code/pkg/adapters/work_context_core.go | 4 +- .../code/pkg/adapters/work_context_rpcs.go | 16 +- .../business/installed_content_mode_test.go | 24 ++ .../pkg/business/installed_content_read.go | 104 +++++-- .../code/pkg/business/module_hop_binding.go | 14 +- .../pkg/business/module_operation_audience.go | 14 +- .../code/pkg/business/source_delegation.go | 6 +- .../code/pkg/business/work_context_seal.go | 4 +- 16 files changed, 468 insertions(+), 70 deletions(-) create mode 100644 module/services/accounts/code/pkg/adapters/installed_module_actor_test.go diff --git a/module/WORK_CONTEXTS.md b/module/WORK_CONTEXTS.md index 55ba8ebfc..a5df2b472 100644 --- a/module/WORK_CONTEXTS.md +++ b/module/WORK_CONTEXTS.md @@ -146,6 +146,36 @@ and installation checks. A host without the configured policy-log witness refuses a mode migration and leaves the stored envelope revision unchanged. No local substitute witness or automatically inferred migration is supported. +**Installed module actors.** + +`"delegation_mode": "module_actor"` is the same installed child for a module +that acts on a person's behalf with any invoke scope, so the callee can see +which module acted. It takes the content-read rules above except two: its +invoke scopes are not limited to `read`, and its audience may be a module or a +`solution:` audience. Lookup, when declared, still yields only lookup scopes. + +```json +{"audience":"solution:example-tools","delegation_mode":"module_actor","invoke_scopes":[{"resource_kind":"tickets","actions":["read","write"]}],"lookup_scopes":[{"resource_kind":"tickets","actions":["read"]}]} +``` + +The parent must be a current human-owned root with no actor hops, addressed to +the authenticated caller module. Core's Child operation derives owner, tenant, +task and expiry from that parent; the one hop it adds is the authenticated +caller's module principal, never one the request names, so it equals the owner +of the module Work Context the host issued that module. The hop is journaled +like any on-behalf-of hop. The binding id is namespaced `installed-actor/` and +commits to the caller's installation, presence and envelope facts and to the +audience's installation: a module audience's presence and installation, or the +organization's installation of a solution audience. Uninstalling, reinstalling +or narrowing either side retires every child already issued. A callee's +revision check confirms the hop from the binding it names +(`ConfirmModuleHopBinding`); only the owner is a question for the +authorization store. The child is terminal, as above. + +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. + 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, diff --git a/module/services/accounts/AUTHZ.md b/module/services/accounts/AUTHZ.md index c08f38683..209e5d293 100644 --- a/module/services/accounts/AUTHZ.md +++ b/module/services/accounts/AUTHZ.md @@ -175,6 +175,12 @@ a host without it refuses migration and preserves its current envelope. See [Work Contexts](../../WORK_CONTEXTS.md#installed-delegated-audience-exchange) for the envelope field, terminal-lifetime rules and full exchange contract. +`delegation_mode: module_actor` issues the same terminal child for any invoke +scope, addressed to a module or a `solution:` audience, so the callee sees the +installed module acting for the person. The person's own grants still decide +every scope; the module's hop adds none, and its binding commits to both +installations' lifetimes. + ## A read grant is standing to mint a content read A module reading content for a viewer presents that viewer's Work Context, diff --git a/module/services/accounts/code/pkg/adapters/delegated_read_audience.go b/module/services/accounts/code/pkg/adapters/delegated_read_audience.go index e40acd9ea..2012e54e4 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_read_audience.go +++ b/module/services/accounts/code/pkg/adapters/delegated_read_audience.go @@ -127,8 +127,8 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context default: return nil, refuseUnidentifiedParent() } - if business.NamesInstalledContentRead(verified.GetOperationBinding().GetBindingId()) { - return nil, status.Error(codes.PermissionDenied, "installed content child cannot be exchanged") + if business.NamesInstalledBinding(verified.GetOperationBinding().GetBindingId()) { + return nil, status.Error(codes.PermissionDenied, "installed binding child cannot be exchanged") } if verified.GetTenantId() == "" || verified.GetOwnerPrincipalId() == "" { return nil, refuseUnidentifiedParent() @@ -193,9 +193,12 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context return nil, err } operation, _ := binding.policy.(business.ModuleOperationAudience) - installedRead := operation.DelegationMode == business.DelegationModeContentRead - if installedRead && (delegated || len(parent.ActorChain) != 0 || parent.GetOwnerPrincipalKind() != business.PrincipalKindHuman) { - err := status.Error(codes.PermissionDenied, "installed content read requires an ordinary human parent") + // An installed delegation mode is the binding's, never the request's: the + // caller cannot ask for an ordinary child of it, or for this child of an + // ordinary binding, and the chain-preserving exchange stays as it was. + installed := operation.DelegationMode != "" + if installed && (delegated || len(parent.ActorChain) != 0 || parent.GetOwnerPrincipalKind() != business.PrincipalKindHuman) { + err := status.Error(codes.PermissionDenied, "installed binding requires an ordinary human parent") audit(business.DelegatedAudienceExchangeRefused, binding.audience, err) return nil, err } @@ -210,8 +213,8 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context var issued *gen.IssuedWorkContext if delegated { issued, err = authority.exchangeDelegatedParent(ctx, caller, parent, delegation, binding, time.Duration(ttl)*time.Second) - } else if installedRead { - issued, err = authority.exchangeInstalledContentRead(ctx, presented, caller, request, binding, time.Duration(ttl)*time.Second) + } else if installed { + issued, err = authority.exchangeInstalledBinding(ctx, presented, caller, request, binding, operation.DelegationMode, time.Duration(ttl)*time.Second) } else { issued, err = authority.exchangeVerifiedParent(ctx, parent, actor, &gen.ExchangeWorkContextAudienceRequest{OrgId: parent.TenantId, Audience: binding.audience, AttenuatedScopes: binding.scopes, ReplayPolicy: gen.WorkContextReplayPolicy_WORK_CONTEXT_REPLAY_POLICY_IDEMPOTENT, TtlSeconds: int32(ttl)}, "", moduleBindingSeal{PrincipalID: caller.PrincipalID, BindingID: request.operationBinding()}) } diff --git a/module/services/accounts/code/pkg/adapters/delegated_record_access.go b/module/services/accounts/code/pkg/adapters/delegated_record_access.go index fc0ff501f..e86fdb025 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_record_access.go +++ b/module/services/accounts/code/pkg/adapters/delegated_record_access.go @@ -56,7 +56,7 @@ func (s *ModuleCapabilitiesServer) CheckWorkContextRecordAccess(ctx context.Cont ctx = auth.WithVerifiedDatabaseIdentity(ctx, claims.GetOwnerPrincipalId(), claims.GetTenantId()) installedRead := business.NamesInstalledContentRead(claims.GetOperationBinding().GetBindingId()) if installedRead { - if err := authority.requireInstalledContentRead(ctx, claims); err != nil { + if err := authority.requireInstalledBinding(ctx, claims); err != nil { return nil, err } } diff --git a/module/services/accounts/code/pkg/adapters/installed_content_read.go b/module/services/accounts/code/pkg/adapters/installed_content_read.go index 329e5d4b7..2305b57a5 100644 --- a/module/services/accounts/code/pkg/adapters/installed_content_read.go +++ b/module/services/accounts/code/pkg/adapters/installed_content_read.go @@ -15,21 +15,27 @@ import ( "google.golang.org/grpc/status" ) -func installedContentReadError(err error) error { +func installedBindingError(err error) error { if errors.Is(err, business.ErrNoOperationBinding) || errors.Is(err, business.ErrModuleHopStale) || status.Code(err) == codes.PermissionDenied { - return status.Error(codes.PermissionDenied, "installed content read authority no longer stands") + return status.Error(codes.PermissionDenied, "installed binding authority no longer stands") } - return status.Error(codes.Unavailable, "installed content read authority unavailable") + return status.Error(codes.Unavailable, "installed binding authority unavailable") } -func (s *WorkContextAuthorityServer) exchangeInstalledContentRead(ctx context.Context, parent *workcontext.Verified, caller business.ModuleCaller, request delegatedAudienceRequest, selected delegatedAudienceBinding, ttl time.Duration) (*gen.IssuedWorkContext, error) { +// exchangeInstalledBinding issues the person's child of a binding installed +// under mode: Core derives owner, tenant, lineage and expiry from the presented +// parent, and the one hop it adds is the authenticated caller, never a +// principal the request names. The hop is journaled like any other +// on-behalf-of hop, in either mode, so the record of who acted outlives the +// token. +func (s *WorkContextAuthorityServer) exchangeInstalledBinding(ctx context.Context, parent *workcontext.Verified, caller business.ModuleCaller, request delegatedAudienceRequest, selected delegatedAudienceBinding, mode string, ttl time.Duration) (*gen.IssuedWorkContext, error) { claims := parent.Context() - identity, binding, err := service.InstalledContentReadBinding(ctx, caller, request.bindingID) + identity, binding, err := service.InstalledBinding(ctx, caller, request.bindingID, mode) if err != nil { - return nil, installedContentReadError(err) + return nil, installedBindingError(err) } if binding.Audience != selected.audience || claims.GetSeal().GetInstallationId() != identity.InstallationID { - return nil, status.Error(codes.PermissionDenied, "installed content parent mismatch") + return nil, status.Error(codes.PermissionDenied, "installed binding parent mismatch") } _, scopes, err := workContextScopes(selected.scopes, recheckContentReads()) if err != nil { @@ -43,7 +49,7 @@ func (s *WorkContextAuthorityServer) exchangeInstalledContentRead(ctx context.Co if err != nil { return nil, mapWorkContextError(err) } - if err := s.requireInstalledContentRead(ctx, signed); err != nil { + if err := s.requireInstalledBinding(ctx, signed); err != nil { return nil, err } if err := s.journalActorHop(ctx, signed); err != nil { @@ -53,21 +59,21 @@ func (s *WorkContextAuthorityServer) exchangeInstalledContentRead(ctx context.Co } // The installed module is authenticated by Core and the exact current binding; -// only the person supplies node visibility. This branch accepts no other chain. -func (s *WorkContextAuthorityServer) requireInstalledContentRead(ctx context.Context, claims *basev0.WorkContextV1) error { +// only the person supplies visibility. This branch accepts no other chain. +func (s *WorkContextAuthorityServer) requireInstalledBinding(ctx context.Context, claims *basev0.WorkContextV1) error { if claims.GetOwnerPrincipalKind() != business.PrincipalKindHuman || len(claims.ActorChain) != 1 { - return status.Error(codes.PermissionDenied, "installed content child shape refused") + return status.Error(codes.PermissionDenied, "installed binding child shape refused") } actor := claims.ActorChain[0] identity, err := service.WorkContextOperationBinding(ctx, claims.TenantId, claims.GetOperationBinding().GetBindingId()) if err != nil { - return installedContentReadError(err) + return installedBindingError(err) } if identity.PrincipalID != actor.PrincipalId || identity.InstallationID != claims.GetSeal().GetInstallationId() { - return status.Error(codes.PermissionDenied, "installed content child identity refused") + return status.Error(codes.PermissionDenied, "installed binding child identity refused") } if err := service.ConfirmModuleHopBinding(ctx, claims.TenantId, actor.PrincipalId, identity.ID, operationScopesFromCore(actor.GrantedScopes)); err != nil { - return installedContentReadError(err) + return installedBindingError(err) } facts, err := s.resolveAuthority(ctx, claims.TenantId, claims.OwnerPrincipalId, "", permissionsFromCoreScopes(actor.GrantedScopes, recheckContentReads())) if err != nil { diff --git a/module/services/accounts/code/pkg/adapters/installed_content_read_test.go b/module/services/accounts/code/pkg/adapters/installed_content_read_test.go index df9ee59d0..edfb6d221 100644 --- a/module/services/accounts/code/pkg/adapters/installed_content_read_test.go +++ b/module/services/accounts/code/pkg/adapters/installed_content_read_test.go @@ -141,6 +141,7 @@ func installedReadFixture(t *testing.T) (*installedReadStore, *WorkContextAuthor if prefix == "reader" { authority.Bindings = []solutionhost.AuthorityBinding{{ID: "evidence", Audience: "documents", Operations: map[string][]string{"invoke": {"documents:read"}, "lookup": {"documents:read"}}}, {ID: "execution", Audience: "compute", Operations: map[string][]string{"invoke": {"profiles:read", "profiles:invoke"}, "lookup": {"profiles:read"}}}} grant["operation_audiences"] = map[string]any{"evidence": map[string]any{"audience": "documents", "delegation_mode": "content_read", "lookup_scopes": []any{map[string]any{"resource_kind": "documents", "actions": []string{"read"}, "resource_ids": []string{"boundary-a"}}}, "invoke_scopes": []any{map[string]any{"resource_kind": "documents", "actions": []string{"read"}}}}, "execution": map[string]any{"audience": "compute", "invoke_scopes": []any{map[string]any{"resource_kind": "profiles", "actions": []string{"invoke", "read"}}}, "lookup_scopes": []any{map[string]any{"resource_kind": "profiles", "actions": []string{"read"}}}}} + addModuleActorBindings(authority, grant) } manifest := &solutionhost.ModuleManifest{Schema: solutionhost.SchemaModuleManifestV1, Binding: "acme.test." + prefix, Alias: prefix, Kind: solutionhost.KindModule, Generation: 1, Host: solutionhost.HostTarget{Coordinate: "host.test", Domain: "acme"}, Release: solutionhost.Release{Publisher: "acme", Name: prefix, Version: "1.0.0"}, Workloads: []solutionhost.Workload{{Service: "api", Container: "api"}}, Authority: authority} payload, err := solutionhost.SignedPayload(manifest) diff --git a/module/services/accounts/code/pkg/adapters/installed_module_actor_test.go b/module/services/accounts/code/pkg/adapters/installed_module_actor_test.go new file mode 100644 index 000000000..faa59cc1f --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/installed_module_actor_test.go @@ -0,0 +1,260 @@ +package adapters + +import ( + "context" + "strings" + "testing" + "time" + + "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + "github.com/codefly-dev/core/solutionhost" + 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/metadata" + "google.golang.org/grpc/status" +) + +const actorSolutionAudience = "solution:example-tools" + +// addModuleActorBindings gives the fixture's consuming module two module_actor +// bindings: one addressed to a module, one to a solution. +func addModuleActorBindings(authority *solutionhost.Authority, grant map[string]any) { + authority.Bindings = append(authority.Bindings, + solutionhost.AuthorityBinding{ID: "act", Audience: "compute", Operations: map[string][]string{"invoke": {"profiles:invoke", "profiles:read"}, "lookup": {"profiles:read"}}}, + solutionhost.AuthorityBinding{ID: "act-solution", Audience: actorSolutionAudience, Operations: map[string][]string{"invoke": {"tickets:read"}}}) + audiences := grant["operation_audiences"].(map[string]any) + audiences["act"] = map[string]any{"audience": "compute", "delegation_mode": "module_actor", + "invoke_scopes": []any{map[string]any{"resource_kind": "profiles", "actions": []string{"invoke", "read"}}}, + "lookup_scopes": []any{map[string]any{"resource_kind": "profiles", "actions": []string{"read"}}}} + audiences["act-solution"] = map[string]any{"audience": actorSolutionAudience, "delegation_mode": "module_actor", + "invoke_scopes": []any{map[string]any{"resource_kind": "tickets", "actions": []string{"read"}}}} +} + +// solutionInstallations answers the organization's installation of a solution +// audience; the other seal reads are not on this path. +type solutionInstallations struct { + business.WorkContextSealStore + installation string +} + +func (s *solutionInstallations) WorkContextSolutionInstallation(_ context.Context, org, binding string) (string, error) { + if org != readOrg || s.installation == "" || binding != strings.TrimPrefix(actorSolutionAudience, business.SolutionAudiencePrefix) { + return "", business.ErrWorkContextNoAudienceInstallation + } + return s.installation, nil +} + +type moduleActorJournal struct { + business.ActorChainJournal + hops []business.ActorChainHopInput +} + +func (j *moduleActorJournal) AppendActorChainHop(_ context.Context, hop business.ActorChainHopInput) (*business.ActorChainHop, error) { + j.hops = append(j.hops, hop) + return &business.ActorChainHop{}, nil +} + +func (j *moduleActorJournal) AnyActorChainHopRevoked(context.Context, string, []string) (bool, error) { + return false, nil +} + +type moduleActorCase struct { + f *installedReadStore + server *WorkContextAuthorityServer + consumer *workContextConsumerAuthorityFake + journal *moduleActorJournal + solutions *solutionInstallations + parent *gen.IssuedWorkContext + moduleToken string + moduleClaims *basev0.WorkContextV1 +} + +func moduleActorFixture(t *testing.T, scopes []*gen.WorkContextScope) moduleActorCase { + t.Helper() + f, server := installedReadFixture(t) + journal := &moduleActorJournal{} + server.journal = journal + consumer := &workContextConsumerAuthorityFake{} + server.consumer = consumer + solutions := &solutionInstallations{installation: uuid.NewString()} + service.SetWorkContextSealStore(solutions) + caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{}) + parent, err := server.StartTask(caller, &gen.StartTaskWorkContextRequest{OrgId: readOrg, TaskId: uuid.NewString(), Audience: "reader", AuthorityScopes: scopes}) + require.NoError(t, err) + moduleToken, _, err := server.StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("reader"), Tenant: readOrg, InstallationID: f.live[business.ModulePrincipalID("reader")].InstallationID}) + require.NoError(t, err) + module, err := corework.Decode(moduleToken) + require.NoError(t, err) + return moduleActorCase{f: f, server: server, consumer: consumer, journal: journal, solutions: solutions, parent: parent, moduleToken: moduleToken, moduleClaims: module.Context()} +} + +func (c moduleActorCase) exchange(binding, parent, credential string, lookup bool) (*gen.IssuedWorkContext, error) { + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "installed-read-test", workContextHeader, credential)) + return (&ModuleCapabilitiesServer{}).ExchangeDelegatedOperationAudience(ctx, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: binding, ParentWorkContextToken: parent, Lookup: lookup}) +} + +// revision asks the host whether a child still stands, as a callee does, +// naming the operation binding the child seals. +func (c moduleActorCase) revision(t *testing.T, child *basev0.WorkContextV1) error { + t.Helper() + subjects := []*gen.WorkContextRevisionSubject{{PrincipalId: child.OwnerPrincipalId, Scopes: wireScopes(child.AuthorityScopes)}} + for _, hop := range child.ActorChain { + subjects = append(subjects, &gen.WorkContextRevisionSubject{PrincipalId: hop.PrincipalId, Scopes: wireScopes(hop.GrantedScopes)}) + } + binding := child.GetOperationBinding().GetBindingId() + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "installed-read-test")) + _, err := c.server.CheckAuthorizationRevision(ctx, &gen.CheckAuthorizationRevisionRequest{OrgId: child.TenantId, OwnerPrincipalId: child.OwnerPrincipalId, AuthorizationRevision: child.AuthorizationRevision, Subjects: subjects, OperationBindingId: &binding}) + return err +} + +func wireScopes(scopes []*basev0.WorkScopeV1) []*gen.WorkContextScope { + out := make([]*gen.WorkContextScope, 0, len(scopes)) + for _, scope := range scopes { + out = append(out, &gen.WorkContextScope{ResourceKind: scope.ResourceKind, Actions: scope.Actions, ResourceIds: scope.ResourceIds}) + } + return out +} + +var moduleActorScopes = []*gen.WorkContextScope{ + {ResourceKind: "runs", Actions: []string{"execute"}}, + {ResourceKind: "profiles", Actions: []string{"invoke", "read"}}, + {ResourceKind: "tickets", Actions: []string{"read"}}, +} + +func TestModuleActorExchangeNamesTheExchangingModule(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + parentClaims, err := corework.Decode(c.parent.Token) + require.NoError(t, err) + parent := parentClaims.Context() + + for _, lookup := range []bool{false, true} { + issued, err := c.exchange("act", c.parent.Token, c.moduleToken, lookup) + require.NoError(t, err) + decoded, err := corework.Decode(issued.Token) + require.NoError(t, err) + child := decoded.Context() + + require.Len(t, child.ActorChain, 1) + hop := child.ActorChain[0] + // The hop is the module the host authenticated, the same principal that + // owns the module Work Context it issued that module; no request field + // names it. + require.Equal(t, c.moduleClaims.OwnerPrincipalId, hop.PrincipalId) + require.Equal(t, business.ModulePrincipalID("reader"), hop.PrincipalId) + require.Equal(t, business.PrincipalKindService, hop.PrincipalKind) + require.Equal(t, hop.PrincipalId, issued.CurrentActorPrincipalId) + want := []string{"invoke", "read"} + if lookup { + want = []string{"read"} + } + require.Len(t, hop.GrantedScopes, 1) + require.Equal(t, "profiles", hop.GrantedScopes[0].ResourceKind) + require.ElementsMatch(t, want, hop.GrantedScopes[0].Actions) + require.True(t, strings.HasPrefix(child.GetOperationBinding().GetBindingId(), "installed-actor/")) + + // Owner, tenant, task and lineage are the parent's, and the child never + // outlives it. + require.Equal(t, parent.OwnerPrincipalId, child.OwnerPrincipalId) + require.Equal(t, business.PrincipalKindHuman, child.GetOwnerPrincipalKind()) + require.Equal(t, parent.TenantId, child.TenantId) + require.Equal(t, parent.TaskId, child.TaskId) + require.Equal(t, "compute", child.Audience) + require.LessOrEqual(t, child.ExpiresAtUnix, parent.ExpiresAtUnix) + require.LessOrEqual(t, child.ExpiresAtUnix, time.Now().Add(61*time.Second).Unix()) + + // A callee's revision check confirms the hop from the binding it names; + // only the owner is a question for the store. + require.NoError(t, c.revision(t, child)) + require.Len(t, c.consumer.revisionSubjects, 1) + require.Equal(t, child.OwnerPrincipalId, c.consumer.revisionSubjects[0].PrincipalID) + } + require.Len(t, c.journal.hops, 2, "every module hop is journaled") + require.Equal(t, business.ModulePrincipalID("reader"), c.journal.hops[0].ActorPrincipalID) + + // The same parent through an ordinary binding keeps its owner-only shape. + ordinary, err := c.exchange("execution", c.parent.Token, c.moduleToken, false) + require.NoError(t, err) + decoded, err := corework.Decode(ordinary.Token) + require.NoError(t, err) + require.Empty(t, decoded.Context().ActorChain) +} + +func TestModuleActorChildIsTerminal(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + issued, err := c.exchange("act", c.parent.Token, c.moduleToken, false) + require.NoError(t, err) + _, err = c.exchange("act", issued.Token, c.moduleToken, false) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{}) + _, err = c.server.ExchangeAudience(caller, &gen.ExchangeWorkContextAudienceRequest{OrgId: readOrg, ParentWorkContextToken: issued.Token, Audience: "reader", AttenuatedScopes: moduleActorScopes}) + require.ErrorContains(t, err, "cannot be transformed") + actorCaller := stampVerifiedIdentity(context.Background(), business.ModulePrincipalID("reader"), readOrg, auth.Assurance{}) + _, err = c.server.RenewWorkContext(actorCaller, &gen.RenewWorkContextRequest{OrgId: readOrg, ParentWorkContextToken: issued.Token}) + require.ErrorContains(t, err, "cannot be renewed") +} + +func TestModuleActorExchangeRefusals(t *testing.T) { + t.Run("a credential that is not a module's", func(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + out, err := c.exchange("act", c.parent.Token, c.parent.Token, false) + require.Error(t, err) + require.Nil(t, out) + }) + t.Run("a binding not granted to the caller", func(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{}) + parent, err := c.server.StartTask(caller, &gen.StartTaskWorkContextRequest{OrgId: readOrg, TaskId: uuid.NewString(), Audience: "compute", AuthorityScopes: moduleActorScopes}) + require.NoError(t, err) + compute, _, err := c.server.StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("compute"), Tenant: readOrg, InstallationID: c.f.live[business.ModulePrincipalID("compute")].InstallationID}) + require.NoError(t, err) + out, err := c.exchange("act", parent.Token, compute, false) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Nil(t, out) + }) + t.Run("a parent that already has a chain", func(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + c.server.authority = &readExchangeAuthority{facts: &business.WorkContextAuthorityFacts{OrganizationRevision: 7, PrincipalRevision: 3}} + scopes := []*basev0.WorkScopeV1{{ResourceKind: "profiles", Actions: []string{"invoke", "read"}}} + token, _ := mintTestCapability(t, c.server, testCapability{Audience: "reader", Tenant: readOrg, Owner: readOwner, Scopes: scopes, Actors: []delegationHop{agentHop("actor-1", "hop-1", scopes...)}}) + out, err := c.exchange("act", token, c.moduleToken, false) + require.Error(t, err) + require.Nil(t, out) + }) + t.Run("authority broader than the parent", func(t *testing.T) { + c := moduleActorFixture(t, []*gen.WorkContextScope{{ResourceKind: "profiles", Actions: []string{"read"}}}) + out, err := c.exchange("act", c.parent.Token, c.moduleToken, false) + require.Error(t, err) + require.Nil(t, out) + require.Empty(t, c.journal.hops) + }) +} + +func TestModuleActorSolutionAudienceFollowsItsInstallation(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + issued, err := c.exchange("act-solution", c.parent.Token, c.moduleToken, false) + require.NoError(t, err) + decoded, err := corework.Decode(issued.Token) + require.NoError(t, err) + child := decoded.Context() + require.Equal(t, actorSolutionAudience, child.Audience) + require.Equal(t, c.moduleClaims.OwnerPrincipalId, child.ActorChain[0].PrincipalId) + require.NoError(t, c.revision(t, child)) + + // A reinstall is a new installation: the child exchanged for the old one no + // longer stands, and a fresh exchange does. + c.solutions.installation = uuid.NewString() + require.Equal(t, codes.PermissionDenied, status.Code(c.revision(t, child))) + _, err = c.exchange("act-solution", c.parent.Token, c.moduleToken, false) + require.NoError(t, err) + + // Uninstalled: nothing is exchanged for it. + c.solutions.installation = "" + out, err := c.exchange("act-solution", c.parent.Token, c.moduleToken, false) + require.Error(t, err) + require.Nil(t, out) +} diff --git a/module/services/accounts/code/pkg/adapters/readable_sources.go b/module/services/accounts/code/pkg/adapters/readable_sources.go index 2646b2525..30c13efc9 100644 --- a/module/services/accounts/code/pkg/adapters/readable_sources.go +++ b/module/services/accounts/code/pkg/adapters/readable_sources.go @@ -72,7 +72,7 @@ func (s *ModuleCapabilitiesServer) ListReadableSourceCollections(ctx context.Con ctx = auth.WithVerifiedDatabaseIdentity(ctx, claims.GetOwnerPrincipalId(), claims.GetTenantId()) installedRead := business.NamesInstalledContentRead(claims.GetOperationBinding().GetBindingId()) if installedRead { - if err := authority.requireInstalledContentRead(ctx, claims); err != nil { + if err := authority.requireInstalledBinding(ctx, claims); err != nil { return nil, err } } diff --git a/module/services/accounts/code/pkg/adapters/work_context_core.go b/module/services/accounts/code/pkg/adapters/work_context_core.go index b3d2e09f1..b1471fe69 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_core.go +++ b/module/services/accounts/code/pkg/adapters/work_context_core.go @@ -389,8 +389,8 @@ func (s *WorkContextAuthorityServer) remint( ctx context.Context, parent *basev0.WorkContextV1, audience, replay string, ttl time.Duration, attenuated []*basev0.WorkScopeV1, session string, exercised moduleBindingSeal, ) (string, *basev0.WorkContextV1, error) { - if business.NamesInstalledContentRead(parent.GetOperationBinding().GetBindingId()) { - return "", nil, status.Error(codes.PermissionDenied, "installed content child cannot be reminted") + if business.NamesInstalledBinding(parent.GetOperationBinding().GetBindingId()) { + return "", nil, status.Error(codes.PermissionDenied, "installed binding child cannot be reminted") } // The seal follows the owner. A person's capability is sealed to the // organization's installation of the audience it addresses — what an diff --git a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go index 0ef0b0a61..debe897a8 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go @@ -198,8 +198,8 @@ func (s *WorkContextAuthorityServer) confirmCurrentAuthority( wool.Field("org", req.GetOrgId()), wool.Field("owner", req.GetOwnerPrincipalId()), wool.Field("module", principalID), wool.Field("binding", req.GetOperationBindingId()), wool.ErrField(err)) - if business.NamesInstalledContentRead(req.GetOperationBindingId()) { - return installedContentReadError(err) + if business.NamesInstalledBinding(req.GetOperationBindingId()) { + return installedBindingError(err) } return status.Error(codes.PermissionDenied, business.ErrModuleHopStale.Error()) } @@ -1668,8 +1668,8 @@ func (s *WorkContextAuthorityServer) verifyParent( return nil, nil, refusedReadBack(ctx, err, status.Error(codes.PermissionDenied, "parent Work Context is not valid for caller")) } claims := parent.Context() - if business.NamesInstalledContentRead(claims.GetOperationBinding().GetBindingId()) { - return nil, nil, status.Error(codes.PermissionDenied, "installed content child cannot be transformed") + if business.NamesInstalledBinding(claims.GetOperationBinding().GetBindingId()) { + return nil, nil, status.Error(codes.PermissionDenied, "installed binding child cannot be transformed") } if claims.GetTenantId() != orgID || claims.GetOwnerPrincipalId() != ownerID { return nil, nil, status.Error(codes.PermissionDenied, "parent Work Context is not valid for caller") @@ -1700,8 +1700,8 @@ func (s *WorkContextAuthorityServer) verifyActorParent( return nil, nil, status.Error(codes.PermissionDenied, "parent Work Context is not valid") } claims := parent.Context() - if business.NamesInstalledContentRead(claims.GetOperationBinding().GetBindingId()) { - return nil, nil, status.Error(codes.PermissionDenied, "installed content child cannot be renewed") + if business.NamesInstalledBinding(claims.GetOperationBinding().GetBindingId()) { + return nil, nil, status.Error(codes.PermissionDenied, "installed binding child cannot be renewed") } actors := claims.GetActorChain() if len(actors) == 0 { @@ -1735,8 +1735,8 @@ func (s *WorkContextAuthorityServer) requireCurrentAuthority( orgID string, parent *basev0.WorkContextV1, ) (*business.Principal, error) { - if business.NamesInstalledContentRead(parent.GetOperationBinding().GetBindingId()) { - return nil, s.requireInstalledContentRead(ctx, parent) + if business.NamesInstalledBinding(parent.GetOperationBinding().GetBindingId()) { + return nil, s.requireInstalledBinding(ctx, parent) } ownerID := parent.GetOwnerPrincipalId() if len(parent.GetActorChain()) == 0 { diff --git a/module/services/accounts/code/pkg/business/installed_content_mode_test.go b/module/services/accounts/code/pkg/business/installed_content_mode_test.go index a0364f9e5..04a390286 100644 --- a/module/services/accounts/code/pkg/business/installed_content_mode_test.go +++ b/module/services/accounts/code/pkg/business/installed_content_mode_test.go @@ -42,3 +42,27 @@ func TestInstalledContentReadModeIsExplicitAndRestricted(t *testing.T) { require.Empty(t, grant.OperationAudiences["evidence"].SourceDelegationScopes) } } + +func TestInstalledModuleActorModeCarriesAnyInvokeScope(t *testing.T) { + invoke := []ModuleOperationScope{{ResourceKind: "tickets", Actions: []string{"read", "write"}}} + read := []ModuleOperationScope{{ResourceKind: "tickets", Actions: []string{"read"}}} + for _, audience := range []string{"compute", SolutionAudiencePrefix + "example-tools"} { + valid := ModuleOperationAudience{Audience: audience, InvokeScopes: invoke, LookupScopes: read, DelegationMode: DelegationModeModuleActor} + require.NoError(t, validateOperationAudiences("reader", map[string]ModuleOperationAudience{"act": valid})) + for name, change := range map[string]func(*ModuleOperationAudience){ + "no invoke": func(b *ModuleOperationAudience) { b.InvokeScopes, b.LookupScopes, b.HeadlessScopes = nil, nil, read }, + "headless": func(b *ModuleOperationAudience) { b.HeadlessScopes = read }, + "source": func(b *ModuleOperationAudience) { b.SourceDelegationScopes = invoke }, + } { + t.Run(audience+"/"+name, func(t *testing.T) { + b := valid + change(&b) + require.Error(t, validateOperationAudiences("reader", map[string]ModuleOperationAudience{"act": b})) + }) + } + } + require.True(t, NamesInstalledBinding(installedModuleActorPrefix+"x")) + require.True(t, NamesInstalledBinding(installedContentReadPrefix+"x")) + require.False(t, NamesInstalledContentRead(installedModuleActorPrefix+"x")) + require.False(t, NamesInstalledBinding("act")) +} diff --git a/module/services/accounts/code/pkg/business/installed_content_read.go b/module/services/accounts/code/pkg/business/installed_content_read.go index 33cde6232..473f71aa2 100644 --- a/module/services/accounts/code/pkg/business/installed_content_read.go +++ b/module/services/accounts/code/pkg/business/installed_content_read.go @@ -13,13 +13,39 @@ import ( "google.golang.org/grpc/status" ) -const installedContentReadPrefix = "installed-read/" +const ( + installedContentReadPrefix = "installed-read/" + installedModuleActorPrefix = "installed-actor/" +) + +// installedBindingPrefixes is the namespace each delegation mode's issuer-owned +// binding ids take, so the id alone says which mode resolves it. +var installedBindingPrefixes = map[string]string{ + DelegationModeContentRead: installedContentReadPrefix, + DelegationModeModuleActor: installedModuleActorPrefix, +} // NamesInstalledContentRead discriminates issuer-owned installed read bindings. func NamesInstalledContentRead(id string) bool { return strings.HasPrefix(id, installedContentReadPrefix) } +// NamesInstalledBinding discriminates an issuer-owned installed binding of any +// delegation mode. Its children are terminal: none is exchanged, reminted, +// renewed or transformed. +func NamesInstalledBinding(id string) bool { + return installedBindingMode(id) != "" +} + +func installedBindingMode(id string) string { + for mode, prefix := range installedBindingPrefixes { + if strings.HasPrefix(id, prefix) { + return mode + } + } + return "" +} + type installedContentReadID struct { Principal string Binding string @@ -27,9 +53,23 @@ type installedContentReadID struct { } // InstalledContentReadBinding resolves one person-invocable content-only binding. +func (s *Service) InstalledContentReadBinding(ctx context.Context, caller ModuleCaller, bindingID string) (WorkContextOperationBinding, ModuleOperationAudience, error) { + return s.InstalledBinding(ctx, caller, bindingID, DelegationModeContentRead) +} + +// InstalledBinding resolves one person-invocable binding installed under mode. // The identity commits to monotonic owner facts, not a hash of current policy: // removing and restoring identical policy can never revive a retained child. -func (s *Service) InstalledContentReadBinding(ctx context.Context, caller ModuleCaller, bindingID string) (WorkContextOperationBinding, ModuleOperationAudience, error) { +// +// content_read requires a module audience serving the content kinds it reads. +// module_actor requires only that the audience be installed in the +// organization, a module or a solution, and commits to that installation, so +// withdrawing the audience retires every child exchanged for it. +func (s *Service) InstalledBinding(ctx context.Context, caller ModuleCaller, bindingID, mode string) (WorkContextOperationBinding, ModuleOperationAudience, error) { + prefix, known := installedBindingPrefixes[mode] + if !known { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } ctx, releaseAuthority, err := s.BeginModuleAuthorityUse(ctx) if err != nil { return WorkContextOperationBinding{}, ModuleOperationAudience{}, err @@ -43,30 +83,43 @@ func (s *Service) InstalledContentReadBinding(ctx context.Context, caller Module if !ok { return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding } - if binding.DelegationMode != DelegationModeContentRead { + if binding.DelegationMode != mode { return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding } if binding.headlessOnly() || len(binding.SourceDelegationScopes) != 0 || len(binding.InvokeScopes) == 0 { return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding } - target, _, err := s.authorizeModuleCapabilityFromSnapshot(ctx, snapshot, ModuleCaller{PrincipalID: ModulePrincipalID(binding.Audience), BoundOrg: caller.BoundOrg}) - if err != nil { - return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + if origin.Grant.PresenceGeneration == 0 || snapshot.envelopeRevision == 0 { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding } - for _, scope := range binding.InvokeScopes { - if len(scope.Actions) != 1 || scope.Actions[0] != "read" || !slices.Contains(target.Grant.Resources, scope.ResourceKind) { + facts := []any{caller.BoundOrg, snapshot.envelopeRevision, + origin.Grant.PresenceBinding, origin.Grant.PresenceGeneration, origin.InstallationID, origin.InstallationRevision, origin.ProducerEpoch} + if mode == DelegationModeModuleActor && strings.HasPrefix(binding.Audience, SolutionAudiencePrefix) { + // A solution audience is held by the organization's installation of + // that solution, which an uninstall or a reinstall replaces. + installation, err := s.WorkContextAudienceInstallation(ctx, caller.BoundOrg, binding.Audience) + if err != nil { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + } + facts = append(facts, binding.Audience, installation) + } else { + target, _, err := s.authorizeModuleCapabilityFromSnapshot(ctx, snapshot, ModuleCaller{PrincipalID: ModulePrincipalID(binding.Audience), BoundOrg: caller.BoundOrg}) + if err != nil { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + } + for _, scope := range binding.InvokeScopes { + if mode == DelegationModeContentRead && (len(scope.Actions) != 1 || scope.Actions[0] != "read" || !slices.Contains(target.Grant.Resources, scope.ResourceKind)) { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + } + if target.Grant.PresenceGeneration == 0 { return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding } - } - if origin.Grant.PresenceGeneration == 0 || target.Grant.PresenceGeneration == 0 || snapshot.envelopeRevision == 0 { - return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + facts = append(facts, target.Grant.PresenceBinding, target.Grant.PresenceGeneration, target.InstallationID, target.InstallationRevision, target.ProducerEpoch) } if s.deliveredSince(ctx, snapshot) { return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrModuleRegistryStale } - facts := []any{caller.BoundOrg, snapshot.envelopeRevision, - origin.Grant.PresenceBinding, origin.Grant.PresenceGeneration, origin.InstallationID, origin.InstallationRevision, origin.ProducerEpoch, - target.Grant.PresenceBinding, target.Grant.PresenceGeneration, target.InstallationID, target.InstallationRevision, target.ProducerEpoch} encoded, err := json.Marshal(facts) if err != nil { return WorkContextOperationBinding{}, ModuleOperationAudience{}, err @@ -79,19 +132,30 @@ func (s *Service) InstalledContentReadBinding(ctx context.Context, caller Module if err := s.FenceModuleMint(ctx, ModuleWorkContextAuthority{PrincipalID: caller.PrincipalID, Tenant: caller.BoundOrg, InstallationID: origin.InstallationID, InstallationRevision: origin.InstallationRevision, snapshot: snapshot}); err != nil { return WorkContextOperationBinding{}, ModuleOperationAudience{}, err } - return WorkContextOperationBinding{ID: installedContentReadPrefix + base64.RawURLEncoding.EncodeToString(encoded), PrincipalID: caller.PrincipalID, InstallationID: origin.InstallationID, Revision: snapshot.envelopeRevision, Incarnation: origin.Grant.PresenceGeneration}, binding, nil + return WorkContextOperationBinding{ID: prefix + base64.RawURLEncoding.EncodeToString(encoded), PrincipalID: caller.PrincipalID, InstallationID: origin.InstallationID, Revision: snapshot.envelopeRevision, Incarnation: origin.Grant.PresenceGeneration}, binding, nil } -func (s *Service) resolveInstalledContentRead(ctx context.Context, org, encoded string) (WorkContextOperationBinding, error) { - raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(encoded, installedContentReadPrefix)) - if err != nil || len(raw) > 4096 { - return WorkContextOperationBinding{}, ErrNoOperationBinding +// decodeInstalledBinding reads the mode and the installed binding an issued id +// names. It authorizes nothing: the id is a pointer, re-resolved by its caller. +func decodeInstalledBinding(encoded string) (string, installedContentReadID, bool) { + mode := installedBindingMode(encoded) + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(encoded, installedBindingPrefixes[mode])) + if mode == "" || err != nil || len(raw) > 4096 { + return "", installedContentReadID{}, false } var id installedContentReadID if json.Unmarshal(raw, &id) != nil || id.Principal == "" || id.Binding == "" || id.Lifetime == "" { + return "", installedContentReadID{}, false + } + return mode, id, true +} + +func (s *Service) resolveInstalledBinding(ctx context.Context, org, encoded string) (WorkContextOperationBinding, error) { + mode, id, ok := decodeInstalledBinding(encoded) + if !ok { return WorkContextOperationBinding{}, ErrNoOperationBinding } - current, _, err := s.InstalledContentReadBinding(ctx, ModuleCaller{PrincipalID: id.Principal, BoundOrg: org}, id.Binding) + current, _, err := s.InstalledBinding(ctx, ModuleCaller{PrincipalID: id.Principal, BoundOrg: org}, id.Binding, mode) if err != nil { return WorkContextOperationBinding{}, err } diff --git a/module/services/accounts/code/pkg/business/module_hop_binding.go b/module/services/accounts/code/pkg/business/module_hop_binding.go index 6d0aa26b2..0adebc103 100644 --- a/module/services/accounts/code/pkg/business/module_hop_binding.go +++ b/module/services/accounts/code/pkg/business/module_hop_binding.go @@ -2,10 +2,7 @@ package business import ( "context" - "encoding/base64" - "encoding/json" "errors" - "strings" ) // ErrModuleHopStale is a module hop of a Work Context whose authority no longer @@ -40,20 +37,19 @@ func (s *Service) ConfirmModuleHopBinding(ctx context.Context, orgID, principalI } defer releaseAuthority() - if NamesInstalledContentRead(bindingID) { - identity, err := s.resolveInstalledContentRead(ctx, orgID, bindingID) + if NamesInstalledBinding(bindingID) { + identity, err := s.resolveInstalledBinding(ctx, orgID, bindingID) if err != nil { return err } if identity.PrincipalID != principalID { return ErrModuleHopStale } - raw, _ := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(bindingID, installedContentReadPrefix)) - var id installedContentReadID - if json.Unmarshal(raw, &id) != nil { + mode, id, ok := decodeInstalledBinding(bindingID) + if !ok { return ErrModuleHopStale } - _, binding, err := s.InstalledContentReadBinding(ctx, ModuleCaller{PrincipalID: principalID, BoundOrg: orgID}, id.Binding) + _, binding, err := s.InstalledBinding(ctx, ModuleCaller{PrincipalID: principalID, BoundOrg: orgID}, id.Binding, mode) if err != nil { return err } diff --git a/module/services/accounts/code/pkg/business/module_operation_audience.go b/module/services/accounts/code/pkg/business/module_operation_audience.go index 09233f92d..788b883c2 100644 --- a/module/services/accounts/code/pkg/business/module_operation_audience.go +++ b/module/services/accounts/code/pkg/business/module_operation_audience.go @@ -40,8 +40,13 @@ import ( // because the context it yields acts on that person's behalf. const DelegationModeContentRead = "content_read" +// DelegationModeModuleActor issues the person's child with the exchanging +// module as its one actor hop, so the callee sees who acts for the person. +const DelegationModeModuleActor = "module_actor" + // DelegationMode is host-owned: content_read requires an authentic installed -// module child and live personal visibility. Empty preserves ordinary exchanges. +// module child and live personal visibility; module_actor requires the same +// installed child for any invoke scope. Empty preserves ordinary exchanges. type ModuleOperationAudience struct { DelegationMode string `json:"delegation_mode,omitempty"` Audience string `json:"audience"` @@ -181,10 +186,13 @@ func validateOperationAudiences(prefix string, bindings map[string]ModuleOperati return fmt.Errorf("invalid installed module operation audience binding") } if binding.DelegationMode != "" { - if binding.DelegationMode != DelegationModeContentRead || len(binding.InvokeScopes) == 0 || binding.HeadlessScopes != nil || binding.SourceDelegationScopes != nil { - return fmt.Errorf("invalid installed content-read delegation mode") + if _, known := installedBindingPrefixes[binding.DelegationMode]; !known || len(binding.InvokeScopes) == 0 || binding.HeadlessScopes != nil || binding.SourceDelegationScopes != nil { + return fmt.Errorf("invalid installed delegation mode") } for _, scope := range binding.InvokeScopes { + if binding.DelegationMode != DelegationModeContentRead { + break + } if len(scope.Actions) != 1 || scope.Actions[0] != "read" { return fmt.Errorf("content-read delegation grants only read") } diff --git a/module/services/accounts/code/pkg/business/source_delegation.go b/module/services/accounts/code/pkg/business/source_delegation.go index 58ad41ba7..8c011fbae 100644 --- a/module/services/accounts/code/pkg/business/source_delegation.go +++ b/module/services/accounts/code/pkg/business/source_delegation.go @@ -808,10 +808,10 @@ func (s *Service) AuthorizeDelegationReferenceExchange( if !installed || validateOperationAudiences(grant.Prefix, map[string]ModuleOperationAudience{bindingID: binding}) != nil { return ErrSourceDelegationInvalid } - // Installed content reads require a human root and the exact installed + // Installed delegation modes require a human root and the exact installed // binding lifetime. A source reference seals a different authority and - // cannot be substituted, even when its read scopes happen to overlap. - if binding.DelegationMode == DelegationModeContentRead { + // cannot be substituted, even when its scopes happen to overlap. + if binding.DelegationMode != "" { return ErrSourceDelegationInvalid } // A person delegated this work, and a headless-only binding was granted diff --git a/module/services/accounts/code/pkg/business/work_context_seal.go b/module/services/accounts/code/pkg/business/work_context_seal.go index 992228124..fffa2377f 100644 --- a/module/services/accounts/code/pkg/business/work_context_seal.go +++ b/module/services/accounts/code/pkg/business/work_context_seal.go @@ -272,8 +272,8 @@ type WorkContextOperationBinding struct { // the person lost the role that let them delegate — is refused outright rather // than answered, exactly as a mint from it is. func (s *Service) WorkContextOperationBinding(ctx context.Context, orgID, bindingID string) (WorkContextOperationBinding, error) { - if NamesInstalledContentRead(bindingID) { - return s.resolveInstalledContentRead(ctx, orgID, bindingID) + if NamesInstalledBinding(bindingID) { + return s.resolveInstalledBinding(ctx, orgID, bindingID) } delegationID, usedBy, ok := parseSourceDelegationBindingID(bindingID) if !ok { From e86bbc8a748a419135113efd2293ab15552906a2 Mon Sep 17 00:00:00 2001 From: Luka Zhupa <5446951+lukazhupa@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:43:27 -0400 Subject: [PATCH 2/2] feat(accounts): module_actor names only what its audience serves, and reaches named sources A module_actor binding addressed to a module now names only kinds that module serves, the check content_read already makes. It may also address the host's own source operations: every scope names datasource.sources with explicit source ids and carries only invoke and read. The source surface then takes the child as the person's call with the module acting, for those sources only. The existing callee path (requireCurrentAuthority, then the installed binding) refuses it once the module's installation or the binding changes, or the person loses access to the source. Part of #1200. --- module/WORK_CONTEXTS.md | 25 ++- module/services/accounts/AUTHZ.md | 7 +- .../adapters/installed_content_read_test.go | 2 +- .../adapters/installed_module_actor_test.go | 144 ++++++++++++++++-- .../business/installed_content_mode_test.go | 2 +- .../pkg/business/installed_content_read.go | 45 +++++- .../business/installed_module_actor_test.go | 22 +++ .../pkg/business/source_operation_receipts.go | 4 + 8 files changed, 220 insertions(+), 31 deletions(-) create mode 100644 module/services/accounts/code/pkg/business/installed_module_actor_test.go diff --git a/module/WORK_CONTEXTS.md b/module/WORK_CONTEXTS.md index a5df2b472..5d7ef077e 100644 --- a/module/WORK_CONTEXTS.md +++ b/module/WORK_CONTEXTS.md @@ -149,10 +149,20 @@ No local substitute witness or automatically inferred migration is supported. **Installed module actors.** `"delegation_mode": "module_actor"` is the same installed child for a module -that acts on a person's behalf with any invoke scope, so the callee can see -which module acted. It takes the content-read rules above except two: its -invoke scopes are not limited to `read`, and its audience may be a module or a -`solution:` audience. Lookup, when declared, still yields only lookup scopes. +that acts on a person's behalf, so the callee can see which module acted. It +takes the content-read rules above except that its invoke scopes are not +limited to `read`. Its audience is one of three: + +- a module, which must serve every kind the binding names; +- a `solution:` audience, held by the organization's installation of that + solution; +- the host's own source operations (`saas-datasource`), where every scope names + `datasource.sources` with explicit source ids and carries only `invoke` and + `read`. The source surface then takes the call as the person's, with the + module acting, for those sources only, and rechecks the person's access to + the source on every call. + +Lookup, when declared, still yields only lookup scopes. ```json {"audience":"solution:example-tools","delegation_mode":"module_actor","invoke_scopes":[{"resource_kind":"tickets","actions":["read","write"]}],"lookup_scopes":[{"resource_kind":"tickets","actions":["read"]}]} @@ -165,9 +175,10 @@ caller's module principal, never one the request names, so it equals the owner of the module Work Context the host issued that module. The hop is journaled like any on-behalf-of hop. The binding id is namespaced `installed-actor/` and commits to the caller's installation, presence and envelope facts and to the -audience's installation: a module audience's presence and installation, or the -organization's installation of a solution audience. Uninstalling, reinstalling -or narrowing either side retires every child already issued. A callee's +audience's installation where it has one: a module audience's presence and +installation, or the organization's installation of a solution audience. +Uninstalling, reinstalling or narrowing either side retires every child +already issued. A callee's revision check confirms the hop from the binding it names (`ConfirmModuleHopBinding`); only the owner is a question for the authorization store. The child is terminal, as above. diff --git a/module/services/accounts/AUTHZ.md b/module/services/accounts/AUTHZ.md index 209e5d293..5118fd06a 100644 --- a/module/services/accounts/AUTHZ.md +++ b/module/services/accounts/AUTHZ.md @@ -175,11 +175,12 @@ a host without it refuses migration and preserves its current envelope. See [Work Contexts](../../WORK_CONTEXTS.md#installed-delegated-audience-exchange) for the envelope field, terminal-lifetime rules and full exchange contract. -`delegation_mode: module_actor` issues the same terminal child for any invoke -scope, addressed to a module or a `solution:` audience, so the callee sees the +`delegation_mode: module_actor` issues the same terminal child for invoke +scopes the audience serves, addressed to a module, a `solution:` audience or +the host's source operations for named sources, so the callee sees the installed module acting for the person. The person's own grants still decide every scope; the module's hop adds none, and its binding commits to both -installations' lifetimes. +installations' lifetimes where the audience has one. ## A read grant is standing to mint a content read diff --git a/module/services/accounts/code/pkg/adapters/installed_content_read_test.go b/module/services/accounts/code/pkg/adapters/installed_content_read_test.go index edfb6d221..bcbdf59fe 100644 --- a/module/services/accounts/code/pkg/adapters/installed_content_read_test.go +++ b/module/services/accounts/code/pkg/adapters/installed_content_read_test.go @@ -141,8 +141,8 @@ func installedReadFixture(t *testing.T) (*installedReadStore, *WorkContextAuthor if prefix == "reader" { authority.Bindings = []solutionhost.AuthorityBinding{{ID: "evidence", Audience: "documents", Operations: map[string][]string{"invoke": {"documents:read"}, "lookup": {"documents:read"}}}, {ID: "execution", Audience: "compute", Operations: map[string][]string{"invoke": {"profiles:read", "profiles:invoke"}, "lookup": {"profiles:read"}}}} grant["operation_audiences"] = map[string]any{"evidence": map[string]any{"audience": "documents", "delegation_mode": "content_read", "lookup_scopes": []any{map[string]any{"resource_kind": "documents", "actions": []string{"read"}, "resource_ids": []string{"boundary-a"}}}, "invoke_scopes": []any{map[string]any{"resource_kind": "documents", "actions": []string{"read"}}}}, "execution": map[string]any{"audience": "compute", "invoke_scopes": []any{map[string]any{"resource_kind": "profiles", "actions": []string{"invoke", "read"}}}, "lookup_scopes": []any{map[string]any{"resource_kind": "profiles", "actions": []string{"read"}}}}} - addModuleActorBindings(authority, grant) } + addModuleActorBindings(prefix, authority, grant) manifest := &solutionhost.ModuleManifest{Schema: solutionhost.SchemaModuleManifestV1, Binding: "acme.test." + prefix, Alias: prefix, Kind: solutionhost.KindModule, Generation: 1, Host: solutionhost.HostTarget{Coordinate: "host.test", Domain: "acme"}, Release: solutionhost.Release{Publisher: "acme", Name: prefix, Version: "1.0.0"}, Workloads: []solutionhost.Workload{{Service: "api", Container: "api"}}, Authority: authority} payload, err := solutionhost.SignedPayload(manifest) require.NoError(t, err) diff --git a/module/services/accounts/code/pkg/adapters/installed_module_actor_test.go b/module/services/accounts/code/pkg/adapters/installed_module_actor_test.go index faa59cc1f..a4931389b 100644 --- a/module/services/accounts/code/pkg/adapters/installed_module_actor_test.go +++ b/module/services/accounts/code/pkg/adapters/installed_module_actor_test.go @@ -2,6 +2,8 @@ package adapters import ( "context" + "errors" + "net/http" "strings" "testing" "time" @@ -9,6 +11,7 @@ import ( "accounts/pkg/auth" "accounts/pkg/business" gen "accounts/pkg/gen/saas/accounts/v1" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/core/solutionhost" corework "github.com/codefly-dev/core/workcontext" @@ -21,18 +24,35 @@ import ( const actorSolutionAudience = "solution:example-tools" -// addModuleActorBindings gives the fixture's consuming module two module_actor -// bindings: one addressed to a module, one to a solution. -func addModuleActorBindings(authority *solutionhost.Authority, grant map[string]any) { - authority.Bindings = append(authority.Bindings, - solutionhost.AuthorityBinding{ID: "act", Audience: "compute", Operations: map[string][]string{"invoke": {"profiles:invoke", "profiles:read"}, "lookup": {"profiles:read"}}}, - solutionhost.AuthorityBinding{ID: "act-solution", Audience: actorSolutionAudience, Operations: map[string][]string{"invoke": {"tickets:read"}}}) - audiences := grant["operation_audiences"].(map[string]any) - audiences["act"] = map[string]any{"audience": "compute", "delegation_mode": "module_actor", - "invoke_scopes": []any{map[string]any{"resource_kind": "profiles", "actions": []string{"invoke", "read"}}}, - "lookup_scopes": []any{map[string]any{"resource_kind": "profiles", "actions": []string{"read"}}}} - audiences["act-solution"] = map[string]any{"audience": actorSolutionAudience, "delegation_mode": "module_actor", - "invoke_scopes": []any{map[string]any{"resource_kind": "tickets", "actions": []string{"read"}}}} +const actorSource = "source-a" + +// addModuleActorBindings gives the fixture's consuming module module_actor +// bindings addressed to a module, a solution and the host's source operations, +// plus one naming a kind its module audience does not serve. The audience +// module serves the kind the first one names. +func addModuleActorBindings(prefix string, authority *solutionhost.Authority, grant map[string]any) { + switch prefix { + case "compute": + authority.ResourceKinds = append(authority.ResourceKinds, "profiles") + grant["resources"] = []string{"profiles"} + case "reader": + authority.Bindings = append(authority.Bindings, + solutionhost.AuthorityBinding{ID: "act", Audience: "compute", Operations: map[string][]string{"invoke": {"profiles:invoke", "profiles:read"}, "lookup": {"profiles:read"}}}, + solutionhost.AuthorityBinding{ID: "act-unserved", Audience: "compute", Operations: map[string][]string{"invoke": {"tickets:read"}}}, + solutionhost.AuthorityBinding{ID: "act-solution", Audience: actorSolutionAudience, Operations: map[string][]string{"invoke": {"tickets:read"}}}, + solutionhost.AuthorityBinding{ID: "act-source", Audience: business.SourceOperationAudience, Operations: map[string][]string{"invoke": {"datasource.sources:invoke", "datasource.sources:read"}, "lookup": {"datasource.sources:read"}}}) + audiences := grant["operation_audiences"].(map[string]any) + audiences["act"] = map[string]any{"audience": "compute", "delegation_mode": "module_actor", + "invoke_scopes": []any{map[string]any{"resource_kind": "profiles", "actions": []string{"invoke", "read"}}}, + "lookup_scopes": []any{map[string]any{"resource_kind": "profiles", "actions": []string{"read"}}}} + audiences["act-unserved"] = map[string]any{"audience": "compute", "delegation_mode": "module_actor", + "invoke_scopes": []any{map[string]any{"resource_kind": "tickets", "actions": []string{"read"}}}} + audiences["act-solution"] = map[string]any{"audience": actorSolutionAudience, "delegation_mode": "module_actor", + "invoke_scopes": []any{map[string]any{"resource_kind": "tickets", "actions": []string{"read"}}}} + audiences["act-source"] = map[string]any{"audience": business.SourceOperationAudience, "delegation_mode": "module_actor", + "invoke_scopes": []any{map[string]any{"resource_kind": "datasource.sources", "actions": []string{"invoke", "read"}, "resource_ids": []string{actorSource}}}, + "lookup_scopes": []any{map[string]any{"resource_kind": "datasource.sources", "actions": []string{"read"}, "resource_ids": []string{actorSource}}}} + } } // solutionInstallations answers the organization's installation of a solution @@ -124,6 +144,7 @@ var moduleActorScopes = []*gen.WorkContextScope{ {ResourceKind: "runs", Actions: []string{"execute"}}, {ResourceKind: "profiles", Actions: []string{"invoke", "read"}}, {ResourceKind: "tickets", Actions: []string{"read"}}, + {ResourceKind: "datasource.sources", Actions: []string{"invoke", "read"}, ResourceIds: []string{actorSource}}, } func TestModuleActorExchangeNamesTheExchangingModule(t *testing.T) { @@ -225,6 +246,13 @@ func TestModuleActorExchangeRefusals(t *testing.T) { require.Error(t, err) require.Nil(t, out) }) + t.Run("a kind its module audience does not serve", func(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + out, err := c.exchange("act-unserved", c.parent.Token, c.moduleToken, false) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Nil(t, out) + require.Empty(t, c.journal.hops) + }) t.Run("authority broader than the parent", func(t *testing.T) { c := moduleActorFixture(t, []*gen.WorkContextScope{{ResourceKind: "profiles", Actions: []string{"read"}}}) out, err := c.exchange("act", c.parent.Token, c.moduleToken, false) @@ -258,3 +286,95 @@ func TestModuleActorSolutionAudienceFollowsItsInstallation(t *testing.T) { require.Error(t, err) require.Nil(t, out) } + +// presentToSource presents a child to the host's source operation surface, as +// a source call does, and returns the context that call would run under. +func presentToSource(token string) (context.Context, error) { + headers := http.Header{} + headers.Set(workContextHeader, token) + return authenticateSourceOperationContext(context.Background(), headers) +} + +type sourceAccessWithdrawn struct { + business.WorkContextAuthorityStore +} + +func (sourceAccessWithdrawn) ResolveWorkContextAuthority(context.Context, string, string, string, []business.WorkContextPermission) (*business.WorkContextAuthorityFacts, error) { + return nil, business.NewStoreError(errors.New("no access to the source"), business.ErrTypePermission) +} + +func TestModuleActorSourceCallIsThePersonsWithTheModuleActing(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + for _, lookup := range []bool{false, true} { + issued, err := c.exchange("act-source", c.parent.Token, c.moduleToken, lookup) + require.NoError(t, err) + decoded, err := corework.Decode(issued.Token) + require.NoError(t, err) + child := decoded.Context() + require.Equal(t, business.SourceOperationAudience, child.Audience) + require.Equal(t, readOwner, child.OwnerPrincipalId) + require.Len(t, child.ActorChain, 1) + require.Equal(t, c.moduleClaims.OwnerPrincipalId, child.ActorChain[0].PrincipalId) + require.Len(t, child.ActorChain[0].GrantedScopes, 1) + granted := child.ActorChain[0].GrantedScopes[0] + require.Equal(t, business.SourceOperationResourceKind, granted.ResourceKind) + require.Equal(t, []string{actorSource}, granted.ResourceIds) + action := "invoke" + if lookup { + action = "read" + require.Equal(t, []string{"read"}, granted.Actions) + } + + // The source surface takes it as the person's call with the module + // acting, for the one source it names. + ctx, err := presentToSource(issued.Token) + require.NoError(t, err) + require.NoError(t, requireSourceOperationScope(ctx, readOrg, actorSource, action)) + require.Error(t, requireSourceOperationScope(ctx, readOrg, "source-b", action)) + } + require.Len(t, c.journal.hops, 2) +} + +// After one accepted call, each loss of standing refuses the same child at the +// source surface. +func TestModuleActorSourceChildRetiresWithItsStanding(t *testing.T) { + for name, revoke := range map[string]func(c moduleActorCase){ + "the module's installation is withdrawn": func(c moduleActorCase) { + c.f.live[business.ModulePrincipalID("reader")].InstallationRevision++ + }, + "the binding is narrowed": func(c moduleActorCase) { + registry := service.ModulePrincipals() + grant := registry[business.ModulePrincipalID("reader")] + audiences := make(map[string]business.ModuleOperationAudience, len(grant.OperationAudiences)) + for id, binding := range grant.OperationAudiences { + audiences[id] = binding + } + narrowed := audiences["act-source"] + narrowed.InvokeScopes = []business.ModuleOperationScope{{ResourceKind: business.SourceOperationResourceKind, Actions: []string{"read"}, ResourceIDs: []string{actorSource}}} + narrowed.LookupScopes = narrowed.InvokeScopes + audiences["act-source"] = narrowed + grant.OperationAudiences = audiences + registry[business.ModulePrincipalID("reader")] = grant + c.f.envelope.Revision = 2 + for _, live := range c.f.live { + v := uint64(2) + live.EnvelopeRevision = &v + } + service.InstallDerivedModulePrincipals(registry, 2, nil) + }, + "the person loses access to the source": func(c moduleActorCase) { + c.server.authority = sourceAccessWithdrawn{} + }, + } { + t.Run(name, func(t *testing.T) { + c := moduleActorFixture(t, moduleActorScopes) + issued, err := c.exchange("act-source", c.parent.Token, c.moduleToken, false) + require.NoError(t, err) + _, err = presentToSource(issued.Token) + require.NoError(t, err) + revoke(c) + _, err = presentToSource(issued.Token) + require.Error(t, err) + }) + } +} diff --git a/module/services/accounts/code/pkg/business/installed_content_mode_test.go b/module/services/accounts/code/pkg/business/installed_content_mode_test.go index 04a390286..a7f0c432f 100644 --- a/module/services/accounts/code/pkg/business/installed_content_mode_test.go +++ b/module/services/accounts/code/pkg/business/installed_content_mode_test.go @@ -43,7 +43,7 @@ func TestInstalledContentReadModeIsExplicitAndRestricted(t *testing.T) { } } -func TestInstalledModuleActorModeCarriesAnyInvokeScope(t *testing.T) { +func TestInstalledModuleActorModeShape(t *testing.T) { invoke := []ModuleOperationScope{{ResourceKind: "tickets", Actions: []string{"read", "write"}}} read := []ModuleOperationScope{{ResourceKind: "tickets", Actions: []string{"read"}}} for _, audience := range []string{"compute", SolutionAudiencePrefix + "example-tools"} { diff --git a/module/services/accounts/code/pkg/business/installed_content_read.go b/module/services/accounts/code/pkg/business/installed_content_read.go index 473f71aa2..66a505dac 100644 --- a/module/services/accounts/code/pkg/business/installed_content_read.go +++ b/module/services/accounts/code/pkg/business/installed_content_read.go @@ -61,10 +61,12 @@ func (s *Service) InstalledContentReadBinding(ctx context.Context, caller Module // The identity commits to monotonic owner facts, not a hash of current policy: // removing and restoring identical policy can never revive a retained child. // -// content_read requires a module audience serving the content kinds it reads. -// module_actor requires only that the audience be installed in the -// organization, a module or a solution, and commits to that installation, so -// withdrawing the audience retires every child exchanged for it. +// Both modes require a module audience to serve every kind they name, and +// content_read reads only. module_actor may also address a solution, held by +// the organization's installation of it, or the host's own source operations, +// one named source per scope and only its call and lookup. The identity +// commits to the audience's installation where there is one, so withdrawing +// the audience retires every child exchanged for it. func (s *Service) InstalledBinding(ctx context.Context, caller ModuleCaller, bindingID, mode string) (WorkContextOperationBinding, ModuleOperationAudience, error) { prefix, known := installedBindingPrefixes[mode] if !known { @@ -94,7 +96,19 @@ func (s *Service) InstalledBinding(ctx context.Context, caller ModuleCaller, bin } facts := []any{caller.BoundOrg, snapshot.envelopeRevision, origin.Grant.PresenceBinding, origin.Grant.PresenceGeneration, origin.InstallationID, origin.InstallationRevision, origin.ProducerEpoch} - if mode == DelegationModeModuleActor && strings.HasPrefix(binding.Audience, SolutionAudiencePrefix) { + switch { + case mode == DelegationModeModuleActor && binding.Audience == SourceOperationAudience: + // The host's own source surface has no installation to commit to, so + // the binding itself is held to what that surface serves: named + // sources, called or looked up. Whether the person may reach a source + // stays their own grant, rechecked when the call is made. + for _, scope := range append(slices.Clone(binding.InvokeScopes), binding.LookupScopes...) { + if !sourceOperationActorScope(scope) { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + } + facts = append(facts, binding.Audience) + case mode == DelegationModeModuleActor && strings.HasPrefix(binding.Audience, SolutionAudiencePrefix): // A solution audience is held by the organization's installation of // that solution, which an uninstall or a reinstall replaces. installation, err := s.WorkContextAudienceInstallation(ctx, caller.BoundOrg, binding.Audience) @@ -102,13 +116,16 @@ func (s *Service) InstalledBinding(ctx context.Context, caller ModuleCaller, bin return WorkContextOperationBinding{}, ModuleOperationAudience{}, err } facts = append(facts, binding.Audience, installation) - } else { + default: target, _, err := s.authorizeModuleCapabilityFromSnapshot(ctx, snapshot, ModuleCaller{PrincipalID: ModulePrincipalID(binding.Audience), BoundOrg: caller.BoundOrg}) if err != nil { return WorkContextOperationBinding{}, ModuleOperationAudience{}, err } for _, scope := range binding.InvokeScopes { - if mode == DelegationModeContentRead && (len(scope.Actions) != 1 || scope.Actions[0] != "read" || !slices.Contains(target.Grant.Resources, scope.ResourceKind)) { + if !slices.Contains(target.Grant.Resources, scope.ResourceKind) { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + if mode == DelegationModeContentRead && (len(scope.Actions) != 1 || scope.Actions[0] != "read") { return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding } } @@ -135,6 +152,20 @@ func (s *Service) InstalledBinding(ctx context.Context, caller ModuleCaller, bin return WorkContextOperationBinding{ID: prefix + base64.RawURLEncoding.EncodeToString(encoded), PrincipalID: caller.PrincipalID, InstallationID: origin.InstallationID, Revision: snapshot.envelopeRevision, Incarnation: origin.Grant.PresenceGeneration}, binding, nil } +// sourceOperationActorScope admits a source scope that names its sources and +// carries only a call and its lookup. +func sourceOperationActorScope(scope ModuleOperationScope) bool { + if scope.ResourceKind != SourceOperationResourceKind || len(scope.ResourceIDs) == 0 || len(scope.Actions) == 0 { + return false + } + for _, action := range scope.Actions { + if action != "invoke" && action != "read" { + return false + } + } + return true +} + // decodeInstalledBinding reads the mode and the installed binding an issued id // names. It authorizes nothing: the id is a pointer, re-resolved by its caller. func decodeInstalledBinding(encoded string) (string, installedContentReadID, bool) { diff --git a/module/services/accounts/code/pkg/business/installed_module_actor_test.go b/module/services/accounts/code/pkg/business/installed_module_actor_test.go new file mode 100644 index 000000000..020d1dd59 --- /dev/null +++ b/module/services/accounts/code/pkg/business/installed_module_actor_test.go @@ -0,0 +1,22 @@ +package business + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSourceOperationActorScopeNamesItsSources(t *testing.T) { + named := ModuleOperationScope{ResourceKind: SourceOperationResourceKind, Actions: []string{"invoke", "read"}, ResourceIDs: []string{"source-a"}} + require.True(t, sourceOperationActorScope(named)) + for name, scope := range map[string]ModuleOperationScope{ + "every source": {ResourceKind: SourceOperationResourceKind, Actions: []string{"invoke"}}, + "another kind": {ResourceKind: "tickets", Actions: []string{"read"}, ResourceIDs: []string{"source-a"}}, + "another action": {ResourceKind: SourceOperationResourceKind, Actions: []string{"invoke", "delete"}, ResourceIDs: []string{"source-a"}}, + "no action": {ResourceKind: SourceOperationResourceKind, ResourceIDs: []string{"source-a"}}, + } { + t.Run(name, func(t *testing.T) { + require.False(t, sourceOperationActorScope(scope)) + }) + } +} diff --git a/module/services/accounts/code/pkg/business/source_operation_receipts.go b/module/services/accounts/code/pkg/business/source_operation_receipts.go index 90141879c..5ebdf8f4e 100644 --- a/module/services/accounts/code/pkg/business/source_operation_receipts.go +++ b/module/services/accounts/code/pkg/business/source_operation_receipts.go @@ -12,6 +12,10 @@ import ( const SourceOperationAudience = "saas-datasource" +// SourceOperationResourceKind is the kind a source operation's scope names; its +// resource ids are the sources it reaches. +const SourceOperationResourceKind = "datasource.sources" + // SourceOperationReceiptWriter binds the SDK receipt to the host transaction. type SourceOperationReceiptWriter interface { RecordSourceOperationReceipt(context.Context, receipts.Store, proto.Message) error