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 @@ -105,6 +105,47 @@ checked again on the return exchange.
{"example":{"tenant":"019f6bf7-5b4b-74e5-8c17-092259bb1661","operation_audiences":{"generate":{"audience":"example-producer","invoke_scopes":[{"resource_kind":"results","actions":["read","write"]}],"lookup_scopes":[{"resource_kind":"results","actions":["read"]}]}}}}
```

**Installed content reads.**

An operation binding may explicitly set `"delegation_mode": "content_read"`
in the host-owned module envelope grant. Empty or absent mode keeps the ordinary
Model, proof and source-delegation exchange rules above. Unknown modes are
invalid. This mode is retained while the host intersects the envelope ceiling
with the installed manifest; it is never inferred from a resource name or from
whether the target is currently present.

```json
{"audience":"content","delegation_mode":"content_read","invoke_scopes":[{"resource_kind":"documents","actions":["read"]}],"lookup_scopes":[{"resource_kind":"documents","actions":["read"]}]}
```

A marked binding requires nonempty read-only invoke scopes, no headless scopes
and no source-delegation scopes. Optional lookup scopes remain a nonempty
read-only subset of invoke scopes; lookup uses the same restricted child path.
The parent must be a current human-owned root with no actor hops, addressed to
the authenticated caller module. Existing actor parents, source-delegated
parents and delegation references are refused. The host uses Core's Child
operation to attach the actual module service actor and attenuate the parent.
The child seals the exact installed binding plus the origin and target's
current installation, presence generation, producer epoch and envelope revision.
It cannot be renewed, exchanged again or transformed into another session.

A collection-only reader may carry an eligible foreign content-read scope at
StartTask for this installed hop. That is standing to request a read, not a
content grant. Actual source listing and record reads still resolve the human's
current collection/record visibility. The module actor's own permissions are
not added to personal visibility. Removing a collection grant withholds those
sources; removing/replacing either installation, changing presence, or removing
and restoring the binding invalidates an already issued child. Missing or
unavailable owner state cannot fall back to an ordinary exchange.

Changing the mode of an existing binding is a policy restriction, including
adding it where the mode was previously absent. The existing witnessed policy
log path must authorize that envelope transition; adding a JSON field is not
an additive grant. An entirely new binding remains subject to the normal grant
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.

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
46 changes: 39 additions & 7 deletions module/services/accounts/AUTHZ.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,11 @@ It is narrow, and fails closed on every other axis:
administrator's role is not the impersonator's to borrow. A Work Context
cannot be minted while impersonating, so a module's forwarded viewer is always
someone acting as themselves.
- **Every delegated subject still reads independently.** A Work Context read
intersects the owner and every actor in its chain; an actor with no grant
fails the intersection whatever the owner's role.
- **Ordinary delegated subjects read independently.** A Work Context read
intersects the owner and every ordinary actor in its chain; an actor with no
grant fails the intersection whatever the owner's role. The explicitly marked
installed content-read child described below authenticates module execution
separately and retains the human's personal visibility.
- **It replaces the grant, never the declaration.** A module that declares no
content resource reads nothing, administrator or not.

Expand All @@ -144,14 +146,43 @@ decision is the record. Granting or revoking the platform role itself is
audited, and bumps the authorization revision of every organization the user
belongs to, which invalidates outstanding Work Contexts and read cursors.

## Explicit installed content-read delegation

An envelope operation binding marked `delegation_mode: content_read` separates
module execution authority from a person's source visibility. The host accepts
only a human root without actor hops, authenticated independently from the
calling module. Core creates a child with that module's actual service actor.
The exact binding records both modules' installation/presence lifetime and the
envelope revision. Every content read revalidates that lifetime before using
the owner's existing collection grants, shares or platform read authority.
The module actor contributes no personal grants; it also does not remove the
human's valid visibility by being treated as a second collection reader.
The actor hop is journaled before the child is returned; an append failure
refuses issuance through the existing actor-chain journal.

StartTask may admit an unscoped foreign read carried by a current marked binding
when the human has existing content-read standing. This never admits content
writes, model inference, host permissions or an absent target declaration.
The child is terminal: renewal, further exchange, actor/source-delegated
parents and source references are refused. Optional bounded lookup uses the
same child path. Ordinary operation, proof, Model and source-delegation paths
keep their prior subject intersection and credential shape.

The host retains the explicit mode through manifest/envelope intersection.
Missing target authority is a refusal or outage, never an ordinary fallback.
Mode changes on existing bindings require the existing policy-log witness;
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.

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

A module reading content for a viewer presents that viewer's Work Context,
carrying an **unscoped** `read` of the content's permission resource type. The
capability is not organization-wide authority over the content: every read
still resolves node by node through the oracles above (the readable-source
projection, the exact-record check), intersected over the owner and every
actor. So the mint admits that one permission on the same bases those oracles
ordinary delegated actor. So the mint admits that one permission on the same bases those oracles
accept — an organization-level role as before, **or** a live scope grant (the
owner's or a team's), a live record share, or platform read authority on at
least one node. Without this a member granted read on one collection could see
Expand All @@ -171,9 +202,10 @@ ask for one.
It is narrow on every other axis (`workContextPermissionAllowed`, flagged by
the adapters' `markContentReads`):

- **Declared content only**, and at the mint that means the content declared by
**the audience this capability is being minted for** — not the composition's
union. That audience is the one module that will ever present the capability
- **Declared content only.** Ordinary mint standing uses content declared by
**the audience this capability is being minted for**, not the composition's
union. An explicit installed content-read binding supplies the bounded foreign
read standing described below. That audience is the one module that will ever present the capability
back, and both module-facing reads already refuse a scope outside its own
declaration, so the union would seal scopes no consumer can use and let one
module's declaration decide what another module's capability may carry. A
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ 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 verified.GetTenantId() == "" || verified.GetOwnerPrincipalId() == "" {
return nil, refuseUnidentifiedParent()
}
Expand All @@ -135,13 +138,14 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context
ctx = auth.WithVerifiedDatabaseIdentity(ctx, verified.OwnerPrincipalId, verified.TenantId)
parent := verified
var actor *business.Principal
actorID := delegatedAudienceActorID(parent)
audit := func(outcome, audience string, exchangeErr error) {
logDelegatedAudienceRefusal(ctx, request, audience, exchangeErr)
service.ObserveDelegatedAudienceExchange(ctx, business.DelegatedAudienceExchangeObservation{
Caller: caller,
OwnerID: parent.OwnerPrincipalId,
Actor: actor,
ActorID: delegatedAudienceActorID(parent),
ActorID: actorID,
Tenant: parent.TenantId,
BindingKind: request.kind,
BindingID: request.bindingID,
Expand Down Expand Up @@ -188,6 +192,13 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context
audit(business.DelegatedAudienceExchangeRefused, "", err)
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")
audit(business.DelegatedAudienceExchangeRefused, binding.audience, err)
return nil, err
}
ttl := min(int64(60), parent.ExpiresAtUnix-time.Now().Unix()-2)
if ttl <= 0 {
err = status.Error(codes.PermissionDenied, "parent expires before exchange")
Expand All @@ -199,6 +210,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 {
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 Expand Up @@ -234,6 +247,7 @@ func (s *ModuleCapabilitiesServer) exchangeDelegatedAudience(ctx context.Context
audit(business.DelegatedAudienceExchangeRefused, binding.audience, err)
return nil, err
}
actorID = issued.CurrentActorPrincipalId
audit(business.DelegatedAudienceExchangeIssued, binding.audience, nil)
return issued, nil
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package adapters

import (
"accounts/pkg/auth"
"accounts/pkg/business"
gen "accounts/pkg/gen/saas/accounts/v1"
"context"
"slices"
Expand Down Expand Up @@ -49,12 +50,21 @@ func (s *ModuleCapabilitiesServer) CheckWorkContextRecordAccess(ctx context.Cont
if !workContextGrants(claims, req.ResourceType, req.Action, req.ResourceId) {
return nil, status.Error(codes.PermissionDenied, "record scope required")
}
if len(claims.GetActorChain()) > 0 && authority.journal == nil {
if len(claims.GetActorChain()) > 0 && authority.journal == nil && !business.NamesInstalledContentRead(claims.GetOperationBinding().GetBindingId()) {
return nil, status.Error(codes.Unavailable, "actor chain authority is not configured")
}
ctx = auth.WithVerifiedDatabaseIdentity(ctx, claims.GetOwnerPrincipalId(), claims.GetTenantId())
installedRead := business.NamesInstalledContentRead(claims.GetOperationBinding().GetBindingId())
if installedRead {
if err := authority.requireInstalledContentRead(ctx, claims); err != nil {
return nil, err
}
}
subjects := []string{claims.GetOwnerPrincipalId()}
for _, actor := range claims.GetActorChain() {
if installedRead {
continue
}
if !slices.Contains(subjects, actor.GetPrincipalId()) {
subjects = append(subjects, actor.GetPrincipalId())
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
package adapters

import (
"context"
"errors"
"time"

"accounts/pkg/business"
gen "accounts/pkg/gen/saas/accounts/v1"

basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0"
"github.com/codefly-dev/core/workcontext"
"github.com/google/uuid"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

func installedContentReadError(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.Unavailable, "installed content read 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) {
claims := parent.Context()
identity, binding, err := service.InstalledContentReadBinding(ctx, caller, request.bindingID)
if err != nil {
return nil, installedContentReadError(err)
}
if binding.Audience != selected.audience || claims.GetSeal().GetInstallationId() != identity.InstallationID {
return nil, status.Error(codes.PermissionDenied, "installed content parent mismatch")
}
_, scopes, err := workContextScopes(selected.scopes, recheckContentReads())
if err != nil {
return nil, err
}
scopes, err = attenuateEffective(claims, scopes)
if err != nil {
return nil, err
}
token, signed, err := s.coreAuthority(claims.TenantId, claims.OwnerPrincipalId).Child(ctx, parent, workcontext.ChildInput{DelegationID: uuid.NewString(), PrincipalID: caller.PrincipalID, PrincipalKind: business.PrincipalKindService, OrganizationID: claims.TenantId, Audience: selected.audience, GrantedScopes: scopes, OperationBindingID: identity.ID, ReplayPolicy: workcontext.ReplayIdempotent, TTL: ttl})
if err != nil {
return nil, mapWorkContextError(err)
}
if err := s.requireInstalledContentRead(ctx, signed); err != nil {
return nil, err
}
if err := s.journalActorHop(ctx, signed); err != nil {
return nil, err
}
return issuedWorkContext(token, signed), nil
}

// 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 {
if claims.GetOwnerPrincipalKind() != business.PrincipalKindHuman || len(claims.ActorChain) != 1 {
return status.Error(codes.PermissionDenied, "installed content child shape refused")
}
actor := claims.ActorChain[0]
identity, err := service.WorkContextOperationBinding(ctx, claims.TenantId, claims.GetOperationBinding().GetBindingId())
if err != nil {
return installedContentReadError(err)
}
if identity.PrincipalID != actor.PrincipalId || identity.InstallationID != claims.GetSeal().GetInstallationId() {
return status.Error(codes.PermissionDenied, "installed content child identity refused")
}
if err := service.ConfirmModuleHopBinding(ctx, claims.TenantId, actor.PrincipalId, identity.ID, operationScopesFromCore(actor.GrantedScopes)); err != nil {
return installedContentReadError(err)
}
facts, err := s.resolveAuthority(ctx, claims.TenantId, claims.OwnerPrincipalId, "", permissionsFromCoreScopes(actor.GrantedScopes, recheckContentReads()))
if err != nil {
return err
}
if facts.EffectiveRevision() != claims.AuthorizationRevision {
return status.Error(codes.FailedPrecondition, "parent Work Context authorization revision is stale")
}
return nil
}
Loading
Loading