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/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..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) } @@ -183,3 +191,113 @@ 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 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{}) + 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: "relay"}) + code(t, err, domain.Denied) + if !strings.Contains(err.Error(), "consents to") { + t.Fatalf("refused for another reason: %v", err) + } + }) +} + +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..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 { @@ -299,6 +300,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) } @@ -313,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 { @@ -326,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") } @@ -385,6 +398,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 +506,16 @@ 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 + // 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() { case "human": @@ -520,7 +546,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.