diff --git a/module/WORK_CONTEXTS.md b/module/WORK_CONTEXTS.md index 017917f45..55ba8ebfc 100644 --- a/module/WORK_CONTEXTS.md +++ b/module/WORK_CONTEXTS.md @@ -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, diff --git a/module/services/accounts/AUTHZ.md b/module/services/accounts/AUTHZ.md index c2895463e..c08f38683 100644 --- a/module/services/accounts/AUTHZ.md +++ b/module/services/accounts/AUTHZ.md @@ -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. @@ -144,6 +146,35 @@ 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, @@ -151,7 +182,7 @@ 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 @@ -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 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 ce32c7054..e40acd9ea 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_read_audience.go +++ b/module/services/accounts/code/pkg/adapters/delegated_read_audience.go @@ -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() } @@ -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, @@ -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") @@ -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()}) } @@ -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 } diff --git a/module/services/accounts/code/pkg/adapters/delegated_record_access.go b/module/services/accounts/code/pkg/adapters/delegated_record_access.go index bce99b609..fc0ff501f 100644 --- a/module/services/accounts/code/pkg/adapters/delegated_record_access.go +++ b/module/services/accounts/code/pkg/adapters/delegated_record_access.go @@ -2,6 +2,7 @@ package adapters import ( "accounts/pkg/auth" + "accounts/pkg/business" gen "accounts/pkg/gen/saas/accounts/v1" "context" "slices" @@ -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()) } diff --git a/module/services/accounts/code/pkg/adapters/installed_content_read.go b/module/services/accounts/code/pkg/adapters/installed_content_read.go new file mode 100644 index 000000000..329e5d4b7 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/installed_content_read.go @@ -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 +} diff --git a/module/services/accounts/code/pkg/adapters/installed_content_read_test.go b/module/services/accounts/code/pkg/adapters/installed_content_read_test.go new file mode 100644 index 000000000..df9ee59d0 --- /dev/null +++ b/module/services/accounts/code/pkg/adapters/installed_content_read_test.go @@ -0,0 +1,409 @@ +package adapters + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" + corework "github.com/codefly-dev/core/workcontext" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "accounts/internal/deliverytest" + "accounts/pkg/auth" + "accounts/pkg/business" + gen "accounts/pkg/gen/saas/accounts/v1" + + "github.com/codefly-dev/core/solutionhost" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" +) + +// This fixture derives the registry from signed applied manifests and durable +// envelope history, using the production reconciler. It never injects provenance. +type installedReadStore struct { + *readProjectionStore + business.WorkContextAuthorityStore + business.SolutionDeliveryStore + business.ModuleEnvelopeStore + records []*business.SolutionHostBindingRecord + deliveries map[string]*business.SolutionDeliveryRecord + envelope *business.ModuleEnvelopeRecord + live map[string]*business.LiveModuleAuthority + broad bool + liveErr error +} + +func (f *installedReadStore) WithControlPlane(ctx context.Context, fn func(context.Context) error) error { + return fn(ctx) +} +func (f *installedReadStore) WithControlPlaneReadSnapshot(ctx context.Context, fn func(context.Context) error) error { + return fn(ctx) +} +func (f *installedReadStore) WithOrgTx(ctx context.Context, _ string, fn func(context.Context) error) error { + return fn(ctx) +} +func (f *installedReadStore) LiveDeclaredBindings(context.Context) ([]business.LiveDeclaredBinding, error) { + return nil, nil +} +func (f *installedReadStore) GetPlatformRole(context.Context, string) (string, error) { return "", nil } +func (f *installedReadStore) GetOrgMembership(context.Context, string, string) (*gen.OrgMembership, error) { + return &gen.OrgMembership{UserId: readOwner, Role: gen.OrgRole_ORG_ROLE_MEMBER}, nil +} +func (f *installedReadStore) ListSolutionHostBindings(context.Context) ([]*business.SolutionHostBindingRecord, error) { + return f.records, nil +} +func (f *installedReadStore) ReadDeliveredDocument(_ context.Context, binding string, _ uint64) (*business.SolutionDeliveryRecord, error) { + return f.deliveries[binding], nil +} +func (f *installedReadStore) LatestModuleEnvelopeRecord(context.Context) (*business.ModuleEnvelopeRecord, error) { + return f.envelope, nil +} +func (f *installedReadStore) LatestModuleEnvelopeRevision(context.Context) (uint64, error) { + return f.envelope.Revision, nil +} +func (f *installedReadStore) ModuleEnvelopeHistory(context.Context, uint64) ([]*business.ModuleEnvelopeRecord, error) { + return []*business.ModuleEnvelopeRecord{f.envelope}, nil +} +func (f *installedReadStore) BeginModuleAuthorityUse(ctx context.Context) (context.Context, func(), error) { + return ctx, func() {}, nil +} +func (f *installedReadStore) LiveModuleAuthority(_ context.Context, principal, org, _ string) (*business.LiveModuleAuthority, error) { + if f.liveErr != nil { + return nil, f.liveErr + } + if org != readOrg || f.live[principal] == nil { + return nil, business.ErrModuleInstallationInactive + } + return f.live[principal], nil +} +func (f *installedReadStore) ResolveWorkContextAuthority(_ context.Context, _, _, actor string, permissions []business.WorkContextPermission) (*business.WorkContextAuthorityFacts, error) { + if actor != "" { + return nil, business.NewStoreError(errors.New("module has no personal grants"), business.ErrTypePermission) + } + for _, p := range permissions { + if p.ResourceKind == "documents" && (!p.ContentRead && !f.broad && p.ResourceID == "" || len(f.grants[readOwner]) == 0) { + return nil, business.NewStoreError(errors.New("collection reader only"), business.ErrTypePermission) + } + } + return &business.WorkContextAuthorityFacts{OrganizationRevision: 7, PrincipalRevision: 3}, nil +} +func (f *installedReadStore) CheckAccess(_ context.Context, subject string, _ gen.SubjectKind, resource, id, action string) (bool, string, error) { + if subject != readOwner || resource != "documents" || action != "read" { + return false, "", nil + } + for _, grant := range f.grants[subject] { + if grant.NodeId == id { + return true, id, nil + } + } + return false, "", nil +} + +func (f *installedReadStore) RecordScopeNodeID(_ context.Context, _, id string) (string, error) { + return id, nil +} + +type installedReadSeals struct { + *sealAnswersFake + fixture *installedReadStore +} + +func (s installedReadSeals) WorkContextAudienceInstallation(_ context.Context, _, audience string) (string, error) { + return s.fixture.live[business.ModulePrincipalID(audience)].InstallationID, nil +} + +func installedReadFixture(t *testing.T) (*installedReadStore, *WorkContextAuthorityServer) { + t.Helper() + previous, previousAuthority := service, workContextSingleton + t.Cleanup(func() { service, workContextSingleton = previous, previousAuthority; SetInternalToken("") }) + f := &installedReadStore{readProjectionStore: &readProjectionStore{grants: map[string][]*gen.AccessibleScope{readOwner: {{NodeId: "boundary-a"}, {NodeId: "boundary-b"}}}, sources: []*business.DatasourceSource{{ID: sourceA, OrgID: readOrg, Provider: "upload", BoundaryNodeID: "boundary-a"}, {ID: sourceB, OrgID: readOrg, Provider: "upload", BoundaryNodeID: "boundary-b"}}}, deliveries: map[string]*business.SolutionDeliveryRecord{}, live: map[string]*business.LiveModuleAuthority{}} + svc, err := business.NewService(f) + require.NoError(t, err) + service = svc + verifier := deliverytest.Verifier(t, map[string][]string{"fixture": {"acme"}}) + svc.SetSolutionDelivery(verifier, f, nil) + svc.SetModuleEnvelopeStore(f) + svc.SetModuleAuthorityReads(f, nil) + principals := map[string]any{} + for _, prefix := range []string{"reader", "documents", "compute"} { + authority := &solutionhost.Authority{Principal: prefix} + grant := map[string]any{"tenant": readOrg} + if prefix == "documents" { + authority.ResourceKinds = []string{"documents"} + grant["resources"] = []string{"documents"} + } + if prefix == "reader" { + 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"}}}}} + } + 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) + digest, err := manifest.Digest() + require.NoError(t, err) + f.records = append(f.records, &business.SolutionHostBindingRecord{BindingID: manifest.Binding, Applied: &business.SolutionHostBindingApplied{SolutionHostBindingGeneration: business.SolutionHostBindingGeneration{Generation: 1, Digest: digest, Document: string(payload)}}}) + f.deliveries[manifest.Binding] = &business.SolutionDeliveryRecord{Binding: manifest.Binding, Generation: 1, Payload: payload, Envelope: deliverytest.Envelope(t, payload, "fixture")} + f.live[business.ModulePrincipalID(prefix)] = &business.LiveModuleAuthority{RegistryBindingID: manifest.Binding, InstallationID: uuid.NewString(), InstallationRevision: 1, ProducerEpoch: 1, EnvelopeRevision: func() *uint64 { v := uint64(1); return &v }()} + principals[prefix] = map[string]any{"grant": grant} + } + raw, err := json.Marshal(map[string]any{"revision": 1, "principals": principals}) + require.NoError(t, err) + f.envelope = &business.ModuleEnvelopeRecord{Revision: 1, Document: raw} + reconciler, err := business.NewSolutionHostBindingReconciler(svc, business.SolutionHostBindingReconcilerConfig{Source: business.NewDeliveredSolutionHostBindings(svc), Verifier: verifier, Coordinate: "host.test", Envelopes: svc}) + require.NoError(t, err) + require.NoError(t, reconciler.ModulePrincipals().RunOnce(context.Background())) + for id, grant := range svc.ModulePrincipals() { + f.live[id].Scope = business.FullInstallationScope(grant) + } + facts := &business.WorkContextAuthorityFacts{OrganizationRevision: 7, PrincipalRevision: 3} + server := newCoreTestServer(t, f, newSealAnswersFake(facts.EffectiveRevision())) + server.boundaries = &solutionBoundaryAuthorityFake{} + server.seals = installedReadSeals{server.seals.(*sealAnswersFake), f} + workContextSingleton = server + SetInternalToken("installed-read-test") + return f, server +} + +func (s installedReadSeals) WorkContextOperationBinding(ctx context.Context, org, id string) (business.WorkContextOperationBinding, error) { + return service.WorkContextOperationBinding(ctx, org, id) +} + +type installedReadJournal struct { + business.ActorChainJournal + hops []business.ActorChainHopInput + err error +} + +func (j *installedReadJournal) AppendActorChainHop(_ context.Context, hop business.ActorChainHopInput) (*business.ActorChainHop, error) { + j.hops = append(j.hops, hop) + return &business.ActorChainHop{ID: hop.ID}, j.err +} + +func (j *installedReadJournal) AnyActorChainHopRevoked(context.Context, string, []string) (bool, error) { + return false, nil +} + +func TestInstalledContentReadJournalsBeforeIssuance(t *testing.T) { + for _, lookup := range []bool{false, true} { + for _, failAppend := range []bool{false, true} { + t.Run(fmt.Sprintf("lookup=%t/append_failure=%t", lookup, failAppend), func(t *testing.T) { + f, server := installedReadFixture(t) + journal := &installedReadJournal{} + if failAppend { + journal.err = errors.New("journal unavailable") + } + server.journal = journal + caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{}) + parent, err := server.StartTask(caller, &gen.StartTaskWorkContextRequest{OrgId: readOrg, TaskId: uuid.NewString(), Audience: "reader", AuthorityScopes: []*gen.WorkContextScope{{ResourceKind: "documents", Actions: []string{"read"}}}}) + require.NoError(t, err) + moduleToken, _, err := server.StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("reader"), Tenant: readOrg, InstallationID: f.live[business.ModulePrincipalID("reader")].InstallationID}) + require.NoError(t, err) + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "installed-read-test", workContextHeader, moduleToken)) + child, err := (&ModuleCapabilitiesServer{}).ExchangeDelegatedOperationAudience(ctx, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "evidence", ParentWorkContextToken: parent.Token, Lookup: lookup}) + require.Len(t, journal.hops, 1) + if failAppend { + require.Equal(t, codes.Internal, status.Code(err)) + require.Nil(t, child, "an unrecorded actor hop must not release a capability") + return + } + require.NoError(t, err) + verified, err := corework.Decode(child.Token) + require.NoError(t, err) + claims := verified.Context() + require.Len(t, claims.ActorChain, 1) + hop := journal.hops[0] + require.Equal(t, claims.ActorChain[0].DelegationId, hop.ID) + require.Equal(t, readOwner, hop.OwnerPrincipalID) + require.Equal(t, business.ModulePrincipalID("reader"), hop.ActorPrincipalID) + require.Equal(t, business.PrincipalKindService, hop.ActorKind) + require.Equal(t, readOrg, hop.OrgID) + require.Equal(t, claims.TaskId, hop.TaskID) + require.Equal(t, claims.SessionId, hop.SessionID) + require.Equal(t, claims.AuthorizationRevision, hop.AuthorizationRevision) + require.Equal(t, actorChainScopes(claims.ActorChain[0].GrantedScopes), hop.GrantedScopes) + }) + } + } +} + +func TestInstalledContentReadCollectionReader(t *testing.T) { + f, server := installedReadFixture(t) + caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{}) + scopes := []*gen.WorkContextScope{{ResourceKind: "documents", Actions: []string{"read"}}, {ResourceKind: "runs", Actions: []string{"execute"}}, {ResourceKind: "profiles", Actions: []string{"invoke", "read"}, ResourceIds: []string{"standard"}}} + parent, err := server.StartTask(caller, &gen.StartTaskWorkContextRequest{OrgId: readOrg, TaskId: uuid.NewString(), Audience: "reader", AuthorityScopes: scopes}) + require.NoError(t, err) + moduleToken, _, err := server.StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("reader"), Tenant: readOrg, InstallationID: f.live[business.ModulePrincipalID("reader")].InstallationID}) + require.NoError(t, err) + exchangeContext := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "installed-read-test", workContextHeader, moduleToken)) + exchange := func() (*gen.IssuedWorkContext, error) { + return (&ModuleCapabilitiesServer{}).ExchangeDelegatedOperationAudience(exchangeContext, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "evidence", ParentWorkContextToken: parent.Token}) + } + for _, lookup := range []bool{false, true} { + model, err := (&ModuleCapabilitiesServer{}).ExchangeDelegatedOperationAudience(exchangeContext, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "execution", ParentWorkContextToken: parent.Token, Lookup: lookup}) + require.NoError(t, err) + modelClaims, err := corework.Decode(model.Token) + require.NoError(t, err) + require.Empty(t, modelClaims.Context().ActorChain, "ordinary Model exchange must preserve its existing human shape") + require.Equal(t, []string{"standard"}, modelClaims.Context().AuthorityScopes[0].ResourceIds) + } + lookup, err := (&ModuleCapabilitiesServer{}).ExchangeDelegatedOperationAudience(exchangeContext, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "evidence", ParentWorkContextToken: parent.Token, Lookup: true}) + require.NoError(t, err) + lookupClaims, err := corework.Decode(lookup.Token) + require.NoError(t, err) + require.Equal(t, []string{"boundary-a"}, lookupClaims.Context().ActorChain[0].GrantedScopes[0].ResourceIds) + child, err := exchange() + require.NoError(t, err) + require.Equal(t, business.ModulePrincipalID("reader"), child.CurrentActorPrincipalId) + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "installed-read-test", workContextHeader, child.Token)) + oracle := &ModuleCapabilitiesServer{} + page, err := oracle.ListReadableSourceCollections(ctx, &gen.ListReadableSourceCollectionsRequest{PageSize: 10}) + require.NoError(t, err) + require.Len(t, page.Collections, 2) + record := &gen.CheckWorkContextRecordAccessRequest{ResourceType: "documents", ResourceId: "boundary-a", Action: "read"} + exact, err := oracle.CheckWorkContextRecordAccess(ctx, record) + require.NoError(t, err) + require.True(t, exact.Allowed) + // Removing only this collection hides its known record while another remains. + f.grants[readOwner] = f.grants[readOwner][1:] + exact, err = oracle.CheckWorkContextRecordAccess(ctx, record) + require.NoError(t, err) + require.False(t, exact.Allowed) + page, err = oracle.ListReadableSourceCollections(ctx, &gen.ListReadableSourceCollectionsRequest{PageSize: 10}) + require.NoError(t, err) + require.Len(t, page.Collections, 1) + // Owner transformation cannot discard installed provenance. + _, err = server.ExchangeAudience(caller, &gen.ExchangeWorkContextAudienceRequest{OrgId: readOrg, ParentWorkContextToken: child.Token, Audience: "reader", AttenuatedScopes: scopes}) + require.ErrorContains(t, err, "cannot be transformed") + _, err = server.StartRootSession(caller, &gen.StartRootSessionWorkContextRequest{SessionId: uuid.NewString(), OrgId: readOrg, ParentWorkContextToken: child.Token, Audience: "reader"}) + require.ErrorContains(t, err, "cannot be transformed") + _, err = server.StartChildSession(caller, &gen.StartChildSessionWorkContextRequest{OrgId: readOrg, ParentWorkContextToken: child.Token, Audience: "reader", ActorPrincipalId: uuid.NewString(), GrantedScopes: scopes}) + require.ErrorContains(t, err, "cannot be transformed") + actorCaller := stampVerifiedIdentity(context.Background(), business.ModulePrincipalID("reader"), readOrg, auth.Assurance{}) + _, err = server.RenewWorkContext(actorCaller, &gen.RenewWorkContextRequest{OrgId: readOrg, ParentWorkContextToken: child.Token}) + require.ErrorContains(t, err, "cannot be renewed") + // Current monotonic envelope facts revoke old children permanently. + registry := service.ModulePrincipals() + oldGrant := registry[business.ModulePrincipalID("reader")] + withdrawn := oldGrant + withdrawn.OperationAudiences = nil + registry[business.ModulePrincipalID("reader")] = withdrawn + f.envelope.Revision = 2 + for _, live := range f.live { + v := uint64(2) + live.EnvelopeRevision = &v + } + service.InstallDerivedModulePrincipals(registry, 2, nil) + _, err = exchange() + require.Error(t, err) + _, err = oracle.CheckWorkContextRecordAccess(ctx, record) + require.Error(t, err) + registry[business.ModulePrincipalID("reader")] = oldGrant + f.envelope.Revision = 3 + for _, live := range f.live { + v := uint64(3) + live.EnvelopeRevision = &v + } + service.InstallDerivedModulePrincipals(registry, 3, nil) + _, err = oracle.CheckWorkContextRecordAccess(ctx, record) + require.Error(t, err) + _, err = exchange() + require.NoError(t, err, "ordinary root permits a newly authorized child") + f.grants[readOwner] = nil + _, err = exchange() + require.Error(t, err) +} + +func TestInstalledContentReadRefusesExistingActors(t *testing.T) { + f, server := installedReadFixture(t) + server.authority = &readExchangeAuthority{facts: &business.WorkContextAuthorityFacts{OrganizationRevision: 7, PrincipalRevision: 3}} + server.journal = &readExchangeJournal{} + scopes := []*basev0.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"read"}}} + token, _ := mintTestCapability(t, server, testCapability{Audience: "reader", Tenant: readOrg, Owner: readOwner, Scopes: scopes, Actors: []delegationHop{agentHop("actor-1", "hop-1", scopes...)}}) + moduleToken, _, err := server.StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("reader"), Tenant: readOrg, InstallationID: f.live[business.ModulePrincipalID("reader")].InstallationID}) + require.NoError(t, err) + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "installed-read-test", workContextHeader, moduleToken)) + result, err := (&ModuleCapabilitiesServer{}).ExchangeDelegatedOperationAudience(ctx, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "evidence", ParentWorkContextToken: token}) + require.Error(t, err, "a marked binding cannot issue an ordinary remint for an existing actor") + require.Nil(t, result) +} + +func TestInstalledContentReadNeverFallsBackWhenTargetContentDisappears(t *testing.T) { + for _, flatRole := range []bool{false, true} { + t.Run(fmt.Sprint(flatRole), func(t *testing.T) { + f, server := installedReadFixture(t) + f.broad = flatRole + caller := stampVerifiedIdentity(context.Background(), readOwner, readOrg, auth.Assurance{}) + root, err := server.StartTask(caller, &gen.StartTaskWorkContextRequest{OrgId: readOrg, TaskId: uuid.NewString(), Audience: "reader", AuthorityScopes: []*gen.WorkContextScope{{ResourceKind: "documents", Actions: []string{"read"}}}}) + require.NoError(t, err) + moduleToken, _, err := server.StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("reader"), Tenant: readOrg, InstallationID: f.live[business.ModulePrincipalID("reader")].InstallationID}) + require.NoError(t, err) + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "installed-read-test", workContextHeader, moduleToken)) + registry := service.ModulePrincipals() + target := registry[business.ModulePrincipalID("documents")] + target.Resources = nil + registry[business.ModulePrincipalID("documents")] = target + service.InstallDerivedModulePrincipals(registry, 1, nil) + for _, lookup := range []bool{false, true} { + out, err := (&ModuleCapabilitiesServer{}).ExchangeDelegatedOperationAudience(ctx, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "evidence", ParentWorkContextToken: root.Token, Lookup: lookup}) + require.Nil(t, out) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + } + }) + } +} + +func TestInstalledContentReadLifetimeUsesBothOwnerFacts(t *testing.T) { + for _, dimension := range []string{"origin generation", "target generation", "origin installation", "target installation", "origin revision", "target revision", "origin epoch", "target epoch", "outage"} { + t.Run(dimension, func(t *testing.T) { + f, _ := installedReadFixture(t) + caller := business.ModuleCaller{PrincipalID: business.ModulePrincipalID("reader"), BoundOrg: readOrg} + held, _, err := service.InstalledContentReadBinding(context.Background(), caller, "evidence") + require.NoError(t, err) + prefix := "reader" + if strings.HasPrefix(dimension, "target") { + prefix = "documents" + } + id := business.ModulePrincipalID(prefix) + live := f.live[id] + switch { + case strings.HasSuffix(dimension, "generation"): + registry := service.ModulePrincipals() + grant := registry[id] + grant.PresenceGeneration++ + registry[id] = grant + service.InstallDerivedModulePrincipals(registry, 1, nil) + case strings.HasSuffix(dimension, "installation"): + live.InstallationID = uuid.NewString() + case strings.HasSuffix(dimension, "revision"): + live.InstallationRevision++ + case strings.HasSuffix(dimension, "epoch"): + live.ProducerEpoch++ + case dimension == "outage": + f.liveErr = errors.New("database unavailable") + } + _, err = service.WorkContextOperationBinding(context.Background(), readOrg, held.ID) + require.Error(t, err) + }) + } +} + +func TestInstalledContentReadRefusesNonhumanRoot(t *testing.T) { + f, server := installedReadFixture(t) + server.authority = &readExchangeAuthority{facts: &business.WorkContextAuthorityFacts{OrganizationRevision: 7, PrincipalRevision: 3}} + scopes := []*basev0.WorkScopeV1{{ResourceKind: "documents", Actions: []string{"read"}}} + token, _ := mintTestCapability(t, server, testCapability{Audience: "reader", Tenant: readOrg, Owner: readOwner, OwnerKind: business.PrincipalKindService, Scopes: scopes}) + moduleToken, _, err := server.StartModuleTask(context.Background(), business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID("reader"), Tenant: readOrg, InstallationID: f.live[business.ModulePrincipalID("reader")].InstallationID}) + require.NoError(t, err) + ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("x-codefly-internal-token", "installed-read-test", workContextHeader, moduleToken)) + for _, lookup := range []bool{false, true} { + result, err := (&ModuleCapabilitiesServer{}).ExchangeDelegatedOperationAudience(ctx, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "evidence", ParentWorkContextToken: token, Lookup: lookup}) + require.Equal(t, codes.PermissionDenied, status.Code(err)) + require.Nil(t, result) + } +} diff --git a/module/services/accounts/code/pkg/adapters/readable_sources.go b/module/services/accounts/code/pkg/adapters/readable_sources.go index da30e13cb..2646b2525 100644 --- a/module/services/accounts/code/pkg/adapters/readable_sources.go +++ b/module/services/accounts/code/pkg/adapters/readable_sources.go @@ -70,8 +70,17 @@ func (s *ModuleCapabilitiesServer) ListReadableSourceCollections(ctx context.Con return &gen.ListReadableSourceCollectionsResponse{}, nil } 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 + } subjects = append(subjects, actor.GetPrincipalId()) } // The capability's own sealed scopes decide which collection details this diff --git a/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go b/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go index d99973b03..25a9b7387 100644 --- a/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go +++ b/module/services/accounts/code/pkg/adapters/source_delegation_binding_test.go @@ -194,3 +194,38 @@ func TestThePlainMintExercisesTheDelegationAsTheDelegatingModule(t *testing.T) { require.Equal(t, business.ModulePrincipalID("docstore"), claims.GetActorChain()[0].GetPrincipalId()) require.Equal(t, business.SourceDelegationBindingID(delegationID, "docstore"), claims.GetOperationBinding().GetBindingId()) } + +func TestSourceDelegationCannotRedeemInstalledContentReadBinding(t *testing.T) { + store := installDelegationBindingService(t) + registry, err := moduleregistrytest.Parse(delegationBindingPrincipals) + require.NoError(t, err) + // An ordinary source delegation legitimately carries read; the selected + // callee binding still requires the distinct installed content-read child. + donor := registry[business.ModulePrincipalID("docstore")] + source := donor.OperationAudiences["source-sync"] + source.SourceDelegationScopes = []business.ModuleOperationScope{{ResourceKind: "collections", Actions: []string{"read", "write"}}} + donor.OperationAudiences["source-sync"] = source + store.delegations[delegationID].BindingDigest = business.SourceDelegationBindingDigest(source) + target := registry[business.ModulePrincipalID("ingestservice")] + read := business.ModuleOperationAudience{Audience: "reports", InvokeScopes: []business.ModuleOperationScope{{ResourceKind: "collections", Actions: []string{"read"}}}, LookupScopes: []business.ModuleOperationScope{{ResourceKind: "collections", Actions: []string{"read"}}}} + target.OperationAudiences["ordinary-read"] = read + read.DelegationMode = business.DelegationModeContentRead + target.OperationAudiences["installed-read"] = read + service.SetModuleCapabilities(nil, nil, registry) + caller := presentedAs(t, identityOf(t, "ingestservice")) + sourceParent, err := mintSourceContext("docstore", moduleToken(t, "docstore"), business.SourceDelegationRef{DelegationID: delegationID}) + require.NoError(t, err) + for _, lookup := range []bool{false, true} { + t.Run(map[bool]string{false: "invoke", true: "lookup"}[lookup], func(t *testing.T) { + ordinary, err := ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience(caller, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "ordinary-read", DelegationId: delegationID, Lookup: lookup}) + require.NoError(t, err) + require.NotEmpty(t, ordinary.GetToken()) + denied, err := ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience(caller, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "installed-read", DelegationId: delegationID, Lookup: lookup}) + require.Equal(t, codes.PermissionDenied, status.Code(err), "marked reference must refuse, got %v", err) + require.Nil(t, denied) + denied, err = ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience(caller, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "installed-read", ParentWorkContextToken: sourceParent.GetToken(), Lookup: lookup}) + require.Equal(t, codes.PermissionDenied, status.Code(err), "source parent must refuse, got %v", err) + require.Nil(t, denied) + }) + } +} diff --git a/module/services/accounts/code/pkg/adapters/work_context_core.go b/module/services/accounts/code/pkg/adapters/work_context_core.go index 8ec963c7c..b3d2e09f1 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_core.go +++ b/module/services/accounts/code/pkg/adapters/work_context_core.go @@ -389,6 +389,9 @@ func (s *WorkContextAuthorityServer) remint( ctx context.Context, parent *basev0.WorkContextV1, audience, replay string, ttl time.Duration, attenuated []*basev0.WorkScopeV1, session string, exercised moduleBindingSeal, ) (string, *basev0.WorkContextV1, error) { + if business.NamesInstalledContentRead(parent.GetOperationBinding().GetBindingId()) { + return "", nil, status.Error(codes.PermissionDenied, "installed content child cannot be reminted") + } // The seal follows the owner. A person's capability is sealed to the // organization's installation of the audience it addresses — what an // uninstall revokes — so a capability re-derived for an audience, renewed 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 3f1f93ff6..0ef0b0a61 100644 --- a/module/services/accounts/code/pkg/adapters/work_context_rpcs.go +++ b/module/services/accounts/code/pkg/adapters/work_context_rpcs.go @@ -198,6 +198,9 @@ func (s *WorkContextAuthorityServer) confirmCurrentAuthority( wool.Field("org", req.GetOrgId()), wool.Field("owner", req.GetOwnerPrincipalId()), wool.Field("module", principalID), wool.Field("binding", req.GetOperationBindingId()), wool.ErrField(err)) + if business.NamesInstalledContentRead(req.GetOperationBindingId()) { + return installedContentReadError(err) + } return status.Error(codes.PermissionDenied, business.ErrModuleHopStale.Error()) } continue @@ -538,7 +541,31 @@ func (s *WorkContextAuthorityServer) StartTask( if err := requireVocabularyAudience(ctx, req.GetAudience()); err != nil { return nil, err } - permissions, scopes, err := workContextScopes(req.GetAuthorityScopes(), mintContentReads(req.GetAudience())) + eligible := mintContentReads(req.GetAudience()) + var standing []string + if service != nil && req.GetActorPrincipalId() == "" { + var release func() + var err error + ctx, release, err = service.BeginModuleAuthorityUse(ctx) + if err != nil { + return nil, err + } + defer release() + var kinds []string + for _, scope := range req.GetAuthorityScopes() { + if len(scope.GetActions()) == 1 && scope.GetActions()[0] == "read" { + kinds = append(kinds, scope.GetResourceKind()) + } + } + foreign, identities, err := service.InstalledContentReadStanding(ctx, req.GetOrgId(), req.GetAudience(), kinds) + if err != nil { + return nil, err + } + standing = identities + direct := eligible + eligible = func(kind string) bool { return direct(kind) || slices.Contains(foreign, kind) } + } + permissions, scopes, err := workContextScopes(req.GetAuthorityScopes(), eligible) if err != nil { return nil, status.Error(codes.InvalidArgument, err.Error()) } @@ -624,6 +651,11 @@ func (s *WorkContextAuthorityServer) StartTask( return nil, err } } + for _, id := range standing { + if _, err := service.WorkContextOperationBinding(ctx, req.GetOrgId(), id); err != nil { + return nil, err + } + } if err := s.journalActorHop(ctx, signed); err != nil { return nil, err } @@ -1636,6 +1668,9 @@ func (s *WorkContextAuthorityServer) verifyParent( return nil, nil, refusedReadBack(ctx, err, status.Error(codes.PermissionDenied, "parent Work Context is not valid for caller")) } claims := parent.Context() + if business.NamesInstalledContentRead(claims.GetOperationBinding().GetBindingId()) { + return nil, nil, status.Error(codes.PermissionDenied, "installed content child cannot be transformed") + } if claims.GetTenantId() != orgID || claims.GetOwnerPrincipalId() != ownerID { return nil, nil, status.Error(codes.PermissionDenied, "parent Work Context is not valid for caller") } @@ -1665,6 +1700,9 @@ func (s *WorkContextAuthorityServer) verifyActorParent( return nil, nil, status.Error(codes.PermissionDenied, "parent Work Context is not valid") } claims := parent.Context() + if business.NamesInstalledContentRead(claims.GetOperationBinding().GetBindingId()) { + return nil, nil, status.Error(codes.PermissionDenied, "installed content child cannot be renewed") + } actors := claims.GetActorChain() if len(actors) == 0 { return nil, nil, status.Error( @@ -1697,6 +1735,9 @@ func (s *WorkContextAuthorityServer) requireCurrentAuthority( orgID string, parent *basev0.WorkContextV1, ) (*business.Principal, error) { + if business.NamesInstalledContentRead(parent.GetOperationBinding().GetBindingId()) { + return nil, s.requireInstalledContentRead(ctx, parent) + } ownerID := parent.GetOwnerPrincipalId() if len(parent.GetActorChain()) == 0 { permissions := permissionsFromCoreScopes(parent.GetAuthorityScopes(), recheckContentReads()) diff --git a/module/services/accounts/code/pkg/business/installed_content_mode_test.go b/module/services/accounts/code/pkg/business/installed_content_mode_test.go new file mode 100644 index 000000000..a0364f9e5 --- /dev/null +++ b/module/services/accounts/code/pkg/business/installed_content_mode_test.go @@ -0,0 +1,44 @@ +package business + +import ( + "testing" + + "github.com/codefly-dev/core/solutionhost" + "github.com/stretchr/testify/require" +) + +func TestInstalledContentReadModeIsExplicitAndRestricted(t *testing.T) { + read := []ModuleOperationScope{{ResourceKind: "documents", Actions: []string{"read"}}} + valid := ModuleOperationAudience{Audience: "documents", InvokeScopes: read, DelegationMode: DelegationModeContentRead} + require.NoError(t, validateOperationAudiences("reader", map[string]ModuleOperationAudience{"evidence": valid})) + lookup := valid + lookup.LookupScopes = read + require.NoError(t, validateOperationAudiences("reader", map[string]ModuleOperationAudience{"evidence": lookup})) + for name, change := range map[string]func(*ModuleOperationAudience){ + "unknown": func(b *ModuleOperationAudience) { b.DelegationMode = "future" }, + "write": func(b *ModuleOperationAudience) { + b.InvokeScopes = []ModuleOperationScope{{ResourceKind: "documents", Actions: []string{"write"}}} + }, + "headless": func(b *ModuleOperationAudience) { b.HeadlessScopes = read }, + "source": func(b *ModuleOperationAudience) { b.SourceDelegationScopes = read }, + "empty lookup": func(b *ModuleOperationAudience) { b.LookupScopes = []ModuleOperationScope{} }, + "wide lookup": func(b *ModuleOperationAudience) { + b.LookupScopes = []ModuleOperationScope{{ResourceKind: "other", Actions: []string{"read"}}} + }, + } { + t.Run(name, func(t *testing.T) { + b := valid + change(&b) + require.Error(t, validateOperationAudiences("reader", map[string]ModuleOperationAudience{"evidence": b})) + }) + } + manifest := &solutionhost.ModuleManifest{Binding: "host.local.reader", Alias: "reader", Generation: 2, Authority: &solutionhost.Authority{Principal: "reader", Bindings: []solutionhost.AuthorityBinding{{ID: "evidence", Audience: "documents", Operations: map[string][]string{"invoke": {"documents:read"}}}}}} + held := ModuleEnvelopePrincipal{Grant: ModulePrincipalGrant{Tenant: "00000000-0000-7000-8000-0000000000a1", OperationAudiences: map[string]ModuleOperationAudience{"evidence": valid}}} + for _, mode := range []ModuleAuthorityEnforcement{ModuleAuthorityEnforce, ModuleAuthorityWarn} { + grant, omitted, err := deriveUnderEnforcement(mode, "reader", held, true, manifest, []ModuleWorkload{testWorkload}, nil) + require.NoError(t, err) + require.Empty(t, omitted) + require.Equal(t, DelegationModeContentRead, grant.OperationAudiences["evidence"].DelegationMode) + require.Empty(t, grant.OperationAudiences["evidence"].SourceDelegationScopes) + } +} diff --git a/module/services/accounts/code/pkg/business/installed_content_read.go b/module/services/accounts/code/pkg/business/installed_content_read.go new file mode 100644 index 000000000..33cde6232 --- /dev/null +++ b/module/services/accounts/code/pkg/business/installed_content_read.go @@ -0,0 +1,140 @@ +package business + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "slices" + "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +const installedContentReadPrefix = "installed-read/" + +// NamesInstalledContentRead discriminates issuer-owned installed read bindings. +func NamesInstalledContentRead(id string) bool { + return strings.HasPrefix(id, installedContentReadPrefix) +} + +type installedContentReadID struct { + Principal string + Binding string + Lifetime string +} + +// InstalledContentReadBinding resolves one person-invocable content-only binding. +// The identity commits to monotonic owner facts, not a hash of current policy: +// removing and restoring identical policy can never revive a retained child. +func (s *Service) InstalledContentReadBinding(ctx context.Context, caller ModuleCaller, bindingID string) (WorkContextOperationBinding, ModuleOperationAudience, error) { + ctx, releaseAuthority, err := s.BeginModuleAuthorityUse(ctx) + if err != nil { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + } + defer releaseAuthority() + origin, snapshot, err := s.authorizeModuleCapability(ctx, caller) + if err != nil { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + } + binding, ok := origin.Grant.OperationAudiences[bindingID] + if !ok { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + if binding.DelegationMode != DelegationModeContentRead { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + if binding.headlessOnly() || len(binding.SourceDelegationScopes) != 0 || len(binding.InvokeScopes) == 0 { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + target, _, err := s.authorizeModuleCapabilityFromSnapshot(ctx, snapshot, ModuleCaller{PrincipalID: ModulePrincipalID(binding.Audience), BoundOrg: caller.BoundOrg}) + if err != nil { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + } + for _, scope := range binding.InvokeScopes { + if len(scope.Actions) != 1 || scope.Actions[0] != "read" || !slices.Contains(target.Grant.Resources, scope.ResourceKind) { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + } + if origin.Grant.PresenceGeneration == 0 || target.Grant.PresenceGeneration == 0 || snapshot.envelopeRevision == 0 { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrNoOperationBinding + } + if s.deliveredSince(ctx, snapshot) { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, ErrModuleRegistryStale + } + facts := []any{caller.BoundOrg, snapshot.envelopeRevision, + origin.Grant.PresenceBinding, origin.Grant.PresenceGeneration, origin.InstallationID, origin.InstallationRevision, origin.ProducerEpoch, + target.Grant.PresenceBinding, target.Grant.PresenceGeneration, target.InstallationID, target.InstallationRevision, target.ProducerEpoch} + encoded, err := json.Marshal(facts) + if err != nil { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + } + id := installedContentReadID{caller.PrincipalID, bindingID, fmt.Sprintf("%x", sha256.Sum256(encoded))} + encoded, err = json.Marshal(id) + if err != nil { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + } + if err := s.FenceModuleMint(ctx, ModuleWorkContextAuthority{PrincipalID: caller.PrincipalID, Tenant: caller.BoundOrg, InstallationID: origin.InstallationID, InstallationRevision: origin.InstallationRevision, snapshot: snapshot}); err != nil { + return WorkContextOperationBinding{}, ModuleOperationAudience{}, err + } + return WorkContextOperationBinding{ID: installedContentReadPrefix + base64.RawURLEncoding.EncodeToString(encoded), PrincipalID: caller.PrincipalID, InstallationID: origin.InstallationID, Revision: snapshot.envelopeRevision, Incarnation: origin.Grant.PresenceGeneration}, binding, nil +} + +func (s *Service) resolveInstalledContentRead(ctx context.Context, org, encoded string) (WorkContextOperationBinding, error) { + raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(encoded, installedContentReadPrefix)) + if err != nil || len(raw) > 4096 { + return WorkContextOperationBinding{}, ErrNoOperationBinding + } + var id installedContentReadID + if json.Unmarshal(raw, &id) != nil || id.Principal == "" || id.Binding == "" || id.Lifetime == "" { + return WorkContextOperationBinding{}, ErrNoOperationBinding + } + current, _, err := s.InstalledContentReadBinding(ctx, ModuleCaller{PrincipalID: id.Principal, BoundOrg: org}, id.Binding) + if err != nil { + return WorkContextOperationBinding{}, err + } + if current.ID != encoded { + return WorkContextOperationBinding{}, ErrNoOperationBinding + } + return current, nil +} + +// InstalledContentReadStanding recognizes foreign read standing only through an +// exact current binding. The returned identities must still resolve at release. +// This grants no visibility: the person's current grants remain authoritative. +func (s *Service) InstalledContentReadStanding(ctx context.Context, org, audience string, kinds []string) ([]string, []string, error) { + snapshot := s.moduleRegistrySnapshot() + grant, ok := snapshot.registry[ModulePrincipalID(audience)] + if !ok { + return nil, nil, nil + } + var resources, identities []string + for key, candidate := range grant.OperationAudiences { + needed := false + for _, scope := range candidate.InvokeScopes { + needed = needed || slices.Contains(kinds, scope.ResourceKind) + } + if !needed { + continue + } + if candidate.DelegationMode != DelegationModeContentRead { + continue + } + identity, binding, err := s.InstalledContentReadBinding(ctx, ModuleCaller{PrincipalID: ModulePrincipalID(audience), BoundOrg: org}, key) + if err != nil { + if err == ErrNoOperationBinding || status.Code(err) == codes.PermissionDenied { + continue + } + return nil, nil, err + } + identities = append(identities, identity.ID) + for _, scope := range binding.InvokeScopes { + if len(scope.ResourceIDs) == 0 { + resources = append(resources, scope.ResourceKind) + } + } + } + return resources, identities, nil +} diff --git a/module/services/accounts/code/pkg/business/module_authority_enforcement.go b/module/services/accounts/code/pkg/business/module_authority_enforcement.go index e33de5587..dbdb2df77 100644 --- a/module/services/accounts/code/pkg/business/module_authority_enforcement.go +++ b/module/services/accounts/code/pkg/business/module_authority_enforcement.go @@ -172,7 +172,7 @@ func requestedCeiling(request *solutionhost.Authority, held ModuleEnvelopePrinci delegating = id } } - if delegating == "" && len(request.Bindings) == 1 { + if delegating == "" && len(request.Bindings) == 1 && held.Grant.OperationAudiences[request.Bindings[0].ID].DelegationMode == "" { delegating = request.Bindings[0].ID } for _, binding := range request.Bindings { @@ -182,11 +182,12 @@ func requestedCeiling(request *solutionhost.Authority, held ModuleEnvelopePrinci invoke := scopesOf(binding.Operations[modulecontract.OperationInvoke]) audience := ModuleOperationAudience{ Audience: binding.Audience, + DelegationMode: held.Grant.OperationAudiences[binding.ID].DelegationMode, InvokeScopes: invoke, LookupScopes: scopesOf(binding.Operations[modulecontract.OperationLookup]), HeadlessScopes: nilIfEmpty(scopesOf(binding.Operations[modulecontract.OperationHeadless])), } - if binding.ID == delegating { + if binding.ID == delegating && audience.DelegationMode == "" { audience.SourceDelegationScopes = nilIfEmpty(invoke) } if grant.OperationAudiences == nil { diff --git a/module/services/accounts/code/pkg/business/module_capabilities.go b/module/services/accounts/code/pkg/business/module_capabilities.go index 58c21fac2..3ff8e1b1b 100644 --- a/module/services/accounts/code/pkg/business/module_capabilities.go +++ b/module/services/accounts/code/pkg/business/module_capabilities.go @@ -143,6 +143,7 @@ func (w ModuleWorkload) validateForm(prefix string) error { type ModulePrincipalGrant struct { // PresenceBinding is the durable registry key proven by delivered authority. + PresenceGeneration uint64 `json:"-"` PresenceBinding string `json:"presence_binding,omitempty"` ArtifactPolicies map[string]ExecutableArtifactPolicy `json:"artifact_policies"` ReadAudiences map[string]ModuleReadAudience `json:"read_audiences"` diff --git a/module/services/accounts/code/pkg/business/module_envelope.go b/module/services/accounts/code/pkg/business/module_envelope.go index 5090ea6a3..6d246cb00 100644 --- a/module/services/accounts/code/pkg/business/module_envelope.go +++ b/module/services/accounts/code/pkg/business/module_envelope.go @@ -796,7 +796,23 @@ func envelopePrincipalsKept(before, after json.RawMessage) bool { } for prefix, section := range was { kept, exists := is[prefix] - if !exists || !jsonOnlyGains(section, kept) { + if !exists || !envelopeDelegationModesKept(section, kept) || !jsonOnlyGains(section, kept) { + return false + } + } + return true +} + +// Changing an existing binding's credential semantics is never a pure grant. +// In particular, adding content_read restricts ordinary owner-only exchanges, +// although adding an object field would otherwise look additive to JSON. +func envelopeDelegationModesKept(before, after json.RawMessage) bool { + var was, is ModuleEnvelopePrincipal + if json.Unmarshal(before, &was) != nil || json.Unmarshal(after, &is) != nil { + return false + } + for id, binding := range was.Grant.OperationAudiences { + if current, ok := is.Grant.OperationAudiences[id]; !ok || current.DelegationMode != binding.DelegationMode { return false } } diff --git a/module/services/accounts/code/pkg/business/module_envelope_gain_test.go b/module/services/accounts/code/pkg/business/module_envelope_gain_test.go index 06558959a..73ddebc8d 100644 --- a/module/services/accounts/code/pkg/business/module_envelope_gain_test.go +++ b/module/services/accounts/code/pkg/business/module_envelope_gain_test.go @@ -89,3 +89,17 @@ func TestAPrincipalMayGainWithoutAWitness(t *testing.T) { require.False(t, moduleEnvelopeOnlyAdds(after, before), "and undoing it is a narrowing") }) } + +func TestExistingBindingDelegationModeChangeRequiresAWitness(t *testing.T) { + envelope := func(mode string) []byte { + return []byte(`{"revision":1,"principals":{"reader":{"grant":{"operation_audiences":{"evidence":{"audience":"documents","invoke_scopes":[{"resource_kind":"documents","actions":["read"]}]` + mode + `}}}}}}`) + } + ordinary := envelope("") + explicitOrdinary := envelope(`,"delegation_mode":""`) + content := envelope(`,"delegation_mode":"content_read"`) + require.False(t, moduleEnvelopeOnlyAdds(ordinary, content), "adding restrictive semantics is not adding authority") + require.False(t, moduleEnvelopeOnlyAdds(content, ordinary)) + require.False(t, moduleEnvelopeOnlyAdds(explicitOrdinary, content)) + require.True(t, moduleEnvelopeOnlyAdds(content, content)) + require.True(t, moduleEnvelopeOnlyAdds([]byte(`{"revision":1,"principals":{"reader":{"grant":{}}}}`), content), "a wholly new binding grants authority") +} diff --git a/module/services/accounts/code/pkg/business/module_hop_binding.go b/module/services/accounts/code/pkg/business/module_hop_binding.go index 10856f1ae..6d0aa26b2 100644 --- a/module/services/accounts/code/pkg/business/module_hop_binding.go +++ b/module/services/accounts/code/pkg/business/module_hop_binding.go @@ -2,7 +2,10 @@ package business import ( "context" + "encoding/base64" + "encoding/json" "errors" + "strings" ) // ErrModuleHopStale is a module hop of a Work Context whose authority no longer @@ -37,6 +40,28 @@ func (s *Service) ConfirmModuleHopBinding(ctx context.Context, orgID, principalI } defer releaseAuthority() + if NamesInstalledContentRead(bindingID) { + identity, err := s.resolveInstalledContentRead(ctx, orgID, bindingID) + if err != nil { + return err + } + if identity.PrincipalID != principalID { + return ErrModuleHopStale + } + raw, _ := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(bindingID, installedContentReadPrefix)) + var id installedContentReadID + if json.Unmarshal(raw, &id) != nil { + return ErrModuleHopStale + } + _, binding, err := s.InstalledContentReadBinding(ctx, ModuleCaller{PrincipalID: principalID, BoundOrg: orgID}, id.Binding) + if err != nil { + return err + } + if len(scopes) == 0 || !operationScopesSubset(scopes, binding.InvokeScopes) { + return ErrModuleHopStale + } + return nil + } if bindingID == "" { return ErrModuleHopStale } 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 0d08e8094..09233f92d 100644 --- a/module/services/accounts/code/pkg/business/module_operation_audience.go +++ b/module/services/accounts/code/pkg/business/module_operation_audience.go @@ -38,7 +38,12 @@ import ( // binding the one a connect records a delegation for; at most one binding of a // module may declare it, and like HeadlessScopes it is bounded by InvokeScopes, // because the context it yields acts on that person's behalf. +const DelegationModeContentRead = "content_read" + +// DelegationMode is host-owned: content_read requires an authentic installed +// module child and live personal visibility. Empty preserves ordinary exchanges. type ModuleOperationAudience struct { + DelegationMode string `json:"delegation_mode,omitempty"` Audience string `json:"audience"` InvokeScopes []ModuleOperationScope `json:"invoke_scopes"` LookupScopes []ModuleOperationScope `json:"lookup_scopes"` @@ -175,6 +180,16 @@ func validateOperationAudiences(prefix string, bindings map[string]ModuleOperati (binding.Audience == prefix && !selfAdmittingBinding(binding)) { return fmt.Errorf("invalid installed module operation audience binding") } + if binding.DelegationMode != "" { + if binding.DelegationMode != DelegationModeContentRead || len(binding.InvokeScopes) == 0 || binding.HeadlessScopes != nil || binding.SourceDelegationScopes != nil { + return fmt.Errorf("invalid installed content-read delegation mode") + } + for _, scope := range binding.InvokeScopes { + if len(scope.Actions) != 1 || scope.Actions[0] != "read" { + return fmt.Errorf("content-read delegation grants only read") + } + } + } // HEADLESS-ONLY: absent invoke is whole only beside headless scopes, and // then it carries nothing a person's exchange could redeem — no lookup and // no source delegation, both of which act as that person and so are diff --git a/module/services/accounts/code/pkg/business/module_principal_reconcile.go b/module/services/accounts/code/pkg/business/module_principal_reconcile.go index 97957d36e..2c8f421ee 100644 --- a/module/services/accounts/code/pkg/business/module_principal_reconcile.go +++ b/module/services/accounts/code/pkg/business/module_principal_reconcile.go @@ -539,16 +539,17 @@ func deriveModulePrincipalGrant( ceiling := entry.Grant request := manifest.Authority grant := ModulePrincipalGrant{ - PresenceBinding: manifest.Binding, - ArtifactPolicies: maps.Clone(ceiling.ArtifactPolicies), - ReadAudiences: maps.Clone(ceiling.ReadAudiences), - Prefix: prefix, - Queues: keepDeclared(ceiling.Queues, request.Queues), - Namespaces: keepDeclared(ceiling.Namespaces, request.Namespaces), - Resources: keepDeclared(ceiling.Resources, request.ResourceKinds), - CrossTenant: ceiling.CrossTenant, - Tenant: ceiling.Tenant, - Workloads: workloads, + PresenceBinding: manifest.Binding, + PresenceGeneration: manifest.Generation, + ArtifactPolicies: maps.Clone(ceiling.ArtifactPolicies), + ReadAudiences: maps.Clone(ceiling.ReadAudiences), + Prefix: prefix, + Queues: keepDeclared(ceiling.Queues, request.Queues), + Namespaces: keepDeclared(ceiling.Namespaces, request.Namespaces), + Resources: keepDeclared(ceiling.Resources, request.ResourceKinds), + CrossTenant: ceiling.CrossTenant, + Tenant: ceiling.Tenant, + Workloads: workloads, } grant.ExternalNamespaces = keepDeclared(ceiling.ExternalNamespaces, grant.Namespaces) @@ -636,8 +637,9 @@ func deriveModulePrincipalGrant( continue } derived := ModuleOperationAudience{ - Audience: allowed.Audience, - InvokeScopes: intersectUnitScopes(allowed.InvokeScopes, invoke), + Audience: allowed.Audience, + DelegationMode: allowed.DelegationMode, + InvokeScopes: intersectUnitScopes(allowed.InvokeScopes, invoke), } if len(headless) > 0 && allowed.HeadlessScopes != nil { derived.HeadlessScopes = nilIfEmpty(intersectUnitScopes(allowed.HeadlessScopes, headless)) diff --git a/module/services/accounts/code/pkg/business/source_delegation.go b/module/services/accounts/code/pkg/business/source_delegation.go index 2673157b5..58ad41ba7 100644 --- a/module/services/accounts/code/pkg/business/source_delegation.go +++ b/module/services/accounts/code/pkg/business/source_delegation.go @@ -808,6 +808,12 @@ func (s *Service) AuthorizeDelegationReferenceExchange( if !installed || validateOperationAudiences(grant.Prefix, map[string]ModuleOperationAudience{bindingID: binding}) != nil { return ErrSourceDelegationInvalid } + // Installed content reads require a human root and the exact installed + // binding lifetime. A source reference seals a different authority and + // cannot be substituted, even when its read scopes happen to overlap. + if binding.DelegationMode == DelegationModeContentRead { + return ErrSourceDelegationInvalid + } // A person delegated this work, and a headless-only binding was granted // nothing it may do as a person: refused by name, before any scope is read. if binding.headlessOnly() { diff --git a/module/services/accounts/code/pkg/business/work_context_seal.go b/module/services/accounts/code/pkg/business/work_context_seal.go index d78cf6b81..992228124 100644 --- a/module/services/accounts/code/pkg/business/work_context_seal.go +++ b/module/services/accounts/code/pkg/business/work_context_seal.go @@ -272,6 +272,9 @@ type WorkContextOperationBinding struct { // the person lost the role that let them delegate — is refused outright rather // than answered, exactly as a mint from it is. func (s *Service) WorkContextOperationBinding(ctx context.Context, orgID, bindingID string) (WorkContextOperationBinding, error) { + if NamesInstalledContentRead(bindingID) { + return s.resolveInstalledContentRead(ctx, orgID, bindingID) + } delegationID, usedBy, ok := parseSourceDelegationBindingID(bindingID) if !ok { return WorkContextOperationBinding{}, ErrNoOperationBinding diff --git a/module/services/accounts/code/pkg/infra/installed_content_mode_witness_test.go b/module/services/accounts/code/pkg/infra/installed_content_mode_witness_test.go new file mode 100644 index 000000000..426103afd --- /dev/null +++ b/module/services/accounts/code/pkg/infra/installed_content_mode_witness_test.go @@ -0,0 +1,42 @@ +//go:build !pure + +package infra_test + +import ( + "accounts/pkg/business" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestInstalledContentReadModeMigrationRequiresWitnessPostgres(t *testing.T) { + for _, removing := range []bool{false, true} { + t.Run(map[bool]string{false: "ordinary to content", true: "content to ordinary"}[removing], func(t *testing.T) { + s := newRegistryScenario(t, "modewitness") + before := envelopeJSON(t, s.envelope(t, 1, envelopeOptions{resources: true})) + binding := func(doc map[string]any) map[string]any { + return doc["principals"].(map[string]any)[s.prefix].(map[string]any)["grant"].(map[string]any)["operation_audiences"].(map[string]any)["run"].(map[string]any) + } + binding(before)["invoke_scopes"] = []any{map[string]any{"resource_kind": "profiles", "actions": []string{"read"}}} + if removing { + binding(before)["delegation_mode"] = business.DelegationModeContentRead + } + current := s.currentRevision(t) + _, err := s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current, envelopeJSONAt(t, before, current+1)) + require.NoError(t, err) + s.delivery.service.SetPolicyLog(nil, nil) + next := envelopeJSONClone(t, before) + if removing { + delete(binding(next), "delegation_mode") + } else { + binding(next)["delegation_mode"] = business.DelegationModeContentRead + } + _, err = s.delivery.service.PutModuleEnvelope(testCtx, s.admin, current+1, envelopeJSONAt(t, next, current+2)) + require.Error(t, err) + require.ErrorIs(t, err, business.ErrPolicyLogUnreachable) + record, err := s.delivery.service.GetModuleEnvelope(testCtx, s.admin) + require.NoError(t, err) + require.Equal(t, current+1, record.Revision) + }) + } +} diff --git a/module/services/accounts/code/pkg/infra/installed_content_read_test.go b/module/services/accounts/code/pkg/infra/installed_content_read_test.go new file mode 100644 index 000000000..7491d4887 --- /dev/null +++ b/module/services/accounts/code/pkg/infra/installed_content_read_test.go @@ -0,0 +1,238 @@ +//go:build !pure + +package infra_test + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "testing" + + "accounts/pkg/adapters" + "accounts/pkg/auth" + "accounts/pkg/business" + "accounts/pkg/business/executiontest" + gen "accounts/pkg/gen/saas/accounts/v1" + "accounts/pkg/infra/storetx" + + "github.com/codefly-dev/core/solutionhost" + "github.com/codefly-dev/core/workcontext" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" +) + +// Owner integration evidence, not native workload/composition qualification: +// the harness controls delivery TokenReview, records policy-log writes, and +// supplies test signing/execution bindings. The module credential is created +// directly by StartModuleTask. Registry, applied generations, installations, +// human grants and authority reads use migrated PostgreSQL and owner adapters. +func TestInstalledContentReadPostgresRetainedBinding(t *testing.T) { + s := newRegistryScenario(t, "contentreader") + target := newRegistryBinding(t, "contenttarget") + svc := s.delivery.service + manifests := map[string]*solutionhost.ModuleManifest{} + for _, b := range []*registryBinding{s.primary, target} { + m := productionPathManifest(t, b.solutionID, 1) + m.Kind = solutionhost.KindModule + m.Authority = &solutionhost.Authority{Principal: b.solutionID} + if b == s.primary { + m.Authority.Bindings = []solutionhost.AuthorityBinding{{ID: "evidence", Audience: target.solutionID, Operations: map[string][]string{"invoke": {"documents:read"}, "lookup": {"documents:read"}}}} + } else { + m.Authority.ResourceKinds = []string{"documents"} + } + manifests[b.solutionID] = m + } + read := []business.ModuleOperationScope{{ResourceKind: "documents", Actions: []string{"read"}}} + grants := map[string]business.ModuleEnvelopePrincipal{ + s.prefix: {Grant: business.ModulePrincipalGrant{Tenant: s.tenant, OperationAudiences: map[string]business.ModuleOperationAudience{"evidence": {Audience: target.solutionID, InvokeScopes: read, LookupScopes: read, DelegationMode: business.DelegationModeContentRead}}}}, + target.solutionID: {Grant: business.ModulePrincipalGrant{Tenant: s.tenant, Resources: []string{"documents"}}}, + } + put := func() { + t.Helper() + rev := s.currentRevision(t) + wire := map[string]any{} + for prefix, entry := range grants { + wire[prefix] = map[string]any{"grant": map[string]any{"tenant": entry.Grant.Tenant, "resources": entry.Grant.Resources, "operation_audiences": entry.Grant.OperationAudiences}} + } + raw, err := json.Marshal(map[string]any{"revision": rev + 1, "principals": wire}) + require.NoError(t, err) + _, err = svc.PutModuleEnvelope(testCtx, s.admin, rev, raw) + require.NoError(t, err) + require.NotErrorIs(t, s.passReasons(t), business.ErrModulePrincipalInputsUnreadable) + } + // First establish host ceiling, then deliver both signed owner declarations. + put() + for _, b := range []*registryBinding{s.primary, target} { + requireDelivered(t, s.delivery.deliver(t, manifests[b.solutionID])) + } + s.pass(t) + originInstall := s.install(t, s.tenant) + targetScenario := *s + targetScenario.prefix, targetScenario.primary = target.solutionID, target + targetInstall := targetScenario.install(t, s.tenant) + svc.SetWorkContextSealStore(testStore) + svc.SetExecutionBinding(executiontest.New()) + owner := seedUser(t) + seedHumanPrincipal(t, owner, "Collection reader") + require.NoError(t, testStore.As(business.Identity{OrgID: s.tenant}).AddOrgMember(testCtx, owner, "member")) + source := seedDatasourceSource(t, s.tenant) + var path string + require.NoError(t, testStore.WithControlPlane(testCtx, func(ctx context.Context) error { + return storetx.Tx(ctx).QueryRow(ctx, `SELECT n.scope_path::text FROM datasource_sources s JOIN scope_nodes n ON n.id=s.boundary_node_id WHERE s.id=$1`, source).Scan(&path) + })) + registerNode(t, s.tenant, path+".record", "record", "documents", "known-document") + role := business.NewIDString() + require.NoError(t, testStore.WithOrgTx(testCtx, s.tenant, func(ctx context.Context) error { + if err := testStore.CreateRole(ctx, &gen.Role{Id: role, OrgId: s.tenant, Name: "Collection reader " + role, Permissions: []*gen.Permission{{Resource: "documents", Action: "read"}}}); err != nil { + return err + } + return testStore.GrantScope(ctx, &gen.ScopeGrant{Id: business.NewIDString(), OrgId: s.tenant, SubjectId: owner, SubjectKind: gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, ScopePath: path, RoleId: role}) + })) + _, key, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + previous := *adapters.WorkContextSingleton() + t.Cleanup(func() { + *adapters.WorkContextSingleton() = previous + adapters.WithService(nil) + adapters.SetInternalToken("") + }) + issuer := adapters.WorkContextSingleton() + issuer.Configure(adapters.WorkContextAuthorityConfiguration{Issuer: "accounts.test", KeyID: "content-test", PrivateKey: key, Authority: testStore, Seals: svc}) + adapters.WithService(svc) + adapters.SetInternalToken(delegationExchangeInternalToken) + caller := auth.WithVerifiedDatabaseIdentity(testCtx, owner, s.tenant) + root, err := issuer.StartTask(caller, &gen.StartTaskWorkContextRequest{OrgId: s.tenant, TaskId: business.NewIDString(), Audience: s.prefix, AuthorityScopes: []*gen.WorkContextScope{{ResourceKind: "documents", Actions: []string{"read"}}}}) + require.NoError(t, err) + moduleToken, _, err := issuer.StartModuleTask(testCtx, business.ModuleWorkContextAuthority{PrincipalID: s.principalID(), Tenant: s.tenant, InstallationID: originInstall.Id}) + require.NoError(t, err) + moduleCtx := metadata.NewIncomingContext(testCtx, metadata.Pairs("x-codefly-internal-token", delegationExchangeInternalToken, "x-codefly-work-context", moduleToken)) + exchange := func(lookup bool) (*gen.IssuedWorkContext, error) { + return adapters.ModuleCapabilitiesSingleton().ExchangeDelegatedOperationAudience(moduleCtx, &gen.ModuleExchangeDelegatedOperationAudienceRequest{BindingId: "evidence", ParentWorkContextToken: root.Token, Lookup: lookup}) + } + child, err := exchange(false) + require.NoError(t, err) + require.Equal(t, s.principalID(), child.CurrentActorPrincipalId) + claims, err := workcontext.Decode(child.Token) + require.NoError(t, err) + require.True(t, business.NamesInstalledContentRead(claims.Context().GetOperationBinding().GetBindingId())) + lookup, err := exchange(true) + require.NoError(t, err) + require.Equal(t, s.principalID(), lookup.CurrentActorPrincipalId) + readCtx := metadata.NewIncomingContext(testCtx, metadata.Pairs("x-codefly-internal-token", delegationExchangeInternalToken, "x-codefly-work-context", child.Token)) + record := &gen.CheckWorkContextRecordAccessRequest{ResourceType: "documents", ResourceId: "known-document", Action: "read"} + decision, err := adapters.ModuleCapabilitiesSingleton().CheckWorkContextRecordAccess(readCtx, record) + require.NoError(t, err) + require.True(t, decision.Allowed) + page, err := adapters.ModuleCapabilitiesSingleton().ListReadableSourceCollections(readCtx, &gen.ListReadableSourceCollectionsRequest{PageSize: 10}) + require.NoError(t, err) + require.Len(t, page.Collections, 1) + // An actual monotonic host update removes then identically restores the read. + original := grants[s.prefix] + withdrawn := original + withdrawn.Grant.OperationAudiences = nil + grants[s.prefix] = withdrawn + put() + _, err = exchange(false) + require.Error(t, err) + _, err = adapters.ModuleCapabilitiesSingleton().CheckWorkContextRecordAccess(readCtx, record) + require.Error(t, err) + grants[s.prefix] = original + put() + _, err = adapters.ModuleCapabilitiesSingleton().CheckWorkContextRecordAccess(readCtx, record) + require.Error(t, err, "identical policy cannot revive old binding lifetime") + // Existing installation consent remains authoritative after envelope changes. + // A new child is allowed only if that same consent still covers the binding. + _, _, err = svc.InstalledContentReadBinding(testCtx, s.caller(s.tenant), "evidence") + require.NoError(t, err) + // Each case starts with a fresh signed child, then moves an owner fact + // through its actual writer. The retained token must fail verification at + // the served record check, not merely differ in a decoded binding string. + freshRead := func() context.Context { + t.Helper() + root, err = issuer.StartTask(caller, &gen.StartTaskWorkContextRequest{OrgId: s.tenant, TaskId: business.NewIDString(), Audience: s.prefix, AuthorityScopes: []*gen.WorkContextScope{{ResourceKind: "documents", Actions: []string{"read"}}}}) + require.NoError(t, err) + moduleToken, _, err = issuer.StartModuleTask(testCtx, business.ModuleWorkContextAuthority{PrincipalID: s.principalID(), Tenant: s.tenant, InstallationID: originInstall.Id}) + require.NoError(t, err) + moduleCtx = metadata.NewIncomingContext(testCtx, metadata.Pairs("x-codefly-internal-token", delegationExchangeInternalToken, "x-codefly-work-context", moduleToken)) + next, err := exchange(false) + require.NoError(t, err) + held := metadata.NewIncomingContext(testCtx, metadata.Pairs("x-codefly-internal-token", delegationExchangeInternalToken, "x-codefly-work-context", next.Token)) + allowed, err := adapters.ModuleCapabilitiesSingleton().CheckWorkContextRecordAccess(held, record) + require.NoError(t, err) + require.True(t, allowed.Allowed) + return held + } + t.Run("content mode removed", func(t *testing.T) { + held := freshRead() + heldMetadata, ok := metadata.FromIncomingContext(held) + require.True(t, ok) + token := heldMetadata.Get("x-codefly-work-context")[0] + heldClaims, err := workcontext.Decode(token) + require.NoError(t, err) + bindingID := heldClaims.Context().GetOperationBinding().GetBindingId() + ask := func() (*gen.ModuleResolveWorkContextAuthorityResponse, error) { + t.Helper() + credential, _, err := issuer.StartModuleTask(testCtx, business.ModuleWorkContextAuthority{PrincipalID: business.ModulePrincipalID(target.solutionID), Tenant: s.tenant, InstallationID: targetInstall.Id}) + require.NoError(t, err) + callee := metadata.NewIncomingContext(testCtx, metadata.Pairs("x-codefly-internal-token", delegationExchangeInternalToken, "x-codefly-work-context", credential)) + return adapters.ModuleCapabilitiesSingleton().ResolveWorkContextAuthority(callee, &gen.ModuleResolveWorkContextAuthorityRequest{ + WorkContext: token, + Question: &gen.ModuleResolveWorkContextAuthorityRequest_OperationBinding{OperationBinding: &gen.WorkContextOperationBindingQuestion{BindingId: bindingID}}, + }) + } + answer, err := ask() + require.NoError(t, err) + require.Equal(t, gen.WorkContextOperationBindingState_WORK_CONTEXT_OPERATION_BINDING_STATE_RESOLVED, answer.GetOperationBinding().GetState()) + + // The ordinary binding remains installed. Removing its content-read mode + // is a witnessed restriction, and the callee must receive an explicit + // absence for the retained child's old binding, not an authority outage. + marked := grants[s.prefix].Grant.OperationAudiences["evidence"] + ordinary := marked + ordinary.DelegationMode = "" + grants[s.prefix].Grant.OperationAudiences["evidence"] = ordinary + put() + defer func() { + grants[s.prefix].Grant.OperationAudiences["evidence"] = marked + put() + }() + answer, err = ask() + require.NoError(t, err, "completed revocation must be a decided absence, not Unavailable") + require.Equal(t, bindingID, answer.GetOperationBinding().GetBindingId()) + require.Equal(t, gen.WorkContextOperationBindingState_WORK_CONTEXT_OPERATION_BINDING_STATE_ABSENT, answer.GetOperationBinding().GetState()) + }) + for _, which := range []string{"target presence", "origin presence", "target installation", "origin installation"} { + t.Run(which, func(t *testing.T) { + held := freshRead() + switch which { + case "target presence", "origin presence": + b := target + if which == "origin presence" { + b = s.primary + } + m := manifests[b.solutionID] + m.Generation++ + requireDelivered(t, s.delivery.deliver(t, m)) + s.pass(t) + case "target installation": + require.NoError(t, svc.UninstallSolution(testCtx, s.admin, s.tenant, targetInstall.Id)) + _, err = adapters.ModuleCapabilitiesSingleton().CheckWorkContextRecordAccess(held, record) + require.Error(t, err) + targetInstall = targetScenario.install(t, s.tenant) + case "origin installation": + require.NoError(t, svc.UninstallSolution(testCtx, s.admin, s.tenant, originInstall.Id)) + _, err = adapters.ModuleCapabilitiesSingleton().CheckWorkContextRecordAccess(held, record) + require.Error(t, err) + originInstall = s.install(t, s.tenant) + } + _, err = adapters.ModuleCapabilitiesSingleton().CheckWorkContextRecordAccess(held, record) + require.Error(t, err, "changed owner lifetime must reject retained signed child") + }) + } + require.NoError(t, testStore.WithOrgTx(testCtx, s.tenant, func(ctx context.Context) error { + return testStore.RevokeScope(ctx, s.tenant, owner, gen.SubjectKind_SUBJECT_KIND_PRINCIPAL, path, role) + })) + _, err = issuer.StartTask(caller, &gen.StartTaskWorkContextRequest{OrgId: s.tenant, TaskId: business.NewIDString(), Audience: s.prefix, AuthorityScopes: []*gen.WorkContextScope{{ResourceKind: "documents", Actions: []string{"read"}}}}) + require.Error(t, err, "collection-only standing is not a visibility grant") +}