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
41 changes: 41 additions & 0 deletions module/WORK_CONTEXTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,47 @@ and installation checks. A host without the configured policy-log witness
refuses a mode migration and leaves the stored envelope revision unchanged.
No local substitute witness or automatically inferred migration is supported.

**Installed module actors.**

`"delegation_mode": "module_actor"` is the same installed child for a module
that acts on a person's behalf, so the callee can see which module acted. It
takes the content-read rules above except that its invoke scopes are not
limited to `read`. Its audience is one of three:

- a module, which must serve every kind the binding names;
- a `solution:` audience, held by the organization's installation of that
solution;
- the host's own source operations (`saas-datasource`), where every scope names
`datasource.sources` with explicit source ids and carries only `invoke` and
`read`. The source surface then takes the call as the person's, with the
module acting, for those sources only, and rechecks the person's access to
the source on every call.

Lookup, when declared, still yields only lookup scopes.

```json
{"audience":"solution:example-tools","delegation_mode":"module_actor","invoke_scopes":[{"resource_kind":"tickets","actions":["read","write"]}],"lookup_scopes":[{"resource_kind":"tickets","actions":["read"]}]}
```

The parent must be a current human-owned root with no actor hops, addressed to
the authenticated caller module. Core's Child operation derives owner, tenant,
task and expiry from that parent; the one hop it adds is the authenticated
caller's module principal, never one the request names, so it equals the owner
of the module Work Context the host issued that module. The hop is journaled
like any on-behalf-of hop. The binding id is namespaced `installed-actor/` and
commits to the caller's installation, presence and envelope facts and to the
audience's installation where it has one: a module audience's presence and
installation, or the organization's installation of a solution audience.
Uninstalling, reinstalling or narrowing either side retires every child
already issued. A callee's
revision check confirms the hop from the binding it names
(`ConfirmModuleHopBinding`); only the owner is a question for the
authorization store. The child is terminal, as above.

The mode is the binding's, never the request's. The chain-preserving exchange,
StartTask and registered-agent actors are unchanged, and a binding without a
mode keeps its owner-only child.

