From d994dd26caf55ee21d4fd0ca1a2d1c2cc37e632d Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Sun, 11 Oct 2026 20:09:15 -0400 Subject: [PATCH 1/4] fix(accounts): a confined mint's ceiling is what its consumer declares it consumes A mint made with a resource-confined credential is held to the consumer that credential was delivered to. Its ceiling is now that consumer's own declaration, not the installation's `allowed_audiences` / `allowed_scopes`. The declaration already reaches this host in the terms a mint is checked in. A consumer's published contract (module.contract.codefly.yaml) names each module it calls on a person's behalf: the audience a capability for that module is addressed to, plus the scope ceiling of every operation it redeems there. The render derives the authority document from it and delivery signs it. This host derives its registry from the signed document, the applied presence and the envelope, and the organization's installation scope narrows the result. business.ConsumerInvokeCeiling reads the invoke bindings of that grant for the installation the perimeter stated, through AuthorizeModuleCapability, sealed to that installation. A confined mint must name an audience the consumer declares, and its scopes must fit one binding declared there. The installation's lists only narrow that ceiling. When empty they add no bound, and a list naming more than the declaration admits nothing extra. An installation that records no delivered authority declares nothing, and a confined mint under it is refused by name. The decision holds until the capability is signed. StartTask opens the authority use span for a confined request before any other transaction, and the ceiling read shares that span. The proto comment on InstallSolutionRequest.allowed_audiences, the install form's description and the gateway's AGENTS.md now state this rule. The regenerated artifacts all follow from the proto comment: both contract descriptors, the catalog, the module package, the generated Go and TypeScript, both swagger documents, and the published client's patch version. They were regenerated with CI's pinned CLI (0.1.185), and the chain leaves origin/main unchanged. Closes #1083 Co-Authored-By: Claude Opus 5.5 (1M context) --- .../api/accounts/authority/contract.binpb | Bin 1132873 -> 1132853 bytes .../saas/accounts/v1/installations.proto | 17 +- .../api/accounts/connect/contract.binpb | Bin 1132873 -> 1132853 bytes .../saas/accounts/v1/installations.proto | 17 +- module/contracts/api/catalog.codefly.json | 4 +- module/module.package.codefly.yaml | 4 +- .../work_context_confined_mint_test.go | 71 +--- .../work_context_consumer_ceiling_test.go | 365 ++++++++++++++++++ .../code/pkg/adapters/work_context_rpcs.go | 126 +++--- .../pkg/business/consumer_invoke_ceiling.go | 125 ++++++ .../business/consumer_invoke_ceiling_test.go | 123 ++++++ .../gen/saas/accounts/v1/installations.pb.go | 17 +- .../generated/openapi-raw/api.swagger.json | 2 +- .../accounts/openapi/api.swagger.json | 2 +- .../saas/accounts/v1/installations.proto | 17 +- module/services/auth-gateway/AGENTS.md | 30 +- .../services/frontend/code/package-lock.json | 4 +- module/services/frontend/code/package.json | 2 +- .../generated/contract/catalog.codefly.json | 2 +- .../generated/contract/contract.binpb | Bin 1132873 -> 1132853 bytes .../saas-sdk/generated/library.codefly.yaml | 2 +- .../gen/saas/accounts/v1/installations_pb.ts | 17 +- .../code/packages/saas-sdk/package.json | 2 +- .../ui/install-solution-dialog.tsx | 2 +- .../gen/saas/accounts/v1/installations_pb.ts | 17 +- 25 files changed, 795 insertions(+), 173 deletions(-) create mode 100644 module/services/accounts/code/pkg/adapters/work_context_consumer_ceiling_test.go create mode 100644 module/services/accounts/code/pkg/business/consumer_invoke_ceiling.go create mode 100644 module/services/accounts/code/pkg/business/consumer_invoke_ceiling_test.go diff --git a/module/contracts/api/accounts/authority/contract.binpb b/module/contracts/api/accounts/authority/contract.binpb index 28bc29cbd1d975fa1c6d760ec306b8be3da3f57b..255ae371c9087fc644dc1d8b2cc710909a5db50f 100644 GIT binary patch delta 9402 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 delta 9399 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 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..8919132d8 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,15 @@ 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 what the solution DECLARES it + // consumes: the invoke bindings of its delivered contract, as this + // installation's scope consents to them. These lists can only narrow that + // ceiling: an empty list adds no bound, and a list naming more than the + // solution declares 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 28bc29cbd1d975fa1c6d760ec306b8be3da3f57b..255ae371c9087fc644dc1d8b2cc710909a5db50f 100644 GIT binary patch delta 9402 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 delta 9399 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 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..8919132d8 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,15 @@ 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 what the solution DECLARES it + // consumes: the invoke bindings of its delivered contract, as this + // installation's scope consents to them. These lists can only narrow that + // ceiling: an empty list adds no bound, and a list naming more than the + // solution declares 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 e604cff85..f59acf27f 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:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869", + "digest": "sha256:a8fcf121d96909157dd3bff2e978881824abacdccb4fbe5f6180b5957d2ede97", "services": [ { "name": "ModuleCapabilitiesService", @@ -64,7 +64,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869", + "digest": "sha256:a8fcf121d96909157dd3bff2e978881824abacdccb4fbe5f6180b5957d2ede97", "services": [ { "name": "APIKeyService", diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index d3d21ea7d..262892a0e 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:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869 + digest: sha256:a8fcf121d96909157dd3bff2e978881824abacdccb4fbe5f6180b5957d2ede97 - endpoint: connect kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/connect/contract.binpb - digest: sha256:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869 + digest: sha256:a8fcf121d96909157dd3bff2e978881824abacdccb4fbe5f6180b5957d2ede97 - name: auth-gateway endpoints: [grpc, rest] api-contracts: 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..f9877eb00 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/work_context_consumer_ceiling_test.go @@ -0,0 +1,365 @@ +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, + }} + 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") } + + t.Run("the installation scope dropped the 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(), `"documents" is not a module the consumer`) + }) + 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") +} 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..db93cea0c 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,23 @@ 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. + 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 } @@ -561,12 +578,17 @@ 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. +// 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 @@ -621,6 +643,41 @@ func (s *WorkContextAuthorityServer) holdConfinedMintToItsConsumer( return 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 status.Error(codes.FailedPrecondition, + "this host cannot read what an installed consumer declares, so there is no ceiling to hold its credential to") + } + declared, err := service.ConsumerInvokeCeiling(ctx, req.GetOrgId(), installation) + if err != nil { + if _, isStatus := status.FromError(err); isStatus { + return err + } + return status.Error(codes.Unavailable, "cannot read what the installed consumer declares") + } + audienceDeclared, inside := business.ConsumerCeilingAdmits(declared, req.GetAudience(), moduleOperationScopes(scopes)) + if !audienceDeclared { + return status.Errorf(codes.PermissionDenied, + "audience %q is not a module the consumer this credential was delivered to declares it consumes", req.GetAudience()) + } + if !inside { + return 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()) + } + + // 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 @@ -633,50 +690,25 @@ func (s *WorkContextAuthorityServer) holdConfinedMintToItsConsumer( return 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) } +// 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 // instead of a bearer and draws its authority from an installation: the owner of // record becomes the context owner, the installation's agent principal the sole 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..cca757a3c --- /dev/null +++ b/module/services/accounts/code/pkg/business/consumer_invoke_ceiling.go @@ -0,0 +1,125 @@ +package business + +import ( + "context" + "errors" + "maps" + "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. The declaration is not a list an +// administrator types: it is the consumer's own published contract +// (module.contract.codefly.yaml), whose outward bindings name each module it +// calls — the audience a capability for that module is addressed to — and the +// scope ceiling of every operation it redeems there. The render derives the +// authority document from it, the delivery pipeline signs it, and this host +// derives the registry from the signed document, the applied presence it is +// granted over, and the envelope. So a consumer's registry grant already holds +// the consumption in exactly the terms a mint is checked in — an audience and +// the scopes under it — and holds it structurally, re-verified on every pass. +// +// The ceiling is the INVOKE half of that grant, because invoke is the operation +// that carries a person's own authority, attenuated, which is what a confined +// mint is. Lookup and headless are other operations and never widen it. +// +// 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 AuthorizeModuleCapability — the same decision +// every capability of that consumer goes through. So an envelope that shrank, a +// narrowed installation, a withdrawn binding or an unreadable authority each +// narrows or refuses this ceiling exactly as it does the consumer's own work. +// +// 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. + +// ConsumerInvokeCeiling is what the consumer installed as installationID in +// orgID declares it may act for on a person's behalf: its invoke bindings, each +// naming one consumed module's audience and the scopes it asks for there. +// +// 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. +func (s *Service) ConsumerInvokeCeiling(ctx context.Context, orgID, installationID string) (map[string]ModuleOperationAudience, 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") + } + authority, 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 + } + return authority.Grant.OperationAudiences, nil +} + +// ConsumerCeilingAdmits reports whether a capability addressed to audience and +// carrying scopes lies inside what the consumer declares: declared says the +// consumer names audience at all, and inside says the scopes fit ONE binding it +// declares for that audience. +// +// 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..67695c641 --- /dev/null +++ b/module/services/accounts/code/pkg/business/consumer_invoke_ceiling_test.go @@ -0,0 +1,123 @@ +package business + +import ( + "context" + "testing" + + "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"}}) + 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, 1) + require.Equal(t, declared["documents"].Audience, ceiling["documents"].Audience) + require.Equal(t, declared["documents"].InvokeScopes, ceiling["documents"].InvokeScopes) + } else { + require.Nil(t, ceiling) + } + }) + } +} 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..d84828492 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,15 @@ 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 what the solution DECLARES it + // consumes: the invoke bindings of its delivered contract, as this + // installation's scope consents to them. These lists can only narrow that + // ceiling: an empty list adds no bound, and a list naming more than the + // solution declares 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/generated/openapi-raw/api.swagger.json b/module/services/accounts/generated/openapi-raw/api.swagger.json index 524422ac1..e8fff2f08 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 what the solution DECLARES it\nconsumes: the invoke bindings of its delivered contract, as this\ninstallation's scope consents to them. These lists can only narrow that\nceiling: an empty list adds no bound, and a list naming more than the\nsolution declares admits nothing extra." }, "allowedScopes": { "type": "array", diff --git a/module/services/accounts/openapi/api.swagger.json b/module/services/accounts/openapi/api.swagger.json index a2c559bac..79906a5de 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 what the solution DECLARES it\nconsumes: the invoke bindings of its delivered contract, as this\ninstallation's scope consents to them. These lists can only narrow that\nceiling: an empty list adds no bound, and a list naming more than the\nsolution declares admits nothing 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..8919132d8 100644 --- a/module/services/accounts/proto/saas/accounts/v1/installations.proto +++ b/module/services/accounts/proto/saas/accounts/v1/installations.proto @@ -133,14 +133,15 @@ 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 what the solution DECLARES it + // consumes: the invoke bindings of its delivered contract, as this + // installation's scope consents to them. These lists can only narrow that + // ceiling: an empty list adds no bound, and a list naming more than the + // solution declares 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..9073d5f6d 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -172,21 +172,25 @@ 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 what the consumer declares it consumes, not a list an +administrator keeps.** A consumer's published contract (`module.contract.codefly.yaml`) +names every module it calls on a person's behalf — the audience a capability for that +module is addressed to — and the scope ceiling of each invoke binding. The render +derives the authority document from it, delivery signs it, and accounts derives its +registry from the signed document, the applied presence and the envelope; the +organization's installation scope narrows that. Accounts reads the invoke bindings of +that grant for the installation this gateway stated +(`business.ConsumerInvokeCeiling`): the audience must be one the consumer declares, +and the scopes must fit one binding it declares 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 an installation whose +lists are empty or wider than the declaration admits nothing the declaration does +not. An installation that records no delivered authority (one made before +installations did) declares nothing, and a confined mint under it is refused by name. 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 8e38d4a5f..4bcbb685e 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.26", "@codefly-dev/saas-ui": "0.12.7", "@codefly-dev/ui": "0.12.7", "@codefly/saas-plugin-manifest": "0.1.1", @@ -15347,7 +15347,7 @@ }, "packages/saas-sdk": { "name": "@codefly-dev/saas-sdk", - "version": "0.3.25", + "version": "0.3.26", "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 79d24462a..59f575756 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.26", "@codefly-dev/saas-ui": "0.12.7", "@codefly-dev/ui": "0.12.7", "@codefly/saas-plugin-manifest": "0.1.1", 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 43cbdf026..1c5c3dadd 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:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869", + "digest": "sha256:a8fcf121d96909157dd3bff2e978881824abacdccb4fbe5f6180b5957d2ede97", "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 28bc29cbd1d975fa1c6d760ec306b8be3da3f57b..255ae371c9087fc644dc1d8b2cc710909a5db50f 100644 GIT binary patch delta 9402 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 delta 9399 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 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 df0c01473..272ff3dd2 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:045c67283ce0dbce7dc8ab3b21a9bb23b3e9d461fb51972853b6217128417869 + contract-digest: sha256:a8fcf121d96909157dd3bff2e978881824abacdccb4fbe5f6180b5957d2ede97 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..784abf19a 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,15 @@ 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 what the solution DECLARES it + * consumes: the invoke bindings of its delivered contract, as this + * installation's scope consents to them. These lists can only narrow that + * ceiling: an empty list adds no bound, and a list naming more than the + * solution declares 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 fbc4f3c3d..826e0439a 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.25", + "version": "0.3.26", "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..32537e156 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..17238e92f 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,15 @@ 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 what the solution DECLARES it + * consumes: the invoke bindings of its delivered contract, as this + * installation's scope consents to them. These lists can only narrow that + * ceiling: an empty list adds no bound, and a list naming more than the + * solution declares admits nothing extra. * * @generated from field: repeated string allowed_audiences = 10; */ From a4ad2606a9b0720d3be3402a27ff09e6afe13664 Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:40:57 -0400 Subject: [PATCH 2/4] fix(accounts): the confined ceiling names each refusal, keeps invoke-only bindings, and re-decides after signing The confined mint's ceiling is the consumer's delivered authority: the invoke bindings of its registry grant, as the installation consented to them. What reaches that grant is a module's published contract. A solution's `api.consumes` does not, and the docs now say so. Rendering it is the delivery half of #1083. - Lookup is optional on an operation audience, like headless. A binding that declares only invoke is kept, not dropped from the registry. A lookup exchange under it is refused by name, and installation consent intersects lookup to nil. - A module the consumer declares that the installation does not consent to is refused as not consented, and the message names reinstalling as the remedy. Consent stays fixed at install. - A capability addressed to the consumer itself is refused by name. - Capabilities withdrawn by consent are named, not reported as an inactive installation. - After signing, the ceiling is decided again through the same authority, in the same span. A changed declaration or consent refuses the capability (Aborted). The proto comment no longer says a solution's declaration is the ceiling; regenerated with CI's pinned CLI 0.1.185. Adds the review's tests, including Postgres-backed ones. Refs #1083 Co-Authored-By: Claude Opus 5.5 (1M context) --- module/WORK_CONTEXTS.md | 6 +- .../api/accounts/authority/contract.binpb | Bin 1132853 -> 1132905 bytes .../saas/accounts/v1/installations.proto | 11 +- .../api/accounts/connect/contract.binpb | Bin 1132853 -> 1132905 bytes .../saas/accounts/v1/installations.proto | 11 +- module/contracts/api/catalog.codefly.json | 4 +- module/module.package.codefly.yaml | 4 +- .../pkg/adapters/delegated_read_audience.go | 5 + .../work_context_consumer_ceiling_test.go | 132 +++++++++++- .../code/pkg/adapters/work_context_rpcs.go | 76 ++++--- .../pkg/business/consumer_invoke_ceiling.go | 194 +++++++++++++++--- .../business/consumer_invoke_ceiling_test.go | 129 +++++++++++- .../code/pkg/business/installation_scope.go | 2 +- .../code/pkg/business/module_envelope_test.go | 20 +- .../pkg/business/module_operation_audience.go | 12 +- .../module_operation_audience_test.go | 10 +- .../gen/saas/accounts/v1/installations.pb.go | 11 +- .../infra/consumer_invoke_ceiling_db_test.go | 127 ++++++++++++ .../generated/openapi-raw/api.swagger.json | 2 +- .../accounts/openapi/api.swagger.json | 2 +- .../saas/accounts/v1/installations.proto | 11 +- module/services/auth-gateway/AGENTS.md | 59 ++++-- .../generated/contract/catalog.codefly.json | 2 +- .../generated/contract/contract.binpb | Bin 1132853 -> 1132905 bytes .../saas-sdk/generated/library.codefly.yaml | 2 +- .../gen/saas/accounts/v1/installations_pb.ts | 11 +- .../ui/install-solution-dialog.tsx | 2 +- .../gen/saas/accounts/v1/installations_pb.ts | 11 +- 28 files changed, 730 insertions(+), 126 deletions(-) create mode 100644 module/services/accounts/code/pkg/infra/consumer_invoke_ceiling_db_test.go diff --git a/module/WORK_CONTEXTS.md b/module/WORK_CONTEXTS.md index 642e61361..30f865291 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 255ae371c9087fc644dc1d8b2cc710909a5db50f..052464e6ed1903a3681f9d46d2da0209017cffb9 100644 GIT binary patch delta 9244 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 G@BahrwD#Qq 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 8919132d8..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 @@ -137,11 +137,12 @@ message InstallSolutionRequest { // // 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 what the solution DECLARES it - // consumes: the invoke bindings of its delivered contract, as this - // installation's scope consents to them. These lists can only narrow that - // ceiling: an empty list adds no bound, and a list naming more than the - // solution declares admits nothing extra. + // 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 255ae371c9087fc644dc1d8b2cc710909a5db50f..052464e6ed1903a3681f9d46d2da0209017cffb9 100644 GIT binary patch delta 9244 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 G@BahrwD#Qq 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 8919132d8..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 @@ -137,11 +137,12 @@ message InstallSolutionRequest { // // 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 what the solution DECLARES it - // consumes: the invoke bindings of its delivered contract, as this - // installation's scope consents to them. These lists can only narrow that - // ceiling: an empty list adds no bound, and a list naming more than the - // solution declares admits nothing extra. + // 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 f59acf27f..975e0e1fc 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:a8fcf121d96909157dd3bff2e978881824abacdccb4fbe5f6180b5957d2ede97", + "digest": "sha256:077be15abf5487231a7f731aacaedbb9b33028807580ed43e990ec78d6ca5b3a", "services": [ { "name": "ModuleCapabilitiesService", @@ -64,7 +64,7 @@ "kind": "protobuf", "package": "saas.accounts.v1", "path": "contracts/api/accounts/connect/contract.binpb", - "digest": "sha256:a8fcf121d96909157dd3bff2e978881824abacdccb4fbe5f6180b5957d2ede97", + "digest": "sha256:077be15abf5487231a7f731aacaedbb9b33028807580ed43e990ec78d6ca5b3a", "services": [ { "name": "APIKeyService", diff --git a/module/module.package.codefly.yaml b/module/module.package.codefly.yaml index 262892a0e..06e72ed0d 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:a8fcf121d96909157dd3bff2e978881824abacdccb4fbe5f6180b5957d2ede97 + digest: sha256:077be15abf5487231a7f731aacaedbb9b33028807580ed43e990ec78d6ca5b3a - endpoint: connect kind: protobuf package: saas.accounts.v1 path: contracts/api/accounts/connect/contract.binpb - digest: sha256:a8fcf121d96909157dd3bff2e978881824abacdccb4fbe5f6180b5957d2ede97 + digest: sha256:077be15abf5487231a7f731aacaedbb9b33028807580ed43e990ec78d6ca5b3a - 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_consumer_ceiling_test.go b/module/services/accounts/code/pkg/adapters/work_context_consumer_ceiling_test.go index f9877eb00..92fdf77cd 100644 --- 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 @@ -153,6 +153,7 @@ func confinedConsumerService(t *testing.T) *consumerFixture { PrincipalID: business.ModulePrincipalID(declaringConsumerPrefix), BindingID: declaringConsumerBinding, Revision: 1, + Scope: business.InstallationScope{Capabilities: true}, }} svc, err := business.NewService(declaredConsumerStore{fixture: fixture}) require.NoError(t, err) @@ -294,7 +295,11 @@ func TestTheInstallationsListsNarrowTheDeclaredCeiling(t *testing.T) { func TestTheDeclaredCeilingIsReadAsConsented(t *testing.T) { inside := func() *gen.StartTaskWorkContextRequest { return scoped("documents", "evidence", "read") } - t.Run("the installation scope dropped the module", func(t *testing.T) { + // 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") @@ -304,7 +309,19 @@ func TestTheDeclaredCeilingIsReadAsConsented(t *testing.T) { require.Nil(t, issued) require.Equal(t, codes.PermissionDenied, status.Code(err)) - require.Contains(t, err.Error(), `"documents" is not a module the consumer`) + 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) @@ -363,3 +380,114 @@ func TestConfinedMintHoldsTheConsumersAuthorityThroughTheMint(t *testing.T) { 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 db93cea0c..c110dbfb4 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go @@ -457,7 +457,9 @@ func (s *WorkContextAuthorityServer) StartTask( // 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. + // 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 @@ -486,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()) @@ -537,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 } @@ -583,6 +595,9 @@ func (s *WorkContextAuthorityServer) StartTask( // 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 @@ -608,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 { @@ -619,28 +634,28 @@ 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). @@ -655,24 +670,34 @@ func (s *WorkContextAuthorityServer) holdConfinedMintToItsConsumer( // 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 status.Error(codes.FailedPrecondition, + 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") } - declared, err := service.ConsumerInvokeCeiling(ctx, req.GetOrgId(), installation) + ceiling, err := service.ConsumerInvokeCeiling(ctx, req.GetOrgId(), installation) if err != nil { if _, isStatus := status.FromError(err); isStatus { - return err + return nil, err } - return status.Error(codes.Unavailable, "cannot read what the installed consumer declares") - } - audienceDeclared, inside := business.ConsumerCeilingAdmits(declared, req.GetAudience(), moduleOperationScopes(scopes)) - if !audienceDeclared { - return status.Errorf(codes.PermissionDenied, - "audience %q is not a module the consumer this credential was delivered to declares it consumes", req.GetAudience()) - } - if !inside { - return status.Errorf(codes.PermissionDenied, + 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 @@ -680,17 +705,20 @@ func (s *WorkContextAuthorityServer) holdConfinedMintToItsConsumer( // 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") } - 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 diff --git a/module/services/accounts/code/pkg/business/consumer_invoke_ceiling.go b/module/services/accounts/code/pkg/business/consumer_invoke_ceiling.go index cca757a3c..2d5eab1f6 100644 --- a/module/services/accounts/code/pkg/business/consumer_invoke_ceiling.go +++ b/module/services/accounts/code/pkg/business/consumer_invoke_ceiling.go @@ -4,6 +4,7 @@ import ( "context" "errors" "maps" + "reflect" "slices" "google.golang.org/grpc/codes" @@ -14,40 +15,106 @@ import ( // // 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. The declaration is not a list an -// administrator types: it is the consumer's own published contract -// (module.contract.codefly.yaml), whose outward bindings name each module it -// calls — the audience a capability for that module is addressed to — and the -// scope ceiling of every operation it redeems there. The render derives the -// authority document from it, the delivery pipeline signs it, and this host -// derives the registry from the signed document, the applied presence it is -// granted over, and the envelope. So a consumer's registry grant already holds -// the consumption in exactly the terms a mint is checked in — an audience and -// the scopes under it — and holds it structurally, re-verified on every pass. +// that consumer DECLARES it consumes, as this organization consented to it. // -// The ceiling is the INVOKE half of that grant, because invoke is the operation -// that carries a person's own authority, attenuated, which is what a confined -// mint is. Lookup and headless are other operations and never widen 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 AuthorizeModuleCapability — the same decision -// every capability of that consumer goes through. So an envelope that shrank, a -// narrowed installation, a withdrawn binding or an unreadable authority each -// narrows or refuses this ceiling exactly as it does the consumer's own work. +// 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. -// ConsumerInvokeCeiling is what the consumer installed as installationID in -// orgID declares it may act for on a person's behalf: its invoke bindings, each -// naming one consumed module's audience and the scopes it asks for there. +// 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 — and no +// consumer can declare itself (validateOperationAudiences refuses a binding to +// its own prefix), so a ceiling read from the declaration could never admit one. +// It is refused by name rather than as "undeclared". +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. -func (s *Service) ConsumerInvokeCeiling(ctx context.Context, orgID, installationID string) (map[string]ModuleOperationAudience, error) { +// 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") @@ -87,7 +154,15 @@ func (s *Service) ConsumerInvokeCeiling(ctx context.Context, orgID, installation "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") } - authority, err := s.AuthorizeModuleCapability(ctx, ModuleCaller{ + 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 @@ -99,13 +174,78 @@ func (s *Service) ConsumerInvokeCeiling(ctx context.Context, orgID, installation if err != nil { return nil, err } - return authority.Grant.OperationAudiences, nil + // 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 what the consumer declares: declared says the -// consumer names audience at all, and inside says the scopes fit ONE binding it -// declares for that audience. +// 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 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 index 67695c641..d834a88c5 100644 --- a/module/services/accounts/code/pkg/business/consumer_invoke_ceiling_test.go +++ b/module/services/accounts/code/pkg/business/consumer_invoke_ceiling_test.go @@ -2,6 +2,7 @@ package business import ( "context" + "github.com/codefly-dev/core/solutionhost" "testing" "github.com/stretchr/testify/require" @@ -103,7 +104,7 @@ func TestConsumerInvokeCeilingIsReadUnderTheNamedInstallation(t *testing.T) { "another installation": {"installation-other", codes.Unauthenticated}, } { t.Run(name, func(t *testing.T) { - svc, err := NewService(consumerStateStore{state: &InstallationAuthorityState{PrincipalID: principal, BindingID: "binding"}}) + 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) @@ -112,12 +113,132 @@ func TestConsumerInvokeCeilingIsReadUnderTheNamedInstallation(t *testing.T) { require.Equal(t, probe.want, status.Code(err), "err=%v", err) if probe.want == codes.OK { - require.Len(t, ceiling, 1) - require.Equal(t, declared["documents"].Audience, ceiling["documents"].Audience) - require.Equal(t, declared["documents"].InvokeScopes, ceiling["documents"].InvokeScopes) + 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) +} + +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 2ffb77715..b8d79f087 100644 --- a/module/services/accounts/code/pkg/business/module_operation_audience.go +++ b/module/services/accounts/code/pkg/business/module_operation_audience.go @@ -123,9 +123,19 @@ func validateOperationAudiences(prefix string, bindings map[string]ModuleOperati return fmt.Errorf("module operation audience bindings exceed limit") } for id, binding := range bindings { - if !validOperationValue(id, 128) || !validOperationValue(binding.Audience, 512) || binding.Audience == prefix || !validOperationScopes(binding.InvokeScopes, false) || !validOperationScopes(binding.LookupScopes, true) || !operationScopesSubset(binding.LookupScopes, binding.InvokeScopes) { + if !validOperationValue(id, 128) || !validOperationValue(binding.Audience, 512) || binding.Audience == prefix || !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 d84828492..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 @@ -316,11 +316,12 @@ type InstallSolutionRequest struct { // // 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 what the solution DECLARES it - // consumes: the invoke bindings of its delivered contract, as this - // installation's scope consents to them. These lists can only narrow that - // ceiling: an empty list adds no bound, and a list naming more than the - // solution declares admits nothing extra. + // 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 e8fff2f08..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.\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 what the solution DECLARES it\nconsumes: the invoke bindings of its delivered contract, as this\ninstallation's scope consents to them. These lists can only narrow that\nceiling: an empty list adds no bound, and a list naming more than the\nsolution declares admits nothing extra." + "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 79906a5de..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.\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 what the solution DECLARES it\nconsumes: the invoke bindings of its delivered contract, as this\ninstallation's scope consents to them. These lists can only narrow that\nceiling: an empty list adds no bound, and a list naming more than the\nsolution declares admits nothing extra.", + "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 8919132d8..17fe6a96d 100644 --- a/module/services/accounts/proto/saas/accounts/v1/installations.proto +++ b/module/services/accounts/proto/saas/accounts/v1/installations.proto @@ -137,11 +137,12 @@ message InstallSolutionRequest { // // 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 what the solution DECLARES it - // consumes: the invoke bindings of its delivered contract, as this - // installation's scope consents to them. These lists can only narrow that - // ceiling: an empty list adds no bound, and a list naming more than the - // solution declares admits nothing extra. + // 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 9073d5f6d..a1d64e3c9 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -176,21 +176,50 @@ 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. -**The ceiling is what the consumer declares it consumes, not a list an -administrator keeps.** A consumer's published contract (`module.contract.codefly.yaml`) -names every module it calls on a person's behalf — the audience a capability for that -module is addressed to — and the scope ceiling of each invoke binding. The render -derives the authority document from it, delivery signs it, and accounts derives its -registry from the signed document, the applied presence and the envelope; the -organization's installation scope narrows that. Accounts reads the invoke bindings of -that grant for the installation this gateway stated -(`business.ConsumerInvokeCeiling`): the audience must be one the consumer declares, -and the scopes must fit one binding it declares 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 an installation whose -lists are empty or wider than the declaration admits nothing the declaration does -not. An installation that records no delivered authority (one made before -installations did) declares nothing, and a confined mint under it is refused by name. +**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. + The credential already reaches that consumer's own routes, and no consumer can + declare a binding to itself. +- **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/packages/saas-sdk/generated/contract/catalog.codefly.json b/module/services/frontend/code/packages/saas-sdk/generated/contract/catalog.codefly.json index 1c5c3dadd..5b44110c0 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:a8fcf121d96909157dd3bff2e978881824abacdccb4fbe5f6180b5957d2ede97", + "digest": "sha256:077be15abf5487231a7f731aacaedbb9b33028807580ed43e990ec78d6ca5b3a", "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 255ae371c9087fc644dc1d8b2cc710909a5db50f..052464e6ed1903a3681f9d46d2da0209017cffb9 100644 GIT binary patch delta 9244 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 G@BahrwD#Qq 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 272ff3dd2..27d9b0af9 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:a8fcf121d96909157dd3bff2e978881824abacdccb4fbe5f6180b5957d2ede97 + contract-digest: sha256:077be15abf5487231a7f731aacaedbb9b33028807580ed43e990ec78d6ca5b3a 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 784abf19a..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 @@ -175,11 +175,12 @@ export type InstallSolutionRequest = Message<"saas.accounts.v1.InstallSolutionRe * * 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 what the solution DECLARES it - * consumes: the invoke bindings of its delivered contract, as this - * installation's scope consents to them. These lists can only narrow that - * ceiling: an empty list adds no bound, and a list naming more than the - * solution declares admits nothing extra. + * 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/src/features/solution-installations/ui/install-solution-dialog.tsx b/module/services/frontend/code/src/features/solution-installations/ui/install-solution-dialog.tsx index 32537e156..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 17238e92f..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 @@ -175,11 +175,12 @@ export type InstallSolutionRequest = Message<"saas.accounts.v1.InstallSolutionRe * * 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 what the solution DECLARES it - * consumes: the invoke bindings of its delivered contract, as this - * installation's scope consents to them. These lists can only narrow that - * ceiling: an empty list adds no bound, and a list naming more than the - * solution declares admits nothing extra. + * 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; */ From 6b3baf4ba56b63b5e1c678dbbc28420954959d75 Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:02:38 -0400 Subject: [PATCH 3/4] style(accounts): group the consumer-ceiling test imports as goimports does Co-Authored-By: Claude Opus 5.5 (1M context) --- .../accounts/code/pkg/business/consumer_invoke_ceiling_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index d834a88c5..b89bfee4d 100644 --- a/module/services/accounts/code/pkg/business/consumer_invoke_ceiling_test.go +++ b/module/services/accounts/code/pkg/business/consumer_invoke_ceiling_test.go @@ -2,9 +2,9 @@ package business import ( "context" - "github.com/codefly-dev/core/solutionhost" "testing" + "github.com/codefly-dev/core/solutionhost" "github.com/stretchr/testify/require" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" From 0983053035ad1805946ea5ad49fc0d147bcff7c5 Mon Sep 17 00:00:00 2001 From: Codefly Automation <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:12:28 -0400 Subject: [PATCH 4/4] docs(auth-gateway): a self-admitting binding may name its own module; the own-audience refusal comes first Co-Authored-By: Claude Opus 5.5 (1M context) --- module/services/auth-gateway/AGENTS.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/module/services/auth-gateway/AGENTS.md b/module/services/auth-gateway/AGENTS.md index a1d64e3c9..baf64a504 100644 --- a/module/services/auth-gateway/AGENTS.md +++ b/module/services/auth-gateway/AGENTS.md @@ -204,9 +204,11 @@ What a refusal says, and who acts on it: 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. - The credential already reaches that consumer's own routes, and no consumer can - declare a binding to itself. +- **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