diff --git a/module/WORK_CONTEXTS.md b/module/WORK_CONTEXTS.md index 55ba8ebfc..5d7ef077e 100644 --- a/module/WORK_CONTEXTS.md +++ b/module/WORK_CONTEXTS.md @@ -146,6 +146,47 @@ 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, 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"]}]} +``` + +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 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. + +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..5118fd06a 100644 --- a/module/services/accounts/AUTHZ.md +++ b/module/services/accounts/AUTHZ.md @@ -175,6 +175,13 @@ 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 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 where the audience has one. + ## 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..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 @@ -142,6 +142,7 @@ func installedReadFixture(t *testing.T) (*installedReadStore, *WorkContextAuthor 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(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 new file mode 100644 index 000000000..a4931389b --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/installed_module_actor_test.go @@ -0,0 +1,380 @@ +package adapters + +import ( + "context" + "errors" + "net/http" + "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" + +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 +// 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"}}, + {ResourceKind: "datasource.sources", Actions: []string{"invoke", "read"}, ResourceIds: []string{actorSource}}, +} + +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("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) + 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) +} + +// 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/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..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 @@ -42,3 +42,27 @@ func TestInstalledContentReadModeIsExplicitAndRestricted(t *testing.T) { require.Empty(t, grant.OperationAudiences["evidence"].SourceDelegationScopes) } } + +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"} { + 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..66a505dac 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,25 @@ 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) { +// +// 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 { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } ctx, releaseAuthority, err := s.BeginModuleAuthorityUse(ctx) if err != nil { return WorkContextOperationBinding{}, ModuleOperationAudience{}, err @@ -43,30 +85,58 @@ 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} + 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) + if err != nil { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + } + facts = append(facts, binding.Audience, installation) + 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 !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 + } + } + 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 +149,44 @@ 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 +// 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) { + 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/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/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/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 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 {