Resource kinds, actions and resource ids are sorted and unique; wildcards are
refused. Lookup scopes are optional: a binding that declares only invoke scopes
redeems no lookup, and a lookup exchange under it is refused by name. When present,
Expand Down
7 changes: 7 additions & 0 deletions module/services/accounts/AUTHZ.md
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,13 @@ a host without it refuses migration and preserves its current envelope.
See [Work Contexts](../../WORK_CONTEXTS.md#installed-delegated-audience-exchange)
for the envelope field, terminal-lifetime rules and full exchange contract.

`delegation_mode: module_actor` issues the same terminal child for invoke
scopes the audience serves, addressed to a module, a `solution:` audience or
the host's source operations for named sources, so the callee sees the
installed module acting for the person. The person's own grants still decide
every scope; the module's hop adds none, and its binding commits to both
installations' lifetimes where the audience has one.

## A read grant is standing to mint a content read

A module reading content for a viewer presents that viewer's Work Context,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,8 +127,8 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context
default:
return nil, refuseUnidentifiedParent()
}
if business.NamesInstalledContentRead(verified.GetOperationBinding().GetBindingId()) {
return nil, status.Error(codes.PermissionDenied, "installed content child cannot be exchanged")
if business.NamesInstalledBinding(verified.GetOperationBinding().GetBindingId()) {
return nil, status.Error(codes.PermissionDenied, "installed binding child cannot be exchanged")
}
if verified.GetTenantId() == "" || verified.GetOwnerPrincipalId() == "" {
return nil, refuseUnidentifiedParent()
Expand Down Expand Up @@ -193,9 +193,12 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context
return nil, err
}
operation, _ := binding.policy.(business.ModuleOperationAudience)
installedRead := operation.DelegationMode == business.DelegationModeContentRead
if installedRead && (delegated || len(parent.ActorChain) != 0 || parent.GetOwnerPrincipalKind() != business.PrincipalKindHuman) {
err := status.Error(codes.PermissionDenied, "installed content read requires an ordinary human parent")
// An installed delegation mode is the binding's, never the request's: the
// caller cannot ask for an ordinary child of it, or for this child of an
// ordinary binding, and the chain-preserving exchange stays as it was.
installed := operation.DelegationMode != ""
if installed && (delegated || len(parent.ActorChain) != 0 || parent.GetOwnerPrincipalKind() != business.PrincipalKindHuman) {
err := status.Error(codes.PermissionDenied, "installed binding requires an ordinary human parent")
audit(business.DelegatedAudienceExchangeRefused, binding.audience, err)
return nil, err
}
Expand All @@ -210,8 +213,8 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context
var issued *gen.IssuedWorkContext
if delegated {
issued, err = authority.exchangeDelegatedParent(ctx, caller, parent, delegation, binding, time.Duration(ttl)*time.Second)
} else if installedRead {
issued, err = authority.exchangeInstalledContentRead(ctx, presented, caller, request, binding, time.Duration(ttl)*time.Second)
} else if installed {
issued, err = authority.exchangeInstalledBinding(ctx, presented, caller, request, binding, operation.DelegationMode, time.Duration(ttl)*time.Second)
} else {
issued, err = authority.exchangeVerifiedParent(ctx, parent, actor, &gen.ExchangeWorkContextAudienceRequest{OrgId: parent.TenantId, Audience: binding.audience, AttenuatedScopes: binding.scopes, ReplayPolicy: gen.WorkContextReplayPolicy_WORK_CONTEXT_REPLAY_POLICY_IDEMPOTENT, TtlSeconds: int32(ttl)}, "", moduleBindingSeal{PrincipalID: caller.PrincipalID, BindingID: request.operationBinding()})
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ func (s *ModuleCapabilitiesServer) CheckWorkContextRecordAccess(ctx context.Cont
ctx = auth.WithVerifiedDatabaseIdentity(ctx, claims.GetOwnerPrincipalId(), claims.GetTenantId())
installedRead := business.NamesInstalledContentRead(claims.GetOperationBinding().GetBindingId())
if installedRead {
if err := authority.requireInstalledContentRead(ctx, claims); err != nil {
if err := authority.requireInstalledBinding(ctx, claims); err != nil {
return nil, err
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,27 @@ import (
"google.golang.org/grpc/status"
)

func installedContentReadError(err error) error {
func installedBindingError(err error) error {
if errors.Is(err, business.ErrNoOperationBinding) || errors.Is(err, business.ErrModuleHopStale) || status.Code(err) == codes.PermissionDenied {
return status.Error(codes.PermissionDenied, "installed content read authority no longer stands")
return status.Error(codes.PermissionDenied, "installed binding authority no longer stands")
}
return status.Error(codes.Unavailable, "installed content read authority unavailable")
return status.Error(codes.Unavailable, "installed binding authority unavailable")
}

func (s *WorkContextAuthorityServer) exchangeInstalledContentRead(ctx context.Context, parent *workcontext.Verified, caller business.ModuleCaller, request delegatedAudienceRequest, selected delegatedAudienceBinding, ttl time.Duration) (*gen.IssuedWorkContext, error) {
// exchangeInstalledBinding issues the person's child of a binding installed
// under mode: Core derives owner, tenant, lineage and expiry from the presented
// parent, and the one hop it adds is the authenticated caller, never a
// principal the request names. The hop is journaled like any other
// on-behalf-of hop, in either mode, so the record of who acted outlives the
// token.
func (s *WorkContextAuthorityServer) exchangeInstalledBinding(ctx context.Context, parent *workcontext.Verified, caller business.ModuleCaller, request delegatedAudienceRequest, selected delegatedAudienceBinding, mode string, ttl time.Duration) (*gen.IssuedWorkContext, error) {
claims := parent.Context()
identity, binding, err := service.InstalledContentReadBinding(ctx, caller, request.bindingID)
identity, binding, err := service.InstalledBinding(ctx, caller, request.bindingID, mode)
if err != nil {
return nil, installedContentReadError(err)
return nil, installedBindingError(err)
}
if binding.Audience != selected.audience || claims.GetSeal().GetInstallationId() != identity.InstallationID {
return nil, status.Error(codes.PermissionDenied, "installed content parent mismatch")
return nil, status.Error(codes.PermissionDenied, "installed binding parent mismatch")
}
_, scopes, err := workContextScopes(selected.scopes, recheckContentReads())
if err != nil {
Expand All @@ -43,7 +49,7 @@ func (s *WorkContextAuthorityServer) exchangeInstalledContentRead(ctx context.Co
if err != nil {
return nil, mapWorkContextError(err)
}
if err := s.requireInstalledContentRead(ctx, signed); err != nil {
if err := s.requireInstalledBinding(ctx, signed); err != nil {
return nil, err
}
if err := s.journalActorHop(ctx, signed); err != nil {
Expand All @@ -53,21 +59,21 @@ func (s *WorkContextAuthorityServer) exchangeInstalledContentRead(ctx context.Co
}

// The installed module is authenticated by Core and the exact current binding;
// only the person supplies node visibility. This branch accepts no other chain.
func (s *WorkContextAuthorityServer) requireInstalledContentRead(ctx context.Context, claims *basev0.WorkContextV1) error {
// only the person supplies visibility. This branch accepts no other chain.
func (s *WorkContextAuthorityServer) requireInstalledBinding(ctx context.Context, claims *basev0.WorkContextV1) error {
if claims.GetOwnerPrincipalKind() != business.PrincipalKindHuman || len(claims.ActorChain) != 1 {
return status.Error(codes.PermissionDenied, "installed content child shape refused")
return status.Error(codes.PermissionDenied, "installed binding child shape refused")
}
actor := claims.ActorChain[0]
identity, err := service.WorkContextOperationBinding(ctx, claims.TenantId, claims.GetOperationBinding().GetBindingId())
if err != nil {
return installedContentReadError(err)
return installedBindingError(err)
}
if identity.PrincipalID != actor.PrincipalId || identity.InstallationID != claims.GetSeal().GetInstallationId() {
return status.Error(codes.PermissionDenied, "installed content child identity refused")
return status.Error(codes.PermissionDenied, "installed binding child identity refused")
}
if err := service.ConfirmModuleHopBinding(ctx, claims.TenantId, actor.PrincipalId, identity.ID, operationScopesFromCore(actor.GrantedScopes)); err != nil {
return installedContentReadError(err)
return installedBindingError(err)
}
facts, err := s.resolveAuthority(ctx, claims.TenantId, claims.OwnerPrincipalId, "", permissionsFromCoreScopes(actor.GrantedScopes, recheckContentReads()))
if err != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ func installedReadFixture(t *testing.T) (*installedReadStore, *WorkContextAuthor
authority.Bindings = []solutionhost.AuthorityBinding{{ID: "evidence", Audience: "documents", Operations: map[string][]string{"invoke": {"documents:read"}, "lookup": {"documents:read"}}}, {ID: "execution", Audience: "compute", Operations: map[string][]string{"invoke": {"profiles:read", "profiles:invoke"}, "lookup": {"profiles:read"}}}}
grant["operation_audiences"] = map[string]any{"evidence": map[string]any{"audience": "documents", "delegation_mode": "content_read", "lookup_scopes": []any{map[string]any{"resource_kind": "documents", "actions": []string{"read"}, "resource_ids": []string{"boundary-a"}}}, "invoke_scopes": []any{map[string]any{"resource_kind": "documents", "actions": []string{"read"}}}}, "execution": map[string]any{"audience": "compute", "invoke_scopes": []any{map[string]any{"resource_kind": "profiles", "actions": []string{"invoke", "read"}}}, "lookup_scopes": []any{map[string]any{"resource_kind": "profiles", "actions": []string{"read"}}}}}
}
addModuleActorBindings(prefix, authority, grant)
manifest := &solutionhost.ModuleManifest{Schema: solutionhost.SchemaModuleManifestV1, Binding: "acme.test." + prefix, Alias: prefix, Kind: solutionhost.KindModule, Generation: 1, Host: solutionhost.HostTarget{Coordinate: "host.test", Domain: "acme"}, Release: solutionhost.Release{Publisher: "acme", Name: prefix, Version: "1.0.0"}, Workloads: []solutionhost.Workload{{Service: "api", Container: "api"}}, Authority: authority}
payload, err := solutionhost.SignedPayload(manifest)
require.NoError(t, err)
Expand Down
Loading
Loading