diff --git a/module/WORK_CONTEXTS.md b/module/WORK_CONTEXTS.md index 4c9aaee7b..d3845d1d7 100644 --- a/module/WORK_CONTEXTS.md +++ b/module/WORK_CONTEXTS.md @@ -101,8 +101,10 @@ and current revocation is checked again on the return exchange. ``` Resource kinds, actions and resource ids are sorted and unique; wildcards are -refused. Lookup scopes must be a subset of invoke scopes and may contain only -`read`. Removing or changing the installed binding revokes the exchange on the +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, +they must be non-empty, a subset of invoke scopes, and may contain only `read`. +Removing or changing the installed binding revokes the exchange on the next call, including the post-signing policy recheck. Every attempt emits a credential-free `saas.module.delegated_audience_exchange` observation (v2) with the effective actor, the authenticated module, the binding, the selected diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index 97c511425..ee08e2ec4 100644 Binary files a/module/contracts/api/accounts/authority/contract.binpb and b/module/contracts/api/accounts/authority/contract.binpb differ diff --git a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto index bc9c0e277..17fe6a96d 100644 --- a/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto +++ b/module/contracts/api/accounts/authority/proto/saas/accounts/v1/installations.proto @@ -133,14 +133,16 @@ message InstallSolutionRequest { // audiences it may be minted for and the resource kinds it may ever touch. // // Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an - // administrator consented to when they installed it. It is NOT unrestricted - // for a mint made with a credential delivered to a THIRD PARTY on a person's - // behalf — an MCP client the person authorized for this one solution. There - // the installation is the only statement of what the solution may act for, so - // an empty list is read as nothing and such a mint is REFUSED by name - // (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on - // purpose: one bounds a consumer an admin trusts, the other bounds a - // credential they never saw. + // administrator consented to when they installed it. + // + // A mint made with a credential delivered to a THIRD PARTY on a person's + // behalf — an MCP client the person authorized for this one solution — is not + // bounded by these lists. Its ceiling is the invoke bindings of the + // consumer's delivered authority, as this installation's scope consented to + // them when it was installed; a consumer with no delivered authority has no + // ceiling, and such a mint is refused. These lists can only narrow that + // ceiling: an empty list adds no bound, and a list naming more admits + // nothing extra. repeated string allowed_audiences = 10 [ (buf.validate.field).repeated.max_items = 64, (buf.validate.field).repeated.items.string.max_len = 512 diff --git a/module/contracts/api/accounts/connect/contract.binpb b/module/contracts/api/accounts/connect/contract.binpb index 97c511425..ee08e2ec4 100644 Binary files a/module/contracts/api/accounts/connect/contract.binpb and b/module/contracts/api/accounts/connect/contract.binpb differ diff --git a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto index bc9c0e277..17fe6a96d 100644 --- a/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto +++ b/module/contracts/api/accounts/connect/proto/saas/accounts/v1/installations.proto @@ -133,14 +133,16 @@ message InstallSolutionRequest { // audiences it may be minted for and the resource kinds it may ever touch. // // Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an - // administrator consented to when they installed it. It is NOT unrestricted - // for a mint made with a credential delivered to a THIRD PARTY on a person's - // behalf — an MCP client the person authorized for this one solution. There - // the installation is the only statement of what the solution may act for, so - // an empty list is read as nothing and such a mint is REFUSED by name - // (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on - // purpose: one bounds a consumer an admin trusts, the other bounds a - // credential they never saw. + // administrator consented to when they installed it. + // + // A mint made with a credential delivered to a THIRD PARTY on a person's + // behalf — an MCP client the person authorized for this one solution — is not + // bounded by these lists. Its ceiling is the invoke bindings of the + // consumer's delivered authority, as this installation's scope consented to + // them when it was installed; a consumer with no delivered authority has no + // ceiling, and such a mint is refused. These lists can only narrow that + // ceiling: an empty list adds no bound, and a list naming more admits + // nothing extra. repeated string allowed_audiences = 10 [ (buf.validate.field).repeated.max_items = 64, (buf.validate.field).repeated.items.string.max_len = 512 diff --git a/module/contracts/api/catalog.codefly.json b/module/contracts/api/catalog.codefly.json index 9f35f673c..003319932 100644 --- a/module/contracts/api/catalog.codefly.json +++ b/module/contracts/api/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/authority/contract.binpb", - "digest": "sha256:dbbea2526330d41a9f4ecaa7c384235a641340d769b4a45f58f61977fe7b0b24", + "digest": "sha256:16a2647b0f308e889ba45a6efa8f291ef590d19819c9128036eb476bf1dee8c3", "services": [ { "name": "ModuleCapabilitiesService", @@ -64,7 +64,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:dbbea2526330d41a9f4ecaa7c384235a641340d769b4a45f58f61977fe7b0b24", + "digest": "sha256:16a2647b0f308e889ba45a6efa8f291ef590d19819c9128036eb476bf1dee8c3", "services": [ { "name": "APIKeyService", diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index e9c70f098..884c93cb3 100644 --- a/module/module.package.codefly.yaml +++ b/module/module.package.codefly.yaml @@ -18,12 +18,12 @@ services: kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/authority/contract.binpb - digest: sha256:dbbea2526330d41a9f4ecaa7c384235a641340d769b4a45f58f61977fe7b0b24 + digest: sha256:16a2647b0f308e889ba45a6efa8f291ef590d19819c9128036eb476bf1dee8c3 - endpoint: connect kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/connect/contract.binpb - digest: sha256:dbbea2526330d41a9f4ecaa7c384235a641340d769b4a45f58f61977fe7b0b24 + digest: sha256:16a2647b0f308e889ba45a6efa8f291ef590d19819c9128036eb476bf1dee8c3 - name: auth-gateway endpoints: [grpc, rest] api-contracts: 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 a76ec5330..5fecf4e02 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_read_audience.go +++ b/module/services/accounts/code/pkg/adapters/delegated_read_audience.go @@ -367,6 +367,11 @@ func (s *ModuleCapabilitiesServer) ExchangeDelegatedOperationAudience(ctx contex } else { binding, err = service.ModuleOperationAudience(ctx, caller, tenant, parentAudience, req.BindingId) } + // A binding that declares no lookup redeems none: refused by name rather + // than signed as a capability carrying no scope at all. + if err == nil && req.Lookup && len(binding.LookupScopes) == 0 { + err = status.Errorf(codes.PermissionDenied, "operation audience binding %q declares no lookup", req.BindingId) + } return delegatedAudienceBinding{audience: binding.Audience, scopes: binding.WireScopes(req.Lookup), policy: binding}, err }) } diff --git a/module/services/accounts/code/pkg/adapters/work_context_confined_mint_test.go b/module/services/accounts/code/pkg/adapters/work_context_confined_mint_test.go index 75fef3d17..d336d47b8 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_confined_mint_test.go +++ b/module/services/accounts/code/pkg/adapters/work_context_confined_mint_test.go @@ -36,8 +36,9 @@ const ( confinedResource = "https://host.example.com/api/solutions/example/proxy/mcp" ) -// confinedConsumer is the installed consumer's ceiling: it may address the -// `documents` module and act on evidence, and nothing else. +// confinedConsumer is the installation's agent and its two lists, which narrow +// what the consumer declares (declaredConsumption): `documents` only, evidence +// only. The declaration is the ceiling; these may only take from it. func confinedConsumer() *business.InstallationAuthorityFacts { return &business.InstallationAuthorityFacts{ OwnerPrincipalID: renewActorID, @@ -89,7 +90,7 @@ func newConfinedMintServer(t *testing.T) *WorkContextAuthorityServer { // The consumer's own work: the audience its installation declares, a scope its // agent is granted, in its own organization. Admitted. func TestConfinedMintIsIssuedInsideItsConsumersCeiling(t *testing.T) { - solutionBoundaryService(t) + confinedConsumerService(t) withConfiguredOrigin(t) server := newConfinedMintServer(t) @@ -123,14 +124,14 @@ func TestConfinedMintIsRefusedOutsideItsConsumersCeiling(t *testing.T) { }, // 2. AN AUDIENCE THE CONSUMER DOES NOT DECLARE. `example` is in the // host's vocabulary — a declared module prefix — so the vocabulary gate - // admits it and only the installation's ceiling refuses it. + // admits it and only the consumer's declaration refuses it. "an audience the consumer does not declare": { request: func() *gen.StartTaskWorkContextRequest { return confinedMintRequest("example") }, expectedCode: codes.PermissionDenied, - expectedText: "allowed audiences", + expectedText: "is not a module the consumer", }, - // 3. A SCOPE OUTSIDE THE INSTALLATION'S GRANT. - "a scope the installation does not grant": { + // 3. A SCOPE THE CONSUMER DOES NOT DECLARE AT THAT MODULE. + "a scope the consumer does not declare": { request: func() *gen.StartTaskWorkContextRequest { req := confinedMintRequest("documents") req.AuthorityScopes = []*gen.WorkContextScope{{ @@ -139,7 +140,7 @@ func TestConfinedMintIsRefusedOutsideItsConsumersCeiling(t *testing.T) { return req }, expectedCode: codes.PermissionDenied, - expectedText: "allowed scopes", + expectedText: "outside what the consumer", }, // A confinement the perimeter admitted without naming an installation. // Nothing produces it, and a mint is not issued on a ceiling nobody can @@ -164,7 +165,7 @@ func TestConfinedMintIsRefusedOutsideItsConsumersCeiling(t *testing.T) { }, } { t.Run(name, func(t *testing.T) { - solutionBoundaryService(t) + confinedConsumerService(t) withConfiguredOrigin(t) server := newConfinedMintServer(t) ctx := confinedCallerContext(confinedResource, confinedInstallation) @@ -190,7 +191,7 @@ func TestConfinedMintIsRefusedOutsideItsConsumersCeiling(t *testing.T) { // same scope the consumer's ceiling refuses above are issued here, so the // refusals are about the confinement and not about the mint. func TestAnUnconfinedMintIsNotHeldToAnyConsumer(t *testing.T) { - solutionBoundaryService(t) + confinedConsumerService(t) withConfiguredOrigin(t) server := newConfinedMintServer(t) @@ -209,7 +210,7 @@ func TestAnUnconfinedMintIsNotHeldToAnyConsumer(t *testing.T) { // of the two refusals it is — an operator fixes a configuration, a client // re-authorizes. func TestConfinedMintRefusesWithNoConfiguredOrigin(t *testing.T) { - solutionBoundaryService(t) + confinedConsumerService(t) previous, _ := accountsauth.ConfiguredPublicOrigin() require.NoError(t, accountsauth.SetConfiguredPublicOrigin("")) t.Cleanup(func() { _ = accountsauth.SetConfiguredPublicOrigin(previous) }) @@ -283,7 +284,7 @@ func TestConfinedMintThroughTheRealInterceptor(t *testing.T) { }, } { t.Run(name, func(t *testing.T) { - solutionBoundaryService(t) + confinedConsumerService(t) withConfiguredOrigin(t) server := newConfinedMintServer(t) ctx, err := authorizeAsTheGateway(t, forwardedByTheGateway(true)) @@ -299,46 +300,12 @@ func TestConfinedMintThroughTheRealInterceptor(t *testing.T) { } } -// AN EMPTY CEILING IS NOTHING FOR A CONFINED MINT, where it is unrestricted -// everywhere else. -// -// This is the ordinary state, not an edge case: both fields are optional, the -// proto documents empty as unrestricted, and the install dialog leaves them -// empty. Read that way here, a credential delivered to one third-party client -// mints for any audience this host serves and then pairs at every module the -// organization installed — which is the escape the ceiling exists to close. -func TestConfinedMintUnderAnUndeclaredCeilingIsRefused(t *testing.T) { - for name, strip := range map[string]func(*business.Principal){ - "no declared audiences": func(p *business.Principal) { p.AllowedAudiences = nil }, - "no declared scopes": func(p *business.Principal) { p.AllowedScopes = nil }, - } { - t.Run(name, func(t *testing.T) { - solutionBoundaryService(t) - withConfiguredOrigin(t) - server, authority := newSolutionBoundaryServer(t) - consumer := confinedConsumer() - strip(consumer.Actor) - authority.installationFcts = consumer - ctx, err := authorizeAsTheGateway(t, forwardedByTheGateway(true)) - require.NoError(t, err) - - // `documents` is what this consumer's full ceiling DOES declare, so - // the only thing that can refuse it here is the empty list itself. - issued, err := server.StartTask(ctx, confinedMintRequest("documents")) - - require.Error(t, err, "an installation with %s put no bound on a credential delivered to a third party", name) - require.Nil(t, issued) - require.Equal(t, codes.FailedPrecondition, status.Code(err)) - require.Contains(t, err.Error(), "declares no allowed") - }) - } -} - -// THE CONTROL for the two above: the same empty ceiling puts no bound on an -// UNCONFINED mint, which is what the field means everywhere else and what an -// installation's own headless work relies on. +// An installation whose lists are empty puts no bound on an UNCONFINED mint, +// which is what the fields mean everywhere and what an installation's own +// headless work relies on — and no consumer's declaration is read for one: the +// audience and scope below are both outside what the consumer declares. func TestAnUndeclaredCeilingStillLeavesAnUnconfinedMintUnbounded(t *testing.T) { - solutionBoundaryService(t) + confinedConsumerService(t) withConfiguredOrigin(t) server, authority := newSolutionBoundaryServer(t) consumer := confinedConsumer() @@ -356,7 +323,7 @@ func TestAnUndeclaredCeilingStillLeavesAnUnconfinedMintUnbounded(t *testing.T) { // only once each. Without the credential they are deleted — which also leaves the // request unauthenticated, so there is nothing to mint for either. func TestCeilingHeadersCannotBeForged(t *testing.T) { - solutionBoundaryService(t) + confinedConsumerService(t) withConfiguredOrigin(t) t.Run("no gateway credential", func(t *testing.T) { diff --git a/module/services/accounts/code/pkg/adapters/work_context_consumer_ceiling_test.go b/module/services/accounts/code/pkg/adapters/work_context_consumer_ceiling_test.go new file mode 100644 index 000000000..92fdf77cd --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_consumer_ceiling_test.go @@ -0,0 +1,493 @@ +package adapters + +// THE CEILING OF A CONFINED MINT IS WHAT ITS CONSUMER DECLARES IT CONSUMES +// (issue #1083), and the installation's lists may only narrow it. +// +// The consumer's declaration reaches this host as its registry grant: the +// invoke bindings of its delivered contract, held to the envelope and to the +// organization's installation scope. These tests wire that grant through the +// real seams — the installation's delivered principal read from the store, the +// live authority read, AuthorizeModuleCapability — and run the whole StartTask. + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" +) + +const ( + // declaringConsumerPrefix is the consumer's own module principal: the + // principal its delivered authority is issued to, NOT the installation's + // per-organization agent. + declaringConsumerPrefix = "example-consumer" + declaringConsumerBinding = "workspace.prod.example-consumer" +) + +// declaredConsumption is what the consumer's contract declares it calls on a +// person's behalf: `documents`, where it may read and write evidence and read +// notes, and `rows`, where it may read evidence. It declares nothing at +// `example`, which IS a module this host serves. +func declaredConsumption() map[string]business.ModuleOperationAudience { + return map[string]business.ModuleOperationAudience{ + "documents": {Audience: "documents", InvokeScopes: []business.ModuleOperationScope{ + {ResourceKind: "evidence", Actions: []string{"read", "write"}}, + {ResourceKind: "notes", Actions: []string{"read"}}, + }}, + "rows": {Audience: "rows", InvokeScopes: []business.ModuleOperationScope{ + {ResourceKind: "evidence", Actions: []string{"read"}}, + }}, + } +} + +// consumerFixture is the declaring consumer's state on this host, each piece +// replaceable by the test that is about it. +type consumerFixture struct { + service *business.Service + // state is the installation row: its delivered principal and binding. + state *business.InstallationAuthorityState + // scope, when set, is the organization's installation scope in place of the + // full grant; liveErr replaces the live read outright. + scope *business.InstallationScope + liveErr error + + // The authority use span, observed: how many were opened, whether one is + // open now, and whether the live read and the owner's membership lookup — + // which comes after the ceiling decision and before the signature — each + // ran inside one. + spans int + open bool + readInSpan bool + ownerInSpan bool +} + +type authorityUseSpanKey struct{} + +func (f *consumerFixture) inSpan(ctx context.Context) bool { + return f.open && ctx.Value(authorityUseSpanKey{}) != nil +} + +func (f *consumerFixture) declare(bindings map[string]business.ModuleOperationAudience) { + registry := f.service.ModulePrincipals() + registry[business.ModulePrincipalID(declaringConsumerPrefix)] = business.ModulePrincipalGrant{ + Prefix: declaringConsumerPrefix, + PresenceBinding: declaringConsumerBinding, + OperationAudiences: bindings, + } + f.service.SetModulePrincipals(registry) +} + +// declaredConsumerStore reads the installation's delivered principal. +type declaredConsumerStore struct { + renewMembershipStore + fixture *consumerFixture +} + +func (s declaredConsumerStore) InstallationAuthorityState( + _ context.Context, _, id string, +) (*business.InstallationAuthorityState, error) { + if s.fixture.state == nil || id != confinedInstallation { + return nil, business.NewStoreError(business.ErrModuleInstallationInactive, business.ErrTypeNotFound) + } + state := *s.fixture.state + return &state, nil +} + +func (s declaredConsumerStore) GetOrgMembership(ctx context.Context, orgID, userID string) (*gen.OrgMembership, error) { + s.fixture.ownerInSpan = s.fixture.inSpan(ctx) + return s.renewMembershipStore.GetOrgMembership(ctx, orgID, userID) +} + +func (declaredConsumerStore) NarrowInstallationScope( + context.Context, string, string, uint64, business.InstallationScope, business.ModulePrincipalRegistry, uint64, +) error { + return errors.New("a mint never narrows an installation") +} + +// declaredConsumerAuthority is the live authority read: the installation the +// perimeter named, current, consenting to the whole grant unless told otherwise. +type declaredConsumerAuthority struct{ fixture *consumerFixture } + +func (a declaredConsumerAuthority) LiveModuleAuthority( + ctx context.Context, principal, _, _ string, +) (*business.LiveModuleAuthority, error) { + a.fixture.readInSpan = a.fixture.inSpan(ctx) + if a.fixture.liveErr != nil { + return nil, a.fixture.liveErr + } + grant := a.fixture.service.ModulePrincipals()[principal] + scope := business.FullInstallationScope(grant) + if a.fixture.scope != nil { + scope = *a.fixture.scope + } + return &business.LiveModuleAuthority{ + InstallationID: confinedInstallation, RegistryBindingID: grant.PresenceBinding, Scope: scope, + }, nil +} + +// BeginModuleAuthorityUse is re-entrant the way the Postgres store's is: a +// context already carrying a span reuses it, and only the outermost release +// ends it. +func (a declaredConsumerAuthority) BeginModuleAuthorityUse(ctx context.Context) (context.Context, func(), error) { + if ctx.Value(authorityUseSpanKey{}) != nil { + return ctx, func() {}, nil + } + a.fixture.spans++ + a.fixture.open = true + return context.WithValue(ctx, authorityUseSpanKey{}, true), func() { a.fixture.open = false }, nil +} + +// confinedConsumerService is solutionBoundaryService with the consumer the +// confined credential was delivered to installed and declaring its consumption. +func confinedConsumerService(t *testing.T) *consumerFixture { + t.Helper() + previous := service + fixture := &consumerFixture{state: &business.InstallationAuthorityState{ + Kind: "solution", + PrincipalID: business.ModulePrincipalID(declaringConsumerPrefix), + BindingID: declaringConsumerBinding, + Revision: 1, + Scope: business.InstallationScope{Capabilities: true}, + }} + svc, err := business.NewService(declaredConsumerStore{fixture: fixture}) + require.NoError(t, err) + fixture.service = svc + declareTestAudiences(svc) + fixture.declare(declaredConsumption()) + svc.SetModuleAuthorityReads(declaredConsumerAuthority{fixture: fixture}, nil) + service = svc + t.Cleanup(func() { service = previous }) + return fixture +} + +func confinedMint(t *testing.T, request *gen.StartTaskWorkContextRequest, actor func(*business.Principal)) (*gen.IssuedWorkContext, error) { + t.Helper() + withConfiguredOrigin(t) + server, authority := newSolutionBoundaryServer(t) + consumer := confinedConsumer() + if actor != nil { + actor(consumer.Actor) + } + authority.installationFcts = consumer + return server.StartTask(confinedCallerContext(confinedResource, confinedInstallation), request) +} + +func scoped(audience, kind string, actions ...string) *gen.StartTaskWorkContextRequest { + request := confinedMintRequest(audience) + request.AuthorityScopes = []*gen.WorkContextScope{{ResourceKind: kind, Actions: actions}} + return request +} + +// THE ISSUE'S OWN TEST: an audience the consumer does not declare is refused +// even when the installation's lists are empty or wider. `example` is a module +// this host serves, so the vocabulary admits it and nothing else refuses it. +func TestConfinedMintForAnUndeclaredAudienceIsRefusedWhateverTheInstallationLists(t *testing.T) { + for name, lists := range map[string]func(*business.Principal){ + "the installation's lists are empty": func(p *business.Principal) { + p.AllowedAudiences, p.AllowedScopes = nil, nil + }, + "the installation's lists are wider": func(p *business.Principal) { + p.AllowedAudiences = []string{"documents", "rows", "example"} + p.AllowedScopes = []string{"evidence", "notes", "billing"} + }, + } { + t.Run(name, func(t *testing.T) { + confinedConsumerService(t) + + issued, err := confinedMint(t, confinedMintRequest("example"), lists) + + require.Error(t, err, "nothing may be signed for an audience the consumer never declared") + require.Nil(t, issued) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Contains(t, err.Error(), `"example" is not a module the consumer`) + }) + } +} + +// The same for the scopes: a declared audience, asked for a kind or an action +// the consumer does not declare there, refused with wide or empty lists. +func TestConfinedMintOutsideTheDeclaredScopesIsRefusedWhateverTheInstallationLists(t *testing.T) { + for name, request := range map[string]func() *gen.StartTaskWorkContextRequest{ + "a kind it does not declare at the module": func() *gen.StartTaskWorkContextRequest { return scoped("documents", "billing", "write") }, + "an action it does not declare on the kind": func() *gen.StartTaskWorkContextRequest { return scoped("documents", "notes", "write") }, + "a kind it declares only at another module": func() *gen.StartTaskWorkContextRequest { return scoped("rows", "notes", "read") }, + "an action it declares only at another module": func() *gen.StartTaskWorkContextRequest { return scoped("rows", "evidence", "write") }, + } { + for lists, actor := range map[string]func(*business.Principal){ + "empty lists": func(p *business.Principal) { p.AllowedAudiences, p.AllowedScopes = nil, nil }, + "wider lists": func(p *business.Principal) { + p.AllowedAudiences = []string{"documents", "rows", "example"} + p.AllowedScopes = []string{"evidence", "notes", "billing"} + }, + } { + t.Run(name+", "+lists, func(t *testing.T) { + confinedConsumerService(t) + + issued, err := confinedMint(t, request(), actor) + + require.Error(t, err) + require.Nil(t, issued) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Contains(t, err.Error(), "outside what the consumer") + }) + } + } +} + +// THE CONTROLS. Every declared (audience, scope) is issued — with the +// installation's lists empty, which used to refuse every confined mint and now +// adds no bound, because the declaration is the ceiling. +func TestConfinedMintInsideTheDeclarationIsIssuedWithEmptyInstallationLists(t *testing.T) { + for name, request := range map[string]func() *gen.StartTaskWorkContextRequest{ + "documents, evidence read": func() *gen.StartTaskWorkContextRequest { return scoped("documents", "evidence", "read") }, + "documents, evidence write": func() *gen.StartTaskWorkContextRequest { return scoped("documents", "evidence", "write") }, + "documents, notes read": func() *gen.StartTaskWorkContextRequest { return scoped("documents", "notes", "read") }, + "rows, evidence read": func() *gen.StartTaskWorkContextRequest { return scoped("rows", "evidence", "read") }, + } { + t.Run(name, func(t *testing.T) { + confinedConsumerService(t) + + issued, err := confinedMint(t, request(), func(p *business.Principal) { + p.AllowedAudiences, p.AllowedScopes = nil, nil + }) + + require.NoError(t, err) + require.NotEmpty(t, issued.GetToken()) + }) + } +} + +// THE INSTALLATION'S LISTS STILL NARROW. Each request here is inside the +// declaration, so only the list can refuse it. +func TestTheInstallationsListsNarrowTheDeclaredCeiling(t *testing.T) { + t.Run("an audience the declaration names and the list does not", func(t *testing.T) { + confinedConsumerService(t) + + // confinedConsumer's list names `documents` only. + issued, err := confinedMint(t, scoped("rows", "evidence", "read"), nil) + + require.Nil(t, issued) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Contains(t, err.Error(), "allowed audiences") + }) + t.Run("a kind the declaration names and the list does not", func(t *testing.T) { + confinedConsumerService(t) + + // confinedConsumer's list names `evidence` only. + issued, err := confinedMint(t, scoped("documents", "notes", "read"), nil) + + require.Nil(t, issued) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Contains(t, err.Error(), "allowed scopes") + }) +} + +// The declaration is read AS THIS ORGANIZATION CONSENTED TO IT, not as +// delivered: a narrowed installation scope, a consumer that declares nothing, an +// installation with no delivered authority, and a withdrawn installation each +// refuse a mint the delivered declaration alone would admit. +func TestTheDeclaredCeilingIsReadAsConsented(t *testing.T) { + inside := func() *gen.StartTaskWorkContextRequest { return scoped("documents", "evidence", "read") } + + // Consent is fixed at install. A module the consumer declares that this + // installation does not consent to — it was installed before the consumer + // declared it, or an administrator narrowed it away — is refused as NOT + // CONSENTED, naming the remedy, never as undeclared. + t.Run("the installation does not consent to a declared module", func(t *testing.T) { + fixture := confinedConsumerService(t) + narrowed := business.FullInstallationScope(fixture.service.ModulePrincipals()[business.ModulePrincipalID(declaringConsumerPrefix)]) + delete(narrowed.OperationAudiences, "documents") + fixture.scope = &narrowed + + issued, err := confinedMint(t, inside(), nil) + + require.Nil(t, issued) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Contains(t, err.Error(), `declares this at audience "documents", and the installation it was admitted under does not consent to it`) + require.Contains(t, err.Error(), "installing it again consents") + }) + t.Run("the installation withdrew its consumer's capabilities", func(t *testing.T) { + fixture := confinedConsumerService(t) + fixture.state.Scope.Capabilities = false + + issued, err := confinedMint(t, inside(), nil) + + require.Nil(t, issued) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Contains(t, err.Error(), "withdrew its consumer's capabilities") + require.NotContains(t, err.Error(), "not active", "the installation is active; its consent is what withdrew them") + }) + t.Run("the consumer declares no consumption", func(t *testing.T) { + fixture := confinedConsumerService(t) + fixture.declare(nil) + + issued, err := confinedMint(t, inside(), nil) + + require.Nil(t, issued) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Contains(t, err.Error(), "is not a module the consumer") + }) + t.Run("the installation records no delivered authority", func(t *testing.T) { + fixture := confinedConsumerService(t) + fixture.state.PrincipalID = "" + + issued, err := confinedMint(t, inside(), nil) + + require.Nil(t, issued) + require.Equal(t, codes.FailedPrecondition, status.Code(err)) + require.Contains(t, err.Error(), "records no delivered authority") + }) + t.Run("the installation was withdrawn", func(t *testing.T) { + fixture := confinedConsumerService(t) + fixture.state = nil + + issued, err := confinedMint(t, inside(), nil) + + require.Nil(t, issued) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Contains(t, err.Error(), "no longer active") + }) + t.Run("the live authority cannot be read", func(t *testing.T) { + fixture := confinedConsumerService(t) + fixture.liveErr = errors.New("connection refused") + + issued, err := confinedMint(t, inside(), nil) + + require.Nil(t, issued) + require.Equal(t, codes.Unavailable, status.Code(err), "an unanswered question is not an allow and not a denial") + }) +} + +// The consumer's authority is held from the decision through the mint: one use +// span, opened before the ceiling is read and released only once StartTask has +// returned. An uninstall or a narrowed installation therefore waits for the +// capability rather than landing between the decision and the signature. +func TestConfinedMintHoldsTheConsumersAuthorityThroughTheMint(t *testing.T) { + fixture := confinedConsumerService(t) + + issued, err := confinedMint(t, scoped("documents", "evidence", "read"), nil) + + require.NoError(t, err) + require.NotEmpty(t, issued.GetToken()) + require.Equal(t, 1, fixture.spans, "one span, shared by the ceiling read") + require.True(t, fixture.readInSpan, "the consumer's live authority is read inside the span") + require.True(t, fixture.ownerInSpan, "the span is still held after the ceiling decision, while the mint proceeds") + require.False(t, fixture.open, "the span is released once the mint returns") +} + +func noLists(p *business.Principal) { p.AllowedAudiences, p.AllowedScopes = nil, nil } + +// A declaration bound to resource ids admits only requests naming a subset of +// them: "every evidence row" is not inside "row c1". +func TestAConfinedMintUnderAResourceBoundDeclaration(t *testing.T) { + for name, probe := range map[string]struct { + ids []string + code codes.Code + }{ + "no ids (every resource)": {nil, codes.PermissionDenied}, + "the declared id": {[]string{"c1"}, codes.OK}, + "an undeclared id": {[]string{"c2"}, codes.PermissionDenied}, + } { + t.Run(name, func(t *testing.T) { + fixture := confinedConsumerService(t) + fixture.declare(map[string]business.ModuleOperationAudience{"documents": {Audience: "documents", + InvokeScopes: []business.ModuleOperationScope{{ResourceKind: "evidence", Actions: []string{"read"}, ResourceIDs: []string{"c1"}}}}}) + request := confinedMintRequest("documents") + request.AuthorityScopes = []*gen.WorkContextScope{{ResourceKind: "evidence", Actions: []string{"read"}, ResourceIds: probe.ids}} + + _, err := confinedMint(t, request, noLists) + + require.Equal(t, probe.code, status.Code(err), "%v", err) + }) + } +} + +// Two bindings for one module, each declaring half of a request, do not +// together declare the whole of it. +func TestTwoBindingsForOneModuleAreNotUnioned(t *testing.T) { + fixture := confinedConsumerService(t) + fixture.declare(map[string]business.ModuleOperationAudience{ + "a": {Audience: "documents", InvokeScopes: []business.ModuleOperationScope{{ResourceKind: "evidence", Actions: []string{"read"}}}}, + "b": {Audience: "documents", InvokeScopes: []business.ModuleOperationScope{{ResourceKind: "notes", Actions: []string{"read"}}}}, + }) + request := confinedMintRequest("documents") + request.AuthorityScopes = []*gen.WorkContextScope{{ResourceKind: "evidence", Actions: []string{"read"}}, {ResourceKind: "notes", Actions: []string{"read"}}} + + _, err := confinedMint(t, request, noLists) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + + _, err = confinedMint(t, scoped("documents", "notes", "read"), noLists) + require.NoError(t, err, "control: each half alone is declared") +} + +// A confined mint addressed to the consumer ITSELF is refused by name: the +// credential already reaches the consumer's own routes, and no consumer can +// declare itself. +func TestAConfinedMintForTheConsumersOwnAudienceIsRefusedByName(t *testing.T) { + confinedConsumerService(t) + + issued, err := confinedMint(t, scoped(declaringConsumerPrefix, "evidence", "read"), func(p *business.Principal) { + p.AllowedAudiences = []string{declaringConsumerPrefix, "documents"} + }) + + require.Nil(t, issued) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Contains(t, err.Error(), "is the consumer this credential was delivered to") +} + +// redeliveryStore withdraws the consumer's declaration while the mint runs: +// after the ceiling was decided (the owner's membership is resolved after it) +// and before the signature. +type redeliveryStore struct { + declaredConsumerStore + onOwner func() +} + +func (s redeliveryStore) GetOrgMembership(ctx context.Context, orgID, userID string) (*gen.OrgMembership, error) { + s.onOwner() + return s.declaredConsumerStore.GetOrgMembership(ctx, orgID, userID) +} + +// A redelivery that withdraws the binding during the mint refuses it. The use +// span does not hold the in-memory registry, so the declaration is re-checked +// once the capability is signed and before it leaves. +func TestARedeliveryDuringTheMintRefusesIt(t *testing.T) { + fixture := confinedConsumerService(t) + svc, err := business.NewService(redeliveryStore{declaredConsumerStore{fixture: fixture}, func() { fixture.declare(nil) }}) + require.NoError(t, err) + declareTestAudiences(svc) + fixture.service = svc + fixture.declare(declaredConsumption()) + svc.SetModuleAuthorityReads(declaredConsumerAuthority{fixture: fixture}, nil) + service = svc + + issued, err := confinedMint(t, scoped("documents", "evidence", "read"), noLists) + + require.Nil(t, issued, "the declaration was withdrawn before the capability left") + require.Equal(t, codes.Aborted, status.Code(err)) + require.Contains(t, err.Error(), "changed what it declares while this capability was being minted") +} + +// The control for the one above: a pass that republishes the registry WITHOUT +// changing this consumer's declaration does not refuse the mint. +func TestARepublishThatChangesNothingDoesNotRefuseTheMint(t *testing.T) { + fixture := confinedConsumerService(t) + svc, err := business.NewService(redeliveryStore{declaredConsumerStore{fixture: fixture}, func() { fixture.declare(declaredConsumption()) }}) + require.NoError(t, err) + declareTestAudiences(svc) + fixture.service = svc + fixture.declare(declaredConsumption()) + svc.SetModuleAuthorityReads(declaredConsumerAuthority{fixture: fixture}, nil) + service = svc + + issued, err := confinedMint(t, scoped("documents", "evidence", "read"), noLists) + + require.NoError(t, err) + require.NotEmpty(t, issued.GetToken()) +} 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 35c5342d1..c110dbfb4 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go @@ -450,6 +450,25 @@ func (s *WorkContextAuthorityServer) StartTask( if err := Validate(req); err != nil { return nil, err } + // A CONFINED MINT IS DECIDED ON ITS CONSUMER'S LIVE AUTHORITY, and that + // decision has to hold until the capability is signed: an uninstall or a + // narrowed installation committed in between would otherwise leave a + // capability minted on consent that no longer exists. So the authority use + // span opens here, before any other transaction this mint takes — the + // order the fence connection is reserved in — and the consumer's ceiling + // is read inside it (business.ConsumerInvokeCeiling) and released only + // when this returns. The span holds the installation, its target and the + // envelope revision, not the in-memory registry a redelivery replaces; that + // half is re-checked after signing (business.ConsumerCeilingStillCurrent). + if _, confined := accountsauth.VerifiedResourceConfinement(ctx); confined && service != nil { + var releaseAuthority func() + var fenceErr error + ctx, releaseAuthority, fenceErr = service.BeginModuleAuthorityUse(ctx) + if fenceErr != nil { + return nil, fenceErr + } + defer releaseAuthority() + } if err := requireVocabularyAudience(ctx, req.GetAudience()); err != nil { return nil, err } @@ -469,7 +488,8 @@ func (s *WorkContextAuthorityServer) StartTask( // database scope, and that refusal names a database scope rather than a // consumer. Runs for a resource-confined request only; every other mint // reaches the viewer's authority below exactly as before. - if err := s.holdConfinedMintToItsConsumer(ctx, req, permissions, scopes); err != nil { + consumerCeiling, err := s.holdConfinedMintToItsConsumer(ctx, req, permissions, scopes) + if err != nil { return nil, err } ownerID, err := s.authorizeOwner(ctx, req.GetOrgId()) @@ -520,6 +540,15 @@ func (s *WorkContextAuthorityServer) StartTask( if err != nil { return nil, mapWorkContextError(err) } + // The consumer's declaration must still be the one the ceiling was decided + // against now that the capability is signed and before it leaves: a binding + // withdrawn by a redelivery in between refuses it (see + // business.ConsumerCeilingStillCurrent for what this does and does not hold). + if consumerCeiling != nil { + if err := service.ConsumerCeilingStillCurrent(ctx, consumerCeiling); err != nil { + return nil, err + } + } if err := s.journalActorHop(ctx, signed); err != nil { return nil, err } @@ -561,12 +590,20 @@ func (s *WorkContextAuthorityServer) StartTask( // membership path that this rule must not survive, and it names something // other than the consumer for an operator reading the refusal. // -// 2. THE AUDIENCE, and 3. THE SCOPES, both against the INSTALLATION the -// perimeter admitted under, which is an administrator's recorded consent to -// this consumer: its declared `allowed_audiences`, narrowed to what this host -// still serves, and its agent principal's standing scope grants. The viewer's -// own authority continues to bound the mint below; this is the second, -// narrower bound, and a capability must be inside both. +// 2. THE AUDIENCE, and 3. THE SCOPES, both against what the consumer the +// perimeter admitted under DECLARES it consumes: the invoke bindings of its +// delivered contract, as this organization's installation consents to them +// (business.ConsumerInvokeCeiling). The audience must be a module the +// consumer declares, and the scopes must fit one binding it declares there. +// A module the consumer declares that the installation did not consent to +// — consent is fixed at install — is refused as exactly that, and so is an +// audience naming the consumer itself. +// The installation's `allowed_audiences` and `allowed_scopes` are then an +// administrator's further narrowing — applied when they name something, +// and never a source of authority, so an installation whose lists are empty +// or wider than the declaration admits nothing the declaration does not. +// The viewer's own authority continues to bound the mint below; a +// capability must be inside all of them. // // What this does NOT re-derive is which installation the resource names. The // perimeter resolved resource → declared binding → target → the entitlement that @@ -586,10 +623,10 @@ func (s *WorkContextAuthorityServer) holdConfinedMintToItsConsumer( req *gen.StartTaskWorkContextRequest, permissions []business.WorkContextPermission, scopes []*basev0.WorkScopeV1, -) error { +) (*business.ConsumerCeiling, error) { resource, confined := accountsauth.VerifiedResourceConfinement(ctx) if !confined { - return nil + return nil, nil } origin, configured := accountsauth.ConfiguredPublicOrigin() if !configured { @@ -597,84 +634,107 @@ func (s *WorkContextAuthorityServer) holdConfinedMintToItsConsumer( // cannot judge one. The perimeter refuses a confined credential in the // same state, so the two sides agree rather than one admitting what the // other cannot account for. - return status.Error(codes.FailedPrecondition, + return nil, status.Error(codes.FailedPrecondition, "this host has no configured public address, so it cannot account for the resource this credential is confined to") } if _, err := accountsauth.RequireResourceAtOrigin(resource, origin); err != nil { - return status.Error(codes.PermissionDenied, + return nil, status.Error(codes.PermissionDenied, "the credential is confined to a resource this host does not issue") } caller, authenticated := accountsauth.VerifiedRequestIdentity(ctx) if !authenticated || caller.OrgID == uuid.Nil { - return status.Error(codes.PermissionDenied, + return nil, status.Error(codes.PermissionDenied, "a confined credential names no organization, so there is none to hold this mint to") } if caller.OrgID.String() != req.GetOrgId() { // Neither organization is named. One is the caller's own and the other is // theirs to read off the request they sent; printing them would make this // refusal an oracle for which organizations a person belongs to. - return status.Error(codes.PermissionDenied, + return nil, status.Error(codes.PermissionDenied, "a capability minted with a credential delivered to one consumer is issued only for that credential's own organization") } installation, admitted := accountsauth.VerifiedInstallation(ctx) if !admitted { - return status.Error(codes.FailedPrecondition, + return nil, status.Error(codes.FailedPrecondition, "a confined credential reached this mint without the installation it was admitted under, so there is no consented ceiling to hold it to") } + // THE CEILING IS WHAT THE CONSUMER DECLARES IT CONSUMES (issue #1083). + // + // Not the installation's lists: those are an administrator's hand-kept + // statement, optional, empty by default, and free to drift from what the + // consumer actually calls — too narrow breaks it, too wide lets its + // credential mint for modules it never uses. The consumer's delivered + // contract names every module it calls on a person's behalf and the scopes + // it asks for there, signed, held to the envelope and to this + // organization's installation scope, and that is what a credential + // delivered to it may act for. A consumer that declares no consumption of a + // module may not be handed a capability for it, whatever the lists say. + if service == nil { + return nil, status.Error(codes.FailedPrecondition, + "this host cannot read what an installed consumer declares, so there is no ceiling to hold its credential to") + } + ceiling, err := service.ConsumerInvokeCeiling(ctx, req.GetOrgId(), installation) + if err != nil { + if _, isStatus := status.FromError(err); isStatus { + return nil, err + } + return nil, status.Error(codes.Unavailable, "cannot read what the installed consumer declares") + } + switch ceiling.Admit(req.GetAudience(), moduleOperationScopes(scopes)) { + case business.ConsumerCeilingAdmitted: + case business.ConsumerCeilingOwnAudience: + return nil, status.Errorf(codes.PermissionDenied, + "audience %q is the consumer this credential was delivered to; the credential reaches that consumer's own routes directly, and no capability addressed to it is minted with it", req.GetAudience()) + case business.ConsumerCeilingNotConsented: + // Consent is fixed when an organization installs, so a module the + // consumer declared afterwards is outside it — and that, not the + // declaration, is what an operator has to change. + return nil, status.Errorf(codes.PermissionDenied, + "the consumer this credential was delivered to declares this at audience %q, and the installation it was admitted under does not consent to it: "+ + "consent is fixed when the consumer is installed, and installing it again consents to what it declares now", req.GetAudience()) + case business.ConsumerCeilingOutsideDeclared: + return nil, status.Errorf(codes.PermissionDenied, + "the scopes asked for are outside what the consumer this credential was delivered to declares it consumes at audience %q", req.GetAudience()) + default: + return nil, status.Errorf(codes.PermissionDenied, + "audience %q is not a module the consumer this credential was delivered to declares it consumes", req.GetAudience()) + } + + // THE INSTALLATION'S LISTS MAY ONLY NARROW. Read through the ordinary + // ceiling, where an empty list is no further bound — which is safe here, + // and only here, because the declaration above has already bounded the mint. consumer, err := s.resolveInstallationAuthority(ctx, req.GetOrgId(), installation, permissions) if err != nil { - return err + return nil, err } if consumer.Actor == nil { // A healthy resolve always names the installation's agent. Fail closed // rather than treat a missing ceiling as an absent one: enforceActorCeiling // reads a nil actor as unrestricted, which is the most permissive reading // of the state that says least. - return status.Error(codes.Internal, + return nil, status.Error(codes.Internal, "the consumer's installation resolved without an agent principal, so its ceiling could not be read") } - // AN EMPTY CEILING IS NOTHING HERE, where everywhere else it is everything. - // - // `allowed_audiences` and `allowed_scopes` are optional, and an empty list - // means UNRESTRICTED on every other mint — an installation that names no - // audience puts no audience limit on its agent's own headless work, which is - // the documented and intended reading for a consumer an administrator - // installed and trusts. The install form leaves both empty by default, so - // that is the ordinary state, not an edge case. - // - // For a CONFINED mint it inverts. The credential is held by a third party - // this organization never installed — an MCP client the person authorized for - // ONE solution — and the installation is the only statement of what that - // solution may act for. Read as unrestricted, it says the person's whole - // authority is available to that client at every module the organization - // installed, which is the escape the ceiling exists to close and is what the - // round-4 review found shipped. - // - // So it refuses, by name and actionably. A silent narrowing to "nothing" would - // have been the same decision with the operator left to discover it from a - // failing tool call. - // - // WHAT THIS IS NOT: the ceiling a consumer's own DECLARATION would give. The - // delivered presence document carries module PINS (module, package, version); - // the audience a capability names is the consumed module's ROUTE ALIAS; and no - // record on this host joins the two. Deriving one would be a guess at exactly - // the question this check answers, so the authority stays the installation an - // administrator consented to, and the gap is recorded rather than papered - // over (see the PR body and module/SOLUTION_REGISTRATION.md). - if len(consumer.Actor.AllowedAudiences) == 0 { - return status.Errorf(codes.FailedPrecondition, - "the installation of the consumer this credential was delivered to declares no allowed audiences, so there is nothing to hold its mint to. "+ - "An empty ceiling is unrestricted for an installation's own work and is NOT accepted for a credential delivered to a third party: "+ - "name the audiences agent %s may be minted for on that installation.", - consumer.Actor.AgentIdentifier) - } - if len(consumer.Actor.AllowedScopes) == 0 { - return status.Errorf(codes.FailedPrecondition, - "the installation of the consumer this credential was delivered to declares no allowed scopes, so there is nothing to bound its mint to. "+ - "Name the resource kinds agent %s may be minted for on that installation.", - consumer.Actor.AgentIdentifier) - } - return enforceActorCeiling(ctx, consumer.Actor, req.GetAudience(), scopes) + if err := enforceActorCeiling(ctx, consumer.Actor, req.GetAudience(), scopes); err != nil { + return nil, err + } + return ceiling, nil +} + +// moduleOperationScopes is a mint's scopes in the vocabulary a consumer's +// declared bindings are written in, for a containment check. Nothing is +// normalized: a requested action or resource id the declaration does not name +// verbatim is outside it. +func moduleOperationScopes(scopes []*basev0.WorkScopeV1) []business.ModuleOperationScope { + out := make([]business.ModuleOperationScope, 0, len(scopes)) + for _, scope := range scopes { + out = append(out, business.ModuleOperationScope{ + ResourceKind: scope.GetResourceKind(), + Actions: scope.GetActions(), + ResourceIDs: scope.GetResourceIds(), + }) + } + return out } // StartInstallationTask is the headless mint. It opens with a service credential diff --git a/module/services/accounts/code/pkg/business/consumer_invoke_ceiling.go b/module/services/accounts/code/pkg/business/consumer_invoke_ceiling.go new file mode 100644 index 000000000..ba13fb16f --- /dev/null +++ b/module/services/accounts/code/pkg/business/consumer_invoke_ceiling.go @@ -0,0 +1,268 @@ +package business + +import ( + "context" + "errors" + "maps" + "reflect" + "slices" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// THE CEILING OF A CREDENTIAL DELIVERED TO A THIRD PARTY (issue #1083). +// +// A resource-confined credential is held by a client a person authorized for ONE +// installed consumer, and a capability minted with it may act for no more than +// that consumer DECLARES it consumes, as this organization consented to it. +// +// The record read is the consumer's registry grant: the principal its delivered +// authority document is issued to, derived from the signed document, the applied +// presence it is granted over and the envelope. Its operation audiences name +// each module the consumer calls — the audience a capability for that module is +// addressed to — and the scopes of every operation it redeems there. The ceiling +// is their INVOKE half, because invoke is the operation that carries a person's +// own authority, attenuated, which is what a confined mint is. +// +// WHAT REACHES THAT RECORD TODAY. The renderer derives the authority document +// from a module's published contract (module.contract.codefly.yaml) and from +// nothing else. A solution that declares its consumption only in +// solution.codefly.yaml (`api.consumes`) has no authority document, so no +// registry grant, so this ceiling holds nothing for it and every confined mint +// it receives is refused. Rendering a solution's declared consumption into its +// authority document is the delivery half of #1083 and belongs to the renderer; +// this file is the host half and reads only what delivery produced. +// +// What is read is the grant AS THIS ORGANIZATION CONSENTED TO IT: the registry +// entry for the installation's delivered principal, intersected with the +// installation's scope, through the same decision every capability of that +// consumer goes through. Consent is fixed when the organization installs: a +// module the consumer starts declaring afterwards is outside the consent until +// the organization installs again, and the refusal says exactly that rather than +// calling a declared module undeclared. +// +// The installation's `allowed_audiences` / `allowed_scopes` are NOT read here. +// They are an administrator's lists and may only narrow what this returns; the +// caller applies them after. + +// ConsumerCeiling is one consumer's ceiling as decided for one mint. +type ConsumerCeiling struct { + // Consented is what the installation consents to: the declared invoke + // bindings intersected with the installation's scope. It is the ceiling. + Consented map[string]ModuleOperationAudience + // Declared is what the consumer's delivered authority declares now, before + // the installation's consent. Read only to say WHY a mint outside Consented + // is refused; it never admits anything. + Declared map[string]ModuleOperationAudience + // Own are the audiences that name the consumer itself. + Own []string + + principalID string + binding string + orgID string + installationID string +} + +// ConsumerCeilingVerdict is the answer for one (audience, scopes). +type ConsumerCeilingVerdict int + +const ( + // ConsumerCeilingAdmitted: inside one consented binding. + ConsumerCeilingAdmitted ConsumerCeilingVerdict = iota + // ConsumerCeilingOwnAudience: the audience is the consumer itself. + ConsumerCeilingOwnAudience + // ConsumerCeilingUndeclared: the consumer declares no binding at the audience. + ConsumerCeilingUndeclared + // ConsumerCeilingOutsideDeclared: declared at the audience, but the scopes + // fit no binding the consumer declares there. + ConsumerCeilingOutsideDeclared + // ConsumerCeilingNotConsented: inside what the consumer declares now, and + // outside what this installation consented to when it was made. + ConsumerCeilingNotConsented +) + +// Admit decides one capability against the ceiling. +// +// A CONFINED MINT IS NEVER ADDRESSED TO THE CONSUMER ITSELF. The credential is +// already confined to that consumer and reaches its own routes directly, so a +// capability for its own audience would add nothing it does not hold. It is +// refused by name, and BEFORE any binding is consulted: a binding may name its +// own module when it is self-admitting (headless or delegation work, see +// selfAdmittingBinding), and such a binding carries invoke scopes. Its purpose +// is unattended work with nobody present, never a person's credential held by a +// third party, so it must not admit a confined mint — and checking the +// audience first is what keeps it from doing so. +func (c ConsumerCeiling) Admit(audience string, scopes []ModuleOperationScope) ConsumerCeilingVerdict { + if slices.Contains(c.Own, audience) { + return ConsumerCeilingOwnAudience + } + if _, inside := ConsumerCeilingAdmits(c.Consented, audience, scopes); inside { + return ConsumerCeilingAdmitted + } + declared, inside := ConsumerCeilingAdmits(c.Declared, audience, scopes) + switch { + case inside: + return ConsumerCeilingNotConsented + case declared: + return ConsumerCeilingOutsideDeclared + } + return ConsumerCeilingUndeclared +} + +// ConsumerInvokeCeiling resolves the ceiling of the consumer installed as +// installationID in orgID. +// +// It fails closed. An installation that names no delivered principal — one made +// before installations recorded the authority they consent to — declares +// nothing, and is refused by name rather than read as declaring everything. So +// is one whose consent withdrew the consumer's capabilities. +func (s *Service) ConsumerInvokeCeiling(ctx context.Context, orgID, installationID string) (*ConsumerCeiling, error) { + if s == nil || s.store == nil { + return nil, status.Error(codes.FailedPrecondition, + "this host cannot read what an installed consumer declares, so there is no ceiling to hold its credential to") + } + store, ok := s.store.(InstallationScopeStore) + if !ok { + return nil, status.Error(codes.FailedPrecondition, + "this host cannot read an installation's delivered authority, so there is no ceiling to hold its credential to") + } + // The decision is only as current as the locks behind it. A caller that + // goes on to mint opens the use span first (see StartTask), so this one is + // the same span and its locks are held through the signature; opened here + // alone, it covers the read. + ctx, releaseAuthority, fenceErr := s.BeginModuleAuthorityUse(ctx) + if fenceErr != nil { + return nil, fenceErr + } + defer releaseAuthority() + + var state *InstallationAuthorityState + if err := s.store.WithOrgTx(ctx, orgID, func(ctx context.Context) error { + var err error + state, err = store.InstallationAuthorityState(ctx, orgID, installationID) + return err + }); err != nil { + var storeErr *StoreError + if errors.As(err, &storeErr) && storeErr.StoreErrorType == ErrTypeNotFound { + // The perimeter admitted under this installation a moment ago, so + // gone now means withdrawn in between: nothing is consented to. + return nil, status.Error(codes.PermissionDenied, + "the installation this credential was admitted under is no longer active") + } + return nil, status.Error(codes.Unavailable, "cannot read the installed consumer's delivered authority") + } + if state.PrincipalID == "" { + return nil, status.Error(codes.FailedPrecondition, + "the installation this credential was admitted under records no delivered authority, so its consumer declares nothing a credential may act for; "+ + "install the consumer again, which records the authority its delivered contract declares") + } + if !state.Scope.Capabilities { + // Named, because the live read below reports it as an inactive + // installation, and this installation is active: its consent withdrew + // the consumer's capabilities, which consent can only narrow. + return nil, status.Error(codes.PermissionDenied, + "the installation this credential was admitted under withdrew its consumer's capabilities, so a credential delivered to it may act for nothing; "+ + "an administrator restores them by installing the consumer again") + } + authority, snapshot, err := s.authorizeModuleCapability(ctx, ModuleCaller{ + PrincipalID: state.PrincipalID, + BoundOrg: orgID, + // The installation the perimeter named, and no other: the consumer's + // principal could in principle be installed again under a new id, and + // a ceiling read from that one would be consent nobody gave this + // credential. + Sealed: &SealedModuleAuthority{InstallationID: &installationID}, + }) + if err != nil { + return nil, err + } + // The declaration the decision was made against, from the SAME registry + // snapshot: never a second read that could disagree with it. + declared := snapshot.registry[state.PrincipalID] + ceiling := &ConsumerCeiling{ + Consented: authority.Grant.OperationAudiences, + Declared: declared.OperationAudiences, + principalID: state.PrincipalID, + binding: declared.PresenceBinding, + orgID: orgID, + installationID: installationID, + } + if declared.Prefix != "" { + ceiling.Own = append(ceiling.Own, declared.Prefix) + } + if declared.PresenceBinding != "" { + ceiling.Own = append(ceiling.Own, SolutionAudience(declared.PresenceBinding)) + } + return ceiling, nil +} + +// ConsumerCeilingStillCurrent refuses a capability whose consumer's ceiling +// changed after it was decided. +// +// The use span holds the installation, its target and the envelope revision; it +// does not hold the registry, which the reconciler replaces in memory when a new +// authority generation is applied. So a binding withdrawn between the decision +// and the signature would otherwise still be signed. A mint calls this AFTER +// signing and BEFORE the capability leaves the process, and it DECIDES AGAIN — +// the same live authority read, sealed to the same installation, in the same +// span — then requires the consumer's declaration and this installation's +// consent to be what they were. A withdrawal published before this refuses the +// capability; one published after it lands after issuance, which is the +// ordinary bound every capability has: its lifetime. +// +// It compares the declaration and the consent, not the registry's identity: +// the reconciler republishes on every pass, and a pass that changed nothing +// about this consumer must not refuse its mint. +func (s *Service) ConsumerCeilingStillCurrent(ctx context.Context, c *ConsumerCeiling) error { + if c == nil { + return nil + } + ctx, releaseAuthority, fenceErr := s.BeginModuleAuthorityUse(ctx) + if fenceErr != nil { + return fenceErr + } + defer releaseAuthority() + + changed := status.Error(codes.Aborted, + "the consumer this credential was delivered to changed what it declares while this capability was being minted, so it was not issued; ask again") + authority, snapshot, err := s.authorizeModuleCapability(ctx, ModuleCaller{ + PrincipalID: c.principalID, + BoundOrg: c.orgID, + Sealed: &SealedModuleAuthority{InstallationID: &c.installationID}, + }) + if err != nil { + if code := status.Code(err); code == codes.PermissionDenied || code == codes.Unauthenticated { + return changed + } + return err + } + current := snapshot.registry[c.principalID] + if current.PresenceBinding != c.binding || + !reflect.DeepEqual(current.OperationAudiences, c.Declared) || + !reflect.DeepEqual(authority.Grant.OperationAudiences, c.Consented) { + return changed + } + return nil +} + +// ConsumerCeilingAdmits reports whether a capability addressed to audience and +// carrying scopes lies inside a set of bindings: declared says the set names +// audience at all, and inside says the scopes fit ONE binding there. +// +// One binding, not their union: a capability is minted under a declaration, and +// two bindings for one module that each grant half of a request do not together +// declare the whole of it. +func ConsumerCeilingAdmits(bindings map[string]ModuleOperationAudience, audience string, scopes []ModuleOperationScope) (declared, inside bool) { + for _, id := range slices.Sorted(maps.Keys(bindings)) { + binding := bindings[id] + if audience == "" || binding.Audience != audience || len(binding.InvokeScopes) == 0 { + continue + } + declared = true + if operationScopesSubset(scopes, binding.InvokeScopes) { + return true, true + } + } + return declared, false +} diff --git a/module/services/accounts/code/pkg/business/consumer_invoke_ceiling_test.go b/module/services/accounts/code/pkg/business/consumer_invoke_ceiling_test.go new file mode 100644 index 000000000..085ef16b5 --- /dev/null +++ b/module/services/accounts/code/pkg/business/consumer_invoke_ceiling_test.go @@ -0,0 +1,259 @@ +package business + +import ( + "context" + "testing" + + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestConsumerCeilingAdmitsOnlyInsideOneDeclaredBinding(t *testing.T) { + bindings := map[string]ModuleOperationAudience{ + "documents-read": {Audience: "documents", InvokeScopes: []ModuleOperationScope{ + {ResourceKind: "evidence", Actions: []string{"read"}}, + }}, + "documents-notes": {Audience: "documents", InvokeScopes: []ModuleOperationScope{ + {ResourceKind: "notes", Actions: []string{"write"}}, + }}, + "rows": {Audience: "rows", InvokeScopes: []ModuleOperationScope{ + {ResourceKind: "evidence", Actions: []string{"read"}, ResourceIDs: []string{"a", "b"}}, + }}, + // A binding the installation scope emptied is no declaration at all. + "emptied": {Audience: "billing"}, + } + for name, probe := range map[string]struct { + audience string + scopes []ModuleOperationScope + declared, inside bool + }{ + "inside one binding": {"documents", []ModuleOperationScope{{ResourceKind: "evidence", Actions: []string{"read"}}}, true, true}, + "inside the other": {"documents", []ModuleOperationScope{{ResourceKind: "notes", Actions: []string{"write"}}}, true, true}, + // Each half is declared, by a different binding. Their union is not a + // declaration anyone made. + "split across two bindings": {"documents", []ModuleOperationScope{ + {ResourceKind: "evidence", Actions: []string{"read"}}, + {ResourceKind: "notes", Actions: []string{"write"}}, + }, true, false}, + "an action not declared": {"documents", []ModuleOperationScope{{ResourceKind: "evidence", Actions: []string{"write"}}}, true, false}, + "named resources, inside": {"rows", []ModuleOperationScope{{ResourceKind: "evidence", Actions: []string{"read"}, ResourceIDs: []string{"a"}}}, true, true}, + "named resources, outside": {"rows", []ModuleOperationScope{{ResourceKind: "evidence", Actions: []string{"read"}, ResourceIDs: []string{"c"}}}, true, false}, + // "every evidence row" is not inside "rows a and b". + "a whole kind under named resources": {"rows", []ModuleOperationScope{{ResourceKind: "evidence", Actions: []string{"read"}}}, true, false}, + "an audience nothing declares": {"example", []ModuleOperationScope{{ResourceKind: "evidence", Actions: []string{"read"}}}, false, false}, + "a binding with no invoke scopes": {"billing", nil, false, false}, + "no audience": {"", nil, false, false}, + } { + t.Run(name, func(t *testing.T) { + declared, inside := ConsumerCeilingAdmits(bindings, probe.audience, probe.scopes) + require.Equal(t, probe.declared, declared, "declared") + require.Equal(t, probe.inside, inside, "inside") + }) + } +} + +// consumerStateStore reads one installation's delivered principal. +type consumerStateStore struct { + Store + state *InstallationAuthorityState +} + +func (s consumerStateStore) WithOrgTx(ctx context.Context, _ string, fn func(context.Context) error) error { + return fn(ctx) +} + +func (s consumerStateStore) InstallationAuthorityState(context.Context, string, string) (*InstallationAuthorityState, error) { + return s.state, nil +} + +func (consumerStateStore) NarrowInstallationScope(context.Context, string, string, uint64, InstallationScope, ModulePrincipalRegistry, uint64) error { + return nil +} + +// liveUnder reports the consumer's principal as installed under one id. +type liveUnder struct { + service *Service + installation string +} + +func (l liveUnder) LiveModuleAuthority(_ context.Context, principal, _, _ string) (*LiveModuleAuthority, error) { + grant := l.service.ModulePrincipals()[principal] + return &LiveModuleAuthority{InstallationID: l.installation, RegistryBindingID: grant.PresenceBinding, Scope: FullInstallationScope(grant)}, nil +} + +func (liveUnder) BeginModuleAuthorityUse(ctx context.Context) (context.Context, func(), error) { + return ctx, func() {}, nil +} + +// The ceiling is read under the installation the perimeter NAMED. The same +// principal live under another installation is consent nobody gave this +// credential, and it is refused rather than read. +func TestConsumerInvokeCeilingIsReadUnderTheNamedInstallation(t *testing.T) { + const org = "11111111-1111-4111-8111-111111111111" + principal := ModulePrincipalID("example-consumer") + declared := map[string]ModuleOperationAudience{"documents": {Audience: "documents", InvokeScopes: []ModuleOperationScope{ + {ResourceKind: "evidence", Actions: []string{"read"}}, + }}} + for name, probe := range map[string]struct { + live string + want codes.Code + }{ + "the named installation": {"installation-named", codes.OK}, + "another installation": {"installation-other", codes.Unauthenticated}, + } { + t.Run(name, func(t *testing.T) { + svc, err := NewService(consumerStateStore{state: &InstallationAuthorityState{PrincipalID: principal, BindingID: "binding", Scope: InstallationScope{Capabilities: true}}}) + require.NoError(t, err) + svc.SetModulePrincipals(ModulePrincipalRegistry{principal: {Prefix: "example-consumer", PresenceBinding: "binding", OperationAudiences: declared}}) + svc.SetModuleAuthorityReads(liveUnder{service: svc, installation: probe.live}, nil) + + ceiling, err := svc.ConsumerInvokeCeiling(context.Background(), org, "installation-named") + + require.Equal(t, probe.want, status.Code(err), "err=%v", err) + if probe.want == codes.OK { + require.Len(t, ceiling.Consented, 1) + require.Equal(t, declared["documents"].Audience, ceiling.Consented["documents"].Audience) + require.Equal(t, declared["documents"].InvokeScopes, ceiling.Consented["documents"].InvokeScopes) + } else { + require.Nil(t, ceiling) + } + }) + } +} + +// derivedCeiling is a consumer's operation audiences DERIVED the way production +// derives them — envelope ∩ delivered authority document — not hand-written. +func derivedCeiling(t *testing.T, units ...solutionhost.AuthorityBinding) map[string]ModuleOperationAudience { + t.Helper() + envelope := parseTestEnvelope(t) + document := testAuthority(t, units...) + require.NoError(t, document.ValidateAgainst(envelope.authorityEnvelopeFor(document))) + grant, omitted, err := deriveModulePrincipalGrant(testModulePrefix, envelope.Principals[testModulePrefix], document, units, testWorkload) + require.NoError(t, err) + require.Empty(t, omitted) + return grant.OperationAudiences +} + +// What the consumer declares is bounded by the envelope AND by what its +// document asked for: the envelope allows profiles:{invoke,read,write}, the +// delivered unit asks profiles:read, and an audience the envelope lists that the +// document never asked for is not declared. +func TestTheDeclaredCeilingIsBoundedByTheEnvelopeAndTheDocument(t *testing.T) { + ceiling := derivedCeiling(t, testUnit("run", "invoke"), testUnit("run", "lookup")) + read := []ModuleOperationScope{{ResourceKind: "profiles", Actions: []string{"read"}, ResourceIDs: []string{"standard"}}} + + declared, inside := ConsumerCeilingAdmits(ceiling, testProducerAudience, read) + require.True(t, declared) + require.True(t, inside, "the declared, envelope-allowed read is admitted") + + _, inside = ConsumerCeilingAdmits(ceiling, testProducerAudience, + []ModuleOperationScope{{ResourceKind: "profiles", Actions: []string{"write"}, ResourceIDs: []string{"standard"}}}) + require.False(t, inside, "write: the envelope allows it, the document did not ask") + + _, inside = ConsumerCeilingAdmits(ceiling, testProducerAudience, + []ModuleOperationScope{{ResourceKind: "profiles", Actions: []string{"read"}}}) + require.False(t, inside, "the grant is bound to resource ids, so an unbounded request is outside") + + declared, _ = ConsumerCeilingAdmits(ceiling, "elsewhere", []ModuleOperationScope{{ResourceKind: "things", Actions: []string{"read"}}}) + require.False(t, declared, "the envelope lists `elsewhere`; the consumer never asked for it") +} + +// An INVOKE-ONLY declaration — the shape of a binding that verifies as the +// asking person and has nothing to look up — reaches the ceiling. It used to be +// dropped by the reconciler and then refused as undeclared. +func TestAnInvokeOnlyDeclarationReachesTheCeiling(t *testing.T) { + ceiling := derivedCeiling(t, testUnit("run", "invoke")) + + declared, inside := ConsumerCeilingAdmits(ceiling, testProducerAudience, + []ModuleOperationScope{{ResourceKind: "profiles", Actions: []string{"read"}, ResourceIDs: []string{"standard"}}}) + + require.True(t, declared, "the consumer's delivered authority declares invoke at this audience") + require.True(t, inside) +} + +// No consumer can declare itself, so a ceiling read from the declaration never +// admits a capability addressed to the consumer; Admit says so by name. +func TestAConsumerCannotDeclareItself(t *testing.T) { + err := validateOperationAudiences(testModulePrefix, map[string]ModuleOperationAudience{"self": { + Audience: testModulePrefix, + InvokeScopes: []ModuleOperationScope{{ResourceKind: "reports", Actions: []string{"read"}}}, + }}) + require.Error(t, err) +} + +// A SELF-ADMITTING binding — headless work the consumer admits under its own +// prefix — is a valid declaration and carries invoke scopes, and it still never +// admits a confined mint addressed to the consumer: the own-audience refusal +// comes before any binding is read. +func TestASelfAdmittingBindingNeverAdmitsAConfinedMint(t *testing.T) { + evidence := []ModuleOperationScope{{ResourceKind: "evidence", Actions: []string{"read"}}} + self := map[string]ModuleOperationAudience{"triggers": { + Audience: "example-consumer", InvokeScopes: evidence, HeadlessScopes: evidence, + }} + require.NoError(t, validateOperationAudiences("example-consumer", self), "the declaration itself is valid") + ceiling := ConsumerCeiling{Declared: self, Consented: self, Own: []string{"example-consumer"}} + + require.Equal(t, ConsumerCeilingOwnAudience, ceiling.Admit("example-consumer", evidence)) +} + +func TestConsumerCeilingVerdicts(t *testing.T) { + evidence := []ModuleOperationScope{{ResourceKind: "evidence", Actions: []string{"read"}}} + declared := map[string]ModuleOperationAudience{ + "documents": {Audience: "documents", InvokeScopes: evidence}, + "rows": {Audience: "rows", InvokeScopes: evidence}, + } + ceiling := ConsumerCeiling{ + Declared: declared, + Consented: map[string]ModuleOperationAudience{"documents": declared["documents"]}, + Own: []string{"example-consumer", SolutionAudience("binding")}, + } + for name, probe := range map[string]struct { + audience string + scopes []ModuleOperationScope + want ConsumerCeilingVerdict + }{ + "declared and consented": {"documents", evidence, ConsumerCeilingAdmitted}, + "declared after install": {"rows", evidence, ConsumerCeilingNotConsented}, + "declared, scopes outside": {"documents", []ModuleOperationScope{{ResourceKind: "evidence", Actions: []string{"write"}}}, ConsumerCeilingOutsideDeclared}, + "never declared": {"example", evidence, ConsumerCeilingUndeclared}, + "the consumer's module audience": {"example-consumer", evidence, ConsumerCeilingOwnAudience}, + "the consumer's solution audience": {SolutionAudience("binding"), evidence, ConsumerCeilingOwnAudience}, + "no audience": {"", evidence, ConsumerCeilingUndeclared}, + } { + t.Run(name, func(t *testing.T) { + require.Equal(t, probe.want, ceiling.Admit(probe.audience, probe.scopes)) + }) + } +} + +// The re-check after signing decides again and compares the consumer's +// declaration and this installation's consent, not the registry's identity: a +// republish that changed nothing passes; a withdrawn binding, a moved presence +// binding or a principal that left the registry refuses. +func TestConsumerCeilingStillCurrent(t *testing.T) { + const org = "11111111-1111-4111-8111-111111111111" + principal := ModulePrincipalID("example-consumer") + declared := map[string]ModuleOperationAudience{"documents": {Audience: "documents", InvokeScopes: []ModuleOperationScope{ + {ResourceKind: "evidence", Actions: []string{"read"}}, + }}} + svc, err := NewService(consumerStateStore{state: &InstallationAuthorityState{PrincipalID: principal, BindingID: "binding", Scope: InstallationScope{Capabilities: true}}}) + require.NoError(t, err) + svc.SetModuleAuthorityReads(liveUnder{service: svc, installation: "installation-named"}, nil) + svc.SetModulePrincipals(ModulePrincipalRegistry{principal: {Prefix: "example-consumer", PresenceBinding: "binding", OperationAudiences: declared}}) + decided, err := svc.ConsumerInvokeCeiling(context.Background(), org, "installation-named") + require.NoError(t, err) + + svc.SetModulePrincipals(ModulePrincipalRegistry{principal: {Prefix: "example-consumer", PresenceBinding: "binding", OperationAudiences: declared}}) + require.NoError(t, svc.ConsumerCeilingStillCurrent(context.Background(), decided), "republished, unchanged") + + svc.SetModulePrincipals(ModulePrincipalRegistry{principal: {Prefix: "example-consumer", PresenceBinding: "binding"}}) + require.Equal(t, codes.Aborted, status.Code(svc.ConsumerCeilingStillCurrent(context.Background(), decided)), "binding withdrawn") + + svc.SetModulePrincipals(ModulePrincipalRegistry{principal: {Prefix: "example-consumer", PresenceBinding: "binding-2", OperationAudiences: declared}}) + require.Equal(t, codes.Aborted, status.Code(svc.ConsumerCeilingStillCurrent(context.Background(), decided)), "presence binding moved") + + svc.SetModulePrincipals(ModulePrincipalRegistry{}) + require.Equal(t, codes.Aborted, status.Code(svc.ConsumerCeilingStillCurrent(context.Background(), decided)), "principal gone") +} diff --git a/module/services/accounts/code/pkg/business/installation_scope.go b/module/services/accounts/code/pkg/business/installation_scope.go index b58585c32..e634168b3 100644 --- a/module/services/accounts/code/pkg/business/installation_scope.go +++ b/module/services/accounts/code/pkg/business/installation_scope.go @@ -107,7 +107,7 @@ func intersectInstallationScope(g ModulePrincipalGrant, consent InstallationScop continue } binding.InvokeScopes = intersectOperationScopes(binding.InvokeScopes, installed.InvokeScopes) - binding.LookupScopes = intersectOperationScopes(binding.LookupScopes, installed.LookupScopes) + binding.LookupScopes = nilIfEmpty(intersectOperationScopes(binding.LookupScopes, installed.LookupScopes)) binding.HeadlessScopes = nilIfEmpty(intersectOperationScopes(binding.HeadlessScopes, installed.HeadlessScopes)) binding.SourceDelegationScopes = nilIfEmpty(intersectOperationScopes(binding.SourceDelegationScopes, installed.SourceDelegationScopes)) if len(binding.InvokeScopes) > 0 { diff --git a/module/services/accounts/code/pkg/business/module_envelope_test.go b/module/services/accounts/code/pkg/business/module_envelope_test.go index 7da79e7d8..b74d46869 100644 --- a/module/services/accounts/code/pkg/business/module_envelope_test.go +++ b/module/services/accounts/code/pkg/business/module_envelope_test.go @@ -2,7 +2,6 @@ package business import ( "encoding/json" - "errors" "maps" "slices" "strings" @@ -313,21 +312,22 @@ func TestTheDerivedEntryIsTheEnvelopeBoundedByTheDocument(t *testing.T) { require.NoError(t, ValidateModulePrincipalGrant(testModulePrefix, grant)) } -// TestAnAudienceWithoutItsLookupIsOmittedByName: a document granting the -// invoke unit and not the lookup one leaves an audience the registry cannot -// hold, so it is dropped and the reason returned — never filled in from the -// envelope. -func TestAnAudienceWithoutItsLookupIsOmittedByName(t *testing.T) { +// TestAnInvokeOnlyAudienceIsKept: a document granting the invoke unit and not +// the lookup one declares an audience that redeems no lookup. It is kept, with +// no lookup scopes — never filled in from the envelope, and never dropped as if +// the module had not declared it (the shape of a module that verifies, as the +// asking person, against a store it has nothing to look up in). +func TestAnInvokeOnlyAudienceIsKept(t *testing.T) { envelope := parseTestEnvelope(t) units := []solutionhost.AuthorityBinding{testUnit("run", "invoke")} document := testAuthority(t, units...) require.NoError(t, document.ValidateAgainst(envelope.authorityEnvelopeFor(document))) grant, omitted, err := deriveModulePrincipalGrant(testModulePrefix, envelope.Principals[testModulePrefix], document, units, testWorkload) require.NoError(t, err) - require.Empty(t, grant.OperationAudiences) - require.Len(t, omitted, 1) - require.True(t, errors.Is(omitted[0], ErrModulePrincipalBindingOmitted)) - require.Contains(t, omitted[0].Error(), `"run"`) + require.Empty(t, omitted) + require.Contains(t, grant.OperationAudiences, "run") + require.NotEmpty(t, grant.OperationAudiences["run"].InvokeScopes) + require.Nil(t, grant.OperationAudiences["run"].LookupScopes, "no lookup unit, no lookup scopes") } // TestAHeadlessUnitGrantsOnlyWhatBothSidesAllow: headless is the module acting 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 f94b475d6..d7c9ec2b8 100644 --- a/module/services/accounts/code/pkg/business/module_operation_audience.go +++ b/module/services/accounts/code/pkg/business/module_operation_audience.go @@ -149,10 +149,19 @@ func validateOperationAudiences(prefix string, bindings map[string]ModuleOperati for id, binding := range bindings { if !validOperationValue(id, 128) || !validOperationValue(binding.Audience, 512) || (binding.Audience == prefix && !selfAdmittingBinding(binding)) || - !validOperationScopes(binding.InvokeScopes, false) || !validOperationScopes(binding.LookupScopes, true) || - !operationScopesSubset(binding.LookupScopes, binding.InvokeScopes) { + !validOperationScopes(binding.InvokeScopes, false) { return fmt.Errorf("invalid installed module operation audience binding") } + // Lookup is OPTIONAL, like headless: a binding that declares only invoke + // redeems no lookup, and that is a whole declaration — a module verifying + // citations as the asking person has nothing to look up. Absent is "this + // binding redeems no lookup" and a lookup exchange under it is refused; + // present but empty is a misconfiguration, not a second spelling of absent. + // Requiring it dropped every invoke-only binding a module declared, and the + // binding then read as never declared at all. + if binding.LookupScopes != nil && (!validOperationScopes(binding.LookupScopes, true) || !operationScopesSubset(binding.LookupScopes, binding.InvokeScopes)) { + return fmt.Errorf("invalid installed module operation audience lookup scopes") + } // Absent is the fail-closed default (no headless mint); present but empty // is a misconfiguration rather than a second spelling of absent. if binding.HeadlessScopes != nil && (!validOperationScopes(binding.HeadlessScopes, false) || !operationScopesSubset(binding.HeadlessScopes, binding.InvokeScopes)) { diff --git a/module/services/accounts/code/pkg/business/module_operation_audience_test.go b/module/services/accounts/code/pkg/business/module_operation_audience_test.go index f2a8b88b1..ed39e8089 100644 --- a/module/services/accounts/code/pkg/business/module_operation_audience_test.go +++ b/module/services/accounts/code/pkg/business/module_operation_audience_test.go @@ -26,13 +26,21 @@ func TestInstalledOperationAudiencePolicy(t *testing.T) { invalid := []ModuleOperationAudience{ {}, {Audience: "example", InvokeScopes: valid.InvokeScopes, LookupScopes: valid.LookupScopes}, - {Audience: "other", InvokeScopes: valid.InvokeScopes}, + // Lookup present but empty is a misconfiguration, not "no lookup". + {Audience: "other", InvokeScopes: valid.InvokeScopes, LookupScopes: []ModuleOperationScope{}}, {Audience: "other", InvokeScopes: valid.InvokeScopes, LookupScopes: []ModuleOperationScope{{ResourceKind: "results", Actions: []string{"write"}, ResourceIDs: []string{"result-1"}}}}, {Audience: "other", InvokeScopes: valid.InvokeScopes, LookupScopes: []ModuleOperationScope{{ResourceKind: "results", Actions: []string{"read"}}}}, } for _, binding := range invalid { require.Error(t, validateOperationAudiences("example", map[string]ModuleOperationAudience{"generate": binding})) } + + // INVOKE-ONLY IS A WHOLE DECLARATION: absent lookup is "this binding + // redeems no lookup", and the binding is kept. + require.NoError(t, validateOperationAudiences("example", map[string]ModuleOperationAudience{ + "verify": {Audience: "other", InvokeScopes: valid.InvokeScopes}, + })) + require.Empty(t, ModuleOperationAudience{Audience: "other", InvokeScopes: valid.InvokeScopes}.WireScopes(true)) } func TestInstalledOperationAudienceRejectsUnknownFieldsAndNonCanonicalSets(t *testing.T) { diff --git a/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go index 0794d9af7..21d178a5c 100644 --- a/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go +++ b/module/services/accounts/code/pkg/gen/saas/accounts/v1/installations.pb.go @@ -312,14 +312,16 @@ type InstallSolutionRequest struct { // audiences it may be minted for and the resource kinds it may ever touch. // // Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an - // administrator consented to when they installed it. It is NOT unrestricted - // for a mint made with a credential delivered to a THIRD PARTY on a person's - // behalf — an MCP client the person authorized for this one solution. There - // the installation is the only statement of what the solution may act for, so - // an empty list is read as nothing and such a mint is REFUSED by name - // (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on - // purpose: one bounds a consumer an admin trusts, the other bounds a - // credential they never saw. + // administrator consented to when they installed it. + // + // A mint made with a credential delivered to a THIRD PARTY on a person's + // behalf — an MCP client the person authorized for this one solution — is not + // bounded by these lists. Its ceiling is the invoke bindings of the + // consumer's delivered authority, as this installation's scope consented to + // them when it was installed; a consumer with no delivered authority has no + // ceiling, and such a mint is refused. These lists can only narrow that + // ceiling: an empty list adds no bound, and a list naming more admits + // nothing extra. AllowedAudiences []string `protobuf:"bytes,10,rep,name=allowed_audiences,json=allowedAudiences,proto3" json:"allowed_audiences,omitempty"` AllowedScopes []string `protobuf:"bytes,11,rep,name=allowed_scopes,json=allowedScopes,proto3" json:"allowed_scopes,omitempty"` // The solution target being installed, from ListAvailableSolutions. Required. diff --git a/module/services/accounts/code/pkg/infra/consumer_invoke_ceiling_db_test.go b/module/services/accounts/code/pkg/infra/consumer_invoke_ceiling_db_test.go new file mode 100644 index 000000000..a23f6a08c --- /dev/null +++ b/module/services/accounts/code/pkg/infra/consumer_invoke_ceiling_db_test.go @@ -0,0 +1,127 @@ +//go:build !pure + +package infra_test + +// ConsumerInvokeCeiling against real Postgres rows: the installation's delivered +// principal and consent read through the real store, and the live authority read +// through the real fence (issue #1083). + +import ( + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + "context" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func consumerCeilingBindings() map[string]business.ModuleOperationAudience { + return map[string]business.ModuleOperationAudience{"documents": {Audience: "documents", + InvokeScopes: []business.ModuleOperationScope{{ResourceKind: "evidence", Actions: []string{"read"}}}, + LookupScopes: []business.ModuleOperationScope{{ResourceKind: "evidence", Actions: []string{"read"}}}}} +} + +func consumerCeilingInstall(t *testing.T, installed business.ModulePrincipalGrant) dynamicInstall { + t.Helper() + owner := seedUser(t) + seedHumanPrincipal(t, owner, "Jane Doe") + org := seedOrg(t, owner) + require.NoError(t, testStore.As(business.Identity{OrgID: org}).AddOrgMember(testCtx, owner, "admin")) + role := business.NewIDString() + require.NoError(t, testStore.WithOrgTx(testCtx, org, func(ctx context.Context) error { + return testStore.CreateRole(ctx, &gen.Role{Id: role, Name: "Example role " + role, OrgId: org, Permissions: []*gen.Permission{{Resource: "solution", Action: "use"}}}) + })) + alias := uniqueAlias("example-consumer") + binding := "acme.test." + alias + target := declarePresence(t, binding, alias) + principal := business.ModulePrincipalID(alias) + installed.PresenceBinding, installed.Tenant, installed.CrossTenant = binding, org, true + scope := business.FullInstallationScope(installed) + var revision uint64 + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + var err error + revision, err = testStore.LatestModuleEnvelopeRevision(ctx) + return err + })) + var installation *gen.Installation + require.NoError(t, testStore.WithOrgTx(testCtx, org, func(ctx context.Context) error { + var err error + installation, err = testStore.InstallSolution(ctx, &business.InstallSolutionParams{OrgID: org, AgentIdentifier: "acme.example/consumer:1.0.0", TargetID: target, + OwnerPrincipalID: owner, GrantedBy: owner, RoleID: role, Kind: "solution", RegistryBindingID: binding, RegistryPrincipalID: principal, Scope: &scope, EnvelopeRevision: revision}) + return err + })) + return dynamicInstall{org, owner, target, binding, principal, installation, installed, revision} +} + +func consumerCeilingService(t *testing.T, a dynamicInstall, current business.ModulePrincipalGrant) *business.Service { + t.Helper() + current.PresenceBinding, current.Tenant, current.CrossTenant = a.binding, a.org, true + svc, err := business.NewService(testStore) + require.NoError(t, err) + svc.InstallDerivedModulePrincipals(business.ModulePrincipalRegistry{a.principal: current}, a.envelope) + svc.SetModuleAuthorityReads(testStore, nil) + return svc +} + +// Control: the declared invoke binding, consented at install, is the ceiling. +func TestConsumerInvokeCeilingDBDeclaredBindingIsTheCeiling(t *testing.T) { + grant := business.ModulePrincipalGrant{Prefix: "example", Resources: []string{"records"}, OperationAudiences: consumerCeilingBindings()} + a := consumerCeilingInstall(t, grant) + svc := consumerCeilingService(t, a, grant) + ceiling, err := svc.ConsumerInvokeCeiling(testCtx, a.org, a.installation.Id) + require.NoError(t, err) + require.Equal(t, business.ConsumerCeilingAdmitted, + ceiling.Admit("documents", []business.ModuleOperationScope{{ResourceKind: "evidence", Actions: []string{"read"}}})) +} + +// The consumer declares a consumed module AFTER the organization installed it +// (a new authority generation). Consent is fixed at install, so the module is +// outside the ceiling — and the verdict says NOT CONSENTED, which is what an +// operator has to change, not undeclared. +func TestConsumerInvokeCeilingDBModuleDeclaredAfterInstallIsNotConsented(t *testing.T) { + before := business.ModulePrincipalGrant{Prefix: "example", Resources: []string{"records"}} + a := consumerCeilingInstall(t, before) + after := before + after.OperationAudiences = consumerCeilingBindings() + svc := consumerCeilingService(t, a, after) + + ceiling, err := svc.ConsumerInvokeCeiling(testCtx, a.org, a.installation.Id) + + require.NoError(t, err) + evidence := []business.ModuleOperationScope{{ResourceKind: "evidence", Actions: []string{"read"}}} + declared, _ := business.ConsumerCeilingAdmits(ceiling.Consented, "documents", evidence) + require.False(t, declared, "the install-time consent does not reach the module declared since") + require.Equal(t, business.ConsumerCeilingNotConsented, ceiling.Admit("documents", evidence)) +} + +// An installation row with no registry principal (the state migration 32's +// backfill leaves every pre-existing row in; the store writes the same NULL for +// an empty RegistryPrincipalID). +func TestConsumerInvokeCeilingDBRowWithoutDeliveredAuthorityIsRefusedByName(t *testing.T) { + grant := business.ModulePrincipalGrant{Prefix: "example", Resources: []string{"records"}, OperationAudiences: consumerCeilingBindings()} + owner := seedUser(t) + seedHumanPrincipal(t, owner, "Jane Doe") + org := seedOrg(t, owner) + require.NoError(t, testStore.As(business.Identity{OrgID: org}).AddOrgMember(testCtx, owner, "admin")) + role := business.NewIDString() + require.NoError(t, testStore.WithOrgTx(testCtx, org, func(ctx context.Context) error { + return testStore.CreateRole(ctx, &gen.Role{Id: role, Name: "Example role " + role, OrgId: org, Permissions: []*gen.Permission{{Resource: "solution", Action: "use"}}}) + })) + alias := uniqueAlias("example-legacy") + binding := "acme.test." + alias + target := declarePresence(t, binding, alias) + var installation *gen.Installation + require.NoError(t, testStore.WithOrgTx(testCtx, org, func(ctx context.Context) error { + var err error + installation, err = testStore.InstallSolution(ctx, &business.InstallSolutionParams{OrgID: org, AgentIdentifier: "acme.example/legacy:1.0.0", TargetID: target, + OwnerPrincipalID: owner, GrantedBy: owner, RoleID: role, Kind: "solution", RegistryBindingID: binding}) + return err + })) + a := dynamicInstall{org: org, binding: binding, principal: business.ModulePrincipalID(alias), installation: installation} + svc := consumerCeilingService(t, a, grant) + _, err := svc.ConsumerInvokeCeiling(testCtx, a.org, a.installation.Id) + require.Equal(t, codes.FailedPrecondition, status.Code(err), "%v", err) + require.Contains(t, err.Error(), "records no delivered authority") +} diff --git a/module/services/accounts/generated/openapi-raw/api.swagger.json b/module/services/accounts/generated/openapi-raw/api.swagger.json index 524422ac1..186cbadab 100644 --- a/module/services/accounts/generated/openapi-raw/api.swagger.json +++ b/module/services/accounts/generated/openapi-raw/api.swagger.json @@ -8827,7 +8827,7 @@ "items": { "type": "string" }, - "description": "The agent's delegated-execution ceiling (migration 108): the Work Context\naudiences it may be minted for and the resource kinds it may ever touch.\n\nEmpty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an\nadministrator consented to when they installed it. It is NOT unrestricted\nfor a mint made with a credential delivered to a THIRD PARTY on a person's\nbehalf — an MCP client the person authorized for this one solution. There\nthe installation is the only statement of what the solution may act for, so\nan empty list is read as nothing and such a mint is REFUSED by name\n(accounts' holdConfinedMintToItsConsumer). The two readings are opposite on\npurpose: one bounds a consumer an admin trusts, the other bounds a\ncredential they never saw." + "description": "The agent's delegated-execution ceiling (migration 108): the Work Context\naudiences it may be minted for and the resource kinds it may ever touch.\n\nEmpty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an\nadministrator consented to when they installed it.\n\nA mint made with a credential delivered to a THIRD PARTY on a person's\nbehalf — an MCP client the person authorized for this one solution — is not\nbounded by these lists. Its ceiling is the invoke bindings of the\nconsumer's delivered authority, as this installation's scope consented to\nthem when it was installed; a consumer with no delivered authority has no\nceiling, and such a mint is refused. These lists can only narrow that\nceiling: an empty list adds no bound, and a list naming more admits\nnothing extra." }, "allowedScopes": { "type": "array", diff --git a/module/services/accounts/openapi/api.swagger.json b/module/services/accounts/openapi/api.swagger.json index a2c559bac..7329e158a 100644 --- a/module/services/accounts/openapi/api.swagger.json +++ b/module/services/accounts/openapi/api.swagger.json @@ -2350,7 +2350,7 @@ "type": "string" }, "allowedAudiences": { - "description": "The agent's delegated-execution ceiling (migration 108): the Work Context\naudiences it may be minted for and the resource kinds it may ever touch.\n\nEmpty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an\nadministrator consented to when they installed it. It is NOT unrestricted\nfor a mint made with a credential delivered to a THIRD PARTY on a person's\nbehalf — an MCP client the person authorized for this one solution. There\nthe installation is the only statement of what the solution may act for, so\nan empty list is read as nothing and such a mint is REFUSED by name\n(accounts' holdConfinedMintToItsConsumer). The two readings are opposite on\npurpose: one bounds a consumer an admin trusts, the other bounds a\ncredential they never saw.", + "description": "The agent's delegated-execution ceiling (migration 108): the Work Context\naudiences it may be minted for and the resource kinds it may ever touch.\n\nEmpty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an\nadministrator consented to when they installed it.\n\nA mint made with a credential delivered to a THIRD PARTY on a person's\nbehalf — an MCP client the person authorized for this one solution — is not\nbounded by these lists. Its ceiling is the invoke bindings of the\nconsumer's delivered authority, as this installation's scope consented to\nthem when it was installed; a consumer with no delivered authority has no\nceiling, and such a mint is refused. These lists can only narrow that\nceiling: an empty list adds no bound, and a list naming more admits\nnothing extra.", "items": { "type": "string" }, diff --git a/module/services/accounts/proto/saas/accounts/v1/installations.proto b/module/services/accounts/proto/saas/accounts/v1/installations.proto index bc9c0e277..17fe6a96d 100644 --- a/module/services/accounts/proto/saas/accounts/v1/installations.proto +++ b/module/services/accounts/proto/saas/accounts/v1/installations.proto @@ -133,14 +133,16 @@ message InstallSolutionRequest { // audiences it may be minted for and the resource kinds it may ever touch. // // Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an - // administrator consented to when they installed it. It is NOT unrestricted - // for a mint made with a credential delivered to a THIRD PARTY on a person's - // behalf — an MCP client the person authorized for this one solution. There - // the installation is the only statement of what the solution may act for, so - // an empty list is read as nothing and such a mint is REFUSED by name - // (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on - // purpose: one bounds a consumer an admin trusts, the other bounds a - // credential they never saw. + // administrator consented to when they installed it. + // + // A mint made with a credential delivered to a THIRD PARTY on a person's + // behalf — an MCP client the person authorized for this one solution — is not + // bounded by these lists. Its ceiling is the invoke bindings of the + // consumer's delivered authority, as this installation's scope consented to + // them when it was installed; a consumer with no delivered authority has no + // ceiling, and such a mint is refused. These lists can only narrow that + // ceiling: an empty list adds no bound, and a list naming more admits + // nothing extra. repeated string allowed_audiences = 10 [ (buf.validate.field).repeated.max_items = 64, (buf.validate.field).repeated.items.string.max_len = 512 diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md index 89b3b7ee3..baf64a504 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -172,21 +172,56 @@ the organization to have installed it and the viewer to hold a grant reaching it the same question, through the same authority, as every proxied request to that solution — and states WHICH installation admitted the request as `x-codefly-installation-id`. Accounts then refuses a mint for another organization, -for an audience that installation does not declare, or for scopes outside its -agent's standing grants. An authority that cannot be asked answers 503; a capability +for an audience the consumer does not declare it consumes, or for scopes outside +what it declares there. An authority that cannot be asked answers 503; a capability is not issued on an unanswered question. -**An installation that declares NO audiences is refused here, and is unrestricted -everywhere else.** `allowed_audiences` and `allowed_scopes` are optional and empty -means unrestricted — the right reading for the installation's own headless work, -which an administrator consented to — and the install form leaves both empty, so -that is the ordinary state. For a confined mint the same emptiness would say the -person's whole authority is available, at every module the organization installed, -to a third party nobody installed. So accounts refuses and names the field. This is -the installation's ceiling and NOT the consumer's own declaration: the delivered -presence document carries module pins (module, package, version), a capability's -audience is the consumed module's route alias, and no record on this host joins the -two — deriving one would be a guess at the question the check exists to answer. +**The ceiling is the consumer's delivered authority, not a list an administrator +keeps.** Accounts reads the registry grant of the principal the stated installation +records (`business.ConsumerInvokeCeiling`): the invoke half of its operation +audiences, derived from the consumer's signed authority document, the applied +presence and the envelope, and intersected with the installation's consent. The +audience must be one of those bindings' audiences, and the scopes must fit one binding +there. The installation's `allowed_audiences` and `allowed_scopes` then narrow when +they name something and add no bound when empty: they are never a source of +authority, so lists that are empty or wider than the grant admit nothing it does not. + +**What reaches that grant today.** The renderer derives an authority document from a +module's `module.contract.codefly.yaml`, and from nothing else. A binding there may +declare `invoke` alone — lookup is optional, and an invoke-only binding is kept — so a +module that publishes a contract reaches this ceiling. **A solution's `api.consumes` +in `solution.codefly.yaml` does not:** it feeds the generated SDK and +`CODEFLY__API_CONSUMES`, never an authority document. A solution with no contract has +no registry grant, so every confined mint addressed through it is refused. Rendering a +solution's declared consumption into its authority document is the delivery half of +issue #1083 and belongs to the renderer. + +What a refusal says, and who acts on it: + +- **Not consented.** Consent is fixed when an organization installs (the full grant at + that moment). A module the consumer declares afterwards, or one an administrator + narrowed away, is refused as not consented by this installation; installing the + consumer again consents to what it declares now. Consent cannot be widened in place. +- **Not declared**, or scopes outside what is declared at that audience: the + consumer's delivery has to declare it. +- **The consumer's own audience** (its prefix, or `solution:`): refused, + because the credential already reaches that consumer's own routes. It is refused + before any binding is read: a self-admitting binding (headless or delegation work) + may name its own module, and it must never admit a person's credential held by a + third party. +- **Capabilities withdrawn** by the installation's consent: refused as such, not as an + inactive installation; installing again restores them. +- **An installation that records no delivered authority** (made before installations + recorded it) declares nothing. This gateway's entitlement check already leaves such + an installation out, so the caller sees the generic 403 above before any mint; the + named refusal in accounts is the second line, not what an operator will read. + +The decision holds until the capability leaves: StartTask opens the authority use +span before any other transaction, so an uninstall or narrowed consent waits for the +mint. The span does not hold the in-memory registry a redelivery replaces, so the +consumer's declaration is re-checked after signing and a changed one refuses the +capability (`Aborted`, ask again). A withdrawal published after that check lands after +issuance, bounded by the capability's lifetime like every other one. Both stamps are on `untrustedAuthHeaders`, so a caller can assert neither and cannot pick its own ceiling. The consumer check and the installation stamp run on the HTTP diff --git a/module/services/frontend/code/package-lock.json b/module/services/frontend/code/package-lock.json index 68b24e8c7..cc2ee7cc5 100644 --- a/module/services/frontend/code/package-lock.json +++ b/module/services/frontend/code/package-lock.json @@ -15,7 +15,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.2", "@codefly-dev/saas-plugin-react": "0.4.3", - "@codefly-dev/saas-sdk": "0.3.25", + "@codefly-dev/saas-sdk": "0.3.27", "@codefly-dev/saas-ui": "0.12.7", "@codefly-dev/ui": "0.12.7", "@codefly/saas-plugin-manifest": "0.1.2", @@ -15347,7 +15347,7 @@ }, "packages/saas-sdk": { "name": "@codefly-dev/saas-sdk", - "version": "0.3.26", + "version": "0.3.27", "license": "Apache-2.0", "dependencies": { "@bufbuild/protobuf": "^2.11.0", diff --git a/module/services/frontend/code/package.json b/module/services/frontend/code/package.json index a7dfa4706..99f929323 100644 --- a/module/services/frontend/code/package.json +++ b/module/services/frontend/code/package.json @@ -38,7 +38,7 @@ "@bufbuild/protobuf": "^2.11.0", "@codefly-dev/saas-plugin-contract": "2.4.2", "@codefly-dev/saas-plugin-react": "0.4.3", - "@codefly-dev/saas-sdk": "0.3.25", + "@codefly-dev/saas-sdk": "0.3.27", "@codefly-dev/saas-ui": "0.12.7", "@codefly-dev/ui": "0.12.7", "@codefly/saas-plugin-manifest": "0.1.2", diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json index 7cd85c927..fe26d294d 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json +++ b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json @@ -10,7 +10,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contract.binpb", - "digest": "sha256:dbbea2526330d41a9f4ecaa7c384235a641340d769b4a45f58f61977fe7b0b24", + "digest": "sha256:16a2647b0f308e889ba45a6efa8f291ef590d19819c9128036eb476bf1dee8c3", "services": [ { "name": "APIKeyService", diff --git a/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb b/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb index 97c511425..ee08e2ec4 100644 Binary files a/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb and b/module/services/frontend/code/packages/saas-sdk/generated/contract/contract.binpb differ diff --git a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml index ff1e34279..464536a1d 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml +++ b/module/services/frontend/code/packages/saas-sdk/generated/library.codefly.yaml @@ -12,7 +12,7 @@ sources: module: saas-starter service: accounts endpoint: connect - contract-digest: sha256:dbbea2526330d41a9f4ecaa7c384235a641340d769b4a45f58f61977fe7b0b24 + contract-digest: sha256:16a2647b0f308e889ba45a6efa8f291ef590d19819c9128036eb476bf1dee8c3 services: - APIKeyService - AccessibleScopeService diff --git a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts index da9ec3898..340af05d7 100644 --- a/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts +++ b/module/services/frontend/code/packages/saas-sdk/generated/typescript/src/gen/saas/accounts/v1/installations_pb.ts @@ -171,14 +171,16 @@ export type InstallSolutionRequest = Message<"saas.accounts.v1.InstallSolutionRe * audiences it may be minted for and the resource kinds it may ever touch. * * Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an - * administrator consented to when they installed it. It is NOT unrestricted - * for a mint made with a credential delivered to a THIRD PARTY on a person's - * behalf — an MCP client the person authorized for this one solution. There - * the installation is the only statement of what the solution may act for, so - * an empty list is read as nothing and such a mint is REFUSED by name - * (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on - * purpose: one bounds a consumer an admin trusts, the other bounds a - * credential they never saw. + * administrator consented to when they installed it. + * + * A mint made with a credential delivered to a THIRD PARTY on a person's + * behalf — an MCP client the person authorized for this one solution — is not + * bounded by these lists. Its ceiling is the invoke bindings of the + * consumer's delivered authority, as this installation's scope consented to + * them when it was installed; a consumer with no delivered authority has no + * ceiling, and such a mint is refused. These lists can only narrow that + * ceiling: an empty list adds no bound, and a list naming more admits + * nothing extra. * * @generated from field: repeated string allowed_audiences = 10; */ diff --git a/module/services/frontend/code/packages/saas-sdk/package.json b/module/services/frontend/code/packages/saas-sdk/package.json index 826e0439a..e48c2248d 100644 --- a/module/services/frontend/code/packages/saas-sdk/package.json +++ b/module/services/frontend/code/packages/saas-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@codefly-dev/saas-sdk", - "version": "0.3.26", + "version": "0.3.27", "description": "Typed Connect client, gateway-bound facades, and audit data-graph tooling for the saas-starter public API.", "repository": { "type": "git", diff --git a/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx b/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx index a94471a7c..7739cba0b 100644 --- a/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx +++ b/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx @@ -200,7 +200,7 @@ export function InstallSolutionDialog({
{(control) => ( diff --git a/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts b/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts index 2f1632ad2..161b7293e 100644 --- a/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts +++ b/module/services/frontend/code/src/gen/saas/accounts/v1/installations_pb.ts @@ -171,14 +171,16 @@ export type InstallSolutionRequest = Message<"saas.accounts.v1.InstallSolutionRe * audiences it may be minted for and the resource kinds it may ever touch. * * Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an - * administrator consented to when they installed it. It is NOT unrestricted - * for a mint made with a credential delivered to a THIRD PARTY on a person's - * behalf — an MCP client the person authorized for this one solution. There - * the installation is the only statement of what the solution may act for, so - * an empty list is read as nothing and such a mint is REFUSED by name - * (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on - * purpose: one bounds a consumer an admin trusts, the other bounds a - * credential they never saw. + * administrator consented to when they installed it. + * + * A mint made with a credential delivered to a THIRD PARTY on a person's + * behalf — an MCP client the person authorized for this one solution — is not + * bounded by these lists. Its ceiling is the invoke bindings of the + * consumer's delivered authority, as this installation's scope consented to + * them when it was installed; a consumer with no delivered authority has no + * ceiling, and such a mint is refused. These lists can only narrow that + * ceiling: an empty list adds no bound, and a list naming more admits + * nothing extra. * * @generated from field: repeated string allowed_audiences = 10; */