From 075a813a29d2da6c1521fa5095698749ba8283ac Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:28:40 -0400 Subject: [PATCH 1/3] feat(authority-core): an installation's consent bounds its module's operation bindings domain.Installation.Bindings is what the host's consent allows of the target's operation bindings at one installation. nil keeps every binding as delivered; a non-nil map allows only the bindings it names, each within both what was delivered and what it states. Every issue through a binding applies it: a workload's, an installation acting for itself through its solution's binding, a module's hop, and an issue from a delegation. A binding the consent dropped or narrowed issues no more than the consent allows, and a redelivery that widens a binding reaches an installation only once its consent does. Found by the review of codefly-dev/module-saas-starter#1257 (N1): a host whose administrators consent to each module's bindings could not state that consent to the core, so every new issue used the delivered binding. Co-Authored-By: Claude Opus 5.5 --- .../authority-core/go/authority/delegation.go | 3 + .../authority/host_installs_modules_test.go | 89 +++++++++++++++++++ .../authority-core/go/authority/issue.go | 11 ++- .../authority-core/go/authority/service.go | 23 +++++ libraries/authority-core/go/domain/domain.go | 7 ++ 5 files changed, 132 insertions(+), 1 deletion(-) diff --git a/libraries/authority-core/go/authority/delegation.go b/libraries/authority-core/go/authority/delegation.go index 72888bde..9efd8c4b 100644 --- a/libraries/authority-core/go/authority/delegation.go +++ b/libraries/authority-core/go/authority/delegation.go @@ -155,6 +155,9 @@ func (s *Service) IssueForDelegation(ctx context.Context, id string, actor Workl if !ok { return domain.Missing("operation binding %q is not one %s declares", r.Binding, am.Name) } + if binding, err = ceiled(tx, d.Organization, am, binding); err != nil { + return err + } if audience, err = tx.Module(r.Audience); err != nil { return notFound(err) } diff --git a/libraries/authority-core/go/authority/host_installs_modules_test.go b/libraries/authority-core/go/authority/host_installs_modules_test.go index 25ccd114..5c1920fb 100644 --- a/libraries/authority-core/go/authority/host_installs_modules_test.go +++ b/libraries/authority-core/go/authority/host_installs_modules_test.go @@ -183,3 +183,92 @@ func TestAWorkloadIsIssuedOnlyWhereItsHostInstalledIt(t *testing.T) { } }) } + +// recordConsent writes the host's installation record of the store module +// with the bindings its consent allows. +func (h *hostInstalled) recordConsent(org, id string, bindings map[string]domain.Binding) { + h.t.Helper() + if err := h.store.Write(h.ctx, func(tx authority.Tx) error { + return tx.PutInstallation(domain.Installation{ID: id, Organization: org, Target: "store", Status: domain.Active, Revision: 1, Bindings: bindings}) + }); err != nil { + h.t.Fatal(err) + } +} + +// What a host's consent allows of a module's bindings at one installation +// bounds every issue through them, whatever the delivery declares: a binding +// the consent does not name issues nothing, one it narrows issues within the +// narrowing, and a redelivery that widens the binding issues no more than was +// consented. +func TestAnInstallationsConsentBoundsItsModulesBindings(t *testing.T) { + read, readWrite := []*basev0.WorkScopeV1{sc(kind, "read")}, []*basev0.WorkScopeV1{sc(kind, "read", "write")} + for _, tc := range []struct { + name string + consent map[string]domain.Binding + widen bool + want []string + refusal domain.Code + refusedB bool + }{ + {name: "no ceiling: as delivered", want: []string{"read"}}, + {name: "the binding consented as delivered", consent: map[string]domain.Binding{"self": {ID: "self", HeadlessScopes: read, InvokeScopes: readWrite}}, want: []string{"read"}}, + {name: "a consent naming no binding", consent: map[string]domain.Binding{}, refusedB: true, refusal: domain.Denied}, + {name: "a consent naming another binding", consent: map[string]domain.Binding{"other": {ID: "other", HeadlessScopes: read}}, refusedB: true, refusal: domain.Denied}, + {name: "a widening redelivery stays within the consent", consent: map[string]domain.Binding{"self": {ID: "self", HeadlessScopes: read}}, widen: true, want: []string{"read"}}, + {name: "a widening redelivery with no ceiling widens", widen: true, want: []string{"read", "write"}}, + } { + t.Run(tc.name, func(t *testing.T) { + h := newHostInstalled(t) + h.recordConsent(acme, "host-1", tc.consent) + if tc.widen { + if _, err := h.svc.ApplyModule(h.ctx, domain.Module{Name: "store", Principal: "store", Kind: domain.KindModule, Generation: 2, + ResourceKinds: readWrite, + Bindings: map[string]domain.Binding{"self": {ID: "self", Audience: "store", InvokeScopes: readWrite, HeadlessScopes: readWrite}}, + }); err != nil { + t.Fatal(err) + } + } + issued, err := h.issue(authority.Workload{Module: "store"}, acme) + if tc.refusedB { + code(t, err, tc.refusal) + return + } + if err != nil { + t.Fatal(err) + } + got := effective(issued) + if len(got) != 1 || got[0].GetResourceKind() != kind || !sameActions(got[0].GetActions(), tc.want) { + t.Fatalf("issued %v, want %s %v", got, kind, tc.want) + } + }) + } + + t.Run("a person's hop through a binding the consent does not name is refused", func(t *testing.T) { + h := newHostInstalled(t) + h.recordConsent(acme, "host-1", map[string]domain.Binding{}) + h.store.PutOrgRole(domain.OrgRole{Organization: acme, Name: "reader", Scopes: []domain.RoleScope{{Kind: kind, Actions: []string{"read"}}}}) + h.store.PutPerson(domain.Person{ID: "alex", Organization: acme, Roles: []string{"admin", "reader"}}) + parent, err := h.svc.IssueForPerson(h.ctx, authority.Person{ID: "alex", Organization: acme, SessionID: "s"}, authority.IssueRequest{Audience: "store"}) + if err != nil { + t.Fatal(err) + } + _, err = h.svc.IssueChild(h.ctx, parent.Token, authority.Workload{Module: "store"}, authority.IssueRequest{Binding: "self"}) + code(t, err, domain.Denied) + }) +} + +func sameActions(got, want []string) bool { + if len(got) != len(want) { + return false + } + seen := map[string]bool{} + for _, a := range got { + seen[a] = true + } + for _, a := range want { + if !seen[a] { + return false + } + } + return true +} diff --git a/libraries/authority-core/go/authority/issue.go b/libraries/authority-core/go/authority/issue.go index d96bbab6..9795e3a8 100644 --- a/libraries/authority-core/go/authority/issue.go +++ b/libraries/authority-core/go/authority/issue.go @@ -299,6 +299,9 @@ func (s *Service) IssueForWorkload(ctx context.Context, w Workload, r IssueReque if !ok { return fmt.Errorf("%w: operation binding %q is not one %s declares", workcontext.ErrNoBinding, r.Binding, module.Name) } + if binding, err = ceiled(tx, org, module, binding); err != nil { + return err + } if binding.Audience != audience.Principal { return domain.Refuse("binding %s is addressed to %s, not %s", r.Binding, binding.Audience, audience.Principal) } @@ -385,6 +388,9 @@ func (s *Service) IssueForInstallation(ctx context.Context, w Workload, installa if !ok { return domain.Missing("operation binding %q is not one %s declares", r.Binding, solution.Name) } + if binding, err = ceiled(tx, inst.Organization, solution, binding); err != nil { + return err + } if binding.Audience != audience.Principal { return domain.Refuse("binding %q is addressed to %s, not %s", r.Binding, binding.Audience, audience.Principal) } @@ -490,6 +496,10 @@ func (s *Service) IssueChild(ctx context.Context, parent string, actor Workload, err = s.store.Read(ctx, func(tx Tx) error { tenant := claims.GetTenantId() var heldHere, sourceCeiling Scopes + var err error + if binding, err = ceiled(tx, tenant, am, binding); err != nil { + return err + } bounded := false switch claims.GetOwnerPrincipalKind() { case "human": @@ -520,7 +530,6 @@ func (s *Service) IssueChild(ctx context.Context, parent string, actor Workload, if (bounded || claims.GetOwnerPrincipalKind() == "agent") && !audience.OwnerAware { return decide.OwnerAware(audience, decide.Scopes{{ResourceKind: audience.Name, ResourceOwnerId: claims.GetOwnerPrincipalId()}}) } - var err error if through, err = decide.ScopesFor(binding, r.Operation, "invoke"); err != nil { return err } diff --git a/libraries/authority-core/go/authority/service.go b/libraries/authority-core/go/authority/service.go index 61457442..e152365e 100644 --- a/libraries/authority-core/go/authority/service.go +++ b/libraries/authority-core/go/authority/service.go @@ -16,6 +16,7 @@ import ( "time" "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "github.com/codefly-dev/authority/libraries/authority-core/go/scope" basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" "github.com/codefly-dev/core/workcontext" "github.com/google/uuid" @@ -357,6 +358,28 @@ func (src sources) OperationBinding(ctx context.Context, id string) (workcontext return out, err } +// ceiled is m's delivered binding b as usable at org: within what the host's +// consent at m's installation there allows (domain.Installation.Bindings). An +// organization with no installation of m leaves b as delivered; whoever needs +// the installation refuses its absence. +func ceiled(tx Tx, org string, m domain.Module, b domain.Binding) (domain.Binding, error) { + inst, err := installationOf(tx, org, m.Name) + if errors.Is(err, domain.ErrNotFound) { + return b, nil + } + if err != nil || inst.Bindings == nil { + return b, err + } + consented, ok := inst.Bindings[b.ID] + if !ok { + return domain.Binding{}, domain.Refuse("%s's operation binding %q is not one its installation at %s consents to", m.Name, b.ID, org) + } + b.InvokeScopes = scope.Intersect(b.InvokeScopes, consented.InvokeScopes) + b.HeadlessScopes = scope.Intersect(b.HeadlessScopes, consented.HeadlessScopes) + b.LookupScopes = scope.Intersect(b.LookupScopes, consented.LookupScopes) + return b, nil +} + // installationOf is an organization's installation of target: its active one // when it has one, since a host that installs modules keeps the records of the // installations it retired beside the one it made again. diff --git a/libraries/authority-core/go/domain/domain.go b/libraries/authority-core/go/domain/domain.go index 31aa67cd..f1bb9a05 100644 --- a/libraries/authority-core/go/domain/domain.go +++ b/libraries/authority-core/go/domain/domain.go @@ -112,6 +112,13 @@ type Installation struct { Consent ConsentReceipt // Owned is the bounded set of attached resource ids, by kind. Owned map[string][]string + // Bindings is what the host's consent allows of the target's operation + // bindings at this installation. nil means every binding as delivered. A + // non-nil map allows only the bindings it names, each used within both + // what was delivered and what it states (every scope list intersected), so + // a binding the consent dropped or narrowed, or one a later delivery + // widened, issues no more than the consent allows. + Bindings map[string]Binding } // RoleGrant is one role on one installation held by exactly one person or team. From 93bc9f58c208d282b50c2da29b269984e05025f3 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 27 Oct 2026 01:39:07 -0400 Subject: [PATCH 2/3] fix(authority-core): a hop back to a module's own audience is bounded by its parent, not by its consent A module's hop through a binding addressed to itself reaches nothing the parent, already addressed to it, did not: the child is bounded by that parent and the owner's grants. The consent that bounds what a module reaches at other audiences therefore does not apply to it, and a host whose consent model has no word for a self-addressed binding still serves the hop. Co-Authored-By: Claude Opus 5.5 --- .../authority/host_installs_modules_test.go | 35 +++++++++++++++++-- .../authority-core/go/authority/issue.go | 10 ++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/libraries/authority-core/go/authority/host_installs_modules_test.go b/libraries/authority-core/go/authority/host_installs_modules_test.go index 5c1920fb..49b814b5 100644 --- a/libraries/authority-core/go/authority/host_installs_modules_test.go +++ b/libraries/authority-core/go/authority/host_installs_modules_test.go @@ -1,6 +1,7 @@ package authority_test import ( + "strings" "testing" "github.com/codefly-dev/authority/libraries/authority-core/go/authority" @@ -33,10 +34,17 @@ func newHostInstalled(t *testing.T) *hostInstalled { } store.PutOrgRole(domain.OrgRole{Organization: acme, Name: "admin"}) store.PutPerson(domain.Person{ID: "alex", Organization: acme, Roles: []string{"admin"}}) + if _, err := svc.ApplyModule(h.ctx, domain.Module{Name: "relay", Principal: "relay", Kind: domain.KindModule, Generation: 1, + ResourceKinds: []*basev0.WorkScopeV1{sc(legacyKind, "read")}}); err != nil { + t.Fatal(err) + } if _, err := svc.ApplyModule(h.ctx, domain.Module{Name: "store", Principal: "store", Kind: domain.KindModule, Generation: 1, ResourceKinds: []*basev0.WorkScopeV1{sc(kind, "read", "write")}, - Bindings: map[string]domain.Binding{"self": {ID: "self", Audience: "store", - InvokeScopes: []*basev0.WorkScopeV1{sc(kind, "read", "write")}, HeadlessScopes: []*basev0.WorkScopeV1{sc(kind, "read")}}}, + Bindings: map[string]domain.Binding{ + "self": {ID: "self", Audience: "store", + InvokeScopes: []*basev0.WorkScopeV1{sc(kind, "read", "write")}, HeadlessScopes: []*basev0.WorkScopeV1{sc(kind, "read")}}, + "relay": {ID: "relay", Audience: "relay", InvokeScopes: []*basev0.WorkScopeV1{sc(legacyKind, "read")}}, + }, }); err != nil { t.Fatal(err) } @@ -243,6 +251,24 @@ func TestAnInstallationsConsentBoundsItsModulesBindings(t *testing.T) { }) } + t.Run("a hop back to the module's own audience is bounded by its parent, not by the consent", func(t *testing.T) { + h := newHostInstalled(t) + h.recordConsent(acme, "host-1", map[string]domain.Binding{}) + h.store.PutOrgRole(domain.OrgRole{Organization: acme, Name: "reader", Scopes: []domain.RoleScope{{Kind: kind, Actions: []string{"read"}}}}) + h.store.PutPerson(domain.Person{ID: "alex", Organization: acme, Roles: []string{"admin", "reader"}}) + parent, err := h.svc.IssueForPerson(h.ctx, authority.Person{ID: "alex", Organization: acme, SessionID: "s"}, authority.IssueRequest{Audience: "store"}) + if err != nil { + t.Fatal(err) + } + child, err := h.svc.IssueChild(h.ctx, parent.Token, authority.Workload{Module: "store"}, authority.IssueRequest{Binding: "self"}) + if err != nil { + t.Fatal(err) + } + if got := effective(child); len(got) != 1 || !sameActions(got[0].GetActions(), []string{"read"}) { + t.Fatalf("a hop to itself issued %v, want read", got) + } + }) + t.Run("a person's hop through a binding the consent does not name is refused", func(t *testing.T) { h := newHostInstalled(t) h.recordConsent(acme, "host-1", map[string]domain.Binding{}) @@ -252,8 +278,11 @@ func TestAnInstallationsConsentBoundsItsModulesBindings(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = h.svc.IssueChild(h.ctx, parent.Token, authority.Workload{Module: "store"}, authority.IssueRequest{Binding: "self"}) + _, err = h.svc.IssueChild(h.ctx, parent.Token, authority.Workload{Module: "store"}, authority.IssueRequest{Binding: "relay"}) code(t, err, domain.Denied) + if !strings.Contains(err.Error(), "consents to") { + t.Fatalf("refused for another reason: %v", err) + } }) } diff --git a/libraries/authority-core/go/authority/issue.go b/libraries/authority-core/go/authority/issue.go index 9795e3a8..f4fa979f 100644 --- a/libraries/authority-core/go/authority/issue.go +++ b/libraries/authority-core/go/authority/issue.go @@ -497,8 +497,14 @@ func (s *Service) IssueChild(ctx context.Context, parent string, actor Workload, tenant := claims.GetTenantId() var heldHere, sourceCeiling Scopes var err error - if binding, err = ceiled(tx, tenant, am, binding); err != nil { - return err + // A hop back to the module's own audience reaches nothing the parent, + // addressed to it already, did not: it is bounded by that parent and + // the owner's grants, so the consent that bounds what a module reaches + // elsewhere does not apply to it. + if binding.Audience != am.Principal { + if binding, err = ceiled(tx, tenant, am, binding); err != nil { + return err + } } bounded := false switch claims.GetOwnerPrincipalKind() { From 22b86ccbf2a54840ccd9bb4c3088bb4214002cd7 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint <312536060+antoinetoussaint-byte@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:34:50 -0400 Subject: [PATCH 3/3] fix(authority-core): a workload issue is fenced between its decision and its mint Review finding N2: IssueForWorkload decided from an installation's consent in one read and sealed in another, with no check between them, so a narrowing committed in between was sealed at the new revision while the capability carried what the withdrawn consent allowed, valid for its TTL. It now reads the revision fence in the decision (fenceIn) and refuses a mint that does not hold it (f.holds), as every other issuance path does. Tests: the reviewer's interleaving (a narrowing committed right after the decision) is refused on the workload and child paths; without the fence the workload case is handed out. The consent's ceiling is now also proven on an installation acting for itself and an issue from a delegation. Co-Authored-By: Claude Opus 5.5 --- .../go/authority/consent_fence_test.go | 146 ++++++++++++++++++ .../authority-core/go/authority/issue.go | 10 ++ 2 files changed, 156 insertions(+) create mode 100644 libraries/authority-core/go/authority/consent_fence_test.go diff --git a/libraries/authority-core/go/authority/consent_fence_test.go b/libraries/authority-core/go/authority/consent_fence_test.go new file mode 100644 index 00000000..96d2bdb3 --- /dev/null +++ b/libraries/authority-core/go/authority/consent_fence_test.go @@ -0,0 +1,146 @@ +package authority_test + +import ( + "context" + "strings" + "sync" + "testing" + + "github.com/codefly-dev/authority/libraries/authority-core/go/authority" + "github.com/codefly-dev/authority/libraries/authority-core/go/domain" + "github.com/codefly-dev/authority/libraries/authority-core/go/memstore" + basev0 "github.com/codefly-dev/core/generated/go/codefly/base/v0" +) + +// hookStore runs after once, right after the Read that finds it armed +// returns: a narrowing the host commits between the core's decision and its +// mint. +type hookStore struct { + *memstore.Store + mu sync.Mutex + after func() +} + +func (h *hookStore) Read(ctx context.Context, fn func(authority.Tx) error) error { + err := h.Store.Read(ctx, fn) + h.mu.Lock() + f := h.after + h.after = nil + h.mu.Unlock() + if f != nil { + f() + } + return err +} + +func (h *hookStore) arm(f func()) { + h.mu.Lock() + h.after = f + h.mu.Unlock() +} + +// A narrowing the host commits between an issue's decision and its mint is +// never handed out: the capability decided under the withdrawn consent is +// refused at issuance, on every path, by the revision fence. +func TestANarrowingBetweenDecisionAndMintIsNeverHandedOut(t *testing.T) { + for _, path := range []string{"workload", "child"} { + t.Run(path, func(t *testing.T) { + h := newHarness(t) + cfg := h.cfg + cfg.HostInstallsModules = true + mem := memstore.New() + store := &hookStore{Store: mem} + svc, err := authority.New(store, cfg) + if err != nil { + t.Fatal(err) + } + mem.PutOrganization(domain.Organization{ID: acme, Name: acme}) + mem.PutOrgRole(domain.OrgRole{Organization: acme, Name: "reader", Scopes: []domain.RoleScope{{Kind: legacyKind, Actions: []string{"read", "write"}}, {Kind: kind, Actions: []string{"read"}}}}) + mem.PutPerson(domain.Person{ID: "alex", Organization: acme, Roles: []string{"reader"}}) + relayScopes := []*basev0.WorkScopeV1{sc(legacyKind, "read", "write")} + for _, m := range []domain.Module{ + {Name: "relay", Principal: "relay", Kind: domain.KindModule, Generation: 1, ResourceKinds: relayScopes}, + {Name: "store", Principal: "store", Kind: domain.KindModule, Generation: 1, ResourceKinds: []*basev0.WorkScopeV1{sc(kind, "read")}, + Bindings: map[string]domain.Binding{"relay": {ID: "relay", Audience: "relay", InvokeScopes: relayScopes, HeadlessScopes: relayScopes}}}, + } { + if _, err := svc.ApplyModule(h.ctx, m); err != nil { + t.Fatal(err) + } + } + put := func(revision uint64, bindings map[string]domain.Binding) { + if err := mem.Write(h.ctx, func(tx authority.Tx) error { + return tx.PutInstallation(domain.Installation{ID: "st-1", Organization: acme, Target: "store", Status: domain.Active, Revision: revision, Bindings: bindings}) + }); err != nil { + t.Fatal(err) + } + } + put(1, map[string]domain.Binding{"relay": {ID: "relay", InvokeScopes: relayScopes, HeadlessScopes: relayScopes}}) + narrow := func() { put(2, map[string]domain.Binding{}) } + if path == "workload" { + store.arm(narrow) + _, err = svc.IssueForWorkload(h.ctx, authority.Workload{Module: "store"}, authority.IssueRequest{Binding: "relay", Organization: acme}) + } else { + parent, perr := svc.IssueForPerson(h.ctx, authority.Person{ID: "alex", Organization: acme, SessionID: "s"}, authority.IssueRequest{Audience: "store"}) + if perr != nil { + t.Fatal(perr) + } + // IssueChild reads twice: the first resolves the binding, the + // second decides; the narrowing lands after the second. + store.arm(func() { store.arm(narrow) }) + _, err = svc.IssueChild(h.ctx, parent.Token, authority.Workload{Module: "store"}, authority.IssueRequest{Binding: "relay"}) + } + if err == nil { + t.Fatal("a capability decided under the consent the host withdrew before the mint was handed out") + } + _, err = svc.IssueForWorkload(h.ctx, authority.Workload{Module: "store"}, authority.IssueRequest{Binding: "relay", Organization: acme}) + code(t, err, domain.Denied) + }) + } +} + +// consented replaces an installation's consent with bindings, as a host +// that installs by consent narrows it. +func (h *harness) consented(org, target string, bindings map[string]domain.Binding) { + h.t.Helper() + if err := h.store.Write(h.ctx, func(tx authority.Tx) error { + inst, err := tx.Installation(authority.StableInstallationID(org, target)) + if err != nil { + return err + } + inst.Bindings, inst.Revision = bindings, inst.Revision+1 + return tx.PutInstallation(inst) + }); err != nil { + h.t.Fatal(err) + } +} + +func refusedByConsent(t *testing.T, err error) { + t.Helper() + code(t, err, domain.Denied) + if !strings.Contains(err.Error(), "consents to") { + t.Fatalf("refused for another reason than the consent: %v", err) + } +} + +// The consent bounds an installation acting for itself through its +// solution's binding, and an issue from a delegation through the module's. +func TestTheConsentBoundsInstallationAndDelegationIssues(t *testing.T) { + t.Run("an installation acting for itself", func(t *testing.T) { + h := newHarness(t) + h.must(h.agent("a", "store")) + h.consented(acme, "a", map[string]domain.Binding{"legacy": {ID: "legacy", InvokeScopes: []*basev0.WorkScopeV1{sc(legacyKind, "read")}}}) + _, err := h.agent("a", "store") + refusedByConsent(t, err) + }) + t.Run("an issue from a delegation", func(t *testing.T) { + h := newHarness(t) + rob := h.person("rob") + d, err := h.svc.Delegate(h.ctx, rob, "store", []*basev0.WorkScopeV1{owned(kind, "", []string{"read"}, "store-x")}, "a job") + if err != nil { + t.Fatal(err) + } + h.consented(acme, "store", map[string]domain.Binding{}) + _, err = h.svc.IssueForDelegation(h.ctx, d.ID, authority.Workload{Module: "store"}, authority.IssueRequest{Binding: "legacy"}) + refusedByConsent(t, err) + }) +} diff --git a/libraries/authority-core/go/authority/issue.go b/libraries/authority-core/go/authority/issue.go index f4fa979f..f2ce3408 100644 --- a/libraries/authority-core/go/authority/issue.go +++ b/libraries/authority-core/go/authority/issue.go @@ -253,6 +253,7 @@ func (s *Service) IssueForWorkload(ctx context.Context, w Workload, r IssueReque var module, audience domain.Module var through, scopes Scopes var org, instID string + var f fence err := s.store.Read(ctx, func(tx Tx) error { var err error if module, err = tx.ModuleNamed(w.Module); err != nil { @@ -316,6 +317,12 @@ func (s *Service) IssueForWorkload(ctx context.Context, w Workload, r IssueReque return domain.NotNow("%s is not installed at %s", module.Name, org) } instID = inst.ID + // What the decision read: the installation's revision moves with + // the consent its bindings came from, so a capability minted after a + // narrowing committed is refused rather than handed out. + if f, err = s.src.fenceIn(tx, org, module.Principal, inst.ID); err != nil { + return err + } return decide.OwnerAware(audience, scopes) }) if err != nil { @@ -329,6 +336,9 @@ func (s *Service) IssueForWorkload(ctx context.Context, w Workload, r IssueReque if err != nil { return Issued{}, err } + if err := f.holds(signed); err != nil { + return Issued{}, err + } if signed.GetSeal().GetPrincipalEpoch() != module.Generation || signed.GetOperationBinding().GetRevision() != module.Generation { return Issued{}, domain.Refuse("workload binding changed during issuance") }