Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions module/WORK_CONTEXTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Binary file modified module/contracts/api/accounts/authority/contract.binpb
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,16 @@ message InstallSolutionRequest {
// audiences it may be minted for and the resource kinds it may ever touch.
//
// Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an
// administrator consented to when they installed it. It is NOT unrestricted
// for a mint made with a credential delivered to a THIRD PARTY on a person's
// behalf — an MCP client the person authorized for this one solution. There
// the installation is the only statement of what the solution may act for, so
// an empty list is read as nothing and such a mint is REFUSED by name
// (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on
// purpose: one bounds a consumer an admin trusts, the other bounds a
// credential they never saw.
// administrator consented to when they installed it.
//
// A mint made with a credential delivered to a THIRD PARTY on a person's
// behalf — an MCP client the person authorized for this one solution — is not
// bounded by these lists. Its ceiling is the invoke bindings of the
// consumer's delivered authority, as this installation's scope consented to
// them when it was installed; a consumer with no delivered authority has no
// ceiling, and such a mint is refused. These lists can only narrow that
// ceiling: an empty list adds no bound, and a list naming more admits
// nothing extra.
repeated string allowed_audiences = 10 [
(buf.validate.field).repeated.max_items = 64,
(buf.validate.field).repeated.items.string.max_len = 512
Expand Down
Binary file modified module/contracts/api/accounts/connect/contract.binpb
Binary file not shown.
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,16 @@ message InstallSolutionRequest {
// audiences it may be minted for and the resource kinds it may ever touch.
//
// Empty = unrestricted FOR THIS INSTALLATION'S OWN WORK, which is what an
// administrator consented to when they installed it. It is NOT unrestricted
// for a mint made with a credential delivered to a THIRD PARTY on a person's
// behalf — an MCP client the person authorized for this one solution. There
// the installation is the only statement of what the solution may act for, so
// an empty list is read as nothing and such a mint is REFUSED by name
// (accounts' holdConfinedMintToItsConsumer). The two readings are opposite on
// purpose: one bounds a consumer an admin trusts, the other bounds a
// credential they never saw.
// administrator consented to when they installed it.
//
// A mint made with a credential delivered to a THIRD PARTY on a person's
// behalf — an MCP client the person authorized for this one solution — is not
// bounded by these lists. Its ceiling is the invoke bindings of the
// consumer's delivered authority, as this installation's scope consented to
// them when it was installed; a consumer with no delivered authority has no
// ceiling, and such a mint is refused. These lists can only narrow that
// ceiling: an empty list adds no bound, and a list naming more admits
// nothing extra.
repeated string allowed_audiences = 10 [
(buf.validate.field).repeated.max_items = 64,
(buf.validate.field).repeated.items.string.max_len = 512
Expand Down
4 changes: 2 additions & 2 deletions module/contracts/api/catalog.codefly.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"kind": "protobuf",
"package": "saas.accounts.v1",
"path": "contracts/api/accounts/authority/contract.binpb",
"digest": "sha256:dbbea2526330d41a9f4ecaa7c384235a641340d769b4a45f58f61977fe7b0b24",
"digest": "sha256:16a2647b0f308e889ba45a6efa8f291ef590d19819c9128036eb476bf1dee8c3",
"services": [
{
"name": "ModuleCapabilitiesService",
Expand Down Expand Up @@ -64,7 +64,7 @@
"kind": "protobuf",
"package": "saas.accounts.v1",
"path": "contracts/api/accounts/connect/contract.binpb",
"digest": "sha256:dbbea2526330d41a9f4ecaa7c384235a641340d769b4a45f58f61977fe7b0b24",
"digest": "sha256:16a2647b0f308e889ba45a6efa8f291ef590d19819c9128036eb476bf1dee8c3",
"services": [
{
"name": "APIKeyService",
Expand Down
4 changes: 2 additions & 2 deletions module/module.package.codefly.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ services:
kind: protobuf
package: saas.accounts.v1
path: contracts/api/accounts/authority/contract.binpb
digest: sha256:dbbea2526330d41a9f4ecaa7c384235a641340d769b4a45f58f61977fe7b0b24
digest: sha256:16a2647b0f308e889ba45a6efa8f291ef590d19819c9128036eb476bf1dee8c3
- endpoint: connect
kind: protobuf
package: saas.accounts.v1
path: contracts/api/accounts/connect/contract.binpb
digest: sha256:dbbea2526330d41a9f4ecaa7c384235a641340d769b4a45f58f61977fe7b0b24
digest: sha256:16a2647b0f308e889ba45a6efa8f291ef590d19819c9128036eb476bf1dee8c3
- name: auth-gateway
endpoints: [grpc, rest]
api-contracts:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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{{
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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)

Expand All @@ -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) })
Expand Down Expand Up @@ -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))
Expand All @@ -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()
Expand All @@ -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) {
Expand Down
Loading
Loading